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
+154
View File
@@ -0,0 +1,154 @@
// direct_quota_test.go — 額度用完要說人話並降速(2026-08-07 task 2)端到端驗證。
//
// 模擬 wiki mistakes.md 2026-08-06 記錄的真實上游狀況:Workers AI 每日免費額度
// 10,000 neurons 用完,HTTP 502,訊息含「4006: you have used up your daily free
// allocation of 10,000 neurons」。驗收:畫面(status.json)出現三句話,
// 沒有裸露的錯誤碼;且同一輪/下一輪不再繼續撞同一面牆。
package collector
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
)
// quotaExhaustedServer 對任何 /portal/daemon/extract 請求都回上游額度用完的原始錯誤。
// calls 只計「真的萃取」請求(body.text 非空)——ProbeWorkersAI 每輪固定會探測一次
// (送空 text,不燒 LLM 額度,見 probe_workersai.go),不是本測試要驗的「有沒有繼續撞牆」。
func quotaExhaustedServer(t *testing.T, calls *int) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Text string `json:"text"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if strings.TrimSpace(body.Text) != "" {
*calls++
}
w.WriteHeader(http.StatusBadGateway) // 實據:HTTP 502
_ = json.NewEncoder(w).Encode(map[string]string{
"error": "4006: you have used up your daily free allocation of 10,000 neurons",
})
}))
}
func TestDirect_QuotaExhausted_ShowsHumanMessageNoRawCode(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "a.md", "內容 A", baseTime)
writeFile(t, root, "b.md", "內容 B", baseTime.Add(time.Minute))
var calls int
srv := quotaExhaustedServer(t, &calls)
defer srv.Close()
manifest := filepath.Join(t.TempDir(), "m.json")
cfg := &DirectConfig{
WatchFolders: []string{root},
Manifest: manifest,
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
Library: "kb", Extractor: "workers-ai", ExtractorExplicit: true,
MaxRemoved: DefaultMaxRemovedRatio,
}
results, exit, _ := RunDirectOnce(cfg, false)
if exit != 1 {
t.Fatalf("兩份都該萃取失敗,exit 應為 1got %d", exit)
}
if len(results) != 2 {
t.Fatalf("results=%+v", results)
}
banned := []string{"4006", "502", "HTTP", "neurons"}
for _, r := range results {
for _, b := range banned {
if strings.Contains(r.Error, b) {
t.Errorf("結果不該出現裸露的錯誤碼 %q%q", b, r.Error)
}
}
for _, want := range []string{"今天已經幫你整理了", "升級 Cloudflare", "自動恢復"} {
if !strings.Contains(r.Error, want) {
t.Errorf("缺三句話之一 %q%q", want, r.Error)
}
}
}
// status.json 也要是人話,同樣不准有裸碼
st, err := LoadSyncStatus(StatusFilePath(manifest))
if err != nil {
t.Fatalf("讀 status.json 失敗:%v", err)
}
if st.ExtractorOK {
t.Error("額度冷卻中,ExtractorOK 應為 false")
}
for _, b := range banned {
if strings.Contains(st.ExtractorError, b) {
t.Errorf("status.ExtractorError 不該有裸碼 %q%q", b, st.ExtractorError)
}
}
acc, ok := st.AccountDetails[instanceHostOf(srv.URL)]
if !ok {
t.Fatal("找不到帳號的 status")
}
if acc.QuotaMessage == nil {
t.Fatal("應該有 QuotaMessage")
}
if acc.QuotaCooldownUntil == "" {
t.Fatal("應該記下冷卻到什麼時候")
}
for _, b := range banned {
combined := acc.QuotaMessage.Combined()
if strings.Contains(combined, b) {
t.Errorf("QuotaMessage 不該有裸碼 %q%q", b, combined)
}
}
// 只有第一次真的打了 API(第二份的 xerr 判斷不需要——一偵測到就進冷卻,
// 第二個事件被上面的 qs.inCooldown 擋下,不再觸網)。
if calls != 1 {
t.Fatalf("偵測到額度用完後不該繼續撞牆,got %d 次呼叫", calls)
}
}
// 同一帳號、下一輪(新的 RunDirectOnce 呼叫,模擬下一次輪詢):冷卻還沒過,
// 一次網路呼叫都不該再打——這是「不要一次湧上去、也不要一直撞牆」的核心。
func TestDirect_QuotaExhausted_NextRunSkipsWithoutNetworkCall(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "a.md", "內容 A", baseTime)
var calls int
srv := quotaExhaustedServer(t, &calls)
defer srv.Close()
cfg := &DirectConfig{
WatchFolders: []string{root},
Manifest: filepath.Join(t.TempDir(), "m.json"),
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
Library: "kb", Extractor: "workers-ai", ExtractorExplicit: true,
MaxRemoved: DefaultMaxRemovedRatio,
}
if _, exit, _ := RunDirectOnce(cfg, false); exit != 1 {
t.Fatal("第一輪應該偵測到額度用完")
}
if calls != 1 {
t.Fatalf("第一輪應該只打 1 次,got %d", calls)
}
results2, exit2, _ := RunDirectOnce(cfg, false)
if exit2 != 0 {
t.Fatalf("冷卻中的 skip 不是失敗,exit 應為 0got %d results=%+v", exit2, results2)
}
if calls != 1 {
t.Fatalf("冷卻中不該再打任何一次 APIgot %d 次呼叫", calls)
}
if len(results2) != 1 || results2[0].Status != "skipped" {
t.Fatalf("results2=%+v", results2)
}
if !strings.Contains(results2[0].Error, "自動恢復") {
t.Errorf("跳過訊息也該是三句話:%q", results2[0].Error)
}
}