Files
Cyrene_v2/internal/runtime/events/counters_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

211 lines
7.0 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.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
}