feat(events): 渠道事件桥接接线 + 组合根(design/26 E3+E4)

- E3:channelbridge/events.go 唯一插入点 recordEvent(两轴映射 Category→Tier、摘要恒过 SanitizeNoticeText、角色/账号只认白名单)+ Options.EventStore 注入
  - 未接线 store=nil ⇒ 内存计数 + 首次 DEBUG,绝不中断入站(变异实测:改成直接调 Record ⇒ panic,测试红)
  - 普通聊天消息零副作用(变异:去掉 Applicable 早退 ⇒ rows=1 红)
  - T1 遥测只计数不落行(变异:心跳误判 T2 ⇒ 「逐条落行 1 行,want 0」红)
- E4:wire_channel_events.go(启动期清扫 + 双 ticker + 退出 flush,逐字对齐 wire_document_gc 的三条取舍)、wire_channels 注入、main.go 加一行 startChannelEvents
- internal/config/duration_env.go:新 env 键的读取口径(进程环境 > .env > 默认;「没写」与「写了空」都当没配;非法值回落 + 报错)——避免「只写在 .env 里被静默忽略」
- 三个新 env 键:CHANNEL_EVENT_RETENTION / GC_INTERVAL / COUNTER_FLUSH(模板同批更新,测试钉住键名一致)
- 插入点比设计示意图晚一步(放 preEntryReview 之后,只归一化一次且 NoticeClass 已有结论),「留存无条件、在任何早退之前」这条性质不变
This commit is contained in:
AskaEth committed 2026-10-05 14:10:24 +08:00
1 parent 7aaf7731dc
commit afe9afb1b5
12 files changed
+1689 -2

No files matched your search

