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
+107
View File
@@ -114,6 +114,99 @@ type TriggerPayload struct {
// 可能存成了別的格式)。只報總數在「幾百張圖」時是對的,在「1 個」時等於沒說。
// ⇒ 少量時就點名,讓使用者自己一眼看出「喔,我存錯格式了」。
SkippedOtherNames []string `json:"-"`
// DuplicateFormats=本輪偵測到、同檔名主幹的多格式重複(2026-08-07,見 FormatDuplicate)。
// 同 Skipped:只給本機使用者看,不隨 payload 送雲端(schema additionalProperties:false 會擋)。
DuplicateFormats []FormatDuplicate `json:"-"`
}
// FormatDuplicate=同一份內容被偵測到有多種格式並存(同檔名主幹、不同副檔名)。
//
// 🔴 為什麼要有這個(2026-08-07,leo 實據):封測者 Evan 給的資料集
// 27,164 檔=9,045 個 md9,044 個 json9,043 個 html,是同一批來源轉出的三種格式
// `markdown/160-00F3_001.md` 與 `json/160-00F3_001.json` 逐一對應,同錯誤碼、同變體號)。
// 現行設計不知道這件事,會把三種格式各當一份新檔,萃三次、吃三倍額度——
// 使用者不會知道要挑一種,這是我們該擋的,不是他該懂的。
//
// 判準:檔名主幹(去副檔名、轉小寫)在整個看守根內相同 → 視為同一份內容的不同格式匯出,
// 只留優先序最高的一份進事件管線,其餘標記在這裡(不產生 added/modified/renamed 事件)。
//
// ⚠️ 已知的取捨:純靠檔名主幹比對,不比對內容——兩個不相干的檔恰好同名不同副檔名
// (如兩個專案各自的 `README.md``README.pdf`)會被誤判成同一份。刻意接受這個風險,
// 因為:①這不是靜默丟棄——DuplicateFormats 會被 direct.go 收進 status.json 讓使用者看到
// 「跳過:與 X 視為同一格式」,看起來不對可以改檔名破解誤判;②不比對,就是 leo 實據的
// md/json 案例(不同目錄、不同副檔名,唯一共同點正是檔名主幹)根本擋不掉。
//
// 🔴 刻意不寫進 manifest(同 Skipped 的理由,見上方 SkippedFile 註解):每輪由檔案系統
// 重算,勝出者若之後消失,下一輪換另一份自然遞補,不留跨輪狀態要維護。
type FormatDuplicate struct {
Path string `json:"path"` // 被跳過的那份
KeptPath string `json:"kept_path"` // 真正進事件管線的那份
Stem string `json:"stem"` // 判定依據:去副檔名、轉小寫後的檔名主幹
}
// dedupFormatPriority:格式去重時「留誰」的優先序(越前面越優先留下)。
// 原則:越接近「使用者原始編輯」的格式排越前面(docx/pptx 是可編輯原稿),
// md/markdown 常是**從別的格式轉出的產物**(leo 實據的資料集正是 html→chm 轉出 md/json),
// 排在轉檔產物之前但在原生辦公格式之後。不在表內的副檔名排最後(理論上不會發生,
// current 只收 allowedExt)。
var dedupFormatPriority = []string{".docx", ".pptx", ".xlsx", ".csv", ".pdf", ".md", ".markdown", ".txt"}
func formatPriority(relPath string) int {
ext := strings.ToLower(filepath.Ext(relPath))
for i, e := range dedupFormatPriority {
if e == ext {
return i
}
}
return len(dedupFormatPriority)
}
// dedupStemOf 回傳去重判準:檔名主幹(basename 去副檔名),轉小寫(跨平台大小寫不敏感)。
// 刻意只看 basename、不看目錄——leo 實據的 md/json 恰好活在不同的兄弟目錄
// markdown/160-00F3_001.md vs json/160-00F3_001.json),只有 basename 主幹相同。
func dedupStemOf(relPath string) string {
base := filepath.Base(relPath)
stem := strings.TrimSuffix(base, filepath.Ext(base))
return strings.ToLower(stem)
}
// detectFormatDuplicates 在 current(本輪掃到、通過 allowedExt 的檔)裡找出同檔名主幹的分組,
// 每組留優先序最高的一份,其餘回報為 loserpath -> winner path)。
// 走訪用 stem 字母序=確定性輸出(map 迭代順序不穩定)。
func detectFormatDuplicates(current map[string]fileState) (map[string]string, []FormatDuplicate) {
byStem := map[string][]string{}
for p := range current {
stem := dedupStemOf(p)
byStem[stem] = append(byStem[stem], p)
}
stems := make([]string, 0, len(byStem))
for s := range byStem {
stems = append(stems, s)
}
sort.Strings(stems)
loserOf := map[string]string{}
var dups []FormatDuplicate
for _, stem := range stems {
group := byStem[stem]
if len(group) < 2 {
continue
}
sort.Slice(group, func(i, j int) bool {
pi, pj := formatPriority(group[i]), formatPriority(group[j])
if pi != pj {
return pi < pj
}
return group[i] < group[j] // 同優先序時字母序,確定性
})
winner := group[0]
for _, loser := range group[1:] {
loserOf[loser] = winner
dups = append(dups, FormatDuplicate{Path: loser, KeptPath: winner, Stem: stem})
}
}
return loserOf, dups
}
type ScanOptions struct {
@@ -239,9 +332,16 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
return nil, err
}
// 1.5) 同內容多格式去重(2026-08-07):先決定誰是 loser,事件管線全程跳過它們。
dupLoser, duplicateFormats := detectFormatDuplicates(current)
// 2) 初分:added 候選(現況有、manifest 無)與 removed 候選(manifest 有、現況無)。
// loser 不進候選——它不該被當成新檔,也不該被當成 rename 的另一端。
var addedPaths, removedPaths []string
for p := range current {
if dupLoser[p] != "" {
continue
}
if _, ok := orig[p]; !ok {
addedPaths = append(addedPaths, p)
}
@@ -289,6 +389,9 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
return Event{Type: "added", Path: p, SourceHash: st.hash, Size: &size, R2Key: r2KeyOf(st.hash)}
}
for _, p := range sortedCurrent {
if dupLoser[p] != "" { // 同內容的另一格式已在事件管線,這份跳過(1.5)
continue
}
if op, isRenamed := renamedOldOf[p]; isRenamed {
if orig[op].IngestedHash == "" { // 改名的檔其實從未 ingest 成功 → 補一發 added
events = append(events, addedEvent(p))
@@ -304,6 +407,9 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
// 5) modifiedmanifest 有、現況有、content_hash != ingested_hashdesign §3 順序 3)。
for _, p := range sortedCurrent {
if dupLoser[p] != "" { // 同內容的另一格式已在事件管線,這份跳過(1.5)
continue
}
e, existed := orig[p]
if !existed {
continue
@@ -392,5 +498,6 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
Skipped: skipped,
SkippedOther: skippedOther,
SkippedOtherNames: skippedOtherNames,
DuplicateFormats: duplicateFormats,
}, nil
}