- 迁移 0025_memory_ingest_reviews(幂等,既有部署重跑不炸/不丢行) - GC:保留期 720h + 条数上限 10 万行,挂进审查服务生命周期(Start 起 / Stop 停 / 启动先扫) - 过期即硬删(RevertMerged 只读 memory_items,删留痕不损失回滚能力) - 偏离一处并给理由:表只留 created_at(先例 0017;append-only;加 updated_at 需三份 DDL 真源) - 3 个变异自检(边界 <=/元组比较方向/Retention=0)
252 lines
9.4 KiB
Go
252 lines
9.4 KiB
Go
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)
|
||
}
|
||
}
|