@vuer-ai/vuer-rtc-server 0.1.1
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/.env +1 -0
- package/PHASE1_SUMMARY.md +94 -0
- package/README.md +423 -0
- package/dist/broker/InMemoryBroker.d.ts +24 -0
- package/dist/broker/InMemoryBroker.d.ts.map +1 -0
- package/dist/broker/InMemoryBroker.js +65 -0
- package/dist/broker/InMemoryBroker.js.map +1 -0
- package/dist/broker/index.d.ts +3 -0
- package/dist/broker/index.d.ts.map +1 -0
- package/dist/broker/index.js +2 -0
- package/dist/broker/index.js.map +1 -0
- package/dist/broker/types.d.ts +47 -0
- package/dist/broker/types.d.ts.map +1 -0
- package/dist/broker/types.js +9 -0
- package/dist/broker/types.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/journal/JournalRepository.d.ts +39 -0
- package/dist/journal/JournalRepository.d.ts.map +1 -0
- package/dist/journal/JournalRepository.js +102 -0
- package/dist/journal/JournalRepository.js.map +1 -0
- package/dist/journal/JournalService.d.ts +69 -0
- package/dist/journal/JournalService.d.ts.map +1 -0
- package/dist/journal/JournalService.js +224 -0
- package/dist/journal/JournalService.js.map +1 -0
- package/dist/journal/index.d.ts +6 -0
- package/dist/journal/index.d.ts.map +1 -0
- package/dist/journal/index.js +6 -0
- package/dist/journal/index.js.map +1 -0
- package/dist/persistence/DocumentRepository.d.ts +22 -0
- package/dist/persistence/DocumentRepository.d.ts.map +1 -0
- package/dist/persistence/DocumentRepository.js +66 -0
- package/dist/persistence/DocumentRepository.js.map +1 -0
- package/dist/persistence/PrismaClient.d.ts +8 -0
- package/dist/persistence/PrismaClient.d.ts.map +1 -0
- package/dist/persistence/PrismaClient.js +21 -0
- package/dist/persistence/PrismaClient.js.map +1 -0
- package/dist/persistence/SessionRepository.d.ts +22 -0
- package/dist/persistence/SessionRepository.d.ts.map +1 -0
- package/dist/persistence/SessionRepository.js +103 -0
- package/dist/persistence/SessionRepository.js.map +1 -0
- package/dist/persistence/index.d.ts +7 -0
- package/dist/persistence/index.d.ts.map +1 -0
- package/dist/persistence/index.js +7 -0
- package/dist/persistence/index.js.map +1 -0
- package/dist/serve.d.ts +18 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/serve.js +211 -0
- package/dist/serve.js.map +1 -0
- package/dist/transport/RTCServer.d.ts +92 -0
- package/dist/transport/RTCServer.d.ts.map +1 -0
- package/dist/transport/RTCServer.js +273 -0
- package/dist/transport/RTCServer.js.map +1 -0
- package/dist/transport/index.d.ts +2 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/index.js +2 -0
- package/dist/transport/index.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/jest.config.js +36 -0
- package/package.json +56 -0
- package/prisma/schema.prisma +121 -0
- package/src/broker/InMemoryBroker.ts +81 -0
- package/src/broker/index.ts +2 -0
- package/src/broker/types.ts +60 -0
- package/src/index.ts +23 -0
- package/src/journal/JournalRepository.ts +119 -0
- package/src/journal/JournalService.ts +291 -0
- package/src/journal/index.ts +10 -0
- package/src/persistence/DocumentRepository.ts +76 -0
- package/src/persistence/PrismaClient.ts +24 -0
- package/src/persistence/SessionRepository.ts +114 -0
- package/src/persistence/index.ts +7 -0
- package/src/serve.ts +240 -0
- package/src/transport/RTCServer.ts +327 -0
- package/src/transport/index.ts +1 -0
- package/src/version.ts +1 -0
- package/tests/README.md +112 -0
- package/tests/demo.ts +555 -0
- package/tests/e2e/convergence.test.ts +221 -0
- package/tests/e2e/helpers/assertions.ts +158 -0
- package/tests/e2e/helpers/createTestServer.ts +220 -0
- package/tests/e2e/latency.test.ts +512 -0
- package/tests/e2e/packet-loss.test.ts +229 -0
- package/tests/e2e/relay.test.ts +255 -0
- package/tests/e2e/sync-perf.test.ts +365 -0
- package/tests/e2e/sync-reconciliation.test.ts +237 -0
- package/tests/e2e/text-sync.test.ts +199 -0
- package/tests/e2e/tombstone-convergence.test.ts +356 -0
- package/tests/fixtures/array-ops.jsonl +6 -0
- package/tests/fixtures/boolean-ops.jsonl +6 -0
- package/tests/fixtures/color-ops.jsonl +4 -0
- package/tests/fixtures/edit-buffer.jsonl +3 -0
- package/tests/fixtures/messages.jsonl +4 -0
- package/tests/fixtures/node-ops.jsonl +6 -0
- package/tests/fixtures/number-ops.jsonl +7 -0
- package/tests/fixtures/object-ops.jsonl +4 -0
- package/tests/fixtures/operations.jsonl +7 -0
- package/tests/fixtures/string-ops.jsonl +4 -0
- package/tests/fixtures/undo-redo.jsonl +3 -0
- package/tests/fixtures/vector-ops.jsonl +9 -0
- package/tests/integration/repositories.test.ts +320 -0
- package/tests/journal/journal-service.test.ts +185 -0
- package/tests/test-data/datatypes.ts +677 -0
- package/tests/test-data/operations-example.ts +306 -0
- package/tests/test-data/scene-example.ts +247 -0
- package/tests/unit/operations.test.ts +310 -0
- package/tests/unit/vectorClock.test.ts +281 -0
- package/tsconfig.json +19 -0
- package/tsconfig.test.json +8 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync Performance Tests
|
|
3
|
+
*
|
|
4
|
+
* Stress tests for bloom filter sync reconciliation under heavy load.
|
|
5
|
+
* Measures: digest build time, convergence time with packet loss,
|
|
6
|
+
* compaction impact, and scaling with multiple clients.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
|
10
|
+
import { buildSyncDigest } from '@vuer-ai/vuer-rtc';
|
|
11
|
+
import type { GraphStore } from '@vuer-ai/vuer-rtc';
|
|
12
|
+
import { createTestServer, type TestServer } from './helpers/createTestServer.js';
|
|
13
|
+
import { waitFor, waitForConvergence, assertGraphsEqual } from './helpers/assertions.js';
|
|
14
|
+
|
|
15
|
+
function seedGraph(store: GraphStore): void {
|
|
16
|
+
store.edit({
|
|
17
|
+
otype: 'node.insert',
|
|
18
|
+
key: '',
|
|
19
|
+
path: 'children',
|
|
20
|
+
value: { key: 'scene', tag: 'Scene', name: 'Scene' },
|
|
21
|
+
});
|
|
22
|
+
store.edit({
|
|
23
|
+
otype: 'node.insert',
|
|
24
|
+
key: 'scene',
|
|
25
|
+
path: 'children',
|
|
26
|
+
value: {
|
|
27
|
+
key: 'cube-1',
|
|
28
|
+
tag: 'Mesh',
|
|
29
|
+
name: 'Cube',
|
|
30
|
+
position: [0, 0, 0],
|
|
31
|
+
opacity: 1,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
store.commit('seed scene');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Generate N position edits on cube-1, one commit each. */
|
|
38
|
+
function generateEdits(store: GraphStore, n: number): void {
|
|
39
|
+
for (let i = 0; i < n; i++) {
|
|
40
|
+
store.edit({
|
|
41
|
+
otype: 'vector3.set',
|
|
42
|
+
key: 'cube-1',
|
|
43
|
+
path: 'position',
|
|
44
|
+
value: [i, i * 2, i * 3],
|
|
45
|
+
});
|
|
46
|
+
store.commit(`edit ${i}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hrMs(start: [number, number]): number {
|
|
51
|
+
const diff = process.hrtime(start);
|
|
52
|
+
return diff[0] * 1000 + diff[1] / 1e6;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Tests ─────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
describe('sync-perf', () => {
|
|
58
|
+
let ts: TestServer;
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
ts = createTestServer();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
ts.close();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ── 1. Digest build time at scale ───────────────────────────
|
|
69
|
+
|
|
70
|
+
it('digest build time scales linearly (1K, 5K, 10K messages)', async () => {
|
|
71
|
+
const clientA = ts.connectClient('room-1', 'session-a');
|
|
72
|
+
seedGraph(clientA.store);
|
|
73
|
+
|
|
74
|
+
const sizes = [1000, 5000, 10000];
|
|
75
|
+
const results: Array<{ n: number; buildMs: number; filterBytes: number }> = [];
|
|
76
|
+
|
|
77
|
+
for (const n of sizes) {
|
|
78
|
+
const currentJournal = clientA.store.getState().journal.length;
|
|
79
|
+
const needed = n - currentJournal + 1;
|
|
80
|
+
if (needed > 0) generateEdits(clientA.store, needed);
|
|
81
|
+
|
|
82
|
+
// Measure digest build time (average of 10 runs)
|
|
83
|
+
const runs = 10;
|
|
84
|
+
const t0 = process.hrtime();
|
|
85
|
+
for (let i = 0; i < runs; i++) {
|
|
86
|
+
buildSyncDigest(clientA.store.getState());
|
|
87
|
+
}
|
|
88
|
+
const elapsed = hrMs(t0);
|
|
89
|
+
const avgMs = elapsed / runs;
|
|
90
|
+
|
|
91
|
+
const digest = buildSyncDigest(clientA.store.getState());
|
|
92
|
+
const filterBytes = digest.filter.length;
|
|
93
|
+
|
|
94
|
+
results.push({ n: digest.count, buildMs: avgMs, filterBytes });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
console.log('\n Digest build time:');
|
|
98
|
+
console.log(' ┌───────────┬───────────────┬──────────────┬────────────────┐');
|
|
99
|
+
console.log(' │ Messages │ Build (ms) │ Filter (KB) │ msgs/ms │');
|
|
100
|
+
console.log(' ├───────────┼───────────────┼──────────────┼────────────────┤');
|
|
101
|
+
for (const r of results) {
|
|
102
|
+
const kb = (r.filterBytes / 1024).toFixed(1);
|
|
103
|
+
const throughput = (r.n / r.buildMs).toFixed(0);
|
|
104
|
+
console.log(
|
|
105
|
+
` │ ${String(r.n).padStart(7)} │ ${r.buildMs.toFixed(3).padStart(11)} │ ${kb.padStart(10)} │ ${throughput.padStart(12)} │`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
console.log(' └───────────┴───────────────┴──────────────┴────────────────┘');
|
|
109
|
+
|
|
110
|
+
// Sanity: 10K messages should build in < 50ms
|
|
111
|
+
const last = results[results.length - 1];
|
|
112
|
+
expect(last.buildMs).toBeLessThan(50);
|
|
113
|
+
expect(last.filterBytes).toBeLessThan(last.n * 3);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── 2. Compaction shrinks digest dramatically ───────────────
|
|
117
|
+
|
|
118
|
+
it('compaction reduces digest size after ack', async () => {
|
|
119
|
+
const clientA = ts.connectClient('room-1', 'session-a');
|
|
120
|
+
const clientB = ts.connectClient('room-1', 'session-b');
|
|
121
|
+
seedGraph(clientA.store);
|
|
122
|
+
await waitForConvergence([clientA.store, clientB.store], 3000);
|
|
123
|
+
|
|
124
|
+
// B makes 2000 edits
|
|
125
|
+
generateEdits(clientB.store, 2000);
|
|
126
|
+
await waitForConvergence([clientA.store, clientB.store], 5000);
|
|
127
|
+
|
|
128
|
+
const beforeDigest = buildSyncDigest(clientA.store.getState());
|
|
129
|
+
const beforeCount = beforeDigest.count;
|
|
130
|
+
const beforeFilterKB = beforeDigest.filter.length / 1024;
|
|
131
|
+
|
|
132
|
+
clientA.store.compact();
|
|
133
|
+
|
|
134
|
+
const afterState = clientA.store.getState();
|
|
135
|
+
const afterDigest = buildSyncDigest(afterState);
|
|
136
|
+
const afterCount = afterDigest.count;
|
|
137
|
+
const afterFilterKB = afterDigest.filter.length / 1024;
|
|
138
|
+
|
|
139
|
+
console.log('\n Compaction impact (client A, 2000 remote edits):');
|
|
140
|
+
console.log(` Before: journal=${beforeCount}, filter=${beforeFilterKB.toFixed(1)} KB`);
|
|
141
|
+
console.log(` After: journal=${afterCount}, filter=${afterFilterKB.toFixed(1)} KB`);
|
|
142
|
+
console.log(` Reduction: ${((1 - afterCount / beforeCount) * 100).toFixed(0)}%`);
|
|
143
|
+
|
|
144
|
+
expect(afterCount).toBeLessThan(beforeCount);
|
|
145
|
+
expect(Object.keys(afterState.snapshot.vectorClock).length).toBeGreaterThan(0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ── 3. Heavy load convergence with 50% drops + sync ────────
|
|
149
|
+
|
|
150
|
+
it('converges 1000 edits with 50% drop + sync recovery', async () => {
|
|
151
|
+
const clientA = ts.connectClient('room-1', 'session-a');
|
|
152
|
+
const clientB = ts.connectClient('room-1', 'session-b', { dropRate: 0.5 });
|
|
153
|
+
seedGraph(clientA.store);
|
|
154
|
+
|
|
155
|
+
for (const entry of clientA.store.getState().journal) {
|
|
156
|
+
clientB.store.receive(entry.msg);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const editStart = process.hrtime();
|
|
160
|
+
generateEdits(clientA.store, 1000);
|
|
161
|
+
const editMs = hrMs(editStart);
|
|
162
|
+
|
|
163
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
164
|
+
|
|
165
|
+
const bJournal = clientB.store.getState().journal.length;
|
|
166
|
+
const aJournal = clientA.store.getState().journal.length;
|
|
167
|
+
const missing = aJournal - bJournal;
|
|
168
|
+
|
|
169
|
+
clientB.disableDrop();
|
|
170
|
+
const syncStart = process.hrtime();
|
|
171
|
+
|
|
172
|
+
for (let round = 0; round < 5; round++) {
|
|
173
|
+
clientB.retryUnacked();
|
|
174
|
+
clientB.sendSync();
|
|
175
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await waitForConvergence([clientA.store, clientB.store], 10000);
|
|
179
|
+
const syncMs = hrMs(syncStart);
|
|
180
|
+
|
|
181
|
+
assertGraphsEqual(
|
|
182
|
+
clientA.store.getState().graph,
|
|
183
|
+
clientB.store.getState().graph,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
console.log('\n Heavy load convergence (1000 edits, 50% drop):');
|
|
187
|
+
console.log(` Edit generation: ${editMs.toFixed(1)} ms`);
|
|
188
|
+
console.log(` Messages dropped: ~${missing} of ${aJournal}`);
|
|
189
|
+
console.log(` Sync recovery: ${syncMs.toFixed(1)} ms`);
|
|
190
|
+
console.log(` Final position: ${JSON.stringify(clientB.store.getState().graph.nodes['cube-1'].position)}`);
|
|
191
|
+
|
|
192
|
+
expect(clientB.store.getState().graph.nodes['cube-1'].position).toEqual([999, 1998, 2997]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ── 4. Multi-client scaling: 5 clients, 100 edits each ─────
|
|
196
|
+
|
|
197
|
+
it('5 clients × 100 edits converge via sync', async () => {
|
|
198
|
+
const N_CLIENTS = 5;
|
|
199
|
+
const EDITS_PER_CLIENT = 100;
|
|
200
|
+
const clients = [];
|
|
201
|
+
|
|
202
|
+
for (let i = 0; i < N_CLIENTS; i++) {
|
|
203
|
+
const c = ts.connectClient('room-1', `session-${i}`, { dropRate: 0.3 });
|
|
204
|
+
clients.push(c);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// First client seeds reliably
|
|
208
|
+
clients[0].disableDrop();
|
|
209
|
+
seedGraph(clients[0].store);
|
|
210
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
211
|
+
|
|
212
|
+
// Replay seed to all clients
|
|
213
|
+
const seed = clients[0].store.getState().journal;
|
|
214
|
+
for (let i = 1; i < N_CLIENTS; i++) {
|
|
215
|
+
for (const entry of seed) {
|
|
216
|
+
clients[i].store.receive(entry.msg);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// All clients fire edits
|
|
221
|
+
const editStart = process.hrtime();
|
|
222
|
+
for (let i = 0; i < N_CLIENTS; i++) {
|
|
223
|
+
for (let j = 0; j < EDITS_PER_CLIENT; j++) {
|
|
224
|
+
clients[i].store.edit({
|
|
225
|
+
otype: 'number.set',
|
|
226
|
+
key: 'cube-1',
|
|
227
|
+
path: `prop_${i}_${j}`,
|
|
228
|
+
value: i * 1000 + j,
|
|
229
|
+
});
|
|
230
|
+
clients[i].store.commit(`client-${i} edit ${j}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const editMs = hrMs(editStart);
|
|
234
|
+
|
|
235
|
+
// Let some broadcasts arrive
|
|
236
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
237
|
+
|
|
238
|
+
// Disable drops and sync repeatedly
|
|
239
|
+
const syncStart = process.hrtime();
|
|
240
|
+
for (const c of clients) c.disableDrop();
|
|
241
|
+
|
|
242
|
+
for (let round = 0; round < 15; round++) {
|
|
243
|
+
for (const c of clients) {
|
|
244
|
+
c.retryUnacked();
|
|
245
|
+
c.sendSync();
|
|
246
|
+
}
|
|
247
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
await waitForConvergence(
|
|
251
|
+
clients.map((c) => c.store),
|
|
252
|
+
20000,
|
|
253
|
+
);
|
|
254
|
+
const syncMs = hrMs(syncStart);
|
|
255
|
+
|
|
256
|
+
for (let i = 1; i < N_CLIENTS; i++) {
|
|
257
|
+
assertGraphsEqual(
|
|
258
|
+
clients[0].store.getState().graph,
|
|
259
|
+
clients[i].store.getState().graph,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const totalEdits = N_CLIENTS * EDITS_PER_CLIENT;
|
|
264
|
+
const journalSizes = clients.map((c) => c.store.getState().journal.length);
|
|
265
|
+
|
|
266
|
+
console.log(`\n Multi-client convergence (${N_CLIENTS} clients × ${EDITS_PER_CLIENT} edits):`);
|
|
267
|
+
console.log(` Total edits: ${totalEdits}`);
|
|
268
|
+
console.log(` Edit generation: ${editMs.toFixed(1)} ms (${(totalEdits / editMs * 1000).toFixed(0)} edits/sec)`);
|
|
269
|
+
console.log(` Sync recovery: ${syncMs.toFixed(1)} ms`);
|
|
270
|
+
console.log(` Journal sizes: [${journalSizes.join(', ')}]`);
|
|
271
|
+
|
|
272
|
+
const node = clients[0].store.getState().graph.nodes['cube-1'];
|
|
273
|
+
const propCount = Object.keys(node).filter((k) => k.startsWith('prop_')).length;
|
|
274
|
+
expect(propCount).toBe(totalEdits);
|
|
275
|
+
}, 60_000);
|
|
276
|
+
|
|
277
|
+
// ── 5. Long session: compaction keeps journal bounded ───────
|
|
278
|
+
|
|
279
|
+
it('long session: 2000 edits with periodic compaction', async () => {
|
|
280
|
+
// Two clients — A edits, B receives. A compacts periodically.
|
|
281
|
+
// Key insight: compaction works on ACKED entries, so we need to
|
|
282
|
+
// let acks arrive (async) before calling compact.
|
|
283
|
+
const clientA = ts.connectClient('room-1', 'session-a');
|
|
284
|
+
const clientB = ts.connectClient('room-1', 'session-b', { dropRate: 0.2 });
|
|
285
|
+
seedGraph(clientA.store);
|
|
286
|
+
|
|
287
|
+
for (const entry of clientA.store.getState().journal) {
|
|
288
|
+
clientB.store.receive(entry.msg);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const TOTAL = 2000;
|
|
292
|
+
const BATCH_SIZE = 500;
|
|
293
|
+
const compactionStats: Array<{ at: number; journalBefore: number; journalAfter: number }> = [];
|
|
294
|
+
|
|
295
|
+
const start = process.hrtime();
|
|
296
|
+
|
|
297
|
+
for (let batch = 0; batch < TOTAL / BATCH_SIZE; batch++) {
|
|
298
|
+
// Fire a batch of edits
|
|
299
|
+
for (let i = 0; i < BATCH_SIZE; i++) {
|
|
300
|
+
const idx = batch * BATCH_SIZE + i;
|
|
301
|
+
clientA.store.edit({
|
|
302
|
+
otype: 'number.set',
|
|
303
|
+
key: 'cube-1',
|
|
304
|
+
path: `val_${idx % 100}`,
|
|
305
|
+
value: idx,
|
|
306
|
+
});
|
|
307
|
+
clientA.store.commit(`edit ${idx}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Wait for acks to arrive (they come back via setTimeout(0))
|
|
311
|
+
await waitFor(
|
|
312
|
+
() => clientA.store.getState().journal.every((e) => e.ack),
|
|
313
|
+
3000,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
// Now compact — all entries are acked
|
|
317
|
+
const jBefore = clientA.store.getState().journal.length;
|
|
318
|
+
clientA.store.compact();
|
|
319
|
+
const jAfter = clientA.store.getState().journal.length;
|
|
320
|
+
compactionStats.push({
|
|
321
|
+
at: (batch + 1) * BATCH_SIZE,
|
|
322
|
+
journalBefore: jBefore,
|
|
323
|
+
journalAfter: jAfter,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const editMs = hrMs(start);
|
|
328
|
+
|
|
329
|
+
// Final sync for B
|
|
330
|
+
clientB.disableDrop();
|
|
331
|
+
for (let round = 0; round < 10; round++) {
|
|
332
|
+
clientB.retryUnacked();
|
|
333
|
+
clientB.sendSync();
|
|
334
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
await waitForConvergence([clientA.store, clientB.store], 15000);
|
|
338
|
+
const totalMs = hrMs(start);
|
|
339
|
+
|
|
340
|
+
assertGraphsEqual(
|
|
341
|
+
clientA.store.getState().graph,
|
|
342
|
+
clientB.store.getState().graph,
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
const digestA = buildSyncDigest(clientA.store.getState());
|
|
346
|
+
const digestB = buildSyncDigest(clientB.store.getState());
|
|
347
|
+
const snapshotClock = clientA.store.getState().snapshot.vectorClock;
|
|
348
|
+
|
|
349
|
+
console.log(`\n Long session (${TOTAL} edits, compact every ${BATCH_SIZE}):`);
|
|
350
|
+
console.log(` Edit + compact: ${editMs.toFixed(1)} ms (${(TOTAL / editMs * 1000).toFixed(0)} edits/sec)`);
|
|
351
|
+
console.log(` Total time: ${totalMs.toFixed(1)} ms`);
|
|
352
|
+
console.log(` Final journal: A=${digestA.count}, B=${digestB.count}`);
|
|
353
|
+
console.log(` Final filter: A=${(digestA.filter.length / 1024).toFixed(1)} KB, B=${(digestB.filter.length / 1024).toFixed(1)} KB`);
|
|
354
|
+
console.log(` Snapshot vector clock: ${JSON.stringify(snapshotClock)}`);
|
|
355
|
+
console.log(' Compaction rounds:');
|
|
356
|
+
for (const s of compactionStats) {
|
|
357
|
+
console.log(` @${s.at}: journal ${s.journalBefore} → ${s.journalAfter}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// After periodic compaction, A's journal should be near-empty
|
|
361
|
+
expect(digestA.count).toBeLessThan(BATCH_SIZE);
|
|
362
|
+
// Vector clock should cover A's compacted entries
|
|
363
|
+
expect(snapshotClock['session-a']).toBeGreaterThan(0);
|
|
364
|
+
}, 60_000);
|
|
365
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E: Bloom filter-based sync reconciliation.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that clients can recover from dropped server→client broadcasts
|
|
5
|
+
* by sending a bloom filter digest and receiving missing messages.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
|
9
|
+
import { createTestServer, type TestServer } from './helpers/createTestServer.js';
|
|
10
|
+
import { waitForConvergence, assertGraphsEqual } from './helpers/assertions.js';
|
|
11
|
+
import type { GraphStore } from '@vuer-ai/vuer-rtc';
|
|
12
|
+
|
|
13
|
+
// ── Helpers ───────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
function seedGraph(store: GraphStore): void {
|
|
16
|
+
store.edit({
|
|
17
|
+
otype: 'node.insert',
|
|
18
|
+
key: '',
|
|
19
|
+
path: 'children',
|
|
20
|
+
value: { key: 'scene', tag: 'Scene', name: 'Scene' },
|
|
21
|
+
});
|
|
22
|
+
store.edit({
|
|
23
|
+
otype: 'node.insert',
|
|
24
|
+
key: 'scene',
|
|
25
|
+
path: 'children',
|
|
26
|
+
value: {
|
|
27
|
+
key: 'cube-1',
|
|
28
|
+
tag: 'Mesh',
|
|
29
|
+
name: 'Cube',
|
|
30
|
+
position: [0, 0, 0],
|
|
31
|
+
opacity: 1,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
store.commit('seed scene');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Tests ─────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
describe('sync-reconciliation', () => {
|
|
40
|
+
let ts: TestServer;
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
ts = createTestServer();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
ts.close();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ── 1. Full recovery via sync after 100% drop ─────────────
|
|
51
|
+
|
|
52
|
+
it('recovers all messages via sync after 100% broadcast drop', async () => {
|
|
53
|
+
// A sends reliably to the server, but B drops all incoming (100% drop)
|
|
54
|
+
const clientA = ts.connectClient('room-1', 'session-a');
|
|
55
|
+
const clientB = ts.connectClient('room-1', 'session-b', { dropRate: 1.0 });
|
|
56
|
+
|
|
57
|
+
seedGraph(clientA.store);
|
|
58
|
+
|
|
59
|
+
// Wait for A's messages to be processed by the server
|
|
60
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
61
|
+
|
|
62
|
+
// B should have nothing since all broadcasts were dropped
|
|
63
|
+
expect(Object.keys(clientB.store.getState().graph.nodes).length).toBe(0);
|
|
64
|
+
|
|
65
|
+
// Disable drop and send sync — B tells server what it has (nothing),
|
|
66
|
+
// server retransmits everything
|
|
67
|
+
clientB.disableDrop();
|
|
68
|
+
clientB.sendSync();
|
|
69
|
+
|
|
70
|
+
await waitForConvergence([clientA.store, clientB.store], 3000);
|
|
71
|
+
|
|
72
|
+
const graphA = clientA.store.getState().graph;
|
|
73
|
+
const graphB = clientB.store.getState().graph;
|
|
74
|
+
assertGraphsEqual(graphA, graphB);
|
|
75
|
+
expect(Object.keys(graphB.nodes).length).toBeGreaterThan(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ── 2. Partial recovery via sync after 50% drop ───────────
|
|
79
|
+
|
|
80
|
+
it('recovers from partial drop via sync', async () => {
|
|
81
|
+
// Seed reliably first so both clients have the base graph
|
|
82
|
+
const clientA = ts.connectClient('room-1', 'session-a');
|
|
83
|
+
const clientB = ts.connectClient('room-1', 'session-b');
|
|
84
|
+
|
|
85
|
+
seedGraph(clientA.store);
|
|
86
|
+
await waitForConvergence([clientA.store, clientB.store], 3000);
|
|
87
|
+
|
|
88
|
+
// Connect a third client with 50% drops — it starts behind
|
|
89
|
+
const clientC = ts.connectClient('room-1', 'session-c', { dropRate: 0.5 });
|
|
90
|
+
|
|
91
|
+
// Replay journal so C has the base graph
|
|
92
|
+
for (const entry of clientA.store.getState().journal) {
|
|
93
|
+
clientC.store.receive(entry.msg);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A makes property edits (commutative, safe for out-of-order replay)
|
|
97
|
+
clientA.store.edit({
|
|
98
|
+
otype: 'vector3.set',
|
|
99
|
+
key: 'cube-1',
|
|
100
|
+
path: 'position',
|
|
101
|
+
value: [10, 20, 30],
|
|
102
|
+
});
|
|
103
|
+
clientA.store.commit('move cube');
|
|
104
|
+
|
|
105
|
+
clientA.store.edit({
|
|
106
|
+
otype: 'number.set',
|
|
107
|
+
key: 'cube-1',
|
|
108
|
+
path: 'opacity',
|
|
109
|
+
value: 0.5,
|
|
110
|
+
});
|
|
111
|
+
clientA.store.commit('set opacity');
|
|
112
|
+
|
|
113
|
+
// Wait for processing — C may have received some broadcasts, missed others
|
|
114
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
115
|
+
|
|
116
|
+
// Disable drop and sync to recover missing messages
|
|
117
|
+
clientC.disableDrop();
|
|
118
|
+
clientC.sendSync();
|
|
119
|
+
|
|
120
|
+
await waitForConvergence([clientA.store, clientC.store], 3000);
|
|
121
|
+
|
|
122
|
+
const graphA = clientA.store.getState().graph;
|
|
123
|
+
const graphC = clientC.store.getState().graph;
|
|
124
|
+
assertGraphsEqual(graphA, graphC);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── 3. Sync is idempotent ─────────────────────────────────
|
|
128
|
+
|
|
129
|
+
it('sync is idempotent when nothing is missing', async () => {
|
|
130
|
+
const clientA = ts.connectClient('room-1', 'session-a');
|
|
131
|
+
const clientB = ts.connectClient('room-1', 'session-b');
|
|
132
|
+
|
|
133
|
+
seedGraph(clientA.store);
|
|
134
|
+
await waitForConvergence([clientA.store, clientB.store], 3000);
|
|
135
|
+
|
|
136
|
+
// Both converged, now send sync — should be a no-op
|
|
137
|
+
const graphBefore = JSON.stringify(clientB.store.getState().graph);
|
|
138
|
+
clientB.sendSync();
|
|
139
|
+
|
|
140
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
141
|
+
|
|
142
|
+
const graphAfter = JSON.stringify(clientB.store.getState().graph);
|
|
143
|
+
expect(graphAfter).toBe(graphBefore);
|
|
144
|
+
|
|
145
|
+
assertGraphsEqual(clientA.store.getState().graph, clientB.store.getState().graph);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ── 4. Bidirectional sync recovers both sides ─────────────
|
|
149
|
+
|
|
150
|
+
it('bidirectional sync recovers both clients', async () => {
|
|
151
|
+
// Both clients drop each other's broadcasts
|
|
152
|
+
const clientA = ts.connectClient('room-1', 'session-a', { dropRate: 1.0 });
|
|
153
|
+
const clientB = ts.connectClient('room-1', 'session-b', { dropRate: 1.0 });
|
|
154
|
+
|
|
155
|
+
// Seed from A — server gets it, but broadcast to B is dropped
|
|
156
|
+
seedGraph(clientA.store);
|
|
157
|
+
|
|
158
|
+
// B makes its own edit — server gets it, but broadcast to A is dropped
|
|
159
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
160
|
+
|
|
161
|
+
// Disable drop for sync
|
|
162
|
+
clientA.disableDrop();
|
|
163
|
+
clientB.disableDrop();
|
|
164
|
+
|
|
165
|
+
// B also needs to retry its unacked messages (since A→server messages were also
|
|
166
|
+
// dropped when clientA had 100% dropRate)
|
|
167
|
+
clientA.retryUnacked();
|
|
168
|
+
clientB.retryUnacked();
|
|
169
|
+
|
|
170
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
171
|
+
|
|
172
|
+
// Now both send sync
|
|
173
|
+
clientA.sendSync();
|
|
174
|
+
clientB.sendSync();
|
|
175
|
+
|
|
176
|
+
await waitForConvergence([clientA.store, clientB.store], 3000);
|
|
177
|
+
|
|
178
|
+
const graphA = clientA.store.getState().graph;
|
|
179
|
+
const graphB = clientB.store.getState().graph;
|
|
180
|
+
assertGraphsEqual(graphA, graphB);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ── 5. Sync + retry together ──────────────────────────────
|
|
184
|
+
|
|
185
|
+
it('sync + retry together achieve full convergence', async () => {
|
|
186
|
+
const clientA = ts.connectClient('room-1', 'session-a', { dropRate: 0.5 });
|
|
187
|
+
const clientB = ts.connectClient('room-1', 'session-b', { dropRate: 0.5 });
|
|
188
|
+
|
|
189
|
+
// Seed reliably
|
|
190
|
+
clientA.disableDrop();
|
|
191
|
+
clientB.disableDrop();
|
|
192
|
+
seedGraph(clientA.store);
|
|
193
|
+
await waitForConvergence([clientA.store, clientB.store], 3000);
|
|
194
|
+
|
|
195
|
+
// Re-enable drops for edits
|
|
196
|
+
clientA.setLatency(0);
|
|
197
|
+
clientB.setLatency(0);
|
|
198
|
+
|
|
199
|
+
// Both make edits (some may be dropped)
|
|
200
|
+
clientA.store.edit({
|
|
201
|
+
otype: 'vector3.set',
|
|
202
|
+
key: 'cube-1',
|
|
203
|
+
path: 'position',
|
|
204
|
+
value: [99, 88, 77],
|
|
205
|
+
});
|
|
206
|
+
clientA.store.commit('move');
|
|
207
|
+
|
|
208
|
+
clientB.store.edit({
|
|
209
|
+
otype: 'number.set',
|
|
210
|
+
key: 'cube-1',
|
|
211
|
+
path: 'opacity',
|
|
212
|
+
value: 0.3,
|
|
213
|
+
});
|
|
214
|
+
clientB.store.commit('opacity');
|
|
215
|
+
|
|
216
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
217
|
+
|
|
218
|
+
// Full recovery: disable drop, retry unacked, sync
|
|
219
|
+
clientA.disableDrop();
|
|
220
|
+
clientB.disableDrop();
|
|
221
|
+
clientA.retryUnacked();
|
|
222
|
+
clientB.retryUnacked();
|
|
223
|
+
|
|
224
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
225
|
+
|
|
226
|
+
clientA.sendSync();
|
|
227
|
+
clientB.sendSync();
|
|
228
|
+
|
|
229
|
+
await waitForConvergence([clientA.store, clientB.store], 3000);
|
|
230
|
+
|
|
231
|
+
const graphA = clientA.store.getState().graph;
|
|
232
|
+
const graphB = clientB.store.getState().graph;
|
|
233
|
+
assertGraphsEqual(graphA, graphB);
|
|
234
|
+
expect(graphB.nodes['cube-1'].position).toEqual([99, 88, 77]);
|
|
235
|
+
expect(graphB.nodes['cube-1'].opacity).toBe(0.3);
|
|
236
|
+
});
|
|
237
|
+
});
|