Files
arcrun-collector/routebackoff_201_test.go
T

424 lines
16 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.
// routebackoff_201_test.go — `inkstone/arcrun-rag#201` 的驗收。
//
// 2026-09-16 leo 的畫面:「雲端『雲端 AI 整理文件』這條路連續失敗 11 次,先停 26 分鐘再試」。
// 實查(collector.log.oldmanifest):那句是 geek6688 帳號的,而且那 11 次是 09-14 開 App
// 之後兩天內零星的失敗(最後一發=17:48:42 探測等了 20 秒),計數器從沒歸零過;
// 另外 leo 自己那台知識庫的 KB 資料夾有 17 份檔因為「這台電腦當時查不到 DNS」被記滿 8 次永久暫停、
// 12 份 0 位元組的日記被雲端回 400 後永久暫停。
package collector
import (
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/url"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func dnsNotFoundErr(host string) error {
return &url.Error{Op: "Post", URL: "https://" + host + "/portal/daemon/extract",
Err: &net.OpError{Op: "dial", Net: "tcp", Err: &net.DNSError{Err: "no such host", Name: host, IsNotFound: true}}}
}
type timeoutErr struct{}
func (timeoutErr) Error() string {
return "context deadline exceeded (Client.Timeout exceeded while awaiting headers)"
}
func (timeoutErr) Timeout() bool { return true }
func (timeoutErr) Temporary() bool { return true }
// 本機沒連出去(DNS/網路不通/連線被拒)不花雲端額度 ⇒ 不准讓斷路器跳。
func TestRouteBreaker201_LocalNetworkDoesNotTrip(t *testing.T) {
b := &routeBreaker{routes: map[string]*routeState{}}
host := "arcrun-cypher-executor.kb-a.workers.dev"
u := "https://" + host + "/portal/daemon/extract"
t0 := time.Date(2026, 9, 16, 8, 0, 0, 0, time.UTC)
errs := []error{
dnsNotFoundErr(host),
&url.Error{Op: "Post", URL: u, Err: &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connect: network is unreachable")}},
errors.New(`Post "x": dial tcp: lookup ` + host + `: no such host`),
errors.New("dial tcp 127.0.0.1:9: connect: connection refused"),
}
for i := 0; i < 50; i++ {
b.record(u, t0, 0, errs[i%len(errs)], false)
}
if n := b.note(u, t0); n != "" {
t.Fatalf("本機沒連出去 50 次,斷路器不該跳:%q", n)
}
for i := 0; i < 50; i++ {
b.record(u, t0, 400, nil, false)
}
if n := b.note(u, t0); n != "" {
t.Fatalf("400 不花額度,斷路器不該跳:%q", n)
}
// 對照組:雲端連續 5xx 仍然要跳。
for i := 0; i < routeStrikesBeforeBackoff; i++ {
b.record(u, t0, 503, nil, false)
}
if b.note(u, t0) == "" {
t.Fatal("雲端連續 5xx 仍然要退避(#121 的保護不准被拆掉)")
}
}
// 「連續」要是真的連續:兩天內零星 11 次(每次隔 4 小時)不准累積成跳閘。
func TestRouteBreaker201_ScatteredFailuresDoNotAccumulate(t *testing.T) {
b := &routeBreaker{routes: map[string]*routeState{}}
u := "https://arcrun-cypher-executor.arcrun-fc9490d5.workers.dev/portal/daemon/extract"
t0 := time.Date(2026, 9, 14, 9, 22, 0, 0, time.UTC)
stall := &url.Error{Op: "Post", URL: u, Err: timeoutErr{}}
for i := 0; i < 11; i++ {
at := t0.Add(time.Duration(i) * 4 * time.Hour)
b.record(u, at, 0, stall, false)
if n := b.note(u, at); n != "" {
t.Fatalf("第 %d 次零星失敗(距上一次 4 小時)就跳閘了:%q", i+1, n)
}
}
t1 := t0.Add(100 * time.Hour)
for i := 0; i < routeStrikesBeforeBackoff; i++ {
b.record(u, t1.Add(time.Duration(i)*time.Minute), 0, stall, false)
}
if b.note(u, t1.Add(3*time.Minute+30*time.Second)) == "" {
t.Fatal("幾分鐘內連續 4 發等不到回應,應該退避")
}
}
// 跳閘時畫面那句話要點名是哪一台知識庫、最近一次是哪一種失敗;log 每一次失敗都帶時間。
func TestRouteBreaker201_NoteNamesAccountAndCause_LogHasTime(t *testing.T) {
cloudRoutes.reset()
defer cloudRoutes.reset()
var mu sync.Mutex
var got []string
old := routeFailureLog
routeFailureLog = func(at time.Time, f RouteFailure) {
mu.Lock()
defer mu.Unlock()
line, _ := json.Marshal(struct {
At string `json:"at"`
RouteFailure
}{at.Format(time.RFC3339), f})
got = append(got, string(line))
}
defer func() { routeFailureLog = old }()
u := "https://arcrun-cypher-executor.arcrun-fc9490d5.workers.dev/portal/daemon/extract"
t0 := time.Date(2026, 9, 16, 9, 48, 42, 0, time.UTC)
oldNow := directNow
directNow = func() time.Time { return t0 }
defer func() { directNow = oldNow }()
cloudRoutes.record(u, t0, 0, dnsNotFoundErr("x"), false) // 不算,也不印
for i := 0; i < routeStrikesBeforeBackoff; i++ {
cloudRoutes.record(u, t0, 0, &url.Error{Op: "Post", URL: u, Err: timeoutErr{}}, false)
}
cfg := &DirectConfig{InstanceName: "geek6688", CypherURL: "https://arcrun-cypher-executor.arcrun-fc9490d5.workers.dev"}
n := cfg.routeNote(u)
for _, want := range []string{"geek6688", "雲端 AI 整理文件", "等不到回應", "稍後會自動恢復"} {
if !strings.Contains(n, want) {
t.Fatalf("畫面那句少了 %q%q", want, n)
}
}
if strings.Contains(n, "HTTP") {
t.Fatalf("畫面上的話不准裸露狀態碼:%q", n)
}
if len(got) != routeStrikesBeforeBackoff {
t.Fatalf("每一次算進去的失敗都要印一行,要 %d 行,拿到 %d:%v", routeStrikesBeforeBackoff, len(got), got)
}
last := got[len(got)-1]
for _, want := range []string{`"at":"2026-09-16T09:48:42Z"`, `"cause":"等不到回應"`, `"fails":4`, `"backoff_until"`, "Client.Timeout"} {
if !strings.Contains(last, want) {
t.Fatalf("log 那一行少了 %s%s", want, last)
}
}
t.Logf("route_failure 紀錄範例:%s", last)
t.Logf("畫面那句:%s", n)
}
// 舊病歷:斷網被記滿 8 次的檔,要自己再試、算排隊中。
func TestManifest201_LegacyNetworkPauseRetriesByItself(t *testing.T) {
host := "arcrun-cypher-executor.kb-a.workers.dev"
m := &Manifest{Entries: map[string]*ManifestEntry{
"journals/2026_04_28.md": {ContentHash: "h1", FailCount: 8, LastFailAt: 1786674239, NextRetry: 1786695839,
LastError: `本地萃取失敗:連不上你的知識庫:Post "https://` + host + `/portal/daemon/extract": dial tcp: lookup ` + host + `: no such host`},
"assets/scan.pdf": {ContentHash: "h2", FailCount: 8, NextRetry: 1, LastError: "本地萃取失敗:轉檔失敗(assets/scan.pdf):檔案裡沒有可抽取的文字"},
}}
now := time.Date(2026, 9, 16, 18, 0, 0, 0, time.Local).Unix()
if !m.ShouldRetry("journals/2026_04_28.md", now, false) {
t.Fatal("斷網被記滿 8 次的檔,網路好了要自己再試,不必按「立刻同步」")
}
if m.ShouldRetry("assets/scan.pdf", now, false) {
t.Fatal("檔案本身的問題(讀不出文字)照舊暫停")
}
if m.HasOwnFailure("journals/2026_04_28.md") {
t.Fatal("斷網不算這個檔的前科")
}
if p := m.Progress(); p.Pending != 1 || p.Stuck != 1 {
t.Fatalf("斷網那份算排隊中、讀不出字那份算卡住:%+v", p)
}
m.MarkNetworkUnavailable("journals/2026_04_28.md", now, "dial tcp: lookup x: no such host")
e := m.Entries["journals/2026_04_28.md"]
if e.FailCount != 8 || m.ShouldRetry("journals/2026_04_28.md", now+30, false) || !m.ShouldRetry("journals/2026_04_28.md", now+61, false) {
t.Fatalf("斷網要短暫等待(60 秒)而且不加次數:%+v", e)
}
if r := retrySkipReason(m, "journals/2026_04_28.md", now+30); !explainsWhySkipped(r) {
t.Fatalf("等待中的訊息要講得出原因:%q", r)
}
// 從沒失敗過的新檔第一次就撞上斷網,同樣要等那 60 秒(不准每輪空轉重轉檔)。
m.Entries["new.md"] = &ManifestEntry{ContentHash: "h3"}
m.MarkNetworkUnavailable("new.md", now, "dial tcp: lookup x: no such host")
if m.ShouldRetry("new.md", now+5, false) || !m.ShouldRetry("new.md", now+61, false) {
t.Fatalf("新檔斷網後要等 60 秒再試:%+v", m.Entries["new.md"])
}
m.MarkFailed("journals/2026_04_28.md", now+61, "雲端萃取失敗(HTTP 500")
if e.FailCount != 1 {
t.Fatalf("斷網留下的次數不該算進真的失敗:FailCount=%d", e.FailCount)
}
}
// 端到端:知識庫連不上(本機沒連出去)的那段時間,檔案不被記失敗、斷路器不跳;
// 連線恢復後不按任何按鈕,下一輪就自己送上去。空白檔不送、不算失敗。
func TestDirect201_OfflineThenBackOnline(t *testing.T) {
cloudRoutes.reset()
defer cloudRoutes.reset()
withCloudCheckInterval(t, 0)
root := t.TempDir()
writeFile(t, root, "筆記.md", "# 筆記\n\n內容", baseTime)
writeFile(t, root, "journals/2026_05_07.md", "", baseTime)
var mu sync.Mutex
extracts, cards := 0, map[string]bool{}
emptyTextSent := false
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
mu.Lock()
defer mu.Unlock()
switch {
case strings.HasSuffix(r.URL.Path, "/portal/daemon/extract"):
var req struct {
PageName string `json:"page_name"`
Text string `json:"text"`
}
_ = json.Unmarshal(body, &req)
if strings.TrimSpace(req.Text) == "" {
if req.PageName != "" {
emptyTextSent = true
}
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"success":false,"error":"page_name 與 text 必填"}`))
return
}
extracts++
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "output": cardFixture(req.PageName, "測試")})
case strings.HasSuffix(r.URL.Path, "/rag_ingest_card/trigger"):
var req struct {
Path string `json:"path"`
}
_ = json.Unmarshal(body, &req)
cards[req.Path] = true
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
default:
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}
})
// 先占一個埠再關掉:斷線期間那個位址「連線被拒」(本機層),之後在同一個位址開伺服器。
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := ln.Addr().String()
_ = ln.Close()
start := time.Date(2026, 9, 16, 0, 0, 0, 0, time.UTC)
var clockMu sync.Mutex
clock := start
setClock := func(c time.Time) { clockMu.Lock(); clock = c; clockMu.Unlock() }
oldNow := directNow
directNow = func() time.Time { clockMu.Lock(); defer clockMu.Unlock(); return clock }
defer func() { directNow = oldNow }()
cfg := &DirectConfig{
Manifest: filepath.Join(t.TempDir(), "m.json"),
CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct",
Extractor: "workers-ai", ExtractorExplicit: true,
MachineLabel: "測試機",
MaxRemoved: DefaultMaxRemovedRatio,
Accounts: []AccountConfig{{
CypherURL: "http://" + addr, Namespace: "demo", APIKey: "demo", InstanceName: "我的知識庫",
WatchFolders: []string{root},
}},
}
var offline []DirectResult
for i := 0; i < 30; i++ {
setClock(start.Add(time.Duration(i*5) * time.Second))
offline, _, _ = RunDirectOnce(cfg, false)
}
acc := cfg.makeAccountSubConfig(cfg.Accounts[0])
m, err := LoadManifest(acc.manifestPathFor(root), root)
if err != nil {
t.Fatal(err)
}
if e := m.Entries["筆記.md"]; e == nil || e.FailCount != 0 {
t.Fatalf("斷線期間不准把這份記成失敗:%+v", e)
}
if n := cloudRoutes.note(workersAIExtractURL(acc.CypherURL), directNow()); n != "" {
t.Fatalf("斷線 30 輪,斷路器不該跳:%q", n)
}
saw := false
for _, r := range offline {
if r.At == "" {
t.Fatalf("每一筆結果都要帶時間:%+v", r)
}
if r.Path == "筆記.md" && (strings.Contains(r.Error, "我的知識庫") || strings.Contains(r.Error, "沒連上網路")) && explainsWhySkipped(r.Error) {
saw = true
}
}
if !saw {
t.Fatalf("斷線時要講得出是哪台知識庫、為什麼沒送:%+v", offline)
}
ln2, err := net.Listen("tcp", addr)
if err != nil {
t.Skipf("無法重新綁定同一個埠(%v),略過恢復段", err)
}
srv := &http.Server{Handler: handler}
go func() { _ = srv.Serve(ln2) }()
defer srv.Close()
for i := 0; i < 3; i++ {
setClock(start.Add(3*time.Minute + time.Duration(i*5)*time.Second))
RunDirectOnce(cfg, false)
}
mu.Lock()
defer mu.Unlock()
if !cards["筆記.md"] {
t.Fatalf("網路恢復後沒按按鈕,這份應該自己送上去(萃取 %d 次,送卡 %v)", extracts, cards)
}
if emptyTextSent {
t.Fatal("空白檔不准再送去雲端(會拿到 400 被記成失敗)")
}
m, _ = LoadManifest(acc.manifestPathFor(root), root)
if p := m.Progress(); p.Stuck != 0 || p.Done != p.Total {
t.Fatalf("恢復後兩份都要算完成(空白檔=沒有內容可整理):%+v", p)
}
}
// 0.18.54 實機第一輪抓到的:舊版把空白日記送上雲端、被 400 退回、記滿 8 次暫停——
// 這批**舊病歷**也要被重新判成「空白略過」,不能繼續掛著「連續失敗 8 次」。
// 同一輪:舊病歷寫「雲端沒有綁定 Workers AI」(當時雲端是舊版)的檔,雲端更新後要自己再試。
func TestDirect201_LegacyPausedEmptyAndOldCloudRecover(t *testing.T) {
cloudRoutes.reset()
defer cloudRoutes.reset()
withCloudCheckInterval(t, 0)
root := t.TempDir()
writeFile(t, root, "journals/2024_11_24.md", "", baseTime)
writeFile(t, root, "journals/2026_07_15.md", "# 七月\n\n一些內容", baseTime)
var mu sync.Mutex
cards := map[string]bool{}
srv := http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
mu.Lock()
defer mu.Unlock()
var req struct {
PageName string `json:"page_name"`
Text string `json:"text"`
Path string `json:"path"`
}
_ = json.Unmarshal(body, &req)
switch {
case strings.HasSuffix(r.URL.Path, "/portal/daemon/extract"):
if strings.TrimSpace(req.Text) == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "output": cardFixture(req.PageName, "測試")})
case strings.HasSuffix(r.URL.Path, "/rag_ingest_card/trigger"):
cards[req.Path] = true
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
default:
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}
})}
// 先占一個位址再關掉:第一輪連不上(只為了建出帳本),之後在同一個位址開伺服器。
ln0, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := ln0.Addr().String()
_ = ln0.Close()
clock := time.Date(2026, 9, 16, 15, 0, 0, 0, time.UTC)
oldNow := directNow
directNow = func() time.Time { return clock }
defer func() { directNow = oldNow }()
cfg := &DirectConfig{
Manifest: filepath.Join(t.TempDir(), "m.json"),
CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct",
Extractor: "workers-ai", ExtractorExplicit: true, MachineLabel: "測試機",
MaxRemoved: DefaultMaxRemovedRatio,
Accounts: []AccountConfig{{CypherURL: "http://" + addr, Namespace: "demo", APIKey: "demo",
InstanceName: "我的知識庫", WatchFolders: []string{root}}},
}
RunDirectOnce(cfg, false) // 連不上的一輪:只為了建出帳本
acc := cfg.makeAccountSubConfig(cfg.Accounts[0])
mp := acc.manifestPathFor(root)
m, err := LoadManifest(mp, root)
if err != nil {
t.Fatal(err)
}
past := clock.Add(-30 * 24 * time.Hour).Unix()
for path, why := range map[string]string{
"journals/2024_11_24.md": "本地萃取失敗:雲端萃取失敗(HTTP 400):page_name 與 text 必填",
"journals/2026_07_15.md": "本地萃取失敗:雲端萃取失敗(HTTP 501):這個部署沒有綁定 Workers AIwrangler.toml 需有 [ai] binding),請更新知識庫版本",
} {
e := m.Entries[path]
if e == nil {
t.Fatalf("帳本裡沒有 %s", path)
}
// 還原成「舊版留下的樣子」:沒送成、記滿 8 次暫停
e.IngestedHash, e.IngestedAt, e.NoCloudCard = "", 0, false
e.FailCount, e.LastFailAt, e.NextRetry, e.LastError = 8, past, past+6*3600, why
}
if err := m.Save(mp); err != nil {
t.Fatal(err)
}
ln, err := net.Listen("tcp", addr)
if err != nil {
t.Skipf("無法重新綁定同一個埠(%v)", err)
}
go func() { _ = srv.Serve(ln) }()
defer srv.Close()
if m2, _ := LoadManifest(mp, root); m2.Progress().Stuck != 2 {
t.Fatalf("前置條件:兩份都該是卡住:%+v", m2.Progress())
}
results, _, _ := RunDirectOnce(cfg, false)
m, _ = LoadManifest(mp, root)
var emptyNote string
for _, r := range results {
if r.Path == "journals/2024_11_24.md" {
emptyNote = r.Error
}
}
if !strings.Contains(emptyNote, "空白檔案") {
t.Fatalf("舊病歷的空白日記要被重新判成空白略過,拿到:%q", emptyNote)
}
mu.Lock()
defer mu.Unlock()
if !cards["journals/2026_07_15.md"] {
t.Fatalf("雲端更新後,「沒有綁定 Workers AI」那份要自己再試並送上去:%v", cards)
}
if p := m.Progress(); p.Stuck != 0 || p.Done != p.Total {
t.Fatalf("兩份都要算完成:%+v", p)
}
}