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

122 lines
5.1 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
// 本文件归属**保留期清扫(Sweep)**(design/26 §4.4;切片 E1)。它逐字沿用
// `internal/runtime/documents/gc.go` + `cmd/cyrene/wire_document_gc.go` 的既有 GC
// 范式(design/26 §4.4 明写「不另造」),三条取舍也照抄:
//
// 1. **启动期扫一次、此后按间隔定时扫**(间隔默认 DefaultGCInterval)。启动期那次
// 与定时循环都**不**阻塞启动、不绑在磁盘上 —— 常驻循环住在组合根(E4 的
// `wire_channel_events.go` 逐字对齐 `wire_document_gc.go`),本包只提供
// `Sweep` 与它需要的三个口径(Enabled / Interval / Retention)。
// 2. **失败只记 WARN、绝不让进程退出**:`Sweep` 把查询/删除失败**上抛**给调用方
// (组合根记 WARN 后继续等下一轮);已经删掉的行不会因为后续批次失败而「回滚」,
// 未删的行也不会被清空 —— 一轮失败就只是「这一轮没扫干净」。
// 3. **≤0 = 关闭**(显式),关闭原因在 NewStore 里记进日志 —— 「配了却不生效」
// 不存在(与文档 GC 同口径)。
//
// 清理条件只有一条:`created_at < now − 保留期` 的 `channel_events` 行逐批删除。
// **计数表不参与 GC**:它本身就是「历史已浓缩成一行」的形态,删了就再也答不上
// 「上游推过没有」——那正是本切片存在的理由(design/26 §4.4)。
import (
"context"
"fmt"
"time"
)
const (
// DefaultGCInterval 是 `CHANNEL_EVENT_GC_INTERVAL` 的默认值:每小时清扫一次。
DefaultGCInterval = time.Hour
// DefaultRetention 是 `CHANNEL_EVENT_RETENTION` 的默认值:30 天。它比文档保留期
// (168h)长得多,因为事件表的核心用途是**事后定案**(design/26 §0.2)——
// 一周太短,事故复盘常常发生在一周之后(§4.4)。
DefaultRetention = 720 * time.Hour
// sweepBatchSize 是单批删除的行数上限;maxSweepBatches 是一轮清扫的批数上限。
// 两者一起把「一轮清扫」的时长与单次内存占用**有界**(AGENTS §10 的压测纪律:
// 有界,不靠堆进程):积压百万行时不是一次 DELETE 全表,而是每轮最多
// maxSweepBatches × sweepBatchSize 行,剩下的留给下一轮。
sweepBatchSize = 500
maxSweepBatches = 100
)
// SweepStats 是一轮清扫的汇总(成功件只记总数,绝不记任何行内容)。
type SweepStats struct {
// Batches 是实际执行的删除批数。
Batches int
// Deleted 是删除的过期行数。
Deleted int64
}
// Enabled 报告清扫是否真的会删行(保留期与间隔都 > 0)。
func (s *SQLStore) Enabled() bool { return s != nil && s.gcEnabled }
// Interval 返回定时清扫间隔(关闭时为 0)。
func (s *SQLStore) Interval() time.Duration {
if s == nil {
return 0
}
return s.gcInterval
}
// Retention 返回事件行保留期(关闭时为 0)。
func (s *SQLStore) Retention() time.Duration {
if s == nil {
return 0
}
return s.retention
}
// sweepSQL 按时间戳删一批过期行。子查询带 `ORDER BY created_at LIMIT ?`,因此它
// 吃得上 `idx_channel_events_created_at`,且每批的扫描面有界(design/26 §4.4)。
// 时间戳用定宽 UTC 字符串比较:字典序等价于时间序(见 store.go 的口径注释)。
const sweepSQL = `DELETE FROM channel_events WHERE id IN (
SELECT id FROM channel_events WHERE created_at < ? ORDER BY created_at LIMIT ?)`
// Sweep 按保留期清扫 `channel_events` 的过期行,返回一轮汇总。
//
// 关闭时(Enabled() 为 false)是**空操作**:返回零值与 nil 错误、不碰任何一行。
// 这与「配了却不生效」是两回事 —— 关闭原因在 NewStore 里已记 WARN。
//
// 失败语义:查询/删除失败返回错误并**带上已完成的统计**(调用方据此知道这一轮
// 走到了哪一步,记 WARN 后等下一轮)。ctx 取消时立刻停下:**已删的保持删除、
// 未删的绝不因此被清空** —— 这是「失败不清空」在代码里的落点。
func (s *SQLStore) Sweep(ctx context.Context) (SweepStats, error) {
if !s.Enabled() {
return SweepStats{}, nil
}
cutoff := sqliteTimeStamp(s.now().Add(-s.retention))
var stats SweepStats
for i := 0; i < maxSweepBatches; i++ {
if err := ctx.Err(); err != nil {
return stats, fmt.Errorf("events: sweep: %w", err)
}
deleted, err := s.sweepBatch(ctx, cutoff)
if err != nil {
return stats, err
}
stats.Batches++
stats.Deleted += deleted
if deleted < int64(sweepBatchSize) {
break
}
}
s.log.Info("channel event sweep",
"deleted", stats.Deleted, "batches", stats.Batches, "retention", s.retention.String())
return stats, nil
}
// sweepBatch 删一批过期行并返回本批删掉的行数。
func (s *SQLStore) sweepBatch(ctx context.Context, cutoff string) (int64, error) {
res, err := s.db.ExecContext(ctx, sweepSQL, cutoff, sweepBatchSize)
if err != nil {
return 0, fmt.Errorf("events: sweep 删过期行: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("events: sweep 读取删除行数: %w", err)
}
return n, nil
}