Files
Cyrene_v2/internal/services/gateway/chat_stream_dispatch.go
T
AskaEth 7512d593c2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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`
2026-10-04 14:37:37 +08:00

130 lines
7.0 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"
"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})
}
}