Files
arcrun-collector/direct_pacing_test.go

530 lines
20 KiB
Go
Raw Permalink 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_pacing_test.go — 上傳節奏/新檔優先/單輪上限/斷點續傳(2026-08-07)。
//
// 背景見 direct_pacing.go 檔頭:封測事故實測 1,070 次寫入撞上免費上限 1,000,
// 690 個檔逐檔萃取上傳、daemon 這一半完全沒有節奏。這裡驗四件事:
// 1. 一輪掃到的多個事件依 mtime 新到舊處理(今天寫的最優先)。
// 2. 兩次觸發雲端之間有節流間隔。
// 3. 單輪上限存在——巨量積壓不會一次湧完。
// 4. 已處理的事件會立刻落地 manifest,process 被殺掉重開也不會重做。
package collector
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// init:測試環境預設把節流歸零,不然每個既有測試都要多等 700ms×N,拖慢整個套件。
// 需要驗證節流本身的測試(見下)自行 save/restore 成一個很小的非零值。
func init() {
directPaceInterval = 0
}
// ── 1) sortEventsNewestFirst ─────────────────────────────────────────────
func TestSortEventsNewestFirst_NewestGoesFirst(t *testing.T) {
root := t.TempDir()
old := baseTime
mid := baseTime.Add(time.Hour)
newest := baseTime.Add(2 * time.Hour)
writeFile(t, root, "old.md", "old", old)
writeFile(t, root, "mid.md", "mid", mid)
writeFile(t, root, "newest.md", "newest", newest)
events := []Event{
{Type: "added", Path: "old.md"},
{Type: "added", Path: "newest.md"},
{Type: "added", Path: "mid.md"},
}
got := sortEventsNewestFirst(root, events)
want := []string{"newest.md", "mid.md", "old.md"}
for i, w := range want {
if got[i].Path != w {
t.Fatalf("順序=%vwant %v", pathsOf(got), want)
}
}
}
func pathsOf(evs []Event) []string {
out := make([]string, len(evs))
for i, e := range evs {
out[i] = e.Path
}
return out
}
// removed 事件沒有 mtime 可排,一律排在 added/modified/renamed 之後(下架不急)。
func TestSortEventsNewestFirst_RemovedGoesLast(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "new.md", "x", baseTime.Add(time.Hour))
events := []Event{
{Type: "removed", Path: "gone.md"},
{Type: "added", Path: "new.md"},
}
got := sortEventsNewestFirst(root, events)
if got[0].Path != "new.md" || got[1].Path != "gone.md" {
t.Fatalf("順序=%v", pathsOf(got))
}
}
// ── 2) 節流間隔 ────────────────────────────────────────────────────────────
func TestPace_RespectsInterval(t *testing.T) {
old := directPaceInterval
directPaceInterval = 30 * time.Millisecond
defer func() { directPaceInterval = old }()
start := time.Now()
pace()
pace()
elapsed := time.Since(start)
if elapsed < 55*time.Millisecond { // 兩次 pace,留一點餘裕
t.Fatalf("兩次 pace() 只花了 %v,節流間隔沒有生效", elapsed)
}
}
// ── 端到端:積壓一次湧上去 vs 有節奏 ─────────────────────────────────────────
//
// 模擬「大量積壓」場景:8 個檔、單輪上限設 3——驗證①一輪只處理 3 個
// ②被延後的檔案有交代(不是安靜消失)③下一輪接著處理下一批、依然新到舊。
func TestDirect_LargeBacklog_ProcessedInNewestFirstBatches(t *testing.T) {
root := t.TempDir()
// 8 個檔,mtime 依檔名反向遞增(a 最舊,h 最新)
names := []string{"a", "b", "c", "d", "e", "f", "g", "h"}
for i, n := range names {
writeFile(t, root, n+".md", "內容 "+n, baseTime.Add(time.Duration(i)*time.Minute))
}
var mu sync.Mutex
var order []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// InkStoneCo#44:資料夾樹走 portal 登記端點,body 沒有 page_name。
// 不先擋掉,下面那行型別斷言會 panic,而測試會變成掛住 8 分鐘(見該 helper 的說明)。
if answeredFolderTreePost(w, r) {
return
}
body, _ := io.ReadAll(r.Body)
var m map[string]any
_ = json.Unmarshal(body, &m)
mu.Lock()
order = append(order, m["page_name"].(string))
mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
defer srv.Close()
defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
// 這條測試只在意「觸發了幾次、依什麼順序送到假 cypher」,卡片內容固定即可。
_ = json.NewEncoder(w).Encode(map[string]any{
"candidates": []map[string]any{{
"content": map[string]any{"parts": []map[string]any{{"text": cardFixture("卡", "測試")}}},
}},
})
})()
cfg := &DirectConfig{
WatchFolders: []string{root},
Manifest: filepath.Join(t.TempDir(), "m.json"),
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
MaxEventsPerRun: 3,
}
// 第一輪:只處理 3 個(上限),且依 mtime 新到舊=h, g, f
results1, exit1, _ := RunDirectOnce(cfg, false)
if exit1 != 0 {
t.Fatalf("exit=%d results=%+v", exit1, results1)
}
ingested1 := ingestedPaths(results1)
if len(ingested1) != 3 {
t.Fatalf("第一輪應處理 3 個,got %d: %+v", len(ingested1), results1)
}
wantFirst := []string{"h.md", "g.md", "f.md"}
for i, w := range wantFirst {
if ingested1[i] != w {
t.Fatalf("第一輪順序=%vwant %v(新改的檔要優先)", ingested1, wantFirst)
}
}
// 有交代被延後了幾筆,不是安靜消失
if !hasDeferredNotice(results1) {
t.Errorf("應該要交代還有事件被延後,results=%+v", results1)
}
// 第二輪:接著處理下一批 3 個(e, d, c)
results2, exit2, _ := RunDirectOnce(cfg, false)
if exit2 != 0 {
t.Fatalf("exit=%d results=%+v", exit2, results2)
}
ingested2 := ingestedPaths(results2)
wantSecond := []string{"e.md", "d.md", "c.md"}
if len(ingested2) != 3 {
t.Fatalf("第二輪應處理 3 個,got %d: %+v", len(ingested2), results2)
}
for i, w := range wantSecond {
if ingested2[i] != w {
t.Fatalf("第二輪順序=%vwant %v", ingested2, wantSecond)
}
}
// 第三輪:剩下 2 個(b, a)全部處理完,沒有再延後
results3, exit3, _ := RunDirectOnce(cfg, false)
if exit3 != 0 {
t.Fatalf("exit=%d", exit3)
}
ingested3 := ingestedPaths(results3)
if len(ingested3) != 2 || ingested3[0] != "b.md" || ingested3[1] != "a.md" {
t.Fatalf("第三輪=%vwant [b.md a.md]", ingested3)
}
if hasDeferredNotice(results3) {
t.Error("全部處理完不該再有延後交代")
}
// 全部 8 個都真的送到雲端了(沒有一個被永久漏掉);總覽卡(結構先行)另計
mu.Lock()
defer mu.Unlock()
var contentCards []string
for _, pn := range order {
if !strings.HasPrefix(pn, "資料夾總覽") {
contentCards = append(contentCards, pn)
}
}
if len(contentCards) != 8 {
t.Fatalf("雲端總共應收到 8 次卡片,got %d: %v", len(contentCards), order)
}
}
// ── 4) 退避中的檔案不該永久佔滿單輪名額(arcrun-rag#104 comment 4480t217)──
//
// 背景:leo 實測 leo21c 帳號的積壓「1691→1880→1936 筆從不減少」,
// 「已經好久沒有加過任何檔案,哪來的這些筆數?那就是之前卡住的,就是你要解決的問題」
// 「佇列就是問題本身」。
//
// 根因:舊版把 perRunCap 套在「排序後的原始清單」上。mtime 最新的幾個檔如果持續
// 失敗(進入退避),它們不會因為在退避就往後排,於是每一輪都繼續佔著最前面的
// perRunCap 個名額——即使這一輪根本不會被嘗試,只是被跳過。排在它們後面、
// 從沒被嘗試過的健康檔案因此永遠排不到,不管跑幾輪都一樣。
//
// 這個測試重現該情境:3 個 mtime 最新的檔一直失敗(模擬持續性錯誤,例如票上量到的
// 雲端 subrequest 上限或萃取回傳格式錯誤),5 個 mtime較舊、原本會成功的健康檔案
// 排在它們後面。單輪上限=3。
//
// - 第一輪:全部檔案都還沒失敗過(FailCount=0),cap 選中 mtime 最新的 3 個
// (也就是那 3 個會一直失敗的檔),全部失敗,記下退避(下次重試在 60 秒後)。
// - 第二輪(緊接著呼叫,真實時間遠不到 60 秒):那 3 個檔仍在退避中。
// 舊版行為:cap 依然套在原始排序上,選中的還是同一批退避中的檔案 ⇒
// 這一輪 0 個健康檔案被嘗試,健康檔案永遠排不到。
// 修好後的行為:退避中的檔案被分流到 waiting、不佔 ready 的名額,
// cap 改套用在 ready 上 ⇒ 健康檔案的前 3 名遞補上來,這一輪就會被嘗試並成功。
func TestDirect_StarvedBacklog_HealthyFilesEventuallyGetATurn(t *testing.T) {
root := t.TempDir()
// 3 個「一直失敗」的檔,mtime 最新(若 bug 還在,會永久佔滿 cap)。
blockers := []string{"blocker-a", "blocker-b", "blocker-c"}
for i, n := range blockers {
writeFile(t, root, n+".md", "持續失敗的內容 "+n, baseTime.Add(time.Duration(10+i)*time.Minute))
}
// 5 個「健康」的檔,mtime 較舊(排在後面,理應遞補上來)。
healthy := []string{"h5", "h4", "h3", "h2", "h1"}
for i, n := range healthy {
writeFile(t, root, n+".md", "健康內容 "+n, baseTime.Add(time.Duration(4-i)*time.Minute))
}
failingPages := map[string]bool{"blocker-a": true, "blocker-b": true, "blocker-c": true}
var mu sync.Mutex
var succeededPages []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if answeredFolderTreePost(w, r) {
return
}
body, _ := io.ReadAll(r.Body)
var m map[string]any
_ = json.Unmarshal(body, &m)
pageName, _ := m["page_name"].(string)
if strings.HasPrefix(pageName, "資料夾總覽") {
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
return
}
if failingPages[pageName] {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"success":false,"error":"boom"}`))
return
}
mu.Lock()
succeededPages = append(succeededPages, pageName)
mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
defer srv.Close()
defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"candidates": []map[string]any{{
"content": map[string]any{"parts": []map[string]any{{"text": cardFixture("卡", "測試")}}},
}},
})
})()
cfg := &DirectConfig{
WatchFolders: []string{root},
Manifest: filepath.Join(t.TempDir(), "m.json"),
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
MaxEventsPerRun: 3,
}
// 第一輪:8 個檔都還沒失敗過,cap 選中 mtime 最新的 3 個(blocker-a/b/c),全部失敗。
results1, _, _ := RunDirectOnce(cfg, false)
if got := ingestedPaths(results1); len(got) != 0 {
t.Fatalf("第一輪不該有任何成功(cap 選中的 3 個全會失敗),got %v", got)
}
var failedCount int
for _, r := range results1 {
if r.Status == "failed" {
failedCount++
}
}
if failedCount != 3 {
t.Fatalf("第一輪應該有 3 筆真的被嘗試且失敗(blocker-a/b/c),got %d%+v", failedCount, results1)
}
// 第二輪:緊接著呼叫(真實時間遠不到 60 秒退避窗口)。blocker-a/b/c 仍在退避中。
results2, _, _ := RunDirectOnce(cfg, false)
ingested2 := ingestedPaths(results2)
if len(ingested2) != 3 {
t.Fatalf("🔴 第二輪應該有 3 個健康檔案遞補上來被嘗試並成功——"+
"如果這裡是 0,代表退避中的 blocker-a/b/c 又佔滿了本輪名額,"+
"健康檔案永遠排不到(這正是 leo 實測「佇列從不減少」的那個 bug)。got %d%+v",
len(ingested2), results2)
}
wantSecond := []string{"h5.md", "h4.md", "h3.md"}
for i, w := range wantSecond {
if ingested2[i] != w {
t.Fatalf("第二輪順序=%vwant %v(健康檔案仍照 mtime 新到舊遞補)", ingested2, wantSecond)
}
}
// blocker-a/b/c 這一輪不該再被真的嘗試(還在退避中)——它們只會以「skipped」出現。
for _, r := range results2 {
if r.Path == "blocker-a.md" || r.Path == "blocker-b.md" || r.Path == "blocker-c.md" {
if r.Status != "skipped" {
t.Fatalf("退避中的 %s 這一輪不該被真的嘗試,got status=%s", r.Path, r.Status)
}
}
}
mu.Lock()
defer mu.Unlock()
if len(succeededPages) != 3 {
t.Fatalf("雲端應該收到 3 筆健康卡片,got %d: %v", len(succeededPages), succeededPages)
}
}
func ingestedPaths(results []DirectResult) []string {
var out []string
for _, r := range results {
// 只數「檔案事件」。總覽卡(inventory)與資料夾樹(folder_treeInkStoneCo#44
// 都是每輪的結構回報,不是使用者的檔案——同 countsAsDocument 的那條線。
if r.Status == "ingested" && r.Type != "inventory" && r.Type != "folder_tree" {
out = append(out, r.Path)
}
}
return out
}
func hasDeferredNotice(results []DirectResult) bool {
for _, r := range results {
if r.Type == "info" && strings.Contains(r.Error, "已排入佇列") {
return true
}
}
return false
}
// ── 3) 斷點續傳:process 被殺掉重開,不從頭來 ────────────────────────────────
//
// 用「單輪上限=1」模擬中斷:一次只做一件事就等同「這一刻的程序被殺掉」,
// 用全新的 RunDirectOnce 呼叫(不共用任何記憶體狀態,manifest 完全從磁碟重讀)
// 模擬「程序重開」。驗證:已經成功的不會被重送,且每一步都真的落地磁碟
// (不是等到全部做完才存檔)。
func TestDirect_ResumeAfterInterruption(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "one.md", "內容一", baseTime)
writeFile(t, root, "two.md", "內容二", baseTime.Add(time.Minute))
writeFile(t, root, "three.md", "內容三", baseTime.Add(2*time.Minute))
var mu sync.Mutex
var posted []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// InkStoneCo#44:資料夾樹的 body 沒有 path ⇒ 不擋掉的話會被記成一筆空字串,
// 本測就會看到「送了 4 次」而誤判斷點續傳失效。
if answeredFolderTreePost(w, r) {
return
}
body, _ := io.ReadAll(r.Body)
var m map[string]any
_ = json.Unmarshal(body, &m)
mu.Lock()
if p, _ := m["path"].(string); !strings.HasPrefix(p, ".arcrun-rag/") {
posted = append(posted, p) // 結構先行:總覽卡(合成路徑)另計
}
mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
defer srv.Close()
defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"candidates": []map[string]any{{
"content": map[string]any{"parts": []map[string]any{{"text": cardFixture("卡", "測試")}}},
}},
})
})()
manifestPath := filepath.Join(t.TempDir(), "m.json")
newCfg := func() *DirectConfig {
return &DirectConfig{
WatchFolders: []string{root},
Manifest: manifestPath,
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
MaxEventsPerRun: 1, // 模擬「只做一件事就被打斷」
}
}
// 「跑到一半」:只完成第一批(three.md,最新)
if _, exit, _ := RunDirectOnce(newCfg(), false); exit != 0 {
t.Fatal("第一批失敗")
}
// 直接讀磁碟上的 manifest(不透過任何記憶體物件)——驗證真的已經落地,
// 不是要等三批都跑完才存檔。
absRoot, _ := filepath.Abs(root)
mp := newCfg().manifestPathFor(absRoot)
m1, err := LoadManifest(mp, absRoot)
if err != nil {
t.Fatalf("讀 manifest 失敗:%v", err)
}
if m1.Entries["three.md"].IngestedHash == "" {
t.Fatal("「跑到一半」之後,已完成的那份應該已經落地 manifest(斷點續傳的前提)")
}
if m1.Entries["two.md"].IngestedHash != "" || m1.Entries["one.md"].IngestedHash != "" {
t.Fatal("還沒輪到的不該被誤標成已完成")
}
// 「重開程序」:全新呼叫,模擬 process 重啟——應該接著做 two.md,不是重做 three.md
if _, exit, _ := RunDirectOnce(newCfg(), false); exit != 0 {
t.Fatal("第二批失敗")
}
if _, exit, _ := RunDirectOnce(newCfg(), false); exit != 0 {
t.Fatal("第三批失敗")
}
mu.Lock()
defer mu.Unlock()
if len(posted) != 3 {
t.Fatalf("三個檔應該總共只被送出 3 次(不重送已完成的),got %d: %v", len(posted), posted)
}
seen := map[string]bool{}
for _, p := range posted {
if seen[p] {
t.Fatalf("%q 被重複送出——斷點續傳失效,重開後從頭來了", p)
}
seen[p] = true
}
// 第四輪:三個都做完了,不該再有任何事件
results4, _, _ := RunDirectOnce(newCfg(), false)
if len(results4) != 0 {
t.Fatalf("全部完成後應該零事件:%+v", results4)
}
}
// removed 事件在「已放回但下架失敗」時不能被提早存檔清掉——否則下一輪兩邊都找不到
// 這個路徑,永遠不會再重試下架。驗證:POST 失敗時,manifest 磁碟版本仍保留該筆,
// 下一輪會重新產生 removed 事件。
func TestDirect_RemovedRetriesOnFailureEvenWithIncrementalSave(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "keep.md", "留著", baseTime)
writeFile(t, root, "gone.md", "要刪的", baseTime.Add(time.Minute))
var failRemoved = true
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "rag_takedown_direct") && failRemoved {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"boom"}`))
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
defer srv.Close()
defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"candidates": []map[string]any{{
"content": map[string]any{"parts": []map[string]any{{"text": cardFixture("卡", "測試")}}},
}},
})
})()
cfg := &DirectConfig{
WatchFolders: []string{root},
Manifest: filepath.Join(t.TempDir(), "m.json"),
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
CardIngestWF: "rag_ingest_card", RemovedWF: "rag_takedown_direct",
MaxRemoved: 1.0,
}
if _, exit, _ := RunDirectOnce(cfg, false); exit != 0 {
t.Fatal("第一輪(兩份都新增)失敗")
}
if err := os.Remove(filepath.Join(root, "gone.md")); err != nil {
t.Fatal(err)
}
// 第二輪:下架 POST 會失敗(且這輪也會處理 keep.md 的事件?不會,keep.md 已 ingest 過、
// 沒有變化,不會再產生事件——但仍會呼叫 saveManifest() 若有其他觸發)。
results, exit, _ := RunDirectOnce(cfg, false)
if exit != 1 {
t.Fatalf("下架失敗應該 exit=1got %d results=%+v", exit, results)
}
absRoot, _ := filepath.Abs(root)
mp := cfg.manifestPathFor(absRoot)
m, err := LoadManifest(mp, absRoot)
if err != nil {
t.Fatal(err)
}
if _, ok := m.Entries["gone.md"]; !ok {
t.Fatal("下架失敗時,manifest 不該把這個路徑永久丟掉——否則永遠不會再重試下架")
}
// 第三輪:換伺服器成功 → 應該重新補發並成功下架
failRemoved = false
results3, exit3, _ := RunDirectOnce(cfg, false)
if exit3 != 0 {
t.Fatalf("第三輪應成功下架:exit=%d results=%+v", exit3, results3)
}
var removedOK bool
for _, r := range results3 {
if r.Type == "removed" && r.Status == "removed" && r.Path == "gone.md" {
removedOK = true
}
}
if !removedOK {
t.Fatalf("第三輪應該重試下架成功:%+v", results3)
}
m2, err := LoadManifest(mp, absRoot)
if err != nil {
t.Fatal(err)
}
if _, ok := m2.Entries["gone.md"]; ok {
t.Fatal("下架成功後這個路徑應該真的從 manifest 消失")
}
}