feat(flow): 链路图时间轴出口(W1)—— 所有轮次按时间正序
- 新增 GET /api/v1/admin/flow/timeline?sessionId=&limit=&before=:时间正序多轮,
每轮 {turnId,round,startedAt,endedAt,lanes[]};limit 缺省 50/上限 200 + before 游标
- 数据源=落盘 flow_graphs + 内存最近一轮(同图 id 去重);保留期 168h 外的轮次不返回,
但响应给 retentionHours/retentionEnabled/truncated ⇒ 前端如实标注,不悄悄少一块
- 老出口 GET /admin/flow/graph 零 diff;角色门 writeAuth(owner|admin)
- 4 个变异自检(游标 <= 重复、分组降级、去重缺失、truncated 静默)
This commit is contained in:
1 parent
78e99d9f4c
commit
1efe3a6a79
5 files changed
+1401
No files matched your search
@@ -0,0 +1,297 @@
|
||||
package flowgraph
|
||||
|
||||
// 本文件是**时间轴读侧**(design/19 §7;切片 W1,契约 v0.80 的
|
||||
// `GET /api/v1/admin/flow/timeline`):把该会话**保留期内**的多轮链路图按时间正序
|
||||
// 取回来,供管理面板把「所有消息按时间顺序串起来」。
|
||||
//
|
||||
// 它补的是主人 2026-10-06 那句原话的缺口:「web 后台的全链路流程图我要的是所有消息按
|
||||
// 时间顺序串起来的流程图,而不是只有最新一个来回」。数据早就在库里(迁移 0022,每轮/
|
||||
// 每路一行、保留 168h),缺的只是**多轮聚合读侧** —— 本文件就是它。采集层(`loop` 的
|
||||
// 发布器)与保留期清扫(`gc.go`)一字未改。
|
||||
//
|
||||
// 三条口径(每条都有测试钉住,不靠注释自觉):
|
||||
//
|
||||
// 1. **按轮分组**:一轮 = 一条用户消息引出的全部工作;真并行时一轮有多路
|
||||
// (`graph.Round` 同值、`graph.TurnID` 各异,design/15 §2.5.5)⇒ 分组键是**轮次号**,
|
||||
// 不是图 id。图上没有轮次号的老行(`round` 键缺席)**各自成组**,绝不猜它属于哪一轮。
|
||||
// 2. **游标向更早翻页**:`before` 是**轮次开始时刻**(定宽 UTC 毫秒 RFC3339),只返回
|
||||
// **严格早于**它的轮次;调用方把响应里的 `nextBefore`(= 本页最早一轮的开始时刻)
|
||||
// 原样回带即可连续翻页。
|
||||
// 3. **查不到就是查不到**:已过保留期的行已被 `gc.go` 清扫、从未落盘的行本就不存在 ——
|
||||
// 两者都不出现在返回值里,本包**不猜**、绝不用别的轮次顶替。保留期口径由调用方经
|
||||
// `Retention()` 如实转达给客户端(「更早的轮次已过保留期」这句话不能靠前端编)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// TimelineRound 是时间轴里的**一轮**(design/19 §7.1):该轮的开始时刻 + 该轮每一路的图。
|
||||
//
|
||||
// 「轮」(round)与「路」(leg)的区分见 design/19 §6.0:一轮是时间单元(一条用户消息
|
||||
// 引出的全部工作),一路是同轮里同时推进的独立回合。**泳道**是图内的子代理带,与本类型
|
||||
// 无关。
|
||||
type TimelineRound struct {
|
||||
// Key 是**分组键**(`r:<轮次号>` / `t:<图 id>`),与 RoundKeyOf 逐字同源:键相同的
|
||||
// 两条路属于同一轮。调用方用它把**内存里的在飞路**并进同一轮(而不是另起一轮)。
|
||||
Key string
|
||||
// Round 是该轮的**会话轮次号**;0 = 未知(图上没有该键的旧行)。调用方据此让线上
|
||||
// JSON 出 `null`,**绝不用 0 冒充**一个真实轮次(design/19 §6.1 的缺席语义)。
|
||||
Round int
|
||||
// StartedAt 是该轮**最早一路**的开始时刻。图上的 `startedAt` 不可解析时回落该行的
|
||||
// 落盘时刻(与 SQL 侧的 COALESCE 同口径 —— 换的是同一条真实记录里的另一个时间字段,
|
||||
// 不是编造一个时刻)。
|
||||
StartedAt time.Time
|
||||
// Lanes 是该轮**每一路**的图(真并行时多于一条)。次序**不保证**:调用方按路的开始
|
||||
// 时刻自行排序展示(网关注入内存路后再统一排)。
|
||||
Lanes []*trace.FlowGraph
|
||||
}
|
||||
|
||||
// RoundKeyOf 给出一个图的**轮次分组键**:有轮次号 ⇒ `r:<轮次号>`,否则 ⇒ `t:<图 id>`
|
||||
// (旧图没有轮次号 ⇒ 每张图自成一组,绝不把它们猜成同一轮)。
|
||||
//
|
||||
// 它与 `timelineRoundKeySQL` **逐字同形**:一处是 Go、一处是 SQL,改一个必须改另一个。
|
||||
// 两处的存在是必要的 —— SQL 侧要能在库里分组分页(不把整会话的行都读进内存),Go 侧要
|
||||
// 能把**内存里的在飞路**归到同一个键上。
|
||||
func RoundKeyOf(g *trace.FlowGraph) string {
|
||||
if g == nil {
|
||||
return ""
|
||||
}
|
||||
if g.Round > 0 {
|
||||
return "r:" + strconv.Itoa(g.Round)
|
||||
}
|
||||
return "t:" + g.ID
|
||||
}
|
||||
|
||||
// CursorStamp 把一个时刻格式化成**时间轴游标口径**(定宽 UTC 毫秒 + 字面量 Z)。
|
||||
//
|
||||
// 它与 `timelineRoundStartSQL` 归一化出来的字符串**逐字同形**,因此可以直接当作
|
||||
// `Timeline` 的 `before` 参数回带(游标精度到毫秒:这是 `strftime('%f')` 的精度,
|
||||
// 也是本包全部时间比较的口径)。零值时刻返回空串 = 未知(调用方原样出线上字段,
|
||||
// 不要用一个假时刻顶替)。
|
||||
func CursorStamp(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return sqliteTimeStamp(t)
|
||||
}
|
||||
|
||||
// timelineRoundKeySQL 是**分组键**的 SQL 表达式(与 `RoundKeyOf` 逐字同形):
|
||||
// 有轮次号 ⇒ `r:<轮次号>`,否则 ⇒ `t:<图 id>`。
|
||||
//
|
||||
// 外层的 `json_valid` 是**保护**:JSON 破损的行根本不去解析 —— 否则 `json_extract`
|
||||
// 会让整条查询报错,把「一行坏数据」升级成「时间轴整体读不了」(且坏行若落在本页之外,
|
||||
// 用户根本不该因此受罚)。用**嵌套 CASE**(而不是 `json_valid(...) AND ...`)是因为
|
||||
// SQLite 不保证 AND 的操作数求值次序,而 CASE 只求值命中的那一支。
|
||||
const timelineRoundKeySQL = `CASE WHEN json_valid(graph_json) THEN
|
||||
CASE WHEN CAST(json_extract(graph_json,'$.round') AS INTEGER) > 0
|
||||
THEN 'r:' || CAST(json_extract(graph_json,'$.round') AS INTEGER)
|
||||
ELSE 't:' || turn_id END
|
||||
ELSE 't:' || turn_id END`
|
||||
|
||||
// timelineRoundStartSQL 是**轮次开始时刻**的 SQL 表达式:把图上的 `$.startedAt` 归一成
|
||||
// 定宽 UTC 毫秒字符串(与 `sqliteTimeStamp` 同口径 ⇒ 字典序等价于时间序,可以直接比较);
|
||||
// 取不到或不可解析时回落该行的 `created_at`(同为定宽 UTC 毫秒)。
|
||||
//
|
||||
// 归一化是必须的:图里的 `startedAt` 由 Go 的 `time.Time` 编码,可能是**本地时区偏移**
|
||||
// 且**小数位数不定**,直接按字符串比较会得出错误的时间序。
|
||||
//
|
||||
// `json_type(...) = 'text'` 与迁移 0023 的回填条件同一条纪律:只认**字符串**形态的时间,
|
||||
// 数值形态(畸形外部改动)不交给 `strftime` —— 它会把一个数字当儒略日解析出一个看似
|
||||
// 合理的时刻,那是**编造**。这类行一律回落 `created_at`。两个条件嵌套(而非 AND)同
|
||||
// 上一条的理由:SQLite 不保证操作数求值次序。
|
||||
const timelineRoundStartSQL = `COALESCE(
|
||||
CASE WHEN json_valid(graph_json) THEN
|
||||
CASE WHEN json_type(graph_json,'$.startedAt') = 'text'
|
||||
THEN strftime('%Y-%m-%dT%H:%M:%fZ', json_extract(graph_json,'$.startedAt')) END
|
||||
END,
|
||||
created_at)`
|
||||
|
||||
// timelineRoundNoSQL 是**轮次号**的 SQL 表达式(没有轮次号时为 NULL,SQLite 的 DESC 会把
|
||||
// NULL 排在最后)。它只用于 ORDER BY 的同毫秒**定序兜底**:两个轮次若落在同一毫秒,
|
||||
// 就按轮次号降序(号大的在前 = 更新),绝不把次序交给数据库的自由发挥。
|
||||
//
|
||||
// 为什么不拿 `round_key` 直接当兜底:它是字符串,`'r:9'` 的字典序**大于** `'r:10'`,
|
||||
// 单靠它定序会把第 9 轮排到第 10 轮之前。字符串键仍是最后一道兜底(保证完全确定),
|
||||
// 但轮次号本身必须先比一遍。
|
||||
const timelineRoundNoSQL = `CASE WHEN json_valid(graph_json)
|
||||
THEN CAST(json_extract(graph_json,'$.round') AS INTEGER) END`
|
||||
|
||||
// timelineKeysHeadSQL 取**最新一页**的轮次键(最新在前),多取一条用于判断还有没有更早的轮。
|
||||
const timelineKeysHeadSQL = `SELECT ` + timelineRoundKeySQL + ` AS round_key,
|
||||
MIN(` + timelineRoundStartSQL + `) AS started_at
|
||||
FROM flow_graphs
|
||||
WHERE session_id = ?
|
||||
GROUP BY round_key
|
||||
ORDER BY started_at DESC, MAX(` + timelineRoundNoSQL + `) DESC, round_key DESC
|
||||
LIMIT ?`
|
||||
|
||||
// timelineKeysBeforeSQL 与上一条同形,但只取**开始时刻严格早于游标**的轮次(向更早翻页)。
|
||||
const timelineKeysBeforeSQL = `SELECT ` + timelineRoundKeySQL + ` AS round_key,
|
||||
MIN(` + timelineRoundStartSQL + `) AS started_at
|
||||
FROM flow_graphs
|
||||
WHERE session_id = ?
|
||||
GROUP BY round_key
|
||||
HAVING MIN(` + timelineRoundStartSQL + `) < ?
|
||||
ORDER BY started_at DESC, MAX(` + timelineRoundNoSQL + `) DESC, round_key DESC
|
||||
LIMIT ?`
|
||||
|
||||
// Timeline 取该会话**保留期内**的多轮链路图,每轮带上它**每一路**的图。
|
||||
//
|
||||
// 返回值与语义:
|
||||
// - 轮次按开始时刻**升序**(最早在前)—— 时间轴要从上往下读成时间序;
|
||||
// - `limit` ≤0 取 `DefaultTurnsLimit`、超过 `MaxTurnsLimit` 即钳住(绝不回「全部」);
|
||||
// - `before` 为零值 = 取**最新那一页**;给定时只取开始时刻**严格早于**它的轮次;
|
||||
// - 第二个返回值 `hasMore` 报告库里**还有更早的轮次**没被返回:调用方把本页最早一轮的
|
||||
// 开始时刻回带成本次请求的 `before` 即可继续(`runs` 为空时 `hasMore` 恒为 false);
|
||||
// - 该会话从未落盘(含发布会话范围外、已过保留期)时返回**空切片**与 `(false, nil)`:
|
||||
// 这是一个**诚实的空态**,绝不用别的会话/别的轮次顶替。
|
||||
//
|
||||
// 一页最多跑两条 SQL:先取本页的轮次键(多取一条判 `hasMore`),再按键把这一页的**全部
|
||||
// 路**取回来。**不按路分页** —— 那一轮的图会缺路(「两路只回一路」),正是 FV2 修掉的那类
|
||||
// 静默丢数据的另一种形态。查询全部参数化;本方法只读,不写库、不碰保留期。
|
||||
func (s *SQLStore) Timeline(ctx context.Context, sessionID string, limit int, before time.Time) ([]TimelineRound, bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, false, fmt.Errorf("flowgraph: query timeline: %w", ErrNilDB)
|
||||
}
|
||||
if strings.TrimSpace(sessionID) == "" {
|
||||
return []TimelineRound{}, false, nil
|
||||
}
|
||||
keys, more, err := s.timelineKeys(ctx, sessionID, normalizeLimit(limit), before)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return []TimelineRound{}, more, nil
|
||||
}
|
||||
rounds, err := s.timelineRounds(ctx, sessionID, keys)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return rounds, more, nil
|
||||
}
|
||||
|
||||
// timelineKeys 取本页的轮次键(最新 ⇒ 最旧)。它多取一条:拿到 `limit+1` 条就说明
|
||||
// 库里还有更早的轮次(`hasMore`),多出来的那条**不返回**(它是下一页的第一条)。
|
||||
func (s *SQLStore) timelineKeys(ctx context.Context, sessionID string, limit int, before time.Time) ([]string, bool, error) {
|
||||
query := timelineKeysHeadSQL
|
||||
args := []any{sessionID, limit + 1}
|
||||
if !before.IsZero() {
|
||||
query = timelineKeysBeforeSQL
|
||||
args = []any{sessionID, sqliteTimeStamp(before), limit + 1}
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("flowgraph: 查时间轴轮次(%s): %w", sessionID, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
keys := make([]string, 0, limit)
|
||||
for rows.Next() {
|
||||
var (
|
||||
key string
|
||||
startedAt string
|
||||
)
|
||||
if err := rows.Scan(&key, &startedAt); err != nil {
|
||||
return nil, false, fmt.Errorf("flowgraph: 读时间轴轮次行(%s): %w", sessionID, err)
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, false, fmt.Errorf("flowgraph: 遍历时间轴轮次行(%s): %w", sessionID, err)
|
||||
}
|
||||
if len(keys) > limit {
|
||||
return keys[:limit], true, nil
|
||||
}
|
||||
return keys, false, nil
|
||||
}
|
||||
|
||||
// timelineRounds 按轮次键取回这一页**每一路**的图,并按时间**升序**装配成轮。
|
||||
//
|
||||
// 键的次序由 SQL 给出(最新 ⇒ 最旧,且已按时间定序),本方法取完整路后反转一次即为升序。
|
||||
// 行内的 `graph_json` 解不开时返回错误(与 `Graph` / `Latest` 同一口径:读不懂就如实报错,
|
||||
// 绝不把坏行当成「这一轮没有图」);错误信息只带标识,**绝不打印图内容**。
|
||||
func (s *SQLStore) timelineRounds(ctx context.Context, sessionID string, keys []string) ([]TimelineRound, error) {
|
||||
args := make([]any, 0, len(keys)+1)
|
||||
args = append(args, sessionID)
|
||||
placeholders := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, key)
|
||||
}
|
||||
query := `SELECT turn_id, graph_json, created_at FROM flow_graphs
|
||||
WHERE session_id = ? AND (` + timelineRoundKeySQL + `) IN (` + strings.Join(placeholders, ", ") + `)
|
||||
ORDER BY created_at ASC, id ASC`
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("flowgraph: 查时间轴轮次图(%s, %d 轮): %w", sessionID, len(keys), err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
byKey := make(map[string]*TimelineRound, len(keys))
|
||||
for rows.Next() {
|
||||
var (
|
||||
turnID string
|
||||
payload string
|
||||
createdAt string
|
||||
)
|
||||
if err := rows.Scan(&turnID, &payload, &createdAt); err != nil {
|
||||
return nil, fmt.Errorf("flowgraph: 读时间轴轮次图行(%s): %w", sessionID, err)
|
||||
}
|
||||
g, err := decodeGraph(sessionID, turnID, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mergeLane(byKey, g, createdAt); err != nil {
|
||||
return nil, fmt.Errorf("flowgraph: 归并时间轴轮次路(%s/%s): %w", sessionID, turnID, err)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("flowgraph: 遍历时间轴轮次图行(%s): %w", sessionID, err)
|
||||
}
|
||||
return roundsInKeyOrder(byKey, keys), nil
|
||||
}
|
||||
|
||||
// mergeLane 把一路图并进它所属的那一轮(键相同即同一轮)。该轮尚未建组时新建一个。
|
||||
//
|
||||
// 轮次的开始时刻取**各路最早的那个**:真并行时两路的开始时刻相差毫秒级,轮的起点是
|
||||
// 先起的那一路。
|
||||
func mergeLane(byKey map[string]*TimelineRound, g *trace.FlowGraph, createdAt string) error {
|
||||
key := RoundKeyOf(g)
|
||||
round := byKey[key]
|
||||
if round == nil {
|
||||
round = &TimelineRound{Key: key, Round: g.Round}
|
||||
byKey[key] = round
|
||||
}
|
||||
start := g.StartedAt
|
||||
if start.IsZero() {
|
||||
// 图上的开始时刻不可用 ⇒ 回落该行的落盘时刻(与 SQL 的 COALESCE 同口径)。
|
||||
at, ok := parseSQLiteTime(createdAt)
|
||||
if !ok {
|
||||
return fmt.Errorf("轮次 %s 的 created_at 不可解析(%q): %w", g.ID, createdAt, ErrBadTimestamp)
|
||||
}
|
||||
start = at
|
||||
}
|
||||
if round.StartedAt.IsZero() || start.Before(round.StartedAt) {
|
||||
round.StartedAt = start
|
||||
}
|
||||
round.Lanes = append(round.Lanes, g)
|
||||
return nil
|
||||
}
|
||||
|
||||
// roundsInKeyOrder 按 SQL 给出的键次序(最新 ⇒ 最旧)装配轮次并**反转为升序**
|
||||
// (时间轴从上往下读成时间序)。键没有对应行时跳过 —— 那意味着并发清扫刚删掉了它。
|
||||
func roundsInKeyOrder(byKey map[string]*TimelineRound, keys []string) []TimelineRound {
|
||||
out := make([]TimelineRound, 0, len(keys))
|
||||
for i := len(keys) - 1; i >= 0; i-- {
|
||||
if round := byKey[keys[i]]; round != nil {
|
||||
out = append(out, *round)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
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 必须如实报错,不得假装「没有轮次」")
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user