+3
View File
@@ -99,6 +99,9 @@ func run() error {
// 文档回收(切片 S7):启动期清扫一次 + 此后按 DOCUMENT_GC_INTERVAL 定时清扫。
// 它不等这一次跑完就去拉网关(见 wire_document_gc.go 的取舍 1)。
a.startDocumentGC(ctx)
// 渠道事件留存维护(切片 E3+E4):保留期清扫一次 + 此后定时清扫,内存计数
// 按 CHANNEL_EVENT_COUNTER_FLUSH 定时落盘(退出前最后一次落盘在循环里)。
a.startChannelEvents(ctx)
// LLM 用量落库与保留期清理(切片 U1):单写者 + 有界队列的异步写入器,
// 加上按 LLM_USAGE_RETENTION 的过期样本清理。
a.startUsage(ctx)
+11
View File
@@ -20,6 +20,7 @@ import (
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
@@ -95,6 +96,13 @@ type app struct {
// docGC 是文档回收的常驻任务(切片 S7):未被引用且超过保留期的文档连同
// sidecar 与元数据行一起回收。GC 被配置关闭时为 nil(见 wire_document_gc.go)。
docGC *documentGC
// channelEvents 是渠道事件留存面(design/26 §4;切片 E3+E4):非消息事件逐条
// 落行 / T1 计数都经它,桥接层由 wire_channels.go 注入。库未接时为 nil
// (桥接层随即走内存计数降级,见 wire_channel_events.go)。
channelEvents *events.SQLStore
// channelEventKeeper 是事件留存的常驻维护任务(保留期清扫 + 计数落盘),
// 由 main 的 startChannelEvents 拉起(见 wire_channel_events.go)。
channelEventKeeper *channelEventKeeper
// usage 是 LLM 用量的异步落库出口(切片 U1):注入模型栈后,每次成功模型
// 调用都会把非敏感计数样本塞进它的有界队列。库未接时为 nil(= 只记日志)。
usage *model.UsageWriter
@@ -183,6 +191,9 @@ func newApp(ctx context.Context, cfg *config.Config, log *slog.Logger) (*app, er
return a, err
}
a.wirePlugins(ctx)
// 渠道事件留存面(design/26 §4;切片 E3+E4):必须在 wireChannels 之前构造 ——
// 桥接层在构造时就把它注入(Options.EventStore),运行时不再重取。
a.wireChannelEvents()
if err := a.wireChannels(); err != nil {
return a, err
}
+188
View File
@@ -0,0 +1,188 @@
package main
// 本文件把**渠道事件留存**接进组合根(design/26 §4.3/§4.4;切片 E3+E4):按配置构造
// 事件存储、交给桥接层注入(wire_channels.go 的 channelbridge.Options.EventStore),
// 拉起常驻的「保留期清扫 + 内存计数落盘」循环,并在退出前最后一次落盘。
//
// 三条取舍逐字对齐参照实现 `wire_document_gc.go`(design/26 §4.4 明写「不另造」):
//
// 1. **启动期清扫不阻塞启动**:它是常驻 goroutine 的第一件事(紧随子系统启动之后
// 开始,网关监听之前就绪),而不是在 newApp/run 里同步等它跑完。
// 2. **失败不退出进程**:清扫 / 落盘失败只记一条 WARN 并等下一轮;一轮失败只是
// 「这一轮没扫干净」,绝不让进程退出。
// 3. **关闭 = 显式配 ≤0**:`CHANNEL_EVENT_RETENTION=0`(或负值)或
// `CHANNEL_EVENT_GC_INTERVAL=0` 都不开启清扫,NewStore 会把关闭原因记进日志
// (静默的「配了却不生效」不存在)。
//
// ⚠️ 与参照实现的一处**已知差异**(如实登记,不假装对齐):docGC 的 `stop()` 由
// `wire_services.go` 的 close 调用,而该文件由别的切片在改(本片不许动)。因此这里的
// 退出路径做成**循环自己的 ctx.Done 分支**:ctx 取消即用独立超时的 context 落最后一次盘。
// 语义与「退出前最后一次 Flush」相同,区别只在**不阻塞** close —— 计数本就是尽力而为的
// 近似值(§4.3),并与关库并发(真撞上只记 WARN,不丢留存面本身)。
//
// 三个键的读取走 `config.LoadDurationEnv`(进程环境 > `.env` > E1 导出的默认常量):
// `.env` 不写回进程环境,直接 os.Getenv 会让只写在 `.env` 里的声明静默失效。
import (
"context"
"log/slog"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
)
// 三个 env 键名:必须与 `.env.example` **逐字一致**(AGENTS §10 的 E6 条目)。
const (
channelEventRetentionEnv = "CHANNEL_EVENT_RETENTION"
channelEventGCIntervalEnv = "CHANNEL_EVENT_GC_INTERVAL"
channelEventCounterFlushEnv = "CHANNEL_EVENT_COUNTER_FLUSH"
)
// channelEventFlushTimeout 是**退出前最后一次 Flush** 的时长上界。它必须配一个独立的
// context:ctx 在这一刻已被取消,拿它去写库只会立刻拿到 context.Canceled —— 那正是
// 「退出时最后一次 flush」最容易悄悄失效的地方。
const channelEventFlushTimeout = 5 * time.Second
// wireChannelEvents 构造事件留存面并挂到 app 上(随后由 wireChannels 注入桥接层)。
// 它**不**启动任何 goroutine:常驻循环由 main 的 startChannelEvents 在子系统启动之后
// 拉起(与文档 GC 的分工逐字相同)。
func (a *app) wireChannelEvents() {
a.channelEvents = a.newChannelEventStore(envPath)
}
// newChannelEventStore 按配置构造事件存储。库未接(`a.db == nil`,只可能出现在测试
// 夹具里)时返回 nil:桥接层随即走「未接线」的 fail-visible 降级路径(内存计数 +
// 首次一条 DEBUG),入站流水线照常,绝不因为没有存储而失败或丢帧。
//
// 默认值一律取 E1(`internal/runtime/events`)导出的常量 —— **不在这里另写一份字面量**:
// 两处各写一份 720h/1h/30s,改一处就会让「文档承诺的默认值」与「代码里的默认值」漂移。
// `MaxPendingKeys` 没有 env 键(design/26 §4.4 只给了三个旋钮),同样取 E1 的默认常量。
func (a *app) newChannelEventStore(envFilePath string) *events.SQLStore {
if a.db == nil {
a.log.Warn("channel event store skipped: no database connection")
return nil
}
store, err := events.NewStore(events.Config{
DB: a.db,
Log: a.log,
FlushInterval: durationEnv(a.log, envFilePath, channelEventCounterFlushEnv, events.DefaultFlushInterval),
MaxPendingKeys: events.DefaultMaxPendingKeys,
GCInterval: durationEnv(a.log, envFilePath, channelEventGCIntervalEnv, events.DefaultGCInterval),
Retention: durationEnv(a.log, envFilePath, channelEventRetentionEnv, events.DefaultRetention),
})
if err != nil {
// NewStore 唯一的失败是 DB 为 nil(上面已挡掉):仍如实处理,绝不吞错。
a.log.Warn("channel event store unavailable", "err", err)
return nil
}
return store
}
// durationEnv 读一个 duration 型 env 键(进程环境 > `.env` > def)。配了但读不懂时记
// 一条带原始取值的 WARN 并回落 def —— 错的是那一个键,不该让整条留存面失效
// (AGENTS §10:非法配置不许静默失效,但也不许一票否决)。
func durationEnv(log *slog.Logger, envFilePath, key string, def time.Duration) time.Duration {
value, err := config.LoadDurationEnv(envFilePath, key, def)
if err != nil {
log.Warn("channel event env ignored, falling back to default",
"env", key, "error", err, "fallback", def.String())
}
return value
}
// channelEventStore 返回注入桥接层的留存面:未接线时返回**真正的 nil 接口**,而不是
// 「装着 nil 指针的接口」—— 后者在 Go 里不等于 nil,会让桥接层的「未接线 ⇒ 内存计数」
// 判据失效(症状是每帧都撞一次 nil 指针 panic)。
func (a *app) channelEventStore() events.Store {
if a.channelEvents == nil {
return nil
}
return a.channelEvents
}
// startChannelEvents 拉起留存维护循环(未接线时是空操作)。
func (a *app) startChannelEvents(ctx context.Context) {
if a.channelEvents == nil {
a.log.Info("channel event upkeep disabled: store not wired",
"effect", "events are counted in memory only (bridged to the api layer counters)")
return
}
keeper := &channelEventKeeper{
store: a.channelEvents,
log: logger.WithSource(a.log, "cmd/cyrene/wire_channel_events"),
done: make(chan struct{}),
}
a.channelEventKeeper = keeper
go keeper.loop(ctx)
}
// channelEventKeeper 是渠道事件留存的常驻维护任务:保留期清扫 + 内存计数落盘 +
// 退出前最后一次落盘。它持有**存储句柄**(不是 app),因此生命周期与用法一目了然。
type channelEventKeeper struct {
store *events.SQLStore
log *slog.Logger
// done 在循环退出时关闭。它让「退出前最后一次 Flush」**可被观测**:测试据此确认
// 内存里的计数真的落了盘,而不是随 goroutine 一起消失。
done chan struct{}
}
// loop 是维护循环:启动期先清扫一次、落一次盘,此后按各自间隔定时跑,直到 ctx 被取消
// (取消时落最后一次盘再退出)。
func (k *channelEventKeeper) loop(ctx context.Context) {
defer close(k.done)
k.sweep(ctx)
k.flush(ctx)
gcTicks, stopGC := k.gcTicker()
defer stopGC()
flushTicks := time.NewTicker(k.store.FlushInterval())
defer flushTicks.Stop()
for {
select {
case <-ctx.Done():
k.flushOnShutdown()
return
case <-gcTicks:
k.sweep(ctx)
case <-flushTicks.C:
k.flush(ctx)
}
}
}
// gcTicker 给出清扫的定时通道;GC 关闭时返回 **nil 通道**(select 上永不触发)。
// `time.NewTicker(0)` 会 panic,所以「配了 ≤0 = 关闭」必须在**这里**就分岔,而不是
// 建一个零间隔 ticker 再指望它不跑。
func (k *channelEventKeeper) gcTicker() (<-chan time.Time, func()) {
if !k.store.Enabled() {
return nil, func() {}
}
ticker := time.NewTicker(k.store.Interval())
return ticker.C, ticker.Stop
}
// sweep 跑一轮保留期清扫。每轮的**成功**汇总由 store 自己记(E1 的 Sweep 已记一条,
// 含删除行数与批次),这里只负责把「这一轮整个没跑成」记成 WARN 并让循环继续。
func (k *channelEventKeeper) sweep(ctx context.Context) {
if _, err := k.store.Sweep(ctx); err != nil {
k.log.Warn("channel event sweep failed", "err", err)
}
}
// flush 把内存里待落盘的计数写进库;失败只 WARN(计数是尽力而为的近似值,§4.3)。
func (k *channelEventKeeper) flush(ctx context.Context) {
if err := k.store.Flush(ctx); err != nil {
k.log.Warn("channel event counter flush failed", "err", err)
}
}
// flushOnShutdown 是退出前的最后一次落盘:用独立超时的 context(此刻调用方的 ctx
// 已取消),失败只 WARN —— 退出路径绝不因为一次写库失败而 panic 或阻塞。
func (k *channelEventKeeper) flushOnShutdown() {
ctx, cancel := context.WithTimeout(context.Background(), channelEventFlushTimeout)
defer cancel()
k.flush(ctx)
}
+341
View File
@@ -0,0 +1,341 @@
package main
// 本文件是**渠道事件留存组合根接线**的验收(design/26 §4.3/§4.4;切片 E3+E4)。
//
// 两道并行的取证:
//
// 1. **接线存在性**(与 TestWireDocumentGC_IsWired 同一手法):组合根本身无法在单元
// 测试里实例化(开库、迁移、绑端口,而后端还在运行),因此「存储真的被构造」「真的
// 注入桥接层」「常驻循环真的被拉起」「退出前真的落最后一次盘」只能在源码层钉住 ——
// 少任何一处,留存都会**静默不工作**(事件照丢、日志里一句都没有),而这不
// 会让任何既有用例变红。最后一段还核对 `.env.example` 的三个键(AGENTS §10)。
// 2. **行为**:三个 env 键的默认值必须来自 E1 导出的常量(**不另写一份字面量**)、
// 显式 ≤0 = 关闭清扫、只写在 `.env` 里也真实生效、非法取值回落默认 + WARN;
// 以及「退出前最后一次落盘」真的把内存里的计数写进库。
import (
"bytes"
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
)
// TestWireChannelEvents_IsWired 钉住组合根的四个接缝 + `.env.example` 同步。
func TestWireChannelEvents_IsWired(t *testing.T) {
t.Parallel()
seams := map[string][]string{
"wire_channel_events.go": {
// 存储真的被构造,且默认值取 E1 导出的常量(不另写字面量)。
"events.NewStore(events.Config{",
"MaxPendingKeys: events.DefaultMaxPendingKeys",
"durationEnv(a.log, envFilePath, channelEventCounterFlushEnv, events.DefaultFlushInterval)",
"durationEnv(a.log, envFilePath, channelEventGCIntervalEnv, events.DefaultGCInterval)",
"durationEnv(a.log, envFilePath, channelEventRetentionEnv, events.DefaultRetention)",
// 未接线时交出**真 nil 接口**(装 nil 指针的接口不等于 nil,会让桥接层的
// 「未接线 ⇒ 内存计数」降级判据失效)。
"func (a *app) channelEventStore() events.Store {",
// GC 关闭时不许建零间隔 ticker(time.NewTicker(0) 会 panic)。
"if !k.store.Enabled() {",
// 退出前最后一次落盘,用独立 ctx(此刻调用方的 ctx 已取消)。
"k.flushOnShutdown()",
},
"wire_channels.go": {
"EventStore: a.channelEventStore(),",
},
"wire.go": {
// 必须在 wireChannels 之前构造:桥接层在构造时就注入它。
"a.wireChannelEvents()",
},
"main.go": {
// 常驻循环:没有这一行,留存面只存在于代码里而从不维护(清扫与计数落盘都不跑)。
"a.startChannelEvents(ctx)",
},
}
for file, wants := range seams {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, want := range wants {
if !strings.Contains(string(src), want) {
t.Fatalf("%s 里找不到 %q —— 渠道事件留存没有被接上组合根", file, want)
}
}
}
// 三个新键必须与代码里的键名**逐字一致**(AGENTS §10 的 E6 条目)。
src, err := os.ReadFile("wire_channel_events.go")
if err != nil {
t.Fatalf("read wire_channel_events.go: %v", err)
}
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
for _, key := range []string{channelEventRetentionEnv, channelEventGCIntervalEnv, channelEventCounterFlushEnv} {
if !strings.Contains(string(src), `"`+key+`"`) {
t.Fatalf("wire_channel_events.go 里没有键名常量 %q", key)
}
if !strings.Contains(string(raw), key+"=") {
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10)", key+"=")
}
}
}
// channelEventTestApp 构造一个只带留存面所需依赖的 app(库 + 静默 logger)。
func channelEventTestApp(t *testing.T, log *slog.Logger) *app {
t.Helper()
if log == nil {
log = testLog()
}
return &app{db: openMigratedTestDB(t, filepath.Join(t.TempDir(), "cyrene.db")), log: log}
}
// unsetChannelEventEnv 把三个键从**进程环境**里拿掉并在用例结束后复原。
//
// ⚠️ 不能用 `t.Setenv(key, "")` 代替:空值在优先级链里仍然「存在」,会**盖住** `.env`
// 里的同名声明(lookupRaw 先看 os.LookupEnv),而「只写在 .env 里也生效」正是要验的东西。
func unsetChannelEventEnv(t *testing.T) {
t.Helper()
for _, key := range []string{channelEventRetentionEnv, channelEventGCIntervalEnv, channelEventCounterFlushEnv} {
key := key
prev, had := os.LookupEnv(key)
if had {
t.Cleanup(func() { _ = os.Setenv(key, prev) })
} else {
t.Cleanup(func() { _ = os.Unsetenv(key) })
}
if err := os.Unsetenv(key); err != nil {
t.Fatalf("unset %s: %v", key, err)
}
}
}
// TestNewChannelEventStore_默认值取E1导出常量_正常:未配置时三个旋钮必须**就是**
// E1 导出的默认值(720h / 1h / 30s),并且清扫是开启的 —— 若这里另写了一份字面量,
// 「文档承诺的默认值」与「代码里的默认值」迟早漂移。
func TestNewChannelEventStore_默认值取E1导出常量_正常(t *testing.T) {
unsetChannelEventEnv(t)
store := channelEventTestApp(t, nil).newChannelEventStore("")
if store == nil {
t.Fatal("有库时必须构造出事件存储")
}
if store.Retention() != events.DefaultRetention {
t.Fatalf("Retention = %v, want %v(E1 导出的默认常量)", store.Retention(), events.DefaultRetention)
}
if store.Interval() != events.DefaultGCInterval {
t.Fatalf("GC 间隔 = %v, want %v", store.Interval(), events.DefaultGCInterval)
}
if store.FlushInterval() != events.DefaultFlushInterval {
t.Fatalf("计数落盘间隔 = %v, want %v", store.FlushInterval(), events.DefaultFlushInterval)
}
if !store.Enabled() {
t.Fatal("未配置时清扫必须开启(漏配 != 关闭)")
}
}
// TestNewChannelEventStore_显式零关闭清扫_边界:`≤0 = 关闭`(design/26 §4.4)——
// 0 刻意**不**解释成「立即清扫」,那会让一次笔误抹掉唯一的取证面。
func TestNewChannelEventStore_显式零关闭清扫_边界(t *testing.T) {
cases := []struct {
name string
key string
value string
}{
{"保留期为 0", channelEventRetentionEnv, "0"},
{"保留期为负", channelEventRetentionEnv, "-1h"},
{"清扫间隔为 0", channelEventGCIntervalEnv, "0"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
unsetChannelEventEnv(t)
t.Setenv(tc.key, tc.value)
store := channelEventTestApp(t, nil).newChannelEventStore("")
if store == nil {
t.Fatal("配置非法不该让存储整个消失")
}
if store.Enabled() {
t.Fatalf("%s=%s 时清扫仍开启,want 关闭(显式 ≤0 = 关闭)", tc.key, tc.value)
}
})
}
}
// TestNewChannelEventStore_只写在env文件里也生效_正常:`.env` 不写回进程环境,直接
// os.Getenv 会**静默忽略**它(= 配了却不生效)。本用例把三个键只写进临时 `.env`,
// 取值必须原样生效。
func TestNewChannelEventStore_只写在env文件里也生效_正常(t *testing.T) {
unsetChannelEventEnv(t)
path := filepath.Join(t.TempDir(), ".env")
body := strings.Join([]string{
channelEventRetentionEnv + "=24h",
channelEventGCIntervalEnv + "=30m",
channelEventCounterFlushEnv + "=5s",
}, "\n") + "\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("写临时 .env: %v", err)
}
store := channelEventTestApp(t, nil).newChannelEventStore(path)
if store == nil {
t.Fatal("有库时必须构造出事件存储")
}
if store.Retention() != 24*time.Hour || store.Interval() != 30*time.Minute || store.FlushInterval() != 5*time.Second {
t.Fatalf("取值 = (%v, %v, %v), want (24h, 30m, 5s)",
store.Retention(), store.Interval(), store.FlushInterval())
}
}
// TestNewChannelEventStore_非法取值回落默认并告警_异常:配了但读不懂 ⇒ 回落 E1 默认值
// **且**记一条带键名与原始取值的 WARN(AGENTS §10:非法配置不许静默失效,也不许一票
// 否决整条留存面)。
func TestNewChannelEventStore_非法取值回落默认并告警_异常(t *testing.T) {
unsetChannelEventEnv(t)
path := filepath.Join(t.TempDir(), ".env")
if err := os.WriteFile(path, []byte(channelEventRetentionEnv+"=三十天\n"), 0o600); err != nil {
t.Fatalf("写临时 .env: %v", err)
}
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
store := channelEventTestApp(t, log).newChannelEventStore(path)
if store == nil {
t.Fatal("单个键非法不该让存储整个消失")
}
if store.Retention() != events.DefaultRetention {
t.Fatalf("非法取值时的保留期 = %v, want %v", store.Retention(), events.DefaultRetention)
}
logged := buf.String()
if !strings.Contains(logged, "level=WARN") || !strings.Contains(logged, channelEventRetentionEnv) {
t.Fatalf("非法取值没有留下带键名的 WARN:%q", logged)
}
}
// TestChannelEventStore_未接线时交出真nil接口_边界:未接数据库(或构造失败)时
// `channelEventStore()` 必须返回**真 nil 接口** —— 返回「装着 nil 指针的接口」会让
// 桥接层的 nil 判据失效,症状是每帧撞一次 nil 指针 panic,而单测全绿。
func TestChannelEventStore_未接线时交出真nil接口_边界(t *testing.T) {
blank := &app{log: testLog()}
if store := blank.newChannelEventStore(""); store != nil {
t.Fatalf("库未接时不该构造存储:%+v", store)
}
var asInterface events.Store = blank.channelEventStore()
if asInterface != nil {
t.Fatalf("未接线时必须交出真 nil 接口,拿到的是 %#v", asInterface)
}
}
// TestChannelEventKeeper_退出前最后一次落盘_正常:ctx 取消时循环必须用**独立**的
// context 把内存里的计数写进库。这里把已取消的 ctx 直接交给 loop(同步跑完)——
// 若 `flushOnShutdown` 用调用方那个已取消的 ctx 落盘,它会立刻拿到 context.Canceled,
// 待落盘的计数就会留在内存里(用例据此变红)。
func TestChannelEventKeeper_退出前最后一次落盘_正常(t *testing.T) {
unsetChannelEventEnv(t)
a := channelEventTestApp(t, nil)
store := a.newChannelEventStore("")
if store == nil {
t.Fatal("有库时必须构造出事件存储")
}
ctx := context.Background()
// 一条只计数的 T1 事件:它在内存里待落盘(逐条落行的那类会立刻写库,掩盖问题)。
if err := store.Record(ctx, events.Event{
Platform: events.PlatformQQ,
Tier: events.TierT1,
Category: events.CategoryHeartbeat,
PlatformType: "heartbeat",
}); err != nil {
t.Fatalf("Record: %v", err)
}
if store.PendingKeys() == 0 {
t.Fatal("计数没有留在内存里,用例前提不成立")
}
cancelled, cancel := context.WithCancel(ctx)
cancel()
keeper := &channelEventKeeper{store: store, log: testLog(), done: make(chan struct{})}
keeper.loop(cancelled)
if store.PendingKeys() != 0 {
t.Fatalf("退出时仍有 %d 个键待落盘:最后一次 Flush 没跑", store.PendingKeys())
}
counters, err := store.Counters(ctx, events.PlatformQQ)
if err != nil {
t.Fatalf("Counters: %v", err)
}
if len(counters) != 1 || counters[0].Count != 1 {
t.Fatalf("退出后库里的计数 = %+v, want 一条 count=1 的心跳", counters)
}
select {
case <-keeper.done:
default:
t.Fatal("loop 退出时没有关闭 done(退出路径不可观测)")
}
}
// TestChannelEventKeeper_GC关闭时不建零间隔ticker_边界:`time.NewTicker(0)` 会 panic,
// 所以「配了 ≤0 = 关闭清扫」必须在建 ticker 之前分岔(关闭 ⇒ nil 通道,select 上永不触发)。
func TestChannelEventKeeper_GC关闭时不建零间隔ticker_边界(t *testing.T) {
t.Run("关闭时不建 ticker", func(t *testing.T) {
unsetChannelEventEnv(t)
t.Setenv(channelEventGCIntervalEnv, "0")
store := channelEventTestApp(t, nil).newChannelEventStore("")
if store == nil || store.Enabled() {
t.Fatalf("GC 应关闭:store=%v enabled=%v", store, store != nil && store.Enabled())
}
ticks, stop := (&channelEventKeeper{store: store, log: testLog()}).gcTicker()
if ticks != nil {
t.Fatal("GC 关闭时不该建 ticker(零间隔 ticker 会 panic)")
}
stop()
})
t.Run("开启时给出定时通道", func(t *testing.T) {
unsetChannelEventEnv(t)
store := channelEventTestApp(t, nil).newChannelEventStore("")
if store == nil || !store.Enabled() {
t.Fatal("默认配置下 GC 应开启")
}
ticks, stop := (&channelEventKeeper{store: store, log: testLog()}).gcTicker()
if ticks == nil {
t.Fatal("GC 开启时必须给出定时通道")
}
stop()
})
}
// TestChannelEventEnv_模板取值是合法duration_正常 是 E6(协同清单)自查的机械化版本:
// 三个键名必须能在 `.env.example` 里逐字找到,且模板值本身就是合法 duration(占位符形态,
// 从不放凭据)—— 否则照模板部署的新实例会得到一条 WARN 并**静默**回落到默认值。
func TestChannelEventEnv_模板取值是合法duration_正常(t *testing.T) {
t.Parallel()
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
lines := strings.Split(string(raw), "\n")
for _, key := range []string{channelEventRetentionEnv, channelEventGCIntervalEnv, channelEventCounterFlushEnv} {
found := false
for _, line := range lines {
value, ok := strings.CutPrefix(line, key+"=")
if !ok {
continue
}
found = true
if _, err := time.ParseDuration(value); err != nil {
t.Fatalf("%s 的模板值 %q 不是合法 duration", key, value)
}
}
if !found {
t.Fatalf(".env.example 缺少 %s(AGENTS §10:键名必须与代码逐字一致)", key)
}
}
}
+6
View File
@@ -103,6 +103,12 @@ func (a *app) wireChannels() error {
Meta: documents.MetadataWriterFor(a.dbPath, a.log),
Log: a.log,
},
// EventStore 是渠道事件留存面(design/26 §4/§5;切片 E3+E4):非消息事件
// (含未知类型与解析失败)由桥接层**无条件**写进它 —— T2/T3/unknown 逐条落行、
// T1 只累加计数;「进不进上下文」另由传入前审查决定,两者互不决定。
// 通道由 channelEventStore() 归一:未接线时交出**真 nil 接口**,桥接层据此走
// 内存计数降级(绝不因为缺存储而中断入站)。
EventStore: a.channelEventStore(),
// 可选的复审模型在接近单适配器上限时剔除低价值排队消息
// (design/12 §6 策略四)。适配器为 nil 时,闸门回落到 FIFO 驱逐。
Review: reviewFor(a.modelAdapter, logger.WithSource(a.log, "services/channelbridge/review")),