Files
Cyrene_v2/internal/runtime/memory/preinsert_recall_test.go
T
AskaEth 42a07cb454 fix(config): 单条入库审查的墙钟上限收敛到唯一真源(M16)
生产启动行仍是 timeout=20s —— M15 把 memory 包的 defaultReviewTimeout
抬到 90s 的修复**从未生效**:config 层的 env tag 自己带着 default:"20",
env 层永远产出非零值,于是 memory 侧「≤0 才回落默认」的归一化成了死代码。

修法(二选一中的「① 让 memory 侧成为唯一真源」):去掉 config 侧
MEMORY_REVIEW_TIMEOUT_SEC 的字面默认值,未配置时读出 0 = 「没配」,
由 normalizeReviewConfig 单点落地 90s。

生效值:
  .env / env 没写这个键 ⇒ 90s(唯一真源)
  写了正数            ⇒ 原样生效(不做任何「保底抬到 90」的加码)
  写了 0 / 负数       ⇒ 90s(与项目其它旋钮同一口径:≤0 = 用默认)

新增测试(走真实组装路径,不绕过任何一层):
  config.LoadMemoryReviewConfig → ReviewConfig.Timeout →
  NewPreInsertReviewer → normalizeReviewConfig → 生效值(= 启动行打印的 timeout=)
断言生效值严格大于「实测最坏召回链 57s + T1 子超时 8s」,并断言启动行打印它。
变异自证(修前红):把 tag 的 default:"20" 加回去 ⇒ 报出
  「组装后 timeout = 20s」+「生效值 20s != 唯一真源 1m30s」

原有 TestEmbeddingTimeoutFitsReviewBudget 拦不住这条,因为它比的是**本包常量**,
看不见 env 层;注释里已写明「比常量」与「比组装后生效值」的区别(防后人重蹈)。
2026-10-13 23:09:19 +08:00

229 lines
11 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.
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),两边互为护栏。
// 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)
}
}
// 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)
}
}