Files
arcrun-collector/quota.go
T
Leo a6ced32d45 collector:積壓分批+新檔優先/額度用完講人話降速/斷點續傳/同內容多格式去重
封測事故(Evan):daemon 逐檔萃取上傳、每個檔在雲端產生一次工作流執行,
690 個檔在自己的免費 CF 帳號上短時間內衝出 1,070 次寫入,撞上免費上限 1,000,
額度爆掉、只有 8 個檔成功。雲端那一半(紀錄改走資料層 API)已修好,
daemon 這一半原本完全沒有節奏——本次補齊四件事:

1. 上傳節奏(direct_pacing.go):單輪最多處理 MaxEventsPerRun 個事件
   (預設 25)、每次觸發雲端前節流 700ms;一輪掃到的事件依檔案 mtime
   由新到舊排序,今天寫的永遠優先,積壓慢慢消化不擋日常使用。

2. 額度用完講人話(quota.go):偵測到 Workers AI「10,000 neurons」/
   「4006」等已知上游訊號後,換成三句話(今天已整理幾份/可換模型或
   升級 Cloudflare/不花錢也沒關係、今天或明天早上 8:00 會自動恢復),
   不出現裸露的錯誤碼;同帳號同輪與下一輪都不再繼續撞牆
   (quotaState 全域冷卻,跨資料夾/跨程序重啟持續,直到台灣時間
   早上 8:00 額度重置)。

3. 斷點續傳:每個事件處理完立刻寫回 manifest(不再等整輪跑完才存一次),
   process 被殺掉重開只會接著做真正還沒完成的部分;removed 事件另外
   用 preScanEntries 快照保護,下架失敗時不會被其他事件的存檔動作
   誤標成「已完成」而永遠不再重試。

4. 同內容多格式去重(scan.go):同一批來源轉出的多種格式(如 leo 給的
   資料集 27,164 檔=9,045 md+9,044 json+9,043 html,md/json 同檔名
   主幹)依檔名主幹分組,只留優先序最高的一份進事件管線,其餘標記在
   DuplicateFormats(不吃三倍額度)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:58:07 +08:00

