主人 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)。
259 lines
9.4 KiB
Go
259 lines
9.4 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"slices"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||
)
|
||
|
||
// 本文件钉住**迁移 0025 与 Store 自举 DDL 的同形与幂等**(切片 W5)。
|
||
//
|
||
// 背景:`memory_ingest_reviews` 原来只由 `memory.NewStore` 的幂等前滚创建,2026-10-06
|
||
// 补登记为迁移 `0025_memory_ingest_reviews`。于是 DDL 有了**两份真源**
|
||
// (迁移 + preinsert_schema.go 的自举),而真实启动顺序是「先 MigrateUp、后 NewStore」;
|
||
// 既有部署更特殊:**表早就存在**(自举建的),只是迁移账本里没有 0025 —— 那一跳必须
|
||
// 不炸、不丢数据、不改形状。三条判据都在这里:
|
||
//
|
||
// 1. TestMigration0025CreatesAndRollsBackIngestReviewTable —— 新库上迁移真的建出
|
||
// 表与两条索引,且可回滚(down 删表 ⇒ up 重建空表);
|
||
// 2. TestMigration0025IsIdempotentOverStoreBootstrapTable —— **既有部署**:表已存在
|
||
// 且已有数据时重跑 0025,不报错、不丢行、形状不变;
|
||
// 3. TestMigration0025ShapeMatchesStoreBootstrap —— 两份 DDL **逐列逐索引同形**
|
||
// (少一列就是「新库有、老库无」的漂移,`CREATE TABLE IF NOT EXISTS` 救不了)。
|
||
|
||
// ingestReviewTable 是被测表名(与迁移、自举 DDL 里的字面量一致)。
|
||
const ingestReviewTable = "memory_ingest_reviews"
|
||
|
||
// ingestReviewMigrationID 是 0025 在 schema_migrations 里的记账 id(= 文件名去掉 .up.sql)。
|
||
const ingestReviewMigrationID = "0025_memory_ingest_reviews"
|
||
|
||
// openRawMemoryDB 开一个私有的内存库:不迁移、不建 Store,由调用方自己决定走
|
||
// 迁移还是自举(两条路径的对照是本文件的目的)。
|
||
func openRawMemoryDB(t *testing.T) *sql.DB {
|
||
t.Helper()
|
||
d, err := db.Open(context.Background(), ":memory:", testLogger())
|
||
if err != nil {
|
||
t.Fatalf("open in-memory db: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = d.Close() })
|
||
return d
|
||
}
|
||
|
||
// migrateUp 跑全量迁移(含本片新增的 0025)。
|
||
func migrateUp(t *testing.T, d *sql.DB) {
|
||
t.Helper()
|
||
if err := db.MigrateUp(context.Background(), d, testLogger()); err != nil {
|
||
t.Fatalf("MigrateUp: %v", err)
|
||
}
|
||
}
|
||
|
||
// tableExists 报告表是否存在。
|
||
func tableExists(t *testing.T, d *sql.DB, name string) bool {
|
||
t.Helper()
|
||
var n int
|
||
if err := d.QueryRowContext(context.Background(),
|
||
`SELECT count(*) FROM sqlite_master WHERE type='table' AND name = ?`, name).Scan(&n); err != nil {
|
||
t.Fatalf("check table %s: %v", name, err)
|
||
}
|
||
return n == 1
|
||
}
|
||
|
||
// tableColumns 把表结构渲染成可比较的字符串切片(name|type|notnull|default|pk),
|
||
// 顺序即建表顺序。
|
||
func tableColumns(t *testing.T, d *sql.DB, table string) []string {
|
||
t.Helper()
|
||
rows, err := d.QueryContext(context.Background(), "PRAGMA table_info("+table+")")
|
||
if err != nil {
|
||
t.Fatalf("table_info(%s): %v", table, err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
out := make([]string, 0)
|
||
for rows.Next() {
|
||
var (
|
||
cid int
|
||
name, typ string
|
||
notNull int
|
||
dflt sql.NullString
|
||
pk int
|
||
)
|
||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil {
|
||
t.Fatalf("scan table_info(%s): %v", table, err)
|
||
}
|
||
out = append(out, fmt.Sprintf("%d|%s|%s|%d|%s|%d", cid, name, typ, notNull, dflt.String, pk))
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
t.Fatalf("iterate table_info(%s): %v", table, err)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// tableIndexes 把表自己建的索引渲染成 `name|unique`(按名字排序,忽略建索引顺序)。
|
||
// `sqlite_autoindex_*` 是 TEXT PRIMARY KEY 的隐式索引,不由我们的 DDL 创建,
|
||
// 故排除 —— 主键形状已由 tableColumns 的 pk 列覆盖。
|
||
func tableIndexes(t *testing.T, d *sql.DB, table string) []string {
|
||
t.Helper()
|
||
rows, err := d.QueryContext(context.Background(), "PRAGMA index_list("+table+")")
|
||
if err != nil {
|
||
t.Fatalf("index_list(%s): %v", table, err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
out := make([]string, 0)
|
||
for rows.Next() {
|
||
var (
|
||
seq int
|
||
name string
|
||
unique int
|
||
origin string
|
||
partial int
|
||
)
|
||
if err := rows.Scan(&seq, &name, &unique, &origin, &partial); err != nil {
|
||
t.Fatalf("scan index_list(%s): %v", table, err)
|
||
}
|
||
if strings.HasPrefix(name, "sqlite_autoindex_") {
|
||
continue
|
||
}
|
||
out = append(out, fmt.Sprintf("%s|%d", name, unique))
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
t.Fatalf("iterate index_list(%s): %v", table, err)
|
||
}
|
||
slices.Sort(out)
|
||
return out
|
||
}
|
||
|
||
// countRawReviewRows 直读留痕行数(不经 Store)。
|
||
func countRawReviewRows(t *testing.T, d *sql.DB) int {
|
||
t.Helper()
|
||
var n int
|
||
if err := d.QueryRowContext(context.Background(),
|
||
"SELECT count(*) FROM "+ingestReviewTable).Scan(&n); err != nil {
|
||
t.Fatalf("count rows in %s: %v", ingestReviewTable, err)
|
||
}
|
||
return n
|
||
}
|
||
|
||
// TestMigration0025CreatesAndRollsBackIngestReviewTable 钉住迁移本身:up 建表 + 两条
|
||
// 索引、down 删表、再 up 能重建(回滚不留半状态)。
|
||
func TestMigration0025CreatesAndRollsBackIngestReviewTable(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
d := openRawMemoryDB(t)
|
||
migrateUp(t, d)
|
||
|
||
if !tableExists(t, d, ingestReviewTable) {
|
||
t.Fatalf("迁移后应存在 %s", ingestReviewTable)
|
||
}
|
||
wantIndexes := []string{
|
||
"idx_memory_ingest_reviews_created_at|0",
|
||
"idx_memory_ingest_reviews_item_id|0",
|
||
}
|
||
if got := tableIndexes(t, d, ingestReviewTable); !slices.Equal(got, wantIndexes) {
|
||
t.Fatalf("索引 = %v, want %v", got, wantIndexes)
|
||
}
|
||
cols := tableColumns(t, d, ingestReviewTable)
|
||
for _, want := range []string{"id", "item_id", "created_at"} {
|
||
if !slices.ContainsFunc(cols, func(c string) bool { return strings.Contains(c, "|"+want+"|") }) {
|
||
t.Fatalf("列 %s 缺席:%v", want, cols)
|
||
}
|
||
}
|
||
|
||
// 回滚:本表之上还压着一条 0026(`memory_retention_tier`,切片 M37),因此这里
|
||
// 回滚**两条**才轮到 0025 —— 迁移账本按 id 升序、down 按降序撤,这是既有语义。
|
||
if err := db.MigrateDown(ctx, d, 2, testLogger()); err != nil {
|
||
t.Fatalf("MigrateDown(2): %v", err)
|
||
}
|
||
if tableExists(t, d, ingestReviewTable) {
|
||
t.Fatalf("回滚后 %s 应已被删除", ingestReviewTable)
|
||
}
|
||
|
||
// 再 up:表重建且为空(down/up 前后不留残留)。
|
||
migrateUp(t, d)
|
||
if !tableExists(t, d, ingestReviewTable) {
|
||
t.Fatalf("再次迁移后应存在 %s", ingestReviewTable)
|
||
}
|
||
if got := countRawReviewRows(t, d); got != 0 {
|
||
t.Fatalf("重建后的行数 = %d, want 0", got)
|
||
}
|
||
if got := tableColumns(t, d, ingestReviewTable); !slices.Equal(got, cols) {
|
||
t.Fatalf("重建后的表结构 = %v, want %v", got, cols)
|
||
}
|
||
}
|
||
|
||
// TestMigration0025IsIdempotentOverStoreBootstrapTable 模拟**既有部署**的真实状态:
|
||
// 表已由自举建好、里面已有留痕,而迁移账本里没有 0025(本次升级的全部增量就是
|
||
// 重跑这一条)。判据:不报错、数据不丢、形状不变。
|
||
func TestMigration0025IsIdempotentOverStoreBootstrapTable(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
d := openRawMemoryDB(t)
|
||
migrateUp(t, d)
|
||
|
||
// 造出「既有部署」:把 0025 的成果抹掉(表 + 账本记录),让自举重新建表。
|
||
if _, err := d.ExecContext(ctx, "DROP TABLE "+ingestReviewTable); err != nil {
|
||
t.Fatalf("drop table: %v", err)
|
||
}
|
||
if _, err := d.ExecContext(ctx,
|
||
"DELETE FROM schema_migrations WHERE id = ?", ingestReviewMigrationID); err != nil {
|
||
t.Fatalf("unrecord migration: %v", err)
|
||
}
|
||
|
||
store, err := NewStore(ctx, d, testLogger())
|
||
if err != nil {
|
||
t.Fatalf("NewStore(自举建表): %v", err)
|
||
}
|
||
seedReviewRow(t, store, "kept", tidyTestTime(t))
|
||
beforeCols := tableColumns(t, d, ingestReviewTable)
|
||
beforeIndexes := tableIndexes(t, d, ingestReviewTable)
|
||
|
||
// 升级路径:只有 0025 待应用(表已存在)。
|
||
migrateUp(t, d)
|
||
|
||
if got := countRawReviewRows(t, d); got != 1 {
|
||
t.Fatalf("迁移后行数 = %d, want 1(既有留痕一行都不能丢)", got)
|
||
}
|
||
if got := tableColumns(t, d, ingestReviewTable); !slices.Equal(got, beforeCols) {
|
||
t.Fatalf("迁移改变了表结构:\n got=%v\nwant=%v", got, beforeCols)
|
||
}
|
||
if got := tableIndexes(t, d, ingestReviewTable); !slices.Equal(got, beforeIndexes) {
|
||
t.Fatalf("迁移改变了索引:\n got=%v\nwant=%v", got, beforeIndexes)
|
||
}
|
||
|
||
// 账本幂等:已记账后再跑一次是纯空转。
|
||
migrateUp(t, d)
|
||
if got := countRawReviewRows(t, d); got != 1 {
|
||
t.Fatalf("重复迁移后行数 = %d, want 1", got)
|
||
}
|
||
}
|
||
|
||
// TestMigration0025ShapeMatchesStoreBootstrap 是**漂移护栏**:迁移建出的表与自举
|
||
// 建出的表必须逐列、逐索引同形。它红的时候,正确的修法是让两份 DDL 对齐,
|
||
// 而不是放宽这条断言。
|
||
func TestMigration0025ShapeMatchesStoreBootstrap(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
|
||
migrated := openRawMemoryDB(t)
|
||
migrateUp(t, migrated)
|
||
|
||
bootstrapped := openRawMemoryDB(t)
|
||
if _, err := NewStore(ctx, bootstrapped, testLogger()); err != nil {
|
||
t.Fatalf("NewStore: %v", err)
|
||
}
|
||
|
||
gotCols := tableColumns(t, migrated, ingestReviewTable)
|
||
wantCols := tableColumns(t, bootstrapped, ingestReviewTable)
|
||
if !slices.Equal(gotCols, wantCols) {
|
||
t.Fatalf("迁移与自举的表结构不一致(两份 DDL 必须逐字同形):\n迁移 =%v\n自举 =%v", gotCols, wantCols)
|
||
}
|
||
gotIdx := tableIndexes(t, migrated, ingestReviewTable)
|
||
wantIdx := tableIndexes(t, bootstrapped, ingestReviewTable)
|
||
if !slices.Equal(gotIdx, wantIdx) {
|
||
t.Fatalf("迁移与自举的索引不一致:\n迁移 =%v\n自举 =%v", gotIdx, wantIdx)
|
||
}
|
||
}
|