@gencow/core 0.1.24 → 0.1.26
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/crud.d.ts +2 -2
- package/dist/crud.js +225 -208
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/reactive.js +10 -3
- package/dist/retry.js +1 -1
- package/dist/rls-db.d.ts +2 -2
- package/dist/rls-db.js +1 -5
- package/dist/scheduler.d.ts +2 -0
- package/dist/scheduler.js +16 -6
- package/dist/server.d.ts +0 -1
- package/dist/server.js +0 -1
- package/dist/storage.js +29 -22
- package/dist/v.d.ts +2 -2
- package/dist/workflow.js +4 -11
- package/dist/workflows-api.js +5 -12
- package/package.json +45 -42
- package/src/__tests__/auth.test.ts +90 -86
- package/src/__tests__/crons.test.ts +69 -67
- package/src/__tests__/crud-codegen-integration.test.ts +164 -170
- package/src/__tests__/crud-owner-rls.test.ts +308 -301
- package/src/__tests__/crud.test.ts +694 -711
- package/src/__tests__/dist-exports.test.ts +120 -120
- package/src/__tests__/fixtures/basic/auth.ts +16 -16
- package/src/__tests__/fixtures/basic/drizzle.config.ts +1 -4
- package/src/__tests__/fixtures/basic/index.ts +1 -1
- package/src/__tests__/fixtures/basic/schema.ts +1 -1
- package/src/__tests__/fixtures/basic/tasks.ts +4 -4
- package/src/__tests__/fixtures/common/auth-schema.ts +38 -34
- package/src/__tests__/helpers/basic-rls-fixture.ts +80 -78
- package/src/__tests__/helpers/pglite-migrations.ts +2 -5
- package/src/__tests__/helpers/pglite-rls-session.ts +13 -16
- package/src/__tests__/helpers/seed-like-fill.ts +47 -41
- package/src/__tests__/helpers/test-gencow-ctx-rls.ts +4 -7
- package/src/__tests__/httpaction.test.ts +91 -91
- package/src/__tests__/image-optimization.test.ts +570 -574
- package/src/__tests__/load.test.ts +321 -308
- package/src/__tests__/network-sim.test.ts +238 -215
- package/src/__tests__/reactive.test.ts +380 -358
- package/src/__tests__/retry.test.ts +99 -84
- package/src/__tests__/rls-crud-basic.test.ts +172 -245
- package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +81 -81
- package/src/__tests__/rls-custom-mutation-handlers.test.ts +47 -94
- package/src/__tests__/rls-custom-query-handlers.test.ts +92 -92
- package/src/__tests__/rls-db-leased-connection.test.ts +2 -6
- package/src/__tests__/rls-session-and-policies.test.ts +181 -199
- package/src/__tests__/scheduler-durable-v2.test.ts +199 -181
- package/src/__tests__/scheduler-durable.test.ts +117 -117
- package/src/__tests__/scheduler-exec.test.ts +258 -246
- package/src/__tests__/scheduler.test.ts +129 -111
- package/src/__tests__/storage.test.ts +282 -269
- package/src/__tests__/tsconfig.json +6 -6
- package/src/__tests__/validator.test.ts +236 -232
- package/src/__tests__/workflow.test.ts +309 -286
- package/src/__tests__/ws-integration.test.ts +223 -218
- package/src/__tests__/ws-scale.test.ts +168 -159
- package/src/auth-config.ts +18 -18
- package/src/auth.ts +106 -106
- package/src/crons.ts +77 -77
- package/src/crud.ts +523 -479
- package/src/index.ts +69 -5
- package/src/reactive.ts +357 -331
- package/src/retry.ts +51 -54
- package/src/rls-db.ts +195 -205
- package/src/rls.ts +33 -36
- package/src/scheduler.ts +237 -211
- package/src/server.ts +0 -1
- package/src/storage.ts +632 -593
- package/src/v.ts +119 -114
- package/src/workflow-types.ts +67 -70
- package/src/workflow.ts +99 -116
- package/src/workflows-api.ts +231 -241
- package/dist/db.d.ts +0 -13
- package/dist/db.js +0 -16
- package/src/db.ts +0 -18
|
@@ -7,370 +7,383 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, it, expect } from "bun:test";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
subscribe,
|
|
13
|
-
registerClient,
|
|
14
|
-
deregisterClient,
|
|
15
|
-
} from "../reactive";
|
|
16
|
-
import type { GencowCtx } from "../reactive";
|
|
10
|
+
import { buildRealtimeCtx, subscribe, registerClient, deregisterClient } from "../reactive.js";
|
|
11
|
+
import type { GencowCtx } from "../reactive.js";
|
|
17
12
|
|
|
18
13
|
// ─── Mock WSContext ───────────────────────────────────────────────────────────
|
|
19
14
|
|
|
20
15
|
function makeMockWs(id = 0) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
16
|
+
let sendCount = 0;
|
|
17
|
+
let lastPayloadBytes = 0;
|
|
18
|
+
return {
|
|
19
|
+
send: (msg: string) => {
|
|
20
|
+
sendCount++;
|
|
21
|
+
lastPayloadBytes = msg.length;
|
|
22
|
+
},
|
|
23
|
+
readyState: 1,
|
|
24
|
+
id,
|
|
25
|
+
get sendCount() {
|
|
26
|
+
return sendCount;
|
|
27
|
+
},
|
|
28
|
+
get lastPayloadBytes() {
|
|
29
|
+
return lastPayloadBytes;
|
|
30
|
+
},
|
|
31
|
+
} as any;
|
|
33
32
|
}
|
|
34
33
|
|
|
35
34
|
// 메시지 타입을 추적하는 WS (비교 테스트용)
|
|
36
35
|
function makeTrackedWs(id = 0) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
36
|
+
type Msg = { type: string; hasData: boolean };
|
|
37
|
+
const received: Msg[] = [];
|
|
38
|
+
return {
|
|
39
|
+
send: (msg: string) => {
|
|
40
|
+
const parsed = JSON.parse(msg);
|
|
41
|
+
received.push({
|
|
42
|
+
type: parsed.type,
|
|
43
|
+
hasData: parsed.data !== undefined,
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
readyState: 1,
|
|
47
|
+
id,
|
|
48
|
+
get received() {
|
|
49
|
+
return received;
|
|
50
|
+
},
|
|
51
|
+
get sendCount() {
|
|
52
|
+
return received.length;
|
|
53
|
+
},
|
|
54
|
+
} as any;
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
const wait = (ms: number) => new Promise(r => setTimeout(r, ms));
|
|
57
|
+
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
55
58
|
|
|
56
59
|
function makeUniqueKey(prefix: string) {
|
|
57
|
-
|
|
60
|
+
return `${prefix}.${Math.random().toString(36).slice(2)}`;
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
// ─── 1. 다수 구독자 throughput ────────────────────────────────────────────────
|
|
61
64
|
|
|
62
65
|
describe("[Load] 다수 구독자 emit 처리량", () => {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
66
|
+
it("1,000명 구독자에게 emit이 150ms 이내에 완료된다", async () => {
|
|
67
|
+
const N = 1_000;
|
|
68
|
+
const key = makeUniqueKey("load1k");
|
|
69
|
+
const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
|
|
70
|
+
clients.forEach((ws) => subscribe(key, ws));
|
|
71
|
+
|
|
72
|
+
const rt = buildRealtimeCtx();
|
|
73
|
+
const start = performance.now();
|
|
74
|
+
rt.emit(key, [{ id: 1, title: "Task 1" }]);
|
|
75
|
+
await wait(60);
|
|
76
|
+
const elapsed = performance.now() - start;
|
|
77
|
+
|
|
78
|
+
console.log(`[throughput] 1,000 subscribers → ${elapsed.toFixed(1)}ms`);
|
|
79
|
+
expect(clients.filter((ws) => ws.sendCount === 1).length).toBe(N);
|
|
80
|
+
expect(elapsed).toBeLessThan(150);
|
|
81
|
+
clients.forEach((ws) => deregisterClient(ws));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("5,000명 구독자에게 emit이 300ms 이내에 완료된다", async () => {
|
|
85
|
+
const N = 5_000;
|
|
86
|
+
const key = makeUniqueKey("load5k");
|
|
87
|
+
const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
|
|
88
|
+
clients.forEach((ws) => subscribe(key, ws));
|
|
89
|
+
|
|
90
|
+
const rt = buildRealtimeCtx();
|
|
91
|
+
const start = performance.now();
|
|
92
|
+
rt.emit(key, [{ id: 1 }]);
|
|
93
|
+
await wait(60);
|
|
94
|
+
const elapsed = performance.now() - start;
|
|
95
|
+
|
|
96
|
+
console.log(`[throughput] 5,000 subscribers → ${elapsed.toFixed(1)}ms`);
|
|
97
|
+
expect(clients.filter((ws) => ws.sendCount === 1).length).toBe(N);
|
|
98
|
+
expect(elapsed).toBeLessThan(300);
|
|
99
|
+
clients.forEach((ws) => deregisterClient(ws));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("10,000명 구독자에게 emit이 500ms 이내에 완료된다", async () => {
|
|
103
|
+
const N = 10_000;
|
|
104
|
+
const key = makeUniqueKey("load10k");
|
|
105
|
+
const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
|
|
106
|
+
clients.forEach((ws) => subscribe(key, ws));
|
|
107
|
+
|
|
108
|
+
const rt = buildRealtimeCtx();
|
|
109
|
+
const start = performance.now();
|
|
110
|
+
rt.emit(key, [{ id: 1 }]);
|
|
111
|
+
await wait(60);
|
|
112
|
+
const elapsed = performance.now() - start;
|
|
113
|
+
|
|
114
|
+
console.log(`[throughput] 10,000 subscribers → ${elapsed.toFixed(1)}ms`);
|
|
115
|
+
expect(clients.filter((ws) => ws.sendCount === 1).length).toBe(N);
|
|
116
|
+
expect(elapsed).toBeLessThan(500);
|
|
117
|
+
clients.forEach((ws) => deregisterClient(ws));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("50,000명 구독자에게 emit이 2,000ms 이내에 완료된다", async () => {
|
|
121
|
+
const N = 50_000;
|
|
122
|
+
const key = makeUniqueKey("load50k");
|
|
123
|
+
const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
|
|
124
|
+
clients.forEach((ws) => subscribe(key, ws));
|
|
125
|
+
|
|
126
|
+
const rt = buildRealtimeCtx();
|
|
127
|
+
const start = performance.now();
|
|
128
|
+
rt.emit(key, [{ id: 1 }]);
|
|
129
|
+
await wait(60);
|
|
130
|
+
const elapsed = performance.now() - start;
|
|
131
|
+
|
|
132
|
+
console.log(`[throughput] 50,000 subscribers → ${elapsed.toFixed(1)}ms`);
|
|
133
|
+
expect(clients.filter((ws) => ws.sendCount === 1).length).toBe(N);
|
|
134
|
+
expect(elapsed).toBeLessThan(2_000);
|
|
135
|
+
clients.forEach((ws) => deregisterClient(ws));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("100,000명 구독자에게 emit이 5,000ms 이내에 완료된다", async () => {
|
|
139
|
+
const N = 100_000;
|
|
140
|
+
const key = makeUniqueKey("load100k");
|
|
141
|
+
const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
|
|
142
|
+
clients.forEach((ws) => subscribe(key, ws));
|
|
143
|
+
|
|
144
|
+
const rt = buildRealtimeCtx();
|
|
145
|
+
const start = performance.now();
|
|
146
|
+
rt.emit(key, [{ id: 1 }]);
|
|
147
|
+
await wait(60);
|
|
148
|
+
const elapsed = performance.now() - start;
|
|
149
|
+
|
|
150
|
+
console.log(`[throughput]100,000 subscribers → ${elapsed.toFixed(1)}ms`);
|
|
151
|
+
expect(clients.filter((ws) => ws.sendCount === 1).length).toBe(N);
|
|
152
|
+
expect(elapsed).toBeLessThan(5_000);
|
|
153
|
+
clients.forEach((ws) => deregisterClient(ws));
|
|
154
|
+
});
|
|
152
155
|
});
|
|
153
156
|
|
|
154
157
|
// ─── 2. 동시 다수 queryKey emit 처리량 ─────────────────────────────────────
|
|
155
158
|
|
|
156
159
|
describe("[Load] 동시 다수 queryKey emit", () => {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
160
|
+
it("100개 queryKey × 100명 구독자 emit을 200ms 이내에 처리한다", async () => {
|
|
161
|
+
const KEYS = 100;
|
|
162
|
+
const SUBS_PER_KEY = 100;
|
|
163
|
+
const keyMap: { key: string; clients: any[] }[] = [];
|
|
164
|
+
|
|
165
|
+
for (let k = 0; k < KEYS; k++) {
|
|
166
|
+
const key = makeUniqueKey(`multi.${k}`);
|
|
167
|
+
const clients = Array.from({ length: SUBS_PER_KEY }, (_, i) => makeMockWs(k * 1000 + i));
|
|
168
|
+
clients.forEach((ws) => subscribe(key, ws));
|
|
169
|
+
keyMap.push({ key, clients });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const rt = buildRealtimeCtx();
|
|
173
|
+
const start = performance.now();
|
|
174
|
+
for (const { key } of keyMap) {
|
|
175
|
+
rt.emit(key, [{ id: Math.random() }]);
|
|
176
|
+
}
|
|
177
|
+
await wait(80);
|
|
178
|
+
const elapsed = performance.now() - start;
|
|
179
|
+
|
|
180
|
+
console.log(`[multi-key] 100 keys × 100 subs → ${elapsed.toFixed(1)}ms`);
|
|
181
|
+
|
|
182
|
+
const totalReceived = keyMap.reduce(
|
|
183
|
+
(acc, { clients }) => acc + clients.filter((ws) => ws.sendCount === 1).length,
|
|
184
|
+
0,
|
|
185
|
+
);
|
|
186
|
+
expect(totalReceived).toBe(KEYS * SUBS_PER_KEY);
|
|
187
|
+
expect(elapsed).toBeLessThan(200);
|
|
188
|
+
|
|
189
|
+
keyMap.forEach(({ clients }) => clients.forEach((ws) => deregisterClient(ws)));
|
|
190
|
+
});
|
|
188
191
|
});
|
|
189
192
|
|
|
190
193
|
// ─── 3. debounce 효율 ────────────────────────────────────────────────────────
|
|
191
194
|
|
|
192
195
|
describe("[Load] 빠른 연속 emit debounce 효율", () => {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
196
|
+
it("50ms 이내 100번 연속 emit은 단 1회만 전송된다", async () => {
|
|
197
|
+
const key = makeUniqueKey("debounce.stress");
|
|
198
|
+
const ws = makeMockWs();
|
|
199
|
+
subscribe(key, ws);
|
|
200
|
+
|
|
201
|
+
const rt = buildRealtimeCtx();
|
|
202
|
+
for (let i = 0; i < 100; i++) {
|
|
203
|
+
rt.emit(key, [{ id: i, title: `Task ${i}` }]);
|
|
204
|
+
}
|
|
205
|
+
await wait(80);
|
|
206
|
+
|
|
207
|
+
console.log(`[debounce] 100 rapid emits → ${ws.sendCount} actual sends`);
|
|
208
|
+
expect(ws.sendCount).toBe(1);
|
|
209
|
+
|
|
210
|
+
deregisterClient(ws);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("debounce 효율: 1,000번 emit → 전송 횟수 감소율 99% 이상", async () => {
|
|
214
|
+
const key = makeUniqueKey("debounce.efficiency");
|
|
215
|
+
const ws = makeMockWs();
|
|
216
|
+
subscribe(key, ws);
|
|
217
|
+
|
|
218
|
+
const rt = buildRealtimeCtx();
|
|
219
|
+
const RAPID = 1_000;
|
|
220
|
+
const emitStart = performance.now();
|
|
221
|
+
for (let i = 0; i < RAPID; i++) {
|
|
222
|
+
rt.emit(
|
|
223
|
+
key,
|
|
224
|
+
Array.from({ length: 10 }, (_, j) => ({ id: j, value: i })),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
const emitTime = performance.now() - emitStart;
|
|
228
|
+
await wait(80);
|
|
229
|
+
|
|
230
|
+
const reductionRate = ((RAPID - ws.sendCount) / RAPID) * 100;
|
|
231
|
+
console.log(
|
|
232
|
+
`[debounce] emits: ${RAPID}, sends: ${ws.sendCount}, reduction: ${reductionRate.toFixed(1)}%, loop: ${emitTime.toFixed(1)}ms`,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
expect(reductionRate).toBeGreaterThanOrEqual(99);
|
|
236
|
+
expect(ws.sendCount).toBe(1);
|
|
237
|
+
expect(emitTime).toBeLessThan(100);
|
|
238
|
+
|
|
239
|
+
deregisterClient(ws);
|
|
240
|
+
});
|
|
233
241
|
});
|
|
234
242
|
|
|
235
243
|
// ─── 4. connect/disconnect 반복 안정성 ─────────────────────────────────────
|
|
236
244
|
|
|
237
245
|
describe("[Load] 클라이언트 connect/disconnect 안정성", () => {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
246
|
+
it("10,000번 connect/disconnect 반복 후 메모리 안정적이다", () => {
|
|
247
|
+
const CYCLES = 10_000;
|
|
248
|
+
const key = makeUniqueKey("churn.list");
|
|
249
|
+
const before = process.memoryUsage().heapUsed;
|
|
250
|
+
|
|
251
|
+
for (let i = 0; i < CYCLES; i++) {
|
|
252
|
+
const ws = makeMockWs(i);
|
|
253
|
+
registerClient(ws);
|
|
254
|
+
subscribe(key, ws);
|
|
255
|
+
deregisterClient(ws);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const after = process.memoryUsage().heapUsed;
|
|
259
|
+
const diffMB = (after - before) / 1024 / 1024;
|
|
260
|
+
console.log(`[churn] 10k connect/disconnect → heap diff: ${diffMB.toFixed(2)}MB`);
|
|
261
|
+
|
|
262
|
+
expect(diffMB).toBeLessThan(50);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("zombie ws(send throw)는 emit 후 자동 정리되고 이후 정상 클라이언트는 영향 없다", async () => {
|
|
266
|
+
const key = makeUniqueKey("dead.cleanup");
|
|
267
|
+
let throwCount = 0;
|
|
268
|
+
|
|
269
|
+
const zombieWs = {
|
|
270
|
+
send: () => {
|
|
271
|
+
throwCount++;
|
|
272
|
+
throw new Error("WebSocket closed");
|
|
273
|
+
},
|
|
274
|
+
readyState: 3,
|
|
275
|
+
} as any;
|
|
276
|
+
subscribe(key, zombieWs);
|
|
277
|
+
|
|
278
|
+
const rt = buildRealtimeCtx();
|
|
279
|
+
rt.emit(key, [{ id: 1 }]);
|
|
280
|
+
await wait(80);
|
|
281
|
+
expect(throwCount).toBe(1);
|
|
282
|
+
|
|
283
|
+
// zombie 정리 후 정상 클라이언트 추가
|
|
284
|
+
const normalWs = makeMockWs();
|
|
285
|
+
subscribe(key, normalWs);
|
|
286
|
+
|
|
287
|
+
const rt2 = buildRealtimeCtx();
|
|
288
|
+
rt2.emit(key, [{ id: 2 }]);
|
|
289
|
+
await wait(80);
|
|
290
|
+
|
|
291
|
+
expect(normalWs.sendCount).toBe(1);
|
|
292
|
+
deregisterClient(normalWs);
|
|
293
|
+
});
|
|
283
294
|
});
|
|
284
295
|
|
|
285
296
|
// ─── 5. 대용량 페이로드 성능 ─────────────────────────────────────────────────
|
|
286
297
|
|
|
287
298
|
describe("[Load] 대용량 페이로드 emit 성능", () => {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
299
|
+
it("10,000행 데이터셋을 100명 구독자에게 500ms 이내에 전송한다", async () => {
|
|
300
|
+
const N_ROWS = 10_000;
|
|
301
|
+
const N_SUBS = 100;
|
|
302
|
+
const key = makeUniqueKey("large.payload");
|
|
303
|
+
|
|
304
|
+
const largeDataset = Array.from({ length: N_ROWS }, (_, i) => ({
|
|
305
|
+
id: i,
|
|
306
|
+
title: `Task ${i}`,
|
|
307
|
+
description: `Description for task ${i} to simulate real content length`,
|
|
308
|
+
done: i % 2 === 0,
|
|
309
|
+
createdAt: new Date().toISOString(),
|
|
310
|
+
tags: ["alpha", "beta", "gamma"],
|
|
311
|
+
}));
|
|
312
|
+
|
|
313
|
+
const clients = Array.from({ length: N_SUBS }, (_, i) => makeMockWs(i));
|
|
314
|
+
clients.forEach((ws) => subscribe(key, ws));
|
|
315
|
+
|
|
316
|
+
const rt = buildRealtimeCtx();
|
|
317
|
+
const start = performance.now();
|
|
318
|
+
rt.emit(key, largeDataset);
|
|
319
|
+
await wait(80);
|
|
320
|
+
const elapsed = performance.now() - start;
|
|
321
|
+
|
|
322
|
+
const payloadKB = clients[0]?.lastPayloadBytes / 1024;
|
|
323
|
+
console.log(
|
|
324
|
+
`[large payload] ${N_ROWS} rows × ${N_SUBS} subs → ${elapsed.toFixed(1)}ms, payload: ${payloadKB.toFixed(1)}KB`,
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
const received = clients.filter((ws) => ws.sendCount === 1).length;
|
|
328
|
+
expect(received).toBe(N_SUBS);
|
|
329
|
+
expect(elapsed).toBeLessThan(500);
|
|
330
|
+
|
|
331
|
+
clients.forEach((ws) => deregisterClient(ws));
|
|
332
|
+
});
|
|
320
333
|
});
|
|
321
334
|
|
|
322
335
|
// ─── 6. emit broadcast 확장성 (컨넥튰드 클라이언트 기반) ─────────────────────────
|
|
323
336
|
|
|
324
337
|
describe("[Load] emit broadcast 확장성", () => {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
338
|
+
it("1,000개 구독자에게 emit이 50ms 이내에 완료된다", async () => {
|
|
339
|
+
const N = 1_000;
|
|
340
|
+
const key = makeUniqueKey("broadcast1k");
|
|
341
|
+
const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
|
|
342
|
+
clients.forEach((ws) => subscribe(key, ws));
|
|
330
343
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
344
|
+
const start = performance.now();
|
|
345
|
+
buildRealtimeCtx().emit(key, [{ id: 1 }, { id: 2 }]);
|
|
346
|
+
await wait(60);
|
|
347
|
+
const elapsed = performance.now() - start;
|
|
335
348
|
|
|
336
|
-
|
|
349
|
+
console.log(`[broadcast] 1,000 subscribers → ${elapsed.toFixed(1)}ms`);
|
|
337
350
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
351
|
+
const received = clients.filter((ws) => ws.sendCount === 1).length;
|
|
352
|
+
expect(received).toBe(N);
|
|
353
|
+
expect(elapsed).toBeLessThan(100); // emit has 50ms debounce
|
|
341
354
|
|
|
342
|
-
|
|
343
|
-
|
|
355
|
+
clients.forEach((ws) => deregisterClient(ws));
|
|
356
|
+
});
|
|
344
357
|
});
|
|
345
358
|
|
|
346
359
|
// ─── 7. Push emit 포맷 검증 ───────────────────────────────────────────────
|
|
347
360
|
|
|
348
361
|
describe("[Load] Push emit 포맷 검증", () => {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
362
|
+
it("emit(push)은 query:updated+data를 실제 페이로드와 함께 전달한다", async () => {
|
|
363
|
+
const N_SUBS = 500;
|
|
364
|
+
const keyPush = makeUniqueKey("compare.push");
|
|
352
365
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
366
|
+
// push: 구독 클라이언트 (query:updated 수신 예상)
|
|
367
|
+
const pushClients = Array.from({ length: N_SUBS }, (_, i) => makeTrackedWs(i));
|
|
368
|
+
pushClients.forEach((ws) => subscribe(keyPush, ws));
|
|
356
369
|
|
|
357
|
-
|
|
370
|
+
const data = Array.from({ length: 100 }, (_, i) => ({ id: i, title: `T${i}` }));
|
|
358
371
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
372
|
+
// Push path: emit → 50ms debounce → query:updated 전송
|
|
373
|
+
const pushStart = performance.now();
|
|
374
|
+
buildRealtimeCtx().emit(keyPush, data);
|
|
375
|
+
await wait(80);
|
|
376
|
+
const pushElapsed = performance.now() - pushStart;
|
|
364
377
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
378
|
+
const pushWithData = pushClients.filter((ws) =>
|
|
379
|
+
ws.received.some((m: any) => m.type === "query:updated" && m.hasData),
|
|
380
|
+
).length;
|
|
368
381
|
|
|
369
|
-
|
|
382
|
+
console.log(`[push] ${pushElapsed.toFixed(1)}ms → ${pushWithData}/${N_SUBS} with data (query:updated)`);
|
|
370
383
|
|
|
371
|
-
|
|
372
|
-
|
|
384
|
+
// 핵심 검증
|
|
385
|
+
expect(pushWithData).toBe(N_SUBS); // 500/500이 data 포함 메시지 수신
|
|
373
386
|
|
|
374
|
-
|
|
375
|
-
|
|
387
|
+
pushClients.forEach((ws) => deregisterClient(ws));
|
|
388
|
+
});
|
|
376
389
|
});
|