feat(memory): retention_tier 档位列 + defer 与档位合并为同一判定(M37)

主人 2026-10-26 对「记忆分型」裁定「两列」:kind(关于什么)保留,新增
retention_tier(留多久 / 干什么用)。本片把这条轴从列、判定、写入、读侧过滤
一路打通,并把「判第五态 defer」与「判 retention_tier=temp」合并成一个判定。

结构(迁移 0026 + 自举前滚):
- memory_items 新增 retention_tier TEXT NOT NULL DEFAULT ''(空串 = 尚未分型,
  历史行与受限来源行),索引 idx_memory_items_retention_tier;up/down 均实跑过
  (在隔离的生产库副本上 migrate → 建 Store → down → 再 up,5073 行零丢失)。
- NewStore 幂等前滚该列 + 建索引(ensureRetentionSchema),与迁移同形由测试钉住。

判定(retention.go,全仓唯一入口):
- RetentionTier 闭集 temp/daily/work/long/knowledge + 空串未分型;与 kind 正交。
- resolveRetention 的优先级:调用方显式档位 > 显式有效期(⇒ temp)> 受限来源
  不分型 > 留存规则 N1~N7 ⇒ temp > 默认 long。判据靠元数据,规则不许推翻显式档位。
- tempDeadline 是「档位 → expires_at」的唯一换算:temp ⇔ 有效期非空,两列同一笔
  INSERT 落下(M2 登记过的「INSERT 与补写之间的短窗口」就此关闭)。

合并(defer ⇔ temp):
- judgeIngest 不再跑留存规则,只读 Add 落定的 item.RetentionTier;规则调用点因此
  全仓唯一(源码级断言钉住)。
- 判定不再依赖同步闸是否开启:闸未接/关着时档位与有效期照样落库。
- 异步层对已是 temp 的行不重复改写(避免 TTL 随审查延迟漂移)。
- IngestRuleVersion d3.3 → d3.4(判据作用范围变了,按既有硬规矩升版)。

读侧:
- 四条列表读路径的 SELECT 与 scanItem 读出 retention_tier(14 列);Get 同样读回。
- 新增 ListByPersonaTiers / ListScopeTiers / ListTiers / ListProjectTiers(可选档位
  过滤,缺省不过滤 ⇒ 历史行照常返回;显式传空串只取未分型;非法档位报错)。
  原方法签名逐字不变 —— 网关 MemoryStore 接口钉着它们,可变参数会编译不过。
- store.go 的读路径拆到 store_list.go,守 500 行上限。

回归:go build ./... + go vet ./... + go test ./... 全绿(内存包另有 -race)。
This commit is contained in:
AskaEth committed 2026-10-26 13:09:30 +08:00
1 parent 240b59708d
commit cc6904b1b4
23 files changed
+1592 -181

No files matched your search

@@ -0,0 +1,55 @@
package memory
import (
"context"
"database/sql"
"fmt"
"strings"
)
// 本文件是「留多久 / 干什么用」轴(迁移 0026)的 schema 前滚:给**既有**的
// memory_items 补 `retention_tier` 列,再建读侧索引。
//
// 为什么需要在这里前滚:`createMemoryItemsTable` 是 `CREATE TABLE IF NOT EXISTS`,
// 它只对**新库**生效 —— 既有部署的表早就建好了,新列不会自己长出来。这与
// `ensureProjectIDColumn` / `ensureTidyMemoryColumns` 是同一手法、同一原因
// (记忆存储必须自包含、可在任意 *sql.DB 上使用)。
//
// 🔴 与迁移的分工(两份真源,必须同形):权威机制是
// `internal/db/migrations/0026_memory_retention_tier`;这里是「任意 *sql.DB」的
// 前滚。启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此生产
// 库里 0026 先落地、这里走到 `duplicate column name` 是**预期路径**;反过来
// (先自举、后迁移)也成立,因为迁移的 ALTER 同样只跑一次。两份 DDL 的列/索引
// 逐项同形由 retention_migration_test.go 钉住。
//
// 顺序**必须**是「建表 → 前滚列 → 建索引」:索引依赖的列在既有库上还不存在时,
// 先建索引会报 "no such column: retention_tier"(tidy_schema.go 记过同型的教训)。
// ensureRetentionSchema 幂等地给 memory_items 补上档位列与读侧索引。
func ensureRetentionSchema(ctx context.Context, db *sql.DB) error {
if err := ensureRetentionTierColumn(ctx, db); err != nil {
return err
}
if _, err := db.ExecContext(ctx, createMemoryItemsRetentionTierIndex); err != nil {
return fmt.Errorf("memory: retention tier index: %w", err)
}
return nil
}
// ensureRetentionTierColumn 在列缺失时补加 `retention_tier`,已存在时忽略
// SQLite 的 "duplicate column name"(那是预期路径,不是错误)。
//
// 默认值 = 空串 = 尚未分型:既有行因此**行为逐字不变**(不设过期、读路径照常可见),
// 本片也刻意**不**在启动时回填它们的档位 —— 回填是一次写全表的动作,该由整理侧
// 显式做(「按空串过滤」正是那个入口),不该藏在组合根的启动路径里。
func ensureRetentionTierColumn(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `ALTER TABLE memory_items
ADD COLUMN retention_tier TEXT NOT NULL DEFAULT ''`)
if err == nil {
return nil
}
if strings.Contains(err.Error(), "duplicate column name") {
return nil
}
return fmt.Errorf("memory: roll forward retention_tier column: %w", err)
}