Files
sundynix-agentix/sundynix-shared/bus/bus.go
T
Blizzard da9c76f073 feat(prod): 实时就绪探针——gateway /readyz 真 ping + dispatcher/mcp-go 加 HTTP 探针(B2)
此前 gateway /readyz 用的是 Enabled() 启动期降级标志(反映不了运行中 PG 掉线,LB 会继续
往已不可用实例导流);dispatcher/mcp-go 干脆没有 HTTP 探针(只有 NATS ServeHealth,k8s/LB
够不着、只能靠 gateway 经 NATS 代探)。

- gateway /readyz 改用 db.Ping 实时探活。只把 **DB 当硬依赖门**:Redis 掉线仍可服务(限流有
  A5 进程内 fail-safe 兜底、SSE 回落 live NATS),一 blip 就把全部实例踢出轮转反而制造整站
  故障,故 Redis 只上报不 gate。NATS 启动即连(fatal)不单列。
- 新 sundynix-shared/health 包:Serve/Handler 提供 /healthz(恒 200 liveness) + /readyz(由
  ready() 决定 readiness)。dispatcher(:8091, DISPATCHER_HEALTH_ADDR)与 mcp-go(:8092,
  MCP_GO_HEALTH_ADDR)各起一个,readiness = NATS 连接可用(bus.IsConnected)。接入各自优雅停机。
- bus 加 IsConnected()(nc.IsConnected 实时);dispatcher Subscriber 透传。

四模块 build+vet+test 绿;health 带 Handler 单测。探针端口仅内部用(对外仍只暴露 gateway)。
compose/k8s 的 healthcheck 编排属 C 层 ops,另做。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:50:31 +08:00

