Skip to content

Commit f53edc4

Browse files
committed
feat(sdk/python+js): 入站双车道迁移——业务洪峰下心跳/注册/drain 永可达
按 Go MuxConn 基准完成双车道迁移一期(Python + JS): - 旧状(两语言共通):入站单队列有界 worker 池,业务 handler 洪峰打满 队列时心跳被 fail-fast 拒绝 → Agent 判定会话死亡 → 过载升级为连接雪崩 - Python:protocol.is_control_request(对齐 Go controlRequests 集合)+ transport/tcp.py 控制车道单 worker(永不拒绝),close 时随连接释放 - JS:protocol.ts isControlRequest + tcp_transport.ts 独立控制队列与 单并发 loop,业务车道有界 fail-fast 语义不变 - 测试(两语言同口径):打满业务车道(慢 handler 占住 worker + 队列)后, 心跳仍须在 2s 内得到响应;对照断言新业务请求被 fail-fast 回空帧 - 文档:sdk-wire-protocol 双车道落地清单更新(Java/C++/C# 待迁移) 已知边界(诚实清单):声明式超时(描述符 Behavior.TimeoutMs)执行层 接线未做——元数据注册表当前为路由注册期临时 Store,不在调用链上, 需契约列 + 迁移 0016 + 导入管线改造,作为独立交付排期。 验证:python 489 passed / js 350 passed(含新增双车道饱和用例), docs build 通过。
1 parent 9925743 commit f53edc4

7 files changed

Lines changed: 403 additions & 4 deletions

File tree

docs/architecture/sdk-wire-protocol.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ context deadline 的天然 min 语义):
149149
已知边界:
150150

151151
- `overloaded` / `retry_after_ms` / `too_many_inflight` 等显式过载信号字段尚未定义 wire 消息;当前唯一过载信号是业务队列满时的错误 payload 文案
152-
- 双车道目前落地于 Go 侧`MuxConn` + Go SDK)Python/Java/JS/C++/C# 的入站仍为单队列有界 worker 池(心跳与业务共队列),待按 Go 基准迁移
152+
- 双车道已落地:Go`MuxConn` + Go SDK)Python`transport/tcp.py` 控制车道单 worker,永不拒绝)、JS(`tcp_transport.ts` 控制队列 + 单并发 loop)。Java/C++/C# 的入站仍为单队列有界 worker 池(心跳与业务共队列),待按 Go 基准迁移
153153

154154
### 双车道:控制消息优先级
155155

