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) } }