Skip to content

Commit 9925743

Browse files
committed
feat(core): GM 同步调用超时端到端贯通 + OTel SDK 一期传播
核心链路两个正确性/可观测性缺口(依据项目自身已知边界清单): P0-1 超时贯通(正确性缺陷): - 旧状:Server 派发 15s(可配),Agent 硬编码 10s 倒挂,且调用方声明的 超时全程无人理会;描述符契约 timeout_ms(默认 30s)在执行层是死字段 - 约定 metadata["timeout_ms"](wire 文档新增小节):HTTP timeoutMs → metadata 注入 → dispatcher clamp [1s,60s] 收紧 ctx(Go deadline min 语义)→ agent clamp [1s,配置上限] 与默认取小 - Agent 默认对齐 15s + agent.invokeTimeoutMs 可配(替代硬编码 10s) - 测试:dispatcher clamp 矩阵、agent 慢 provider 端到端(真实 TCP session, 声明 1.2s 预算 → 预算内超时;默认 15s → 2.5s 慢调用成功)、HTTP 注入 P0-2 OTel SDK 一期(Go/Python/JS,无 otel 依赖传播): - Go:WithTraceMetadata 注入 invoke/startTask handler ctx + TraceParentFromContext/TraceIDFromContext - Python:croupier.trace 读取辅助(context JSON 已随 metadata 透传) - JS:trace.ts 辅助 + InvokerResult.traceId 透出(对齐 Go) - 文档:SDK 现状表更新为一期完成(span 创建为后续增量,标注诚实边界) 顺带:function/openapi 82.5%→86.4%(schema mapper 边界分支) 验证:go test ./internal/... ./cmd/... 全绿;go/python/js SDK 测试全绿 (go 9.1s / python 487 passed / js 348 passed);docs build 通过。
1 parent c2c2612 commit 9925743

22 files changed

Lines changed: 625 additions & 29 deletions

File tree

cmd/agent/root.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,10 @@ type AgentInfoConfig struct {
288288
LocalAddr string `json:"localAddr" yaml:"localAddr"`
289289
HTTPAddr string `json:"httpAddr" yaml:"httpAddr"`
290290
Labels map[string]string `json:"labels" yaml:"labels"`
291+
// InvokeTimeoutMs 是 Agent → Provider 同步调用的默认预算(毫秒)。
292+
// 请求 metadata["timeout_ms"] 声明更小值时取更小者;默认 15000
293+
// (对齐 Server 派发层);上限 60000。
294+
InvokeTimeoutMs int `json:"invokeTimeoutMs,omitempty" yaml:"invokeTimeoutMs,omitempty"`
291295
}
292296

