主人要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」: - 落盘:迁移 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)
157 lines
6.5 KiB
Go
157 lines
6.5 KiB
Go
package main
|
||
|
||
// 本文件把**链路图落盘**接进组合根(design/19 §4 P3;切片 F2):按配置构造链路图
|
||
// 存储,交给发布器当「每轮收尾落一条」的出口,并拉起常驻的保留期清扫循环。网关的
|
||
// 只读出口(契约 v0.73 的 `?turnId=` 与 `turns[]`)在 wire_frontend.go 里注入**同一个**
|
||
// 存储句柄,因此「写进去的」与「查得到的」永远是一份状态。
|
||
//
|
||
// 三条取舍逐字对齐参照实现 `wire_channel_events.go` / `wire_document_gc.go`
|
||
// (design 明写「不另造」):
|
||
//
|
||
// 1. **启动期清扫不阻塞启动**:它是常驻 goroutine 的第一件事,而不是在 newApp/run 里
|
||
// 同步等它跑完。
|
||
// 2. **失败不退出进程**:清扫失败只记一条 WARN 并等下一轮;一轮失败只是「这一轮没
|
||
// 扫干净」,绝不让进程退出。
|
||
// 3. **关闭 = 显式配 ≤0**:`FLOW_GRAPH_RETENTION=0`(或负值)或
|
||
// `FLOW_GRAPH_GC_INTERVAL=0` 都不开启清扫,NewStore 会把关闭原因记进日志(静默的
|
||
// 「配了却不生效」不存在)。
|
||
//
|
||
// ⚠️ 与 channelEventKeeper 的一处刻意留白:本 keeper 只在 ctx 取消时退出循环(不额外
|
||
// 挂退出钩子)—— 与 `wire_channel_events.go` 的已知差异一致,因为 `wire_services.go`
|
||
// 由别的切片在改(本片不许动)。清扫本就是幂等且尽力而为的。
|
||
//
|
||
// 两个键的读取走 `config.LoadDurationEnv`(进程环境 > `.env` > 本包导出的默认常量):
|
||
// `.env` 不写回进程环境,直接 os.Getenv 会让只写在 `.env` 里的声明静默失效。
|
||
|
||
import (
|
||
"context"
|
||
"log/slog"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/flowgraph"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
|
||
)
|
||
|
||
// 两个 env 键名:必须与 `.env.example` **逐字一致**(AGENTS §10 的 E6 条目)。
|
||
const (
|
||
flowGraphRetentionEnv = "FLOW_GRAPH_RETENTION"
|
||
flowGraphGCIntervalEnv = "FLOW_GRAPH_GC_INTERVAL"
|
||
)
|
||
|
||
// wireFlowGraphs 构造链路图存储并挂到 app 上(随后 wireRuntime 把它交给发布器作落盘
|
||
// 出口,wireFrontend 把它交给网关作只读查询出口)。它**不**启动任何 goroutine:常驻
|
||
// 循环由 main 的 startFlowGraphs 在子系统启动之后拉起(与渠道事件留存的分工逐字相同)。
|
||
func (a *app) wireFlowGraphs() {
|
||
a.flowStore = a.newFlowGraphStore(envPath)
|
||
}
|
||
|
||
// newFlowGraphStore 按配置构造链路图存储。库未接(`a.db == nil`,只可能出现在测试
|
||
// 夹具里)时返回 nil:发布器随即走「不落盘」的纯内存路径(F1 之前的行为),会话收尾
|
||
// 照常,绝不因为没有存储而失败或改变实时帧。
|
||
//
|
||
// 默认值一律取 `internal/runtime/flowgraph` 导出的常量 —— **不在这里另写一份字面量**:
|
||
// 两处各写一份 168h/1h,改一处就会让「文档承诺的默认值」与「代码里的默认值」漂移。
|
||
func (a *app) newFlowGraphStore(envFilePath string) *flowgraph.SQLStore {
|
||
if a.db == nil {
|
||
a.log.Warn("flow graph store skipped: no database connection")
|
||
return nil
|
||
}
|
||
store, err := flowgraph.NewStore(flowgraph.Config{
|
||
DB: a.db,
|
||
Log: a.log,
|
||
GCInterval: durationEnv(a.log, envFilePath, flowGraphGCIntervalEnv, flowgraph.DefaultGCInterval),
|
||
Retention: durationEnv(a.log, envFilePath, flowGraphRetentionEnv, flowgraph.DefaultRetention),
|
||
})
|
||
if err != nil {
|
||
// NewStore 唯一的失败是 DB 为 nil(上面已挡掉):仍如实处理,绝不吞错。
|
||
a.log.Warn("flow graph store unavailable", "err", err)
|
||
return nil
|
||
}
|
||
return store
|
||
}
|
||
|
||
// flowRoundRecorder 返回注入发布器的落盘出口:未接线时返回**真正的 nil 接口**,而不是
|
||
// 「装着 nil 指针的接口」—— 后者在 Go 里不等于 nil,会让发布器的 `p.record != nil`
|
||
// 判据失效(症状是每一轮收尾都撞一次 nil 接收者并记一条 WARN)。
|
||
func (a *app) flowRoundRecorder() loop.RoundRecorder {
|
||
if a.flowStore == nil {
|
||
return nil
|
||
}
|
||
return a.flowStore
|
||
}
|
||
|
||
// flowGraphStore 返回注入网关的只读出口:未接线时同样交出**真 nil 接口**(与
|
||
// channelEventStore 同一取舍)。发布器与网关拿到的是**同一个**存储句柄。
|
||
func (a *app) flowGraphStore() gateway.FlowGraphStore {
|
||
if a.flowStore == nil {
|
||
return nil
|
||
}
|
||
return a.flowStore
|
||
}
|
||
|
||
// startFlowGraphs 拉起链路图留存的维护循环(未接线时是空操作)。
|
||
func (a *app) startFlowGraphs(ctx context.Context) {
|
||
if a.flowStore == nil {
|
||
a.log.Info("flow graph upkeep disabled: store not wired",
|
||
"effect", "rounds are kept in memory only (no local history)")
|
||
return
|
||
}
|
||
keeper := &flowGraphKeeper{
|
||
store: a.flowStore,
|
||
log: logger.WithSource(a.log, "cmd/cyrene/wire_flow_graphs"),
|
||
done: make(chan struct{}),
|
||
}
|
||
a.flowGraphKeeper = keeper
|
||
go keeper.loop(ctx)
|
||
}
|
||
|
||
// flowGraphKeeper 是链路图留存的常驻维护任务(保留期清扫)。它持有**存储句柄**
|
||
// (不是 app),因此生命周期与用法一目了然。
|
||
type flowGraphKeeper struct {
|
||
store *flowgraph.SQLStore
|
||
log *slog.Logger
|
||
// done 在循环退出时关闭(让退出路径可观测)。
|
||
done chan struct{}
|
||
}
|
||
|
||
// loop 是维护循环:启动期先清扫一次,此后按间隔定时清扫,直到 ctx 被取消。
|
||
// GC 关闭时启动期那次是空操作,循环随即返回(不留空转 goroutine)。
|
||
func (k *flowGraphKeeper) loop(ctx context.Context) {
|
||
defer close(k.done)
|
||
k.sweep(ctx)
|
||
ticks, stop := k.gcTicker()
|
||
defer stop()
|
||
if ticks == nil {
|
||
return
|
||
}
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticks:
|
||
k.sweep(ctx)
|
||
}
|
||
}
|
||
}
|
||
|
||
// gcTicker 给出清扫的定时通道;GC 关闭时返回 **nil 通道**(select 上永不触发)。
|
||
// `time.NewTicker(0)` 会 panic,所以「配了 ≤0 = 关闭」必须**在这里**就分岔,而不是
|
||
// 建一个零间隔 ticker 再指望它不跑。
|
||
func (k *flowGraphKeeper) 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 自己记(含删除行数与批次),
|
||
// 这里只负责把「这一轮整个没跑成」记成 WARN 并让循环继续。
|
||
func (k *flowGraphKeeper) sweep(ctx context.Context) {
|
||
if _, err := k.store.Sweep(ctx); err != nil {
|
||
k.log.Warn("flow graph sweep failed", "err", err)
|
||
}
|
||
}
|