fix(gateway,history): REST 流式不再静默失败(G2)+ clientMsgId 幂等(G4)(协同 §G,均标「中高/中」)
- G2:`stream:true` 原经**有损总线**且**先回 200** ⇒ 总线饱和时客户端拿到 200 却什么都没发生。
改为**直达编排器**(与 ws chat 帧既有先例同一原则):新文件 chat_stream_dispatch.go;200 变成有意义的
承诺(同步穿过「编排器接收 + 用户消息落库」才回);顺带对齐 `202 {queued,queueDepth}` / `409 chat_busy`;
兜底路径(未接编排器才走总线)现在**返回错误**、ctx 与请求脱钩(WithoutCancel + 30min 上界)→ `503 chat_dispatch_failed`;
未接 loop 由 200 改为 `500 chat_unavailable`(与同步路径同口径,已同步改既有用例并新增钉住用例)
- G4:`chat`(WS 帧 + REST 体)新增**可选** `clientMsgId` ⇒ 幂等。
原子性两层,均非「先查再插」:① 同进程在 `o.mu` 同一临界区内完成「查认领表→查存储→预分配 id→认领」;
② 跨重启靠 `(session_id, client_msg_id)` **部分**唯一索引 + `INSERT … ON CONFLICT DO NOTHING`(命中则读回既有行)。
重复语义:REST `200 {duplicate:true, messageId}`;WS 新帧 `chat_duplicate`(owner/admin 档);重复**不落库、不起回合**。
`clientMsgId` 回填进 DTO ⇒ `message_persisted` 与读接口都可见(客户端可据此对齐本地气泡,不再靠文本相等)
- 迁移 `0013_chat_client_msg_id`(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:先删索引再删列),
已在 `data/cyrene.db` **副本**上验 up → 判重 → down → 再 up
- 拆分:store.go 647→498 行(`log_reads.go` 抽走四个读路径);七个 SELECT 清单抽成 `messageColumns` 一处
- 复核:**全树 `go test ./... -count=1` 0 FAIL** + build/vet 绿 + linux/darwin 交叉编译绿;
四条真断言(总线饱和仍落库出帧且 `ws.chat` 零发布 / 同键两次仅一回合+并发两条也仅一回合 / 省略键逐字不变 /
迁移副本 up-down-up);**3 条变异反证**均已还原
- 越界说明(均已报备):`history.go` DTO 字段(否则「读接口可见」不可能)、`policy_store_test.go`
(0013 使「回滚最后一步 == 0012」前提失效,改为按 schema_migrations 现算)、`extra_test.go`(补 runner)
- 顺带补交 P0-A 遗漏的新测试文件 `loop/memory_writeback_test.go`
This commit is contained in:
1 parent
da3a386daf
commit
7512d593c2
18 files changed
+1573
-216
No files matched your search
@@ -52,6 +52,13 @@ const (
|
||||
EventSessionIdle = "session_idle"
|
||||
EventSessionInterrupt = "session_interrupted"
|
||||
EventPendingFull = "pending_full"
|
||||
// EventChatDuplicate 是一次重复到达的明确应答(G4 幂等):本条 clientMsgId
|
||||
// 已有落库的消息,本次没有新回合、也不会有新帧。它的 data 携带
|
||||
// {sessionId, clientMsgId, messageId},让客户端把本地乐观气泡对齐到既有
|
||||
// 消息;没有发过这个 clientMsgId 的其他客户端自行忽略即可。它不是状态机
|
||||
// 迁移,因此不在 design/11 §7.1 的「状态信号」白名单里 —— 帧门默认只把它
|
||||
// 投给 owner/admin(fail-closed)。
|
||||
EventChatDuplicate = "chat_duplicate"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -82,6 +89,15 @@ type SubmitResult struct {
|
||||
PendingFull bool
|
||||
// Empty 报告回合因既无文本也无图片而被拒绝。
|
||||
Empty bool
|
||||
// Duplicate 报告该回合被判为**重复到达**(G4 幂等):同一个 clientMsgId
|
||||
// 此前已经在本会话落过库,因此本次既不落库、也不启动回合。它是「接住了、
|
||||
// 但什么都没做」的诚实答案,绝不是拒绝 —— 客户端据此把本地乐观气泡对齐到
|
||||
// MessageID,而不必重试、也不会看到第二个回合。
|
||||
Duplicate bool
|
||||
// MessageID 是本次提交对应的那条用户消息 id:正常路径是刚落库的消息
|
||||
// (带幂等键时是编排器预分配的那一个),重复路径是既有那条。省略
|
||||
// clientMsgId 的老客户端为 ""。
|
||||
MessageID string
|
||||
// Err 在同步路径上于运行出错时设置;REST 调用方
|
||||
// 用它返回 5xx,而不是编造回复。
|
||||
Err error
|
||||
@@ -129,6 +145,14 @@ type sessionState struct {
|
||||
// 回合以它为先驱,等本回合真正返回后才开始 —— 因此中断后立即到达的
|
||||
// 消息绝不会在被取消回合落定其 checkpoint 之前读到那份旧快照。
|
||||
fence turnFence
|
||||
// clientMsgs 记录本会话已认领的 clientMsgId → 该消息的 id(G4 幂等)。
|
||||
// 它只在状态锁下读写,且只是**快路径 + 同进程并发仲裁**:跨重启的判重由
|
||||
// chat_messages 上 (session_id, client_msg_id) 的部分唯一索引裁决
|
||||
// (单二进制单进程,不存在跨进程并发)。见 session_persist.go。
|
||||
clientMsgs map[string]string
|
||||
// clientMsgOrder 是 clientMsgs 的插入顺序,用于有界淘汰
|
||||
// (maxClientMsgsPerSession):淘汰只丢快路径,存储仍是权威。
|
||||
clientMsgOrder []string
|
||||
}
|
||||
|
||||
// pendingMsg 是一条已排队用户回合。它携带自己的 env,使续跑
|
||||
@@ -228,20 +252,30 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
|
||||
}
|
||||
deviceID := deviceIDFrom(ctx)
|
||||
msgMode := modeFrom(ctx)
|
||||
// 两道门(队列满 / clientMsgId 重复)由 admitLocked 在锁内判定;它不广播,
|
||||
// 状态事件在这里解锁之后再发。
|
||||
a := o.admitLocked(ctx, st, sessionID)
|
||||
if a.full {
|
||||
o.mu.Unlock()
|
||||
o.emitState(sessionID, EventPendingFull, map[string]any{"sessionId": sessionID, "mode": a.mode, "max": o.maxPending})
|
||||
return SubmitResult{PendingFull: true}, nil
|
||||
}
|
||||
if a.dup {
|
||||
o.mu.Unlock()
|
||||
o.emitState(sessionID, EventChatDuplicate, map[string]any{"sessionId": sessionID, "clientMsgId": a.hint.ClientMsgID, "messageId": a.hint.MessageID})
|
||||
return SubmitResult{Accepted: true, Duplicate: true, MessageID: a.hint.MessageID}, nil
|
||||
}
|
||||
// 认领成功:把预分配的消息 id 盖进 ctx。空 clientMsgId 时它是零值,ctx 与
|
||||
// 加入该字段之前逐字相同。
|
||||
ctx = withClientMsgHint(ctx, a.hint)
|
||||
if st.mode != ModeIdle {
|
||||
if len(st.pending) >= o.maxPending {
|
||||
mode := st.mode
|
||||
o.mu.Unlock()
|
||||
o.emitState(sessionID, EventPendingFull, map[string]any{"sessionId": sessionID, "mode": mode, "max": o.maxPending})
|
||||
return SubmitResult{PendingFull: true}, nil
|
||||
}
|
||||
st.pending = append(st.pending, pendingMsg{messages: messages, env: env, deviceID: deviceID, mode: msgMode, at: time.Now()})
|
||||
depth := len(st.pending)
|
||||
mode := st.mode
|
||||
o.mu.Unlock()
|
||||
o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID)
|
||||
o.emitState(sessionID, EventPendingAck, map[string]any{"sessionId": sessionID, "mode": mode, "queueDepth": depth})
|
||||
return SubmitResult{Accepted: true, Pending: true, QueueDepth: depth}, nil
|
||||
return SubmitResult{Accepted: true, Pending: true, QueueDepth: depth, MessageID: a.hint.MessageID}, nil
|
||||
}
|
||||
|
||||
st.gen++
|
||||
@@ -271,17 +305,25 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
|
||||
o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID)
|
||||
|
||||
if sync {
|
||||
res, err := o.runTurnFenced(runCtx, sessionID, turn, env, fence)
|
||||
fence.release()
|
||||
if err != nil {
|
||||
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
|
||||
}
|
||||
_ = o.completeTurn(sessionID, gen, res, err, deviceID, msgMode, env.PersonaID)
|
||||
return SubmitResult{Accepted: true, Immediate: true, Err: err}, res
|
||||
return o.runImmediateSync(runCtx, sessionID, gen, turn, env, fence, deviceID, msgMode, a.hint.MessageID)
|
||||
}
|
||||
|
||||
go o.goTurn(sessionID, gen, turn, env, runCtx, cancel, deviceID, msgMode, fence)
|
||||
return SubmitResult{Accepted: true, Immediate: true}, nil
|
||||
return SubmitResult{Accepted: true, Immediate: true, MessageID: a.hint.MessageID}, nil
|
||||
}
|
||||
|
||||
// runImmediateSync 在**调用方** goroutine 上跑完刚启动的那个回合(REST 同步
|
||||
// 路径),结算它并把回合结果一并交回调用方。它与 goTurn 是同一轮的两个驱动
|
||||
// 方式(前台等待 / 后台续跑),共享 runTurnFenced + completeTurn 这一对,因此
|
||||
// 谁驱动都产生同样的持久化与状态迁移。
|
||||
func (o *Orchestrator) runImmediateSync(ctx context.Context, sessionID string, gen uint64, turn []model.Message, env loop.SessionEnv, fence turnFence, deviceID, mode, msgID string) (SubmitResult, *loop.Result) {
|
||||
res, err := o.runTurnFenced(ctx, sessionID, turn, env, fence)
|
||||
fence.release()
|
||||
if err != nil {
|
||||
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
|
||||
}
|
||||
_ = o.completeTurn(sessionID, gen, res, err, deviceID, mode, env.PersonaID)
|
||||
return SubmitResult{Accepted: true, Immediate: true, MessageID: msgID, Err: err}, res
|
||||
}
|
||||
|
||||
// goTurn 在后台 goroutine 上运行单个回合,并在它结束时
|
||||
|
||||
Reference in new issue
Block a user