293297
func (c *AgentInfoConfig) UnmarshalYAML(value *yaml.Node) error {
@@ -550,6 +554,8 @@ func startAgentCore(ctx context.Context, c *AgentConfig, configDir string) (*age
550554
core := agentcore.NewWithConfigDir(strings.TrimSpace(c.Server.Addr), agentID, configDir)
551555
core.SetLocalAddr(localAddr)
552556
core.SetUpstreamTransportKind(strings.TrimSpace(c.Server.Transport))
557+
// Agent → Provider 同步调用预算(默认 15s,可经 agent.invokeTimeoutMs 配置)
558+
core.SetProviderCallTimeout(time.Duration(c.Agent.InvokeTimeoutMs) * time.Millisecond)
553559
core.WithUpstreamMetadata(agentcore.UpstreamMetadata{
554560
GameID: strings.TrimSpace(c.Agent.GameID),
555561
Env: strings.TrimSpace(c.Agent.Env),

docs/architecture/sdk-otel-propagation.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -188,14 +188,19 @@ docker run --rm -p 16686:16686 -p 4318:4318 jaegertracing/all-in-one:latest
188188

189189
## SDK 现状(六语言)
190190

191-
| 语言 | 响应透出 traceId | 请求注入 traceparent | Provider 提取延续 | 备注 |
192-
| ------ | ------------------- | -------------------------------------------- | ----------------- | ------------------------ |
193-
| Go | ✅(DTO `TraceID`||| 一期 |
194-
| Python |||| 一期 |
195-
| JS |||| 一期 |
196-
| Java |||| 二期按需 |
197-
| C# |||| 二期按需 |
198-
| C++ | 部分(task 状态) | 部分(`trace_id` 明文注入 metadata,非 W3C) || `InvokeOptions.trace_id` |
191+
| 语言 | 响应透出 traceId | 请求注入 traceparent | Provider 提取延续 | 备注 |
192+
| ------ | ---------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------ |
193+
| Go | ✅(DTO `TraceID`|| ✅ 一期(ctx 注入 `WithTraceMetadata` + `TraceParentFromContext`/`TraceIDFromContext`| 一期 |
194+
| Python | ✅(invoker `trace_id`|| ✅ 一期(context JSON 随 metadata 透传 + `croupier.trace` 读取辅助) | 一期 |
195+
| JS | ✅(`InvokeResult.traceId`|| ✅ 一期(context JSON 随 metadata 透传 + `traceParentFromContext`/`traceIdFromContext`| 一期 |
196+
| Java |||| 二期按需 |
197+
| C# |||| 二期按需 |
198+
| C++ | 部分(task 状态) | 部分(`trace_id` 明文注入 metadata,非 W3C) || `InvokeOptions.trace_id` |
199+
200+
一期口径说明:Provider 侧"提取延续"当前为**无 otel 依赖的传播**——trace
201+
字段进入 handler 上下文(Go 为 context value,Python/JS 为 context JSON
202+
字段)供游戏方读取与日志关联;以 otel API 创建服务端 span(`sdk.invoke`
203+
仍属后续增量(需引入各语言 otel 库依赖,见下节"为什么 SDK 只做传播")。
199204

200205
平台侧不依赖 SDK 是否实现传播:metadata 无 `traceparent` 时各层自动开新
201206
trace,行为与现状完全一致(**零侵入**)。

docs/architecture/sdk-wire-protocol.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,22 @@ v1 不引入独立 `Magic`,而是直接用首条应用层消息识别子协议
120120
- `responseMsgID = requestMsgID + 1` 仍是默认约定
121121
-`TaskEvent` 这样的单向事件消息不属于标准 request/response 配对
122122

123+
### 同步调用超时:metadata `timeout_ms` 约定
124+
125+
调用方声明的一次同步调用预算(毫秒,字符串十进制),放在 invoke 请求的
126+
metadata map 中端到端传播,各跳取 **min(本跳配置, 声明值)** 生效(Go
127+
context deadline 的天然 min 语义):
128+
129+
|| 行为 |
130+
| ----------- | ------------------------------------------------------------------------------------------------------- |
131+
| HTTP API | 请求体 `timeoutMs`(可选)→ 注入 `metadata["timeout_ms"]` |
132+
| Server 派发 | `requestTimeoutBudget`:clamp [1000, 60000],收紧 ctx;全局默认 15s 只作上限 |
133+
| Agent | `providerCallDeadline`:clamp [1000, Agent 配置上限],与默认(`agent.invokeTimeoutMs`,默认 15000)取小 |
134+
135+
- 垃圾值/缺失 → 各跳沿用自身默认,不报错(零侵入)
136+
- 同步通道硬上限 60s:更长操作应走异步任务(事件流语义)
137+
- 语义修正:Agent 侧旧实现硬编码 10s 与 Server 默认 15s 倒挂,现已对齐并可配
138+
123139
## 过载反馈与背压现状
124140

125141
按连接角色分层,当前实现状态如下(双车道已落地,见下节):

docs/operations/config-agent.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,14 @@ tag:
2626

2727
### agent(身份与可达性)
2828

29-
|| 默认 | 说明 |
30-
| ---------------------------- | --------------- | -------------------------------------------------------------------------------------- |
31-
| `agent.id` | `""` | 留空自动生成;多 Agent 部署建议显式指定(拓扑页可读性) |
32-
| `agent.gameId` / `agent.env` | `""` | 作用域绑定;留空由注册的游戏服声明 |
33-
| `agent.localAddr` | `0.0.0.0:19091` | 本地 TCP 监听(游戏服函数注册入口) |
34-
| `agent.httpAddr` | 必填 | **Server→Agent 回调地址**,必须是 Server 视角可达的地址(容器网络用服务名,裸机用 IP) |
35-
| `agent.labels` | `{}` | 自定义标签(机房/机型等,节点页过滤用) |
29+
|| 默认 | 说明 |
30+
| ---------------------------- | --------------- | ---------------------------------------------------------------------------------------------------- |
31+
| `agent.id` | `""` | 留空自动生成;多 Agent 部署建议显式指定(拓扑页可读性) |
32+
| `agent.gameId` / `agent.env` | `""` | 作用域绑定;留空由注册的游戏服声明 |
33+
| `agent.localAddr` | `0.0.0.0:19091` | 本地 TCP 监听(游戏服函数注册入口) |
34+
| `agent.httpAddr` | 必填 | **Server→Agent 回调地址**,必须是 Server 视角可达的地址(容器网络用服务名,裸机用 IP) |
35+
| `agent.labels` | `{}` | 自定义标签(机房/机型等,节点页过滤用) |
36+
| `agent.invokeTimeoutMs` | `15000` | Agent→游戏服同步调用默认预算(毫秒);请求 `metadata["timeout_ms"]` 声明更小值时取更小者,上限 60000 |
3637

3738
> `httpAddr` 是最常见的部署错误:写 `0.0.0.0``localhost` 会导致 Server 回调失败——job 下发/文件传输走不通。
3839

internal/agent/local_handler.go

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"log/slog"
99
"net"
10+
"strconv"
1011
"strings"
1112
"sync"
1213
"time"
@@ -66,7 +67,11 @@ type LocalHandler struct {
6667
agentID string
6768
expectedGameID string // Agent 配置的 gameId,用于校验 SDK 注册
6869
expectedEnv string // Agent 配置的 env,用于校验 SDK 注册
69-
mu sync.RWMutex
70+
// providerCallTimeout 是 Agent → Provider 同步调用的默认预算;
71+
// 请求 metadata["timeout_ms"] 声明更小值时取更小者(Go deadline
72+
// min 语义)。此前硬编码 10s 与 Server 派发层 15s 倒挂。
73+
providerCallTimeout time.Duration
74+
mu sync.RWMutex
7075
}
7176

7277
// SetProviderSessionStore enables callback over the Provider's established
@@ -87,12 +92,56 @@ func NewLocalHandler(store *agentlocal.LocalStore, configDir, agentID string, lo
8792
logger: logger,
8893
configDir: configDir,
8994
agentID: agentID,
95+
// 默认对齐 Server 派发层 invokeTimeout(15s),消除旧 10s<15s 倒挂。
96+
providerCallTimeout: 15 * time.Second,
9097
}
9198
// TaskRunner executes tasks via the handler's invoke path.
9299
h.tasks = NewTaskRunner(h.executeTask, nil, logger)
93100
return h
94101
}
95102

103+
// SetProviderCallTimeout 配置 Agent → Provider 同步调用默认预算。
104+
// 非正值回落默认 15s;上限 60s(同步通道边界,更长操作应走异步任务)。
105+
func (h *LocalHandler) SetProviderCallTimeout(d time.Duration) {
106+
h.mu.Lock()
107+
defer h.mu.Unlock()
108+
if d <= 0 {
109+
d = 15 * time.Second
110+
}
111+
if d > 60*time.Second {
112+
d = 60 * time.Second
113+
}
114+
h.providerCallTimeout = d
115+
}
116+
117+
// providerCallDeadline 计算本次 Provider 调用的超时:请求 metadata 声明的
118+
// timeout_ms(有效范围 [1s, 配置上限],clamp)与配置默认取更小者。
119+
// 垃圾值/缺失 → 配置默认。
120+
func (h *LocalHandler) providerCallDeadline(meta map[string]string) time.Duration {
121+
h.mu.RLock()
122+
def := h.providerCallTimeout
123+
h.mu.RUnlock()
124+
if def <= 0 {
125+
def = 15 * time.Second
126+
}
127+
raw := strings.TrimSpace(meta["timeout_ms"])
128+
if raw == "" {
129+
return def
130+
}
131+
ms, err := strconv.ParseInt(raw, 10, 64)
132+
if err != nil || ms <= 0 {
133+
return def
134+
}
135+
budget := time.Duration(ms) * time.Millisecond
136+
if budget < time.Second {
137+
budget = time.Second
138+
}
139+
if budget > def {
140+
budget = def
141+
}
142+
return budget
143+
}
144+
96145
// SetProviderManager sets the provider manager
97146
func (h *LocalHandler) SetProviderManager(pm ProviderManager) {
98147
h.mu.Lock()
@@ -224,7 +273,7 @@ func (h *LocalHandler) handleInvoke(ctx context.Context, data []byte) ([]byte, e
224273
}
225274
span.SetAttributes(attribute.String("provider.addr", addr))
226275

227-
callCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
276+
callCtx, cancel := context.WithTimeout(ctx, h.providerCallDeadline(req.GetMetadata()))
228277
defer cancel()
229278

230279
reqBytes, err := proto.Marshal(req)
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package agent
2+
3+
import (
4+
"context"
5+
"net"
6+
"testing"
7+
"time"
8+
9+
agentlocal "github.com/cuihairu/croupier/internal/platform/agentlocal"
10+
transportcore "github.com/cuihairu/croupier/internal/transport"
11+
tcptr "github.com/cuihairu/croupier/internal/transport/tcp"
12+
sdkv1 "github.com/cuihairu/croupier/pkg/pb/croupier/sdk/v1"
13+
"github.com/cuihairu/croupier/pkg/protocol"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
"google.golang.org/protobuf/proto"
17+
)
18+
19+
// providerCallDeadline:metadata timeout_ms clamp 与默认值语义。
20+
func TestProviderCallDeadlineSemantics(t *testing.T) {
21+
h := NewLocalHandler(nil, "/tmp", "agent-1", nil)
22+
23+
// 默认 15s(对齐 Server 派发层,替代旧硬编码 10s)
24+
assert.Equal(t, 15*time.Second, h.providerCallDeadline(nil))
25+
assert.Equal(t, 15*time.Second, h.providerCallDeadline(map[string]string{"timeout_ms": "garbage"}))
26+
27+
// 请求声明更小值 → 生效
28+
assert.Equal(t, 3*time.Second, h.providerCallDeadline(map[string]string{"timeout_ms": "3000"}))
29+
// 低于 1s 提到 1s;高于配置默认 → clamp 回默认
30+
assert.Equal(t, time.Second, h.providerCallDeadline(map[string]string{"timeout_ms": "50"}))
31+
assert.Equal(t, 15*time.Second, h.providerCallDeadline(map[string]string{"timeout_ms": "999999"}))
32+
33+
// 配置自定义默认:请求声明可小于配置,不可超过
34+
h.SetProviderCallTimeout(2 * time.Second)
35+
assert.Equal(t, 2*time.Second, h.providerCallDeadline(nil))
36+
assert.Equal(t, 1500*time.Millisecond, h.providerCallDeadline(map[string]string{"timeout_ms": "1500"}))
37+
assert.Equal(t, 2*time.Second, h.providerCallDeadline(map[string]string{"timeout_ms": "30000"}))
38+
39+
// 非法配置回落默认;超上限截断
40+
h.SetProviderCallTimeout(-1)
41+
assert.Equal(t, 15*time.Second, h.providerCallDeadline(nil))
42+
h.SetProviderCallTimeout(5 * time.Minute)
43+
assert.Equal(t, 60*time.Second, h.providerCallDeadline(nil))
44+
}
45+
46+
// 端到端:慢 provider 在 metadata 声明的预算内未完成 → 调用方拿到超时错误。
47+
func TestHandleInvokeRespectsMetadataTimeout(t *testing.T) {
48+
listener, err := NewTCPLocalListener(&TCPLocalListenerConfig{Address: "127.0.0.1:0"}, nil, nil)
49+
require.NoError(t, err)
50+
t.Cleanup(func() { require.NoError(t, listener.Close()) })
51+
52+
ctx, cancel := context.WithCancel(context.Background())
53+
t.Cleanup(cancel)
54+
go func() { _ = listener.Serve(ctx) }()
55+
56+
conn, err := net.Dial("tcp", listener.Addr())
57+
require.NoError(t, err)
58+
provider := tcptr.NewMuxConn(conn, nil, transportcore.HandlerFunc(
59+
func(_ context.Context, msgID uint32, _ uint32, body []byte) ([]byte, error) {
60+
if msgID == protocol.MsgInvokeRequest {
61+
req := &sdkv1.InvokeRequest{}
62+
require.NoError(t, proto.Unmarshal(body, req))
63+
// 慢于声明的 1200ms 预算
64+
time.Sleep(2500 * time.Millisecond)
65+
return proto.Marshal(&sdkv1.InvokeResponse{Payload: []byte(`{}`)})
66+
}
67+
return nil, nil
68+
}))
69+
go func() { _ = provider.Run(ctx) }()
70+
t.Cleanup(func() { _ = provider.Close() })
71+
72+
connectBody, err := proto.Marshal(&sdkv1.ProviderConnectRequest{
73+
ServiceId: "slow-provider",
74+
Version: "1.0.0",
75+
Functions: []*sdkv1.ProviderFunctionDescriptor{{Id: "slow.fn", Version: "1.0.0"}},
76+
})
77+
require.NoError(t, err)
78+
_, _, err = provider.Call(ctx, protocol.MsgProviderConnectRequest, connectBody)
79+
require.NoError(t, err)
80+
81+
session, ok := listener.SessionStore().GetByServiceID("slow-provider")
82+
require.True(t, ok)
83+
require.NotNil(t, session.Conn())
84+
85+
// 生产装配形态:session 连接回调把函数注册进 local store(与
86+
// app.go SetOnConnect 相同),pickInstance 才能路由到该函数。
87+
store := agentlocal.NewLocalStore()
88+
listener.SetOnConnect(func(sess *ProviderSession) {
89+
store.Register(sess.SessionID, sess.ServiceID, sess.Conn().RemoteAddr(), sess.Version, sess.Functions, nil)
90+
})
91+
// 重新握手触发 onConnect(上面的注册发生在 SetOnConnect 之前)
92+
conn2, err := net.Dial("tcp", listener.Addr())
93+
require.NoError(t, err)
94+
provider2 := tcptr.NewMuxConn(conn2, nil, transportcore.HandlerFunc(
95+
func(_ context.Context, msgID uint32, _ uint32, _ []byte) ([]byte, error) {
96+
if msgID == protocol.MsgInvokeRequest {
97+
time.Sleep(2500 * time.Millisecond)
98+
return proto.Marshal(&sdkv1.InvokeResponse{Payload: []byte(`{}`)})
99+
}
100+
return nil, nil
101+
}))
102+
go func() { _ = provider2.Run(ctx) }()
103+
t.Cleanup(func() { _ = provider2.Close() })
104+
_, _, err = provider2.Call(ctx, protocol.MsgProviderConnectRequest, connectBody)
105+
require.NoError(t, err)
106+
107+
h := NewLocalHandler(store, "/tmp", "agent-1", nil)
108+
h.SetProviderSessionStore(listener.SessionStore())
109+
110+
// 1) 声明 1200ms 预算,provider 睡 2.5s → 必须在预算内超时
111+
invokeBody, err := proto.Marshal(&sdkv1.InvokeRequest{
112+
FunctionId: "slow.fn",
113+
Payload: []byte(`{}`),
114+
Metadata: map[string]string{"timeout_ms": "1200"},
115+
})
116+
require.NoError(t, err)
117+
118+
start := time.Now()
119+
_, err = h.Handle(ctx, protocol.MsgInvokeRequest, 1, invokeBody)
120+
require.Error(t, err, "调用必须超时")
121+
elapsed := time.Since(start)
122+
assert.Less(t, elapsed, 2300*time.Millisecond, "应在声明的 ~1.2s 预算内失败")
123+
assert.GreaterOrEqual(t, elapsed, 1100*time.Millisecond, "不应早于预算误伤")
124+
125+
// 2) 不声明预算 → 走默认 15s,provider 2.5s 正常返回成功
126+
invokeBody2, err := proto.Marshal(&sdkv1.InvokeRequest{
127+
FunctionId: "slow.fn",
128+
Payload: []byte(`{}`),
129+
})
130+
require.NoError(t, err)
131+
resp, err := h.Handle(ctx, protocol.MsgInvokeRequest, 2, invokeBody2)
132+
require.NoError(t, err, "默认预算下慢 provider 应成功")
133+
assert.NotEmpty(t, resp)
134+
}

internal/api/function/dto.go

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -151,16 +151,19 @@ type FunctionInstancesResponse struct {
151151

152152
// FunctionInvokeRequest represents a request to invoke a function
153153
type FunctionInvokeRequest struct {
154-
ID string `uri:"id"`
155-
Params json.RawMessage `json:"params,omitempty"`
156-
Payload json.RawMessage `json:"payload,omitempty"`
157-
GameID string `json:"gameId"`
158-
Env string `json:"env"`
159-
Mode string `json:"mode"`
160-
Route string `json:"route"`
161-
TargetServiceID string `json:"targetServiceId"`
162-
HashKey string `json:"hashKey"`
163-
Metadata map[string]string `json:"-"`
154+
ID string `uri:"id"`
155+
Params json.RawMessage `json:"params,omitempty"`
156+
Payload json.RawMessage `json:"payload,omitempty"`
157+
GameID string `json:"gameId"`
158+
Env string `json:"env"`
159+
Mode string `json:"mode"`
160+
Route string `json:"route"`
161+
TargetServiceID string `json:"targetServiceId"`
162+
HashKey string `json:"hashKey"`
163+
// TimeoutMs 声明本次同步调用的超时预算(毫秒,[1000,60000] 越界
164+
// clamp)。仅 sync 模式有意义;async 任务有自己的生命周期。
165+
TimeoutMs int `json:"timeoutMs,omitempty"`
166+
Metadata map[string]string `json:"-"`
164167
}
165168

166169
// FunctionInvokeResponse represents the response of a function invocation

internal/api/function/helpers.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"sort"
8+
"strconv"
89
"strings"
910
"time"
1011

@@ -346,6 +347,11 @@ func functionInvoke(ctx context.Context, svcCtx *svc.ServiceContext, req *Functi
346347
if req.Mode == "async" {
347348
metadata["async"] = "true"
348349
}
350+
// 调用方声明的同步调用预算(毫秒)→ 约定键 timeout_ms,端到端各跳
351+
// 取 min 生效(dispatcher/agent clamp 到 [1s, 上限])。
352+
if req.TimeoutMs > 0 {
353+
metadata["timeout_ms"] = strconv.Itoa(req.TimeoutMs)
354+
}
349355
if gameID := strings.TrimSpace(req.GameID); gameID != "" {
350356
metadata["game_id"] = gameID
351357
}

0 commit comments

Comments
 (0)