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 }