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

405 lines
16 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):构造、写入路径
// (`Record` 的逐条落行 + 计数归属)与两个只读查询(`Count` / `List`)。
//
// 三条取舍写在这里:
//
// 1. **T2/T3/unknown 同步逐条落行,失败上抛**(§4.3):这些行有审计价值、丢不起,
// 它们的失败必须让调用方看见(E3 据此记 WARN,但**不**因此丢帧 —— 留存与
// 「进不进上下文」是两个独立动作)。
// 2. **T1 只累加计数**(§4.3):心跳/在线状态是高频遥测,逐帧写库是纯浪费。
// 两个**例外**逐条落行(见 persistsAsRow):`unknown`/`decode_error`(§3.7 明令
// 「留痕 + 计数」)与 §3.6 的链路事件(`link`,它回答的正是「链路当时是不是
// 活的」——design/26 §0.2 事故定案所依赖的那份证据)。
// 3. **时间戳口径与既有迁移逐字同形**(`strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
// UTC 毫秒 + 字面量 Z):定宽 ⇒ 字典序比较等价于时间序,时间窗筛选因此能直接
// 吃上 `idx_channel_events_created_at`(与 `runtime/model` 的 `llm/usage` 同口径)。
import (
"context"
"database/sql"
"fmt"
"io"
"log/slog"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
const (
// DefaultFlushInterval 是 `CHANNEL_EVENT_COUNTER_FLUSH` 的默认值(design/26 §4.3):
// 内存计数每 30s 落盘一次。30s 是「心跳不把库写胖」与「crash 后还能答上近期
// 有没有推」之间的取舍值。
DefaultFlushInterval = 30 * time.Second
// DefaultMaxPendingKeys 是计数缓冲的**键数上界**(design/26 §4.3 的背压):
// 不同 `(platform, account_id, platform_type)` 键超过它就整批落盘。有了它,
// 内存占用与「DB 长时间不可用」时长脱钩 —— 绝不无界增长。
DefaultMaxPendingKeys = 512
// DefaultListLimit 是 List 的缺省页大小;MaxListLimit 是它的硬上界
// (超过即钳住,绝不静默返回全表)。
DefaultListLimit = 100
MaxListLimit = 500
// sqliteTimeLayout 与迁移 0021 的默认值 `strftime('%Y-%m-%dT%H:%M:%fZ','now')`
// 逐字同形(UTC、毫秒、字面量 Z)。写入与解析共用它,使「我们写进去的」与
// 「SQLite 默认生成的」可以直接比较(与 `documents` 同一手法)。
sqliteTimeLayout = "2006-01-02T15:04:05.000"
// eventsLogSource 是全部日志的 source 标签(AGENTS §10:禁无来源裸日志)。
eventsLogSource = "runtime/events"
)
// Config 是事件存储的接线参数。时间与开关口径由调用方(组合根)给出:本包**不读
// 环境变量**、不猜数据目录布局(与 `documents.StoreConfig` / `documents.GCConfig`
// 同一理由)。
type Config struct {
// DB 是活连接(组合根注入既有 SQLite 连接池;测试注入临时库)。为 nil 时
// NewStore 返回 ErrNilDB —— 计数的 fail-visible 降级(§4.3「store 为 nil ⇒
// 只记 DEBUG + 内存计数」)由桥接层(E3)负责,不在本包伪造替身。
DB *sql.DB
// Log 是基础 logger;nil 时替换为标记来源 runtime/events 的丢弃 logger。
Log *slog.Logger
// Now 是可注入时钟(测试用);nil 时用 time.Now。
Now func() time.Time
// FlushInterval 是内存计数的定期落盘间隔(`CHANNEL_EVENT_COUNTER_FLUSH`)。
// **≤0 取 DefaultFlushInterval**(与 GC 不同:计数 flush 不是破坏性动作,
// 「配了 0 就永远不落盘」只会悄悄丢掉计数证据,因此这里不做「显式关闭」)。
FlushInterval time.Duration
// MaxPendingKeys 是计数缓冲的键数上界;≤0 取 DefaultMaxPendingKeys。
MaxPendingKeys int
// GCInterval 是保留期清扫的间隔(`CHANNEL_EVENT_GC_INTERVAL`)。
// **≤0 = 关闭 GC**(含启动期清扫),默认 DefaultGCInterval。
GCInterval time.Duration
// Retention 是 `channel_events` 的保留期(`CHANNEL_EVENT_RETENTION`)。
// **≤0 = 关闭 GC**,默认 DefaultRetention。0 刻意**不**解释成「立即清扫」:
// 那会让一次笔误把整张事件表清空 —— 而它正是「上游推过没有」的唯一证据。
Retention time.Duration
}
// SQLStore 是基于 SQLite 的事件存储实现。它可安全并发使用:连接池本身串行化写
// (`db.Open` 的 SetMaxOpenConns(1)),内存计数缓冲由自身互斥量保护。
type SQLStore struct {
db *sql.DB
log *slog.Logger
now func() time.Time
counters *counterBuffer
flushInterval time.Duration
gcInterval time.Duration
retention time.Duration
gcEnabled bool
}
// NewStore 构造事件存储。唯一的失败是 DB 为 nil(ErrNilDB);GC 的关闭**不是**
// 构造失败,它是可见的正常状态(Enabled() 为 false + 一条记明原因的 WARN)。
func NewStore(cfg Config) (*SQLStore, error) {
if cfg.DB == nil {
return nil, fmt.Errorf("events: new store: %w", ErrNilDB)
}
log := sourcedLog(cfg.Log)
now := cfg.Now
if now == nil {
now = time.Now
}
flush := cfg.FlushInterval
if flush <= 0 {
flush = DefaultFlushInterval
}
s := &SQLStore{
db: cfg.DB,
log: log,
now: now,
counters: newCounterBuffer(cfg.MaxPendingKeys),
flushInterval: flush,
gcInterval: cfg.GCInterval,
retention: cfg.Retention,
}
switch {
case cfg.GCInterval <= 0:
log.Warn("channel event gc disabled: interval <= 0", "interval", cfg.GCInterval.String())
case cfg.Retention <= 0:
log.Warn("channel event gc disabled: retention <= 0", "retention", cfg.Retention.String())
default:
s.gcEnabled = true
}
log.Info("channel event store ready",
"flush_interval", s.flushInterval.String(),
"max_pending_keys", s.counters.max,
"gc_enabled", s.gcEnabled,
"gc_interval", s.gcInterval.String(),
"retention", s.retention.String())
return s, nil
}
// sourcedLog 把基础 logger 标上 source=runtime/events;nil 时给出丢弃 logger,
// 使调用方永远可以安全记日志(与 `documents` / `history` 同一手法)。
func sourcedLog(l *slog.Logger) *slog.Logger {
if l == nil {
l = slog.New(slog.NewTextHandler(io.Discard, nil))
}
return logger.WithSource(l, eventsLogSource)
}
// FlushInterval 返回生效的计数落盘间隔(组合根的常驻 ticker 用它)。
func (s *SQLStore) FlushInterval() time.Duration {
if s == nil {
return 0
}
return s.flushInterval
}
// PendingKeys 返回内存里待落盘的计数的键数。它有上界(MaxPendingKeys),因此可以
// 直接作为健康指标暴露给面板:只涨不落 = DB 出了问题。
func (s *SQLStore) PendingKeys() int {
if s == nil {
return 0
}
return s.counters.len()
}
// Record 记录一帧事件(design/26 §4.3 的唯一写入口)。
//
// 判定顺序刻意固定:**先校验 → 再归一化 → 再按档落行 → 最后计数**。这样即便逐条
// 落行失败(返回错误),调用方也能明确知道「这一帧没能留下可查痕迹」,而不会拿到
// 一个「成功了但其实什么都没落」的假象。
//
// 计数侧的错误**不上抛**:计数是尽力而为(§4.3),绝不因为计数落盘失败而让入站
// 流水线失败或丢帧。缓冲满了先整批落盘,失败则记 WARN。
func (s *SQLStore) Record(ctx context.Context, ev Event) error {
if strings.TrimSpace(ev.Platform) == "" {
return fmt.Errorf("events: record: %w", ErrNoPlatform)
}
ev = s.normalize(ev)
if persistsAsRow(ev.Tier, ev.Category) {
if err := s.insertEvent(ctx, ev); err != nil {
return fmt.Errorf("events: record 落行: %w", err)
}
}
s.recordCounter(ctx, ev)
return nil
}
// normalize 补齐一条待写事件:时钟零值填当前时刻;tier 取值非法时归一为 TierT1
// 并记 WARN —— 这是 fail-safe(§1.5):看不懂的档**只**降级成「只计数」,绝不让
// 一个字段写错演变成「这一帧什么都没有留下」。
func (s *SQLStore) normalize(ev Event) Event {
switch ev.Tier {
case TierT1, TierT2, TierT3:
default:
s.log.Warn("channel event tier invalid, falling back to t1",
"platform", ev.Platform, "platform_type", ev.PlatformType, "tier", string(ev.Tier))
ev.Tier = TierT1
}
if ev.CreatedAt.IsZero() {
ev.CreatedAt = s.now()
}
return ev
}
// persistsAsRow 判定一帧是否要逐条落 `channel_events`(design/26 §4.1/§4.3/§3.6/§3.7):
//
// - T2 / T3:逐条(有审计价值,丢不起);
// - unknown / decode_error:逐条(§3.7「落库 + 计数 + 脱敏结构留痕」,且
// **绝不进上下文**——兜底不等于放行);
// - link:逐条(§3.6 明写「落 channel_events(category=link)」;链路事件是
// §0.2 事故里「当时链路活没活」的唯一答案,即便它是 T1 也不能只留一个计数)。
//
// 它是纯函数(按 AGENTS §10 不引入包级可变状态)。
func persistsAsRow(tier Tier, category Category) bool {
switch tier {
case TierT2, TierT3:
return true
}
switch category {
case CategoryUnknown, CategoryDecodeError, CategoryLink:
return true
default:
return false
}
}
// insertEventSQL 是逐条落行的唯一语句:列名显式列出(禁 `SELECT *` 的同一条纪律
// 在写侧的表现),值全部参数化。updated_at 与 created_at 同值:事件行不可变,
// 两列同形使「读回时的 updated_at」不会比事实更晚。
const insertEventSQL = `INSERT INTO channel_events
(platform, account_id, tier, category, platform_type, channel_id, channel_type,
group_id, actor_id, actor_role, target_id, self_related, message_id, summary,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
// insertEvent 写一行 `channel_events`。
func (s *SQLStore) insertEvent(ctx context.Context, ev Event) error {
stamp := sqliteTimeStamp(ev.CreatedAt)
if _, err := s.db.ExecContext(ctx, insertEventSQL,
ev.Platform, ev.AccountID, string(ev.Tier), string(ev.Category), ev.PlatformType,
ev.ChannelID, ev.ChannelType, ev.GroupID, ev.ActorID, ev.ActorRole, ev.TargetID,
boolToInt(ev.SelfRelated), ev.MessageID, ev.Summary, stamp, stamp); err != nil {
return fmt.Errorf("events: 落事件行(platform=%s type=%s): %w", ev.Platform, ev.PlatformType, err)
}
return nil
}
// Count 统计符合条件的事件行数。
func (s *SQLStore) Count(ctx context.Context, f Filter) (int64, error) {
where, args := f.where()
var n int64
if err := s.db.QueryRowContext(ctx, "SELECT count(*) FROM channel_events"+where, args...).Scan(&n); err != nil {
return 0, fmt.Errorf("events: 统计事件行: %w", err)
}
return n, nil
}
// listEventsSQL 的列显式列出(与迁移 0021 同序),便于与 scanEvent 对齐核对。
const listEventsSQL = `SELECT id, platform, account_id, tier, category, platform_type,
channel_id, channel_type, group_id, actor_id, actor_role, target_id, self_related,
message_id, summary, created_at, updated_at
FROM channel_events`
// List 按 `created_at DESC, id DESC`(倒序,design/26 §4.5)返回符合条件的事件行。
// id 参与排序是为了让同一毫秒内的多行有确定顺序,分页因此不会重复或漏行。
func (s *SQLStore) List(ctx context.Context, f Filter) ([]Event, error) {
where, args := f.where()
limit, offset := f.pageLimit(), f.pageOffset()
query := listEventsSQL + where + " ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("events: 查事件行: %w", err)
}
defer func() { _ = rows.Close() }()
out := make([]Event, 0, limit)
for rows.Next() {
ev, err := scanEvent(rows)
if err != nil {
return nil, err
}
out = append(out, ev)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("events: 遍历事件行: %w", err)
}
return out, nil
}
// scanEvent 读出一行 `channel_events`。时间戳读不出来时返回错误而不是零值时间:
// 一个读不懂的时间戳意味着这一行被谁改坏了,「如实报错」比「静默给一个 1970 年」
// 更接近 design/26 的诚实口径(与 `documents` GC 的「读不懂的时间绝不当成已超期」
// 同一个保守方向)。
func scanEvent(rows *sql.Rows) (Event, error) {
var (
ev Event
tier, category string
selfRelated int64
created, updated string
)
if err := rows.Scan(&ev.ID, &ev.Platform, &ev.AccountID, &tier, &category, &ev.PlatformType,
&ev.ChannelID, &ev.ChannelType, &ev.GroupID, &ev.ActorID, &ev.ActorRole, &ev.TargetID,
&selfRelated, &ev.MessageID, &ev.Summary, &created, &updated); err != nil {
return Event{}, fmt.Errorf("events: 读事件行: %w", err)
}
ev.Tier = Tier(tier)
ev.Category = Category(category)
ev.SelfRelated = selfRelated != 0
at, ok := parseSQLiteTime(created)
if !ok {
return Event{}, fmt.Errorf("events: 事件行 %d 的 created_at 不可解析(%q): %w", ev.ID, created, ErrBadTimestamp)
}
ev.CreatedAt = at
if ts, ok := parseSQLiteTime(updated); ok {
ev.UpdatedAt = ts
} else {
ev.UpdatedAt = at
}
return ev, nil
}
// where 把 Filter 编译成参数化的 WHERE 子句(列名是常量,值全部走占位符)。
// 有意的取舍:**只拼列名与运算符,绝不拼值**(SQL 全参数化,AGENTS §10)。
func (f Filter) where() (string, []any) {
clauses := make([]string, 0, 6)
args := make([]any, 0, 6)
if platform := strings.TrimSpace(f.Platform); platform != "" {
clauses = append(clauses, "platform = ?")
args = append(args, platform)
}
if f.Tier != "" {
clauses = append(clauses, "tier = ?")
args = append(args, string(f.Tier))
}
if f.Category != "" {
clauses = append(clauses, "category = ?")
args = append(args, string(f.Category))
}
if channelID := strings.TrimSpace(f.ChannelID); channelID != "" {
clauses = append(clauses, "channel_id = ?")
args = append(args, channelID)
}
if !f.Since.IsZero() {
clauses = append(clauses, "created_at >= ?")
args = append(args, sqliteTimeStamp(f.Since))
}
if !f.Until.IsZero() {
clauses = append(clauses, "created_at <= ?")
args = append(args, sqliteTimeStamp(f.Until))
}
if len(clauses) == 0 {
return "", nil
}
return " WHERE " + strings.Join(clauses, " AND "), args
}
// pageLimit 把 Limit 归一:≤0 取缺省、超过硬上界即钳住。
func (f Filter) pageLimit() int {
switch {
case f.Limit <= 0:
return DefaultListLimit
case f.Limit > MaxListLimit:
return MaxListLimit
default:
return f.Limit
}
}
// pageOffset 把 Offset 归一到 ≥0。
func (f Filter) pageOffset() int {
if f.Offset < 0 {
return 0
}
return f.Offset
}
// sqliteTimeStamp 把时刻格式化成库里的时间戳口径(UTC 毫秒 + 字面量 Z)。
func sqliteTimeStamp(t time.Time) string {
return t.UTC().Format(sqliteTimeLayout) + "Z"
}
// parseSQLiteTime 解析库里的时间戳列。它先试本包写出的口径,再退回 RFC3339
// (覆盖带偏移或非毫秒精度的历史值);两者都不成立时返回 false,调用方据此保守
// 处理(绝不把读不懂的时间当成一个真实时刻)。
func parseSQLiteTime(raw string) (time.Time, bool) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return time.Time{}, false
}
if t, err := time.Parse(sqliteTimeLayout+"Z", trimmed); err == nil {
return t.UTC(), true
}
if t, err := time.Parse(time.RFC3339Nano, trimmed); err == nil {
return t.UTC(), true
}
return time.Time{}, false
}
// boolToInt 把布尔列写成 SQLite 的 0/1(列本身是 INTEGER NOT NULL DEFAULT 0)。
func boolToInt(v bool) int {
if v {
return 1
}
return 0
}