feat(memory): 记忆整理**应用路径 + 调度修复 + 手动真跑**(TIDY-1,主人投诉的根因)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人实测: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:
AskaEth committed 2026-10-06 22:39:35 +08:00
1 parent 57d0e8c543
commit 454ddb0b70
18 files changed
+3181 -71

No files matched your search

+294
View File
@@ -0,0 +1,294 @@
package memory
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// 本文件是记忆整理**应用路径**(切片 TIDY-1)的存储层:候选认领、软合并 /
// 软删 / 改 kind 的事务写入,以及回滚。它复用 memory 包既有的 *sql.DB、
// 分区口径(tidyPartition / tidyStatusActive)与软合并且已存在的辅助
// (preinsert_store.go 的 bumpSurvivor / dropSurvivorLink / revertMergedSQL /
// selectMergedIntoSQL —— 那是 design/23 §8 的**同一套**软合并列语义,不是第二套)。
//
// 与 D3(入库前审查)那套的关系:语义同源(status/merged_into/merged_from/
// merge_count),但**闸口不同** —— D3 的顺序队列保证一条记忆只审一次,因此它的
// markAbsorbed 不带 `AND status='active'`;整理的候选是**事后**执行的,可能
// 过时(源行已被别的动作改过),所以这里每一条 UPDATE 都带状态条件,并把它
// 当作幂等/过时的判据(RowsAffected == 0)。
const (
// tidyCandidateApplied 是候选「已应用」状态(design/23 §10 的 status 闭集)。
tidyCandidateApplied = "applied"
// tidyStatusArchived 是软删后的行状态(design/23 §10:active/merged/archived)。
// 归档行不再参与检索与整理,但**行与原文都还在**,因此可 revert(§8)。
tidyStatusArchived = "archived"
)
// claimTidyCandidateSQL 是**执行闸**:只有把候选从 pending 原子地推到
// applied 的那一次调用才继续往下改记忆。WHERE 里的状态条件就是幂等性的根,
// 因此它绝不能省(少了它,重复应用会再写一次 memory_items)。
const claimTidyCandidateSQL = `UPDATE memory_tidy_candidates
SET status = ?, decided_at = ?, decided_by = ? WHERE id = ? AND status = ?`
// markTidyAbsorbedSQL 标记被吸收行:status='merged' + merged_into=存活 id。
// `AND status='active'` 是第二道闸:源行已被别的动作改过时 RowsAffected==0,
// 调用方据此回滚整个事务(不产生第二次合并计数)。
const markTidyAbsorbedSQL = `UPDATE memory_items
SET status = ?, merged_into = ?, updated_at = ? WHERE id = ? AND status = ?`
// setTidyItemStatusSQL 是软删 / 复原共用的一条:只改 status 与 updated_at,
// 带 from 状态条件。
const setTidyItemStatusSQL = `UPDATE memory_items
SET status = ?, updated_at = ? WHERE id = ? AND status = ?`
// reclassifyTidyItemSQL 只改 kind 一列(design/23 §5.1 T0-6),带状态条件。
const reclassifyTidyItemSQL = `UPDATE memory_items
SET kind = ?, updated_at = ? WHERE id = ? AND status = ?`
// undoTidyReclassifySQL 撤回一次 kind 修正:只在当前 kind 仍是当时写的建议值、
// 且行仍在册时生效(否则说明有人后来手动改过,不该被回滚覆盖)。
const undoTidyReclassifySQL = `UPDATE memory_items
SET kind = ?, updated_at = ? WHERE id = ? AND status = ? AND kind = ?`
// tidyApplyTx 在**一个事务**里跑一次应用:先认领候选(条件更新 = 执行闸),
// 再执行 mutate,最后提交。任何一步失败都回滚 —— 因此「候选被标 applied 但
// 记忆没改」这种半成品状态不可能落库。
func (s *Store) tidyApplyTx(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions,
now time.Time, mutate func(context.Context, *sql.Tx) error) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("memory/tidy: apply candidate begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
claimed, err := claimTidyCandidate(ctx, tx, cand.ID, tidyCandidatePending, tidyCandidateApplied, opts.Actor, now)
if err != nil {
return err
}
if !claimed {
return errTidyCandidateDecided
}
if err := mutate(ctx, tx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("memory/tidy: apply candidate commit: %w", err)
}
return nil
}
// tidyRevertTx 与 tidyApplyTx 同构,只是认领的方向相反(applied → pending)。
func (s *Store) tidyRevertTx(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions,
mutate func(context.Context, *sql.Tx) error) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("memory/tidy: revert candidate begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
claimed, err := claimTidyCandidate(ctx, tx, cand.ID, tidyCandidateApplied, tidyCandidatePending, opts.Actor, time.Now().UTC())
if err != nil {
return err
}
if !claimed {
return errTidyCandidateDecided
}
if err := mutate(ctx, tx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("memory/tidy: revert candidate commit: %w", err)
}
return nil
}
// claimTidyCandidate 原子地把候选从**当前状态 from** 推到 to。返回是否真的改了行:
// false 表示它已经被决定过(幂等命中,不是错误)。
//
// from 参数是这道闸的关键:应用要求 from='pending',回滚要求 from='applied'。
// 两者共用一个语句,但**不能共用一个硬编码的 from** —— 那会让回滚永远匹配不到行。
func claimTidyCandidate(ctx context.Context, tx *sql.Tx, id, from, to, actor string, now time.Time) (bool, error) {
res, err := tx.ExecContext(ctx, claimTidyCandidateSQL, to, formatTime(now), actor, id, from)
if err != nil {
return false, fmt.Errorf("memory/tidy: claim candidate %s: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("memory/tidy: claim candidate %s rows: %w", id, err)
}
return n > 0, nil
}
// markTidyAbsorbed 标记被吸收行并刷新存活行的合并元信息。源行不在册时返回
// errTidyItemNotActive(事务因此回滚)。
func markTidyAbsorbed(ctx context.Context, tx *sql.Tx, absorbedID, survivorID string, now time.Time) error {
res, err := tx.ExecContext(ctx, markTidyAbsorbedSQL,
string(ingestStatusMerged), survivorID, formatTime(now), absorbedID, tidyStatusActive)
if err != nil {
return fmt.Errorf("memory/tidy: mark absorbed item: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("memory/tidy: mark absorbed item rows: %w", err)
}
if n == 0 {
return errTidyItemNotActive
}
return nil
}
// setTidyItemStatus 在 from → to 之间改一行的 status。返回是否真的改了行。
func setTidyItemStatus(ctx context.Context, tx *sql.Tx, id, from, to string, now time.Time) (bool, error) {
res, err := tx.ExecContext(ctx, setTidyItemStatusSQL, to, formatTime(now), id, from)
if err != nil {
return false, fmt.Errorf("memory/tidy: set item status %s: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("memory/tidy: set item status rows: %w", err)
}
return n > 0, nil
}
// archiveTidyItem 把一条在册记忆软删(active → archived)。不在册时返回
// errTidyItemNotActive。它**只改 status/updated_at**,绝不碰 content。
func archiveTidyItem(ctx context.Context, tx *sql.Tx, id string, now time.Time) error {
changed, err := setTidyItemStatus(ctx, tx, id, tidyStatusActive, tidyStatusArchived, now)
if err != nil {
return err
}
if !changed {
return errTidyItemNotActive
}
return nil
}
// reclassifyTidyItem 把一条在册记忆的 kind 改为 kind。不在册时返回
// errTidyItemNotActive。
func reclassifyTidyItem(ctx context.Context, tx *sql.Tx, id string, kind Kind, now time.Time) error {
res, err := tx.ExecContext(ctx, reclassifyTidyItemSQL, string(kind), formatTime(now), id, tidyStatusActive)
if err != nil {
return fmt.Errorf("memory/tidy: reclassify item %s: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("memory/tidy: reclassify item rows: %w", err)
}
if n == 0 {
return errTidyItemNotActive
}
return nil
}
// addTidyRunApplied 把该轮留痕的 applied 计数累加(design/23 §10)。
func (s *Store) addTidyRunApplied(ctx context.Context, runID string, applied int) error {
if _, err := s.db.ExecContext(ctx, incrementTidyRunAppliedSQL, applied, runID); err != nil {
return fmt.Errorf("memory/tidy: bump run applied %s: %w", runID, err)
}
return nil
}
// revertTidyAction 在事务里撤销一条已应用候选对记忆的改动,返回真正被复原的
// item id(一条都没复原时返回空切片 —— 那说明用户已经用 /memories/{id}/revert
// 手工撤过了,此时只把候选退回 pending,不假装改过东西)。
func revertTidyAction(ctx context.Context, tx *sql.Tx, cand TidyCandidate, now time.Time) ([]string, error) {
switch cand.Action {
case TidyMerge:
return revertTidyMerge(ctx, tx, cand, now)
case TidyArchive:
return revertTidyArchive(ctx, tx, cand, now)
case TidyReclassify:
return revertTidyReclassify(ctx, tx, cand, now)
default:
return []string{}, nil
}
}
// revertTidyMerge 复原一次软合并:逐条把被吸收行从 merged 复原为 active,并从
// 存活行的 merged_from 里摘掉它(dropSurvivorLink)。只复原**这条候选做的那次**
// 合并(merged_into 必须等于候选的 target)。
func revertTidyMerge(ctx context.Context, tx *sql.Tx, cand TidyCandidate, now time.Time) ([]string, error) {
out := make([]string, 0, len(cand.SourceIDs))
for _, id := range cand.SourceIDs {
ok, err := revertTidyMergeInTx(ctx, tx, id, cand.TargetID, now)
if err != nil {
return nil, err
}
if ok {
out = append(out, id)
}
}
return out, nil
}
// revertTidyMergeInTx 复原单条被吸收行。它只认 `status='merged'` 且
// `merged_into=<该候选的存活 id>` 的行;targetID 为空时接受任意存活 id
// (容错:老候选可能没落 target)。
func revertTidyMergeInTx(ctx context.Context, tx *sql.Tx, absorbedID, targetID string, now time.Time) (bool, error) {
var survivorID string
err := tx.QueryRowContext(ctx, selectMergedIntoSQL, absorbedID, string(ingestStatusMerged)).Scan(&survivorID)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("memory/tidy: read merged item %s: %w", absorbedID, err)
}
if targetID != "" && survivorID != targetID {
// 它现在挂在**别的**存活行上(后来又被合并过一次):这条候选的回滚
// 不该把它从新关系里拽出来。
return false, nil
}
if _, err := tx.ExecContext(ctx, revertMergedSQL,
string(tidyStatusActive), formatTime(now), absorbedID, string(ingestStatusMerged)); err != nil {
return false, fmt.Errorf("memory/tidy: restore merged item %s: %w", absorbedID, err)
}
if survivorID != "" {
if err := dropSurvivorLink(ctx, tx, survivorID, absorbedID, now); err != nil {
return false, err
}
}
return true, nil
}
// revertTidyArchive 复原被软删(archived)的行:archived → active。
func revertTidyArchive(ctx context.Context, tx *sql.Tx, cand TidyCandidate, now time.Time) ([]string, error) {
out := make([]string, 0, len(cand.SourceIDs))
for _, id := range cand.SourceIDs {
changed, err := setTidyItemStatus(ctx, tx, id, tidyStatusArchived, tidyStatusActive, now)
if err != nil {
return nil, err
}
if changed {
out = append(out, id)
}
}
return out, nil
}
// revertTidyReclassify 撤回一次 kind 修正:只在当前 kind 仍等于当时写的建议值、
// 且行仍在册时生效(有人后来手动改过就保持不动)。旧 kind 来自候选证据 KindA。
func revertTidyReclassify(ctx context.Context, tx *sql.Tx, cand TidyCandidate, now time.Time) ([]string, error) {
kind := Kind(cand.Evidence.ProposedKind)
previous := Kind(cand.Evidence.KindA)
if !previous.valid() || !kind.valid() || len(cand.SourceIDs) != 1 {
return []string{}, nil
}
res, err := tx.ExecContext(ctx, undoTidyReclassifySQL,
string(previous), formatTime(now), cand.SourceIDs[0], tidyStatusActive, string(kind))
if err != nil {
return nil, fmt.Errorf("memory/tidy: undo reclassify %s: %w", cand.SourceIDs[0], err)
}
n, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("memory/tidy: undo reclassify rows: %w", err)
}
if n == 0 {
return []string{}, nil
}
return []string{cand.SourceIDs[0]}, nil
}