fix(flow): graph_update 按路分桶——修真并行两路的图互相覆盖(FV2)
【取证(症状成立,且比登记描述更糟)】两路真并行时两路最终落盘**同一个 turn_id**,
其中一路的图彻底消失,且 A 路的工具被画在 B 路的图上。逐层定位:发布器 graphs 只按
sessionID 建键(后起的路整张顶掉前者)· 帧载荷基线的键集被既有测试精确断言为
{id,nodes,edges,sessionId,startedAt,endedAt}(无任何区分路的键)· 前端每会话单桶 ·
落盘 (session_id,turn_id) 唯一键两路相撞 · 冷启动 LastGraph 只有一张。
该 bug 早已公开登记(design/15 §2.5.10-1「本轮未解决」、契约 §6-19「仍**未修**」)。
【修法】
- 归属桶 {会话}[#{路}](loop.FlowOwnerKey,与 checkpoint 键同字符口径);路只来自
ParallelLegFrom(ctx) ⇒ 单路逐字不变(桶 = 会话 id),既有调用点语义零改动
- 图 id 的第二分量改用**建图序号**(每次 +1、永不复用):同一轮里可以有并行路 +
队首续跑的串行路,用轮次号会让两条串行路撞同一个 web-1
- 轮次号只在上一轮的路全部收尾后推进,并丢掉更早轮次的桶(否则内存随路数无界增长)
- 新增只读出口 LatestLanes ⇒ GET /admin/flow/graph 的 lanes[](最近一轮每一路),
graph 仍是「最新一路」= 向后兼容
- 同源洞一并修:子代理归属原取 AgentSpec.ParentSession(会话、不含路)⇒ 并行路里派的
子代理 span 被静默丢弃;新增 AgentSpec.FlowOwner 承载「会话#路」,ParentSession 继续
只承载会话(总线载荷 subagent_result.sessionId 不受影响)
- 前端:store 增 flowLanes(按 turnId 分格、按轮淘汰、硬上限 4)+ LaneTracks 泳道卡;
单路/未给 lanes 时渲染路径与改前逐字相同
【无迁移】turn_id 是自由文本、每路 id 唯一 ⇒ 0022 的唯一索引天然分两行(0023 仍空着)
【我的验收】① 隔离 worktree(30 文件)build/vet/linux/darwin 全过、69 包全绿 0 FAIL;
② 我亲手打红变异:让 FlowOwnerKey 忽略路 ⇒ TestFlowPublisher_ParallelLegsDoNotOverwriteEachOther
FAIL;③ 前端独立复跑 npx tsc --noEmit + npm run build 通过(4.12s)
⚠️ 契约 v0.77 的登记在紧随其后的 docs(contract) 提交里(同一个工作时段内)。
This commit is contained in:
1 parent
c9144c1096
commit
89434eadef
30 files changed
+2039
-148
No files matched your search
+210
-51
@@ -2,6 +2,7 @@ package loop
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -21,6 +22,9 @@ const flowLogSource = "runtime/loop/flow"
|
||||
// allowedSessions 是用于控制哪些会话可发布
|
||||
// flow 图的谓词签名(主人的 web 会话),使 IM/陌生人/子代理轮次绝不
|
||||
// 泄露进管理面板。谓词为 nil 时允许所有会话。
|
||||
//
|
||||
// 谓词收的是**会话 id**(不是归属桶):调用方先经 splitFlowOwner 拆掉路标识,
|
||||
// 因此白名单永远只按会话判 —— 路的存在绝不放宽或收紧任何一档。
|
||||
type allowedSessions func(sessionID string) bool
|
||||
|
||||
// RoundRecorder 把一条**已收尾轮次**的图落盘(design/19 §4 的 P3「多轮历史回放」;
|
||||
@@ -30,6 +34,10 @@ type allowedSessions func(sessionID string) bool
|
||||
// 上调用),失败**上抛**给发布器(发布器记 WARN 后继续)—— 图是可视化资产,落盘失败
|
||||
// 绝不允许影响会话收尾,也绝不改变实时帧。
|
||||
//
|
||||
// 「每轮一条」在真并行下的含义是**每一路一条**:两路各有自己的图 id(见
|
||||
// trace.RunGraph 的 id 口径),因此 `flow_graphs` 的 `(session_id, turn_id)` 唯一键
|
||||
// 不会被两路撞上(design/15 §2.5.10-1)。
|
||||
//
|
||||
// 白名单不在实现方:EndRound 对发布会话范围外的会话**直接返回**,因此落盘出口天然
|
||||
// 只被白名单内的会话触发(复用 flowSessionAllowed,不存在第二份白名单)。
|
||||
type RoundRecorder interface {
|
||||
@@ -50,22 +58,39 @@ func WithFlowLogger(log *slog.Logger) FlowPublisherOption {
|
||||
return func(p *FlowPublisher) { p.log = log }
|
||||
}
|
||||
|
||||
// FlowPublisher 从循环的 trace span 累积每会话 flow 图,
|
||||
// FlowPublisher 从循环的 trace span 累积**按路**的 flow 图,
|
||||
// 并在每个 span 开始/结束时发布 ws.graph_update,给前端
|
||||
// 一张实时的 input→think→tool→output 图(design/19 §1)。它容忍 nil
|
||||
// (nil 发布者为空操作)且并发安全;会话图
|
||||
// (nil 发布者为空操作)且并发安全;同一会话的图
|
||||
// 在同一把互斥锁下串行化,因为并行的工具 span 会并发完成。
|
||||
//
|
||||
// 归属:一个会话拥有一个实时轮次。声明自己是另一会话分支的
|
||||
// Run(loop.WithFlowBranch)绝不调用 StartRound/EndRound,
|
||||
// 而是把其 span 发布到所属会话下,因此子代理渲染为
|
||||
// 归属:**一个 Run 拥有一个路 bucket**。单路时 bucket 就是会话 id(行为与引入
|
||||
// 「路」之前逐字相同);真并行时每一路各有自己的 bucket(`{会话}#{路}`,见
|
||||
// FlowOwnerKey),因此两路的节点绝不互相长在对方的图上、也绝不互相重置
|
||||
// (design/15 §2.5.10-1 登记的那条「graph_update 互相顶掉」)。声明自己是另一
|
||||
// 会话分支的 Run(loop.WithFlowBranch)绝不调用 StartRound/EndRound,
|
||||
// 而是把其 span 发布到所属路的 bucket 下,因此子代理渲染为
|
||||
// 父图的一条泳道,而不是第二张图(design/19 §2)。
|
||||
type FlowPublisher struct {
|
||||
mu sync.Mutex
|
||||
publish func(subject string, payload any)
|
||||
allow allowedSessions
|
||||
graphs map[string]*trace.RunGraph
|
||||
rounds map[string]int
|
||||
// graphs 是「归属桶 → 该桶当前这张图」。键见 FlowOwnerKey。
|
||||
graphs map[string]*trace.RunGraph
|
||||
// seq 是**每会话**的建图序号:每次 StartRound 加一,并写进图 id
|
||||
// (`{会话}-{序号}[#{路}]`)。用序号而不是轮次号做 id 的第二个分量,是为了让
|
||||
// 「同一轮里的第二条路」也拿到一个全新的 id —— 否则两路会撞同一个
|
||||
// `flow_graphs.turn_id`(唯一键),后收尾的那一路把前一路整行顶掉。
|
||||
seq map[string]int
|
||||
// rounds 是每会话当前的**轮次号**(= 该轮第一条路的建图序号)。同一轮的各条路
|
||||
// 共享它,客户端据此只保留最新一轮的泳道。
|
||||
rounds map[string]int
|
||||
// roundOf 是「归属桶 → 它属于哪一轮」。
|
||||
roundOf map[string]int
|
||||
// inflight 是每会话**在飞的桶**集合。新一轮的判据是「上一轮的路全部收尾」
|
||||
// (集合为空),因此它是轮次号推进的唯一依据,也是内存不随路数无界增长的
|
||||
// 依据(新一轮起时丢掉更早轮次的桶)。
|
||||
inflight map[string]map[string]bool
|
||||
// record 是每轮收尾的落盘出口(design/19 §4 P3;切片 F2)。nil = 不落盘
|
||||
// (保持 F1 之前的纯内存行为,且测试可只测发布)。
|
||||
record RoundRecorder
|
||||
@@ -81,10 +106,13 @@ type FlowPublisher struct {
|
||||
// 可变项经 Functional Options 传入(不改变既有两参调用):落盘出口(F2)与日志。
|
||||
func NewFlowPublisher(publish func(subject string, payload any), allow func(sessionID string) bool, opts ...FlowPublisherOption) *FlowPublisher {
|
||||
p := &FlowPublisher{
|
||||
publish: publish,
|
||||
allow: allow,
|
||||
graphs: make(map[string]*trace.RunGraph),
|
||||
rounds: make(map[string]int),
|
||||
publish: publish,
|
||||
allow: allow,
|
||||
graphs: make(map[string]*trace.RunGraph),
|
||||
seq: make(map[string]int),
|
||||
rounds: make(map[string]int),
|
||||
roundOf: make(map[string]int),
|
||||
inflight: make(map[string]map[string]bool),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
@@ -94,40 +122,83 @@ func NewFlowPublisher(publish func(subject string, payload any), allow func(sess
|
||||
return p
|
||||
}
|
||||
|
||||
// StartRound 为 sessionID 开始一张新图,用用户文本
|
||||
// 播种一个 input 节点并发布初始帧。它重置该会话的上一
|
||||
// 轮次,因此每个用户 turn 都是自己的一张图。
|
||||
func (p *FlowPublisher) StartRound(sessionID, inputText string) {
|
||||
if sessionID == "" || !p.allowed(sessionID) {
|
||||
// StartRound 为 owner 开始一张新图,用用户文本
|
||||
// 播种一个 input 节点并发布初始帧。
|
||||
//
|
||||
// owner 是**归属桶**(`{会话}[#{路}]`,见 FlowOwnerKey):单路调用方传的就是会话
|
||||
// id(引入本切片之前的所有调用点原样可用),真并行路由经 flowOwner 传
|
||||
// `{会话}#{路}`。两条路各起各的图 ⇒ 后起的那一路绝不重置前一路的图。
|
||||
//
|
||||
// 轮次号只在**上一轮的路全部收尾**之后推进(inflight 为空),因此「同一条消息
|
||||
// 引出的两路」共享同一个轮次号;新一轮开始时丢掉更早轮次的桶,内存因此不随路数
|
||||
// 无界增长(图的历史在落盘里,见 flowgraph 包)。
|
||||
func (p *FlowPublisher) StartRound(owner, inputText string) {
|
||||
session, lane := splitFlowOwner(owner)
|
||||
if session == "" || owner == "" || !p.allowed(session) {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
round := p.rounds[sessionID] + 1
|
||||
g := trace.NewRunGraph(sessionID)
|
||||
round := p.beginRoundLocked(session, owner)
|
||||
g := trace.NewRunGraph(session)
|
||||
g.SetTurnID(lane)
|
||||
g.SetSeq(p.seq[session])
|
||||
g.Start(round, time.Now())
|
||||
g.SeedInput(inputText)
|
||||
p.graphs[sessionID] = g
|
||||
p.rounds[sessionID] = round
|
||||
p.graphs[owner] = g
|
||||
p.roundOf[owner] = round
|
||||
if p.inflight[session] == nil {
|
||||
p.inflight[session] = make(map[string]bool, 2)
|
||||
}
|
||||
p.inflight[session][owner] = true
|
||||
p.publishLocked(g)
|
||||
}
|
||||
|
||||
// beginRoundLocked 分配(或沿用)本轮次号并清掉更早轮次的桶。调用方必须持有 p.mu。
|
||||
func (p *FlowPublisher) beginRoundLocked(session, owner string) int {
|
||||
p.seq[session]++
|
||||
if len(p.inflight[session]) == 0 {
|
||||
p.rounds[session] = p.seq[session]
|
||||
p.dropEarlierRoundsLocked(session)
|
||||
}
|
||||
return p.rounds[session]
|
||||
}
|
||||
|
||||
// dropEarlierRoundsLocked 丢掉该会话**更早轮次**留下的桶与图。图的历史由落盘承载
|
||||
// (`flow_graphs`),实时桶只需覆盖当前这一轮 —— 否则每一条并行路(turnId 每回合
|
||||
// 全新、bucket 键因此永不复用)都会在内存里永久留一张图。调用方必须持有 p.mu。
|
||||
func (p *FlowPublisher) dropEarlierRoundsLocked(session string) {
|
||||
current := p.rounds[session]
|
||||
for owner, round := range p.roundOf {
|
||||
if round >= current {
|
||||
continue
|
||||
}
|
||||
if s, _ := splitFlowOwner(owner); s != session {
|
||||
continue
|
||||
}
|
||||
delete(p.roundOf, owner)
|
||||
delete(p.graphs, owner)
|
||||
}
|
||||
}
|
||||
|
||||
// EndRound 用回复播种一个 output 节点并发布最终帧,
|
||||
// 以此收尾该轮次。它保留该图,使错过最终帧的客户端能重放最后一份快照 ——
|
||||
// **重放的出口是 `LastGraph`**(网关只读查询,契约 v0.71),而不是又发一帧:
|
||||
// 实时帧向来只推给当时在线的连接,刷新/晚开的页面只能靠只读拉取补齐
|
||||
// (`graph_update` 本身不重放)。
|
||||
// 以此收尾该路的图。它保留该图,使错过最终帧的客户端能重放最后一份快照 ——
|
||||
// **重放的出口是 `LastGraph` / `LatestLanes`**(网关只读查询,契约 v0.71/v0.77),
|
||||
// 而不是又发一帧:实时帧向来只推给当时在线的连接,刷新/晚开的页面只能靠只读拉取
|
||||
// 补齐(`graph_update` 本身不重放)。
|
||||
//
|
||||
// 收尾也是**落盘**的唯一时机(切片 F2;design/19 §4 的 P3「多轮历史回放」):每轮落
|
||||
// **一条**(绝不是每 span 一条),且只在该会话处于发布会话白名单内时发生(本方法对
|
||||
// 白名单外的会话直接返回 ⇒ 落盘出口天然只被白名单会话触发)。深拷贝与写库在**放锁
|
||||
// 之后**做:一次库写绝不把其他会话的 span 发布挡住;失败只记 WARN,绝不影响本轮收尾。
|
||||
func (p *FlowPublisher) EndRound(sessionID, outputText string) {
|
||||
if sessionID == "" || !p.allowed(sessionID) {
|
||||
// 收尾也是**落盘**的唯一时机(切片 F2;design/19 §4 的 P3「多轮历史回放」):每轮
|
||||
// 落**一条**(绝不是每 span 一条),真并行下**每一路各一条**;且只在该会话处于发布
|
||||
// 会话白名单内时发生(本方法对白名单外的会话直接返回 ⇒ 落盘出口天然只被白名单会话
|
||||
// 触发)。深拷贝与写库在**放锁之后**做:一次库写绝不把其他会话的 span 发布挡住;
|
||||
// 失败只记 WARN,绝不影响本轮收尾。
|
||||
func (p *FlowPublisher) EndRound(owner, outputText string) {
|
||||
session, _ := splitFlowOwner(owner)
|
||||
if session == "" || owner == "" || !p.allowed(session) {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
g := p.graphs[sessionID]
|
||||
g := p.graphs[owner]
|
||||
if g == nil {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
@@ -138,29 +209,33 @@ func (p *FlowPublisher) EndRound(sessionID, outputText string) {
|
||||
if p.record != nil {
|
||||
snap = g.Snapshot().Clone()
|
||||
}
|
||||
// 本路退出在飞集合(**只删自己的桶**):集合空了才说明整轮收尾,下一轮才会
|
||||
// 拿到新的轮次号 —— 因此轮次号在同轮并行的两路之间保持同一个值。
|
||||
delete(p.inflight[session], owner)
|
||||
p.mu.Unlock()
|
||||
|
||||
if snap != nil {
|
||||
if err := p.record.RecordRound(snap); err != nil {
|
||||
p.logPersistError(sessionID, snap.ID, err)
|
||||
p.logPersistError(session, snap.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MarkInputSteered 给本轮的 input 节点打上 `steered:true` 标记并发布一帧
|
||||
// (design/15 §5 / 裁决 13.4.5「注入时给 input 节点打 steered:true 标记」)。
|
||||
// 它让链路图如实表达「这一轮的输入在回合中途被追加过」——图仍然只有一条主干
|
||||
// MarkInputSteered 给本路的 input 节点打上 `steered:true` 标记并发布一帧
|
||||
// (design/15 §5 / 裁决 13.4.5「注入时给 input 节点贴 steered:true 标记」)。
|
||||
// 它让链路图如实表达「这一路的输入在回合中途被追加过」——图仍然只有一条主干
|
||||
// (I1:同会话恒只有一个在飞回合),而不是被画成第二轮。
|
||||
//
|
||||
// nil 接收者、空会话、发布白名单外的会话、或该会话还没有图时都是空操作;标记
|
||||
// nil 接收者、空归属、发布白名单外的会话、或该路还没有图时都是空操作;标记
|
||||
// 只并入既有 input 节点(Upsert 的合并语义),绝不新建节点、绝不改动它的标签。
|
||||
func (p *FlowPublisher) MarkInputSteered(sessionID string) {
|
||||
if p == nil || sessionID == "" || !p.allowed(sessionID) {
|
||||
func (p *FlowPublisher) MarkInputSteered(owner string) {
|
||||
session, _ := splitFlowOwner(owner)
|
||||
if p == nil || owner == "" || session == "" || !p.allowed(session) {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g := p.graphs[sessionID]
|
||||
g := p.graphs[owner]
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
@@ -174,13 +249,13 @@ func (p *FlowPublisher) MarkInputSteered(sessionID string) {
|
||||
|
||||
// SpanStarted 为一个刚开始的 span 记录一个运行中的占位
|
||||
// 节点。state_transition span 会被图构建器省略。
|
||||
func (p *FlowPublisher) SpanStarted(sessionID string, sp *trace.Span) {
|
||||
if sp == nil || sessionID == "" || !p.allowed(sessionID) {
|
||||
func (p *FlowPublisher) SpanStarted(owner string, sp *trace.Span) {
|
||||
if sp == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g := p.graphs[sessionID]
|
||||
g := p.bucketLocked(owner)
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
@@ -190,14 +265,14 @@ func (p *FlowPublisher) SpanStarted(sessionID string, sp *trace.Span) {
|
||||
}
|
||||
|
||||
// SpanFinished 把一个刚结束的 span 作为最终节点(状态 done/error)
|
||||
// 并入会话图并发布。必须在 span 的 Finish 之后调用。
|
||||
func (p *FlowPublisher) SpanFinished(sessionID string, sp *trace.Span) {
|
||||
if sp == nil || sessionID == "" || !p.allowed(sessionID) {
|
||||
// 并入该路的图并发布。必须在 span 的 Finish 之后调用。
|
||||
func (p *FlowPublisher) SpanFinished(owner string, sp *trace.Span) {
|
||||
if sp == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g := p.graphs[sessionID]
|
||||
g := p.bucketLocked(owner)
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
@@ -205,16 +280,30 @@ func (p *FlowPublisher) SpanFinished(sessionID string, sp *trace.Span) {
|
||||
p.publishLocked(g)
|
||||
}
|
||||
|
||||
// LastGraph 返回 sessionID **最近一轮**链路图的一份只读深拷贝快照,供
|
||||
// bucketLocked 取归属桶当前那张图;归属非法或该路还没有图时返回 nil。调用方必须
|
||||
// 持有 p.mu。白名单判据与建图处同一处(bucket 拆出的会话),因此**不可能**让
|
||||
// 白名单外的会话经 span 折叠进图。
|
||||
func (p *FlowPublisher) bucketLocked(owner string) *trace.RunGraph {
|
||||
session, _ := splitFlowOwner(owner)
|
||||
if owner == "" || session == "" || !p.allowed(session) {
|
||||
return nil
|
||||
}
|
||||
return p.graphs[owner]
|
||||
}
|
||||
|
||||
// LastGraph 返回 sessionID **最近一路**链路图的一份只读深拷贝快照,供
|
||||
// 管理面板在「轮次已经结束、实时帧已经错过」时补拉(契约 v0.71 的
|
||||
// `GET /api/v1/admin/flow/graph`;design/19 §4 的 P3「多轮历史回放」的
|
||||
// 最小一步)。
|
||||
//
|
||||
// 「最近」= 该会话所有在内存的路里**开始最晚**的那一路(同刻用图 id 定序)。
|
||||
// 真并行时它只是最近的一路,**全貌**是 `LatestLanes`(契约 v0.77 的 `lanes[]`)。
|
||||
//
|
||||
// 它是**只读**的:不发布、不改图、不动轮次计数 —— 因此补拉绝不会在
|
||||
// 管理面板里凭空造出一帧 `graph_update`,也绝不改任何会话的实时图。
|
||||
//
|
||||
// 该会话还没有图时返回 nil:从未跑过带 span 的轮次、或该会话本就**不在
|
||||
// 发布会话范围内**(`StartRound` 对白名单外的会话直接返回 ⇒ 从不建图,
|
||||
// 发布会话范围**(`StartRound` 对白名单外的会话直接返回 ⇒ 从不建图,
|
||||
// 隔离渠道会话因此天然查不到)。调用方**必须**把 nil 读作「确实没有图」,
|
||||
// 绝不允许据此补画任何节点或假图。
|
||||
//
|
||||
@@ -226,11 +315,81 @@ func (p *FlowPublisher) LastGraph(sessionID string) *trace.FlowGraph {
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g := p.graphs[sessionID]
|
||||
if g == nil {
|
||||
latest := p.latestLocked(sessionID)
|
||||
if latest == nil {
|
||||
return nil
|
||||
}
|
||||
return g.Snapshot().Clone()
|
||||
return latest.Clone()
|
||||
}
|
||||
|
||||
// LatestLanes 返回 sessionID **最近一轮每一路**的图快照(每条路一份深拷贝),
|
||||
// 按路的开始时间升序(最早的在前)。契约 v0.77 的 `lanes[]` 就是它:真并行时
|
||||
// 同一会话同一轮有多条路,`LastGraph` 只给出其中最新的一条,管理面板要靠本方法
|
||||
// 才看得全(design/19 §2「并行分支独立显示」)。
|
||||
//
|
||||
// 没有图时返回空切片(不是 nil):调用方据此渲染诚实空态,绝不补画。
|
||||
// 与 LastGraph 一样是**只读**的,且返回深拷贝(读者在锁外编码)。
|
||||
func (p *FlowPublisher) LatestLanes(sessionID string) []*trace.FlowGraph {
|
||||
if p == nil || sessionID == "" {
|
||||
return nil
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
round := p.rounds[sessionID]
|
||||
owners := make([]string, 0, 2)
|
||||
for owner, r := range p.roundOf {
|
||||
if r != round {
|
||||
continue
|
||||
}
|
||||
if s, _ := splitFlowOwner(owner); s == sessionID {
|
||||
owners = append(owners, owner)
|
||||
}
|
||||
}
|
||||
out := make([]*trace.FlowGraph, 0, len(owners))
|
||||
for _, owner := range owners {
|
||||
g := p.graphs[owner]
|
||||
if g == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, g.Snapshot().Clone())
|
||||
}
|
||||
sortLanes(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// latestLocked 取该会话开始最晚的那一路的活图(没有时 nil)。调用方必须持有 p.mu。
|
||||
func (p *FlowPublisher) latestLocked(sessionID string) *trace.FlowGraph {
|
||||
var best *trace.FlowGraph
|
||||
for owner, g := range p.graphs {
|
||||
if s, _ := splitFlowOwner(owner); s != sessionID {
|
||||
continue
|
||||
}
|
||||
snap := g.Snapshot()
|
||||
if best == nil || laneNewer(snap, best) {
|
||||
best = snap
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// laneNewer 报告 a 是否比 b 更「新」:先比开始时刻,同刻用图 id 定序(图 id 里含
|
||||
// 会话级单调序号,因此定序不依赖时间分辨率)。
|
||||
func laneNewer(a, b *trace.FlowGraph) bool {
|
||||
if a.StartedAt.Equal(b.StartedAt) {
|
||||
return a.ID > b.ID
|
||||
}
|
||||
return a.StartedAt.After(b.StartedAt)
|
||||
}
|
||||
|
||||
// sortLanes 按「开始时刻升序、同刻按图 id」把一路路图排序,使一个轮次里的多条路
|
||||
// 有一个确定的展示次序(绝不让展示次序随 map 遍历序抖动)。
|
||||
func sortLanes(lanes []*trace.FlowGraph) {
|
||||
sort.SliceStable(lanes, func(i, j int) bool {
|
||||
if lanes[i].StartedAt.Equal(lanes[j].StartedAt) {
|
||||
return lanes[i].ID < lanes[j].ID
|
||||
}
|
||||
return lanes[i].StartedAt.Before(lanes[j].StartedAt)
|
||||
})
|
||||
}
|
||||
|
||||
// publishLocked 发布 g 的一份快照。调用方必须持有 p.mu。
|
||||
|
||||
Reference in new issue
Block a user