Files
Cyrene_v2/cmd/cyrene/wire_channel_events.go
AskaEth a6cb44b8a8
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(flow): 链路图本地落盘 + 多轮历史回放(design/19 §P3,契约 v0.73)
主人要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」:
- 落盘:迁移 0022_flow_graphs(session_id/turn_id/graph_json/时间戳 + 唯一键 upsert)+ internal/runtime/flowgraph(RecordRound/Graph/Latest/Turns/Turn + 保留期 GC)
- 写入:loop.EndRound **每轮一条**(锁外深拷贝后写;失败 WARN 且不打印图内容);只落发布会话白名单(复用 flowSessionAllowed)
- 读回:端点加 turnId + source(live/persisted/none) + turns[](最新在前,空为 []);**内存优先、miss 才落盘**(最新一轮永远最新)
- 前端:flowGraphs 纳入 persistOptions.partialize(**刷新即刻有图**,version 3→4 + migrate)+ 历史轮选择器 + 诚实文案;顺带收 L1 转交的 flow/NodeDetail.tsx 节点日志倒序
- 新 env 键:FLOW_GRAPH_RETENTION=168h / FLOW_GRAPH_GC_INTERVAL=1h
- 变异实测:每 span 一行 / 保留期守卫失效 / partialize 去掉 flowGraphs / 去掉内存优先 / 白名单失效 ⇒ 全部打红
- 3 个禁动面文件的纯追加改动(Flow.tsx 挂选择器、FlowSnapshotHint 纠已成假话的文案、其渲染断言随之更新)由主会话裁定认可
- 登记存疑:图桶持久化无体积上限;useFlowTurns 的 effect 无自动化证据(项目无 jsdom)
2026-10-05 16:19:32 +08:00

190 lines
8.7 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 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:非法配置不许静默失效,但也不许一票否决)。消息刻意保持中立(不带
// 具体功能名),使渠道事件与链路图两处共用同一份读取口径(切片 E4 / F2)。
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("duration 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)
}