Files
arcrun-collector/direct_quota_test.go
T
Leo a6ced32d45 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>
2026-08-07 16:58:07 +08:00

155 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}
}