Files
arcrun-collector/route_backoff_measure_test.go
T

365 lines
14 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.
// 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")
}
// runRouteDownScenarioextractMode"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) {
// 修前:1511291/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("2xx400 不該讓整條路退避:%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("按了「立刻同步」這一輪要照打")
}
}