package memory import ( "bytes" "context" "log/slog" "os" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" ) // measuredReviewChain* 是**实测参照值**(2026-10-13,本机空闲;正文取自活库 // cyrene/user 分区 201 条,均长 34.7 汉字;完整曲线与复跑命令见 // internal/runtime/embedding/budget.go 的文件头)。它们只用于本文件的断言 —— // 与 embedding 侧 api_url_test.go 的 reviewBudgetRef 同一手法:把「拿实测数字做的 // 决定」留成可复核的常量,而不是消失在注释里。 // // 链的形状 = ⌈(ScanLimit + 1) / embedding.DefaultBatchLimit⌉ 个请求: // // 改前 4 条/片:51 个请求 ⇒ 墙钟 23.070 / 21.860 s // 改后 8 条/片:26 个请求 ⇒ 墙钟 21.435 / 21.597 s // // loaded 那一档取自 M14 只读勘测报告(同一条链与整理链并发时测得 56.7s)—— // 它是按**改前**的 4 条/片形状测的;链路墙钟由「条数 × 每条成本」主导、与批次 // 形状几乎无关,因此拿它当改后代码的最坏参照是**偏保守**的一侧。 const ( measuredReviewChainIdle = 22 * time.Second measuredReviewChainLoaded = 57 * time.Second ) // reviewTimeoutEnvUnset 让 MEMORY_REVIEW_TIMEOUT_SEC 在本次用例中**真的未设置**。 // // 为什么不用 t.Setenv(key, ""):空串在 decode.go 的 lookupRaw 里**照样命中 env // 分支**(`os.LookupEnv` 对空值返回 ok=true)⇒ 配置层读出 0,于是**看不见 env tag // 上的字面默认值** —— 而那正是本切片的回归面(tag 上再加回 `default:"20"` 必须 // 让下面的生效值用例变红)。只有「未设置」才会走到 tag 默认那一档。 // (用 os.Unsetenv 而不是 t.Setenv 是刻意的:Go 没有「置回未设置」的测试 API, // 因此这里自己存旧值 + 在 t.Cleanup 里还原。本用例不并行,故无竞争。) func reviewTimeoutEnvUnset(t *testing.T) { t.Helper() old, had := os.LookupEnv("MEMORY_REVIEW_TIMEOUT_SEC") t.Cleanup(func() { if had { _ = os.Setenv("MEMORY_REVIEW_TIMEOUT_SEC", old) return } _ = os.Unsetenv("MEMORY_REVIEW_TIMEOUT_SEC") }) if err := os.Unsetenv("MEMORY_REVIEW_TIMEOUT_SEC"); err != nil { t.Fatalf("unset MEMORY_REVIEW_TIMEOUT_SEC: %v", err) } } // TestEmbeddingTimeoutFitsReviewBudget 钉住两条**跨包**口径: // // 1. embedding 单次调用的独立超时必须严格小于入库前审查的单条总预算; // 2. 反过来,审查的单条总预算必须严格大于「实测最坏召回链长 + T1 自己的超时」。 // // 第 1 条的理由(2026-10-07 实测的故障形态):端点主机写成 localhost → 先试 IPv6 // ::1 → 连接等系统超时(Windows 上约 21s),一次 embedding 失败正好吃光预算, // 整条审查超时(`memory/review: context deadline exceeded`),排在后面的 T1 // 灰区判定连执行机会都没有。地址规范化(embedding.normalizeEndpointURL)治的是根, // 第 1 条守的是「就算再有新的卡死来源,也得先把预算留给后续步骤」。 // // 第 2 条是本次修复(2026-10-13)的核心断言,**变异自证**:把 defaultReviewTimeout // 调回 20s ⇒ 这一条必红。它守的是「预算 ≥ 链长 + T1」这条不可让步的下界 —— // 链长随 ScanLimit / 批上限漂移,T1 超时随 defaultT1Timeout 漂移,任一侧动到把 // 预算吃掉都会被它抓到。旧值 20s 正是踩在这条线上的:实测链长 21~23s(空闲), // 于是每一条候选满额的审查都必然超时,7 天里 2274 条审查因此降级为空 recall_mode。 // // ⚠️ 本条比的是**本包的常量**,因此**看不见跨包漂移**(env 层另有一个默认值时它 // 照样绿)—— 那条防线在下面的 TestReviewTimeoutEffectiveValueIsTheOnlyDefault。 // // embedding 侧同一口径也有一条钉值测试(api_url_test.go),两边互为护栏。 func TestEmbeddingTimeoutFitsReviewBudget(t *testing.T) { t.Parallel() if embedding.DefaultRequestTimeout <= 0 { t.Fatalf("embedding.DefaultRequestTimeout = %s, 必须为正", embedding.DefaultRequestTimeout) } if embedding.DefaultRequestTimeout >= defaultReviewTimeout { t.Fatalf("embedding 单次超时 %s 必须严格小于审查总预算 %s", embedding.DefaultRequestTimeout, defaultReviewTimeout) } // 第 2 条:预算必须盖得住实测最坏链长,还要留得下 T1 那一步自己的超时 // (T1 在召回**之后**跑,同一条 ctx)。空闲余量倍数由 budget.go 的注释列明, // 这里只钉住「盖得住」这条下界。 worst := measuredReviewChainLoaded + defaultT1Timeout if defaultReviewTimeout <= worst { t.Fatalf("审查单条预算 %s 必须严格大于「实测最坏召回链 %s + T1 超时 %s = %s」—— "+ "否则链还没跑完预算就用光(2026-10-13 的 2274 条超时降级正是这条)", defaultReviewTimeout, measuredReviewChainLoaded, defaultT1Timeout, worst) } if measuredReviewChainIdle >= defaultReviewTimeout { t.Fatalf("空闲实测链长 %s 已不小于审查预算 %s(余量必须为正)", measuredReviewChainIdle, defaultReviewTimeout) } } // TestReviewTimeoutExplicitEnvValueWins 钉住另一半口径(M16):`.env` / 进程环境 // **显式写了**这个键时,它必须**原样到达生效值**(不被 memory 侧的默认值顶掉)。 // // 与 TestReviewTimeoutEffectiveValueIsTheOnlyDefault 合起来覆盖两个情形: // // 没写 ⇒ 生效值 = 90s(唯一真源) // 写了 45 ⇒ 生效值 = 45s(显式优先,且**不做任何「保底抬到 90」的偷偷加码**) // // 后者同样要钉:运维为了让某台机器更早放弃而调低预算,是**显式选择**, // 代码不许自作主张改回去(要改回去也该改配置,而不是让配置失效)。 func TestReviewTimeoutExplicitEnvValueWins(t *testing.T) { t.Setenv("MEMORY_REVIEW_TIMEOUT_SEC", "45") cfg, err := config.LoadMemoryReviewConfig("") if err != nil { t.Fatalf("LoadMemoryReviewConfig: %v", err) } if cfg.TimeoutSec != 45 { t.Fatalf("env 层的 MEMORY_REVIEW_TIMEOUT_SEC = %d, want 45", cfg.TimeoutSec) } reviewer, err := NewPreInsertReviewer(ReviewConfig{ Enabled: true, Threshold: 0.8, Timeout: time.Duration(cfg.TimeoutSec) * time.Second, }, ReviewDeps{Store: newTestStore(t), Log: testLogger()}) if err != nil { t.Fatalf("NewPreInsertReviewer: %v", err) } if got := reviewer.cfg.Timeout; got != 45*time.Second { t.Fatalf("显式配置的 45s 没到达生效值:%s", got) } } // TestReviewTimeoutEffectiveValueIsTheOnlyDefault 是**全链生效值**的钉值测试 // (切片 M16,2026-10-13)。它刻意走**完整组装路径**,一层都不绕过: // // config.LoadMemoryReviewConfig("") ← env 层("env 档没写这个键" 的形态) // → ReviewConfig{Timeout: 秒 × time.Second} ← 组合根 cmd/cyrene 那一行 // → NewPreInsertReviewer → normalizeReviewConfig ← 唯一落地处 // → r.cfg.Timeout(= 启动行打印的 timeout=…,塔复验读的就是它) // // 🔴 它为什么必须存在(而 TestEmbeddingTimeoutFitsReviewBudget 拦不住这条): // 那一条比的是**本包的常量** `defaultReviewTimeout`,它从来看不见 env 层。 // 而线上坏掉的形态恰好发生在**另一个包**:config 侧的 env tag 带着 // `default:"20"` ⇒ env 层永远返回 20 ⇒ 本包「≤0 才回落默认」的归一化成了**死代码** // ⇒ 常量的 90s **一次也没生效过**。两处都是「90 > 实测链长 + T1」的绿色断言, // 生产启动行却是 `timeout=20s` —— 常量比对与生效值比对是**两件事**: // // 比常量 = 「我打算用多少」(单包自洽,跨包漂移看不见) // 比生效值 = 「进程实际会拿到多少」(跨包漂移立刻见红) // // 后者才拦得住「同一个值两处副本、只有一处被改」(本仓 `reviewBudgetRef` 是前科)。 // 变异自证:把 config 侧 tag 的 `default:"20"` 加回去 ⇒ 本用例必红(下方 // cfg.TimeoutSec != 0 那条会先报出来,随后生效值 20s 也会小于下界)。 // // 不许并行:下面要把 env 档置成**真的未设置**(见 reviewTimeoutEnvUnset 的理由)。 func TestReviewTimeoutEffectiveValueIsTheOnlyDefault(t *testing.T) { reviewTimeoutEnvUnset(t) cfg, err := config.LoadMemoryReviewConfig("") if err != nil { t.Fatalf("LoadMemoryReviewConfig: %v", err) } if cfg.TimeoutSec != 0 { // 用 Errorf 而不是 Fatalf:这样**两条防线都会报出来** —— 「配置层又长了 // 一个字面默认值」与「组装后的生效值盖不住实测链长」。变异成修前形态时 // 两条同时见红,读的人不必猜是哪一层坏掉。 t.Errorf("env 层的 MEMORY_REVIEW_TIMEOUT_SEC = %d, want 0(未配置)—— "+ "非零意味着配置层又长出了字面默认值,memory 侧的 %s 会再次成为死代码(M16 的形态)", cfg.TimeoutSec, defaultReviewTimeout) } // 逐字复刻组合根把配置层搬进 ReviewConfig 的那一行 // (cmd/cyrene/wire_memory_review.go:`Timeout: time.Duration(cfg.TimeoutSec) * time.Second`)。 var startupLine bytes.Buffer reviewer, err := NewPreInsertReviewer(ReviewConfig{ Enabled: true, Threshold: 0.8, Timeout: time.Duration(cfg.TimeoutSec) * time.Second, }, ReviewDeps{ Store: newTestStore(t), Log: slog.New(slog.NewTextHandler(&startupLine, nil)), }) if err != nil { t.Fatalf("NewPreInsertReviewer: %v", err) } effective := reviewer.cfg.Timeout // 生效值证据:塔重启后拿它对照生产启动行 // `msg="memory ingest review started" … timeout=…`。 t.Logf("组装后 timeout = %s(env 层未配置;启动行会原样打印它)", effective) worst := measuredReviewChainLoaded + defaultT1Timeout if effective != defaultReviewTimeout { // 同样是 Errorf:与上面那条一起定位「哪一层把默认值改掉了」。 t.Errorf("生效值 %s != 唯一真源 defaultReviewTimeout %s(配置层与 memory 层漂移了)", effective, defaultReviewTimeout) } if effective <= worst { t.Fatalf("组装后的单条审查预算 %s 必须严格大于「实测最坏召回链 %s + T1 子超时 %s = %s」—— "+ "否则链还没跑完预算就用光(生产 20s 时 7 天里 2274 条审查因此降级为空 recall_mode)", effective, measuredReviewChainLoaded, defaultT1Timeout, worst) } if measuredReviewChainIdle >= effective { t.Fatalf("空闲实测链长 %s 已不小于生效预算 %s(余量必须为正)", measuredReviewChainIdle, effective) } // 启动行是塔唯一的复验面:它必须真的打印这个生效值。把 Start/Stop 跑起来, // 因为「日志里写的是 r.cfg.Timeout 还是另一个副本」本身就是要钉住的事。 ctx, cancel := context.WithCancel(context.Background()) reviewer.Start(ctx) stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second) defer stopCancel() if err := reviewer.Stop(stopCtx); err != nil { t.Fatalf("Stop: %v", err) } cancel() if line := startupLine.String(); !strings.Contains(line, "memory ingest review started") || !strings.Contains(line, "timeout="+effective.String()) { t.Fatalf("启动行没有打印生效值 timeout=%s:%q", effective, line) } }