Files
AskaEth ffe2518bf4
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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)
2026-10-06 15:59:16 +08:00

252 lines
9.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}