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 层;注释里已写明「比常量」与「比组装后生效值」的区别(防后人重蹈)。
This commit is contained in:
AskaEth committed 2026-10-13 23:09:19 +08:00
1 parent 202cd8535e
commit 42a07cb454
4 files changed
+259 -6

No files matched your search

+14 -2
View File
@@ -18,9 +18,18 @@ const (
defaultReviewQueueSize = 256
// defaultReviewScanLimit 是每次审查在同一分区内最多比对的既有条目数。
defaultReviewScanLimit = 200
// defaultReviewTimeout 是单条审查的墙钟上限。
// defaultReviewTimeout 是单条审查的墙钟上限,**也是全链的唯一真源**
// (切片 M16,2026-10-13):配置层 `config.MemoryReviewConfig.TimeoutSec`
// 的 env tag **刻意不带** `default:`,没配时读出 0(= 没配),
// 由本文件下方的 normalizeReviewConfig 落地成这个常量。
//
// 🔴 2026-10-13 由 20s 抬到 90s。依据是**实测的链长**,不是感觉:
// 🔴 为什么要把真源搬到这里:原先 tag 上写着 `default:"20"`,而 20s 又等于
// 当时的本常量 ⇒ 看着「两处一致」,实际是 **env 层永远产出一个非零值**,
// 「≤0 才回落默认」这条归一化**永远是死代码** ⇒ 本常量改成 90s 后生产启动行
// 仍是 `timeout=20s`(塔在部署后读真实启动行抓到)。所以别再往 env tag 上写
// 字面默认值:那等于把「默认值」搬回配置层,本常量又一次变成装饰。
//
// 取值推导如下(2026-10-13 由 20s 抬到 90s),依据是**实测的链长**,不是感觉:
// 单条审查的召回链是「新条 1 + ScanLimit 200 = 201 条文本」,而 embedding 侧
// 一次请求只吃 DefaultBatchLimit 条(改前 4、改后 8,见 embedding/budget.go 的
// 文件头实测)⇒ 链长 = ⌈201/批次⌉ 个请求:
@@ -162,6 +171,9 @@ func normalizeReviewConfig(cfg ReviewConfig) ReviewConfig {
cfg.ScanLimit = defaultReviewScanLimit
}
if cfg.Timeout <= 0 {
// 单条审查预算的**唯一**落地处(切片 M16):配置层的 env tag 不带默认值,
// 所以「没配」与「配了 ≤0」都走这一行。全链的生效值就是这一行的结果 ——
// 启动行 `memory ingest review started … timeout=` 打印的也正是它。
cfg.Timeout = defaultReviewTimeout
}
if cfg.Threshold <= 0 {
@@ -1,9 +1,15 @@
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"
)
@@ -26,6 +32,29 @@ const (
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 单次调用的独立超时必须严格小于入库前审查的单条总预算;
@@ -43,6 +72,135 @@ const (
// 预算吃掉都会被它抓到。旧值 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()