feat(memory): 留痕表正式迁移 0025 + 保留期清扫(W5)
- 迁移 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:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user