@bod.ee/db 0.12.1 → 0.12.2

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.
@@ -0,0 +1,270 @@
1
+ import { describe, it, expect, afterEach } from 'bun:test';
2
+ import { BodDB } from '../src/server/BodDB.ts';
3
+ import { BodClient } from '../src/client/BodClient.ts';
4
+
5
+ const wait = (ms: number) => new Promise(r => setTimeout(r, ms));
6
+ let nextPort = 26400 + Math.floor(Math.random() * 1000);
7
+
8
+ /**
9
+ * Stress tests for _repl stream bloat.
10
+ *
11
+ * The real-world issue: _repl grows unbounded → streamMaterialize produces
12
+ * a massive WS response → client requestTimeout (30s) fires or WS chokes.
13
+ *
14
+ * Locally we can't easily reproduce network latency, but we CAN:
15
+ * 1. Push the entry count + payload size to stress serialization/parsing
16
+ * 2. Use short requestTimeout on the replica client to simulate the real failure
17
+ * 3. Measure materialization time scaling (O(n) proof)
18
+ * 4. Verify compaction actually fixes it
19
+ */
20
+ describe('_repl stream bloat', () => {
21
+ const instances: BodDB[] = [];
22
+ const clients: BodClient[] = [];
23
+
24
+ afterEach(() => {
25
+ for (const c of clients) c.disconnect();
26
+ clients.length = 0;
27
+ for (const db of [...instances].reverse()) db.close();
28
+ instances.length = 0;
29
+ });
30
+
31
+ function getPort() { return nextPort++; }
32
+
33
+ /** Primary with auto-compact DISABLED so _repl grows unbounded */
34
+ function createPrimary(opts?: { maxMessageSize?: number }) {
35
+ const port = getPort();
36
+ const db = new BodDB({
37
+ path: ':memory:',
38
+ sweepInterval: 0,
39
+ replication: { role: 'primary', compact: {} },
40
+ });
41
+ db.replication!.start();
42
+ db.serve({ port, maxMessageSize: opts?.maxMessageSize });
43
+ instances.push(db);
44
+ return { db, port };
45
+ }
46
+
47
+ function createReplica(primaryPort: number) {
48
+ const port = getPort();
49
+ const db = new BodDB({
50
+ path: ':memory:',
51
+ sweepInterval: 0,
52
+ replication: {
53
+ role: 'replica',
54
+ primaryUrl: `ws://localhost:${primaryPort}`,
55
+ replicaId: `bloat-replica-${port}`,
56
+ },
57
+ });
58
+ db.serve({ port });
59
+ instances.push(db);
60
+ return { db, port };
61
+ }
62
+
63
+ // Helper: fill _repl with N entries, each ~payloadBytes
64
+ function fillRepl(db: BodDB, count: number, payloadBytes: number) {
65
+ const padding = 'x'.repeat(Math.max(0, payloadBytes - 100));
66
+ for (let i = 0; i < count; i++) {
67
+ db.set(`vfs/files/project/src/deep/nested/path/module${i}/component.tsx`, {
68
+ size: 1024 + i,
69
+ mime: 'application/typescript',
70
+ mtime: Date.now(),
71
+ fileId: `fid_${i}_${padding}`,
72
+ hash: `sha256_${i.toString(16).padStart(64, '0')}`,
73
+ isDir: false,
74
+ metadata: { author: 'test', tags: ['a', 'b', 'c'], version: i },
75
+ });
76
+ }
77
+ }
78
+
79
+ // --- Accumulation ---
80
+
81
+ it('_repl grows unbounded without compaction', () => {
82
+ const { db } = createPrimary();
83
+ fillRepl(db, 5000, 300);
84
+ const repl = db.get('_repl') as Record<string, any>;
85
+ expect(Object.keys(repl).length).toBe(5000);
86
+ });
87
+
88
+ // --- Scaling: measure materialize time at increasing sizes ---
89
+
90
+ it('materialize time scales linearly (O(n) proof)', async () => {
91
+ const sizes = [500, 2000, 5000];
92
+ const timings: { count: number; ms: number; bytes: number }[] = [];
93
+
94
+ for (const count of sizes) {
95
+ const { db, port } = createPrimary();
96
+ fillRepl(db, count, 500);
97
+
98
+ const client = new BodClient({ url: `ws://localhost:${port}` });
99
+ clients.push(client);
100
+ await client.connect();
101
+
102
+ const start = Date.now();
103
+ const result = await client.streamMaterialize('_repl', { keepKey: 'path' });
104
+ const elapsed = Date.now() - start;
105
+
106
+ const json = JSON.stringify(result);
107
+ timings.push({ count, ms: elapsed, bytes: json.length });
108
+ }
109
+
110
+ for (const t of timings) {
111
+ console.log(` ${t.count} entries → ${t.ms}ms, ${(t.bytes / 1024 / 1024).toFixed(2)}MB response`);
112
+ }
113
+
114
+ // Response size should scale roughly linearly
115
+ const ratio = timings[2].bytes / timings[0].bytes;
116
+ console.log(` Size ratio (${sizes[2]}/${sizes[0]}): ${ratio.toFixed(1)}x (expect ~${(sizes[2] / sizes[0]).toFixed(1)}x)`);
117
+ expect(ratio).toBeGreaterThan(5); // 5000/500 = 10x, allow some dedup
118
+ });
119
+
120
+ // --- Timeout reproduction: short requestTimeout simulates real-world failure ---
121
+
122
+ it('short requestTimeout causes streamMaterialize to fail on bloated _repl', async () => {
123
+ const { db: primary, port: primaryPort } = createPrimary();
124
+ fillRepl(primary, 10000, 1000); // 10k entries × 1KB = ~10MB response
125
+
126
+ const replCount = Object.keys(primary.get('_repl') as Record<string, any>).length;
127
+ expect(replCount).toBe(10000);
128
+
129
+ // Direct client with 1ms timeout — guaranteed to fail, proving the
130
+ // timeout path exists and that materialize has no retry/fallback
131
+ const client = new BodClient({
132
+ url: `ws://localhost:${primaryPort}`,
133
+ requestTimeout: 1,
134
+ });
135
+ clients.push(client);
136
+ await client.connect();
137
+
138
+ let error: Error | null = null;
139
+ try {
140
+ await client.streamMaterialize('_repl', { keepKey: 'path' });
141
+ } catch (e) {
142
+ error = e as Error;
143
+ }
144
+
145
+ expect(error).not.toBeNull();
146
+ console.log(` REPRODUCED: streamMaterialize failed: ${error!.message}`);
147
+ expect(error!.message).toContain('timeout');
148
+ });
149
+
150
+ it('streamMaterialize response exceeds typical WS comfort zone', async () => {
151
+ const { db, port } = createPrimary();
152
+ fillRepl(db, 10000, 1000);
153
+
154
+ const client = new BodClient({ url: `ws://localhost:${port}` });
155
+ clients.push(client);
156
+ await client.connect();
157
+
158
+ const start = Date.now();
159
+ const result = await client.streamMaterialize('_repl', { keepKey: 'path' });
160
+ const elapsed = Date.now() - start;
161
+ const responseSize = JSON.stringify(result).length;
162
+
163
+ console.log(` 10k entries × 1KB: ${(responseSize / 1024 / 1024).toFixed(1)}MB response in ${elapsed}ms`);
164
+ // On real networks with latency, this 10MB+ response would easily exceed 30s timeout
165
+ expect(responseSize).toBeGreaterThan(5 * 1024 * 1024); // >5MB
166
+ });
167
+
168
+ // --- Payload size bomb: fewer entries but huge payloads ---
169
+
170
+ it('large payloads per entry amplify the problem', async () => {
171
+ const { db, port } = createPrimary();
172
+
173
+ // 1000 entries but 2KB each → ~2MB+ materialize response
174
+ const bigPadding = 'y'.repeat(2000);
175
+ for (let i = 0; i < 1000; i++) {
176
+ db.set(`data/big${i}`, {
177
+ content: bigPadding,
178
+ nested: { deep: { value: bigPadding.slice(0, 500) } },
179
+ index: i,
180
+ });
181
+ }
182
+
183
+ const client = new BodClient({ url: `ws://localhost:${port}` });
184
+ clients.push(client);
185
+ await client.connect();
186
+
187
+ const start = Date.now();
188
+ const result = await client.streamMaterialize('_repl', { keepKey: 'path' });
189
+ const elapsed = Date.now() - start;
190
+ const responseSize = JSON.stringify(result).length;
191
+
192
+ console.log(` 1000 entries × 2KB = ${(responseSize / 1024 / 1024).toFixed(2)}MB, ${elapsed}ms`);
193
+ expect(responseSize).toBeGreaterThan(2 * 1024 * 1024); // >2MB
194
+ });
195
+
196
+ // --- Compaction fixes it ---
197
+
198
+ it('compaction reduces _repl and speeds up bootstrap', async () => {
199
+ const { db: primary, port: primaryPort } = createPrimary();
200
+ fillRepl(primary, 5000, 500);
201
+
202
+ const beforeCount = Object.keys(primary.get('_repl') as Record<string, any>).length;
203
+ expect(beforeCount).toBe(5000);
204
+
205
+ // Compact down to 500
206
+ primary.stream.compact('_repl', { maxCount: 500, keepKey: 'path' });
207
+ const afterRepl = primary.get('_repl') as Record<string, any>;
208
+ const afterCount = afterRepl ? Object.keys(afterRepl).length : 0;
209
+ console.log(` Compacted: ${beforeCount} → ${afterCount} entries`);
210
+ expect(afterCount).toBeLessThanOrEqual(500);
211
+
212
+ // Bootstrap should now work fast with compacted stream
213
+ const { db: replica } = createReplica(primaryPort);
214
+ const start = Date.now();
215
+ await replica.replication!.start();
216
+ const elapsed = Date.now() - start;
217
+
218
+ console.log(` Compacted bootstrap: ${elapsed}ms`);
219
+ expect(elapsed).toBeLessThan(3000);
220
+
221
+ await wait(300);
222
+ // Verify latest writes are present (compaction keeps newest by keepKey)
223
+ const val = replica.get('vfs/files/project/src/deep/nested/path/module4999/component.tsx') as any;
224
+ expect(val?.size).toBe(1024 + 4999);
225
+ });
226
+
227
+ // --- Repeated writes to same paths: worst case for non-compacted stream ---
228
+
229
+ it('repeated writes to same paths bloat _repl with duplicates', () => {
230
+ const { db } = createPrimary();
231
+
232
+ // 100 paths × 50 writes each = 5000 _repl entries, but only 100 unique paths
233
+ for (let round = 0; round < 50; round++) {
234
+ for (let i = 0; i < 100; i++) {
235
+ db.set(`config/setting${i}`, { value: round, updated: Date.now() });
236
+ }
237
+ }
238
+
239
+ const repl = db.get('_repl') as Record<string, any>;
240
+ const totalEntries = Object.keys(repl).length;
241
+ console.log(` 100 paths × 50 writes = ${totalEntries} _repl entries`);
242
+ expect(totalEntries).toBe(5000);
243
+
244
+ // Compact with keepKey deduplicates to 100
245
+ db.stream.compact('_repl', { keepKey: 'path' });
246
+ const after = db.get('_repl') as Record<string, any>;
247
+ const afterCount = after ? Object.keys(after).length : 0;
248
+ // snapshot (1) + remaining entries
249
+ console.log(` After compact: ${afterCount} entries (expect ~100 unique paths)`);
250
+ expect(afterCount).toBeLessThanOrEqual(150);
251
+ });
252
+
253
+ // --- No bootstrap protection: replica.start() has no timeout ---
254
+
255
+ it('replica.start() has no built-in timeout (current gap)', async () => {
256
+ const { db: primary, port: primaryPort } = createPrimary();
257
+ fillRepl(primary, 3000, 300);
258
+
259
+ const { db: replica } = createReplica(primaryPort);
260
+
261
+ // Measure: start() blocks until materialize completes — no internal timeout
262
+ const start = Date.now();
263
+ await replica.replication!.start();
264
+ const elapsed = Date.now() - start;
265
+
266
+ console.log(` replica.start() blocked for ${elapsed}ms (no internal timeout)`);
267
+ // This documents the gap: there's no way to bail out of a slow bootstrap
268
+ // P0 fix should add a configurable timeout here
269
+ });
270
+ });