feat(memory): 留痕表正式迁移 0025 + 保留期清扫(W5)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 迁移 0025_memory_ingest_reviews(幂等,既有部署重跑不炸/不丢行)
- GC:保留期 720h + 条数上限 10 万行,挂进审查服务生命周期(Start 起 / Stop 停 / 启动先扫)
- 过期即硬删(RevertMerged 只读 memory_items,删留痕不损失回滚能力)
- 偏离一处并给理由:表只留 created_at(先例 0017;append-only;加 updated_at 需三份 DDL 真源)
- 3 个变异自检(边界 <=/元组比较方向/Retention=0)
This commit is contained in:
AskaEth committed 2026-10-06 15:59:16 +08:00
1 parent ddd3d00d09
commit ffe2518bf4
7 files changed
+861 -9

No files matched your search

@@ -0,0 +1,251 @@
package memory
import (
"context"
"fmt"
"slices"
"testing"
"time"
)
// 本文件是入库前审查留痕(design/23 §15 D3-8)的**保留期清扫**测试(切片 W5):
// 只删过期的 / 边界严格小于 / 条数上限精确且对同时间戳爆发也生效 / 批数有界且
// 可重复 / 启动期真的扫过一次。口径与 internal/runtime/events 的 gc_test.go 同构。
// countReviewRows 数留痕表里还剩几行。
func countReviewRows(t *testing.T, store *Store) int {
t.Helper()
var n int
if err := store.db.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM memory_ingest_reviews`).Scan(&n); err != nil {
t.Fatalf("count ingest reviews: %v", err)
}
return n
}
// remainingReviewIDs 按 `(created_at, rowid)` 升序返回留痕 id(最旧在前)。
func remainingReviewIDs(t *testing.T, store *Store) []string {
t.Helper()
rows, err := store.db.QueryContext(context.Background(),
`SELECT id FROM memory_ingest_reviews ORDER BY created_at, rowid`)
if err != nil {
t.Fatalf("list ingest review ids: %v", err)
}
defer func() { _ = rows.Close() }()
out := make([]string, 0)
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
t.Fatalf("scan ingest review id: %v", err)
}
out = append(out, id)
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate ingest review ids: %v", err)
}
return out
}
// seedReviewRow 落一条留痕:走生产插入路径(insertIngestReview),
// 因此 created_at 的格式与真实写入**逐字一致**(定宽 UTC 纳秒串)。
func seedReviewRow(t *testing.T, store *Store, id string, at time.Time) {
t.Helper()
if err := store.insertIngestReview(context.Background(), IngestReview{
ID: id,
ItemID: "item-" + id,
PersonaID: "persona.a",
Scope: ScopePersona,
Decision: IngestCreate,
CreatedAt: at,
}); err != nil {
t.Fatalf("insert ingest review %q: %v", id, err)
}
}
// TestSweepIngestReviewsDeletesOnlyExpired 是保留期判据的钉死:只删**严格早于**
// `now − 保留期` 的行;正好落在 cutoff 上的行保留(否则「保留期恰好 30 天」会随
// 执行时刻抖动,无法断言)。
func TestSweepIngestReviewsDeletesOnlyExpired(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
now := tidyTestTime(t)
retention := DefaultIngestReviewRetention
seedReviewRow(t, store, "expired", now.Add(-retention-time.Second))
seedReviewRow(t, store, "boundary", now.Add(-retention))
seedReviewRow(t, store, "recent", now.Add(-time.Hour))
seedReviewRow(t, store, "now", now)
stats, err := store.SweepIngestReviews(ctx, now, IngestReviewGCPolicy{Retention: retention})
if err != nil {
t.Fatalf("SweepIngestReviews: %v", err)
}
if stats.Deleted != 1 {
t.Fatalf("Deleted = %d, want 1(只有 expired 过期)", stats.Deleted)
}
if stats.Truncated != 0 {
t.Fatalf("Truncated = %d, want 0(MaxRows=0 ⇒ 不做条数裁剪)", stats.Truncated)
}
if got, want := remainingReviewIDs(t, store), []string{"boundary", "recent", "now"}; !slices.Equal(got, want) {
t.Fatalf("剩余留痕 = %v, want %v", got, want)
}
}
// TestSweepIngestReviewsKeepsNewestWithinMaxRows 是条数上限的钉死:保留期那一档
// 关掉(Retention=0)时,只按条数裁掉最旧的尾巴,**恰好**保留最新 MaxRows 条。
func TestSweepIngestReviewsKeepsNewestWithinMaxRows(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
now := tidyTestTime(t)
for i := 0; i < 12; i++ {
seedReviewRow(t, store, fmt.Sprintf("r%02d", i), now.Add(time.Duration(i)*time.Minute))
}
stats, err := store.SweepIngestReviews(ctx, now, IngestReviewGCPolicy{MaxRows: 5})
if err != nil {
t.Fatalf("SweepIngestReviews: %v", err)
}
if stats.Truncated != 7 || stats.Deleted != 0 {
t.Fatalf("裁剪统计 = (truncated=%d, deleted=%d), want (7, 0)", stats.Truncated, stats.Deleted)
}
if got := countReviewRows(t, store); got != 5 {
t.Fatalf("剩余行数 = %d, want 5", got)
}
want := []string{"r07", "r08", "r09", "r10", "r11"}
if got := remainingReviewIDs(t, store); !slices.Equal(got, want) {
t.Fatalf("保留的是 %v, want 最新 5 条 %v", got, want)
}
}
// TestSweepIngestReviewsCapIsExactOnSameTimestampBurst 是上限判据的**最强一格**:
// 明细全在同一 created_at(注入固定时钟的一次性回填)时,只比时间戳的实现会
// 一条也删不掉 —— 本实现按 `(created_at, rowid)` 元组比较,因此仍然精确裁到上限。
func TestSweepIngestReviewsCapIsExactOnSameTimestampBurst(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
now := tidyTestTime(t)
for i := 0; i < 10; i++ {
seedReviewRow(t, store, fmt.Sprintf("burst-%02d", i), now)
}
stats, err := store.SweepIngestReviews(ctx, now, IngestReviewGCPolicy{MaxRows: 3})
if err != nil {
t.Fatalf("SweepIngestReviews: %v", err)
}
if stats.Truncated != 7 {
t.Fatalf("Truncated = %d, want 7(同一时间戳也必须裁得动)", stats.Truncated)
}
if got := countReviewRows(t, store); got != 3 {
t.Fatalf("剩余行数 = %d, want 3", got)
}
want := []string{"burst-07", "burst-08", "burst-09"}
if got := remainingReviewIDs(t, store); !slices.Equal(got, want) {
t.Fatalf("保留的是 %v, want 最后插入的三条 %v", got, want)
}
}
// TestSweepIngestReviewsBatchesAreBoundedAndRepeatable 钉住两点:
// ① 一轮清扫**分批**收手(1200 条 = 500+500+200,第三批不满即停,不一次删全表);
// ② 清扫可重复:再扫一轮删 0 行,绝不误删。
func TestSweepIngestReviewsBatchesAreBoundedAndRepeatable(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
now := tidyTestTime(t)
retention := DefaultIngestReviewRetention
const expiredRows = 3*ingestReviewSweepBatchSize - 300 // 1200 = 500 + 500 + 200
for i := 0; i < expiredRows; i++ {
seedReviewRow(t, store, fmt.Sprintf("old-%04d", i), now.Add(-retention-time.Duration(i+1)*time.Second))
}
stats, err := store.SweepIngestReviews(ctx, now, IngestReviewGCPolicy{Retention: retention})
if err != nil {
t.Fatalf("SweepIngestReviews: %v", err)
}
if stats.Deleted != expiredRows {
t.Fatalf("Deleted = %d, want %d", stats.Deleted, expiredRows)
}
if stats.Batches != 3 {
t.Fatalf("Batches = %d, want 3(500 + 500 + 200)", stats.Batches)
}
if got := countReviewRows(t, store); got != 0 {
t.Fatalf("剩余行数 = %d, want 0", got)
}
again, err := store.SweepIngestReviews(ctx, now, IngestReviewGCPolicy{Retention: retention})
if err != nil {
t.Fatalf("second SweepIngestReviews: %v", err)
}
if again.Deleted != 0 || again.Truncated != 0 {
t.Fatalf("重复清扫 = (deleted=%d, truncated=%d), want (0, 0)", again.Deleted, again.Truncated)
}
}
// TestSweepIngestReviewsZeroPolicyIsNoop 钉住「档位各自独立」的边界:两档都 ≤0 时
// 一轮清扫**一行都不动**(不是「偷偷按默认值删」——默认值由调用方显式传入)。
func TestSweepIngestReviewsZeroPolicyIsNoop(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
now := tidyTestTime(t)
seedReviewRow(t, store, "ancient", now.Add(-10000*time.Hour))
seedReviewRow(t, store, "fresh", now)
stats, err := store.SweepIngestReviews(ctx, now, IngestReviewGCPolicy{})
if err != nil {
t.Fatalf("SweepIngestReviews: %v", err)
}
if stats.Deleted != 0 || stats.Truncated != 0 || stats.Batches != 0 {
t.Fatalf("零值 policy = %+v, want 全零(一行都不动)", stats)
}
if got := countReviewRows(t, store); got != 2 {
t.Fatalf("剩余行数 = %d, want 2", got)
}
}
// TestReviewerStartSweepsExpiredIngestReviews 是**接线**的证据:清扫不是躺在包里的
// 死代码 —— Start 拉起它、启动期真的扫过一次(过期行消失、不过期的行原样保留)。
func TestReviewerStartSweepsExpiredIngestReviews(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
seedReviewRow(t, store, "expired", base.Add(-DefaultIngestReviewRetention-time.Hour))
seedReviewRow(t, store, "fresh", base)
r := newTestReviewer(t, store, nil, 0.80)
r.Start(ctx)
t.Cleanup(func() {
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = r.Stop(stopCtx)
})
waitFor(t, func() bool { return countReviewRows(t, store) == 1 }, "启动期清扫删掉过期留痕")
if got := remainingReviewIDs(t, store); !slices.Equal(got, []string{"fresh"}) {
t.Fatalf("剩余留痕 = %v, want [fresh](只删过期的)", got)
}
}
// TestReviewerDisabledDoesNotSweep 钉住关闭语义不漂移:`Enabled=false` 时 Start
// 只记一条日志就返回(既有测试已钉「不起任何协程」),因此**不会**顺带清留痕。
// 这是如实登记的口径而非缺陷:关闭审查后不再产生新留痕,且组合根在关闭时
// 根本不构造审查服务(cmd/cyrene/wire_memory_review.go)。
func TestReviewerDisabledDoesNotSweep(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
seedReviewRow(t, store, "expired", base.Add(-DefaultIngestReviewRetention-time.Hour))
off, err := NewPreInsertReviewer(ReviewConfig{Enabled: false}, ReviewDeps{Store: store, Log: testLogger()})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
off.Start(ctx)
if got := countReviewRows(t, store); got != 1 {
t.Fatalf("关闭时留痕行数 = %d, want 1(关闭 ⇒ 不扫、也不新增)", got)
}
}