sdks/js/src/dual_lane.test.ts

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
/**
2+
* 双车道派发(控制优先级):业务洪峰下心跳依然可达。
3+
*
4+
* 回归背景:业务/控制共队列时,业务 handler 洪峰打满队列 → 心跳被
5+
* fail-fast 拒绝 → Agent 判定会话死亡 → 过载升级为连接雪崩。
6+
* 双车道后控制消息(心跳/注册/drain)走独立车道,永不拒绝。
7+
*/
8+
9+
import { TCPTransport, TCPTransportConfig } from "./tcp_transport";
10+
import {
11+
MSG_INVOKE_REQUEST,
12+
MSG_INVOKE_RESPONSE,
13+
MSG_PROVIDER_HEARTBEAT_REQUEST,
14+
MSG_PROVIDER_HEARTBEAT_RESPONSE,
15+
MSG_PROVIDER_DRAIN_REQUEST,
16+
MSG_PROVIDER_CONNECT_REQUEST,
17+
MSG_REGISTER_REQUEST,
18+
MSG_REGISTER_CAPABILITIES_REQ,
19+
isControlRequest,
20+
} from "./protocol";
21+
22+
const VERSION = 0x01;
23+
24+
function encodeMessage(msgId: number, reqId: number, body: Buffer): Buffer {
25+
const header = Buffer.alloc(8);
26+
header.writeUInt8(VERSION, 0);
27+
header.writeUIntBE(msgId, 1, 3);
28+
header.writeUInt32BE(reqId, 4);
29+
return Buffer.concat([header, body]);
30+
}
31+
32+
function frame(payload: Buffer): Buffer {
33+
const prefix = Buffer.alloc(4);
34+
prefix.writeUInt32BE(payload.length, 0);
35+
return Buffer.concat([prefix, payload]);
36+
}
37+
38+
describe("dual-lane inbound dispatch", () => {
39+
it("classifies control vs business messages", () => {
40+
for (const id of [
41+
MSG_PROVIDER_HEARTBEAT_REQUEST,
42+
MSG_PROVIDER_CONNECT_REQUEST,
43+
MSG_PROVIDER_DRAIN_REQUEST,
44+
MSG_REGISTER_REQUEST,
45+
MSG_REGISTER_CAPABILITIES_REQ,
46+
]) {
47+
expect(isControlRequest(id)).toBe(true);
48+
}
49+
for (const id of [
50+
MSG_INVOKE_REQUEST,
51+
MSG_INVOKE_RESPONSE,
52+
0x030103, // start task
53+
0x030107, // cancel task
54+
]) {
55+
expect(isControlRequest(id)).toBe(false);
56+
}
57+
});
58+
59+
it("answers heartbeats while the business lane is saturated", async () => {
60+
const server = await startFakeServer();
61+
const { address, sockets, send, nextFrame, close } = server;
62+
63+
const t = new TCPTransport({
64+
address,
65+
inboundWorkers: 1,
66+
});
67+
let businessStarted = false;
68+
t.setHandler((msgId, _reqId, _body) => {
69+
if (msgId === MSG_PROVIDER_HEARTBEAT_REQUEST) {
70+
return Buffer.from("pong");
71+
}
72+
businessStarted = true;
73+
return new Promise<Buffer>((resolve) => {
74+
// 占住唯一业务 worker 3 秒:队列随即打满
75+
setTimeout(() => resolve(Buffer.alloc(0)), 3000);
76+
});
77+
});
78+
await t.connect();
79+
80+
try {
81+
// 打满业务车道(worker 1 + 队列 4 → 连发 6 个业务请求)
82+
for (let reqId = 101; reqId <= 106; reqId++) {
83+
send(MSG_INVOKE_REQUEST, reqId, Buffer.from("x"));
84+
}
85+
await waitFor(() => businessStarted);
86+
87+
// 洪峰中的心跳:控制车道应立即处理
88+
const start = Date.now();
89+
send(MSG_PROVIDER_HEARTBEAT_REQUEST, 900, Buffer.alloc(0));
90+
const resp = await nextFrame(
91+
(f) => f.msgId === MSG_PROVIDER_HEARTBEAT_RESPONSE,
92+
2000,
93+
);
94+
expect(resp).not.toBeNull();
95+
expect(resp?.reqId).toBe(900);
96+
expect(resp?.body.toString()).toBe("pong");
97+
expect(Date.now() - start).toBeLessThan(2000);
98+
99+
// 对照:业务队列满 → 新业务请求被 fail-fast 回空帧
100+
send(MSG_INVOKE_REQUEST, 901, Buffer.from("x"));
101+
const busy = await nextFrame(
102+
(f) => f.msgId === MSG_INVOKE_RESPONSE && f.reqId === 901,
103+
2000,
104+
);
105+
expect(busy).not.toBeNull();
106+
expect(busy?.body.length).toBe(0);
107+
} finally {
108+
t.close();
109+
close();
110+
}
111+
expect(sockets.length).toBeGreaterThanOrEqual(0);
112+
});
113+
});
114+
115+
import { createServer, Server, Socket } from "net";
116+
117+
interface DecodedFrame {
118+
msgId: number;
119+
reqId: number;
120+
body: Buffer;
121+
}
122+
123+
type FakeServer = {
124+
address: string;
125+
sockets: Socket[];
126+
send: (msgId: number, reqId: number, body: Buffer) => void;
127+
nextFrame: (
128+
predicate: (f: DecodedFrame) => boolean,
129+
timeoutMs: number,
130+
) => Promise<DecodedFrame | null>;
131+
close: () => Promise<void>;
132+
};
133+
134+
function startFakeServer(): Promise<FakeServer> {
135+
return new Promise((resolve) => {
136+
const server: Server = createServer();
137+
const sockets: Socket[] = [];
138+
const frames: DecodedFrame[] = [];
139+
const waiters: Array<{
140+
predicate: (f: DecodedFrame) => boolean;
141+
resolve: (f: DecodedFrame | null) => void;
142+
timer: ReturnType<typeof setTimeout>;
143+
}> = [];
144+
let buffer = Buffer.alloc(0);
145+
let address = "";
146+
147+
server.on("connection", (socket) => {
148+
sockets.push(socket);
149+
socket.on("data", (chunk: Buffer) => {
150+
buffer = Buffer.concat([buffer, chunk]);
151+
for (;;) {
152+
if (buffer.length < 4) return;
153+
const size = buffer.readUInt32BE(0);
154+
if (buffer.length < 4 + size) return;
155+
const payload = buffer.subarray(4, 4 + size);
156+
buffer = buffer.subarray(4 + size);
157+
if (payload.length >= 8) {
158+
const decoded: DecodedFrame = {
159+
msgId: payload.readUIntBE(1, 3),
160+
reqId: payload.readUInt32BE(4),
161+
body: Buffer.from(payload.subarray(8)),
162+
};
163+
const idx = waiters.findIndex((w) => w.predicate(decoded));
164+
if (idx >= 0) {
165+
const [w] = waiters.splice(idx, 1);
166+
clearTimeout(w.timer);
167+
w.resolve(decoded);
168+
} else {
169+
frames.push(decoded);
170+
}
171+
}
172+
}
173+
});
174+
});
175+
176+
server.listen(0, "127.0.0.1", () => {
177+
const addr = server.address();
178+
if (addr && typeof addr === "object") {
179+
address = `127.0.0.1:${addr.port}`;
180+
}
181+
resolve({
182+
address,
183+
sockets,
184+
send: (msgId, reqId, body) => {
185+
const raw = frame(encodeMessage(msgId, reqId, body));
186+
for (const s of sockets) s.write(raw);
187+
},
188+
nextFrame: (predicate, timeoutMs) => {
189+
const idx = frames.findIndex(predicate);
190+
if (idx >= 0) {
191+
const [f] = frames.splice(idx, 1);
192+
return Promise.resolve(f);
193+
}
194+
return new Promise((resolve) => {
195+
const timer = setTimeout(() => {
196+
const i = waiters.findIndex((w) => w.resolve === resolve);
197+
if (i >= 0) waiters.splice(i, 1);
198+
resolve(null);
199+
}, timeoutMs);
200+
waiters.push({ predicate, resolve, timer });
201+
});
202+
},
203+
close: () =>
204+
new Promise((done) => {
205+
for (const s of sockets) s.destroy();
206+
server.close(() => done());
207+
}),
208+
});
209+
});
210+
});
211+
}
212+
213+
function waitFor(cond: () => boolean, timeoutMs = 2000): Promise<void> {
214+
const start = Date.now();
215+
return new Promise((resolve, reject) => {
216+
const tick = () => {
217+
if (cond()) return resolve();
218+
if (Date.now() - start > timeoutMs) {
219+
return reject(new Error("condition not met in time"));
220+
}
221+
setTimeout(tick, 10);
222+
};
223+
tick();
224+
});
225+
}

