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>
This commit is contained in:
2026-08-07 16:58:07 +08:00
parent 8ad6f25ba3
commit a6ced32d45
9 changed files with 1291 additions and 7 deletions
+156 -7
View File
@@ -83,6 +83,10 @@ type DirectConfig struct {
CardIngestWF string `json:"card_ingest_workflow,omitempty"` // 收卡 workflow(空=rag_ingest_card
PollSec int `json:"poll_interval_sec"` // 輪詢間隔秒(空/05
MaxRemoved float64 `json:"max_removed_ratio"` // 大量刪除防呆門檻(空/0=0.4)
// MaxEventsPerRun2026-08-07 pacing task):單輪最多處理幾個 added/modified/renamed/
// removed 事件,空/0DefaultMaxEventsPerRun。存在理由:巨量積壓(實據 27,164 檔)
// 不該一輪湧完——搭配節流間隔+新檔優先排序,讓積壓慢慢消化,不擋今天剛寫的新檔。
MaxEventsPerRun int `json:"max_events_per_run,omitempty"`
// ForceSync=這一輪是使用者按「立刻同步」觸發的(t195)。
// 為真時忽略失敗退避與次數上限,一律重送——**人明確要求時不該被機器的退避擋住**。
@@ -482,6 +486,15 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
results := []DirectResult{}
exit := 0
var lastPayload *TriggerPayload
now := time.Now() // 2026-08-07 pacing task:整輪共用同一個時間點(排序/冷卻判斷一致、好測試)
// 2026-08-07:提早載入上一輪 status(原本只在函式尾端載入做 CarryForwardActivity)。
// 額度冷卻與「今天已萃幾份」是**跨輪持續的狀態**quotaState 見 quota.go),
// 要在處理帳號之前就知道上一輪冷卻到什麼時候、今天已經算到幾份。
var prevStatus SyncStatus
if cfg.Manifest != "" {
prevStatus, _ = LoadSyncStatus(StatusFilePath(cfg.Manifest)) // 讀不到=零值,等同「沒有上一輪」
}
// t92-②:預檢 extractor(機器層級),有 fallback 路徑時就地更新 cfg.ClaudeBin。
extractorOK := true
@@ -597,9 +610,24 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
}
}
// 2026-08-07:從上一輪 status 復原這個帳號的額度冷卻/今天已萃份數——
// 這兩件事是**帳號層級、跨輪持續**的狀態,不是單一資料夾的(額度是雲端
// 實例/Cloudflare 帳號共用的,一個根撞到,同帳號其他根不該還繼續撞牆)。
qs := &quotaState{}
if prevAcc, ok := prevStatus.AccountDetails[accHost]; ok {
if prevAcc.DailyIngestedDate == todayUTC(now) {
qs.DailyCount = prevAcc.DailyIngestedCount
}
if prevAcc.QuotaCooldownUntil != "" {
if t, perr := time.Parse(time.RFC3339, prevAcc.QuotaCooldownUntil); perr == nil {
qs.CooldownUntil = t
}
}
}
multi := len(accCfg.Folders()) > 1
for _, root := range accCfg.Folders() {
r, e, p := runDirectOnceRoot(accCfg, root, dryRun)
r, e, p := runDirectOnceRoot(accCfg, root, dryRun, qs, now)
if multi {
for i := range r {
r[i].Root = root
@@ -633,6 +661,23 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
skippedOtherNames = append(skippedOtherNames, p.SkippedOtherNames...)
}
}
// 2026-08-07:把這輪(可能剛更新過的)額度冷卻/今天已萃份數寫回,
// 供下一輪 RunDirectOnce(甚至下一次程序啟動——status.json 落地磁碟)復原。
accSt.DailyIngestedDate = todayUTC(now)
accSt.DailyIngestedCount = qs.DailyCount
if qs.inCooldown(now) {
accSt.QuotaCooldownUntil = qs.CooldownUntil.Format(time.RFC3339)
notice := qs.noticeNow(now)
accSt.QuotaMessage = &notice
// 冷卻中一定代表萃取沒就緒——浮到頂層讓托盤「狀態:」直接看得到,
// 不必展開帳號才發現「為什麼今天都沒有動靜」。
extractorOK = false
extractorError = notice.Combined()
}
// 冷卻已過且本輪沒有新命中 ⇒ QuotaCooldownUntil/QuotaMessage 維持零值,
// 自然清除舊訊息(accSt 每輪重建,不會殘留上一輪的冷卻通知)。
accountDetails[accHost] = accSt
}
@@ -690,7 +735,10 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
// 於是 status.failures 是空的 ⇒ 畫面只剩「⚠ N 份失敗」沒有原因。
// 退避訊息裡已經帶了上游真因(retrySkipReason),一併收進來,
// 使用者才看得到「為什麼」而不只是「幾份」。
if strings.Contains(r.Error, "後重試") || strings.Contains(r.Error, "已暫停自動重試") {
// 2026-08-07:額度冷卻的 skip 訊息(quotaState.noticeNow().Combined()
// 用「會自動恢復」當識別字——同樣要讓使用者看得到原因,不是只看到「幾份」。
if strings.Contains(r.Error, "後重試") || strings.Contains(r.Error, "已暫停自動重試") ||
strings.Contains(r.Error, "會自動恢復") {
st.Failures = append(st.Failures, ExtractFail{
Path: r.Path,
Error: shortError(r.Error),
@@ -699,6 +747,12 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
}
}
}
// 2026-08-07:巨量積壓場景(實據 27,164 檔)下 Failures 可能暴增到數千筆,
// status.json 不該被撐成幾 MB 的清單——裁到跟 SkippedDocs 一樣的上限,
// 總數仍在 ExtractFailedUI 可以說「…等 N 個」(同 MaxSkippedListed 的做法)。
if len(st.Failures) > MaxSkippedListed {
st.Failures = st.Failures[:MaxSkippedListed]
}
// 單帳號時把 cloud version 也填頂層(向後相容)
if len(accountDetails) == 1 {
for _, v := range accountDetails {
@@ -711,8 +765,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
// 本輪有產出就記成「最近一次有做事」;本輪沒事做則把上一輪的原樣帶下來,
// 不要讓「剛整理完 N 份」這個唯一的完成證據在 15 秒後被歸零抹掉。
statusPath := StatusFilePath(cfg.Manifest)
prev, _ := LoadSyncStatus(statusPath) // 讀不到=零值,等同「沒有上一輪」
CarryForwardActivity(prev, &st)
CarryForwardActivity(prevStatus, &st) // 2026-08-07:沿用函式開頭已載入的 prevStatus,不重讀一次
if serr := SaveSyncStatus(statusPath, st); serr != nil {
fmt.Fprintf(os.Stderr, "status 寫入失敗(不擋看守):%v\n", serr)
}
@@ -810,7 +863,9 @@ func accountsConnected(cfg *DirectConfig) bool {
return false
}
func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectResult, int, *TriggerPayload) {
// qs:這個帳號本輪共用的額度冷卻狀態(跨同帳號的多個監看根,見 quota.go)。
// runNow:整輪 RunDirectOnce 共用的時間點(排序/冷卻判斷一致、好測試)。
func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaState, runNow time.Time) ([]DirectResult, int, *TriggerPayload) {
results := []DirectResult{}
exit := 0
@@ -827,6 +882,16 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes
if err != nil {
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil
}
// 2026-08-07 task 3Scan() 會把 removed 的路徑從 m.Entries 整批拿掉(rebuild 語意,
// 見 scan.go 步驟 7)——但那只是「偵測到不見了」,不代表下架 POST 已經成功。
// 沒有這份快照的話,本輪只要有任何一個 added/modified 事件先觸發了下面的
// incremental saveManifest(),就會把「還沒確認下架成功」的路徑一併存進磁碟,
// 下一輪 Scan() 兩邊都找不到它 ⇒ 永遠不會再補發 removed 事件、下架永遠不會重試。
// 先存一份,Scan() 後把這些路徑「暫時放回去」,直到對應的 removed 事件真的成功。
preScanEntries := make(map[string]*ManifestEntry, len(m.Entries))
for k, v := range m.Entries {
preScanEntries[k] = v
}
payload, err := Scan(absRoot, m, ScanOptions{
MaxRemovedRatio: cfg.MaxRemoved,
SkipPaths: map[string]bool{
@@ -841,13 +906,65 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil
}
now := time.Now().Unix()
now := runNow.Unix()
// 2026-08-07 task 3(斷點續傳):每個事件處理完就立刻存檔,不要等整輪跑完。
// 舊行為=整個 for 迴圈跑完才 Save 一次——process 在跑到一半被殺掉(重開機、
// 換版、當機)時,**已經成功的那些也會遺失**,下次重開等於從頭來過,
// 且已經花掉的額度/請求全部白費(正是 leo 要求「不從頭來」要防的事)。
// 改成每個事件收工就存一次:kill 在任何一刻,磁碟上的 manifest 都反映
// 「這一刻之前已確定成功的事」,下一輪只會處理真正還沒做完的。
saveManifest := func() {
if dryRun {
return
}
if serr := m.Save(absManifest); serr != nil {
results = append(results, DirectResult{Status: "failed", Error: "manifest 存檔失敗(斷點續傳可能失效):" + serr.Error()})
exit = 1
}
}
// 承上:把本輪偵測到的 removed 路徑暫時放回 m.Entries,直到迴圈裡真的處理到它、
// POST 成功才由「removed」分支明確刪除。失敗或本輪還沒輪到(單輪上限)都維持放回的狀態,
// 下一輪自然重新偵測、重新嘗試下架——不會因為別的事件先存檔而被誤永久跳過。
for _, ev := range payload.Events {
if ev.Type == "removed" {
if e, ok := preScanEntries[ev.Path]; ok {
m.Entries[ev.Path] = e
}
}
}
// 2026-08-07 pacing task 1:新改的檔優先+單輪上限。
// - 排序:把 payload.Events 依檔案 mtime 由新到舊重排(不動送雲端的 payload 本身,
// 只重排這裡的本機處理佇列——見 sortEventsNewestFirst 註解)。
// - 上限:巨量積壓(實據 27,164 檔)不該一輪湧完;超過上限的事件本輪不碰,
// manifest 未標 ingested ⇒ 下一輪 Scan() 自然重新出現(且若使用者這期間
// 寫了新檔,新檔的 mtime 更新,下一輪排序會插到最前面,不會被積壓卡住)。
orderedEvents := sortEventsNewestFirst(absRoot, payload.Events)
perRunCap := cfg.effectiveMaxEventsPerRun()
deferredCount := 0
if len(orderedEvents) > perRunCap {
deferredCount = len(orderedEvents) - perRunCap
orderedEvents = orderedEvents[:perRunCap]
}
for _, ev := range orderedEvents {
switch ev.Type {
case "added", "modified", "renamed":
// renamed 在 direct 模式視同 added:內容未變但為求 kbdb 有這頁名的卡,重送一次萃取
//(頁名可能改變=要新頁名的卡)。冪等由 kbdb 端承擔(同頁名覆蓋語意)。
res := DirectResult{Type: ev.Type, Path: ev.Path}
// 2026-08-07 pacing task 2:帳號還在額度冷卻中 → 這輪連試都不試。
// 這不是這個檔的問題(不記 FailCount/退避——那是「這個檔」的病歷,
// 額度用完是「整個帳號」的狀態,混在一起會讓退避階梯失真)。
// 放在 ShouldRetry 之前:冷卻是更高層級的條件,沒必要先算退避訊息又蓋掉。
if qs.inCooldown(runNow) {
res.Status = "skipped"
res.Error = qs.noticeNow(runNow).Combined()
results = append(results, res)
continue
}
// 🔴 t195 止血點:這個檔剛失敗過且還在退避窗口內 → 這輪跳過。
// 沒有這道閘時的實測災情:`小果被AFTEE詐貸.pdf` 因雲端 401 失敗,
// 每輪重掃又被當成新檔 ⇒ **1387 輪、跨 11 小時**,且它排在佇列前面,
@@ -866,6 +983,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes
if rerr != nil {
res.Status, res.Error = "failed", "讀檔失敗:"+rerr.Error()
m.MarkFailed(ev.Path, now, res.Error) // t195:讀不到的檔也退避(權限/被鎖/壞掉的外接碟)
saveManifest()
results = append(results, res)
exit = 1
continue
@@ -875,6 +993,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes
results = append(results, res)
continue
}
pace() // 2026-08-07:每次要觸發雲端(萃取/POST)之前先節流一下
if cfg.Extractor != "" {
// 四步定稿:本地萃卡 → 每張卡 POST rag_ingest_card(原文不出機)
var cards []string
@@ -898,10 +1017,21 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes
xerr = fmt.Errorf("不支援的萃取方式 %q(支援:workers-aigemma", cfg.Extractor)
}
if xerr != nil {
res.Status, res.Error = "failed", "本地萃取失敗:"+xerr.Error()
// 2026-08-07 task 2Workers AI 每日免費額度用完是**已知的上游狀況**
// wiki mistakes.md 2026-08-06),不是 bug——不能讓使用者看到裸露的
// 「4006」「HTTP 502」,要換成三句話(成就/出口/保證),且不能再
// 每輪繼續撞同一面牆(qs.markHit 設定帳號層級的冷卻,下一個事件、
// 下一輪都會被上面的 qs.inCooldown 擋下,不再嘗試萃取)。
if isQuotaExhausted(xerr.Error()) {
qs.markHit(runNow, xerr.Error())
res.Status, res.Error = "failed", qs.noticeNow(runNow).Combined()
} else {
res.Status, res.Error = "failed", "本地萃取失敗:"+xerr.Error()
}
// t195:萃取階段失敗同樣要記退避。**這條路徑比上傳更早**,
// 漏記的話(連不上知識庫、金鑰壞、模型錯)照樣每輪重撞。
m.MarkFailed(ev.Path, now, res.Error)
saveManifest()
results = append(results, res)
exit = 1
continue
@@ -934,12 +1064,14 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes
res.Status = "ingested"
// 記下是誰萃的(t73/leo 07-27):換萃取器時才分辨得出哪些卡是舊的。
m.MarkIngestedBy(ev.Path, ev.SourceHash, now, cfg.Extractor)
qs.DailyCount++ // 2026-08-07:今天的成就數(額度訊息「今天已經幫你整理了 N 份」用)
} else {
// t195:記下失敗並排定退避,否則下輪又把它當新檔重試
//(實撞:1387 輪 × 11 小時全在撞同一面 401 的牆,還拖住整個佇列)。
m.MarkFailed(ev.Path, now, res.Error)
exit = 1
}
saveManifest()
results = append(results, res)
continue
}
@@ -965,7 +1097,9 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes
} else {
res.Status = "ingested"
m.MarkIngested(ev.Path, ev.SourceHash, now) // 2xx 才回寫(下輪不重送)
qs.DailyCount++
}
saveManifest()
results = append(results, res)
case "removed":
@@ -975,6 +1109,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes
results = append(results, res)
continue
}
pace() // 2026-08-07:下架一樣是觸發雲端 workflow,同樣節流
// 下架=POST {page_name, path} 進 rag_takedown_direct(按 page_name 讀 kbdb blocks
// 標 deprecated,不碰 R2;獨立於 rag_ingest 的 __CARDS_PREFIX__ 閘——direct 模式檔在
// 資料夾根,會被 rag_ingest 的前綴閘擋掉,故自帶不含前綴閘的下架 workflow)。
@@ -986,8 +1121,14 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes
if perr != nil {
res.Status, res.Error = "failed", perr.Error()
exit = 1
// 2026-08-07:下架失敗——保持上面「暫時放回」的狀態,不刪、不存檔。
// 下一輪 Scan() 會重新偵測到這個檔仍然不見了,自然重新補發 removed 事件。
} else {
res.Status = "removed"
// 2026-08-07 task 3:下架真的成功了,這時才正式從 manifest 拿掉並存檔
// (不是 Scan() rebuild 時就拿掉——那時只是「偵測到不見了」,不是「已下架」)。
delete(m.Entries, ev.Path)
saveManifest()
// t15extractor 模式雲端下架成功後,同步清掉本地萃出的卡
//system-dev/wiki/cards/<頁名>.md),保持本地 wiki 與雲端一致。
// 存在才刪;刪失敗只記 warning 不擋(下架本體已成功)。
@@ -1012,7 +1153,15 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes
results = append(results, DirectResult{Type: "warning", Status: "skipped", Error: w.Code + ": " + w.Message})
}
// 2026-08-07 pacing task 1:本輪因單輪上限被延後的事件——不是失敗,是刻意排隊,
// 不佔用 exit(不該讓「積壓還很多」看起來像出錯了)。
if deferredCount > 0 {
results = append(results, DirectResult{Type: "info", Status: "skipped", Error: resumeAfterCapMessage(deferredCount)})
}
if !dryRun {
// 2026-08-07:每個事件處理完都已個別呼叫 m.Save(見下方迴圈內),這裡是最後的
// 安全網——涵蓋防呆警告輪等不對應單一事件的狀態變化,多存一次無害(原子寫入)。
if err := m.Save(absManifest); err != nil {
results = append(results, DirectResult{Status: "failed", Error: "manifest 存檔失敗:" + err.Error()})
exit = 1