Files
Cyrene_v2/internal/runtime/memory/tidy_dryrun.go
T
AskaEth 6c30ca540a fix(memory): 冷启动放宽改为一次性 + 试算轮区分「没候选」与「向量不可用」
review round1 的 p1/p2 收口(commit 5967272 之后)。

p1:旧实现 `warmed` 只在 2xx 之后才翻 ⇒ 注释里写的「只在第一次成功响应之前」
实际是「服务坏着的全部时间」。reviewer 实测「接受连接但永不回包」时**每次**调用
都等满 30s(fail-fast 变 fail-slow):审查 20s 预算被一次吃光、索引写入拖成
8×30s、开机探针阻塞 30s。

- `warmupLeft` 改为**一次性许可**(CompareAndSwap 取走即失效,成败都算花掉)
  ⇒ 挂死时只有第一个请求慢一次,此后一律 DefaultRequestTimeout。
- `warmupTimeout` 30s → 8s:与常态 5s 同一量级(≤2×),且严格小于审查总预算
  20s(一次冷启动不许吃光整轮);两条界都由 TestDefaultRequestTimeout 守住。
- 放宽被花掉且没成功时留一条 WARN(事后可复核「第一个请求」这个判据)。
- 更正两处自述:api.go 的「不会被 30s 拖住」与 budget.go 的不变式说明(批次形状
  永远按稳态超时推导;冷启动只放宽第一个请求的截止时刻)。
- p2:删掉零调用者的 `WithRequestTimeout` —— 单次超时只允许改常量,批次上限与它
  同源并由编译期/常量断言复核。

p2:试算轮在向量源不可用时 `RecallMode` 与「没有候选可比」同为空串。
- `recallLedger` 增 `unavailable` 计数 + `noteUnavailable()`(strictRecall 失败
  那一笔记一笔);试算汇总行新增 `recall_unavailable_partitions`,**成败都记**。
- 不扩 `recallMode` 取值集合:失败那一路不序列化 result(响应是
  500 tidy_recall_unavailable),新取值只会出现在日志里却要动一次契约,不划算。

测试:三种失败形态 × 两次调用的耗时断言(连接被拒 / 500 / 永不回包),
试算轮的「不可用」与「没候选」两态日志断言;两处变异自证(warmupTimeout 调回
30s ⇒ TestDefaultRequestTimeout 红;去掉 noteUnavailable ⇒ 试算汇总行退化成
recall_mode="" + recall_unavailable_partitions=0)。
2026-10-13 20:51:10 +08:00

