① 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 守卫)
312 lines
11 KiB
Go
312 lines
11 KiB
Go
package flowgraph
|
||
|
||
// 本文件钉住**时间轴读侧**(design/19 §7;切片 W1):多轮按时间正序取回、一轮含其
|
||
// **每一路**、`before` 游标向更早翻页不重不漏、没有轮次号的老行各自成组。
|
||
//
|
||
// 断言的是**库里读回来的事实**(几轮、每轮几路、次序、`hasMore`),不按日志断言 ——
|
||
// 与 flowgraph_test.go / flowgraph_lane_test.go 同一口径。
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||
)
|
||
|
||
// timelineGraph 用真建图器造一条路的收尾图:round 是会话轮次号(同轮的多路共享),
|
||
// seq 是会话级建图序号(每条路必不同,决定图 id),lane 是这一路的权威回合标识。
|
||
func timelineGraph(session string, round, seq int, lane string, started time.Time) *trace.FlowGraph {
|
||
g := trace.NewRunGraph(session)
|
||
g.SetTurnID(lane)
|
||
g.SetSeq(seq)
|
||
g.Start(round, started)
|
||
g.SeedInput("输入-" + lane)
|
||
g.SeedOutput("回复-" + lane)
|
||
return g.Snapshot()
|
||
}
|
||
|
||
// recordTimelineRound 落一条时间轴夹具(失败即 Fatal)。
|
||
func recordTimelineRound(t *testing.T, st *SQLStore, g *trace.FlowGraph) {
|
||
t.Helper()
|
||
if err := st.RecordRound(g); err != nil {
|
||
t.Fatalf("落盘 %s: %v", g.ID, err)
|
||
}
|
||
}
|
||
|
||
// TestTimeline_GroupsLanesByRound_正常 是本文件的核心断言:三轮按**时间正序**返回,
|
||
// 中间那一轮的**两路**并进同一轮(`lanes` 两条),而不是被拆成两轮、也不是只剩一路。
|
||
func TestTimeline_GroupsLanesByRound_正常(t *testing.T) {
|
||
t.Parallel()
|
||
st, _ := newTestStore(t, nil)
|
||
ctx := context.Background()
|
||
|
||
base := testNow().Add(-time.Hour)
|
||
recordTimelineRound(t, st, timelineGraph("web", 1, 1, "turn-a", base.Add(30*time.Minute)))
|
||
recordTimelineRound(t, st, timelineGraph("web", 2, 2, "turn-b", base.Add(40*time.Minute)))
|
||
recordTimelineRound(t, st, timelineGraph("web", 2, 3, "turn-c", base.Add(40*time.Minute).Add(time.Second)))
|
||
recordTimelineRound(t, st, timelineGraph("web", 3, 4, "turn-d", base.Add(50*time.Minute)))
|
||
|
||
rounds, more, err := st.Timeline(ctx, "web", 0, time.Time{})
|
||
if err != nil {
|
||
t.Fatalf("Timeline: %v", err)
|
||
}
|
||
if more {
|
||
t.Fatal("hasMore = true,但库里只有这三轮")
|
||
}
|
||
if len(rounds) != 3 {
|
||
t.Fatalf("轮数 = %d, want 3(同一轮的两路属**同一轮**): %+v", len(rounds), roundKeys(rounds))
|
||
}
|
||
for i, want := range []string{"r:1", "r:2", "r:3"} {
|
||
if rounds[i].Key != want {
|
||
t.Fatalf("rounds[%d].Key = %q, want %q(时间正序)", i, rounds[i].Key, want)
|
||
}
|
||
}
|
||
for i, want := range []int{1, 2, 3} {
|
||
if rounds[i].Round != want {
|
||
t.Fatalf("rounds[%d].Round = %d, want %d", i, rounds[i].Round, want)
|
||
}
|
||
}
|
||
if got := len(rounds[1].Lanes); got != 2 {
|
||
t.Fatalf("第 2 轮的 lanes = %d, want 2(两路都在): %+v", got, rounds[1].Lanes)
|
||
}
|
||
if !rounds[1].StartedAt.Equal(base.Add(40 * time.Minute)) {
|
||
t.Fatalf("第 2 轮开始时刻 = %v, want %v(取最早那一路)", rounds[1].StartedAt, base.Add(40*time.Minute))
|
||
}
|
||
if rounds[0].Lanes[0].ID != "web-1#turn-a" {
|
||
t.Fatalf("第 1 轮的路 id = %q, want web-1#turn-a", rounds[0].Lanes[0].ID)
|
||
}
|
||
}
|
||
|
||
// TestTimeline_BeforeCursorPagesWithoutGapOrDup_边界 钉住分页:`limit=2` 逐页向更早翻,
|
||
// 各页**不重不漏**、`hasMore` 如实、翻到底后是诚实空态。
|
||
func TestTimeline_BeforeCursorPagesWithoutGapOrDup_边界(t *testing.T) {
|
||
t.Parallel()
|
||
st, _ := newTestStore(t, nil)
|
||
ctx := context.Background()
|
||
|
||
base := testNow().Add(-10 * time.Hour)
|
||
for round := 1; round <= 5; round++ {
|
||
started := base.Add(time.Duration(round) * time.Hour)
|
||
recordTimelineRound(t, st, timelineGraph("web", round, round, fmt.Sprintf("turn-%d", round), started))
|
||
}
|
||
|
||
seen := map[string]int{}
|
||
before := time.Time{}
|
||
wantPages := [][]string{
|
||
{"r:4", "r:5"},
|
||
{"r:2", "r:3"},
|
||
{"r:1"},
|
||
{},
|
||
}
|
||
wantMorePages := []bool{true, true, false, false}
|
||
for page, want := range wantPages {
|
||
rounds, more, err := st.Timeline(ctx, "web", 2, before)
|
||
if err != nil {
|
||
t.Fatalf("第 %d 页 Timeline: %v", page+1, err)
|
||
}
|
||
if got := roundKeys(rounds); !sameKeys(got, want) {
|
||
t.Fatalf("第 %d 页轮次 = %v, want %v", page+1, got, want)
|
||
}
|
||
if more != wantMorePages[page] {
|
||
t.Fatalf("第 %d 页 hasMore = %v, want %v", page+1, more, wantMorePages[page])
|
||
}
|
||
if len(rounds) == 0 {
|
||
continue
|
||
}
|
||
for _, r := range rounds {
|
||
seen[r.Key]++
|
||
}
|
||
before = rounds[0].StartedAt
|
||
}
|
||
for round := 1; round <= 5; round++ {
|
||
if key := fmt.Sprintf("r:%d", round); seen[key] != 1 {
|
||
t.Fatalf("轮 %s 出现 %d 次, want 1(翻页不重不漏;全部=%v)", key, seen[key], seen)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestTimeline_LegacyRowWithoutRoundIsOwnGroup_边界 钉住老行的诚实口径:图上没有轮次号
|
||
// (`round` 键缺席)时**每张图自成一组**,绝不把它们猜成同一轮,`Round` 如实为 0
|
||
// (调用方据此让线上 JSON 出 `null`)。
|
||
func TestTimeline_LegacyRowWithoutRoundIsOwnGroup_边界(t *testing.T) {
|
||
t.Parallel()
|
||
st, _ := newTestStore(t, nil)
|
||
ctx := context.Background()
|
||
|
||
// 手工造两张「老图」:Round 为 0(= 键缺席)、图 id 不同。
|
||
for i, id := range []string{"web-old-1", "web-old-2"} {
|
||
g := &trace.FlowGraph{
|
||
ID: id,
|
||
SessionID: "web",
|
||
StartedAt: testNow().Add(-time.Duration(10-i) * time.Minute),
|
||
EndedAt: testNow(),
|
||
Nodes: []trace.FlowNode{{ID: "input", Label: "你好", Kind: "input"}},
|
||
}
|
||
recordTimelineRound(t, st, g)
|
||
}
|
||
|
||
rounds, _, err := st.Timeline(ctx, "web", 0, time.Time{})
|
||
if err != nil {
|
||
t.Fatalf("Timeline: %v", err)
|
||
}
|
||
if len(rounds) != 2 {
|
||
t.Fatalf("轮数 = %d, want 2(无轮次号的行各自成组,绝不猜)", len(rounds))
|
||
}
|
||
if rounds[0].Key != "t:web-old-1" || rounds[1].Key != "t:web-old-2" {
|
||
t.Fatalf("分组键 = %q/%q, want t:web-old-1 / t:web-old-2", rounds[0].Key, rounds[1].Key)
|
||
}
|
||
for i, r := range rounds {
|
||
if r.Round != 0 {
|
||
t.Fatalf("rounds[%d].Round = %d, want 0(未知,不得冒充真实轮次)", i, r.Round)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestTimeline_StartFallsBackToCreatedAt_边界 钉住时间回落:图上的 `startedAt` 不可用时,
|
||
// 轮次开始时刻取该行的**落盘时刻**(同一条记录里的另一个真实时间字段),而不是零值。
|
||
func TestTimeline_StartFallsBackToCreatedAt_边界(t *testing.T) {
|
||
t.Parallel()
|
||
st, _ := newTestStore(t, nil)
|
||
ctx := context.Background()
|
||
|
||
recordTimelineRound(t, st, &trace.FlowGraph{
|
||
ID: "web-9",
|
||
SessionID: "web",
|
||
Round: 9,
|
||
Nodes: []trace.FlowNode{{ID: "input", Label: "你好", Kind: "input"}},
|
||
})
|
||
|
||
rounds, _, err := st.Timeline(ctx, "web", 0, time.Time{})
|
||
if err != nil {
|
||
t.Fatalf("Timeline: %v", err)
|
||
}
|
||
if len(rounds) != 1 {
|
||
t.Fatalf("轮数 = %d, want 1", len(rounds))
|
||
}
|
||
if !rounds[0].StartedAt.Equal(testNow()) {
|
||
t.Fatalf("开始时刻 = %v, want %v(图上没有 startedAt ⇒ 回落落盘时刻)", rounds[0].StartedAt, testNow())
|
||
}
|
||
}
|
||
|
||
// TestTimeline_LimitClamped_边界 钉住页大小:缺省 50、超过硬上限 200 即钳住且 `hasMore`
|
||
// 如实为 true(绝不静默返回全表)。
|
||
func TestTimeline_LimitClamped_边界(t *testing.T) {
|
||
t.Parallel()
|
||
st, _ := newTestStore(t, nil)
|
||
ctx := context.Background()
|
||
|
||
base := testNow().Add(-time.Hour)
|
||
total := MaxTurnsLimit + 5
|
||
for round := 1; round <= total; round++ {
|
||
started := base.Add(time.Duration(round) * time.Second)
|
||
recordTimelineRound(t, st, timelineGraph("web", round, round, "", started))
|
||
}
|
||
|
||
rounds, more, err := st.Timeline(ctx, "web", MaxTurnsLimit+999, time.Time{})
|
||
if err != nil {
|
||
t.Fatalf("Timeline(超上限): %v", err)
|
||
}
|
||
if len(rounds) != MaxTurnsLimit || !more {
|
||
t.Fatalf("超上限时 = %d 轮 / hasMore=%v, want %d / true", len(rounds), more, MaxTurnsLimit)
|
||
}
|
||
rounds, more, err = st.Timeline(ctx, "web", 0, time.Time{})
|
||
if err != nil {
|
||
t.Fatalf("Timeline(缺省): %v", err)
|
||
}
|
||
if len(rounds) != DefaultTurnsLimit || !more {
|
||
t.Fatalf("缺省时 = %d 轮 / hasMore=%v, want %d / true", len(rounds), more, DefaultTurnsLimit)
|
||
}
|
||
}
|
||
|
||
// TestTimeline_EmptyAndNilDB_边界 钉住两个空态:没有任何落盘行时返回**空切片**(不是
|
||
// nil、更不是错误),未接数据库时如实报 ErrNilDB。
|
||
func TestTimeline_EmptyAndNilDB_边界(t *testing.T) {
|
||
t.Parallel()
|
||
st, _ := newTestStore(t, nil)
|
||
|
||
rounds, more, err := st.Timeline(context.Background(), "web", 0, time.Time{})
|
||
if err != nil {
|
||
t.Fatalf("Timeline(空会话): %v", err)
|
||
}
|
||
if rounds == nil || len(rounds) != 0 || more {
|
||
t.Fatalf("空会话 = %+v/%v, want 空切片/false", rounds, more)
|
||
}
|
||
|
||
var nilStore *SQLStore
|
||
if _, _, err := nilStore.Timeline(context.Background(), "web", 0, time.Time{}); err == nil {
|
||
t.Fatal("nil store 必须如实报错,不得假装「没有轮次」")
|
||
}
|
||
}
|
||
|
||
// 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))
|
||
for _, r := range rounds {
|
||
out = append(out, r.Key)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// sameKeys 报告两个键序列是否逐字相等。
|
||
func sameKeys(got, want []string) bool {
|
||
if len(got) != len(want) {
|
||
return false
|
||
}
|
||
for i := range got {
|
||
if got[i] != want[i] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|