feat(memory): 记忆整理**应用路径 + 调度修复 + 手动真跑**(TIDY-1,主人投诉的根因)
主人实测:memory_items 1191 行、候选 77 条全 pending、memory_tidy_runs 仅 1 行、两晚定时没跑。 根因两条(都在这片里修掉): ① **以前根本没有执行面** —— 只有只读试算,候选永远 pending ② **调度被重启饿死** —— interval=24h 的 ticker 从进程启动算起,两天重启十几次 ⇒ 24h 永远走不完 - 应用路径:逐条 / 按 run 批量(默认 100、硬顶 500、超限 truncated)/ revert;**软合并** (status + merged_into/merged_from/merge_count,**绝不改 content、行不删**)+ 审计 (memory.tidy.apply / memory.tidy.revert)+ **幂等**(候选状态条件更新 + 行 status 闸两道) - 调度:改成**到点即跑**(catchUp:无历史+窗口内 / 距上次 run ≥ interval / 窗口起点晚于上次 run), 「上次 run」以 memory_tidy_runs 为**持久事实**;注入时钟用例证明**重启不饿死** - 手动真跑端点 POST /admin/memories/tidy/run(同档鉴权 + 审计 + 回执:新增/合并/软删/耗时) - 候选表加 run_id 列 + 索引(前滚;顺序=建表→补列→建索引,否则既有库会 no such column) - 3 个变异自检(删 catchUp / 去掉两道 status 闸 / truncated 恒 false) ⚠️ 生产库零动作:1191 行真跑与 77 条候选应用**未执行**(按红线由主人确认后主会话执行) ⚠️ 契约段在报告里(candidates 列表/apply/revert/tidy.run),Docs/ 待登记
This commit is contained in:
1 parent
57d0e8c543
commit
454ddb0b70
18 files changed
+3181
-71
No files matched your search
@@ -97,6 +97,10 @@ type TidyRun struct {
|
||||
// TidyStats 是一轮整理的观测口径:**扫描 N / 候选 M / 建议 K / 否决原因分布**
|
||||
// (design/23 §9)。它同时写进日志与运行表,绝不静默。
|
||||
type TidyStats struct {
|
||||
// RunID 是本轮在 memory_tidy_runs 里的留痕 id(切片 TIDY-1):候选按它归属,
|
||||
// 「按 run 批量应用」因此有确定的抓手。留痕写失败时它仍是本轮已用的 id,
|
||||
// 调用方不该据此假装留痕存在。
|
||||
RunID string
|
||||
Scanned int
|
||||
Partitions int
|
||||
Compared int
|
||||
@@ -106,10 +110,15 @@ type TidyStats struct {
|
||||
Skipped int
|
||||
Vetoed int
|
||||
VetoReasons map[string]int
|
||||
// ByAction 是本轮**新建**候选按动作的分布(幂等跳过的 duplicates 不计,
|
||||
// 它们不是这一轮的产出)。它是「新增/合并/软删各多少」这条回执的领域来源。
|
||||
ByAction map[string]int
|
||||
// Errors 是本轮的失败计数(分区失败 + 候选写入失败),进运行表。
|
||||
Errors int
|
||||
// Truncated 是被上限截断的分区标签(扫描或比对上限),供运维放大配额。
|
||||
Truncated []string
|
||||
// DurationMS 是本轮的墙钟耗时(毫秒),与日志里的 duration_ms 同一算法。
|
||||
DurationMS int64
|
||||
}
|
||||
|
||||
// TidyDeps 是注入给整理器的依赖。Store 必需;其余可空并优雅降级。
|
||||
@@ -183,19 +192,24 @@ type tidyRoundPolicy struct {
|
||||
// 日志;只有「所有分区都失败」或运行留痕本身写不进去时才回错误。
|
||||
func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
|
||||
started := t.now().UTC()
|
||||
// 运行 id 与轮次开始**同时**定下来:候选要按它归属(切片 TIDY-1 的按轮
|
||||
// 批量应用),运行留痕也要用它,因此不能等到扫完再生成。
|
||||
runID := mustNewTidyID()
|
||||
stats, partitions, err := t.beginRound(ctx)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.RunID = runID
|
||||
|
||||
failures := t.scanPartitions(ctx, partitions, &stats, tidyRoundPolicy{
|
||||
sink: t.persistCandidates,
|
||||
sink: func(ctx context.Context, cands []TidyCandidate, stats *TidyStats) {
|
||||
t.persistCandidates(ctx, runID, cands, stats)
|
||||
},
|
||||
recall: t.fallbackRecall,
|
||||
})
|
||||
|
||||
run := TidyRun{
|
||||
// 运行 id 在这里就定下来:留痕与日志必须指向同一条运行记录。
|
||||
ID: mustNewTidyID(),
|
||||
ID: runID,
|
||||
StartedAt: started,
|
||||
FinishedAt: t.now().UTC(),
|
||||
Scanned: stats.Scanned,
|
||||
@@ -207,6 +221,7 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
|
||||
VetoReasons: stats.VetoReasons,
|
||||
RuleVersion: TidyRuleVersion,
|
||||
}
|
||||
stats.DurationMS = run.FinishedAt.Sub(run.StartedAt).Milliseconds()
|
||||
if err := t.deps.Store.persistTidyRun(ctx, run); err != nil {
|
||||
failures = append(failures, err)
|
||||
}
|
||||
@@ -251,7 +266,10 @@ func (t *Tidier) logRun(stats TidyStats, run TidyRun) {
|
||||
// beginRound 开一轮:准备口径容器并列出待整理分区。分区列表拿不到时整轮失败,
|
||||
// 且**不写运行留痕** —— 那一轮根本没开始(口径与 design §9 一致)。
|
||||
func (t *Tidier) beginRound(ctx context.Context) (TidyStats, []tidyPartition, error) {
|
||||
stats := TidyStats{VetoReasons: make(map[string]int)}
|
||||
stats := TidyStats{
|
||||
VetoReasons: make(map[string]int),
|
||||
ByAction: make(map[string]int),
|
||||
}
|
||||
partitions, err := t.deps.Store.listTidyPartitions(ctx)
|
||||
if err != nil {
|
||||
return stats, nil, fmt.Errorf("memory/tidy: list partitions: %w", err)
|
||||
@@ -412,9 +430,12 @@ func (t *Tidier) classifyForTidy(it MemoryItem, stats *TidyStats) tidyDispositio
|
||||
}
|
||||
|
||||
// persistCandidates 落库一批候选:幂等键已存在时只计入 duplicates
|
||||
// (design/23 §9「重复跑不重复产候选」)。
|
||||
func (t *Tidier) persistCandidates(ctx context.Context, cands []TidyCandidate, stats *TidyStats) {
|
||||
// (design/23 §9「重复跑不重复产候选」),新建的计入 Candidates 并按动作进
|
||||
// ByAction(回执的领域来源)。runID 是本轮的留痕 id:「按 run 批量应用」据此
|
||||
// 找到这一轮产出的候选;幂等跳过的行保留它**首次**被产出的轮次,不被改写。
|
||||
func (t *Tidier) persistCandidates(ctx context.Context, runID string, cands []TidyCandidate, stats *TidyStats) {
|
||||
for _, c := range cands {
|
||||
c.RunID = runID
|
||||
created, err := t.deps.Store.insertTidyCandidate(ctx, c)
|
||||
if err != nil {
|
||||
stats.Errors++
|
||||
@@ -426,6 +447,9 @@ func (t *Tidier) persistCandidates(ctx context.Context, cands []TidyCandidate, s
|
||||
continue
|
||||
}
|
||||
stats.Candidates++
|
||||
if stats.ByAction != nil {
|
||||
stats.ByAction[string(c.Action)]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user