Files
Cyrene_v2/internal/runtime/events/gc_test.go
T
AskaEth ec3f6e810a feat(events): 渠道事件存储地基(Event/Store + SQLite 实现 + 有界计数聚合 + 保留期 GC + 迁移 0021)
- 新包 internal/runtime/events:Tier/Category/Platform/ChannelType/ActorRole 具名常量 + Event/Filter/Counter + Store 六方法
- 留存口径:T2/T3 逐条落行;T1 只计数;unknown / decode_error / link 三例外逐条落行(链路是否活着是事故定案唯一证据)
- 计数:内存聚合 + 上限 512 的有界缓冲 + 满即整批 upsert,落盘失败回填而不丢;GC:保留期 720h/间隔 1h,≤0 = 关闭
- 迁移 0021_channel_events(两表 + 6 索引,含计数 upsert 唯一键);Event 无 flag/正文/URL 字段(design/26 §4.2 红线)
2026-10-05 13:47:25 +08:00

210 lines
6.8 KiB
Go
Raw 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 events
// 本文件是**保留期清扫**的验收(design/26 §4.4;切片 E1)。三类必测:
//
// 1. **≤0 = 关闭**(保留期 ≤0、间隔 ≤0 各一条):`Enabled()` 为 false 且 `Sweep`
// 不动任何一行 —— 「配了却不生效」不存在(关闭原因由 NewStore 记 WARN);
// 2. **正数清扫**:超期行按 `created_at < now − 保留期` 逐批删除,未超期的行与原样
// 保留,计数表**不参与 GC**;
// 3. **失败不退出、不清空**:库不可用 / ctx 取消时 `Sweep` 返回错误(组合根记 WARN
// 后等下一轮,绝不因此退出进程),**已经删的保持删除、未删的绝不被清空**。
import (
"context"
"testing"
"time"
)
// seedExpired 写入一条指定时刻的 T2 事件(走真实写入路径,不直接拼 SQL)。
func seedExpired(t *testing.T, st *SQLStore, at time.Time, platformType string) {
t.Helper()
ev := sampleT2()
ev.PlatformType = platformType
ev.CreatedAt = at
if err := st.Record(context.Background(), ev); err != nil {
t.Fatalf("Record(%s): %v", platformType, err)
}
}
// TestSweep_RetentionDisabled_边界:保留期 ≤0 ⇒ GC 关闭(显式),Sweep 是空操作。
func TestSweep_RetentionDisabled_边界(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, _ := newTestStore(t, func(cfg *Config) {
cfg.GCInterval = time.Hour
cfg.Retention = 0
})
if st.Enabled() {
t.Fatal("Enabled() = true, want false(保留期 ≤0 = 关闭 GC)")
}
if st.Interval() != time.Hour || st.Retention() != 0 {
t.Fatalf("Interval/Retention = %v/%v, want 1h/0", st.Interval(), st.Retention())
}
seedExpired(t, st, testNow().Add(-1000*time.Hour), "group_recall")
stats, err := st.Sweep(ctx)
if err != nil {
t.Fatalf("Sweep(disabled) err = %v, want nil", err)
}
if stats.Batches != 0 || stats.Deleted != 0 {
t.Fatalf("Sweep(disabled) = %+v, want zero stats", stats)
}
if n, err := st.Count(ctx, Filter{}); err != nil {
t.Fatalf("Count: %v", err)
} else if n != 1 {
t.Fatalf("rows = %d, want 1(GC 关闭时一行都不许动)", n)
}
}
// TestSweep_IntervalDisabled_边界:间隔 ≤0 ⇒ 同样是关闭(与文档 GC 逐字同口径)。
func TestSweep_IntervalDisabled_边界(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, _ := newTestStore(t, func(cfg *Config) {
cfg.GCInterval = 0
cfg.Retention = DefaultRetention
})
if st.Enabled() {
t.Fatal("Enabled() = true, want false(间隔 ≤0 = 关闭 GC)")
}
seedExpired(t, st, testNow().Add(-1000*time.Hour), "group_recall")
stats, err := st.Sweep(ctx)
if err != nil {
t.Fatalf("Sweep(disabled) err = %v, want nil", err)
}
if stats.Deleted != 0 {
t.Fatalf("Sweep(disabled) deleted = %d, want 0", stats.Deleted)
}
if n, err := st.Count(ctx, Filter{}); err != nil {
t.Fatalf("Count: %v", err)
} else if n != 1 {
t.Fatalf("rows = %d, want 1", n)
}
}
// TestSweep_DeletesExpiredOnly_正常:正数保留期 ⇒ 只删超期行;未超期的行保留,
// **计数表不参与 GC**(design/26 §4.4:删了就答不上「上游推过没有」)。
func TestSweep_DeletesExpiredOnly_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
const retention = 24 * time.Hour
st, _ := newTestStore(t, func(cfg *Config) {
cfg.GCInterval = time.Hour
cfg.Retention = retention
})
if !st.Enabled() {
t.Fatal("Enabled() = false, want true")
}
seedExpired(t, st, testNow().Add(-48*time.Hour), "expired")
seedExpired(t, st, testNow().Add(-time.Hour), "fresh")
if err := st.Record(ctx, Event{
Platform: PlatformQQ, Tier: TierT1, Category: CategoryHeartbeat, PlatformType: "heartbeat",
}); err != nil {
t.Fatalf("Record(heartbeat): %v", err)
}
if err := st.Flush(ctx); err != nil {
t.Fatalf("Flush: %v", err)
}
stats, err := st.Sweep(ctx)
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if stats.Deleted != 1 {
t.Fatalf("Deleted = %d, want 1", stats.Deleted)
}
rows, err := st.List(ctx, Filter{})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(rows) != 1 || rows[0].PlatformType != "fresh" {
t.Fatalf("rows after sweep = %+v, want exactly the fresh one", rows)
}
// 计数表不参与 GC:即便某个键的**事件行**被清扫掉,它的计数行照旧在(design/26
// §4.4 —— 删了就再也答不上「上游推过没有」)。
counters := mustCounters(t, st, PlatformQQ)
seen := make(map[string]int64, len(counters))
for _, c := range counters {
seen[c.PlatformType] = c.Count
}
for _, key := range []string{"expired", "fresh", "heartbeat"} {
if seen[key] != 1 {
t.Fatalf("counter %q = %d, want 1(全部计数行都必须留着): %+v", key, seen[key], counters)
}
}
}
// TestSweep_MultipleBatches_边界:积压超过单批上限时按批删除(一轮的时长与内存占用
// 有界),且一轮内能把超过一批的量扫完。
func TestSweep_MultipleBatches_边界(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, _ := newTestStore(t, func(cfg *Config) {
cfg.GCInterval = time.Hour
cfg.Retention = time.Hour
})
const rows = sweepBatchSize + 1
old := testNow().Add(-100 * time.Hour)
for i := 0; i < rows; i++ {
seedExpired(t, st, old, "expired")
}
stats, err := st.Sweep(ctx)
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if stats.Deleted != int64(rows) {
t.Fatalf("Deleted = %d, want %d", stats.Deleted, rows)
}
if stats.Batches != 2 {
t.Fatalf("Batches = %d, want 2 (单批上限 %d)", stats.Batches, sweepBatchSize)
}
if n, err := st.Count(ctx, Filter{}); err != nil {
t.Fatalf("Count: %v", err)
} else if n != 0 {
t.Fatalf("rows = %d, want 0", n)
}
}
// TestSweep_FailureDoesNotClear_异常:库不可用时 Sweep 返回错误(组合根记 WARN 后
// 继续),ctx 取消时立刻停下且**不清空**未删的行。
func TestSweep_FailureDoesNotClear_异常(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, conn := newTestStore(t, func(cfg *Config) {
cfg.GCInterval = time.Hour
cfg.Retention = time.Hour
})
seedExpired(t, st, testNow().Add(-100*time.Hour), "expired")
// ① ctx 取消:立即停下、返回错误、一行都不删(未删的绝不因此被清空)。
canceled, cancel := context.WithCancel(ctx)
cancel()
stats, err := st.Sweep(canceled)
if err == nil {
t.Fatal("Sweep(canceled ctx) err = nil, want an error")
}
if stats.Deleted != 0 {
t.Fatalf("Sweep(canceled ctx) deleted = %d, want 0", stats.Deleted)
}
if n, cerr := st.Count(ctx, Filter{}); cerr != nil {
t.Fatalf("Count: %v", cerr)
} else if n != 1 {
t.Fatalf("rows = %d, want 1 (取消不得清空)", n)
}
// ② 库不可用:同样返回错误(调用方等下一轮),且不 panic。
if err := conn.Close(); err != nil {
t.Fatalf("关闭内存库: %v", err)
}
if _, err := st.Sweep(ctx); err == nil {
t.Fatal("Sweep(closed db) err = nil, want an error")
}
}