Files
AskaEth 4df8d6f53a fix(memory): 按字段扫清 s.cfg 的其余无锁读(P1)+ 回显文案按 rune 截断
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 前缀
  错开对齐),并做过变异复验:改回按字节 ⇒ 用例红(切出半个汉字)。
2026-10-13 22:33:07 +08:00

159 lines
8.0 KiB
Go
Raw Permalink 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) {
// 走 `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)
}