sdks/js/src/protocol.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,28 @@ export function getResponseMsgId(reqMsgId: number): number {
144144
return reqMsgId + 1;
145145
}
146146

147+
/**
148+
* 会话/传输控制消息集合(对齐 Go pkg/protocol.IsControlRequest):
149+
* 心跳、注册、drain 走独立派发车道,业务洪峰打满业务队列时控制面
150+
* 依然可达——否则心跳被 fail-fast 拒绝 → 对端判定会话死亡 → 过载
151+
* 升级为连接雪崩(见 docs/architecture/sdk-wire-protocol.md 双车道)。
152+
*/
153+
const CONTROL_REQUESTS: ReadonlySet<number> = new Set<number>([
154+
MSG_REGISTER_REQUEST,
155+
MSG_HEARTBEAT_REQUEST,
156+
MSG_REGISTER_CAPABILITIES_REQ,
157+
MSG_REGISTER_CLIENT_REQUEST,
158+
MSG_CLIENT_HEARTBEAT_REQUEST,
159+
MSG_PROVIDER_CONNECT_REQUEST,
160+
MSG_PROVIDER_HEARTBEAT_REQUEST,
161+
MSG_PROVIDER_DRAIN_REQUEST,
162+
]);
163+
164+
/** 控制消息(心跳/注册/drain)走独立车道,永不 fail-fast。 */
165+
export function isControlRequest(msgId: number): boolean {
166+
return CONTROL_REQUESTS.has(msgId);
167+
}
168+
147169
/**
148170
* Get human-readable string for MsgID.
149171
*/

