@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.
- package/.claude/skills/config-file.md +6 -0
- package/.claude/skills/deploying-bod-db.md +21 -0
- package/.claude/skills/using-bod-db.md +30 -0
- package/CLAUDE.md +2 -1
- package/README.md +14 -0
- package/docs/para-chat-integration.md +198 -0
- package/docs/repl-stream-bloat-spec.md +48 -0
- package/package.json +1 -1
- package/src/server/BodDB.ts +3 -3
- package/src/server/ReplicationEngine.ts +254 -18
- package/src/server/Transport.ts +57 -29
- package/src/server/VFSEngine.ts +13 -2
- package/src/shared/protocol.ts +1 -0
- package/tests/repl-stream-bloat.test.ts +270 -0
- package/tests/replication-topology.test.ts +835 -0
|
@@ -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
|
+
});
|