Files
sundynix-agentix/sundynix-dispatcher/internal/nats/subscriber.go
T
Blizzard 05c25d7099 feat(ops): 优雅停机 drain —— 三 Go 服务 SIGTERM 后排空在途,不硬切
滚动更新/重启时旧的"信号到→进程退"会硬切在途工作:dispatcher 在途任务被掐断、
mcp-go 在途工具调用让 dispatcher 干等超时、gateway 在途 HTTP 请求被截断。

共享 bus 加在途追踪 + drain:
- ConsumeTasks/ServeTool 各加 sync.WaitGroup 跟踪在途 goroutine,返回 drain(ctx):
  先停止接新活(cc.Stop / Unsubscribe),再等在途跑完至 drain 超时。
- 关键修复:任务 handler 的 ctx 改为派生自 context.Background()(而非信号 ctx),
  否则 SIGTERM 会立即取消在途任务的 ctx,drain 形同虚设。超时未跑完才由 JetStream
  AckWait 重投兜底(不丢任务)。
- DrainTimeout():SHUTDOWN_DRAIN_TIMEOUT 秒,默认 30s。

各服务收尾:
- gateway:r.Run → http.Server + signal.NotifyContext + srv.Shutdown(drain 在途请求),
  随后 defer 关 db/redis/bus(HTTP 排空后才断后端连接)。
- dispatcher:收到信号 → drain 在途任务跑完再退。
- mcp-go:收到信号 → drain 在途工具调用回完再退(dispatcher 拿到结果而非超时)。

bus 加 TestGracefulDrain(drain 等满在途任务 + 验证在途 ctx 不被取消),e2e 测试适配
新签名,四模块全绿。live:在途任务执行中 kill -TERM dispatcher → 日志「drain 在途任务」
→ 11s 后 task done(511字完整生成) → 「drain 完成,退出」,任务终态 done 非 failed;
gateway/mcp-go 同样优雅退出。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:29:23 +08:00

121 lines
4.7 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 nats 是调度器对共享 bus 的薄封装(消费任务 / 回写 Token)。
package nats
import (
"context"
"log"
"time"
sharedbus "github.com/sundynix/sundynix-shared/bus"
"github.com/sundynix/sundynix-shared/contract"
)
// TaskHandler 处理单个任务。
type TaskHandler func(ctx context.Context, t *contract.Task) error
// Subscriber 包装共享 bus,向调度器暴露消费能力。
type Subscriber struct {
inner *sharedbus.Bus
}
// MustConnect 接入 NATS 并确保任务流存在(消费者声明在 Consume 时完成)。
func MustConnect(url string) *Subscriber {
inner, err := sharedbus.Connect(url)
if err != nil {
log.Fatalf("[dispatcher/nats] connect: %v", err)
}
if err := inner.EnsureTaskStream(context.Background()); err != nil {
log.Fatalf("[dispatcher/nats] ensure stream: %v", err)
}
log.Printf("[dispatcher/nats] connected %s", url)
return &Subscriber{inner: inner}
}
// ConsumeTasks 从 sundynix.tasks.* 持续消费任务(队列组负载均衡),阻塞至 ctx 取消。
func (s *Subscriber) ConsumeTasks(ctx context.Context, h TaskHandler) error {
drain, err := s.inner.ConsumeTasks(ctx, func(c context.Context, t *contract.Task) error {
return h(c, t)
})
if err != nil {
return err
}
<-ctx.Done() // 收到停机信号
// 优雅停机:停止消费新任务 + 等在途任务跑完(至多 DrainTimeout),再退出。
to := sharedbus.DrainTimeout()
log.Printf("[dispatcher] 收到停机信号,drain 在途任务(≤%s)…", to)
dctx, cancel := context.WithTimeout(context.Background(), to)
defer cancel()
drain(dctx)
log.Printf("[dispatcher] drain 完成,退出")
return nil
}
// PublishToken / CompleteStream 让 Subscriber 满足 eino.TokenSink
// 把推理 Token 回流到 sundynix.streams.<taskID>。
func (s *Subscriber) PublishToken(taskID string, token []byte) error {
return s.inner.PublishToken(taskID, token)
}
func (s *Subscriber) CompleteStream(taskID string) error {
return s.inner.CompleteStream(taskID)
}
// PublishExec / CompleteExec 让 Subscriber 满足 eino.ExecSink
// 把执行轨迹事件回流到 sundynix.exec.<taskID>(与 Token 流分开)。
func (s *Subscriber) PublishExec(taskID string, data []byte) error {
return s.inner.PublishExec(taskID, data)
}
func (s *Subscriber) CompleteExec(taskID string) error {
return s.inner.CompleteExec(taskID)
}
// CallTool 让 Subscriber 满足 eino.ToolCaller,经 NATS request-reply 调起第 5 层 MCP 工具。
func (s *Subscriber) CallTool(ctx context.Context, subject string, call *contract.ToolCall) (*contract.ToolResult, error) {
return s.inner.CallTool(ctx, subject, call)
}
// ServeHealth 在 dispatcher 心跳主题上应答探活,让管理端「服务状态」判定其在线。
func (s *Subscriber) ServeHealth(provide func() []byte) (func() error, error) {
return s.inner.ServeHealth(contract.SubjectHealthDispatcher, provide)
}
// PublishTaskStatus 让 Subscriber 满足 eino.StatusSink,把任务状态流转回写给网关。
func (s *Subscriber) PublishTaskStatus(taskID, status, detail string) error {
return s.inner.PublishTaskStatus(&contract.TaskStatusEvent{
TaskID: taskID, Status: status, Detail: detail, TS: time.Now().UnixMilli(),
})
}
// PublishEval 让 Subscriber 满足 eino.EvalSink,把自动化评测结果回写给网关落库。
func (s *Subscriber) PublishEval(ev *contract.EvalEvent) error {
return s.inner.PublishEval(ev)
}
// PublishUsage 让 Subscriber 满足 eino.UsageSink,把任务 token 用量回写给网关累计/计费。
func (s *Subscriber) PublishUsage(ev *contract.UsageEvent) error {
return s.inner.PublishUsage(ev)
}
// WaitApproval 让 Subscriber 满足 eino.ApprovalWaiter,阻塞等待审批节点的人工决定。
func (s *Subscriber) WaitApproval(ctx context.Context, taskID string, timeout time.Duration) (*contract.ApprovalDecision, error) {
return s.inner.WaitApproval(ctx, taskID, timeout)
}
// RequestModelConfig 向控制面(Gateway)取当前激活的对话模型配置。
func (s *Subscriber) RequestModelConfig(ctx context.Context) (*contract.ModelConfig, error) {
return s.inner.RequestConfig(ctx, contract.ConfigKindChat)
}
// SubscribeModelConfigUpdated 订阅对话模型配置热更新。
func (s *Subscriber) SubscribeModelConfigUpdated(onUpdate func(*contract.ModelConfig)) (func() error, error) {
return s.inner.SubscribeConfigUpdated(contract.ConfigKindChat, onUpdate)
}
// FetchModelConfigWithRetry 后台重试拉取初始对话模型配置(容忍 gateway 晚于 dispatcher 启动)。
func (s *Subscriber) FetchModelConfigWithRetry(ctx context.Context, apply func(*contract.ModelConfig)) {
s.inner.RequestConfigWithRetry(ctx, contract.ConfigKindChat, apply)
}
func (s *Subscriber) Close() { s.inner.Close() }