Files
Cyrene_v2/internal/runtime/memory/preinsert_recall_test.go
T
AskaEth 18ad2b89ab test(memory): 两条审查超时用例搬到正确位置(复审 p2-1,纯搬运)
复审指出 TestEmbeddingTimeoutFitsReviewBudget 的整段文档被夹在「它描述的
函数」与「两条新用例」之间成了孤儿文档,而该函数自己的文档只剩一句、且在
文件里出现两次(一次属于那段孤儿注释,一次紧贴真正的函数)。

只搬不改语义:把 TestReviewTimeoutExplicitEnvValueWins 与
TestReviewTimeoutEffectiveValueIsTheOnlyDefault 整体挪到
TestEmbeddingTimeoutFitsReviewBudget 之后,删掉重复的那句文案与多余的空行,
让那 21 行文档重新紧贴它描述的函数(与 dev 版的相对位置逐字相同)。

纯搬运的证据:拿「排序后的行集合」比对搬运前后(前者取自 HEAD)——
`diff <(git show HEAD:internal/runtime/memory/preinsert_recall_test.go | sort) <(sort internal/runtime/memory/preinsert_recall_test.go)`
只差两行:一句重复文案 + 一个空行,其余逐行一致。

顺带按「主张与落点」把本文件自查了一遍:每段注释都紧贴它描述的符号
(实测文档与函数之间没有空行:`awk '/^\/\// {doc=NR} /^func / {if (NR-doc!=1) print NR}'`),
且文中的位置指代(「那条防线在下面的 …」「下方 cfg.TimeoutSec != 0 那条」)
在搬运后依然成立。
2026-10-13 23:21:25 +08:00

227 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),两边互为护栏。
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)
}
}