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) } }