365 lines
14 KiB
Go
365 lines
14 KiB
Go
// route_backoff_measure_test.go — `inkstone/arcrun-rag#121`(comment 6923)的量測與驗收。
|
||
//
|
||
// 情境=2026-09-13 leo Mac 的實況:雲端「收卡」那條路(rag_ingest_card)持續回 HTTP 500
|
||
// (`Node list_old_blocks failed: 缺少 credential: kbdb_internal_token`),而每一發都是
|
||
// 雲端的全表掃。其他端點(萃取、資料夾樹、對帳)都正常。
|
||
//
|
||
// 量的是:假時鐘每 5 秒跑一輪(daemon 預設 poll_interval_sec),跑滿 N 分鐘,
|
||
// 各類雲端請求一共打了幾發。**假伺服器=只數請求**,不碰任何真實例。
|
||
//
|
||
// 🔴 這裡的 5 秒是**下限**:真實一輪還要加上本身的耗時(leo Mac 上約 15 秒一輪),
|
||
// 所以同樣 N 分鐘,真機的輪數比這裡少——這份數字是「最壞情況」,不是真機的精確值。
|
||
//
|
||
// 修前基準(commit 7a3eb2c,同一支量測、同一個情境):
|
||
//
|
||
// 1 分鐘( 12 輪):收卡路由 151 發
|
||
// 10 分鐘(120 輪):收卡路由 1291 發
|
||
// 60 分鐘(720 輪):收卡路由 7326 發,萃取 120 次(同一批檔反覆重萃)
|
||
package collector
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
type routeCounts struct {
|
||
Rounds int
|
||
CardRoute int // rag_ingest_card 收到幾發(壞掉那段時間的每一發=雲端一次全表掃)
|
||
CardOK map[string]bool // 雲端恢復後,哪些 path 真的送成功了
|
||
Extract int // /portal/daemon/extract 真的萃取(燒 AI 額度)
|
||
Probe int // /portal/daemon/extract 空探測
|
||
Tree int // /portal/daemon/folder-tree
|
||
Audit int // /kbdb/entries
|
||
Other int // 含 /health(每輪固定問的版本)
|
||
}
|
||
|
||
func (c routeCounts) Total() int {
|
||
return c.CardRoute + c.Extract + c.Probe + c.Tree + c.Audit + c.Other
|
||
}
|
||
|
||
// runCardRouteDownScenario 建一個「30 份新檔+10 份待修出處的舊卡」的資料夾,
|
||
// 讓收卡路由在前 downFor 內持續 500(之後恢復),模擬 minutes 分鐘(每 pollSec 秒一輪)。
|
||
func runCardRouteDownScenario(t *testing.T, minutes, pollSec int, downFor time.Duration) routeCounts {
|
||
return runRouteDownScenario(t, minutes, pollSec, downFor, "ok")
|
||
}
|
||
|
||
// runRouteDownScenario:extractMode="ok"|"5xx"(萃取端點恆回 503,含空探測)|"refuse"(萃取端點連線直接被切斷=連不上)。
|
||
func runRouteDownScenario(t *testing.T, minutes, pollSec int, downFor time.Duration, extractMode string) routeCounts {
|
||
t.Helper()
|
||
cloudRoutes.reset()
|
||
defer cloudRoutes.reset()
|
||
// 量測要用正式值:每輪固定問雲端的兩件事(版本、雲端 AI)一分鐘只問一次(cloudcheck.go)。
|
||
withCloudCheckInterval(t, 60*time.Second)
|
||
|
||
root := t.TempDir()
|
||
for i := 0; i < 30; i++ {
|
||
name := fmt.Sprintf("筆記%02d", i)
|
||
writeFile(t, root, name+".md", "# "+name+"\n\n內容 "+name, baseTime.Add(time.Duration(i)*time.Minute))
|
||
}
|
||
// 10 份舊形出處的卡(Arcrun#167 的就地修正會想重推它們)。
|
||
var docs []wikiDoc
|
||
for i := 0; i < 10; i++ {
|
||
rel := fmt.Sprintf("舊資料/.wiki/舊檔%02d.md", i)
|
||
abs := filepath.Join(root, filepath.FromSlash(rel))
|
||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(abs, []byte(legacyCard), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
docs = append(docs, wikiDoc{Node: "舊資料", Path: fmt.Sprintf("舊檔%02d.pdf", i),
|
||
Status: "extracted", Card: fmt.Sprintf("舊檔%02d", i), Cards: []string{rel}})
|
||
}
|
||
if err := saveWikiManifest(root, &wikiManifest{Version: 1, Docs: docs}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
start := time.Date(2026, 9, 13, 1, 0, 0, 0, time.UTC)
|
||
var clockNs atomic.Int64
|
||
clockNs.Store(start.UnixNano())
|
||
oldNow := directNow
|
||
directNow = func() time.Time { return time.Unix(0, clockNs.Load()).UTC() }
|
||
defer func() { directNow = oldNow }()
|
||
|
||
var mu sync.Mutex
|
||
c := routeCounts{CardOK: map[string]bool{}}
|
||
srv := httptest.NewServer(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, "/rag_ingest_card/trigger"):
|
||
c.CardRoute++
|
||
if time.Unix(0, clockNs.Load()).Before(start.Add(downFor)) {
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
_, _ = w.Write([]byte(`{"success":false,"error":"Node list_old_blocks failed: 缺少 credential: kbdb_internal_token"}`))
|
||
return
|
||
}
|
||
var req struct {
|
||
Path string `json:"path"`
|
||
}
|
||
_ = json.Unmarshal(body, &req)
|
||
c.CardOK[req.Path] = true
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||
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)
|
||
mode := extractMode
|
||
if mode == "5xx-10m" { // 前 10 分鐘 503,之後恢復
|
||
mode = "ok"
|
||
if time.Unix(0, clockNs.Load()).Before(start.Add(10 * time.Minute)) {
|
||
mode = "5xx"
|
||
}
|
||
}
|
||
switch mode {
|
||
case "5xx":
|
||
if strings.TrimSpace(req.Text) == "" {
|
||
c.Probe++
|
||
} else {
|
||
c.Extract++
|
||
}
|
||
w.WriteHeader(http.StatusServiceUnavailable)
|
||
_, _ = w.Write([]byte(`{"success":false,"error":"upstream unavailable"}`))
|
||
return
|
||
case "refuse":
|
||
if strings.TrimSpace(req.Text) == "" {
|
||
c.Probe++
|
||
} else {
|
||
c.Extract++
|
||
}
|
||
if hj, ok := w.(http.Hijacker); ok {
|
||
if conn, _, err := hj.Hijack(); err == nil {
|
||
_ = conn.Close() // 沒有任何回應就斷線=client 端拿到連線層錯誤
|
||
return
|
||
}
|
||
}
|
||
w.WriteHeader(http.StatusBadGateway)
|
||
return
|
||
}
|
||
if strings.TrimSpace(req.Text) == "" {
|
||
c.Probe++
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||
return
|
||
}
|
||
c.Extract++
|
||
subject := req.PageName
|
||
if subject == "" {
|
||
subject = "未命名"
|
||
}
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "output": cardFixture(subject, "測試")})
|
||
case strings.HasSuffix(r.URL.Path, "/portal/daemon/folder-tree"):
|
||
c.Tree++
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||
case strings.HasSuffix(r.URL.Path, "/kbdb/entries"):
|
||
c.Audit++
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "total": 1})
|
||
default:
|
||
c.Other++
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||
}
|
||
}))
|
||
defer srv.Close()
|
||
|
||
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: srv.URL, Namespace: "demo", APIKey: "demo",
|
||
WatchFolders: []string{root},
|
||
}},
|
||
}
|
||
end := start.Add(time.Duration(minutes) * time.Minute)
|
||
for now := start; now.Before(end); now = now.Add(time.Duration(pollSec) * time.Second) {
|
||
clockNs.Store(now.UnixNano())
|
||
RunDirectOnce(cfg, false)
|
||
c.Rounds++
|
||
}
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
return c
|
||
}
|
||
|
||
// TestCardRouteDown_Measure:雲端收卡路由**從頭壞到尾**,daemon 打上去的請求數。
|
||
// 情境與修前基準(檔頭)逐字相同,數字可以直接對照。
|
||
func TestCardRouteDown_Measure(t *testing.T) {
|
||
// 修前:151/1291/7326。上限=門檻 4 發(第一輪內停下)+每個退避窗口到期各 1 發,再留一點餘裕。
|
||
limits := map[int]int{1: 5, 10: 10, 60: 15}
|
||
for _, minutes := range []int{1, 10, 60} {
|
||
c := runCardRouteDownScenario(t, minutes, 5, 24*time.Hour)
|
||
t.Logf("收卡路由持續 500,模擬 %2d 分鐘(%3d 輪):收卡路由 %4d 發|萃取 %3d|探測 %3d|資料夾樹 %2d|對帳 %3d|其他 %d|合計 %4d",
|
||
minutes, c.Rounds, c.CardRoute, c.Extract, c.Probe, c.Tree, c.Audit, c.Other, c.Total())
|
||
if c.CardRoute > limits[minutes] {
|
||
t.Errorf("%d 分鐘內打壞掉那條路 %d 發,超過 %d——退避沒有生效", minutes, c.CardRoute, limits[minutes])
|
||
}
|
||
// 送不出去就不該反覆萃:30 份新檔最多各萃一次(第一份是撞牆的那一發)。
|
||
if c.Extract > 30 {
|
||
t.Errorf("%d 分鐘內萃取 %d 次,同一批檔被重複燒 AI 額度", minutes, c.Extract)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestExtractRouteDown_Measure:`inkstone/arcrun-rag#121` comment 6960 補的驗收——
|
||
// 萃取端點(/portal/daemon/extract,不經 postJSON)持續 5xx/連不上,收卡路由正常。
|
||
// 修前基準見 comment(同一支情境在 7a3eb2c 上跑)。
|
||
func TestExtractRouteDown_Measure(t *testing.T) {
|
||
limits := map[int]int{1: 6, 10: 12, 60: 16} // 萃取+探測合計;與收卡路由同一個量級
|
||
for _, mode := range []string{"5xx", "refuse"} {
|
||
for _, minutes := range []int{1, 10, 60} {
|
||
c := runRouteDownScenario(t, minutes, 5, 0, mode)
|
||
t.Logf("萃取端點持續 %-6s,模擬 %2d 分鐘(%3d 輪):萃取 %4d 發|探測 %4d|萃取端點合計 %4d|收卡路由 %3d|資料夾樹 %d|其他 %d",
|
||
mode, minutes, c.Rounds, c.Extract, c.Probe, c.Extract+c.Probe, c.CardRoute, c.Tree, c.Other)
|
||
if got := c.Extract + c.Probe; got > limits[minutes] {
|
||
t.Errorf("%s/%d 分鐘:打萃取端點 %d 發,超過 %d——萃取那條路沒有退避", mode, minutes, got, limits[minutes])
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestExtractRouteDown_RecoversByItself:萃取端點壞 10 分鐘後恢復——不重開、不按按鈕,
|
||
// 30 份新檔都要自己萃完並送上去(退避不准變成「永久放棄」,也不准把檔累積到「連續失敗 8 次已暫停」)。
|
||
func TestExtractRouteDown_RecoversByItself(t *testing.T) {
|
||
c := runRouteDownScenario(t, 60, 5, 0, "5xx-10m")
|
||
t.Logf("萃取端點壞 10 分鐘後恢復,模擬 60 分鐘:萃取 %d 發|探測 %d|送成功的 path %d 個",
|
||
c.Extract, c.Probe, len(c.CardOK))
|
||
var missing []string
|
||
for i := 0; i < 30; i++ {
|
||
if p := fmt.Sprintf("筆記%02d.md", i); !c.CardOK[p] {
|
||
missing = append(missing, p)
|
||
}
|
||
}
|
||
if len(missing) > 0 {
|
||
t.Fatalf("萃取端點恢復 50 分鐘後仍有 %d 份沒送上去:%v", len(missing), missing)
|
||
}
|
||
}
|
||
|
||
// TestCardRouteDown_RecoversByItself:壞 10 分鐘之後雲端修好了——
|
||
// 不重開小幫手、不按任何按鈕,所有該送的東西都要自己送上去(退避不准變成「永久放棄」)。
|
||
func TestCardRouteDown_RecoversByItself(t *testing.T) {
|
||
c := runCardRouteDownScenario(t, 60, 5, 10*time.Minute)
|
||
t.Logf("壞 10 分鐘後恢復,模擬 60 分鐘:收卡路由 %d 發|送成功的 path %d 個|萃取 %d",
|
||
c.CardRoute, len(c.CardOK), c.Extract)
|
||
var missing []string
|
||
for i := 0; i < 30; i++ {
|
||
if p := fmt.Sprintf("筆記%02d.md", i); !c.CardOK[p] {
|
||
missing = append(missing, p)
|
||
}
|
||
}
|
||
for i := 0; i < 10; i++ {
|
||
if p := fmt.Sprintf("舊資料/舊檔%02d.pdf", i); !c.CardOK[p] {
|
||
missing = append(missing, p)
|
||
}
|
||
}
|
||
if len(missing) > 0 {
|
||
t.Fatalf("雲端恢復 50 分鐘後仍有 %d 份沒送上去:%v", len(missing), missing)
|
||
}
|
||
}
|
||
|
||
// ── 退避本身的規則 ────────────────────────────────────────────────────────
|
||
|
||
func TestRouteBreaker_Rules(t *testing.T) {
|
||
b := &routeBreaker{routes: map[string]*routeState{}}
|
||
u := "https://x.workers.dev/webhooks/named/ns/rag_ingest_card/trigger"
|
||
other := "https://x.workers.dev/webhooks/named/ns/rag_takedown_direct/trigger"
|
||
t0 := time.Date(2026, 9, 13, 0, 0, 0, 0, time.UTC)
|
||
|
||
// 2xx(即使 body 寫工作流失敗,postJSON 也只把狀態碼交進來)與一般 4xx 不開閘。
|
||
b.record(u, t0, 200, nil, false)
|
||
for i := 0; i < 10; i++ {
|
||
b.record(u, t0, 400, nil, false)
|
||
}
|
||
if n := b.note(u, t0); n != "" {
|
||
t.Fatalf("2xx/400 不該讓整條路退避:%q", n)
|
||
}
|
||
|
||
// 有前科的內容再失敗:不算數。
|
||
for i := 0; i < 10; i++ {
|
||
b.record(u, t0, 500, nil, true)
|
||
}
|
||
if n := b.note(u, t0); n != "" {
|
||
t.Fatalf("先前就失敗過的檔再失敗,不准把整條路關掉:%q", n)
|
||
}
|
||
|
||
// 第一次送的內容連續失敗:前 3 發照常重試,第 4 發 ⇒ 停 1 分鐘;只停這一條,別條路照走。
|
||
for i := 0; i < routeStrikesBeforeBackoff-1; i++ {
|
||
b.record(u, t0, 500, nil, false)
|
||
}
|
||
if n := b.note(u, t0); n != "" {
|
||
t.Fatalf("單次(未達門檻)的 500 下一輪要照常重試:%q", n)
|
||
}
|
||
b.record(u, t0, 500, nil, false)
|
||
n := b.note(u, t0.Add(30*time.Second))
|
||
if n == "" || !strings.Contains(n, "rag_ingest_card") {
|
||
t.Fatalf("500 之後應該退避並講出是哪條路:%q", n)
|
||
}
|
||
if !explainsWhySkipped(n) {
|
||
t.Fatalf("退避訊息要講得出原因(sync_status.go 的識別字):%q", n)
|
||
}
|
||
if strings.Contains(n, "500") || strings.Contains(n, "HTTP") {
|
||
t.Fatalf("畫面上的話不准裸露狀態碼:%q", n)
|
||
}
|
||
if b.note(other, t0) != "" {
|
||
t.Fatal("一條路壞了不該牽連同台知識庫的其他路")
|
||
}
|
||
if b.note(u, t0.Add(time.Minute)) != "" {
|
||
t.Fatal("窗口到了就要放一發試試")
|
||
}
|
||
|
||
// 連續失敗 ⇒ 窗口變長;429 與連線錯誤也算。
|
||
b.record(u, t0.Add(time.Minute), 429, nil, false)
|
||
if b.note(u, t0.Add(time.Minute+90*time.Second)) == "" {
|
||
t.Fatal("窗口到期後再失敗一發,應該停 2 分鐘")
|
||
}
|
||
b.record(u, t0.Add(3*time.Minute), 0, errors.New("dial tcp: connection refused"), false)
|
||
if b.note(u, t0.Add(3*time.Minute+4*time.Minute)) == "" {
|
||
t.Fatal("再失敗一發應該停 5 分鐘")
|
||
}
|
||
// 一發成功 ⇒ 全部歸零:之後單次 500 不會立刻停。
|
||
b.record(u, t0.Add(9*time.Minute), 200, nil, false)
|
||
b.record(u, t0.Add(9*time.Minute), 500, nil, false)
|
||
if b.note(u, t0.Add(9*time.Minute)) != "" {
|
||
t.Fatal("成功一次後計數要歸零,重新累積到門檻前不該停")
|
||
}
|
||
// 上限 30 分鐘。
|
||
for i := 0; i < 20; i++ {
|
||
b.record(u, t0, 500, nil, false)
|
||
}
|
||
if b.note(u, t0.Add(30*time.Minute)) != "" {
|
||
t.Fatal("退避上限是 30 分鐘,雲端修好後最慢半小時要自己接上")
|
||
}
|
||
}
|
||
|
||
// 「立刻同步」=使用者明確要求這一輪照打(與逐檔退避同一個語意)。
|
||
func TestRouteBreaker_ForceSyncBypasses(t *testing.T) {
|
||
cloudRoutes.reset()
|
||
defer cloudRoutes.reset()
|
||
u := "https://y.workers.dev/webhooks/named/ns/rag_ingest_card/trigger"
|
||
for i := 0; i < routeStrikesBeforeBackoff; i++ {
|
||
cloudRoutes.record(u, directNow(), 503, nil, false)
|
||
}
|
||
cfg := &DirectConfig{}
|
||
if cfg.routeNote(u) == "" {
|
||
t.Fatal("503 之後應該在退避中")
|
||
}
|
||
cfg.ForceSync = true
|
||
if cfg.routeNote(u) != "" {
|
||
t.Fatal("按了「立刻同步」這一輪要照打")
|
||
}
|
||
}
|