Files
sundynix-agentix/sundynix-shared/bus/bus_e2e_test.go
T
Blizzard 53669437d5 perf(tools): 工具服务单实例并发(ServeTool 协程化)+ mcp-py 同改
垂直扩:单个 mcp 实例内每个工具调用分发到独立 goroutine/task 并发处理,
不再受 NATS 单订阅回调串行所限;配合队列组多副本即「单实例并发 × 副本数」横向扩。

- bus.ServeTool:回调立即起 goroutine 并返回(不阻塞 NATS 投递),信号量限并发
  (MCP_TOOL_CONCURRENCY,默认 16),handler panic → 回错误结果避免调用方干等超时。
- mcp-py mcp_gateway:_on_call 改 asyncio.create_task 派发 + Semaphore 限并发(同默认 16)。
- 测试 TestConcurrentToolServe:slow 工具阻塞时 fast 工具仍返回(旧串行下会超时)。

验证:单测通过;mcp-go/mcp-py live 重启工具就绪,工具往返正常(memory/history/kb_search
均正常响应,无 panic)。全模块 build+vet+test 全绿。

注:下游共享后端(Milvus/Neo4j/PG/嵌入端点)是横向扩的最终上限,届时扩这些而非 mcp 实例。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:40:50 +08:00

309 lines
8.6 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_test
import (
"context"
"encoding/json"
"testing"
"time"
natsserver "github.com/nats-io/nats-server/v2/server"
natstest "github.com/nats-io/nats-server/v2/test"
"github.com/sundynix/sundynix-shared/bus"
"github.com/sundynix/sundynix-shared/contract"
)
// startEmbeddedNATS 启动一个内嵌、开启 JetStream 的 NATS 服务器,免 Docker。
func startEmbeddedNATS(t *testing.T) string {
t.Helper()
opts := natstest.DefaultTestOptions
opts.Port = -1 // 随机端口
opts.JetStream = true
opts.StoreDir = t.TempDir()
srv := natstest.RunServer(&opts)
if !srv.ReadyForConnections(5 * time.Second) {
t.Fatal("embedded NATS not ready")
}
t.Cleanup(srv.Shutdown)
_ = natsserver.Server{} // 触发包引用
return srv.ClientURL()
}
// TestTaskRoundTrip 模拟 Gateway 发布 → NATS → Dispatcher 消费 的完整任务流。
func TestTaskRoundTrip(t *testing.T) {
url := startEmbeddedNATS(t)
// --- Gateway 侧:连接并声明任务流 ---
gw, err := bus.Connect(url)
if err != nil {
t.Fatalf("gateway connect: %v", err)
}
defer gw.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := gw.EnsureTaskStream(ctx); err != nil {
t.Fatalf("ensure stream: %v", err)
}
// --- Dispatcher 侧:连接并开始消费 ---
dp, err := bus.Connect(url)
if err != nil {
t.Fatalf("dispatcher connect: %v", err)
}
defer dp.Close()
got := make(chan *contract.Task, 1)
stop, err := dp.ConsumeTasks(ctx, func(_ context.Context, task *contract.Task) error {
got <- task
return nil
})
if err != nil {
t.Fatalf("consume: %v", err)
}
defer stop()
// --- Gateway 发布一个任务 ---
want := &contract.Task{
ID: "task_demo_001",
Graph: json.RawMessage(`{"nodes":[{"id":"n1","type":"agent"}],"edges":[]}`),
Meta: map[string]any{"user": "wt"},
}
seq, err := gw.PublishTask(ctx, want)
if err != nil {
t.Fatalf("publish: %v", err)
}
if seq == 0 {
t.Fatal("expected non-zero stream sequence")
}
// --- 断言 Dispatcher 收到同一个任务 ---
select {
case task := <-got:
if task.ID != want.ID {
t.Fatalf("task id = %q, want %q", task.ID, want.ID)
}
if task.Meta["user"] != "wt" {
t.Fatalf("task meta lost: %+v", task.Meta)
}
t.Logf("✓ 任务流打通:Gateway publish (seq=%d) → NATS → Dispatcher consumetask_id=%s", seq, task.ID)
case <-time.After(5 * time.Second):
t.Fatal("timeout: dispatcher 未收到任务")
}
}
// TestToolCallRoundTrip 模拟 Dispatcher 经 NATS 调用 → mcp-go 响应 的工具调用闭环。
func TestToolCallRoundTrip(t *testing.T) {
url := startEmbeddedNATS(t)
// --- mcp-go 侧:以队列组订阅工具主题并响应 ---
srv, err := bus.Connect(url)
if err != nil {
t.Fatalf("mcp connect: %v", err)
}
defer srv.Close()
unsub, err := srv.ServeTool(contract.SubjectToolsGoAll, contract.QueueToolsGo,
func(_ context.Context, call *contract.ToolCall) *contract.ToolResult {
if call.Tool != "wiki_search" {
return &contract.ToolResult{OK: false, Error: "unknown tool"}
}
return &contract.ToolResult{OK: true, Content: "命中:" + call.Args["q"].(string)}
})
if err != nil {
t.Fatalf("serve tool: %v", err)
}
defer func() { _ = unsub() }()
// --- Dispatcher 侧:同步调用工具 ---
dp, err := bus.Connect(url)
if err != nil {
t.Fatalf("dispatcher connect: %v", err)
}
defer dp.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
res, err := dp.CallTool(ctx, contract.ToolSubjectGo("wiki_search"), &contract.ToolCall{
Tool: "wiki_search",
TaskID: "task_tool_001",
Args: map[string]any{"q": "向量检索"},
})
if err != nil {
t.Fatalf("call tool: %v", err)
}
if !res.OK || res.Content != "命中:向量检索" {
t.Fatalf("tool result = %+v, want ok content=命中:向量检索", res)
}
t.Logf("✓ 工具调用闭环:Dispatcher → sundynix.tools.go.wiki_search → mcp-go → %q", res.Content)
}
// TestTokenStreamRoundTrip 模拟 Dispatcher 回流 Token → Gateway 订阅 的流式闭环。
func TestTokenStreamRoundTrip(t *testing.T) {
url := startEmbeddedNATS(t)
// Gateway 侧:先订阅(core NATS 无持久化,须先连)。
gw, err := bus.Connect(url)
if err != nil {
t.Fatalf("gateway connect: %v", err)
}
defer gw.Close()
const taskID = "task_stream_001"
var got []string
done := make(chan struct{})
unsub, err := gw.SubscribeTokens(taskID,
func(tok []byte) { got = append(got, string(tok)) },
func() { close(done) },
)
if err != nil {
t.Fatalf("subscribe tokens: %v", err)
}
defer func() { _ = unsub() }()
// Dispatcher 侧:逐 Token 回流后发结束信号。
dp, err := bus.Connect(url)
if err != nil {
t.Fatalf("dispatcher connect: %v", err)
}
defer dp.Close()
want := []string{"Hello", " ", "Agent", "!"}
for _, tok := range want {
if err := dp.PublishToken(taskID, []byte(tok)); err != nil {
t.Fatalf("publish token: %v", err)
}
}
if err := dp.CompleteStream(taskID); err != nil {
t.Fatalf("complete stream: %v", err)
}
select {
case <-done:
joined := ""
for _, s := range got {
joined += s
}
if joined != "Hello Agent!" {
t.Fatalf("token stream = %q, want %q", joined, "Hello Agent!")
}
t.Logf("✓ Token 流闭环:Dispatcher 回流 %d 个 token → Gateway 拼回 %q", len(got), joined)
case <-time.After(5 * time.Second):
t.Fatal("timeout: 未收到流结束信号")
}
}
// TestConcurrentConsume 验证并发消费:一个长时间阻塞的任务(模拟 HITL 待审)
// 不应阻塞后续任务——旧的串行消费下本测试会超时失败。
func TestConcurrentConsume(t *testing.T) {
url := startEmbeddedNATS(t)
gw, err := bus.Connect(url)
if err != nil {
t.Fatalf("gateway connect: %v", err)
}
defer gw.Close()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := gw.EnsureTaskStream(ctx); err != nil {
t.Fatalf("ensure stream: %v", err)
}
dp, err := bus.Connect(url)
if err != nil {
t.Fatalf("dispatcher connect: %v", err)
}
defer dp.Close()
blockA := make(chan struct{})
doneB := make(chan string, 1)
stop, err := dp.ConsumeTasks(ctx, func(_ context.Context, task *contract.Task) error {
switch task.ID {
case "A":
<-blockA // 模拟审批长阻塞
case "B":
doneB <- task.ID
}
return nil
})
if err != nil {
t.Fatalf("consume: %v", err)
}
defer stop()
defer close(blockA)
// 先发 A 并等它被消费、卡在 handler 里;再发 B。
if _, err := gw.PublishTask(ctx, &contract.Task{ID: "A", Graph: json.RawMessage(`{}`)}); err != nil {
t.Fatalf("publish A: %v", err)
}
time.Sleep(400 * time.Millisecond)
if _, err := gw.PublishTask(ctx, &contract.Task{ID: "B", Graph: json.RawMessage(`{}`)}); err != nil {
t.Fatalf("publish B: %v", err)
}
select {
case id := <-doneB:
if id != "B" {
t.Fatalf("unexpected task done: %q", id)
}
t.Log("✓ 并发消费生效:A 仍阻塞时 B 已完成")
case <-time.After(4 * time.Second):
t.Fatal("B 被阻塞的 A 卡住——并发消费未生效(仍是串行)")
}
}
// TestConcurrentToolServe 验证单实例工具服务并发:一个慢工具调用不阻塞另一个快调用。
// 旧的串行 ServeTool 下本测试会超时失败。
func TestConcurrentToolServe(t *testing.T) {
url := startEmbeddedNATS(t)
srv, err := bus.Connect(url)
if err != nil {
t.Fatalf("server connect: %v", err)
}
defer srv.Close()
blockSlow := make(chan struct{})
unsub, err := srv.ServeTool(contract.SubjectToolsGoAll, contract.QueueToolsGo,
func(_ context.Context, call *contract.ToolCall) *contract.ToolResult {
if call.Tool == "slow" {
<-blockSlow // 模拟慢工具(如 RAG 嵌入)
}
return &contract.ToolResult{OK: true, Content: call.Tool}
})
if err != nil {
t.Fatalf("serve tool: %v", err)
}
defer unsub()
defer close(blockSlow)
cli, err := bus.Connect(url)
if err != nil {
t.Fatalf("client connect: %v", err)
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// 先发 slow(会阻塞在 handler 里),再发 fast——fast 应在 slow 仍阻塞时返回。
go func() {
_, _ = cli.CallTool(ctx, contract.ToolSubjectGo("slow"), &contract.ToolCall{Tool: "slow"})
}()
time.Sleep(300 * time.Millisecond)
fctx, fcancel := context.WithTimeout(ctx, 3*time.Second)
defer fcancel()
res, err := cli.CallTool(fctx, contract.ToolSubjectGo("fast"), &contract.ToolCall{Tool: "fast"})
if err != nil {
t.Fatalf("fast 工具被慢工具阻塞——单实例并发未生效: %v", err)
}
if res.Content != "fast" {
t.Fatalf("unexpected result: %q", res.Content)
}
t.Log("✓ 单实例工具并发生效:slow 仍阻塞时 fast 已返回")
}