1063 lines
39 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 bus 封装 NATS JetStream 的连接、流声明、任务发布与消费。
// Gateway 与 Dispatcher 共用这套真实收发逻辑,e2e 测试也直接覆盖它。
package bus
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"strconv"
"sync"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"github.com/sundynix/sundynix-shared/contract"
"github.com/sundynix/sundynix-shared/secrets"
)
// decryptConfig 在消费侧把配置里的 api_key 从密文还原为明文(控制面以密文过线缆,见 secrets 包)。
// 失败(密钥不匹配 / 密文损坏)时清空 api_key 并不再降级阻断——调用方据 Ready() 判定。
// 备用模型(Fallbacks)的 api_key 同样是密文,一并解密。
func decryptConfig(cfg *contract.ModelConfig) {
if cfg == nil {
return
}
if cfg.APIKey != "" {
if plain, err := secrets.Decrypt(cfg.APIKey); err == nil {
cfg.APIKey = plain
}
}
for i := range cfg.Fallbacks {
if cfg.Fallbacks[i].APIKey == "" {
continue
}
if plain, err := secrets.Decrypt(cfg.Fallbacks[i].APIKey); err == nil {
cfg.Fallbacks[i].APIKey = plain
}
}
}
// Bus 持有 NATS 连接与 JetStream 上下文。
type Bus struct {
nc *nats.Conn
js jetstream.JetStream
}
// Connect 接入 NATS 骨干网并初始化 JetStream,使用默认重试参数。
func Connect(url string) (*Bus, error) {
return ConnectWithRetry(url, 30, time.Second)
}
// ConnectWithRetry 在 NATS 暂不可用时按固定间隔重试,容忍服务先于 NATS 启动。
func ConnectWithRetry(url string, attempts int, interval time.Duration) (*Bus, error) {
var lastErr error
for i := 0; i < attempts; i++ {
nc, err := nats.Connect(url,
nats.Timeout(5*time.Second),
nats.RetryOnFailedConnect(true),
nats.MaxReconnects(-1),
nats.ReconnectWait(interval),
)
if err != nil {
lastErr = err
time.Sleep(interval)
continue
}
// RetryOnFailedConnect 下 Connect 可能立即返回但尚未连上,等待真正建立。
if nc.Status() != nats.CONNECTED {
if !waitConnected(nc, 5*time.Second) {
lastErr = fmt.Errorf("nats not connected within timeout")
nc.Close()
time.Sleep(interval)
continue
}
}
js, err := jetstream.New(nc)
if err != nil {
nc.Close()
return nil, fmt.Errorf("jetstream init: %w", err)
}
return &Bus{nc: nc, js: js}, nil
}
return nil, fmt.Errorf("nats connect after %d attempts: %w", attempts, lastErr)
}
func waitConnected(nc *nats.Conn, d time.Duration) bool {
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if nc.Status() == nats.CONNECTED {
return true
}
time.Sleep(50 * time.Millisecond)
}
return nc.Status() == nats.CONNECTED
}
// IsConnected 报告 NATS 连接此刻是否真的可用(供 readiness 探针;非仅启动时连过)。
func (b *Bus) IsConnected() bool { return b.nc != nil && b.nc.IsConnected() }
// Close 关闭底层连接。
func (b *Bus) Close() {
if b.nc != nil {
b.nc.Close()
}
}
// EnsureTaskStream 幂等地创建/更新任务流,捕获 sundynix.tasks.>。
func (b *Bus) EnsureTaskStream(ctx context.Context) error {
_, err := b.js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{
Name: contract.StreamTasks,
Subjects: []string{contract.SubjectTasksAll},
Storage: jetstream.FileStorage,
Replicas: streamReplicas(),
})
return err
}
// PublishTask 把任务发布到 sundynix.tasks.<id>,返回序列号。
// 发布前把链路上下文注入消息头,使 dispatcher 消费时能续上同一条 trace。
func (b *Bus) PublishTask(ctx context.Context, t *contract.Task) (uint64, error) {
data, err := t.Marshal()
if err != nil {
return 0, err
}
ctx, span := tracer().Start(ctx, "nats.publish task",
trace.WithSpanKind(trace.SpanKindProducer),
trace.WithAttributes(
attribute.String("messaging.system", "nats"),
attribute.String("messaging.destination.name", contract.TaskSubject(t.ID)),
attribute.String("sundynix.task_id", t.ID),
))
defer span.End()
msg := nats.NewMsg(contract.TaskSubject(t.ID))
msg.Data = data
injectTrace(ctx, msg.Header) // 把 traceparent 写进消息头 → 跨总线传播
ack, err := b.js.PublishMsg(ctx, msg)
if err != nil {
span.RecordError(err)
return 0, fmt.Errorf("publish task: %w", err)
}
return ack.Sequence, nil
}
// ---- Token 流回流(core NATS 零拷贝字节管道)----
// PublishToken 把一个推理 Token 以 core NATS 写到 sundynix.streams.<taskID>。
func (b *Bus) PublishToken(taskID string, token []byte) error {
return b.nc.Publish(contract.StreamSubject(taskID), token)
}
// CompleteStream 发送 Token 流结束信号(空体 + 结束头)。
func (b *Bus) CompleteStream(taskID string) error {
msg := nats.NewMsg(contract.StreamSubject(taskID))
msg.Header.Set(contract.HeaderStreamEnd, "1")
return b.nc.PublishMsg(msg)
}
// SubscribeTokens 订阅某 task 的 Token 流。每个 Token 触发 onToken
// 收到结束信号后触发 onDone。返回的 unsub 用于退订。
// 注意:core NATS 无持久化,订阅须在 Token 产生前建立(SSE 客户端先连)。
func (b *Bus) SubscribeTokens(taskID string, onToken func([]byte), onDone func()) (unsub func() error, err error) {
sub, err := b.nc.Subscribe(contract.StreamSubject(taskID), func(m *nats.Msg) {
if m.Header.Get(contract.HeaderStreamEnd) == "1" {
onDone()
return
}
// 拷贝,避免 nats 复用底层 buffer。
tok := make([]byte, len(m.Data))
copy(tok, m.Data)
onToken(tok)
})
if err != nil {
return nil, fmt.Errorf("subscribe tokens: %w", err)
}
return sub.Unsubscribe, nil
}
// ---- 执行可视化事件(core NATS,与 Token 流分流)----
// PublishExec 把一条执行事件(JSON)发到 sundynix.exec.<taskID>。
func (b *Bus) PublishExec(taskID string, data []byte) error {
return b.nc.Publish(contract.ExecSubject(taskID), data)
}
// CompleteExec 发送执行事件流结束信号(空体 + 结束头)。
func (b *Bus) CompleteExec(taskID string) error {
msg := nats.NewMsg(contract.ExecSubject(taskID))
msg.Header.Set(contract.HeaderStreamEnd, "1")
return b.nc.PublishMsg(msg)
}
// SubscribeExec 订阅某 task 的执行事件流。每条事件触发 onEvent;结束触发 onDone。
func (b *Bus) SubscribeExec(taskID string, onEvent func([]byte), onDone func()) (unsub func() error, err error) {
sub, err := b.nc.Subscribe(contract.ExecSubject(taskID), func(m *nats.Msg) {
if m.Header.Get(contract.HeaderStreamEnd) == "1" {
onDone()
return
}
data := make([]byte, len(m.Data))
copy(data, m.Data)
onEvent(data)
})
if err != nil {
return nil, fmt.Errorf("subscribe exec: %w", err)
}
return sub.Unsubscribe, nil
}
// ---- MCP 工具调用(core NATS request-reply----
// CallTool 同步调用一个 MCP 工具:发到 subject,阻塞等待应答。
// ctx 超时即视为工具不可用,由调用方决定降级。
func (b *Bus) CallTool(ctx context.Context, subject string, call *contract.ToolCall) (*contract.ToolResult, error) {
data, err := json.Marshal(call)
if err != nil {
return nil, fmt.Errorf("marshal tool call: %w", err)
}
ctx, span := tracer().Start(ctx, "tool.call "+call.Tool,
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
attribute.String("sundynix.tool", call.Tool),
attribute.String("messaging.destination.name", subject),
))
defer span.End()
req := nats.NewMsg(subject)
req.Data = data
injectTrace(ctx, req.Header) // 把链路上下文带给第 5 层工具服务
msg, err := b.nc.RequestMsgWithContext(ctx, req)
if err != nil {
span.RecordError(err)
return nil, fmt.Errorf("call tool %s: %w", subject, err)
}
var res contract.ToolResult
if err := json.Unmarshal(msg.Data, &res); err != nil {
span.RecordError(err)
return nil, fmt.Errorf("unmarshal tool result: %w", err)
}
span.SetAttributes(attribute.Bool("sundynix.tool.ok", res.OK))
return &res, nil
}
// ToolHandler 处理一次工具调用并返回结果。
type ToolHandler func(ctx context.Context, call *contract.ToolCall) *contract.ToolResult
// toolConcurrency 返回单实例工具并发处理上限(env MCP_TOOL_CONCURRENCY,默认 16)。
func toolConcurrency() int {
if v := os.Getenv("MCP_TOOL_CONCURRENCY"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
}
return 16
}
// ServeTool 以队列组订阅工具主题(可用通配 sundynix.tools.go.>),队列组内多副本自动负载均衡(水平扩)。
// 单实例内每个请求分发到独立 goroutine 并发处理(垂直扩,上限 MCP_TOOL_CONCURRENCY)——
// 回调立即返回不阻塞 NATS 投递;信号量约束同时执行的 handler 数。返回的 unsub 用于退订。
func (b *Bus) ServeTool(subject, queue string, h ToolHandler) (drain func(context.Context), err error) {
sem := make(chan struct{}, toolConcurrency())
var wg sync.WaitGroup // 跟踪在途工具调用,供优雅停机 drain 等待回完
sub, err := b.nc.QueueSubscribe(subject, queue, func(m *nats.Msg) {
// 立即起 goroutine 并返回,让 NATS 继续投递下一条(核心 NATS 单订阅回调本是串行)。
wg.Add(1)
go func() {
sem <- struct{}{} // 限并发:超额则在此短暂排队
defer func() {
<-sem
wg.Done()
if r := recover(); r != nil { // handler panic → 回错误结果,调用方不必干等超时
respond(m, &contract.ToolResult{OK: false, Error: fmt.Sprintf("tool panic: %v", r)})
}
}()
var call contract.ToolCall
if err := json.Unmarshal(m.Data, &call); err != nil {
respond(m, &contract.ToolResult{OK: false, Error: "bad tool call: " + err.Error()})
return
}
// 还原上游链路并开服务端 span(成为 dispatcher tool.call span 的子节点)。
mctx := extractTrace(context.Background(), m.Header)
mctx, span := tracer().Start(mctx, "tool.serve "+call.Tool,
trace.WithSpanKind(trace.SpanKindServer),
trace.WithAttributes(attribute.String("sundynix.tool", call.Tool)))
defer span.End()
res := h(mctx, &call)
if res != nil {
span.SetAttributes(attribute.Bool("sundynix.tool.ok", res.OK))
}
respond(m, res)
}()
})
if err != nil {
return nil, fmt.Errorf("serve tool %s: %w", subject, err)
}
// drain:退订(停止接新请求),等在途工具调用回完(至多 dctx)。
return func(dctx context.Context) {
_ = sub.Unsubscribe()
drainWait(&wg, dctx)
}, nil
}
func respond(m *nats.Msg, res *contract.ToolResult) {
data, err := json.Marshal(res)
if err != nil {
data, _ = json.Marshal(&contract.ToolResult{OK: false, Error: "marshal result: " + err.Error()})
}
_ = m.Respond(data)
}
// ---- 服务探活(core NATS request-reply 心跳)----
// ServeHealth 在 subject 上应答健康探测,provide 返回本节点状态 JSON(可为空)。
// 用于无 HTTP/工具端点的节点(如 dispatcher)向控制面暴露存活。
func (b *Bus) ServeHealth(subject string, provide func() []byte) (unsub func() error, err error) {
sub, err := b.nc.Subscribe(subject, func(m *nats.Msg) {
_ = m.Respond(provide())
})
if err != nil {
return nil, fmt.Errorf("serve health %s: %w", subject, err)
}
return sub.Unsubscribe, nil
}
// Ping 同步探测某节点健康:发到 subject 等应答。无人应答 / 超时即返回错误(视为下线)。
func (b *Bus) Ping(ctx context.Context, subject string) ([]byte, error) {
msg, err := b.nc.RequestWithContext(ctx, subject, nil)
if err != nil {
return nil, err
}
return msg.Data, nil
}
// ---- 任务生命周期状态回写(JetStream 持久,at-least-once + 幂等落库)----
// EnsureStatusStream 幂等地创建/更新状态回写流,持久捕获 sundynix.status.task。
func (b *Bus) EnsureStatusStream(ctx context.Context) error {
_, err := b.js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{
Name: contract.StreamStatus,
Subjects: []string{contract.SubjectTaskStatus},
Storage: jetstream.FileStorage,
Replicas: streamReplicas(),
MaxAge: 24 * time.Hour, // 状态一天内必被消费,过期回收防无限增长
})
return err
}
// PublishTaskStatus 发布一次任务状态流转到持久流(dispatcher 调用);同步等 stream ack,失败即返回。
func (b *Bus) PublishTaskStatus(ev *contract.TaskStatusEvent) error {
data, err := json.Marshal(ev)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = b.js.Publish(ctx, contract.SubjectTaskStatus, data)
return err
}
// ConsumeTaskStatus 持久消费任务状态并落库(网关调用)。h 返回 error → Nak 重投(落库失败自愈);
// nil → Ack。多网关副本共用同一 durable,队列式分摊(HA)。落库幂等,重投不致错。
func (b *Bus) ConsumeTaskStatus(ctx context.Context, h func(context.Context, *contract.TaskStatusEvent) error) (drain func(context.Context), err error) {
cons, err := b.js.CreateOrUpdateConsumer(ctx, contract.StreamStatus, jetstream.ConsumerConfig{
Durable: contract.ConsumerStatus,
AckPolicy: jetstream.AckExplicitPolicy,
FilterSubject: contract.SubjectTaskStatus,
AckWait: time.Minute,
MaxAckPending: 512,
})
if err != nil {
return nil, fmt.Errorf("create status consumer: %w", err)
}
cc, err := cons.Consume(func(msg jetstream.Msg) {
var ev contract.TaskStatusEvent
if json.Unmarshal(msg.Data(), &ev) != nil {
_ = msg.Term() // 脏数据,丢弃不重投
return
}
if herr := h(extractTrace(context.Background(), nats.Header(msg.Headers())), &ev); herr != nil {
_ = msg.Nak() // 落库失败 → 重投兜底
return
}
_ = msg.Ack()
})
if err != nil {
return nil, fmt.Errorf("consume task status: %w", err)
}
return func(context.Context) { cc.Stop() }, nil
}
// ---- 自动化评测结果回写(JetStream 持久,at-least-once + 幂等落库)----
// 此前是 core NATS pub-sub:网关离线/慢消费者会丢评测结果(质量趋势/门控依赖它)。
// 升级为持久流,与 status/usage 同级。SaveEval 按 task_id upsert 幂等,重投安全。
// EnsureEvalStream 幂等地创建/更新评测回写流,持久捕获 sundynix.eval.task。
func (b *Bus) EnsureEvalStream(ctx context.Context) error {
_, err := b.js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{
Name: contract.StreamEval,
Subjects: []string{contract.SubjectEval},
Storage: jetstream.FileStorage,
Replicas: streamReplicas(),
MaxAge: 24 * time.Hour, // 评测一天内必被消费,过期回收防无限增长
})
return err
}
// PublishEval 发布一次评测结果到持久流(dispatcher 调用);同步等 stream ack,失败即返回。
func (b *Bus) PublishEval(ev *contract.EvalEvent) error {
data, err := json.Marshal(ev)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = b.js.Publish(ctx, contract.SubjectEval, data)
return err
}
// ConsumeEval 持久消费评测结果并落库(网关调用)。h 返回 error → Nak 重投(落库失败自愈);
// nil → Ack。多网关副本共用同一 durable,队列式分摊(HA)。落库幂等,重投不致错。
func (b *Bus) ConsumeEval(ctx context.Context, h func(context.Context, *contract.EvalEvent) error) (drain func(context.Context), err error) {
cons, err := b.js.CreateOrUpdateConsumer(ctx, contract.StreamEval, jetstream.ConsumerConfig{
Durable: contract.ConsumerEval,
AckPolicy: jetstream.AckExplicitPolicy,
FilterSubject: contract.SubjectEval,
AckWait: time.Minute,
MaxAckPending: 512,
})
if err != nil {
return nil, fmt.Errorf("create eval consumer: %w", err)
}
cc, err := cons.Consume(func(msg jetstream.Msg) {
var ev contract.EvalEvent
if json.Unmarshal(msg.Data(), &ev) != nil {
_ = msg.Term() // 脏数据,丢弃不重投
return
}
if herr := h(extractTrace(context.Background(), nats.Header(msg.Headers())), &ev); herr != nil {
_ = msg.Nak() // 落库失败 → 重投兜底
return
}
_ = msg.Ack()
})
if err != nil {
return nil, fmt.Errorf("consume eval: %w", err)
}
return func(context.Context) { cc.Stop() }, nil
}
// EnsureUsageStream 幂等地创建/更新用量回写流,持久捕获 sundynix.usage.task(计费凭据不丢)。
func (b *Bus) EnsureUsageStream(ctx context.Context) error {
_, err := b.js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{
Name: contract.StreamUsage,
Subjects: []string{contract.SubjectUsage},
Storage: jetstream.FileStorage,
Replicas: streamReplicas(),
MaxAge: 72 * time.Hour, // 计费留足追赶窗口(网关长时间离线后仍能补齐)
})
return err
}
// PublishUsage 发布一次任务 token 用量到持久流(dispatcher 收尾调用);同步等 stream ack。
func (b *Bus) PublishUsage(ev *contract.UsageEvent) error {
data, err := json.Marshal(ev)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = b.js.Publish(ctx, contract.SubjectUsage, data)
return err
}
// ConsumeUsage 持久消费用量并计费(网关调用)。h 返回 error → Nak 重投;nil → Ack。
// 落库幂等(usage_event.task_id 唯一 + 门控)→ 重投绝不重复扣费。多网关副本队列式分摊(HA)。
func (b *Bus) ConsumeUsage(ctx context.Context, h func(context.Context, *contract.UsageEvent) error) (drain func(context.Context), err error) {
cons, err := b.js.CreateOrUpdateConsumer(ctx, contract.StreamUsage, jetstream.ConsumerConfig{
Durable: contract.ConsumerUsage,
AckPolicy: jetstream.AckExplicitPolicy,
FilterSubject: contract.SubjectUsage,
AckWait: time.Minute,
MaxAckPending: 512,
})
if err != nil {
return nil, fmt.Errorf("create usage consumer: %w", err)
}
cc, err := cons.Consume(func(msg jetstream.Msg) {
var ev contract.UsageEvent
if json.Unmarshal(msg.Data(), &ev) != nil {
_ = msg.Term()
return
}
if herr := h(extractTrace(context.Background(), nats.Header(msg.Headers())), &ev); herr != nil {
_ = msg.Nak()
return
}
_ = msg.Ack()
})
if err != nil {
return nil, fmt.Errorf("consume usage: %w", err)
}
return func(context.Context) { cc.Stop() }, nil
}
// ---- 人工审批(HITLcore NATS pub-sub----
// PublishApproval 广播一次人工审批决定(网关在收到 UI 的批准/拒绝后调用)。
func (b *Bus) PublishApproval(dec *contract.ApprovalDecision) error {
data, err := json.Marshal(dec)
if err != nil {
return err
}
return b.nc.Publish(contract.ApprovalSubject(dec.TaskID), data)
}
// EnsureApprovalStream 幂等地创建/更新审批决定流,持久捕获 sundynix.approval.>。
// 决定带 MaxAge 过期(审批不会拖超一天,避免流无限增长);让中断/恢复模型即便 dispatcher
// 在决定发出时离线,重连后仍能消费到决定续跑(core NATS 的 PublishApproval 也被本流捕获)。
func (b *Bus) EnsureApprovalStream(ctx context.Context) error {
_, err := b.js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{
Name: contract.StreamApprovals,
Subjects: []string{contract.SubjectApprovalAll},
Storage: jetstream.FileStorage,
Replicas: streamReplicas(),
MaxAge: 24 * time.Hour,
})
return err
}
// ConsumeApprovals 在持久消费者上消费审批决定,队列组内负载均衡(多 dispatcher 副本下每条决定
// 只一个副本处理 resume)。每条决定派生独立 goroutine 处理(resume 续跑可能秒级),返回 stop 优雅停。
func (b *Bus) ConsumeApprovals(ctx context.Context, h func(context.Context, *contract.ApprovalDecision)) (drain func(context.Context), err error) {
cons, err := b.js.CreateOrUpdateConsumer(ctx, contract.StreamApprovals, jetstream.ConsumerConfig{
Durable: contract.ConsumerApprovals,
AckPolicy: jetstream.AckExplicitPolicy,
FilterSubject: contract.SubjectApprovalAll,
// resume 续跑可能再遇审批 / 出稿(LLM 秒级),AckWait 给足,未 ack 由重投兜底。
AckWait: 15 * time.Minute,
MaxAckPending: taskConcurrency(),
})
if err != nil {
return nil, fmt.Errorf("create approval consumer: %w", err)
}
var wg sync.WaitGroup
cc, err := cons.Consume(func(msg jetstream.Msg) {
var dec contract.ApprovalDecision
if json.Unmarshal(msg.Data(), &dec) != nil {
_ = msg.Term() // 脏数据,丢弃不重投
return
}
wg.Add(1)
go func() {
defer func() {
wg.Done()
if r := recover(); r != nil {
log.Printf("[bus] approval %s handler panic: %v", dec.TaskID, r)
_ = msg.Term()
}
}()
// ctx 派生自 Background:决定处理独立于触发它的请求/信号生命周期(同 ConsumeTasks)。
mctx := extractTrace(context.Background(), nats.Header(msg.Headers()))
h(mctx, &dec)
_ = msg.Ack()
}()
})
if err != nil {
return nil, fmt.Errorf("consume approvals: %w", err)
}
return func(dctx context.Context) { cc.Stop(); drainWait(&wg, dctx) }, nil
}
// WaitApproval 阻塞等待某任务的人工审批决定,直到收到、ctx 取消或超时。
// dispatcher 在审批节点调用:先订阅再等待(订阅早于决定到达,避免错过)。
// 超时返回 (nil, error) —— 调用方据安全默认(拒绝)处理。
func (b *Bus) WaitApproval(ctx context.Context, taskID string, timeout time.Duration) (*contract.ApprovalDecision, error) {
ch := make(chan *contract.ApprovalDecision, 1)
sub, err := b.nc.Subscribe(contract.ApprovalSubject(taskID), func(m *nats.Msg) {
var dec contract.ApprovalDecision
if json.Unmarshal(m.Data, &dec) == nil {
select {
case ch <- &dec:
default: // 已收到一条,丢弃后续重复决定
}
}
})
if err != nil {
return nil, fmt.Errorf("subscribe approval: %w", err)
}
defer func() { _ = sub.Unsubscribe() }()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case dec := <-ch:
return dec, nil
case <-timer.C:
return nil, fmt.Errorf("approval timeout after %s", timeout)
case <-ctx.Done():
return nil, ctx.Err()
}
}
// ---- 配置控制面(core NATS request-reply + broadcast----
// RequestConfig 向控制面(Gateway)请求某 kind 当前激活配置(chat/embedding)。
// 无人应答 / 无激活配置时返回 (nil, nil),由调用方降级。
func (b *Bus) RequestConfig(ctx context.Context, kind string) (*contract.ModelConfig, error) {
msg, err := b.nc.RequestWithContext(ctx, contract.ConfigGetSubject(kind), nil)
if err != nil {
return nil, nil // 控制面暂不可用,降级
}
if len(msg.Data) == 0 {
return nil, nil
}
var cfg contract.ModelConfig
if err := json.Unmarshal(msg.Data, &cfg); err != nil {
return nil, fmt.Errorf("unmarshal %s config: %w", kind, err)
}
if !cfg.Ready() {
return nil, nil
}
decryptConfig(&cfg) // 线缆上是密文,消费侧还原
return &cfg, nil
}
// RequestConfigWithRetry 后台重试拉取某 kind 的初始配置,直到成功或重试耗尽。
// 容忍消费方(dispatcher/mcp-go)早于控制面(gateway)启动——一次性请求扑空后不再干等热更新。
// 拿到即调 apply 并返回;ctx 取消或重试上限到则放弃(此后仍可由热更新广播兜底)。
func (b *Bus) RequestConfigWithRetry(ctx context.Context, kind string, apply func(*contract.ModelConfig)) {
for i := 0; i < 60; i++ {
cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
cfg, _ := b.RequestConfig(cctx, kind)
cancel()
if cfg != nil {
apply(cfg)
return
}
select {
case <-ctx.Done():
return
case <-time.After(2 * time.Second):
}
}
}
// ServeConfig 让控制面响应某 kind 的配置请求;provide 返回当前激活配置(可为 nil)。
// 队列组:多网关副本下每个配置请求只由一个副本应答,避免请求方收到多份重复应答(HA)。
func (b *Bus) ServeConfig(kind string, provide func() *contract.ModelConfig) (unsub func() error, err error) {
sub, err := b.nc.QueueSubscribe(contract.ConfigGetSubject(kind), contract.QueueGateway, func(m *nats.Msg) {
var data []byte
if cfg := provide(); cfg != nil {
data, _ = json.Marshal(cfg)
}
_ = m.Respond(data)
})
if err != nil {
return nil, fmt.Errorf("serve %s config: %w", kind, err)
}
return sub.Unsubscribe, nil
}
// PublishConfigUpdated 广播某 kind 的配置变更(消费方据此热更新)。
func (b *Bus) PublishConfigUpdated(kind string, cfg *contract.ModelConfig) error {
data, err := json.Marshal(cfg)
if err != nil {
return err
}
return b.nc.Publish(contract.ConfigUpdatedSubject(kind), data)
}
// SubscribeConfigUpdated 订阅某 kind 的配置变更。
func (b *Bus) SubscribeConfigUpdated(kind string, onUpdate func(*contract.ModelConfig)) (unsub func() error, err error) {
sub, err := b.nc.Subscribe(contract.ConfigUpdatedSubject(kind), func(m *nats.Msg) {
var cfg contract.ModelConfig
if json.Unmarshal(m.Data, &cfg) == nil {
decryptConfig(&cfg) // 线缆上是密文,消费侧还原
onUpdate(&cfg)
}
})
if err != nil {
return nil, fmt.Errorf("subscribe %s config: %w", kind, err)
}
return sub.Unsubscribe, nil
}
// ---- Prompt 控制面(core NATS request-reply + broadcast,镜像 config----
// ServePrompts 让控制面响应「取全部激活 prompt」请求;provide 返回 key→content 映射(可空)。
// 队列组:多网关副本下每个请求只由一个副本应答。
func (b *Bus) ServePrompts(provide func() map[string]string) (unsub func() error, err error) {
sub, err := b.nc.QueueSubscribe(contract.SubjectPromptsGet, contract.QueueGateway, func(m *nats.Msg) {
data, _ := json.Marshal(provide())
_ = m.Respond(data)
})
if err != nil {
return nil, fmt.Errorf("serve prompts: %w", err)
}
return sub.Unsubscribe, nil
}
// RequestActivePrompts 向控制面请求当前全部激活 prompt(key→content)。无人应答/空集返回 (nil,nil),调用方用内置默认。
func (b *Bus) RequestActivePrompts(ctx context.Context) (map[string]string, error) {
msg, err := b.nc.RequestWithContext(ctx, contract.SubjectPromptsGet, nil)
if err != nil || len(msg.Data) == 0 {
return nil, nil
}
var m map[string]string
if err := json.Unmarshal(msg.Data, &m); err != nil {
return nil, fmt.Errorf("unmarshal prompts: %w", err)
}
return m, nil
}
// RequestPromptsWithRetry 后台重试拉取初始激活 prompt 集(容忍消费方早于网关启动),拿到非空即 apply。
func (b *Bus) RequestPromptsWithRetry(ctx context.Context, apply func(map[string]string)) {
for i := 0; i < 60; i++ {
cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
m, _ := b.RequestActivePrompts(cctx)
cancel()
if len(m) > 0 {
apply(m)
return
}
select {
case <-ctx.Done():
return
case <-time.After(2 * time.Second):
}
}
}
// PublishPromptsUpdated 广播激活集变更(携带全量 key→content,消费方据此整体热更新)。
func (b *Bus) PublishPromptsUpdated(m map[string]string) error {
data, err := json.Marshal(m)
if err != nil {
return err
}
return b.nc.Publish(contract.SubjectPromptsUpdated, data)
}
// SubscribePromptsUpdated 订阅激活集变更,回调拿到全量 key→content。
func (b *Bus) SubscribePromptsUpdated(onUpdate func(map[string]string)) (unsub func() error, err error) {
sub, err := b.nc.Subscribe(contract.SubjectPromptsUpdated, func(m *nats.Msg) {
var mp map[string]string
if json.Unmarshal(m.Data, &mp) == nil {
onUpdate(mp)
}
})
if err != nil {
return nil, fmt.Errorf("subscribe prompts updated: %w", err)
}
return sub.Unsubscribe, nil
}
// TaskHandler 处理一个消费到的任务。
type TaskHandler func(ctx context.Context, t *contract.Task) error
// taskConcurrency 返回任务并发处理上限(env DISPATCHER_CONCURRENCY,默认 8)。
func taskConcurrency() int {
if v := os.Getenv("DISPATCHER_CONCURRENCY"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
}
return 8
}
// DrainTimeout 是优雅停机时等待在途工作(任务/工具调用/HTTP 请求)跑完的上限。
// 经 SHUTDOWN_DRAIN_TIMEOUT 秒配置,缺省 30s。超时即放弃等待退出(在途未 ack 由 JetStream 重投兜底)。
func DrainTimeout() time.Duration {
if v := os.Getenv("SHUTDOWN_DRAIN_TIMEOUT"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return time.Duration(n) * time.Second
}
}
return 30 * time.Second
}
// streamReplicas 是 JetStream 流/KV 桶的副本数,经 NATS_STREAM_REPLICAS 配置,缺省 1。
// 单机(单节点 NATS)保持 1;三机/单机多节点集群部署设 3,durable 流才有 Raft quorum 容错
// (计费/状态/用量/评测/审批/入库回写不会因单节点挂掉而丢)。合法值 1/3/5(Raft 需奇数)。
func streamReplicas() int {
if v := os.Getenv("NATS_STREAM_REPLICAS"); v != "" {
if n, err := strconv.Atoi(v); err == nil && (n == 1 || n == 3 || n == 5) {
return n
}
}
return 1
}
// drainWait 停止接新活后,等待在途 WaitGroup 跑完,或 dctx 到期放弃。
func drainWait(wg *sync.WaitGroup, dctx context.Context) {
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
case <-dctx.Done():
}
}
// ConsumeTasks 在持久消费者上消费任务,队列组内负载均衡。
// 每个任务分发到独立 worker goroutine 并发执行——一个慢任务/HITL 待审不再阻塞后续任务。
// 并发上限由信号量 + 消费者 MaxAckPending 双重约束(背压)。返回的 stop 用于优雅停止消费。
func (b *Bus) ConsumeTasks(ctx context.Context, h TaskHandler) (drain func(context.Context), err error) {
concurrency := taskConcurrency()
cons, err := b.js.CreateOrUpdateConsumer(ctx, contract.StreamTasks, jetstream.ConsumerConfig{
Durable: contract.ConsumerDurable,
AckPolicy: jetstream.AckExplicitPolicy,
FilterSubject: contract.SubjectTasksAll,
// HITL:审批节点会让 Handle 阻塞等人工决定(最长约 5 分钟),
// AckWait 必须覆盖「审批等待 + 图执行」总时长,否则消息在途未 ack 会被重投成重复任务。
AckWait: 15 * time.Minute,
// 在途未 ack 上限 = 并发上限:服务端不会下发超过本节点同时能处理的量(背压)。
MaxAckPending: concurrency,
})
if err != nil {
return nil, fmt.Errorf("create consumer: %w", err)
}
sem := make(chan struct{}, concurrency) // 限并发:最多 N 个任务同时执行
var wg sync.WaitGroup // 跟踪在途任务,供优雅停机 drain 等待跑完
cc, err := cons.Consume(func(msg jetstream.Msg) {
t, err := contract.Unmarshal(msg.Data())
if err != nil {
_ = msg.Term() // 脏数据,丢弃不重投
return
}
// 背压:并发已满则在此等空位;正在关停则留消息不 ack(稍后重投)。
select {
case sem <- struct{}{}:
case <-ctx.Done():
return
}
wg.Add(1)
go func() {
defer func() {
<-sem // 释放并发额度
wg.Done()
if r := recover(); r != nil {
// 任务处理 panic:丢弃不重投(避免崩溃循环),记录后继续。
log.Printf("[bus] task %s handler panic: %v", t.ID, r)
_ = msg.Term()
}
}()
// 关键:handler ctx 派生自 Background(而非信号 ctx),使「停止消费」不会立刻掐断在途任务——
// 在途任务由 drain 等待至完成;drain 超时未跑完才由 JetStream AckWait 重投兜底。
mctx := extractTrace(context.Background(), nats.Header(msg.Headers()))
mctx, span := tracer().Start(mctx, "nats.consume task",
trace.WithSpanKind(trace.SpanKindConsumer),
trace.WithAttributes(attribute.String("sundynix.task_id", t.ID)))
defer span.End()
if herr := h(mctx, t); herr != nil {
span.RecordError(herr)
_ = msg.NakWithDelay(time.Second) // 处理失败,延迟重投
return
}
_ = msg.Ack()
}()
})
if err != nil {
return nil, fmt.Errorf("consume: %w", err)
}
// drain:停止新投递,等在途任务跑完(至多 dctx)。
return func(dctx context.Context) {
cc.Stop()
drainWait(&wg, dctx)
}, nil
}
// ---- 入库工作队列(JetStream 持久,与任务流同级)----
// IngestHandler 处理一条入库作业;lastAttempt=true 表示这是最后一次投递(重投已耗尽),
// handler 应据此做终态收尾(不要再要求重试)。返回非 nil 错误 → 延迟重投(瞬时失败可恢复)。
type IngestHandler func(ctx context.Context, job *contract.IngestJob, lastAttempt bool) error
// ingestMaxDeliver 是单条入库作业的最大投递次数(含首发)。瞬时失败重投兜底,
// 到第 N 次时 handler 收到 lastAttempt=true 做终态收尾,避免毒消息无限重投。
const ingestMaxDeliver = 4
// ingestConcurrency 返回单实例入库并发上限(env INGEST_CONCURRENCY,默认 4)。
// 每篇入库内部还会并发 embedding/图谱,故此值不宜过大,避免打爆 provider 速率。
func ingestConcurrency() int {
if v := os.Getenv("INGEST_CONCURRENCY"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
}
return 4
}
// ingestAckWait 是入库作业未 ack 的最长时长(env INGEST_ACKWAIT_SEC,默认 1800s=30min)。
// 须覆盖单篇最长入库时长;崩溃在途作业在此时长后由 JetStream 重投兜底(值越小恢复越快、但越易把慢作业误判重投)。
func ingestAckWait() time.Duration {
if v := os.Getenv("INGEST_ACKWAIT_SEC"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return time.Duration(n) * time.Second
}
}
return 30 * time.Minute
}
// EnsureIngestStream 幂等地创建/更新入库作业流,持久捕获 sundynix.ingest.>。
func (b *Bus) EnsureIngestStream(ctx context.Context) error {
_, err := b.js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{
Name: contract.StreamIngest,
Subjects: []string{contract.SubjectIngestAll},
Storage: jetstream.FileStorage,
Replicas: streamReplicas(),
MaxAge: ingestStreamMaxAge,
})
return err
}
// ingestStreamMaxAge 限制入库流里消息的保留时长(已 ack 的会被清;未消费的超时丢弃),
// 防止异常堆积无限增长。30 天足以覆盖任何正常重投窗口。
const ingestStreamMaxAge = 30 * 24 * time.Hour
// PublishIngestJob 把入库作业发布到 sundynix.ingest.<job>,持久入队(崩溃不丢)。
func (b *Bus) PublishIngestJob(ctx context.Context, job *contract.IngestJob) error {
data, err := job.Marshal()
if err != nil {
return err
}
ctx, span := tracer().Start(ctx, "nats.publish ingest",
trace.WithSpanKind(trace.SpanKindProducer),
trace.WithAttributes(
attribute.String("messaging.system", "nats"),
attribute.String("messaging.destination.name", contract.IngestSubject(job.JobID)),
))
defer span.End()
msg := nats.NewMsg(contract.IngestSubject(job.JobID))
msg.Data = data
injectTrace(ctx, msg.Header)
if _, err := b.js.PublishMsg(ctx, msg); err != nil {
span.RecordError(err)
return fmt.Errorf("publish ingest job: %w", err)
}
return nil
}
// ConsumeIngestJobs 在持久消费者上消费入库作业,队列组内多副本负载均衡。
// 每条作业派到独立 worker goroutine;并发由信号量 + MaxAckPending 双重约束(背压,削峰不 OOM)。
// 失败延迟重投、网关崩溃由 AckWait 兜底重投——入库幂等(按 doc 先删后写),重跑安全。
func (b *Bus) ConsumeIngestJobs(ctx context.Context, h IngestHandler) (drain func(context.Context), err error) {
concurrency := ingestConcurrency()
cons, err := b.js.CreateOrUpdateConsumer(ctx, contract.StreamIngest, jetstream.ConsumerConfig{
Durable: contract.ConsumerIngest,
AckPolicy: jetstream.AckExplicitPolicy,
FilterSubject: contract.SubjectIngestAll,
// 一篇几十万字含切块+上千次 embedding+至多 60 次图谱抽取,可达数分钟;
// AckWait 须覆盖单篇最长入库时长,否则在途未 ack 会被重投成重复入库。
AckWait: ingestAckWait(),
MaxAckPending: concurrency, // 背压:服务端不下发超过本节点同时能处理的量
MaxDeliver: ingestMaxDeliver, // 毒消息兜底:重投上限,避免坏作业无限循环
})
if err != nil {
return nil, fmt.Errorf("create ingest consumer: %w", err)
}
sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup
cc, err := cons.Consume(func(msg jetstream.Msg) {
job, err := contract.UnmarshalIngestJob(msg.Data())
if err != nil {
_ = msg.Term() // 脏数据,丢弃不重投
return
}
select {
case sem <- struct{}{}:
case <-ctx.Done():
return
}
wg.Add(1)
go func() {
defer func() {
<-sem
wg.Done()
if r := recover(); r != nil {
log.Printf("[bus] ingest %s handler panic: %v", job.JobID, r)
_ = msg.Term() // panic 丢弃不重投,避免崩溃循环
}
}()
lastAttempt := true // 取不到投递元数据时按"最后一次"处理,宁可不重试也不死循环
if meta, merr := msg.Metadata(); merr == nil {
lastAttempt = meta.NumDelivered >= ingestMaxDeliver
}
// handler ctx 派生自 Background:停止消费不掐断在途入库,由 drain 等完 / AckWait 兜底。
mctx := extractTrace(context.Background(), nats.Header(msg.Headers()))
mctx, span := tracer().Start(mctx, "nats.consume ingest",
trace.WithSpanKind(trace.SpanKindConsumer),
trace.WithAttributes(attribute.String("sundynix.ingest_job", job.JobID)))
defer span.End()
if herr := h(mctx, job, lastAttempt); herr != nil {
span.RecordError(herr)
_ = msg.NakWithDelay(2 * time.Second) // 瞬时失败延迟重投(至多 ingestMaxDeliver 次)
return
}
_ = msg.Ack()
}()
})
if err != nil {
return nil, fmt.Errorf("consume ingest: %w", err)
}
return func(dctx context.Context) {
cc.Stop()
drainWait(&wg, dctx)
}, nil
}
// ---- JetStream KV:临时持久态(dispatcher compose checkpoint 等)----
// KVHandle 是一个 JetStream KV 桶的薄封装,把 NATS 细节(ErrKeyNotFound 等)挡在 bus 内,
// 对外暴露 Get([]byte,bool,error)/Put/Delete 的朴素键值语义——结构化满足调用方的最小接口,
// 故 bus 无需反向依赖 dispatcher/eino。键带桶级 TTL 自动过期,进程重启后值仍在(File 存储)。
type KVHandle struct{ kv jetstream.KeyValue }
// Checkpoints 打开(或创建)一个 KV 桶,键按 ttl 自动过期。
// 用于 compose checkpoint 这类"可丢但最好留"的执行态:dispatcher 重启可复活在途审批,
// 决定到达即恢复;TTL 兜底清理被遗弃的中断(如审批人始终不处理)。
func (b *Bus) Checkpoints(ctx context.Context, bucket string, ttl time.Duration) (*KVHandle, error) {
kv, err := b.js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
Bucket: bucket,
Storage: jetstream.FileStorage,
Replicas: streamReplicas(),
TTL: ttl,
})
if err != nil {
return nil, fmt.Errorf("kv bucket %q: %w", bucket, err)
}
return &KVHandle{kv: kv}, nil
}
// Get 取键值;键不存在返回 (nil,false,nil)(非错误),其余 IO 错误如实上抛。
func (h *KVHandle) Get(ctx context.Context, key string) ([]byte, bool, error) {
e, err := h.kv.Get(ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return e.Value(), true, nil
}
// Put 写入键值(覆盖)。
func (h *KVHandle) Put(ctx context.Context, key string, val []byte) error {
_, err := h.kv.Put(ctx, key, val)
return err
}
// Delete 删除键(恢复/终态后显式清理 checkpoint);键不存在视为成功(幂等)。
func (h *KVHandle) Delete(ctx context.Context, key string) error {
err := h.kv.Delete(ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil
}
return err
}