package events // 本文件是**计数面**的验收(design/26 §4.1 表二 + §4.3;切片 E1)。三条不变量在这里 // 各自有对应用例:**有界**(缓冲满即落盘、绝不无界增长)、**尽力而为**(落盘失败 // 不上抛、不丢帧)、**近似值语义**(读的是已落盘的表,不是内存里的实时值)。 import ( "context" "fmt" "testing" "time" ) // TestCounters_BoundedBuffer_边界:缓冲设上界后,**必须**在满时整批落盘,且待落盘 // 键数在任何时刻都不超过上界(design/26 §4.3 的背压;对齐 AGENTS §10 压测纪律的 // 「有界」)。同时验证落盘不是「丢计数」:最终总数与录制条数一致。 func TestCounters_BoundedBuffer_边界(t *testing.T) { t.Parallel() ctx := context.Background() const maxKeys = 4 st, _ := newTestStore(t, func(cfg *Config) { cfg.MaxPendingKeys = maxKeys }) const frames = 20 for i := 0; i < frames; i++ { if err := st.Record(ctx, Event{ Platform: PlatformQQ, Tier: TierT1, Category: CategoryHeartbeat, PlatformType: fmt.Sprintf("type-%02d", i), }); err != nil { t.Fatalf("Record(#%d): %v", i, err) } if got := st.PendingKeys(); got > maxKeys { t.Fatalf("PendingKeys = %d after frame #%d, want ≤ %d(缓冲必须有界)", got, i, maxKeys) } } // 「有界」的直接推论:这一路必须**已经落过盘**(否则内存里会攒着 20 个键)。 flushed, err := st.Counters(ctx, PlatformQQ) if err != nil { t.Fatalf("Counters: %v", err) } if len(flushed) == 0 { t.Fatal("no counters flushed to the database: a bounded buffer must flush when full") } // 落盘 = 计数搬家而不是丢掉:最后一次 flush 后总数必须恰为 frames。 if err := st.Flush(ctx); err != nil { t.Fatalf("Flush: %v", err) } var total int64 for _, c := range mustCounters(t, st, PlatformQQ) { total += c.Count } if total != frames { t.Fatalf("flushed count total = %d, want %d", total, frames) } if got := st.PendingKeys(); got != 0 { t.Fatalf("PendingKeys = %d after Flush, want 0", got) } } // TestCounters_FlushFailureStaysBounded_异常:数据库不可用时,计数落盘失败只记 WARN、 // **不上抛**(Record 仍返回 nil —— 计数是尽力而为,绝不让入站流水线失败,§4.3), // 并且缓冲**仍然有界**(绝不因为一直失败而无界增长)。 func TestCounters_FlushFailureStaysBounded_异常(t *testing.T) { t.Parallel() ctx := context.Background() const maxKeys = 2 st, conn := newTestStore(t, func(cfg *Config) { cfg.MaxPendingKeys = maxKeys }) if err := conn.Close(); err != nil { t.Fatalf("关闭内存库: %v", err) } for i := 0; i < 5; i++ { if err := st.Record(ctx, Event{ Platform: PlatformQQ, Tier: TierT1, Category: CategoryHeartbeat, PlatformType: fmt.Sprintf("type-%02d", i), }); err != nil { t.Fatalf("Record(#%d) err = %v, want nil(计数失败绝不能上抛)", i, err) } if got := st.PendingKeys(); got > maxKeys { t.Fatalf("PendingKeys = %d after failing flush #%d, want ≤ %d", got, i, maxKeys) } } if got := st.PendingKeys(); got != maxKeys { t.Fatalf("PendingKeys = %d, want %d (失败项回填后仍受上界约束)", got, maxKeys) } } // TestCounters_SameKeyAccumulates_正常:同一个键(平台+账号+原生类型)反复计数是 // **累加同一行**,且 tier/category 首次权威、first/last_seen_at 如实推进。 func TestCounters_SameKeyAccumulates_正常(t *testing.T) { t.Parallel() ctx := context.Background() st, _ := newTestStore(t, nil) ev := Event{ Platform: PlatformQQ, AccountID: "bot-1", Tier: TierT1, Category: CategoryHeartbeat, PlatformType: "heartbeat", } for i := 0; i < 3; i++ { if err := st.Record(ctx, ev); err != nil { t.Fatalf("Record(#%d): %v", i, err) } } if err := st.Flush(ctx); err != nil { t.Fatalf("Flush: %v", err) } later := ev later.CreatedAt = testNow().Add(time.Minute) for i := 0; i < 2; i++ { if err := st.Record(ctx, later); err != nil { t.Fatalf("Record(later #%d): %v", i, err) } } if err := st.Flush(ctx); err != nil { t.Fatalf("Flush: %v", err) } rows := mustCounters(t, st, PlatformQQ) if len(rows) != 1 { t.Fatalf("counter rows = %d, want 1 (同键必须合并)", len(rows)) } got := rows[0] if got.Count != 5 { t.Fatalf("count = %d, want 5 (3 + 2)", got.Count) } if !got.FirstSeenAt.Equal(testNow()) { t.Fatalf("first_seen_at = %v, want %v (首次权威)", got.FirstSeenAt, testNow()) } if !got.LastSeenAt.Equal(later.CreatedAt) { t.Fatalf("last_seen_at = %v, want %v", got.LastSeenAt, later.CreatedAt) } if got.AccountID != "bot-1" || got.PlatformType != "heartbeat" { t.Fatalf("key columns = (%q, %q), want (bot-1, heartbeat)", got.AccountID, got.PlatformType) } } // TestCounters_PlatformFilter_正常:Counters 的 platform 参数为空表示全部平台, // 非空即精确过滤(design/26 §4.5 的查询参数语义)。 func TestCounters_PlatformFilter_正常(t *testing.T) { t.Parallel() ctx := context.Background() st, _ := newTestStore(t, nil) for _, platform := range []string{PlatformQQ, PlatformKook} { if err := st.Record(ctx, Event{ Platform: platform, Tier: TierT1, Category: CategoryHeartbeat, PlatformType: "heartbeat", }); err != nil { t.Fatalf("Record(%s): %v", platform, err) } } if err := st.Flush(ctx); err != nil { t.Fatalf("Flush: %v", err) } if rows := mustCounters(t, st, ""); len(rows) != 2 { t.Fatalf("all platforms = %d rows, want 2", len(rows)) } rows := mustCounters(t, st, PlatformKook) if len(rows) != 1 || rows[0].Platform != PlatformKook { t.Fatalf("filtered rows = %+v, want exactly the kook counter", rows) } if rows := mustCounters(t, st, PlatformTelegram); len(rows) != 0 { t.Fatalf("unknown platform = %d rows, want 0", len(rows)) } } // TestCounters_PendingNotVisibleBeforeFlush_边界:Counters 读的是**已落盘**的表 —— // 这正是「计数为近似值」的来源(design/26 §4.3/§4.5),查询面必须如实标注。 func TestCounters_PendingNotVisibleBeforeFlush_边界(t *testing.T) { t.Parallel() ctx := context.Background() st, _ := newTestStore(t, nil) if err := st.Record(ctx, Event{ Platform: PlatformQQ, Tier: TierT1, Category: CategoryHeartbeat, PlatformType: "heartbeat", }); err != nil { t.Fatalf("Record: %v", err) } if rows := mustCounters(t, st, PlatformQQ); len(rows) != 0 { t.Fatalf("rows before Flush = %d, want 0(内存计数不算已落盘)", len(rows)) } if st.PendingKeys() != 1 { t.Fatalf("PendingKeys = %d, want 1", st.PendingKeys()) } if err := st.Flush(ctx); err != nil { t.Fatalf("Flush: %v", err) } if rows := mustCounters(t, st, PlatformQQ); len(rows) != 1 { t.Fatalf("rows after Flush = %d, want 1", len(rows)) } } // mustCounters 是 Counters 的测试包装(失败即 Fatal)。 func mustCounters(t *testing.T, st *SQLStore, platform string) []Counter { t.Helper() rows, err := st.Counters(context.Background(), platform) if err != nil { t.Fatalf("Counters(%q): %v", platform, err) } return rows }