155 lines
7.6 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 (
"context"
"errors"
"fmt"
)
// 本文件是记忆整理的**试算(dry-run)**入口(契约 v0.75;切片 MT1)。
//
// 它与心跳跑的是**同一条**规则路径(扫描 → 召回 → 包含判定 → 规则否决 →
// 构造候选),只有两点刻意不同:
//
// 1. **零副作用**:候选不落库 —— 不写 memory_tidy_candidates、不写
// memory_tidy_runs、**绝不改 memory_items 的任何列**。这是 dry-run 的命门,
// 因此它连运行留痕都不写(留痕本身也是一次写入)。
// 2. **召回不降级**:语义召回(embedding)不可用时**如实报错**,绝不静默回退到
// 词项余弦 —— 否则面板上的「没有候选」会是一句谎话(那是「没算出来」,不是
// 「算过了,没有」)。生产轮的降级口径见 tidy.go 的 fallbackRecall。
//
// 它**不**受低峰窗口约束:窗口是心跳的调度口径(design/23 §4),不是规则的
// 一部分 —— 「现在就想看看它会建议什么」正是这个入口存在的理由。
// ErrTidyDisabled 在整理被配置显式关闭(MEMORY_TIDY_ENABLED=false)却又被要求
// 试算时返回。它与「未接线」不同:这是配置选择,必须如实说出来,而不是回一份
// 空候选假装「没有建议」。
var ErrTidyDisabled = errors.New("memory/tidy: disabled by configuration")
// ErrTidyRecallUnavailable 在**试算**要求语义召回、而 embedding 不可用时返回。
// 生产轮此时会降级到词项余弦;试算轮刻意不降级(见本文件头注释)。
var ErrTidyRecallUnavailable = errors.New("memory/tidy: recall embedder unavailable")
// TidyDryRun 是一次试算的结果。Candidates 与落库候选**同构**(同一套
// buildCandidates 产物),但没有 status/decided 字段 —— 试算不落库,因此不存在
// 「已决定」这回事。
type TidyDryRun struct {
// Stats 是本轮的观测口径(扫描/比对/召回/否决分布),与真跑同一套计数。
Stats TidyStats
// Candidates 是本轮产出的候选(每个分区已套用 CandidateLimit)。
Candidates []TidyCandidate
// RecallMode 记录相似度来源(embedding / lexical),供面板与运维判读。
// 严格模式下一次试算只可能有一种来源:要么语义召回成功,要么如实报错。
RecallMode string
// DurationMS 是本轮试算的墙钟耗时(毫秒),供面板显示「跑一次要多久」。
DurationMS int64
}
// DryRun 跑一轮整理的**规则**并返回候选,但零副作用(见本文件头注释)。
//
// 与 RunOnce 的失败口径一致:单个分区失败不阻塞其它分区;只要有分区失败就回
// 错误(此时 Candidates 可能只含已完成分区的部分结果,调用方**不得**把它当
// 「完整的建议」展示)。
func (t *Tidier) DryRun(ctx context.Context) (TidyDryRun, error) {
started := t.now()
out := TidyDryRun{}
stats, partitions, err := t.beginRound(ctx)
if err != nil {
return out, err
}
// ledger 是**本轮实际发生**的召回来源:试算会写进 RecallMode 返回给面板。
// 旧实现按「有没有配 embedder」写(tidyRecallMode(deps.Embedder != nil)),
// 那会让面板把「向量不可用」读成「向量正常」(M6 2026-10-13 的 medium finding)。
ledger := &recallLedger{}
// 收集出口:闭包只在 scanPartitions 的**串行**循环里被调用,因此无需加锁。
collected := make([]TidyCandidate, 0)
policy := tidyRoundPolicy{
sink: func(_ context.Context, cands []TidyCandidate, _ *TidyStats) {
collected = append(collected, cands...)
},
recall: func(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) {
return t.strictRecall(ctx, p, items, stats, budget, ledger)
},
}
failures := t.scanPartitions(ctx, partitions, &stats, policy)
out.Stats = stats
out.Candidates = collected
// 如实上报**实际发生**的来源:空串 = 没有任何分区真正算过相似度(「没候选」)。
// 「尝试过但向量源不可用」是另一件事,由 recall_unavailable_partitions 单独给出
// (见 logDryRun 的说明与 recallLedger.unavailable)。
out.RecallMode = ledger.label()
out.DurationMS = t.now().Sub(started).Milliseconds()
// 汇总行**成败都记**(与 RunOnce 同一口径):失败那一路的
// `recall_unavailable_partitions` 正是「向量不可用」与「没候选」的唯一区分点,
// 只在成功时记就等于把这条口径丢掉。
t.logDryRun(out, ledger)
if len(failures) > 0 {
return out, fmt.Errorf("memory/tidy: dry-run finished with errors: %w", errors.Join(failures...))
}
return out, nil
}
// strictRecall 是**试算轮**的召回:语义召回不可用时**不**降级,如实上报。
// 日志只记分区与错误,绝不带记忆正文(候选预览里已含正文)。
//
// 失败时要往 ledger 记一笔 unavailable:否则「尝试过但向量源不可用」与「没有候选
// 可比」在观测面上同值(reviewer round1 的 p2)。
func (t *Tidier) strictRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget, ledger *recallLedger) ([]tidyPair, error) {
pairs, err := t.recallPairs(ctx, p, items, stats, budget, ledger)
if err != nil {
ledger.noteUnavailable()
t.log.Warn("memory tidy dry-run recall unavailable",
"partition", p.label(), "items", len(items), "error", err)
return nil, fmt.Errorf("%w: %w", ErrTidyRecallUnavailable, err)
}
return pairs, nil
}
// logDryRun 输出试算的观测口径。它带 source(memory/tidy),**不带任何记忆
// 正文** —— 只记计数、分区数与截断标签。
//
// 🔴 `recall_mode` + `recall_unavailable_partitions` 一起才把三件事分开
// (reviewer round1 的 p2:它们曾同值):
// - mode=""、unavailable=0:本轮**没有候选可比**(压根没算过相似度);
// - mode=""、unavailable>0:**尝试过召回但向量源不可用**(严格口径不降级,
// 调用方拿到的是 `ErrTidyRecallUnavailable` ⇒ HTTP 500 `tidy_recall_unavailable`);
// - mode=embedding/lexical:真的用该来源算过。
//
// 没有为此扩 `recallMode` 的取值集合:试算失败时**不序列化** result(响应是错误
// 体),一个新取值只会出现在这行日志里,却要动一次 API 契约与文档 —— 不划算。
func (t *Tidier) logDryRun(out TidyDryRun, ledger *recallLedger) {
t.log.Info("memory tidy dry-run finished",
"rule_version", TidyRuleVersion,
"partitions", out.Stats.Partitions,
"scanned", out.Stats.Scanned,
"compared", out.Stats.Compared,
"pairs", out.Stats.Pairs,
"candidates", len(out.Candidates),
"vetoed", out.Stats.Vetoed,
"veto_reasons", tidyReasonsLabel(out.Stats.VetoReasons),
"errors", out.Stats.Errors,
"recall_mode", out.RecallMode,
"recall_unavailable_partitions", ledger.unavailablePartitions(),
"duration_ms", out.DurationMS)
}
// DryRun 立即跑一次试算(契约 v0.75;切片 MT1)。它复用与心跳、手动轮**同一把**
// runMu:试算与真跑串行,因此「一边试算一边落候选」不可能发生(design/23 §9
// 单写者)。试算自身零写入。
//
// 配置关闭(MEMORY_TIDY_ENABLED=false)时如实返回 ErrTidyDisabled:关闭是显式
// 选择,试算不绕过它,也不回一份空候选假装「没有建议」。
func (s *TidyService) DryRun(ctx context.Context) (TidyDryRun, error) {
if !s.cfg.Enabled {
return TidyDryRun{}, ErrTidyDisabled
}
if ctx == nil {
ctx = context.Background()
}
s.runMu.Lock()
defer s.runMu.Unlock()
return s.tidier.DryRun(ctx)
}