fix(gateway,history): REST 流式不再静默失败(G2)+ clientMsgId 幂等(G4)(协同 §G,均标「中高/中」)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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:
AskaEth committed 2026-10-04 14:37:37 +08:00
1 parent da3a386daf
commit 7512d593c2
18 files changed
+1573 -216

No files matched your search

@@ -0,0 +1,129 @@
package gateway
import (
"context"
"fmt"
"net/http"
"time"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
)
// 本文件归属 REST `POST /api/v1/chat` 的 `stream:true` 派发(Android 协同 §G2)。
//
// 修复前的链路是 publishChatStream → bus.Publish → hub 订阅 → streamChat,三处
// 叠加起来让「客户端拿到 200、却什么都没发生」成为可能:
// ① bus 的订阅缓冲满时会**丢事件**(bus 包文档明示的代价),而会话消息被丢就是
// 用户消息消失;
// ② handler 在问编排器之前就回了 200 {"stream":true};
// ③ Publish 的错误被丢弃,且传的是**请求作用域**的 ctx(请求返回后即被取消,
// Publish 直接返回)。
//
// 修复后它与 WS 的 chat 帧走**同一条直达路径**(ws_route.go 的既有先例,
// design/15 §2.1「会话消息不得被 drop」):接了编排器就直接提交,完全不经总线。
// handler 因此在回 200 之前就知道这一轮真的被接收并落库。只有在没接编排器时才
// 回退到总线发布,而发布失败不再被吞掉 —— 回 503 + 明确错误码,绝不假装成功。
// chatStreamTurnBudget 是 stream:true 派生回合上下文的上限。该上下文必须与请求
// 作用域**脱钩**:handler 返回后 r.Context() 即被取消,若回合挂在它下面,流式
// 回合会在客户端刚收到 200 时被连带取消(回合是提交之后在后台跑的)。因此基础
// 用 context.WithoutCancel(保留 trace/审计等值、丢掉取消信号),再套一个上限。
//
// 上限取一个远大于任何正常回合的值:工具循环上限 8 轮(loop.DefaultMaxRounds),
// 正常一轮是秒级到分钟级,30 分钟只在回合真的挂死时兜底 —— 它是安全网,不是
// 功能限制(与 WS 路径的 context.Background() 相比,只是多了一个上界)。
const chatStreamTurnBudget = 30 * time.Minute
// handleChatStream 是 POST /api/v1/chat 的 stream:true 分支。它先把这一轮交给
// submitChatStream(与 WS chat 帧同一条直达路径),拿到判定后才回响应,因此
// **200 意味着消息已被接住并落库**;派发用的 ctx 与请求作用域脱钩、带上限
// (见 detachedTurnContext)。
func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request, req chatRequest) {
if s.runner == nil {
// 与同步路径同一口径:没接 loop 就不能假装受理了一轮对话(修复前这里
// 照样回 200,客户端永远等不到任何帧)。
writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_unavailable", "chat service is not configured"))
return
}
sid := req.SessionID
if sid == "" {
sid = defaultSessionID
}
sr, err := s.submitChatStream(detachedTurnContext(r.Context()), sid, req.Text, req.Image, req.DeviceID, req.Mode, req.ClientMsgID)
if err != nil {
// 派发没成功:明确报 503,而不是让客户端以为这一轮在跑(协同 G2)。
s.log.Error("chat stream dispatch", "err", err, "session", sid)
writeError(w, apperr.Wrap(apperr.ErrTimeout, "chat_dispatch_failed", "流式通道不可用,本轮未提交;请重试"))
return
}
s.writeChatStreamAck(w, sr, sid)
}
// submitChatStream 把一次 stream:true 的对话交给编排器,并把它对本次提交的判定
// 交回 HTTP 层(协同 G2)。error 非 nil 表示这一轮**没有**被接收,此时调用方
// 绝不能回 2xx。
func (s *Server) submitChatStream(ctx context.Context, sessionID, text, image, deviceID, mode, clientMsgID string) (SubmitResult, error) {
payload := map[string]any{
"sessionId": sessionID,
"text": text,
"image": image,
"deviceId": deviceID,
"mode": mode,
"clientMsgId": clientMsgID,
}
if s.hub == nil || s.hub.orchestrator == nil {
// 未接编排器:回退到既有的总线触发路径(未接线的 hub 保持完全相同的
// ws.chat 契约),但发布失败与「没有总线」都不再是静默成功。
return s.publishChatStream(ctx, payload)
}
// 直达编排器:streamChat 同步返回提交判定(真正的回合跑在它自己派生的
// goroutine 上),因此这里不会阻塞在模型调用上。
return s.hub.streamChat(ctx, payload), nil
}
// publishChatStream 是 stream:true 的兜底派发:把载荷发布到 ws.chat 主题,由 hub
// 的中继消费。它只在未接编排器时被用到,并且**把错误交回调用方** —— 修复前它吞掉
// 了 Publish 的错误,于是总线关闭/ctx 已取消时客户端照样拿到 200。
func (s *Server) publishChatStream(ctx context.Context, payload map[string]any) (SubmitResult, error) {
if s.bus == nil {
return SubmitResult{}, fmt.Errorf("gateway: chat stream has neither an orchestrator nor a bus: %w", apperr.ErrInternal)
}
if err := s.bus.Publish(ctx, wsChatSubject, payload); err != nil {
return SubmitResult{}, fmt.Errorf("gateway: publish %s: %w", wsChatSubject, err)
}
// 回退路径只能确认「已交给总线」,不能确认编排器收下了:如实按既有契约
// 当已接受处理(2xx),并且已由调用方记日志。
return SubmitResult{Accepted: true, Immediate: true}, nil
}
// detachedTurnContext 返回一个与请求作用域脱钩、带上限的回合上下文(见
// chatStreamTurnBudget)。它刻意**不**把 cancel 交出去:回合活过这次 HTTP 请求,
// 在这里取消父 ctx 会连坐取消在途回合 —— cancel 只是「提前释放计时器」的优化,
// 上限到点自会释放,因此显式丢弃并写明原因(vet 的 lostcancel 检查也要求把
// cancel 用在某个地方)。
func detachedTurnContext(parent context.Context) context.Context {
ctx, cancel := context.WithTimeout(context.WithoutCancel(parent), chatStreamTurnBudget)
_ = cancel
return ctx
}
// writeChatStreamAck 把 stream:true 的提交判定写成响应(协同 G2 顺带项 + G4)。
// 此前它无条件回 200 {"stream":true},于是「排队满」与「重复到达」都只能靠客户端
// 猜;现在四种判定各有明确的状态码与载荷:
// - 重复到达:200 {duplicate:true, messageId}(没有新回合、没有新帧,客户端用
// messageId 把本地乐观气泡对齐到既有消息)
// - 待处理队列满:409 {error:{code:"chat_busy"}}(与同步路径同一口径)
// - 会话正忙、已续接排队:202 {stream:true, queued:true, queueDepth}
// - 已开始:200 {stream:true}
func (s *Server) writeChatStreamAck(w http.ResponseWriter, sr SubmitResult, sessionID string) {
switch {
case sr.Duplicate:
writeJSON(w, http.StatusOK, map[string]any{"duplicate": true, "messageId": sr.MessageID, "sessionId": sessionID})
case sr.PendingFull:
writeError(w, apperr.Wrap(apperr.ErrConflict, "chat_busy", "会话正忙,待处理消息已满"))
case sr.Pending:
writeJSON(w, http.StatusAccepted, map[string]any{"stream": true, "queued": true, "queueDepth": sr.QueueDepth})
default:
writeJSON(w, http.StatusOK, map[string]any{"stream": true})
}
}