review P1:applyTidyConfig 在运行期**整体覆写** s.cfg,而我只把调度读收进了持锁 访问器 —— 同一字段还有几处无锁读,其中两处是**活端点**: - TidyService.DryRun 的开关判定(在取 runMu **之前**)⇒ POST …/tidy/dry-run 与 ?async=1 那条会起后台 goroutine 的版本(真机 90s 级); - TidyService.ManualRun 的开关判定(同样在 runMu 之前)⇒ POST …/tidy/run; - (原本也漏的)Enabled() 与 Start 的启动日志块。 按**字段**重扫了一遍(不按文件):现在生产代码里对 s.cfg 的读**只剩**三个持锁访问器 内部(enabled / cfgSnapshot / scheduleSnapshot),写点只有 applyTidyConfig 一处; s.tidier.cfg 的读点全在持 runMu 的轮次 / 试算路径上(含异步版经 DryRun)。 新增第三条并发回归哨兵:真并发跑「试算 + 手动真跑」两个活端点 × 面板保存。 修前红/修后绿:撤回 DryRun 的同步 ⇒ 报「写 applyTidyConfig × 读 DryRun」;撤回 ManualRun 的同步 ⇒ 报「写 applyTidyConfig × 读 ManualRun」;两次变异都逐字还原 (diff 0 差异)。 顺手两条(塔指定): - 那条并发哨兵的 doc 与实现相反(还写着「正确顺序是 Stop → 写 → Start」),已改成 唤醒机制下的说法 —— 与「注释与实现不符」同类,不留旧说法。 - 非法值的回显文案原按**字节**截断(s[:40]),中文会被切成半截(非法 UTF-8,经 JSON 编码变成 U+FFFD 替换符)。改成按 **rune** 截断,直接复用同包既有的 clipRunes;用例取值刻意让截断点落在多字节字符中间(JSON 引号 + 一个 ASCII 前缀 错开对齐),并做过变异复验:改回按字节 ⇒ 用例红(切出半个汉字)。
159 lines
8.0 KiB
Go
159 lines
8.0 KiB
Go
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) {
|
||
// 走 `enabled()` 而不是裸读字段:这个判断在取 runMu **之前**,而面板调优能在运行期
|
||
// 覆写 `s.cfg`(见 tidy_service_tuning.go 的 ApplyPanelTuning)⇒ 该字段的每个读点
|
||
// 都得与写方同步,否则 `POST …/tidy/dry-run`(含 `?async=1` 那条后台版)与保存并发
|
||
// 时就是数据竞争(`-race` 可复现)。
|
||
if !s.enabled() {
|
||
return TidyDryRun{}, ErrTidyDisabled
|
||
}
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
s.runMu.Lock()
|
||
defer s.runMu.Unlock()
|
||
return s.tidier.DryRun(ctx)
|
||
}
|