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
+141
View File
@@ -0,0 +1,141 @@
// scan_dedup_test.go — 同內容多格式去重(2026-08-07,見 scan.go FormatDuplicate 註解)。
//
// 實據:leo 給的封測資料集 27,164 檔=9,045 md9,044 json9,043 html
// 是同一批來源轉出的三種格式,md/json 逐一同檔名主幹(不同目錄)。
// 驗收:「同內容三種格式並存 → 只產一張卡」。
package collector
import (
"os"
"path/filepath"
"testing"
)
// 三種格式、同檔名主幹、活在兄弟目錄(照實據的 markdown/ vs json/ 結構)
// → 只有一份(依優先序 docx > pptx > xlsx > csv > pdf > md > markdown > txt)進事件管線。
func TestScan_FormatDuplicate_OnlyOneEventPerStem(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "markdown/160-00F3_001.md", "# 錯誤說明\nM118/M128 不可同時使用", baseTime)
writeFile(t, root, "csv/160-00F3_001.csv", "code,msg\n160-00F3,M118/M128 不可同時使用", baseTime)
writeFile(t, root, "doc/160-00F3_001.docx", "docx 二進位占位", baseTime)
m := newTestManifest()
payload, err := Scan(root, m, ScanOptions{})
if err != nil {
t.Fatal(err)
}
var added []Event
for _, ev := range payload.Events {
if ev.Type == "added" {
added = append(added, ev)
}
}
if len(added) != 1 {
t.Fatalf("三種格式應只產生 1 個 added 事件,got %d: %+v", len(added), added)
}
if added[0].Path != "doc/160-00F3_001.docx" {
t.Fatalf("優先序應留 docxgot %q", added[0].Path)
}
if len(payload.DuplicateFormats) != 2 {
t.Fatalf("應回報 2 份被跳過的重複格式,got %d: %+v", len(payload.DuplicateFormats), payload.DuplicateFormats)
}
for _, d := range payload.DuplicateFormats {
if d.KeptPath != "doc/160-00F3_001.docx" {
t.Errorf("KeptPath=%q,應指向留下的那份", d.KeptPath)
}
if d.Stem != "160-00f3_001" {
t.Errorf("Stem=%q", d.Stem)
}
}
}
// 不同檔名主幹的檔案(真正不相干的內容)不受影響——去重不能誤傷正常檔案。
func TestScan_FormatDuplicate_DifferentStemsUnaffected(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "a.md", "內容 A", baseTime)
writeFile(t, root, "b.pdf", "內容 B", baseTime)
m := newTestManifest()
payload, err := Scan(root, m, ScanOptions{})
if err != nil {
t.Fatal(err)
}
added := 0
for _, ev := range payload.Events {
if ev.Type == "added" {
added++
}
}
if added != 2 {
t.Fatalf("不同主幹的檔案應各自產生事件,got %d", added)
}
if len(payload.DuplicateFormats) != 0 {
t.Fatalf("不該誤判為重複:%+v", payload.DuplicateFormats)
}
}
// loser 仍留在 manifest(讓下一輪 mtime/size fast-path 照常運作),只是沒有事件、
// 且 ingested_hash 永遠不會被設定——不會在後續掃描裡被誤判成「新檔」而不斷回報。
func TestScan_FormatDuplicate_LoserStaysInManifestNoRepeatedEvents(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "x.docx", "docx 內容", baseTime)
writeFile(t, root, "x.md", "md 內容(同主幹)", baseTime)
m := newTestManifest()
if _, err := Scan(root, m, ScanOptions{}); err != nil {
t.Fatal(err)
}
if _, ok := m.Entries["x.md"]; !ok {
t.Fatal("loser 應仍被記錄在 manifest(供 fast-path 用)")
}
if m.Entries["x.md"].IngestedHash != "" {
t.Fatal("loser 不該有 ingested_hash(它從未真的被送出)")
}
// 模擬 winner 已成功 ingest
m.Entries["x.docx"].IngestedHash = m.Entries["x.docx"].ContentHash
// 第二輪:兩份檔案都沒變 → loser 不該又跑出一個 added 事件
payload2, err := Scan(root, m, ScanOptions{})
if err != nil {
t.Fatal(err)
}
for _, ev := range payload2.Events {
if ev.Path == "x.md" {
t.Fatalf("loser 不該重複產生事件:%+v", ev)
}
}
}
// winner 消失後,loser 自然遞補(下一輪重算分組時 loser 變成該 stem 唯一成員)。
func TestScan_FormatDuplicate_WinnerRemovedLoserPromoted(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "y.docx", "docx 內容", baseTime)
writeFile(t, root, "y.pdf", "pdf 內容", baseTime)
m := newTestManifest()
if _, err := Scan(root, m, ScanOptions{}); err != nil {
t.Fatal(err)
}
m.Entries["y.docx"].IngestedHash = m.Entries["y.docx"].ContentHash
// docx 被刪除
if err := os.Remove(filepath.Join(root, "y.docx")); err != nil {
t.Fatal(err)
}
payload2, err := Scan(root, m, ScanOptions{MaxRemovedRatio: 1.0})
if err != nil {
t.Fatal(err)
}
var addedPdf bool
for _, ev := range payload2.Events {
if ev.Type == "added" && ev.Path == "y.pdf" {
addedPdf = true
}
}
if !addedPdf {
t.Fatalf("winner 消失後 loser 應遞補產生 added 事件:%+v", payload2.Events)
}
}