sonamu 0.11.16 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/sonamu.d.ts.map +1 -1
- package/dist/api/sonamu.js +12 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/stream/__tests__/ws-test-helpers.d.ts +108 -0
- package/dist/stream/__tests__/ws-test-helpers.d.ts.map +1 -0
- package/dist/stream/__tests__/ws-test-helpers.js +260 -0
- package/dist/stream/index.d.ts +1 -0
- package/dist/stream/index.d.ts.map +1 -1
- package/dist/stream/index.js +4 -2
- package/dist/stream/ws-audience-resolver.d.ts +8 -5
- package/dist/stream/ws-audience-resolver.d.ts.map +1 -1
- package/dist/stream/ws-audience-resolver.js +16 -13
- package/dist/stream/ws-cluster-bus.d.ts +9 -4
- package/dist/stream/ws-cluster-bus.d.ts.map +1 -1
- package/dist/stream/ws-cluster-bus.js +1 -1
- package/dist/stream/ws-core.d.ts +10 -0
- package/dist/stream/ws-core.d.ts.map +1 -1
- package/dist/stream/ws-delivery.d.ts +30 -4
- package/dist/stream/ws-delivery.d.ts.map +1 -1
- package/dist/stream/ws-delivery.js +199 -52
- package/dist/stream/ws-frame.d.ts +15 -0
- package/dist/stream/ws-frame.d.ts.map +1 -0
- package/dist/stream/ws-frame.js +29 -0
- package/dist/stream/ws-local-connection-store.d.ts +4 -4
- package/dist/stream/ws-local-connection-store.d.ts.map +1 -1
- package/dist/stream/ws-local-connection-store.js +2 -2
- package/dist/stream/ws-presence-index.d.ts +36 -0
- package/dist/stream/ws-presence-index.d.ts.map +1 -0
- package/dist/stream/ws-presence-index.js +177 -0
- package/dist/stream/ws-presence-store.d.ts +39 -39
- package/dist/stream/ws-presence-store.d.ts.map +1 -1
- package/dist/stream/ws-presence-store.js +22 -152
- package/dist/stream/ws-registry.d.ts +45 -17
- package/dist/stream/ws-registry.d.ts.map +1 -1
- package/dist/stream/ws-registry.js +169 -60
- package/dist/stream/ws-session-id.d.ts +14 -0
- package/dist/stream/ws-session-id.d.ts.map +1 -0
- package/dist/stream/ws-session-id.js +37 -0
- package/dist/stream/ws.d.ts +20 -10
- package/dist/stream/ws.d.ts.map +1 -1
- package/dist/stream/ws.js +59 -36
- package/package.json +1 -1
- package/src/api/__tests__/sonamu.websocket-routes.test.ts +234 -0
- package/src/api/__tests__/sonamu.websocket.test.ts +241 -20
- package/src/api/sonamu.ts +20 -7
- package/src/index.ts +1 -0
- package/src/stream/__tests__/ws-contracts.test.ts +483 -265
- package/src/stream/__tests__/ws-multinode.test.ts +326 -0
- package/src/stream/__tests__/ws-registry-async.test.ts +855 -0
- package/src/stream/__tests__/ws-test-helpers.ts +360 -0
- package/src/stream/__tests__/ws.test.ts +342 -145
- package/src/stream/index.ts +1 -0
- package/src/stream/ws-audience-resolver.ts +21 -19
- package/src/stream/ws-cluster-bus.ts +12 -4
- package/src/stream/ws-core.ts +14 -0
- package/src/stream/ws-delivery.ts +321 -97
- package/src/stream/ws-frame.ts +39 -0
- package/src/stream/ws-local-connection-store.ts +10 -9
- package/src/stream/ws-presence-index.ts +273 -0
- package/src/stream/ws-presence-store.ts +64 -284
- package/src/stream/ws-registry.ts +272 -85
- package/src/stream/ws-session-id.ts +39 -0
- package/src/stream/ws.ts +91 -50
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
import { createWebSocketRuntime, type WebSocketRuntime } from "../ws";
|
|
5
|
+
import { WebSocketAudience } from "../ws-audience";
|
|
6
|
+
import { InMemoryWebSocketPresenceStore } from "../ws-presence-store";
|
|
7
|
+
import {
|
|
8
|
+
FakeWebSocket,
|
|
9
|
+
RecordingClusterBus,
|
|
10
|
+
ScriptedPresenceStore,
|
|
11
|
+
asWebSocket,
|
|
12
|
+
collectUnhandledRejections,
|
|
13
|
+
createDeferred,
|
|
14
|
+
flushAsync,
|
|
15
|
+
flushUntil,
|
|
16
|
+
isSettled,
|
|
17
|
+
parseFrames,
|
|
18
|
+
} from "./ws-test-helpers";
|
|
19
|
+
|
|
20
|
+
const OutEvents = z.object({
|
|
21
|
+
onReady: z.object({ ok: z.boolean() }),
|
|
22
|
+
onSeq: z.object({ seq: z.number() }),
|
|
23
|
+
});
|
|
24
|
+
const InEvents = z.object({
|
|
25
|
+
ping: z.object({}),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
async function connect(
|
|
29
|
+
runtime: WebSocketRuntime,
|
|
30
|
+
socket: FakeWebSocket = new FakeWebSocket(),
|
|
31
|
+
options: { active?: boolean; namespace?: string } = {},
|
|
32
|
+
) {
|
|
33
|
+
const connection = await runtime.registerConnection(asWebSocket(socket), {
|
|
34
|
+
routeKey: "/chat",
|
|
35
|
+
outEvents: OutEvents,
|
|
36
|
+
inEvents: InEvents,
|
|
37
|
+
namespace: options.namespace ?? "chat",
|
|
38
|
+
active: options.active,
|
|
39
|
+
heartbeat: 0,
|
|
40
|
+
});
|
|
41
|
+
return { socket, connection };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createRuntime(input: {
|
|
45
|
+
presenceStore?: ScriptedPresenceStore | InMemoryWebSocketPresenceStore;
|
|
46
|
+
clusterBus?: RecordingClusterBus;
|
|
47
|
+
nodeId?: string;
|
|
48
|
+
drainTimeoutMs?: number;
|
|
49
|
+
}): WebSocketRuntime {
|
|
50
|
+
const runtime = createWebSocketRuntime({
|
|
51
|
+
nodeId: input.nodeId ?? "node-a",
|
|
52
|
+
presenceStore: input.presenceStore,
|
|
53
|
+
clusterBus: input.clusterBus,
|
|
54
|
+
drainTimeoutMs: input.drainTimeoutMs,
|
|
55
|
+
telemetry: true,
|
|
56
|
+
});
|
|
57
|
+
runtime.registerRoute("/chat", { outEvents: OutEvents });
|
|
58
|
+
return runtime;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function eventsNamed(runtime: WebSocketRuntime, name: string) {
|
|
62
|
+
return runtime.telemetryController.getEventStore()?.query({ name }) ?? [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function registerRemoteSession(
|
|
66
|
+
store: InMemoryWebSocketPresenceStore,
|
|
67
|
+
input: { sessionId: string; nodeId: string; rooms?: string[]; namespace?: string },
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
await store.register({
|
|
70
|
+
sessionId: input.sessionId,
|
|
71
|
+
nodeId: input.nodeId,
|
|
72
|
+
namespace: input.namespace ?? "chat",
|
|
73
|
+
active: true,
|
|
74
|
+
connectedAt: new Date(),
|
|
75
|
+
});
|
|
76
|
+
for (const roomId of input.rooms ?? []) {
|
|
77
|
+
await store.join(input.sessionId, roomId);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("WebSocketRegistry 비동기 presence", () => {
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
vi.useRealTimers();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("등록과 세션별 mutation 순서", () => {
|
|
87
|
+
it("registerConnection은 store가 register를 받은 뒤 resolve하고, 그 시점에 메모리에도 연결이 있다", async () => {
|
|
88
|
+
const store = new ScriptedPresenceStore();
|
|
89
|
+
const gate = createDeferred();
|
|
90
|
+
store.next("register", () => gate.promise);
|
|
91
|
+
const runtime = createRuntime({ presenceStore: store });
|
|
92
|
+
const socket = new FakeWebSocket();
|
|
93
|
+
|
|
94
|
+
const registering = connect(runtime, socket);
|
|
95
|
+
await flushAsync();
|
|
96
|
+
|
|
97
|
+
expect(store.callsOf("register")).toHaveLength(1);
|
|
98
|
+
expect(await isSettled(registering)).toBe(false);
|
|
99
|
+
|
|
100
|
+
gate.resolve();
|
|
101
|
+
const { connection } = await registering;
|
|
102
|
+
|
|
103
|
+
expect(runtime.registry.getConnection(connection.id)).toMatchObject({
|
|
104
|
+
sessionId: connection.id,
|
|
105
|
+
nodeId: "node-a",
|
|
106
|
+
namespace: "chat",
|
|
107
|
+
});
|
|
108
|
+
expect(store.inner.getConnection(connection.id)).toMatchObject({
|
|
109
|
+
sessionId: connection.id,
|
|
110
|
+
nodeId: "node-a",
|
|
111
|
+
namespace: "chat",
|
|
112
|
+
active: true,
|
|
113
|
+
});
|
|
114
|
+
expect(store.inner.getConnection(connection.id)?.connectedAt).toBeInstanceOf(Date);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("느린 mutation이 있어도 store는 같은 세션의 호출을 호출 순서대로 받는다", async () => {
|
|
118
|
+
const store = new ScriptedPresenceStore();
|
|
119
|
+
const runtime = createRuntime({ presenceStore: store });
|
|
120
|
+
const { connection } = await connect(runtime, new FakeWebSocket(), { active: false });
|
|
121
|
+
const gate = createDeferred();
|
|
122
|
+
store.next("join", () => gate.promise);
|
|
123
|
+
const before = store.calls.length;
|
|
124
|
+
|
|
125
|
+
const results = [
|
|
126
|
+
runtime.activateConnection(connection.id),
|
|
127
|
+
connection.join("room-1"),
|
|
128
|
+
connection.leave("room-1"),
|
|
129
|
+
connection.join("room-2"),
|
|
130
|
+
connection.setUserId(7),
|
|
131
|
+
connection.clearUserId(),
|
|
132
|
+
];
|
|
133
|
+
await flushAsync();
|
|
134
|
+
|
|
135
|
+
// 앞선 join이 끝나기 전에는 뒤 mutation이 store에 도달하지 않는다
|
|
136
|
+
expect(store.calls.slice(before)).toEqual([
|
|
137
|
+
`activate:${connection.id}`,
|
|
138
|
+
`join:${connection.id}:room-1`,
|
|
139
|
+
]);
|
|
140
|
+
// 메모리는 호출 시점에 이미 반영돼 있다
|
|
141
|
+
expect(runtime.registry.getRoomMembers("room-2", "chat")).toHaveLength(1);
|
|
142
|
+
expect(runtime.registry.getRoomMembers("room-1", "chat")).toHaveLength(0);
|
|
143
|
+
|
|
144
|
+
gate.resolve();
|
|
145
|
+
await Promise.all(results);
|
|
146
|
+
|
|
147
|
+
expect(store.calls.slice(before)).toEqual([
|
|
148
|
+
`activate:${connection.id}`,
|
|
149
|
+
`join:${connection.id}:room-1`,
|
|
150
|
+
`leave:${connection.id}:room-1`,
|
|
151
|
+
`join:${connection.id}:room-2`,
|
|
152
|
+
`setUserId:${connection.id}:7`,
|
|
153
|
+
`clearUserId:${connection.id}`,
|
|
154
|
+
]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("한 세션의 느린 mutation이 다른 세션의 mutation을 막지 않는다", async () => {
|
|
158
|
+
const store = new ScriptedPresenceStore();
|
|
159
|
+
const runtime = createRuntime({ presenceStore: store });
|
|
160
|
+
const first = await connect(runtime);
|
|
161
|
+
const second = await connect(runtime);
|
|
162
|
+
const gate = createDeferred();
|
|
163
|
+
store.next("join", () => gate.promise);
|
|
164
|
+
|
|
165
|
+
void first.connection.join("room-slow");
|
|
166
|
+
await second.connection.join("room-fast");
|
|
167
|
+
|
|
168
|
+
expect(store.calls).toContain(`join:${second.connection.id}:room-fast`);
|
|
169
|
+
gate.resolve();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("느린 register 도중 소켓이 닫히면 unregister는 register가 끝난 뒤에 호출되고 store에 아무것도 남지 않는다", async () => {
|
|
173
|
+
const store = new ScriptedPresenceStore();
|
|
174
|
+
const gate = createDeferred();
|
|
175
|
+
store.next("register", () => gate.promise);
|
|
176
|
+
const runtime = createRuntime({ presenceStore: store });
|
|
177
|
+
const socket = new FakeWebSocket();
|
|
178
|
+
|
|
179
|
+
const registering = connect(runtime, socket);
|
|
180
|
+
await flushAsync();
|
|
181
|
+
// 리스너는 동기로 붙어 있어야 등록 중 close를 관찰할 수 있다
|
|
182
|
+
socket.close(1000, "bye");
|
|
183
|
+
await flushAsync();
|
|
184
|
+
|
|
185
|
+
expect(store.callsOf("unregister")).toEqual([]);
|
|
186
|
+
|
|
187
|
+
gate.resolve();
|
|
188
|
+
const { connection } = await registering;
|
|
189
|
+
await flushUntil(() => store.callsOf("unregister").length > 0);
|
|
190
|
+
|
|
191
|
+
expect(connection.closed).toBe(true);
|
|
192
|
+
expect(store.calls).toEqual([`register:${connection.id}`, `unregister:${connection.id}`]);
|
|
193
|
+
expect(store.inner.getConnection(connection.id)).toBeUndefined();
|
|
194
|
+
expect(await store.inner.resolveRoute(WebSocketAudience.all(), "someone-else")).toEqual({
|
|
195
|
+
localSessionIds: [],
|
|
196
|
+
remoteNodeIds: [],
|
|
197
|
+
});
|
|
198
|
+
expect(runtime.registry.getConnection(connection.id)).toBeUndefined();
|
|
199
|
+
expect(runtime.registry.getConnectionCount()).toBe(0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("등록 직후 join한 뒤 닫혀도 store에 방/세션 고스트가 남지 않는다", async () => {
|
|
203
|
+
const store = new ScriptedPresenceStore();
|
|
204
|
+
const runtime = createRuntime({ presenceStore: store });
|
|
205
|
+
const gate = createDeferred();
|
|
206
|
+
const { socket, connection } = await connect(runtime);
|
|
207
|
+
store.next("join", () => gate.promise);
|
|
208
|
+
|
|
209
|
+
void connection.join("room-1");
|
|
210
|
+
void connection.setUserId("user-1");
|
|
211
|
+
socket.close(1000, "bye");
|
|
212
|
+
gate.resolve();
|
|
213
|
+
await flushUntil(() => store.callsOf("unregister").length > 0);
|
|
214
|
+
await flushAsync();
|
|
215
|
+
|
|
216
|
+
expect(store.calls.at(-1)).toBe(`unregister:${connection.id}`);
|
|
217
|
+
expect(await store.inner.getRoomMemberCount("room-1")).toBe(0);
|
|
218
|
+
expect(
|
|
219
|
+
await store.inner.resolveRoute(WebSocketAudience.user("user-1"), "someone-else"),
|
|
220
|
+
).toEqual({ localSessionIds: [], remoteNodeIds: [] });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("store.register가 reject하면 메모리 전용 등록으로 resolve하고 로컬 전달은 계속된다", async () => {
|
|
224
|
+
const store = new ScriptedPresenceStore();
|
|
225
|
+
store.next("register", () => Promise.reject(new Error("redis down")));
|
|
226
|
+
const runtime = createRuntime({ presenceStore: store });
|
|
227
|
+
const collect = collectUnhandledRejections();
|
|
228
|
+
|
|
229
|
+
const { socket, connection } = await connect(runtime);
|
|
230
|
+
void connection.join("room-1");
|
|
231
|
+
await runtime.publishToRoom("room-1", "onReady", { ok: true }, "chat");
|
|
232
|
+
await flushUntil(() => socket.sentMessages.length > 0);
|
|
233
|
+
|
|
234
|
+
expect(collect()).toEqual([]);
|
|
235
|
+
expect(connection.closed).toBe(false);
|
|
236
|
+
expect(runtime.registry.getConnection(connection.id)).toBeDefined();
|
|
237
|
+
expect(parseFrames(socket)).toEqual([{ event: "onReady", data: { ok: true } }]);
|
|
238
|
+
expect(eventsNamed(runtime, "ws.registry.mutation.failed").map((e) => e.detail)).toEqual(
|
|
239
|
+
expect.arrayContaining([expect.objectContaining({ operation: "register" })]),
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("store.register가 동기로 throw해도 registerConnection은 resolve한다", async () => {
|
|
244
|
+
const store = new ScriptedPresenceStore();
|
|
245
|
+
store.next("register", () => {
|
|
246
|
+
throw new Error("sync failure");
|
|
247
|
+
});
|
|
248
|
+
const runtime = createRuntime({ presenceStore: store });
|
|
249
|
+
|
|
250
|
+
const { connection } = await connect(runtime);
|
|
251
|
+
|
|
252
|
+
expect(connection.closed).toBe(false);
|
|
253
|
+
expect(runtime.registry.getConnectionCount("chat")).toBe(1);
|
|
254
|
+
expect(eventsNamed(runtime, "ws.registry.mutation.failed").map((e) => e.detail)).toEqual(
|
|
255
|
+
expect.arrayContaining([expect.objectContaining({ operation: "register" })]),
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("fire-and-forget 실패 처리", () => {
|
|
261
|
+
it("store mutation이 동기 throw하거나 reject해도 promise는 resolve하고 unhandledRejection이 없다", async () => {
|
|
262
|
+
const store = new ScriptedPresenceStore();
|
|
263
|
+
const runtime = createRuntime({ presenceStore: store });
|
|
264
|
+
const { socket, connection } = await connect(runtime, new FakeWebSocket(), {
|
|
265
|
+
active: false,
|
|
266
|
+
});
|
|
267
|
+
store.next("activate", () => {
|
|
268
|
+
throw new Error("sync activate failure");
|
|
269
|
+
});
|
|
270
|
+
store.next("join", () => Promise.reject(new Error("async join failure")));
|
|
271
|
+
store.next("setUserId", () => {
|
|
272
|
+
throw new Error("sync setUserId failure");
|
|
273
|
+
});
|
|
274
|
+
store.next("leave", () => Promise.reject(new Error("async leave failure")));
|
|
275
|
+
store.next("clearUserId", () => Promise.reject(new Error("async clearUserId failure")));
|
|
276
|
+
const collect = collectUnhandledRejections();
|
|
277
|
+
|
|
278
|
+
// await 없이 호출해도 안전해야 한다
|
|
279
|
+
void runtime.activateConnection(connection.id);
|
|
280
|
+
void connection.join("room-1");
|
|
281
|
+
void connection.setUserId("user-1");
|
|
282
|
+
await expect(connection.join("room-2")).resolves.toBeUndefined();
|
|
283
|
+
await expect(connection.leave("room-2")).resolves.toBeUndefined();
|
|
284
|
+
await expect(connection.clearUserId()).resolves.toBeUndefined();
|
|
285
|
+
await flushAsync(10);
|
|
286
|
+
|
|
287
|
+
expect(collect()).toEqual([]);
|
|
288
|
+
// 실패 뒤의 mutation도 순서대로 store에 도달한다
|
|
289
|
+
expect(store.calls.slice(1)).toEqual([
|
|
290
|
+
`activate:${connection.id}`,
|
|
291
|
+
`join:${connection.id}:room-1`,
|
|
292
|
+
`setUserId:${connection.id}:"user-1"`,
|
|
293
|
+
`join:${connection.id}:room-2`,
|
|
294
|
+
`leave:${connection.id}:room-2`,
|
|
295
|
+
`clearUserId:${connection.id}`,
|
|
296
|
+
]);
|
|
297
|
+
// 메모리는 store 실패와 무관하게 갱신된다
|
|
298
|
+
expect(runtime.registry.getConnectionCount("chat")).toBe(1);
|
|
299
|
+
expect(runtime.registry.getRoomMembers("room-1", "chat")).toHaveLength(1);
|
|
300
|
+
await runtime.publishToRoom("room-1", "onReady", { ok: true }, "chat");
|
|
301
|
+
await flushUntil(() => socket.sentMessages.length > 0);
|
|
302
|
+
expect(socket.sentMessages).toHaveLength(1);
|
|
303
|
+
|
|
304
|
+
const failedOperations = eventsNamed(runtime, "ws.registry.mutation.failed").map(
|
|
305
|
+
(record) => record.detail?.operation,
|
|
306
|
+
);
|
|
307
|
+
expect(failedOperations).toEqual(
|
|
308
|
+
expect.arrayContaining(["activate", "join", "setUserId", "leave", "clearUserId"]),
|
|
309
|
+
);
|
|
310
|
+
// 성공 telemetry 이름도 유지된다
|
|
311
|
+
expect(eventsNamed(runtime, "ws.connection.activated")).toHaveLength(1);
|
|
312
|
+
expect(eventsNamed(runtime, "ws.room.joined")).toHaveLength(2);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("unregister가 reject해도 unhandledRejection 없이 메모리에서 제거된다", async () => {
|
|
316
|
+
const store = new ScriptedPresenceStore();
|
|
317
|
+
const runtime = createRuntime({ presenceStore: store });
|
|
318
|
+
const { connection } = await connect(runtime);
|
|
319
|
+
store.next("unregister", () => Promise.reject(new Error("unregister failure")));
|
|
320
|
+
const collect = collectUnhandledRejections();
|
|
321
|
+
|
|
322
|
+
connection.close(1000, "done");
|
|
323
|
+
await flushAsync(10);
|
|
324
|
+
|
|
325
|
+
expect(collect()).toEqual([]);
|
|
326
|
+
expect(runtime.registry.getConnection(connection.id)).toBeUndefined();
|
|
327
|
+
expect(eventsNamed(runtime, "ws.connection.unregistered")).toHaveLength(1);
|
|
328
|
+
expect(eventsNamed(runtime, "ws.registry.mutation.failed").map((e) => e.detail)).toEqual(
|
|
329
|
+
expect.arrayContaining([expect.objectContaining({ operation: "unregister" })]),
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("없는 연결에 대한 mutation은 skipped telemetry만 남기고 store를 호출하지 않는다", async () => {
|
|
334
|
+
const store = new ScriptedPresenceStore();
|
|
335
|
+
const runtime = createRuntime({ presenceStore: store });
|
|
336
|
+
|
|
337
|
+
await runtime.activateConnection("missing");
|
|
338
|
+
await runtime.registry.join("missing", "room-1");
|
|
339
|
+
await runtime.registry.leave("missing", "room-1");
|
|
340
|
+
await runtime.registry.setUserId("missing", 1);
|
|
341
|
+
await runtime.registry.clearUserId("missing");
|
|
342
|
+
|
|
343
|
+
expect(store.calls).toEqual([]);
|
|
344
|
+
expect(eventsNamed(runtime, "ws.registry.mutation.skipped")).toHaveLength(5);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("touch(인바운드 메시지·pong)는 메모리만 갱신하고 store를 호출하지 않는다", async () => {
|
|
348
|
+
const store = new ScriptedPresenceStore();
|
|
349
|
+
const runtime = createRuntime({ presenceStore: store });
|
|
350
|
+
const { socket, connection } = await connect(runtime);
|
|
351
|
+
const handled = createDeferred();
|
|
352
|
+
connection.onMessage("ping", () => {
|
|
353
|
+
handled.resolve();
|
|
354
|
+
});
|
|
355
|
+
const before = store.calls.length;
|
|
356
|
+
|
|
357
|
+
socket.emit("message", JSON.stringify({ event: "ping", data: {} }));
|
|
358
|
+
socket.emit("pong");
|
|
359
|
+
await handled.promise;
|
|
360
|
+
await flushAsync();
|
|
361
|
+
|
|
362
|
+
expect(store.calls.slice(before)).toEqual([]);
|
|
363
|
+
expect(connection.closed).toBe(false);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe("publish", () => {
|
|
368
|
+
it("라우팅 속도가 달라도 한 노드의 publish 순서는 로컬과 원격 모두에서 유지된다", async () => {
|
|
369
|
+
const inner = new InMemoryWebSocketPresenceStore();
|
|
370
|
+
const store = new ScriptedPresenceStore(inner);
|
|
371
|
+
const clusterBus = new RecordingClusterBus();
|
|
372
|
+
const runtime = createRuntime({ presenceStore: store, clusterBus });
|
|
373
|
+
const { socket, connection } = await connect(runtime);
|
|
374
|
+
await connection.join("room-1");
|
|
375
|
+
await registerRemoteSession(inner, {
|
|
376
|
+
sessionId: "remote-1",
|
|
377
|
+
nodeId: "node-b",
|
|
378
|
+
rooms: ["room-1"],
|
|
379
|
+
});
|
|
380
|
+
const slowRoute = createDeferred();
|
|
381
|
+
store.next("resolveRoute", () => slowRoute.promise);
|
|
382
|
+
|
|
383
|
+
const first = runtime.publishToRoom("room-1", "onSeq", { seq: 1 }, "chat");
|
|
384
|
+
const second = runtime.publishToRoom("room-1", "onSeq", { seq: 2 }, "chat");
|
|
385
|
+
await flushAsync(5);
|
|
386
|
+
|
|
387
|
+
// 첫 publish의 라우팅이 끝나기 전에는 두 번째 publish가 원격으로 앞질러 나가지 않는다
|
|
388
|
+
expect(clusterBus.published).toEqual([]);
|
|
389
|
+
|
|
390
|
+
slowRoute.resolve();
|
|
391
|
+
await Promise.all([first, second]);
|
|
392
|
+
await flushUntil(() => socket.sentMessages.length >= 2);
|
|
393
|
+
|
|
394
|
+
expect(
|
|
395
|
+
clusterBus.published.map((envelope) => JSON.parse(envelope.frames["/chat"]).data),
|
|
396
|
+
).toEqual([{ seq: 1 }, { seq: 2 }]);
|
|
397
|
+
expect(parseFrames(socket).map((frame) => frame.data)).toEqual([{ seq: 1 }, { seq: 2 }]);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("connections audience는 store.resolveRoute를 호출하지 않고 세션 ID의 노드로 라우팅한다", async () => {
|
|
401
|
+
const store = new ScriptedPresenceStore();
|
|
402
|
+
const clusterBus = new RecordingClusterBus();
|
|
403
|
+
const runtime = createRuntime({ presenceStore: store, clusterBus });
|
|
404
|
+
const { socket, connection } = await connect(runtime);
|
|
405
|
+
const remote = createWebSocketRuntime({ nodeId: "node-b" });
|
|
406
|
+
remote.registerRoute("/chat", { outEvents: OutEvents });
|
|
407
|
+
const { connection: remoteConnection } = await connect(remote);
|
|
408
|
+
|
|
409
|
+
await runtime.publishToAudience(
|
|
410
|
+
WebSocketAudience.connections([connection.id, remoteConnection.id]),
|
|
411
|
+
"onReady",
|
|
412
|
+
{ ok: true },
|
|
413
|
+
);
|
|
414
|
+
await flushUntil(() => socket.sentMessages.length > 0);
|
|
415
|
+
|
|
416
|
+
expect(store.callsOf("resolveRoute")).toEqual([]);
|
|
417
|
+
expect(socket.sentMessages).toHaveLength(1);
|
|
418
|
+
expect(clusterBus.published).toHaveLength(1);
|
|
419
|
+
expect(clusterBus.published[0].targetNodeIds).toEqual(["node-b"]);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("resolveRoute가 reject하면 로컬 전달만 하고 원격은 건너뛰며 telemetry를 남기고 resolve한다", async () => {
|
|
423
|
+
const inner = new InMemoryWebSocketPresenceStore();
|
|
424
|
+
const store = new ScriptedPresenceStore(inner);
|
|
425
|
+
const clusterBus = new RecordingClusterBus();
|
|
426
|
+
const runtime = createRuntime({ presenceStore: store, clusterBus });
|
|
427
|
+
const { socket, connection } = await connect(runtime);
|
|
428
|
+
await connection.join("room-1");
|
|
429
|
+
await registerRemoteSession(inner, {
|
|
430
|
+
sessionId: "remote-1",
|
|
431
|
+
nodeId: "node-b",
|
|
432
|
+
rooms: ["room-1"],
|
|
433
|
+
});
|
|
434
|
+
store.next("resolveRoute", () => Promise.reject(new Error("route failure")));
|
|
435
|
+
store.next("resolveRoute", () => {
|
|
436
|
+
throw new Error("sync route failure");
|
|
437
|
+
});
|
|
438
|
+
const collect = collectUnhandledRejections();
|
|
439
|
+
|
|
440
|
+
await expect(
|
|
441
|
+
runtime.publishToRoom("room-1", "onReady", { ok: true }, "chat"),
|
|
442
|
+
).resolves.toBeUndefined();
|
|
443
|
+
await expect(
|
|
444
|
+
runtime.publishToRoom("room-1", "onReady", { ok: false }, "chat"),
|
|
445
|
+
).resolves.toBeUndefined();
|
|
446
|
+
await flushUntil(() => socket.sentMessages.length >= 2);
|
|
447
|
+
await flushAsync();
|
|
448
|
+
|
|
449
|
+
expect(collect()).toEqual([]);
|
|
450
|
+
expect(parseFrames(socket).map((frame) => frame.data)).toEqual([{ ok: true }, { ok: false }]);
|
|
451
|
+
expect(clusterBus.published).toEqual([]);
|
|
452
|
+
expect(eventsNamed(runtime, "ws.fanout.route.failed")).toHaveLength(2);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("clusterBus.publish가 reject해도 publish는 resolve하고 ws.cluster.envelope.failed를 남긴다", async () => {
|
|
456
|
+
const store = new InMemoryWebSocketPresenceStore();
|
|
457
|
+
const clusterBus = new RecordingClusterBus();
|
|
458
|
+
clusterBus.publish = () => Promise.reject(new Error("bus down"));
|
|
459
|
+
const runtime = createRuntime({ presenceStore: store, clusterBus });
|
|
460
|
+
await registerRemoteSession(store, {
|
|
461
|
+
sessionId: "remote-1",
|
|
462
|
+
nodeId: "node-b",
|
|
463
|
+
rooms: ["room-1"],
|
|
464
|
+
});
|
|
465
|
+
const collect = collectUnhandledRejections();
|
|
466
|
+
|
|
467
|
+
await expect(
|
|
468
|
+
runtime.publishToRoom("room-1", "onReady", { ok: true }, "chat"),
|
|
469
|
+
).resolves.toBeUndefined();
|
|
470
|
+
await flushUntil(() => eventsNamed(runtime, "ws.cluster.envelope.failed").length > 0);
|
|
471
|
+
|
|
472
|
+
expect(collect()).toEqual([]);
|
|
473
|
+
expect(eventsNamed(runtime, "ws.cluster.envelope.failed")).toHaveLength(1);
|
|
474
|
+
expect(
|
|
475
|
+
runtime.telemetryController
|
|
476
|
+
.getSpanStore()
|
|
477
|
+
?.query({ operationName: "ws.cluster.publish" })
|
|
478
|
+
.map((span) => span.status),
|
|
479
|
+
).toEqual(["error"]);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("subscribe가 reject해도 runtime은 로컬 전용으로 동작하고 telemetry를 남긴다", async () => {
|
|
483
|
+
const clusterBus = new RecordingClusterBus();
|
|
484
|
+
clusterBus.subscribe = () => Promise.reject(new Error("subscribe failure"));
|
|
485
|
+
const collect = collectUnhandledRejections();
|
|
486
|
+
const runtime = createRuntime({ clusterBus });
|
|
487
|
+
const { socket } = await connect(runtime);
|
|
488
|
+
|
|
489
|
+
await runtime.broadcast("onReady", { ok: true }, "chat");
|
|
490
|
+
await flushUntil(() => socket.sentMessages.length > 0);
|
|
491
|
+
await flushAsync();
|
|
492
|
+
|
|
493
|
+
expect(collect()).toEqual([]);
|
|
494
|
+
expect(socket.sentMessages).toHaveLength(1);
|
|
495
|
+
expect(eventsNamed(runtime, "ws.cluster.subscribe.failed")).toHaveLength(1);
|
|
496
|
+
await expect(runtime.shutdown()).resolves.toBeUndefined();
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
describe("읽기 API와 telemetry", () => {
|
|
501
|
+
it("getRoomMemberCount는 store 성공 시 클러스터 전체 수와 partial: false를 반환한다", async () => {
|
|
502
|
+
const inner = new InMemoryWebSocketPresenceStore();
|
|
503
|
+
const store = new ScriptedPresenceStore(inner);
|
|
504
|
+
const runtime = createRuntime({ presenceStore: store });
|
|
505
|
+
const { connection } = await connect(runtime);
|
|
506
|
+
await connection.join("room-1");
|
|
507
|
+
await registerRemoteSession(inner, {
|
|
508
|
+
sessionId: "remote-1",
|
|
509
|
+
nodeId: "node-b",
|
|
510
|
+
rooms: ["room-1"],
|
|
511
|
+
});
|
|
512
|
+
await registerRemoteSession(inner, {
|
|
513
|
+
sessionId: "remote-2",
|
|
514
|
+
nodeId: "node-b",
|
|
515
|
+
rooms: ["room-1"],
|
|
516
|
+
namespace: "admin",
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
await expect(runtime.registry.getRoomMemberCount("room-1", "chat")).resolves.toEqual({
|
|
520
|
+
count: 2,
|
|
521
|
+
partial: false,
|
|
522
|
+
});
|
|
523
|
+
await expect(runtime.registry.getRoomMemberCount("room-1")).resolves.toEqual({
|
|
524
|
+
count: 3,
|
|
525
|
+
partial: false,
|
|
526
|
+
});
|
|
527
|
+
// 노드 로컬 읽기는 원격 세션을 포함하지 않는다
|
|
528
|
+
expect(runtime.registry.getRoomMembers("room-1").map((meta) => meta.sessionId)).toEqual([
|
|
529
|
+
connection.id,
|
|
530
|
+
]);
|
|
531
|
+
expect(runtime.registry.getConnectionCount()).toBe(1);
|
|
532
|
+
expect(runtime.registry.getStats()).toMatchObject({
|
|
533
|
+
totalConnections: 1,
|
|
534
|
+
byNamespace: { chat: 1 },
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("getRoomMemberCount는 store가 실패하면 로컬 수와 partial: true를 반환하고 reject하지 않는다", async () => {
|
|
539
|
+
const store = new ScriptedPresenceStore();
|
|
540
|
+
const runtime = createRuntime({ presenceStore: store });
|
|
541
|
+
const first = await connect(runtime);
|
|
542
|
+
const second = await connect(runtime);
|
|
543
|
+
const inactive = await connect(runtime, new FakeWebSocket(), { active: false });
|
|
544
|
+
await first.connection.join("room-1");
|
|
545
|
+
await second.connection.join("room-1");
|
|
546
|
+
await inactive.connection.join("room-1");
|
|
547
|
+
store.next("getRoomMemberCount", () => Promise.reject(new Error("read failure")));
|
|
548
|
+
store.next("getRoomMemberCount", () => {
|
|
549
|
+
throw new Error("sync read failure");
|
|
550
|
+
});
|
|
551
|
+
const collect = collectUnhandledRejections();
|
|
552
|
+
|
|
553
|
+
await expect(runtime.registry.getRoomMemberCount("room-1", "chat")).resolves.toEqual({
|
|
554
|
+
count: 2,
|
|
555
|
+
partial: true,
|
|
556
|
+
});
|
|
557
|
+
await expect(runtime.registry.getRoomMemberCount("room-1")).resolves.toEqual({
|
|
558
|
+
count: 2,
|
|
559
|
+
partial: true,
|
|
560
|
+
});
|
|
561
|
+
await flushAsync();
|
|
562
|
+
|
|
563
|
+
expect(collect()).toEqual([]);
|
|
564
|
+
expect(eventsNamed(runtime, "ws.registry.read.failed")).toHaveLength(2);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it("telemetry collect는 동기이고 공유 store의 원격 세션을 세지 않는다", async () => {
|
|
568
|
+
const store = new InMemoryWebSocketPresenceStore();
|
|
569
|
+
const runtime = createRuntime({ presenceStore: store });
|
|
570
|
+
const { connection } = await connect(runtime);
|
|
571
|
+
await connection.join("room-1");
|
|
572
|
+
await registerRemoteSession(store, {
|
|
573
|
+
sessionId: "remote-1",
|
|
574
|
+
nodeId: "node-b",
|
|
575
|
+
rooms: ["room-1", "room-2"],
|
|
576
|
+
});
|
|
577
|
+
await registerRemoteSession(store, {
|
|
578
|
+
sessionId: "remote-2",
|
|
579
|
+
nodeId: "node-c",
|
|
580
|
+
rooms: ["room-3"],
|
|
581
|
+
namespace: "admin",
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
const snapshot = runtime.telemetryController.getMetricsSnapshot();
|
|
585
|
+
|
|
586
|
+
expect(snapshot).not.toBeInstanceOf(Promise);
|
|
587
|
+
expect(snapshot).toMatchObject({
|
|
588
|
+
activeConnections: 1,
|
|
589
|
+
activeConnectionsByNamespace: { chat: 1 },
|
|
590
|
+
roomCount: 1,
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
describe("nodeId 일관성", () => {
|
|
596
|
+
it("store나 bus의 nodeId가 runtime nodeId와 다르면 생성 시 즉시 실패한다", () => {
|
|
597
|
+
const mismatchedStore = new ScriptedPresenceStore();
|
|
598
|
+
mismatchedStore.nodeId = "node-b";
|
|
599
|
+
const mismatchedBus = new RecordingClusterBus();
|
|
600
|
+
mismatchedBus.nodeId = "node-b";
|
|
601
|
+
|
|
602
|
+
expect(() =>
|
|
603
|
+
createWebSocketRuntime({ nodeId: "node-a", presenceStore: mismatchedStore }),
|
|
604
|
+
).toThrow();
|
|
605
|
+
expect(() =>
|
|
606
|
+
createWebSocketRuntime({ nodeId: "node-a", clusterBus: mismatchedBus }),
|
|
607
|
+
).toThrow();
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("store와 bus의 nodeId가 runtime nodeId와 같으면 정상 생성된다", async () => {
|
|
611
|
+
const store = new ScriptedPresenceStore();
|
|
612
|
+
store.nodeId = "node-a";
|
|
613
|
+
const bus = new RecordingClusterBus();
|
|
614
|
+
bus.nodeId = "node-a";
|
|
615
|
+
|
|
616
|
+
const runtime = createWebSocketRuntime({
|
|
617
|
+
nodeId: "node-a",
|
|
618
|
+
presenceStore: store,
|
|
619
|
+
clusterBus: bus,
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
expect(runtime.registry.nodeId).toBe("node-a");
|
|
623
|
+
await runtime.shutdown();
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
describe("shutdown drain", () => {
|
|
628
|
+
it("shutdown은 연결을 닫고 진행 중인 mutation이 끝난 뒤 store.shutdown과 bus.shutdown을 호출한다", async () => {
|
|
629
|
+
const store = new ScriptedPresenceStore();
|
|
630
|
+
const clusterBus = new RecordingClusterBus();
|
|
631
|
+
const runtime = createRuntime({ presenceStore: store, clusterBus, drainTimeoutMs: 10_000 });
|
|
632
|
+
const { socket, connection } = await connect(runtime);
|
|
633
|
+
const gate = createDeferred();
|
|
634
|
+
store.next("unregister", () => gate.promise);
|
|
635
|
+
|
|
636
|
+
const shuttingDown = runtime.shutdown();
|
|
637
|
+
await flushAsync(5);
|
|
638
|
+
|
|
639
|
+
expect(socket.closedWith?.code).toBe(1001);
|
|
640
|
+
expect(store.callsOf("unregister")).toEqual([`unregister:${connection.id}`]);
|
|
641
|
+
expect(store.callsOf("shutdown")).toEqual([]);
|
|
642
|
+
expect(clusterBus.shutdownCalls).toBe(0);
|
|
643
|
+
|
|
644
|
+
gate.resolve();
|
|
645
|
+
await shuttingDown;
|
|
646
|
+
|
|
647
|
+
expect(store.calls.at(-1)).toBe("shutdown");
|
|
648
|
+
expect(clusterBus.shutdownCalls).toBe(1);
|
|
649
|
+
expect(clusterBus.unsubscribeCalls).toBe(1);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("shutdown은 진행 중인 publish(원격 포함)를 drain한 뒤 bus를 종료한다", async () => {
|
|
653
|
+
const inner = new InMemoryWebSocketPresenceStore();
|
|
654
|
+
const store = new ScriptedPresenceStore(inner);
|
|
655
|
+
const clusterBus = new RecordingClusterBus();
|
|
656
|
+
const runtime = createRuntime({ presenceStore: store, clusterBus, drainTimeoutMs: 10_000 });
|
|
657
|
+
await registerRemoteSession(inner, {
|
|
658
|
+
sessionId: "remote-1",
|
|
659
|
+
nodeId: "node-b",
|
|
660
|
+
rooms: ["room-1"],
|
|
661
|
+
});
|
|
662
|
+
const slowRoute = createDeferred();
|
|
663
|
+
store.next("resolveRoute", () => slowRoute.promise);
|
|
664
|
+
|
|
665
|
+
void runtime.publishToRoom("room-1", "onReady", { ok: true }, "chat");
|
|
666
|
+
const shuttingDown = runtime.shutdown();
|
|
667
|
+
await flushAsync(5);
|
|
668
|
+
|
|
669
|
+
expect(clusterBus.shutdownCalls).toBe(0);
|
|
670
|
+
|
|
671
|
+
slowRoute.resolve();
|
|
672
|
+
await shuttingDown;
|
|
673
|
+
|
|
674
|
+
expect(clusterBus.published).toHaveLength(1);
|
|
675
|
+
expect(clusterBus.shutdownCalls).toBe(1);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("끝나지 않는 store 호출이 있어도 drainTimeoutMs 뒤에 shutdown이 끝나고 store.shutdown을 호출한다", async () => {
|
|
679
|
+
const store = new ScriptedPresenceStore();
|
|
680
|
+
const clusterBus = new RecordingClusterBus();
|
|
681
|
+
const runtime = createRuntime({ presenceStore: store, clusterBus, drainTimeoutMs: 30 });
|
|
682
|
+
await connect(runtime);
|
|
683
|
+
store.next("unregister", () => new Promise<void>(() => {}));
|
|
684
|
+
|
|
685
|
+
const startedAt = Date.now();
|
|
686
|
+
await runtime.shutdown();
|
|
687
|
+
|
|
688
|
+
expect(Date.now() - startedAt).toBeLessThan(2_000);
|
|
689
|
+
expect(store.calls.at(-1)).toBe("shutdown");
|
|
690
|
+
expect(clusterBus.shutdownCalls).toBe(1);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
it("drainTimeoutMs 기본값은 5000ms다", async () => {
|
|
694
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
695
|
+
const store = new ScriptedPresenceStore();
|
|
696
|
+
const runtime = createRuntime({ presenceStore: store });
|
|
697
|
+
await connect(runtime);
|
|
698
|
+
store.next("unregister", () => new Promise<void>(() => {}));
|
|
699
|
+
|
|
700
|
+
const shuttingDown = runtime.shutdown();
|
|
701
|
+
await vi.advanceTimersByTimeAsync(4_900);
|
|
702
|
+
expect(await isSettled(shuttingDown)).toBe(false);
|
|
703
|
+
|
|
704
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
705
|
+
expect(await isSettled(shuttingDown)).toBe(true);
|
|
706
|
+
expect(store.calls.at(-1)).toBe("shutdown");
|
|
707
|
+
});
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
describe("원격 라우팅 동시성", () => {
|
|
711
|
+
// 로컬 연결 하나와 원격 노드 세션 하나가 room-1에 있고, resolveRoute는 호출마다 테스트가 제어하는 promise를 기다린다
|
|
712
|
+
async function createPendingRouteNode(count: number, drainTimeoutMs?: number) {
|
|
713
|
+
const inner = new InMemoryWebSocketPresenceStore();
|
|
714
|
+
const store = new ScriptedPresenceStore(inner);
|
|
715
|
+
const clusterBus = new RecordingClusterBus();
|
|
716
|
+
const runtime = createRuntime({ presenceStore: store, clusterBus, drainTimeoutMs });
|
|
717
|
+
const { socket, connection } = await connect(runtime);
|
|
718
|
+
await connection.join("room-1");
|
|
719
|
+
await registerRemoteSession(inner, {
|
|
720
|
+
sessionId: "remote-1",
|
|
721
|
+
nodeId: "node-b",
|
|
722
|
+
rooms: ["room-1"],
|
|
723
|
+
});
|
|
724
|
+
const routes = Array.from({ length: count }, () => createDeferred());
|
|
725
|
+
for (const route of routes) {
|
|
726
|
+
store.next("resolveRoute", () => route.promise);
|
|
727
|
+
}
|
|
728
|
+
return { store, clusterBus, runtime, socket, routes };
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// 발행된 envelope의 /chat frame에서 data만 발행 순서대로 꺼낸다
|
|
732
|
+
function remoteData(clusterBus: RecordingClusterBus) {
|
|
733
|
+
return clusterBus.published.map((envelope) => JSON.parse(envelope.frames["/chat"]).data);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
it("연속 publish의 resolveRoute는 앞선 라우팅을 기다리지 않고 동시에 시작한다", async () => {
|
|
737
|
+
const { store, clusterBus, runtime, routes } = await createPendingRouteNode(5);
|
|
738
|
+
|
|
739
|
+
const publishes = [1, 2, 3, 4, 5].map((seq) =>
|
|
740
|
+
runtime.publishToRoom("room-1", "onSeq", { seq }, "chat"),
|
|
741
|
+
);
|
|
742
|
+
await flushAsync(5);
|
|
743
|
+
|
|
744
|
+
// 어느 라우팅도 끝나기 전에 다섯 publish 모두 store 조회를 시작해야 한다 (버스트 비용이 N×T가 아닌 약 T)
|
|
745
|
+
expect(store.callsOf("resolveRoute")).toHaveLength(5);
|
|
746
|
+
expect(clusterBus.published).toEqual([]);
|
|
747
|
+
|
|
748
|
+
for (const route of routes) {
|
|
749
|
+
route.resolve();
|
|
750
|
+
}
|
|
751
|
+
await Promise.all(publishes);
|
|
752
|
+
|
|
753
|
+
expect(remoteData(clusterBus)).toEqual([
|
|
754
|
+
{ seq: 1 },
|
|
755
|
+
{ seq: 2 },
|
|
756
|
+
{ seq: 3 },
|
|
757
|
+
{ seq: 4 },
|
|
758
|
+
{ seq: 5 },
|
|
759
|
+
]);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it("라우팅이 역순으로 끝나도 envelope은 publish 순서대로만 발행된다", async () => {
|
|
763
|
+
const { clusterBus, runtime, routes } = await createPendingRouteNode(5);
|
|
764
|
+
const publishes = [1, 2, 3, 4, 5].map((seq) =>
|
|
765
|
+
runtime.publishToRoom("room-1", "onSeq", { seq }, "chat"),
|
|
766
|
+
);
|
|
767
|
+
await flushAsync(5);
|
|
768
|
+
|
|
769
|
+
// 뒤 publish의 라우팅이 먼저 끝나도 앞 publish보다 먼저 발행되면 안 된다
|
|
770
|
+
for (const index of [4, 3, 2, 1]) {
|
|
771
|
+
routes[index].resolve();
|
|
772
|
+
await flushAsync(3);
|
|
773
|
+
expect(clusterBus.published).toEqual([]);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
routes[0].resolve();
|
|
777
|
+
await Promise.all(publishes);
|
|
778
|
+
|
|
779
|
+
expect(remoteData(clusterBus)).toEqual([
|
|
780
|
+
{ seq: 1 },
|
|
781
|
+
{ seq: 2 },
|
|
782
|
+
{ seq: 3 },
|
|
783
|
+
{ seq: 4 },
|
|
784
|
+
{ seq: 5 },
|
|
785
|
+
]);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it("앞선 publish의 라우팅이 늦게 끝나도 로컬과 원격 수신자 모두 A를 B보다 먼저 받는다", async () => {
|
|
789
|
+
const { clusterBus, runtime, socket, routes } = await createPendingRouteNode(2);
|
|
790
|
+
|
|
791
|
+
const first = runtime.publishToRoom("room-1", "onSeq", { seq: 1 }, "chat");
|
|
792
|
+
const second = runtime.publishToRoom("room-1", "onSeq", { seq: 2 }, "chat");
|
|
793
|
+
routes[1].resolve();
|
|
794
|
+
await flushAsync(5);
|
|
795
|
+
expect(clusterBus.published).toEqual([]);
|
|
796
|
+
|
|
797
|
+
routes[0].resolve();
|
|
798
|
+
await Promise.all([first, second]);
|
|
799
|
+
await flushUntil(() => socket.sentMessages.length >= 2);
|
|
800
|
+
|
|
801
|
+
expect(parseFrames(socket).map((frame) => frame.data)).toEqual([{ seq: 1 }, { seq: 2 }]);
|
|
802
|
+
expect(remoteData(clusterBus)).toEqual([{ seq: 1 }, { seq: 2 }]);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it("중간 publish의 라우팅이 실패해도 나머지 envelope은 순서대로 발행되고 실패 telemetry를 남긴다", async () => {
|
|
806
|
+
const { clusterBus, runtime, routes } = await createPendingRouteNode(3);
|
|
807
|
+
const collect = collectUnhandledRejections();
|
|
808
|
+
const publishes = [1, 2, 3].map((seq) =>
|
|
809
|
+
runtime.publishToRoom("room-1", "onSeq", { seq }, "chat"),
|
|
810
|
+
);
|
|
811
|
+
await flushAsync(5);
|
|
812
|
+
|
|
813
|
+
routes[2].resolve();
|
|
814
|
+
routes[1].reject(new Error("route failure"));
|
|
815
|
+
routes[0].resolve();
|
|
816
|
+
await expect(Promise.all(publishes)).resolves.toEqual([undefined, undefined, undefined]);
|
|
817
|
+
await flushAsync();
|
|
818
|
+
|
|
819
|
+
expect(collect()).toEqual([]);
|
|
820
|
+
expect(remoteData(clusterBus)).toEqual([{ seq: 1 }, { seq: 3 }]);
|
|
821
|
+
expect(eventsNamed(runtime, "ws.fanout.route.failed")).toHaveLength(1);
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
it("resolveRoute가 끝나지 않아도 로컬 전달은 지연되지 않는다", async () => {
|
|
825
|
+
const { clusterBus, runtime, socket } = await createPendingRouteNode(2);
|
|
826
|
+
|
|
827
|
+
const first = runtime.publishToRoom("room-1", "onSeq", { seq: 1 }, "chat");
|
|
828
|
+
const second = runtime.publishToRoom("room-1", "onSeq", { seq: 2 }, "chat");
|
|
829
|
+
await flushUntil(() => socket.sentMessages.length >= 2);
|
|
830
|
+
|
|
831
|
+
expect(parseFrames(socket).map((frame) => frame.data)).toEqual([{ seq: 1 }, { seq: 2 }]);
|
|
832
|
+
expect(clusterBus.published).toEqual([]);
|
|
833
|
+
// 원격 라우팅이 끝나지 않았으므로 publish promise는 아직 settle되지 않는다
|
|
834
|
+
expect(await isSettled(first)).toBe(false);
|
|
835
|
+
expect(await isSettled(second)).toBe(false);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it("라우팅이 끝나지 않는 publish가 여러 개 있어도 shutdown은 drainTimeoutMs 안에 끝난다", async () => {
|
|
839
|
+
const { store, clusterBus, runtime } = await createPendingRouteNode(5, 30);
|
|
840
|
+
const publishes = [1, 2, 3, 4, 5].map((seq) =>
|
|
841
|
+
runtime.publishToRoom("room-1", "onSeq", { seq }, "chat"),
|
|
842
|
+
);
|
|
843
|
+
await flushAsync(3);
|
|
844
|
+
|
|
845
|
+
const startedAt = Date.now();
|
|
846
|
+
await runtime.shutdown();
|
|
847
|
+
|
|
848
|
+
expect(Date.now() - startedAt).toBeLessThan(2_000);
|
|
849
|
+
expect(clusterBus.published).toEqual([]);
|
|
850
|
+
expect(store.calls.at(-1)).toBe("shutdown");
|
|
851
|
+
expect(clusterBus.shutdownCalls).toBe(1);
|
|
852
|
+
expect(publishes).toHaveLength(5);
|
|
853
|
+
});
|
|
854
|
+
});
|
|
855
|
+
});
|