Files
AskaEth a6cb44b8a8
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(flow): 链路图本地落盘 + 多轮历史回放(design/19 §P3,契约 v0.73)
主人要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」:
- 落盘:迁移 0022_flow_graphs(session_id/turn_id/graph_json/时间戳 + 唯一键 upsert)+ internal/runtime/flowgraph(RecordRound/Graph/Latest/Turns/Turn + 保留期 GC)
- 写入:loop.EndRound **每轮一条**(锁外深拷贝后写;失败 WARN 且不打印图内容);只落发布会话白名单(复用 flowSessionAllowed)
- 读回:端点加 turnId + source(live/persisted/none) + turns[](最新在前,空为 []);**内存优先、miss 才落盘**(最新一轮永远最新)
- 前端:flowGraphs 纳入 persistOptions.partialize(**刷新即刻有图**,version 3→4 + migrate)+ 历史轮选择器 + 诚实文案;顺带收 L1 转交的 flow/NodeDetail.tsx 节点日志倒序
- 新 env 键:FLOW_GRAPH_RETENTION=168h / FLOW_GRAPH_GC_INTERVAL=1h
- 变异实测:每 span 一行 / 保留期守卫失效 / partialize 去掉 flowGraphs / 去掉内存优先 / 白名单失效 ⇒ 全部打红
- 3 个禁动面文件的纯追加改动(Flow.tsx 挂选择器、FlowSnapshotHint 纠已成假话的文案、其渲染断言随之更新)由主会话裁定认可
- 登记存疑:图桶持久化无体积上限;useFlowTurns 的 effect 无自动化证据(项目无 jsdom)
2026-10-05 16:19:32 +08:00

145 lines
4.4 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
// 本文件是**保留期清扫**的验收(切片 F2)。断言的是库里的事实(哪些行还在),
// 与 `internal/runtime/events/gc_test.go` 同一口径。
import (
"context"
"testing"
"time"
)
// TestSweep_DisabledIsNoOp_边界 钉住「配了 ≤0 = 关闭 GC」:`Sweep` 是**空操作**,
// 一行都不删。0 刻意不解释成「立即清空」—— 那会让一次笔误抹掉刚落盘的历史轮。
func TestSweep_DisabledIsNoOp_边界(t *testing.T) {
t.Parallel()
ctx := context.Background()
for _, tc := range []struct {
name string
gcInterval, retain time.Duration
}{
{"interval=0", 0, time.Hour},
{"interval<0", -time.Hour, time.Hour},
{"retention=0", time.Hour, 0},
{"retention<0", time.Hour, -time.Hour},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
st, _ := newTestStore(t, func(c *Config) {
c.GCInterval = tc.gcInterval
c.Retention = tc.retain
})
if st.Enabled() {
t.Fatalf("Enabled() = true, want false (%s)", tc.name)
}
if err := st.RecordRound(sampleGraph("web", 1)); err != nil {
t.Fatalf("RecordRound: %v", err)
}
stats, err := st.Sweep(ctx)
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if stats.Deleted != 0 || stats.Batches != 0 {
t.Fatalf("disabled Sweep = %+v, want zero", stats)
}
turns, err := st.Turns(ctx, "web", 0)
if err != nil {
t.Fatalf("Turns: %v", err)
}
if len(turns) != 1 {
t.Fatalf("行被删了(turns=%d),但 GC 关闭时必须是空操作", len(turns))
}
})
}
}
// TestSweep_DeletesOnlyExpired_正常 钉住「正数只删超期」:保留期内的行**必须**留下,
// 只有 `created_at < now − 保留期` 的行被回收。时钟注入让这条判定可复现。
func TestSweep_DeletesOnlyExpired_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
base := testNow()
clock := base
st, _ := newTestStore(t, func(c *Config) {
c.GCInterval = time.Hour
c.Retention = 24 * time.Hour
c.Now = func() time.Time { return clock }
})
if !st.Enabled() {
t.Fatal("Enabled() = false, want true(间隔与保留期都 > 0)")
}
// 旧轮:T0 落盘。
if err := st.RecordRound(sampleGraph("web", 1)); err != nil {
t.Fatalf("record old round: %v", err)
}
// 时钟拨到 T0+48h,落一个新轮。
clock = base.Add(48 * time.Hour)
if err := st.RecordRound(sampleGraph("web", 2)); err != nil {
t.Fatalf("record new round: %v", err)
}
// cutoff = (T0+48h) − 24h = T0+24h ⇒ web-1(T0)超期、web-2(T0+48h)留下。
stats, err := st.Sweep(ctx)
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if stats.Deleted != 1 {
t.Fatalf("Deleted = %d, want 1(只删超期的那一轮)", stats.Deleted)
}
turns, err := st.Turns(ctx, "web", 0)
if err != nil {
t.Fatalf("Turns: %v", err)
}
if len(turns) != 1 || turns[0].TurnID != "web-2" {
t.Fatalf("turns after sweep = %+v, want [web-2](保留期内的那轮必须留下)", turns)
}
if _, err := st.Graph(ctx, "web", "web-1"); err != nil {
t.Fatalf("Graph(被删轮) err = %v, want nil(缺席不是错误)", err)
}
old, err := st.Graph(ctx, "web", "web-1")
if err != nil {
t.Fatalf("Graph(web-1): %v", err)
}
if old != nil {
t.Fatalf("web-1 应已被回收,却仍读得到: %+v", old)
}
}
// TestSweep_ScopedPerSession_正常 钉住清扫是**全表按时间**的(不是按会话):过期的
// 会话行照样回收,不会因为另一个会话还有新轮就整表留着。
func TestSweep_ScopedPerSession_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
base := testNow()
clock := base
st, _ := newTestStore(t, func(c *Config) {
c.GCInterval = time.Hour
c.Retention = time.Hour
c.Now = func() time.Time { return clock }
})
if err := st.RecordRound(sampleGraph("web", 1)); err != nil {
t.Fatalf("record web: %v", err)
}
clock = base.Add(3 * time.Hour)
if err := st.RecordRound(sampleGraph("sess-backthink", 1)); err != nil {
t.Fatalf("record backthink: %v", err)
}
if _, err := st.Sweep(ctx); err != nil {
t.Fatalf("Sweep: %v", err)
}
webTurns, err := st.Turns(ctx, "web", 0)
if err != nil {
t.Fatalf("Turns(web): %v", err)
}
if len(webTurns) != 0 {
t.Fatalf("web turns = %+v, want empty(3h 前的轮已超 1h 保留期)", webTurns)
}
btTurns, err := st.Turns(ctx, "sess-backthink", 0)
if err != nil {
t.Fatalf("Turns(backthink): %v", err)
}
if len(btTurns) != 1 {
t.Fatalf("backthink turns = %+v, want [sess-backthink-1]", btTurns)
}
}