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:
@@ -83,6 +83,10 @@ type DirectConfig struct {
|
||||
CardIngestWF string `json:"card_ingest_workflow,omitempty"` // 收卡 workflow(空=rag_ingest_card)
|
||||
PollSec int `json:"poll_interval_sec"` // 輪詢間隔秒(空/0=5)
|
||||
MaxRemoved float64 `json:"max_removed_ratio"` // 大量刪除防呆門檻(空/0=0.4)
|
||||
// MaxEventsPerRun(2026-08-07 pacing task):單輪最多處理幾個 added/modified/renamed/
|
||||
// removed 事件,空/0=DefaultMaxEventsPerRun。存在理由:巨量積壓(實據 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 := "aState{}
|
||||
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 = ¬ice
|
||||
// 冷卻中一定代表萃取沒就緒——浮到頂層讓托盤「狀態:」直接看得到,
|
||||
// 不必展開帳號才發現「為什麼今天都沒有動靜」。
|
||||
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 一樣的上限,
|
||||
// 總數仍在 ExtractFailed,UI 可以說「…等 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 3:Scan() 會把 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-ai/gemma)", cfg.Extractor)
|
||||
}
|
||||
if xerr != nil {
|
||||
res.Status, res.Error = "failed", "本地萃取失敗:"+xerr.Error()
|
||||
// 2026-08-07 task 2:Workers 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()
|
||||
// t15:extractor 模式雲端下架成功後,同步清掉本地萃出的卡
|
||||
//(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
|
||||
|
||||
Reference in New Issue
Block a user