121 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// quota.go — Workers AI 每日免費額度用完時的人話訊息與全域冷卻(2026-08-07 pacing task)。
//
// 背景(封測事故):Evan 在自己的免費 Cloudflare 帳號上跑,690 個檔逐檔萃取上傳,
// 每個檔在雲端產生一次工作流執行 ⇒ 1,070 次寫入撞上免費上限 1,000,額度爆掉、整批 429/502
// 只有 8 個檔成功。雲端那一半(紀錄改走資料層 API)已修好;本檔補的是 daemon 這一半:
// 額度用完時要「說人話+降速」,不是安靜地重試到死或吐一串技術錯誤碼。
//
// 已知的上游錯誤模式(system-dev/wiki/mistakes.md 2026-08-06):
//
// collector.log 出現 `4006: you have used up your daily free allocation of 10,000 neurons`
// HTTP 502,額度**每日 UTC 午夜重置**(=台灣時間早上 8:00)。
//
// leo 定的三句話骨架(缺一不可,且不准出現裸露的錯誤碼):
//
// 成就:「今天已經幫你整理了 N 份」← 先講做到什麼,不是先講失敗
// 出口:「可以換一個模型,或升級 Cloudflare(每月 5 美元)」← 給選擇不是死路
// 保證:「不花錢也沒關係,明天/今天早上 8 點會自動恢復、會接著跑」
package collector
import (
"fmt"
"strings"
"time"
)
// taiwanTZ:固定 UTC+8 偏移,不吃系統 tzdata(跨平台/Windows 常缺時區資料庫;
// 台灣本身沒有日光節約時間,固定偏移在任何時候都正確——不必依賴 time.LoadLocation)。
var taiwanTZ = time.FixedZone("Asia/Taipei", 8*60*60)
// isQuotaExhausted 辨識「Workers AI 每日免費額度用完」這個已知上游狀況(非 bug)。
// 用子字串比對而非單看 HTTP 狀態碼——502 本身太泛用(也可能是別的暫時性錯誤),
// 這段特定文案才是可靠訊號(見 wiki mistakes.md 2026-08-06 記錄的原文)。
func isQuotaExhausted(errMsg string) bool {
return strings.Contains(errMsg, "10,000 neurons") ||
strings.Contains(errMsg, "daily free allocation") ||
strings.Contains(errMsg, "4006:")
}
// nextQuotaResetTaiwan 回傳下一次 Workers AI 每日額度重置的時間點。
// Cloudflare 在 UTC 午夜重置 ⇒ 下一次 UTC 00:00 就是答案(換算成台灣時間固定是早上 8:00)。
func nextQuotaResetTaiwan(now time.Time) time.Time {
u := now.UTC()
nextMidnightUTC := time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, 1)
return nextMidnightUTC.In(taiwanTZ)
}
// QuotaNotice 是額度用完時要給使用者看的三句話(leo 定的骨架,缺一不可)。
// 三句合起來就是完整訊息,任何管道要顯示都直接串接,不再另外組字串(避免各處措辭漂移)。
type QuotaNotice struct {
Achievement string `json:"achievement"` // 今天已經幫你整理了 N 份
ExitOptions string `json:"exit_options"` // 可以換一個模型,或升級 Cloudflare(每月 5 美元)
Guarantee string `json:"guarantee"` // 不花錢也沒關係,今天/明天早上 8:00 會自動恢復
ResumeAt string `json:"resume_at"` // RFC3339,預期恢復時間(供機器判斷冷卻是否結束)
}
// Combined 把三句話接成一句完整訊息(給只有單一 error 欄位可用的地方,如 DirectResult.Error)。
// 用句號分隔——三句話都要出現,缺一不可,且全程不含任何原始錯誤碼。
func (n QuotaNotice) Combined() string {
return n.Achievement + "。" + n.ExitOptions + "。" + n.Guarantee
}
// buildQuotaNotice 組出三句話。dailyCount=今天(UTC 日界,與額度重置同一條線)已成功
// 萃取的份數;resetAtnextQuotaResetTaiwan 算出的下一次重置時間。
func buildQuotaNotice(now time.Time, dailyCount int, resetAt time.Time) QuotaNotice {
return QuotaNotice{
Achievement: fmt.Sprintf("今天已經幫你整理了 %d 份", dailyCount),
ExitOptions: "可以換一個模型,或升級 Cloudflare(每月 5 美元)",
Guarantee: quotaGuaranteeText(now, resetAt),
ResumeAt: resetAt.Format(time.RFC3339),
}
}
// quotaGuaranteeText 把重置時間換成人話:「今天」或「明天」早上 8:00
// (不能寫死「明天」——若這一刻台灣時間已經過了午夜、還沒到 8 點,重置其實是「今天」)。
func quotaGuaranteeText(now, resetAt time.Time) string {
nowTW := now.In(taiwanTZ)
resetTW := resetAt.In(taiwanTZ)
dayWord := "明天"
if nowTW.Year() == resetTW.Year() && nowTW.YearDay() == resetTW.YearDay() {
dayWord = "今天"
}
return fmt.Sprintf("不花錢也沒關係,%s早上 8:00 會自動恢復、會接著跑", dayWord)
}
// quotaState 是「這個帳號本輪的額度冷卻」共享狀態,跨同一帳號的多個監看根
// (額度是雲端實例/Cloudflare 帳號層級的,不是單一資料夾的——一個根撞到,
// 同帳號其他根不該還傻傻地繼續撞同一面牆)。由 RunDirectOnce 建立、以指標
// 傳進每個 runDirectOnceRoot 呼叫,狀態在呼叫之間累積。
type quotaState struct {
CooldownUntil time.Time // 非零值且晚於 now ⇒ 本帳號本輪不再嘗試萃取
Hit bool // 本輪是否新偵測到額度用完(避免同一輪反覆覆寫 CooldownUntil
RawReason string // 上游原文,只供 log/除錯,不進任何使用者看得到的欄位
DailyCount int // 今天(UTC 日界)已成功萃取的份數,seed 自 status.json 並持續累加
}
// inCooldown 回報現在是否仍在額度冷卻窗口內。
func (qs *quotaState) inCooldown(now time.Time) bool {
return !qs.CooldownUntil.IsZero() && now.Before(qs.CooldownUntil)
}
// noticeNow 用目前狀態組一份 QuotaNotice。
func (qs *quotaState) noticeNow(now time.Time) QuotaNotice {
return buildQuotaNotice(now, qs.DailyCount, qs.CooldownUntil)
}
// markHit 記錄「這一刻偵測到額度用完」,只在本輪第一次命中時真正設定冷卻時間
// (之後同一輪的其他失敗不再往後推遲 CooldownUntil,避免因為連續撞牆而不斷延後恢復承諾)。
func (qs *quotaState) markHit(now time.Time, rawReason string) {
if qs.Hit {
return
}
qs.Hit = true
qs.RawReason = rawReason
qs.CooldownUntil = nextQuotaResetTaiwan(now)
}
// todayUTC 回傳 YYYY-MM-DDUTC),與 Workers AI 額度重置同一條日界線。
func todayUTC(now time.Time) string {
return now.UTC().Format("2006-01-02")
}