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:
1 parent
240b59708d
commit
cc6904b1b4
23 files changed
+1592
-181
No files matched your search
@@ -0,0 +1,241 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
)
|
||||
|
||||
// 本文件钉住**迁移 0026(`memory_retention_tier`)与 Store 自举 DDL 的同形与可回滚**,
|
||||
// 手法照 preinsert_migration_test.go 对 0025 的那一套(同一批 helpers)。
|
||||
//
|
||||
// 真实启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此新列有两份
|
||||
// 真源:迁移(权威)与 NewStore 的幂等前滚(任意 *sql.DB 上的自举,retention_schema.go)。
|
||||
// 少一列就是「新库有、老库无」的漂移,因此两条判据都必须有:
|
||||
//
|
||||
// 1. TestMigration0026AddsColumnOverExistingRows —— 迁移落在**已有数据**上:
|
||||
// 行不丢、列补上、默认值是空串(未分型);down 真的把列删掉且不改任何一行的
|
||||
// 其它数据;再 up 能重建。
|
||||
// 2. TestMigration0026ShapeMatchesStoreBootstrap —— 两份 DDL **逐列逐索引同形**
|
||||
// (它红的时候,正确的修法是让两份对齐,而不是放宽断言)。
|
||||
|
||||
// retentionTierColumn 是被测列名(迁移与自举 DDL 里的字面量)。
|
||||
const retentionTierColumn = "retention_tier"
|
||||
|
||||
// retentionTierIndex 是被测索引名。
|
||||
const retentionTierIndex = "idx_memory_items_retention_tier"
|
||||
|
||||
// retentionMigrationID 是 0026 在 schema_migrations 里的记账 id(= 文件名去掉 .up.sql)。
|
||||
const retentionMigrationID = "0026_memory_retention_tier"
|
||||
|
||||
// hasMemoryColumn 报告 memory_items 上是否存在某列。
|
||||
func hasMemoryColumn(t *testing.T, d *sql.DB, name string) bool {
|
||||
t.Helper()
|
||||
return slices.ContainsFunc(tableColumns(t, d, "memory_items"),
|
||||
func(c string) bool { return strings.Contains(c, "|"+name+"|") })
|
||||
}
|
||||
|
||||
// hasMemoryIndex 报告 memory_items 上是否存在某条索引。
|
||||
func hasMemoryIndex(t *testing.T, d *sql.DB, name string) bool {
|
||||
t.Helper()
|
||||
return slices.ContainsFunc(tableIndexes(t, d, "memory_items"),
|
||||
func(i string) bool { return strings.HasPrefix(i, name+"|") })
|
||||
}
|
||||
|
||||
// insertLegacyMemoryRow 以**0026 之前的形态**写一行(不列 retention_tier)。
|
||||
func insertLegacyMemoryRow(t *testing.T, d *sql.DB, id, content string) {
|
||||
t.Helper()
|
||||
if _, err := d.ExecContext(context.Background(), `INSERT INTO memory_items
|
||||
(id, persona_id, scope, kind, content, created_at, updated_at)
|
||||
VALUES (?, 'persona.a', 'persona', 'relationship', ?, `+legacyTimeLiteral+`, `+legacyTimeLiteral+`)`,
|
||||
id, content); err != nil {
|
||||
t.Fatalf("insert legacy row %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// legacyTimeLiteral 是定宽 UTC 文本(与 formatTime 同形,读侧纯文本比较依赖它)。
|
||||
const legacyTimeLiteral = "'2026-10-01T10:00:00.000000000Z'"
|
||||
|
||||
// TestMigration0026AddsColumnOverExistingRows 钉住迁移本体的三条:
|
||||
// 落在既有数据上不丢行、默认值是空串(未分型)、down 真的删列且不动其它数据。
|
||||
func TestMigration0026AddsColumnOverExistingRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
d := openRawMemoryDB(t)
|
||||
migrateUp(t, d)
|
||||
|
||||
// 造出「既有部署」:把 0026 撤掉(列与索引消失),再按**旧形态**写入两行。
|
||||
if err := db.MigrateDown(ctx, d, 1, testLogger()); err != nil {
|
||||
t.Fatalf("MigrateDown(1): %v", err)
|
||||
}
|
||||
if hasMemoryColumn(t, d, retentionTierColumn) || hasMemoryIndex(t, d, retentionTierIndex) {
|
||||
t.Fatalf("0026 回滚后 %s / %s 应已消失", retentionTierColumn, retentionTierIndex)
|
||||
}
|
||||
insertLegacyMemoryRow(t, d, "legacy-1", "一条 0026 之前就存在的记忆")
|
||||
insertLegacyMemoryRow(t, d, "legacy-2", "另一条 0026 之前就存在的记忆")
|
||||
|
||||
// 升级:0026 落在**已有数据**上。
|
||||
migrateUp(t, d)
|
||||
if !hasMemoryColumn(t, d, retentionTierColumn) {
|
||||
t.Fatalf("迁移后 memory_items 应带 %s 列", retentionTierColumn)
|
||||
}
|
||||
if !hasMemoryIndex(t, d, retentionTierIndex) {
|
||||
t.Fatalf("迁移后应存在索引 %s", retentionTierIndex)
|
||||
}
|
||||
var rows int
|
||||
if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM memory_items`).Scan(&rows); err != nil {
|
||||
t.Fatalf("count rows: %v", err)
|
||||
}
|
||||
if rows != 2 {
|
||||
t.Fatalf("迁移后行数 = %d, want 2(既有行一行都不能丢)", rows)
|
||||
}
|
||||
// 默认值必须是**未分型**(空串),不是某一档 —— 本迁移刻意不做回填。
|
||||
for _, id := range []string{"legacy-1", "legacy-2"} {
|
||||
var tier, content string
|
||||
if err := d.QueryRowContext(ctx,
|
||||
`SELECT retention_tier, content FROM memory_items WHERE id = ?`, id).
|
||||
Scan(&tier, &content); err != nil {
|
||||
t.Fatalf("read %s: %v", id, err)
|
||||
}
|
||||
if tier != "" {
|
||||
t.Fatalf("既有行 %s 的档位 = %q, want 空串(历史行必须是「未分型」)", id, tier)
|
||||
}
|
||||
if content == "" {
|
||||
t.Fatalf("既有行 %s 的正文丢了", id)
|
||||
}
|
||||
}
|
||||
|
||||
// 回滚:只撤结构,不改写任何一行的其它数据。
|
||||
if err := db.MigrateDown(ctx, d, 1, testLogger()); err != nil {
|
||||
t.Fatalf("MigrateDown(1, 二次): %v", err)
|
||||
}
|
||||
if hasMemoryColumn(t, d, retentionTierColumn) || hasMemoryIndex(t, d, retentionTierIndex) {
|
||||
t.Fatalf("二次回滚后 %s / %s 应已消失", retentionTierColumn, retentionTierIndex)
|
||||
}
|
||||
if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM memory_items`).Scan(&rows); err != nil {
|
||||
t.Fatalf("count rows after down: %v", err)
|
||||
}
|
||||
if rows != 2 {
|
||||
t.Fatalf("回滚后行数 = %d, want 2(down 只撤结构,不删行)", rows)
|
||||
}
|
||||
|
||||
// 再 up:列以默认值重建,历史行回到「未分型」。
|
||||
migrateUp(t, d)
|
||||
if !hasMemoryColumn(t, d, retentionTierColumn) {
|
||||
t.Fatalf("再次迁移后应重建 %s 列", retentionTierColumn)
|
||||
}
|
||||
var withTier int
|
||||
if err := d.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM memory_items WHERE retention_tier = ""`).Scan(&withTier); err != nil {
|
||||
t.Fatalf("count untyped: %v", err)
|
||||
}
|
||||
if withTier != 2 {
|
||||
t.Fatalf("重建后未分型行 = %d, want 2", withTier)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigration0026ShapeMatchesStoreBootstrap 是**漂移护栏**:迁移建出的
|
||||
// memory_items(列 + 索引)与自举 DDL 建出的必须逐项同形。
|
||||
//
|
||||
// ⚠️ 比对的是**列集合**而不是列序,理由是既有的既成事实:`project_id` 是自举
|
||||
// 前滚补上的(ensureProjectIDColumn),迁移路径上它同样会被追加到末尾,因此两条
|
||||
// 路径的列序本来就不同。本片新增的 `retention_tier` 在两条路径上都追加在**当时**的
|
||||
// 末尾,不制造新的漂移 —— 集合一致 + 索引一致就是这条护栏的判据。
|
||||
func TestMigration0026ShapeMatchesStoreBootstrap(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
// 生产顺序:先迁移、后自举。两边都走完这一整套再比。
|
||||
migrated := openRawMemoryDB(t)
|
||||
migrateUp(t, migrated)
|
||||
if _, err := NewStore(ctx, migrated, testLogger()); err != nil {
|
||||
t.Fatalf("NewStore(migrated): %v", err)
|
||||
}
|
||||
|
||||
bootstrapped := openRawMemoryDB(t)
|
||||
if _, err := NewStore(ctx, bootstrapped, testLogger()); err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
|
||||
gotCols := sortedColumns(tableColumns(t, migrated, "memory_items"))
|
||||
wantCols := sortedColumns(tableColumns(t, bootstrapped, "memory_items"))
|
||||
if !slices.Equal(gotCols, wantCols) {
|
||||
t.Fatalf("迁移与自举的 memory_items 列集合不一致(两份 DDL 必须同形):\n迁移=%v\n自举=%v",
|
||||
gotCols, wantCols)
|
||||
}
|
||||
gotIdx := tableIndexes(t, migrated, "memory_items")
|
||||
wantIdx := tableIndexes(t, bootstrapped, "memory_items")
|
||||
if !slices.Equal(gotIdx, wantIdx) {
|
||||
t.Fatalf("迁移与自举的 memory_items 索引不一致:\n迁移=%v\n自举=%v", gotIdx, wantIdx)
|
||||
}
|
||||
// 新列在两份 DDL 里的形状必须逐字相同(TEXT NOT NULL DEFAULT + 空串字面量)。
|
||||
for name, cols := range map[string][]string{"迁移": gotCols, "自举": wantCols} {
|
||||
if !slices.Contains(cols, retentionTierColumn+"|TEXT|1|''|0") {
|
||||
t.Fatalf("%s 侧的 %s 列形状不是 TEXT NOT NULL DEFAULT 空串:%v", name, retentionTierColumn, cols)
|
||||
}
|
||||
}
|
||||
// 自举 DDL 里新列写在最后一列(新建库的期望形状)——迁移路径上它后面可能还有
|
||||
// 自举追加的 project_id,因此只对自举侧断言顺序。
|
||||
bootCols := tableColumns(t, bootstrapped, "memory_items")
|
||||
if last := bootCols[len(bootCols)-1]; !strings.Contains(last, "|"+retentionTierColumn+"|") {
|
||||
t.Fatalf("自举建的 memory_items 最后一列 = %q, want %s", last, retentionTierColumn)
|
||||
}
|
||||
}
|
||||
|
||||
// sortedColumns 把 `cid|name|type|notnull|default|pk` 渲染出的行去掉 cid 后排序,
|
||||
// 使比对与列序无关(理由见 TestMigration0026ShapeMatchesStoreBootstrap)。
|
||||
func sortedColumns(rows []string) []string {
|
||||
out := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if i := strings.Index(r, "|"); i >= 0 {
|
||||
r = r[i+1:]
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// TestMigration0026ThenStoreBootstrapIsARegularPath 覆盖**真实启动顺序**
|
||||
// (cmd/cyrene/wire.go:先 MigrateUp、后 NewStore):0026 先建好列,随后 Store 的
|
||||
// 自举前滚走到 "duplicate column name" —— 那是**预期路径**,必须无害;重复建 Store
|
||||
// 同样幂等,且写入路径可用。
|
||||
func TestMigration0026ThenStoreBootstrapIsARegularPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
d := openRawMemoryDB(t)
|
||||
migrateUp(t, d)
|
||||
if !hasMemoryColumn(t, d, retentionTierColumn) {
|
||||
t.Fatalf("迁移后应有 %s 列", retentionTierColumn)
|
||||
}
|
||||
|
||||
store, err := NewStore(ctx, d, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore(列已由迁移建好): %v", err)
|
||||
}
|
||||
after := tableColumns(t, d, "memory_items")
|
||||
// 列必须还在(自举的 duplicate column 路径不许把它当成失败)。
|
||||
if !hasMemoryColumn(t, d, retentionTierColumn) {
|
||||
t.Fatalf("自举之后 %s 列消失了", retentionTierColumn)
|
||||
}
|
||||
if !hasMemoryIndex(t, d, retentionTierIndex) {
|
||||
t.Fatalf("自举之后应存在索引 %s", retentionTierIndex)
|
||||
}
|
||||
// 幂等:再建一次 Store 不报错、不改形状。
|
||||
if _, err := NewStore(ctx, d, testLogger()); err != nil {
|
||||
t.Fatalf("NewStore(第二次): %v", err)
|
||||
}
|
||||
if got := tableColumns(t, d, "memory_items"); !slices.Equal(got, after) {
|
||||
t.Fatalf("第二次自举改变了表结构:\n got=%v\nwant=%v", got, after)
|
||||
}
|
||||
// 写入路径可用(档位两列在同一笔 INSERT 落下)。
|
||||
got := seedTidyItem(t, store, gateFixtureItem("谢谢啦", time.Time{}))
|
||||
if tier, _ := readRetentionColumns(t, store, got.ID); tier != RetentionTemp {
|
||||
t.Fatalf("迁移建表后经 Store 落库的档位 = %q, want %q", tier, RetentionTemp)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user