主人要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」: - 落盘:迁移 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)
145 lines
4.4 KiB
Go
145 lines
4.4 KiB
Go
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)
|
||
}
|
||
}
|