Files
AskaEth 2bf3b536dd fix(flow): 时间轴两处诚实性缺陷(X1)
① 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 守卫)
2026-10-06 21:16:21 +08:00

64 lines
3.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 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
}