sdks/js/src/tcp_transport.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
createConnection,
2020
TcpSocketConnectOpts,
2121
} from "net";
22-
import { getResponseMsgId } from "./protocol";
22+
import { getResponseMsgId, isControlRequest } from "./protocol";
2323

2424
/** Frame constants */
2525
const FRAME_HEADER_BYTES = 4; // 4-byte big-endian length prefix
@@ -138,6 +138,9 @@ export class TCPTransport {
138138
private inboundQueue: Array<{ msgId: number; reqId: number; body: Buffer }> = [];
139139
private inboundWorkersRunning = 0;
140140
private inboundWorkerLimit = 0;
141+
// 控制车道:心跳/注册/drain 独立队列 + 单并发(双车道,对齐 Go MuxConn)
142+
private controlQueue: Array<{ msgId: number; reqId: number; body: Buffer }> = [];
143+
private controlWorkerRunning = false;
141144

142145
constructor(config: TCPTransportConfig = {}) {
143146
this.inboundWorkerLimit =
@@ -383,8 +386,20 @@ export class TCPTransport {
383386
});
384387
}
385388

386-
/** 读循环只投递:固定并发消费 handler,队列满立即回 busy。 */
389+
/** 读循环只投递:双车道派发(对齐 Go MuxConn)。
390+
391+
* - 控制消息(心跳/注册/drain)→ 独立车道,永不拒绝
392+
* - 业务消息 → 固定并发消费 handler,队列满立即回 busy(failover)
393+
*/
387394
private dispatchInbound(msgId: number, reqId: number, body: Buffer): void {
395+
if (isControlRequest(msgId)) {
396+
this.controlQueue.push({ msgId, reqId, body });
397+
if (!this.controlWorkerRunning) {
398+
this.controlWorkerRunning = true;
399+
void this.controlWorkerLoop();
400+
}
401+
return;
402+
}
388403
const capacity = this.inboundWorkerLimit * 4;
389404
if (this.inboundQueue.length >= capacity) {
390405
// 队列满:立即回空/错误响应,Agent 侧 failover 接管。
@@ -398,6 +413,17 @@ export class TCPTransport {
398413
}
399414
}
400415

416+
private async controlWorkerLoop(): Promise<void> {
417+
for (;;) {
418+
const task = this.controlQueue.shift();
419+
if (!task) {
420+
this.controlWorkerRunning = false;
421+
return;
422+
}
423+
await this.handleInbound(task.msgId, task.reqId, task.body);
424+
}
425+
}
426+
401427
private async inboundWorkerLoop(): Promise<void> {
402428
for (;;) {
403429
const task = this.inboundQueue.shift();

sdks/python/croupier/protocol.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,29 @@ def is_response(msg_id: int) -> bool:
189189
return msg_id % 2 == 0 and msg_id not in (MSG_TASK_EVENT, MSG_METRIC_EVENT)
190190

191191

192+
# 会话/传输控制消息集合(对齐 Go pkg/protocol.IsControlRequest):
193+
# 心跳、注册、drain 走独立派发车道,业务洪峰打满业务队列时控制面
194+
# 依然可达——否则心跳被 fail-fast 拒绝 → 对端判定会话死亡 → 过载
195+
# 升级为连接雪崩(见 docs/architecture/sdk-wire-protocol.md 双车道)。
196+
CONTROL_REQUESTS = frozenset(
197+
{
198+
MSG_REGISTER_REQUEST,
199+
MSG_HEARTBEAT_REQUEST,
200+
MSG_REGISTER_CAPABILITIES_REQ,
201+
MSG_REGISTER_CLIENT_REQUEST,
202+
MSG_CLIENT_HEARTBEAT_REQUEST,
203+
MSG_PROVIDER_CONNECT_REQUEST,
204+
MSG_PROVIDER_HEARTBEAT_REQUEST,
205+
MSG_PROVIDER_DRAIN_REQUEST,
206+
}
207+
)
208+
209+
210+
def is_control_request(msg_id: int) -> bool:
211+
"""控制消息(心跳/注册/drain)走独立车道,永不 fail-fast。"""
212+
return msg_id in CONTROL_REQUESTS
213+
214+
192215
def get_response_msg_id(req_msg_id: int) -> int:
193216
"""Get the response MsgID for a given request MsgID."""
194217
return req_msg_id + 1

0 commit comments

Comments
 (0)