Files
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

262 lines
9.9 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 events
// 本文件归属**计数面**(design/26 §4.1 表二 + §4.3;切片 E1):内存聚合 + 有界
// 缓冲 + 定期 flush + 逐类汇总读取。
//
// 为什么计数不逐帧落行:心跳 / 在线状态是**高频遥测**,逐帧插一行会把库写胖,而
// 问题「上游到底推过没有」只要求「某个平台某类事件推过几次」。按
// `(platform, account_id, platform_type)` upsert 之后,行数上界是「平台数 × 事件
// 类型数」,与帧数无关(§4.1 表二)。
//
// 三条不变量(每条都有用例钉住):
//
// 1. **有界**:待落盘的键数绝不超过 `Config.MaxPendingKeys`。缓冲满了先整批落盘,
// 绝不无界增长(§4.3 的背压;对齐 AGENTS §10 的压测纪律:有界,不靠堆进程)。
// 2. **尽力而为**:落盘失败只记 WARN / 丢键记 DEBUG,**绝不**让 `Record` 失败 ——
// 入站流水线不该因为计数写不进去而失败或丢帧(§4.3)。
// 3. **首次权威**:同一个键的 tier/category 只认第一次(upsert 不刷写这两列),
// 与 `documents` 的 `ON CONFLICT(name) DO NOTHING` 同口径。
import (
"context"
"fmt"
"sync"
"time"
)
// counterKey 是计数聚合的键,**逐列对齐**迁移 0021 的唯一索引
// `idx_channel_event_counters_key (platform, account_id, platform_type)`。
// 键里刻意不含 tier/category:它们由「平台原生类型」决定,不该让同一类帧分裂成
// 两行(那会让计数上界失控,也让「推过几次」的答案分叉)。
type counterKey struct {
platform string
accountID string
platformType string
}
// counterEntry 是一个键的累计值与归类。
type counterEntry struct {
tier Tier
category Category
count int64
firstSeen time.Time
lastSeen time.Time
}
// counterBuffer 是内存计数聚合。它由互斥量保护(Record 可能来自多条渠道 goroutine)。
//
// 锁的纪律(AGENTS §六):**锁内绝不做 IO** —— drain 在锁内只做「搬运 + 清空」,
// 真正的落盘在锁外进行;失败时的回填也走一次短暂的加锁。
type counterBuffer struct {
mu sync.Mutex
max int
pending map[counterKey]counterEntry
}
// newCounterBuffer 构造聚合缓冲;最大键数 ≤0 时取 DefaultMaxPendingKeys。
func newCounterBuffer(max int) *counterBuffer {
if max <= 0 {
max = DefaultMaxPendingKeys
}
return &counterBuffer{max: max, pending: make(map[counterKey]counterEntry, max)}
}
// add 把一帧的计数累加进缓冲。返回值报告**这次计数是否被吞掉**:缓冲已达最大键数
// 且这是一个新键时为 false(该键不落内存)。它是「有界」在代码里的落点 ——
// 无论落盘成功与否,`len()` 都绝不超过 `max`。
func (b *counterBuffer) add(key counterKey, tier Tier, category Category, at time.Time) bool {
b.mu.Lock()
defer b.mu.Unlock()
if entry, ok := b.pending[key]; ok {
entry.count++
entry.lastSeen = at
b.pending[key] = entry
return true
}
if len(b.pending) >= b.max {
return false
}
b.pending[key] = counterEntry{tier: tier, category: category, count: 1, firstSeen: at, lastSeen: at}
return true
}
// len 返回待落盘的键数(≤ max)。
func (b *counterBuffer) len() int {
b.mu.Lock()
defer b.mu.Unlock()
return len(b.pending)
}
// drain 取走全部待落盘项并清空缓冲(锁内只搬运,不 IO)。
func (b *counterBuffer) drain() map[counterKey]counterEntry {
b.mu.Lock()
defer b.mu.Unlock()
if len(b.pending) == 0 {
return nil
}
out := b.pending
b.pending = make(map[counterKey]counterEntry, b.max)
return out
}
// restore 把一次**落盘失败**的项回填进缓冲,使计数不在瞬时 DB 故障里丢掉。
// 回填同样受 `max` 约束:装不下的项被丢弃并返回丢弃数(调用方记 WARN)——
// 「尽力而为」与「有界」冲突时,**有界**优先(§4.3 的背压是硬约束)。
func (b *counterBuffer) restore(entries map[counterKey]counterEntry) int {
b.mu.Lock()
defer b.mu.Unlock()
dropped := 0
for key, entry := range entries {
if existing, ok := b.pending[key]; ok {
// 落盘失败期间同一键可能又累加了新计数:合并而不是覆盖。
existing.count += entry.count
if entry.lastSeen.After(existing.lastSeen) {
existing.lastSeen = entry.lastSeen
}
if entry.firstSeen.Before(existing.firstSeen) {
existing.firstSeen = entry.firstSeen
}
b.pending[key] = existing
continue
}
if len(b.pending) >= b.max {
dropped++
continue
}
b.pending[key] = entry
}
return dropped
}
// recordCounter 把一帧计入内存聚合(design/26 §4.3:T1 与**全部帧**都计数)。
//
// 它**不返回错误**:计数是尽力而为,绝不让入站流水线因计数而失败。缓冲满时先尝试
// 整批落盘;落盘失败记 WARN(DB 出问题时每次 Record 都会试一次,这也让「DB 恢复后
// 自动收敛」不需要额外的重试器)。被吞掉的新键记 DEBUG —— 单个键的丢弃是上述 WARN
// 的后果,不在这里重复刷 WARN(避免 DB 长时间不可用时日志洪泛)。
func (s *SQLStore) recordCounter(ctx context.Context, ev Event) {
if s.counters.len() >= s.counters.max {
if err := s.flushCounters(ctx); err != nil {
s.log.Warn("channel event counter flush failed",
"err", err, "pending", s.counters.len(), "max_pending", s.counters.max)
}
}
key := counterKey{platform: ev.Platform, accountID: ev.AccountID, platformType: ev.PlatformType}
if !s.counters.add(key, ev.Tier, ev.Category, ev.CreatedAt) {
s.log.Debug("channel event counter key dropped: buffer full",
"platform", ev.Platform, "platform_type", ev.PlatformType)
}
}
// Flush 把内存里待落盘的计数写进库(组合根的常驻 ticker 与退出前的最后一次调用
// 都走它)。没有待落盘项时是空操作。
func (s *SQLStore) Flush(ctx context.Context) error {
return s.flushCounters(ctx)
}
// flushCounters 是 Flush 的实现:取走 → 落盘 → 失败回填。
func (s *SQLStore) flushCounters(ctx context.Context) error {
pending := s.counters.drain()
if len(pending) == 0 {
return nil
}
if err := s.writeCounters(ctx, pending); err != nil {
if dropped := s.counters.restore(pending); dropped > 0 {
s.log.Warn("channel event counter entries dropped after failed flush",
"dropped", dropped, "max_pending", s.counters.max)
}
return fmt.Errorf("events: flush 计数: %w", err)
}
s.log.Debug("channel event counters flushed", "keys", len(pending))
return nil
}
// upsertCounterSQL 是批量落盘的唯一语句:单事务 + 同一条语句反复执行,避免 N 次
// 往返以外的组织开销(design/26 §4.3)。`count = count + excluded.count` 使同键
// 累加;tier/category 只在**插入**时写入,冲突时保持首次的归类(首次权威)。
const upsertCounterSQL = `INSERT INTO channel_event_counters
(platform, account_id, tier, platform_type, category, count, first_seen_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(platform, account_id, platform_type) DO UPDATE SET
count = count + excluded.count,
last_seen_at = excluded.last_seen_at`
// writeCounters 在一个事务里 upsert 整批计数:要么全落,要么全不落(失败时由
// flushCounters 回填缓冲,交给下一次 flush 重试)。
func (s *SQLStore) writeCounters(ctx context.Context, pending map[counterKey]counterEntry) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("events: 计数落盘开事务: %w", err)
}
defer func() { _ = tx.Rollback() }()
for key, entry := range pending {
if _, err := tx.ExecContext(ctx, upsertCounterSQL,
key.platform, key.accountID, string(entry.tier), key.platformType, string(entry.category),
entry.count, sqliteTimeStamp(entry.firstSeen), sqliteTimeStamp(entry.lastSeen)); err != nil {
return fmt.Errorf("events: 计数落盘(platform=%s type=%s): %w", key.platform, key.platformType, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("events: 计数落盘提交: %w", err)
}
return nil
}
// listCountersSQL 的列显式列出(禁 `SELECT *`),排序固定,便于客户端做稳定展示。
const listCountersSQL = `SELECT platform, account_id, tier, platform_type, category, count,
first_seen_at, last_seen_at
FROM channel_event_counters`
// Counters 返回逐类计数汇总,按 `(platform, account_id, platform_type)` 升序。
// platform 为空表示不过滤(返回全部平台)。
//
// 它读的是**已落盘**的表,不含内存里尚未 flush 的部分 —— 这正是「计数为近似值」
// 的来源,查询面必须如实标注(design/26 §4.3/§4.5)。需要新鲜数据时先 Flush。
func (s *SQLStore) Counters(ctx context.Context, platform string) ([]Counter, error) {
query := listCountersSQL
args := make([]any, 0, 1)
if platform != "" {
query += " WHERE platform = ?"
args = append(args, platform)
}
query += " ORDER BY platform, account_id, platform_type"
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("events: 查计数行: %w", err)
}
defer func() { _ = rows.Close() }()
out := make([]Counter, 0, 16)
for rows.Next() {
var (
c Counter
tier, category string
first, last string
)
if err := rows.Scan(&c.Platform, &c.AccountID, &tier, &c.PlatformType, &category,
&c.Count, &first, &last); err != nil {
return nil, fmt.Errorf("events: 读计数行: %w", err)
}
c.Tier = Tier(tier)
c.Category = Category(category)
at, ok := parseSQLiteTime(first)
if !ok {
return nil, fmt.Errorf("events: 计数行 %s/%s 的 first_seen_at 不可解析(%q): %w",
c.Platform, c.PlatformType, first, ErrBadTimestamp)
}
c.FirstSeenAt = at
if ts, ok := parseSQLiteTime(last); ok {
c.LastSeenAt = ts
} else {
c.LastSeenAt = at
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("events: 遍历计数行: %w", err)
}
return out, nil
}