Files
Cyrene_v2/internal/runtime/memory/retention_schema.go
T
AskaEth cc6904b1b4 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)。
2026-10-26 13:09:30 +08:00

56 lines
2.6 KiB
Go
Raw 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"
"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)
}