Files
arcrun-collector/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

111 lines
3.9 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.
// quota_test.go — 額度用完人話訊息(2026-08-07 pacing task 2)。
package collector
import (
"strings"
"testing"
"time"
)
func TestIsQuotaExhausted(t *testing.T) {
cases := []struct {
msg string
want bool
}{
{"本地萃取失敗:雲端萃取失敗(HTTP 502):4006: you have used up your daily free allocation of 10,000 neurons", true},
{"雲端萃取失敗:exceeded daily free allocation", true},
{"連不上你的知識庫:dial tcp: connection refused", false},
{"檔案裡沒有可抽取的文字", false},
{"雲端萃取失敗(HTTP 500):internal error", false},
}
for _, c := range cases {
if got := isQuotaExhausted(c.msg); got != c.want {
t.Errorf("isQuotaExhausted(%q)=%v want %v", c.msg, got, c.want)
}
}
}
// UTC 午夜重置=台灣時間固定早上 8:00。
func TestNextQuotaResetTaiwan(t *testing.T) {
// 2026-08-07 15:00 UTC = 2026-08-07 23:00 台灣 → 下次重置 2026-08-08 00:00 UTC = 08-08 08:00 台灣
now := time.Date(2026, 8, 7, 15, 0, 0, 0, time.UTC)
got := nextQuotaResetTaiwan(now)
want := time.Date(2026, 8, 8, 8, 0, 0, 0, taiwanTZ)
if !got.Equal(want) {
t.Fatalf("got %v want %v", got, want)
}
}
// 三句話缺一不可,且絕不含裸露的錯誤碼(4006/502/HTTP)。
func TestBuildQuotaNotice_NoRawErrorCode(t *testing.T) {
now := time.Date(2026, 8, 7, 10, 0, 0, 0, time.UTC)
resetAt := nextQuotaResetTaiwan(now)
notice := buildQuotaNotice(now, 42, resetAt)
if !strings.Contains(notice.Achievement, "42") {
t.Errorf("成就句沒帶到份數:%q", notice.Achievement)
}
if !strings.Contains(notice.ExitOptions, "升級") || !strings.Contains(notice.ExitOptions, "換") {
t.Errorf("出口句缺選項:%q", notice.ExitOptions)
}
if !strings.Contains(notice.Guarantee, "自動恢復") {
t.Errorf("保證句沒講自動恢復:%q", notice.Guarantee)
}
combined := notice.Combined()
for _, banned := range []string{"4006", "502", "HTTP", "neurons"} {
if strings.Contains(combined, banned) {
t.Errorf("三句話不該出現裸露的錯誤碼 %q:%q", banned, combined)
}
}
}
// 「今天」vs「明天」:現在台灣時間若已過午夜還沒到 8 點,重置其實是「今天」。
func TestQuotaGuaranteeText_TodayVsTomorrow(t *testing.T) {
// 台灣時間 08-08 02:00= UTC 08-07 18:00)→ 下次重置 08-08 08:00 台灣 → 同一天 → 「今天」
now := time.Date(2026, 8, 7, 18, 0, 0, 0, time.UTC)
resetAt := nextQuotaResetTaiwan(now)
got := quotaGuaranteeText(now, resetAt)
if !strings.Contains(got, "今天") {
t.Fatalf("應該是「今天」:%qnow台灣=%v resetAt台灣=%v", got, now.In(taiwanTZ), resetAt.In(taiwanTZ))
}
// 台灣時間 08-07 23:00= UTC 08-07 15:00)→ 下次重置 08-08 08:00 台灣 → 隔天 → 「明天」
now2 := time.Date(2026, 8, 7, 15, 0, 0, 0, time.UTC)
resetAt2 := nextQuotaResetTaiwan(now2)
got2 := quotaGuaranteeText(now2, resetAt2)
if !strings.Contains(got2, "明天") {
t.Fatalf("應該是「明天」:%q", got2)
}
}
func TestQuotaState_MarkHitOnlyFirstTimeSetsCooldown(t *testing.T) {
qs := &quotaState{}
now := time.Date(2026, 8, 7, 10, 0, 0, 0, time.UTC)
qs.markHit(now, "第一次原因")
first := qs.CooldownUntil
later := now.Add(90 * time.Minute)
qs.markHit(later, "第二次原因(同一輪另一個檔也撞到)")
if !qs.CooldownUntil.Equal(first) {
t.Fatalf("第二次命中不該再往後推遲冷卻時間:first=%v got=%v", first, qs.CooldownUntil)
}
if qs.RawReason != "第一次原因" {
t.Fatalf("RawReason 應保留第一次的:%q", qs.RawReason)
}
}
func TestQuotaState_InCooldown(t *testing.T) {
qs := &quotaState{}
now := time.Date(2026, 8, 7, 10, 0, 0, 0, time.UTC)
if qs.inCooldown(now) {
t.Fatal("從沒命中過不該在冷卻中")
}
qs.markHit(now, "x")
if !qs.inCooldown(now) {
t.Fatal("剛命中應立刻在冷卻中")
}
if qs.inCooldown(qs.CooldownUntil.Add(time.Second)) {
t.Fatal("過了冷卻時間應該解除")
}
}