① round 号跨重启被复用 ⇒ 时间轴把多个真实轮次并成一块(真机实测 web 的 r:1 挤了 4 路、 startedAt 跨 13:52→21:05)。修法:发号前从落盘播种(MaxRound,json_extract + json_valid 挡坏行), 每会话每进程只播一次、失败只 WARN 且不重试、并发首轮共享一次读;接口形状零变化 ② truncated 一个字段兼两个意思 ⇒ 一页没拉完时前端会说「更早的轮次已过保留期」(假话)。 新增 retentionBoundaryReached(= 保留期启用 且 已翻到底 且 现存最早一轮 round>1 ⇒ 确有轮次被清扫), truncated 语义一字未改;盲区如实登记(整段被清扫会说少、最早一轮落盘失败可能多说) 3 个变异自检(不落账/floor>0/去掉 hasMore 守卫)
64 lines
3.7 KiB
Go
64 lines
3.7 KiB
Go
package flowgraph
|
||
|
||
// 本文件是**轮次计数器的播种读侧**(design/19 §6.1「`graph.round`:每会话单调」;
|
||
// 切片 X1,2026-10-06)。
|
||
//
|
||
// 它补的是真机挖出的第一处真缺陷:发布器的轮次号是**进程内**计数
|
||
// (`loop.FlowPublisher` 的 `rounds`/`seq`),后端一重启就从 1 重来,而落盘的
|
||
// `flow_graphs` 保留 168h ⇒ 时间轴(`Timeline`)按轮次号分组时把**三个真实轮次并成
|
||
// 一块**。真机实测(`session=web`):分组键 `r:1` 下有 13:52:53 / 15:42:27 / 18:40:44
|
||
// 三次真实的轮,块标题「第 1 轮 · 3 路」是失真的。
|
||
//
|
||
// 本文件只提供**一个只读出口**(`MaxRound`):把一个会话**已经落盘**的最大轮次号交回
|
||
// 调用方,让它把进程内的计数器播到那个值之上。播种与发号在 `loop` 侧
|
||
// (`internal/runtime/loop/flow_roundseed.go`)—— 本包不认识发布器、不判会话、也绝不
|
||
// 改任何会话的状态(与 `Timeline` 同一条只读纪律)。
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
)
|
||
|
||
// maxRoundSQL 取该会话已落盘的最大轮次号(一轮一行,因此聚合面有界)。
|
||
//
|
||
// 三条口径与 `timeline.go` 的 `timelineRoundKeySQL` **逐字同源**:
|
||
//
|
||
// 1. `json_valid` 是**保护**:JSON 破损的行根本不参与解析 —— 否则 `json_extract`
|
||
// 会让整条查询报错,把「一行坏数据」升级成「播种整体失败」(坏行不该拖垮重启后的
|
||
// 计数器)。用嵌套 `CASE`(而不是 `json_valid(...) AND ...`)是因为 SQLite 不保证
|
||
// AND 的操作数求值次序,而 CASE 只求值命中的那一支。
|
||
// 2. **非正数不算真实轮次**:`round` 键缺席 / 为 0 / 为负 / 非数字(`CAST` 归 0)的行
|
||
// 都给不出一个「更大的号」;一个会话若只剩下这类行,聚合结果就是 0 或负数 ⇒ Go 侧
|
||
// 归 0 —— 绝不冒充一个真实轮次号(design/19 §6.1 的缺席语义)。
|
||
// 3. 没有可用行时 `MAX` 是 NULL ⇒ `COALESCE` 归 **0**(=「库里没有该会话的轮次」),
|
||
// 调用方据此从 1 起发号(与引入播种之前的行为一致)。
|
||
const maxRoundSQL = `SELECT COALESCE(MAX(CASE WHEN json_valid(graph_json)
|
||
THEN CAST(json_extract(graph_json,'$.round') AS INTEGER) END), 0)
|
||
FROM flow_graphs
|
||
WHERE session_id = ?`
|
||
|
||
// MaxRound 返回该会话**已落盘**的最大轮次号;库里没有该会话的可用行时返回 0。
|
||
//
|
||
// 语义与边界:
|
||
// - **只读**:不写库、不删行、不碰保留期(与 `Graph` / `Latest` / `Timeline` 同档);
|
||
// - **保留期决定它能看多远**:已被 `Sweep` 清掉的轮次不在这里的账上 ⇒ 一个会话若整段
|
||
// 历史都被清扫过,返回值会回落到 0,调用方随即从 1 起发号 —— 那**不会**造成时间轴
|
||
// 上的轮次合并(旧行已经不在了),只是轮次号从头开始(design/19 §7.4 的诚实边界);
|
||
// - `ctx` 的上界由调用方给(发布器侧用一个有界的读取上界,绝不把一次库读挂在会话收尾
|
||
// 或轮次开始上无限等);
|
||
// - 失败**如实上抛**(调用方记 WARN 后退回进程内计数):宁可让计数器退回修复前的行为,
|
||
// 也绝不把「读不到」当成「库里没有轮次」而悄悄发一个会撞旧行的号。
|
||
func (s *SQLStore) MaxRound(ctx context.Context, sessionID string) (int, error) {
|
||
if s == nil || s.db == nil {
|
||
return 0, fmt.Errorf("flowgraph: query max round: %w", ErrNilDB)
|
||
}
|
||
var n int
|
||
if err := s.db.QueryRowContext(ctx, maxRoundSQL, sessionID).Scan(&n); err != nil {
|
||
return 0, fmt.Errorf("flowgraph: 查最大轮次号(%s): %w", sessionID, err)
|
||
}
|
||
if n < 0 {
|
||
return 0, nil
|
||
}
|
||
return n, nil
|
||
}
|