Files
Cyrene_v2/internal/services/gateway/session_parallel_test.go
T
AskaEth 89e13cf53d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway,loop): 忙时并行回合基础管线(I1′ 有条件放宽)
- turnId 由编排器生成、loop Run 复用(硬前提:否则 turn_begin 与流帧各铸一个,客户端两级分桶立即失配)
- StateSink 增带 turnId 的变体(未接线时退化为现签名,不丢状态帧)
- 上行 chat.parallel(WS chat 帧 + REST 三入口同形;省略 = 逐字不变)
- 单会话额度默认 2 / 硬上限 3 / <2 关闭;额度满或未声明 ⇒ 落回既有排队语义
- I1′:checkpoint 归属、历史配对、忙态计数三处从会话级单例升为路级
- session_idle 收紧为「所有在途路收尾且 pending 空」(仅并行时生效;单路逐字不变)
- 预留接口(形状定死、无实现无接线):tool/legfile.go 的 FileClaimer / ErrFileBusy / WithLegID
- 顺手修一个真 bug:并行路 ctx 派生自提交请求作用域,REST 同步入口一写响应就把它连坐杀死(改 context.WithoutCancel 派生)
- 23 个新用例(含 -race -count=3);变异 6 条(turnId 复用 / idle 时点 / 并行升级 / checkpoint 键 / 编排器交 id / pending_ack reason)
- 契约变更点 10 条待监工升 v0.65(本人未改 Docs/**)
2026-10-05 09:18:50 +08:00

532 lines
19 KiB
Go
Raw 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 gateway
import (
"context"
"fmt"
"log/slog"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件是 design/15 §2.5「忙时并行回合(多路)」**基础管线**的回归套件
//(主人 2026-10-05 裁决 B)。它钉住六件事:
//
// ① 省略 `parallel` ⇒ 行为逐字不变(不并行、不新增帧键、报空闲时点不变);
// ② 显式 `parallel` 且额度未满 ⇒ 真的起第二路(两条不同的 turnId、各自的路标识);
// ③ 额度满 ⇒ 落回排队(不报错、不丢消息),并如实给出 reason;
// ④ 单路时 `session_idle` 的时点不变;
// ⑤ 并行时 `session_idle` 等到**所有路**收尾;
// ⑥ turnId 一致性(编排器生成的那个 = 交给 Run、并由流帧盖上壳的那个)。
//
// 夹具刻意用**可逐路放行**的 runner(放行顺序即收尾顺序),因此「谁先收尾」是
// 确定性的,断言的是时序而不是睡眠。
// gatedLegRunner 是可控的 TurnRunner:每条路的 Run 都阻塞在自己的放行闸上,
// 测试按启动顺序逐路放行,因此能精确断言「一路收尾而另一路仍在飞」的那一刻。
type gatedLegRunner struct {
mu sync.Mutex
calls []callRecord
gates []chan struct{}
started chan struct{}
}
func newGatedLegRunner() *gatedLegRunner {
return &gatedLegRunner{started: make(chan struct{}, 8)}
}
func (g *gatedLegRunner) Run(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error) {
gate := make(chan struct{})
g.mu.Lock()
idx := len(g.calls)
g.calls = append(g.calls, callRecord{sessionID: sessionID, messages: append([]model.Message(nil), messages...), env: env, ctx: ctx})
g.gates = append(g.gates, gate)
g.mu.Unlock()
g.started <- struct{}{}
select {
case <-gate:
case <-ctx.Done():
return nil, ctx.Err()
}
return &loop.Result{Text: fmt.Sprintf("reply-%d", idx+1), Steps: 1, StopReason: "stop"}, nil
}
// waitStarted 等到至少 n 条路真的进了 runner(而不是只被登记)。
func (g *gatedLegRunner) waitStarted(n int) []callRecord {
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
g.mu.Lock()
got := len(g.calls)
out := append([]callRecord(nil), g.calls...)
g.mu.Unlock()
if got >= n {
return out
}
time.Sleep(time.Millisecond)
}
panic("timed out waiting for leg starts")
}
// release 放行第 i 条启动的路(i 从 0 起)。它幂等(重复关闭会 panic,
// 因此用 select 保护),使测试的放行顺序就是收尾顺序。
func (g *gatedLegRunner) release(i int) {
g.mu.Lock()
gate := g.gates[i]
g.mu.Unlock()
select {
case <-gate:
default:
close(gate)
}
}
// turnStateRec 是一次状态帧(含帧壳上的 turnId,可能为空)。
type turnStateRec struct {
sessionID string
turnID string
typ string
data map[string]any
}
// turnSink 同时实现 StateSink 与 TurnStateSink,把两类帧记在同一个序列里,
// 使「带 turnId 的变体真的把 turnId 盖到帧壳上」可被直接断言。
type turnSink struct {
mu sync.Mutex
events []turnStateRec
}
func (s *turnSink) Emit(sessionID, typ string, data map[string]any) {
s.record(turnStateRec{sessionID: sessionID, typ: typ, data: data})
}
func (s *turnSink) EmitTurn(sessionID, turnID, typ string, data map[string]any) {
s.record(turnStateRec{sessionID: sessionID, turnID: turnID, typ: typ, data: data})
}
func (s *turnSink) record(rec turnStateRec) {
s.mu.Lock()
s.events = append(s.events, rec)
s.mu.Unlock()
}
func (s *turnSink) snapshot() []turnStateRec {
s.mu.Lock()
defer s.mu.Unlock()
return append([]turnStateRec(nil), s.events...)
}
// ofType 返回某类状态帧(按发生顺序)。
func (s *turnSink) ofType(typ string) []turnStateRec {
var out []turnStateRec
for _, e := range s.snapshot() {
if e.typ == typ {
out = append(out, e)
}
}
return out
}
// parallelFixture 是并行管线用例的公共夹具。
type parallelFixture struct {
o *Orchestrator
run *gatedLegRunner
sink *turnSink
history *fakeHistory
}
// newParallelFixture 按给定的队列容量与并行上限构建编排器(Log 走丢弃型,
// 避免并行用例互相污染日志断言)。
func newParallelFixture(maxPending, maxParallel int) *parallelFixture {
run := newGatedLegRunner()
sink := &turnSink{}
hist := &fakeHistory{}
o := NewOrchestrator(OrchestratorOptions{
Run: run.Run,
Emit: sink.Emit,
EmitTurn: sink.EmitTurn,
Log: slog.New(slog.NewTextHandler(discardWriter{}, nil)),
MaxPending: maxPending,
MaxTail: 20,
History: hist,
MaxParallelTurns: maxParallel,
})
return &parallelFixture{o: o, run: run, sink: sink, history: hist}
}
// discardWriter 接收被丢弃的日志字节(用例只关心状态帧与历史行)。
type discardWriter struct{}
func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }
// waitActiveTurns 等到某会话的在途路数等于 want(超时即失败),使断言不靠睡眠。
func waitActiveTurns(t *testing.T, o *Orchestrator, sessionID string, want int) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if o.ActiveTurns(sessionID) == want {
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("在途路数 = %d, want %d", o.ActiveTurns(sessionID), want)
}
// stateInt 从状态帧的 data 里取一个整数键(缺失或类型不符时返回 -1)。
func stateInt(rec turnStateRec, key string) int {
v, ok := rec.data[key].(int)
if !ok {
return -1
}
return v
}
// stateKeyPresent 报告状态帧的 data 里某个键是否**在场**(用于「该键缺席」的断言:
// 缺席与空串对客户端含义不同,绝不伪造)。
func stateKeyPresent(rec turnStateRec, key string) bool {
_, ok := rec.data[key]
return ok
}
// assistantContents 返回已落库记录里的助手回复正文(按顺序)。
func assistantContents(msgs []history.Message) []string {
out := make([]string, 0, len(msgs))
for _, m := range msgs {
if m.Role == history.RoleAssistant {
out = append(out, m.Content)
}
}
return out
}
// windowText 返回一路播种到的模型消息正文(按顺序),供断言 I4「每路一份窗口」。
func windowText(msgs []model.Message) []string {
out := make([]string, 0, len(msgs))
for _, m := range msgs {
out = append(out, messageText(m))
}
return out
}
// parallelCtx 返回一条**显式声明并行**的提交上下文(与 WS/REST 边界同源的 ctx 通道)。
func parallelCtx() context.Context { return withParallelTurn(context.Background(), true) }
// ---------------------------------------------------------------------------
// ① 省略 parallel ⇒ 逐字不变
// ---------------------------------------------------------------------------
// TestParallelOmittedKeepsQueueingBehaviour 钉住「老客户端零影响」:不声明 parallel
// 的忙时消息仍然入队,帧形状不变(**没有** reason 键),且始终只有一条在途路。
func TestParallelOmittedKeepsQueueingBehaviour(t *testing.T) {
t.Parallel()
f := newParallelFixture(5, 2)
if sr := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv()); !sr.Immediate || sr.Parallel {
t.Fatalf("空闲提交 = %+v, want Immediate 且非 Parallel", sr)
}
f.run.waitStarted(1)
if got := f.o.ActiveTurns("web"); got != 1 {
t.Fatalf("在途路数 = %d, want 1", got)
}
// 忙时第二条(未声明并行)⇒ 排队,且帧上没有任何新键。
sr := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv())
if !sr.Pending || sr.Parallel || sr.QueueDepth != 1 {
t.Fatalf("忙时未声明并行的提交 = %+v, want Pending(1) 且非 Parallel", sr)
}
if got := f.o.ActiveTurns("web"); got != 1 {
t.Fatalf("排队时在途路数 = %d, want 1(绝不新开路)", got)
}
acks := f.sink.ofType(EventPendingAck)
if len(acks) != 1 {
t.Fatalf("pending_ack 帧数 = %d, want 1", len(acks))
}
if stateKeyPresent(acks[0], "reason") {
t.Fatalf("未声明并行的排队不该带 reason,得到 %v", acks[0].data)
}
if stateInt(acks[0], "queueDepth") != 1 {
t.Fatalf("pending_ack queueDepth = %v, want 1", acks[0].data["queueDepth"])
}
if stateInt(acks[0], "activeTurns") != -1 {
t.Fatalf("pending_ack 不该带 activeTurns(契约里只有 session_busy/idle/drain/interrupted 带它):%v", acks[0].data)
}
// 放行第一路 ⇒ 弹队首续跑(仍是串行,一条在途路)。
f.run.release(0)
calls := f.run.waitStarted(2)
if got := f.o.ActiveTurns("web"); got != 1 {
t.Fatalf("续跑时在途路数 = %d, want 1", got)
}
if got := loop.ParallelLegFrom(calls[1].ctx); got != "" {
t.Fatalf("续跑路的路标识 = %q, want 空串(串行路不挂并发路标识)", got)
}
if loop.TurnIDFrom(calls[1].ctx) == loop.TurnIDFrom(calls[0].ctx) {
t.Fatal("续跑路必须是一条**新路**,turnId 不能与上一路相同")
}
drains := f.sink.ofType(EventPendingDrain)
if len(drains) != 1 || stateInt(drains[0], "activeTurns") != 1 {
t.Fatalf("pending_drain = %v, want 一帧且 activeTurns=1", drains)
}
f.run.release(1)
if !waitIdle(f.o, "web") {
t.Fatal("会话没有回到空闲")
}
idle := f.sink.ofType(EventSessionIdle)
if len(idle) != 1 || stateInt(idle[0], "activeTurns") != 0 {
t.Fatalf("session_idle = %v, want 一帧且 activeTurns=0", idle)
}
busy := f.sink.ofType(EventSessionBusy)
if len(busy) != 1 || stateInt(busy[0], "activeTurns") != 1 {
t.Fatalf("session_busy = %v, want 一帧且 activeTurns=1", busy)
}
if got := len(assistantContents(f.history.records())); got != 2 {
t.Fatalf("助手回复行数 = %d, want 2(每条消息各答一次)", got)
}
}
// TestSessionIdleTimingSingleLegUnchanged 钉住④:单路(含排队续跑)的
// `session_idle` 时点与现状一致 —— 队列空且本路收尾就报,绝不多等、绝不少报。
func TestSessionIdleTimingSingleLegUnchanged(t *testing.T) {
t.Parallel()
f := newParallelFixture(5, 2)
if sr := f.o.Submit(context.Background(), "web", []model.Message{userMsg("甲")}, loop.OwnerEnv()); !sr.Immediate {
t.Fatalf("提交 = %+v, want Immediate", sr)
}
f.run.waitStarted(1)
if got := f.sink.ofType(EventSessionIdle); len(got) != 0 {
t.Fatalf("回合在飞时不该报空闲,得到 %v", got)
}
if got := f.o.ActiveTurns("web"); got != 1 {
t.Fatalf("在途路数 = %d, want 1", got)
}
f.run.release(0)
if !waitIdle(f.o, "web") {
t.Fatal("会话没有回到空闲")
}
if got := f.sink.ofType(EventSessionIdle); len(got) != 1 {
t.Fatalf("session_idle 帧数 = %d, want 1", len(got))
}
if got := f.o.ActiveTurns("web"); got != 0 {
t.Fatalf("收尾后在途路数 = %d, want 0", got)
}
}
// ---------------------------------------------------------------------------
// ② 显式 parallel 且额度未满 ⇒ 真的起第二路
// ---------------------------------------------------------------------------
// TestParallelDeclaredStartsSecondLeg 钉住②与⑥:第二条显式声明并行的消息**立即**
// 起一条独立的路(两路 turnId 不同、并行路带自己的路标识),两路同时生长,各自
// 落一行助手回复,且 `session_idle` 只在两路都收尾后出现一次。
func TestParallelDeclaredStartsSecondLeg(t *testing.T) {
t.Parallel()
f := newParallelFixture(5, 2)
first := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv())
if !first.Immediate || first.TurnID == "" {
t.Fatalf("第一路 = %+v, want Immediate 且带 turnId", first)
}
f.run.waitStarted(1)
second := f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv())
if !second.Immediate || !second.Parallel {
t.Fatalf("显式并行提交 = %+v, want Immediate 且 Parallel", second)
}
if second.TurnID == "" || second.TurnID == first.TurnID {
t.Fatalf("第二路 turnId = %q,第一路 = %q:必须各自不同", second.TurnID, first.TurnID)
}
if f.o.QueueDepth("web") != 0 {
t.Fatalf("并行路不该入队,队列深度 = %d", f.o.QueueDepth("web"))
}
calls := f.run.waitStarted(2)
waitActiveTurns(t, f.o, "web", 2)
// I4:每一路都有**自己的一份窗口** —— 各自带来自己那条用户消息,且看不到
// 对方在跑的东西(对方的答复尚未落库,因此不在任何一路的转录里)。
if got := windowText(calls[0].messages); len(got) != 1 || got[0] != "第一问" {
t.Fatalf("第一路的窗口 = %v, want [第一问]", got)
}
if got := windowText(calls[1].messages); len(got) != 1 || got[0] != "第二问" {
t.Fatalf("第二路的窗口 = %v, want [第二问]", got)
}
// ⑥ 两路各自的 turnId:第一路不带并发路标识(checkpoint 归属键仍是 sessionID),
// 第二路带自己的(归属键升为 {sessionID}#{turnID}),且都与提交时交回的 turnId 同值。
if got := loop.TurnIDFrom(calls[0].ctx); got != first.TurnID {
t.Fatalf("第一路 ctx turnId = %q,提交回报 %q:两处必须同值", got, first.TurnID)
}
if got := loop.ParallelLegFrom(calls[0].ctx); got != "" {
t.Fatalf("第一路不该挂并发路标识,得到 %q", got)
}
if got := loop.TurnIDFrom(calls[1].ctx); got != second.TurnID {
t.Fatalf("第二路 ctx turnId = %q,提交回报 %q:两处必须同值", got, second.TurnID)
}
if got := loop.ParallelLegFrom(calls[1].ctx); got != second.TurnID {
t.Fatalf("第二路路标识 = %q, want %q(checkpoint 归属键按路隔离)", got, second.TurnID)
}
// 忙态帧如实报告两路在途。
busy := f.sink.ofType(EventSessionBusy)
if len(busy) != 2 || stateInt(busy[1], "activeTurns") != 2 {
t.Fatalf("session_busy 帧 = %v, want 两帧且第二帧 activeTurns=2", busy)
}
// ⑤ 先收尾第二路:会话仍不空闲(第一路还在飞)。
f.run.release(1)
waitActiveTurns(t, f.o, "web", 1)
if got := f.sink.ofType(EventSessionIdle); len(got) != 0 {
t.Fatalf("还有一路在飞时不该报空闲,得到 %v", got)
}
f.run.release(0)
if !waitIdle(f.o, "web") {
t.Fatal("所有路收尾后会话没有回到空闲")
}
idle := f.sink.ofType(EventSessionIdle)
if len(idle) != 1 || stateInt(idle[0], "activeTurns") != 0 {
t.Fatalf("session_idle = %v, want 恰好一帧且 activeTurns=0", idle)
}
// 两路各落一行助手回复(I5:绝不合并成一行、不覆盖对方),且每条用户消息只被
// 作答一次(两条 user 行、两条 assistant 行)。
rows := f.history.records()
if got := len(assistantContents(rows)); got != 2 {
t.Fatalf("助手回复行数 = %d, want 2", got)
}
users := 0
for _, m := range rows {
if m.Role == history.RoleUser {
users++
}
}
if users != 2 {
t.Fatalf("用户消息行数 = %d, want 2(恰好一次)", users)
}
}
// TestSessionIdleWaitsForAllLegs 是⑤的**最小时序**回归:并行两路,任意顺序收尾,
// 只有第二路也收尾时才出现唯一的 session_idle。
func TestSessionIdleWaitsForAllLegs(t *testing.T) {
t.Parallel()
f := newParallelFixture(5, 2)
f.o.Submit(context.Background(), "web", []model.Message{userMsg("A")}, loop.OwnerEnv())
f.run.waitStarted(1)
f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("B")}, loop.OwnerEnv())
f.run.waitStarted(2)
waitActiveTurns(t, f.o, "web", 2)
// 先收尾**第一**路(与上一个用例相反的收尾顺序,证明判据不是「最后启动的那一路」)。
f.run.release(0)
waitActiveTurns(t, f.o, "web", 1)
if got := f.sink.ofType(EventSessionIdle); len(got) != 0 {
t.Fatalf("一路收尾、另一路仍在飞 ⇒ 不该报空闲,得到 %v", got)
}
f.run.release(1)
if !waitIdle(f.o, "web") {
t.Fatal("所有路收尾后会话没有回到空闲")
}
if got := f.sink.ofType(EventSessionIdle); len(got) != 1 {
t.Fatalf("session_idle 帧数 = %d, want 1", len(got))
}
}
// ---------------------------------------------------------------------------
// ③ 额度满 ⇒ 落回排队
// ---------------------------------------------------------------------------
// TestParallelQuotaFullFallsBackToQueue 钉住③:额度(默认 2)用满后,再来的显式
// 并行消息**落回既有排队语义** —— 不报错、不打断、不丢,并如实给出
// `pending_ack{reason:"parallel_limit"}`;被排队的消息最终仍被作答一次。
func TestParallelQuotaFullFallsBackToQueue(t *testing.T) {
t.Parallel()
f := newParallelFixture(5, 2)
f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv())
f.run.waitStarted(1)
if sr := f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv()); !sr.Parallel {
t.Fatalf("额度未满时应当照准并行,得到 %+v", sr)
}
f.run.waitStarted(2)
waitActiveTurns(t, f.o, "web", 2)
// 第三条声明并行的消息:额度已满 ⇒ 排队。
third := f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("第三问")}, loop.OwnerEnv())
if !third.Accepted || !third.Pending || third.Parallel {
t.Fatalf("额度满的并行提交 = %+v, want Accepted+Pending 且非 Parallel", third)
}
if third.QueueDepth != 1 {
t.Fatalf("队列深度 = %d, want 1", third.QueueDepth)
}
acks := f.sink.ofType(EventPendingAck)
if len(acks) != 1 || acks[0].data["reason"] != pendingAckReasonParallelLimit {
t.Fatalf("pending_ack = %v, want reason=%q", acks, pendingAckReasonParallelLimit)
}
if got := f.o.ActiveTurns("web"); got != 2 {
t.Fatalf("落回排队不该改变在途路数,得到 %d", got)
}
// 逐路收尾:排队的那条由结算中的一路续跑(起一条**串行**新路),最终被作答。
f.run.release(0)
f.run.waitStarted(3)
f.run.release(1)
f.run.release(2)
if !waitIdle(f.o, "web") {
t.Fatal("会话没有回到空闲")
}
if got := len(assistantContents(f.history.records())); got != 3 {
t.Fatalf("助手回复行数 = %d, want 3(排队的第三条也必须被作答一次)", got)
}
}
// TestParallelDisabledByLimitBelowTwo 钉住「<2 = 关闭真并行 ⇒ 完全回落到现状」:
// 上限为 1 时显式并行也一律排队,在途路数恒不越过 1。
func TestParallelDisabledByLimitBelowTwo(t *testing.T) {
t.Parallel()
f := newParallelFixture(5, 1)
f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv())
f.run.waitStarted(1)
sr := f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv())
if !sr.Pending || sr.Parallel {
t.Fatalf("真并行被关闭时 = %+v, want Pending 且非 Parallel", sr)
}
if got := f.o.ActiveTurns("web"); got != 1 {
t.Fatalf("在途路数 = %d, want 1(关闭真并行)", got)
}
acks := f.sink.ofType(EventPendingAck)
if len(acks) != 1 || acks[0].data["reason"] != pendingAckReasonParallelLimit {
t.Fatalf("pending_ack = %v, want 仍如实给出 reason", acks)
}
f.run.release(0)
f.run.waitStarted(2)
f.run.release(1)
if !waitIdle(f.o, "web") {
t.Fatal("会话没有回到空闲")
}
}
// TestResolveMaxParallelTurns 钉住额度解析的三档边界(默认 / 关闭 / 硬上限)。
func TestResolveMaxParallelTurns(t *testing.T) {
t.Parallel()
cases := []struct {
in int
want int
}{
{-5, defaultMaxParallelTurns},
{0, defaultMaxParallelTurns},
{1, 1},
{2, 2},
{3, 3},
{99, maxParallelTurnsHardCap},
}
for _, c := range cases {
if got := resolveMaxParallelTurns(c.in); got != c.want {
t.Fatalf("resolveMaxParallelTurns(%d) = %d, want %d", c.in, got, c.want)
}
}
}