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 守卫)
This commit is contained in:
1 parent
b36798265a
commit
2bf3b536dd
9 files changed
+820
-12
No files matched your search
@@ -0,0 +1,63 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package flowgraph
|
||||
|
||||
// 本文件钉住**轮次计数器的播种读侧**(design/19 §6.1「`graph.round`:每会话单调」;
|
||||
// 切片 X1):`MaxRound` 如实交回该会话**已落盘**的最大轮次号,坏行/未知轮次/别的会话
|
||||
// 的行都不参与取值,读不到时如实报错而不是回 0。
|
||||
//
|
||||
// 断言的是**库里读回来的事实**(数值本身),不按日志断言 —— 与 flowgraph_test.go /
|
||||
// timeline_test.go 同一口径。发布器侧「拿这个值播种之后轮次号跨重启不重用」的判据在
|
||||
// `internal/runtime/loop/flow_roundseed_test.go`(同一切片的另一半)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// maxRoundOf 取某会话的最大轮次号(失败即 Fatal)。
|
||||
func maxRoundOf(t *testing.T, st *SQLStore, session string) int {
|
||||
t.Helper()
|
||||
n, err := st.MaxRound(context.Background(), session)
|
||||
if err != nil {
|
||||
t.Fatalf("MaxRound(%s): %v", session, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// timelineRoundNoKey 造一行**轮次号缺席**的老图(`round` 键根本不写入 —— Round 为 0
|
||||
// 时 `trace` 的 JSON 编码不带该键,与真机上那批旧行同形)。
|
||||
func timelineRoundNoKey(id, session string) *trace.FlowGraph {
|
||||
return &trace.FlowGraph{
|
||||
ID: id,
|
||||
SessionID: session,
|
||||
StartedAt: testNow().Add(-time.Minute),
|
||||
EndedAt: testNow(),
|
||||
Nodes: []trace.FlowNode{{ID: "input", Label: "你好", Kind: "input"}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaxRound_PicksLargestPersistedRound_正常 钉住取值本身:多轮取最大,缺失轮次号的
|
||||
// 老行(Round 0)与**别的会话**的行都不干扰。
|
||||
func TestMaxRound_PicksLargestPersistedRound_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, _ := newTestStore(t, nil)
|
||||
|
||||
if got := maxRoundOf(t, st, "web"); got != 0 {
|
||||
t.Fatalf("空会话 MaxRound = %d, want 0(库里没有该会话的行)", got)
|
||||
}
|
||||
recordTimelineRound(t, st, timelineGraph("web", 3, 3, "turn-c", testNow()))
|
||||
recordTimelineRound(t, st, timelineRoundNoKey("web-old", "web"))
|
||||
recordTimelineRound(t, st, timelineGraph("web", 7, 9, "turn-g", testNow()))
|
||||
recordTimelineRound(t, st, timelineGraph("other", 42, 42, "turn-x", testNow()))
|
||||
|
||||
if got := maxRoundOf(t, st, "web"); got != 7 {
|
||||
t.Fatalf("MaxRound(web) = %d, want 7(最大值;老行与别的会话都不算)", got)
|
||||
}
|
||||
if got := maxRoundOf(t, st, "other"); got != 42 {
|
||||
t.Fatalf("MaxRound(other) = %d, want 42(按会话隔离,绝不串会话)", got)
|
||||
}
|
||||
if got := maxRoundOf(t, st, "sess-im-stranger"); got != 0 {
|
||||
t.Fatalf("MaxRound(白名单外会话) = %d, want 0(它从不落盘)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaxRound_BrokenJSONRowIsIgnored_边界 钉住坏行**不拖垮**播种:`graph_json` 破损
|
||||
// 的那一行从不参与解析(SQL 侧 `json_valid` 保护),查询照常给出其余行的最大值。
|
||||
func TestMaxRound_BrokenJSONRowIsIgnored_边界(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, conn := newTestStore(t, nil)
|
||||
recordTimelineRound(t, st, timelineGraph("web", 5, 5, "turn-e", testNow()))
|
||||
recordTimelineRound(t, st, timelineGraph("web", 6, 6, "turn-f", testNow()))
|
||||
if _, err := conn.ExecContext(context.Background(),
|
||||
`UPDATE flow_graphs SET graph_json = '{ oops' WHERE turn_id = ?`, "web-6#turn-f"); err != nil {
|
||||
t.Fatalf("把一行做坏: %v", err)
|
||||
}
|
||||
|
||||
if got := maxRoundOf(t, st, "web"); got != 5 {
|
||||
t.Fatalf("MaxRound = %d, want 5(坏行被忽略;它绝不能让整条查询报错)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaxRound_NonPositiveRoundIsUnknown_边界 钉住缺席语义:`round` 为 0 / 负数 /
|
||||
// 非数字(`CAST` 归 0)的行都算「未知轮次」,绝不冒充一个真实轮次号 —— 因此一个只有
|
||||
// 这类行的会话回 0,而不是回一个假的「第 -5 轮」。
|
||||
func TestMaxRound_NonPositiveRoundIsUnknown_边界(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, conn := newTestStore(t, nil)
|
||||
recordTimelineRound(t, st, timelineGraph("web", 4, 4, "turn-d", testNow()))
|
||||
recordTimelineRound(t, st, timelineGraph("neg", 9, 9, "turn-n", testNow()))
|
||||
if _, err := conn.ExecContext(context.Background(),
|
||||
`UPDATE flow_graphs SET graph_json = json_set(graph_json, '$.round', -5) WHERE turn_id = ?`,
|
||||
"neg-9#turn-n"); err != nil {
|
||||
t.Fatalf("把 round 改成负数: %v", err)
|
||||
}
|
||||
|
||||
if got := maxRoundOf(t, st, "neg"); got != 0 {
|
||||
t.Fatalf("MaxRound(只有负轮次号) = %d, want 0(未知不冒充真实轮次)", got)
|
||||
}
|
||||
if got := maxRoundOf(t, st, "web"); got != 4 {
|
||||
t.Fatalf("MaxRound(web) = %d, want 4(别的会话的负值不该影响它)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaxRound_NilDBIsHonest_异常 钉住未接数据库时如实报 `ErrNilDB`:绝不静默回 0
|
||||
// —— 那会让发布器把「读不到」当成「库里没有轮次」而发一个会撞旧行的号。
|
||||
func TestMaxRound_NilDBIsHonest_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
var nilStore *SQLStore
|
||||
if _, err := nilStore.MaxRound(context.Background(), "web"); !errors.Is(err, ErrNilDB) {
|
||||
t.Fatalf("nil store 的 err = %v, want ErrNilDB", err)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ package flowgraph
|
||||
// 3. **查不到就是查不到**:已过保留期的行已被 `gc.go` 清扫、从未落盘的行本就不存在 ——
|
||||
// 两者都不出现在返回值里,本包**不猜**、绝不用别的轮次顶替。保留期口径由调用方经
|
||||
// `Retention()` 如实转达给客户端(「更早的轮次已过保留期」这句话不能靠前端编)。
|
||||
// 4. **清扫事实单独判**(切片 X1 新增,见 `EarlierRoundsSwept`):`Timeline` 的第二个
|
||||
// 返回值只回答「本次是不是全部」(`hasMore`),它**不**回答「更早的轮次是不是被
|
||||
// 保留期清扫掉了」—— 那是另一件事、另一个判据。
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -284,6 +287,54 @@ func mergeLane(byKey map[string]*TimelineRound, g *trace.FlowGraph, createdAt st
|
||||
return nil
|
||||
}
|
||||
|
||||
// EarlierRoundsSwept 报告「该会话**确有更早的轮次已被保留期清扫删掉**」这一事实
|
||||
// (design/19 §7.1 的清扫信号;切片 X1,2026-10-06)。
|
||||
//
|
||||
// **它补的是真机挖出的第二处真缺陷**:此前契约与设计把 `truncated` 读作「更早的轮次
|
||||
// 已过保留期」,而后端的 `truncated = hasMore || !storeWired` 说的是「本次不是全部」
|
||||
// —— 一页还没拉完时,面板就会**对着仍然可翻的历史说假话**。两件事必须分开:
|
||||
// `truncated` 回答「本次是不是全部」,本函数回答「更早的是不是被清扫掉了」。
|
||||
//
|
||||
// **判据(一条链,逐环可复核)**:
|
||||
// 1. 轮次号**自 1 起**:一个会话的第一次发号必然是 1(`loop` 的计数器从 0 起,其轮
|
||||
// 次号 = 该轮首路的建图序号),且此后**每会话单调**(跨重启也单调 —— 发布器在
|
||||
// 本进程第一次给该会话发号之前,用 `MaxRound` 把计数器播到已落盘的最大值之上);
|
||||
// 2. 因此「现存**最早**一轮的轮次号 > 1」只可能由**删除**造成:号更小的那些轮次确实
|
||||
// 发过号(= 确实建过图、收尾时确实尝试落盘);
|
||||
// 3. `flow_graphs` 的**唯一**删除者就是保留期清扫(`gc.go`:按 `created_at` 删过期行;
|
||||
// 读取侧只读、发布器只 upsert)。
|
||||
// ⇒ 最早一轮的轮次号 > 1 时,「更早的轮次已被保留期清扫」成立。
|
||||
//
|
||||
// **调用方必须保证 rounds 是该会话**完整**的轮次列表**(即 `Timeline` 返回 `hasMore`
|
||||
// 为 false 的那一页,可再并入内存里的在飞路):只看一页就说「已过保留期」,正是本切片
|
||||
// 要治的那句假话。本函数是**纯判断**,不查库、不猜时间。
|
||||
//
|
||||
// **诚实的盲区(不美化)**:① 一个会话若**整段历史都被清扫**(一条可用行都不剩),
|
||||
// 发号会从 1 重新开始 ⇒ 本函数无法断言,返回 false(**少说**,绝不多说);② 同一会话
|
||||
// 里有轮次号缺席的老行时同样返回 false(号不可信);③ 唯一可能**错报**的路径是该会话
|
||||
// 最早的轮次**落盘失败**(`RecordRound` 报错,发布器记 WARN)—— 那一轮从未进库、号却
|
||||
// 已用掉,本函数会把它算成「已清扫」。概率低、只在库写失败时出现,如实登记在
|
||||
// design/19 §7 与契约里,不掩盖。
|
||||
func EarlierRoundsSwept(rounds []TimelineRound) bool {
|
||||
if len(rounds) == 0 {
|
||||
return false
|
||||
}
|
||||
if rounds[0].Round <= 0 {
|
||||
return false
|
||||
}
|
||||
floor := rounds[0].Round
|
||||
for i := 1; i < len(rounds); i++ {
|
||||
if rounds[i].Round <= 0 {
|
||||
// 有轮次号未知的轮次 ⇒ 「号更小的都删过了」这条推论不成立,保守为否。
|
||||
return false
|
||||
}
|
||||
if rounds[i].Round < floor {
|
||||
floor = rounds[i].Round
|
||||
}
|
||||
}
|
||||
return floor > 1
|
||||
}
|
||||
|
||||
// roundsInKeyOrder 按 SQL 给出的键次序(最新 ⇒ 最旧)装配轮次并**反转为升序**
|
||||
// (时间轴从上往下读成时间序)。键没有对应行时跳过 —— 那意味着并发清扫刚删掉了它。
|
||||
func roundsInKeyOrder(byKey map[string]*TimelineRound, keys []string) []TimelineRound {
|
||||
|
||||
@@ -240,6 +240,54 @@ func TestTimeline_EmptyAndNilDB_边界(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEarlierRoundsSwept_RoundFloorIsTheEvidence_正常 钉住**清扫信号**的判据(切片 X1;
|
||||
// design/19 §7.1):该会话**完整**的轮次列表里,最早一轮的轮次号 > 1 ⇒ 号更小的轮次曾
|
||||
// 发过号且已不在库里 ⇒ 「更早的轮次已被保留期清扫删掉」成立;最早一轮就是 1 ⇒ 没有更早
|
||||
// 的被删,绝不说「已过保留期」。
|
||||
func TestEarlierRoundsSwept_RoundFloorIsTheEvidence_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
roundsOf := func(rounds ...int) []TimelineRound {
|
||||
out := make([]TimelineRound, 0, len(rounds))
|
||||
for _, r := range rounds {
|
||||
out = append(out, TimelineRound{Key: fmt.Sprintf("r:%d", r), Round: r})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
if EarlierRoundsSwept(roundsOf(1, 2, 3)) {
|
||||
t.Fatal("最早一轮是第 1 轮 ⇒ 没有更早的被清扫,绝不能说「已过保留期」")
|
||||
}
|
||||
if !EarlierRoundsSwept(roundsOf(5, 6)) {
|
||||
t.Fatal("最早一轮是第 5 轮 ⇒ 号更小的轮次确实发过号、已不在库里 ⇒ 必须为 true")
|
||||
}
|
||||
if !EarlierRoundsSwept(roundsOf(3, 7, 12)) {
|
||||
t.Fatal("乱序/跳号不影响判据:取最小轮次号 > 1 即为 true")
|
||||
}
|
||||
// 同一轮的多路(真并行)不改变最早轮次号。
|
||||
parallel := append(roundsOf(2, 3), TimelineRound{Key: "r:2", Round: 2, Lanes: nil})
|
||||
if !EarlierRoundsSwept(parallel) {
|
||||
t.Fatal("同一轮两路时最早的仍是第 2 轮 ⇒ true(多路不该把判据翻成 false)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEarlierRoundsSwept_UnknownRoundAndEmptyAreConservative_边界 钉住两条保守口径:
|
||||
// 轮次号未知的轮次在场(老行 / 在飞路没有号)时判据**不成立**(返回 false,宁少说不多说),
|
||||
// 空列表同样为 false —— 别把「不知道」说成「被清扫了」。
|
||||
func TestEarlierRoundsSwept_UnknownRoundAndEmptyAreConservative_边界(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if EarlierRoundsSwept(nil) || EarlierRoundsSwept([]TimelineRound{}) {
|
||||
t.Fatal("没有轮次 ⇒ 判据不成立(绝不是「已过保留期」)")
|
||||
}
|
||||
mixed := []TimelineRound{
|
||||
{Key: "t:web-old-1", Round: 0},
|
||||
{Key: "r:5", Round: 5},
|
||||
}
|
||||
if EarlierRoundsSwept(mixed) {
|
||||
t.Fatal("有轮次号未知的轮次在场 ⇒ 号不可信 ⇒ 必须保守为 false")
|
||||
}
|
||||
}
|
||||
|
||||
// roundKeys 取出轮次键序列(断言用)。
|
||||
func roundKeys(rounds []TimelineRound) []string {
|
||||
out := make([]string, 0, len(rounds))
|
||||
|
||||
Reference in new issue
Block a user