- 新包 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 红线)
211 lines
7.0 KiB
Go
211 lines
7.0 KiB
Go
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
|
||
}
|