File size: 15,586 Bytes
4e23b01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
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
152
153
154
155
156
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
188
189
190
191
192
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
233
234
235
236
237
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
283
284
285
286
287
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
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
// test/cluster/concurrent.test.js
//
// True multi-process concurrent read/write tests. Each scenario spawns real
// child processes (node --import tsx mp-worker.ts) that open the same cluster
// directory concurrently, and verifies data integrity afterwards.

import { test } from 'vitest';
import assert from 'node:assert/strict';
import path from 'node:path';
import { MiniDb } from '../../src/index.js';
import { ClusterDb, shardDirName } from '../../src/cluster/index.js';
import { ShardLockPool } from '../../src/cluster/lock-pool.js';
import { shardFor } from '../../src/cluster/utils.js';
import { tmpDir } from '../e2e/helpers/tmp.js';
import { keyOnShard, runWorker, runWorkerOk, rmrf, sleep } from './helpers.js';

test(
  'P=4 processes write disjoint keyspaces concurrently (S=8); all data survives',
  { timeout: 180_000 },
  async () => {
    const dir = await tmpDir('minidb-cluster-mp-');
    try {
      const P = 4;
      const N = 150;
      const runs = Array.from({ length: P }, (_, p) =>
        runWorkerOk(['write', dir, '8', `p${p}`, String(N)], { timeoutMs: 120_000 }),
      );
      const reports = await Promise.all(runs);
      for (const r of reports) assert.equal(r.n, N);

      // Verify from a brand-new process: every key, every value.
      await Promise.all(
        Array.from({ length: P }, (_, p) => runWorkerOk(['verify', dir, '8', `p${p}`, String(N)], { timeoutMs: 120_000 })),
      );

      // Double-check through a read-only cluster in this process.
      const db = await ClusterDb.open<{ p: string; i: number }>({ dir, readOnly: true });
      let count = 0;
      for (const e of await db.scan()) {
        assert.equal(e.key, `${e.value.p}:${e.value.i}`);
        count++;
      }
      assert.equal(count, P * N);
      await db.close();
    } finally {
      await rmrf(dir);
    }
  },
);

test(
  'concurrent writers on the SAME shard serialize safely with no lost writes',
  { timeout: 180_000 },
  async () => {
    const dir = await tmpDir('minidb-cluster-mp-');
    try {
      const shards = 4;
      const targetShard = 2;
      const procs = 3;
      const perProc = 40;
      // Precompute disjoint keys that all route to the same shard.
      const keysPerProc = Array.from({ length: procs }, (_, p) =>
        Array.from({ length: perProc }, (_, i) => keyOnShard(`hot${p}:${i}`, targetShard, shards)),
      );
      const runs = keysPerProc.map((keys) =>
        runWorkerOk(['writekeys', dir, String(shards), keys.join(',')], { timeoutMs: 150_000 }),
      );
      const reports = await Promise.all(runs);
      // Retries are expected under contention but not required; log for insight.
      const retries = reports.map((r) => r.retries);

      const db = await ClusterDb.open<{ key: string }>({ dir, shardCount: shards, readOnly: true });
      const allKeys = keysPerProc.flat();
      const got = await db.mget(allKeys);
      assert.deepEqual(
        got.map((v) => v?.key),
        allKeys,
        `no lost writes on a hot shard (retries: ${retries.join('/')})`,
      );
      await db.close();
    } finally {
      await rmrf(dir);
    }
  },
);

test(
  'a reader process observes commits from a concurrently running writer process',
  { timeout: 180_000 },
  async () => {
    const dir = await tmpDir('minidb-cluster-mp-');
    try {
      // Start a long-polling reader BEFORE the data exists. The inner wait
      // budget must absorb writer process startup on heavily loaded CI
      // runners (writer spawn + first open can take tens of seconds there).
      const waiter = runWorkerOk(['wait-read', dir, '4', 'live:k', '42', '120000'], { timeoutMs: 150_000 });
      // Give the reader a head start so it really polls with a cold cache.
      await new Promise((r) => setTimeout(r, 500));

      const db = await ClusterDb.open({ dir, shardCount: 4, valueCodec: 'json' });
      await db.set('live:k', { i: 42 });
      const report = await waiter;
      assert.ok(typeof report.waitedMs === 'number');
      await db.close();
    } finally {
      await rmrf(dir);
    }
  },
);

test(
  'mixed read/write storm across processes keeps all committed data readable',
  { timeout: 240_000 },
  async () => {
    const dir = await tmpDir('minidb-cluster-mp-');
    try {
      const writers = Array.from({ length: 3 }, (_, p) =>
        runWorkerOk(['write', dir, '8', `storm${p}`, '100'], { timeoutMs: 150_000 }),
      );
      // Concurrent read-only verifiers race the writers; they may legitimately
      // see partial progress, so only assert they never error out.
      const racingReaders = Array.from({ length: 2 }, () =>
        runWorker(['wait-read', dir, '8', 'storm0:0', '0', '90000'], { timeoutMs: 120_000 }),
      );
      const writerReports = await Promise.all(writers);
      const readerResults = await Promise.all(racingReaders);
      for (const w of writerReports) assert.equal(w.n, 100);
      for (const r of readerResults) assert.equal(r.code, 0, `racing reader exited cleanly: ${r.stderr}`);

      // After quiesce, the full dataset is visible to a new process.
      for (let p = 0; p < 3; p++) {
        await runWorkerOk(['verify', dir, '8', `storm${p}`, '100'], { timeoutMs: 120_000 });
      }
    } finally {
      await rmrf(dir);
    }
  },
);


/** Dual-check: every view of the cached reader on the hot shard must equal a
 *  from-scratch full open of the same shard files. */
type Doc = Record<string, unknown>;

async function assertSameAsFreshOpen(db: ClusterDb<Doc>, dir: string, hot: number): Promise<void> {
  const sortByKey = (a: { key: string }, b: { key: string }): number => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
  const ref = await MiniDb.open<Doc>({ dir: path.join(dir, shardDirName(hot, db.shardCount)), readOnly: true, valueCodec: 'json' });
  try {
    assert.deepEqual(await db.scan(), ref.scan(), 'scan equality with a fresh full open');
    assert.deepEqual((await db.findEq('c', 'c3')).sort(sortByKey), ref.findEq('c', 'c3').sort(sortByKey), 'findEq equality');
    const [crange, rrange] = [await db.findRange('n', { min: 100, max: 900 }), ref.findRange('n', { min: 100, max: 900 })];
    assert.deepEqual(crange.sort(sortByKey), rrange.sort(sortByKey), 'findRange equality');
    assert.deepEqual(await db.findEq('u', 'pre-u42'), ref.findEq('u', 'pre-u42'), 'unique-index equality');
    const [cs, rs] = [await db.search('t', 'alpha'), ref.search('t', 'alpha')];
    assert.deepEqual(cs.sort(sortByKey), rs.sort(sortByKey), 'text search equality');
  } finally {
    await ref.close();
  }
}

test(
  'reader WAL catch-up: incremental tail apply equals a full reopen (multi-process)',
  { timeout: 180_000 },
  async () => {
    const dir = await tmpDir('minidb-cluster-mp-');
    try {
      const shards = 4;
      const hot = 1;
      // Preload 30k keys on the hot shard (secondary eq+range indexes, one
      // unique index, one text index, dt column on every key), then release
      // all locks so the storm child can write.
      const setup = await ClusterDb.open<Doc>({ dir, shardCount: shards, valueCodec: 'json', fsyncPolicy: 'no', lockHoldMs: 0 });
      const pre: string[] = [];
      for (let seq = 0; pre.length < 30_000; seq++) {
        const key = `pre:${seq}`;
        if (shardFor(key, shards) === hot) pre.push(key);
      }
      for (let j = 0; j < pre.length; j++) {
        await setup.set(pre[j]!, { n: j, c: `c${j % 11}`, u: `pre-u${j}`, t: `alpha beta pre w${j % 17}` }, { dt: { created: 1700000100000 + j } });
      }
      await setup.createIndex('c', { field: 'c' });
      await setup.createIndex('n', { field: 'n', type: 'range' });
      await setup.createIndex('u', { field: 'u', unique: true });
      await setup.createTextIndex('t', { fields: ['t'] });
      await setup.close();

      // The parent's read-only cluster: first read opens and warms the cached
      // reader of the hot shard (records the WAL watermark).
      const db = await ClusterDb.open<Doc>({ dir, readOnly: true });
      assert.equal((await db.get(pre[0]!))?.n, 0);
      const stats0 = db.stats();
      assert.equal(stats0.readerReopens, 0);
      assert.equal(stats0.incrementalCatchups, 0);

      // Mixed storm from a child process: sets, updates, dels, TYPE_BATCH
      // batches, short/long TTL sets, dt changes — all on the hot shard.
      const report = await runWorkerOk(['storm', dir, String(shards), String(hot), 'storm', '4000'], { timeoutMs: 120_000 });
      // Outlive the 50ms short TTLs so replay-side expiry is deterministic.
      await sleep(150);

      // The first read after the storm catches the cached reader up by
      // applying ONLY the appended frames (exactly the storm's frame count);
      // no full reader reopen is allowed on this pure-append path.
      await db.get(pre[1]!);
      const stats1 = db.stats();
      assert.ok(stats1.incrementalCatchups > 0, 'incremental catch-up happened');
      assert.equal(stats1.readerReopens - stats0.readerReopens, 0, 'no full reopen on the pure-append path');
      assert.equal(stats1.catchupFramesApplied - stats0.catchupFramesApplied, report.frames, 'every storm frame applied exactly once');

      // Every view equals a from-scratch full open of the shard.
      await assertSameAsFreshOpen(db, dir, hot);

      // Control: a quiet shard (no writes) — reads cost only the fingerprint.
      const quiet = keyOnShard('quiet', 3, shards);
      await db.get(quiet); // first read caches the shard's reader
      const stats2 = db.stats();
      for (let k = 0; k < 200; k++) assert.equal(await db.get(quiet), undefined);
      const stats3 = db.stats();
      assert.equal(stats3.incrementalCatchups, stats2.incrementalCatchups, 'no catch-ups without writes');
      assert.equal(stats3.readerReopens, stats2.readerReopens, 'no reopens without writes');
      await db.close();
    } finally {
      await rmrf(dir);
    }
  },
);

test(
  'reader falls back to a full reopen on compaction rotation, then resumes incrementally',
  { timeout: 180_000 },
  async () => {
    const dir = await tmpDir('minidb-cluster-mp-');
    try {
      const shards = 4;
      const hot = 1;
      const setup = await ClusterDb.open<Doc>({ dir, shardCount: shards, valueCodec: 'json', fsyncPolicy: 'no', lockHoldMs: 0 });
      const pre: string[] = [];
      for (let seq = 0; pre.length < 1_000; seq++) {
        const key = `pre:${seq}`;
        if (shardFor(key, shards) === hot) pre.push(key);
      }
      for (let j = 0; j < pre.length; j++) {
        await setup.set(pre[j]!, { n: j, c: `c${j % 11}`, u: `pre-u${j}`, t: `alpha beta pre w${j % 17}` }, { dt: { created: 1700000100000 + j } });
      }
      await setup.createIndex('c', { field: 'c' });
      await setup.createIndex('n', { field: 'n', type: 'range' });
      await setup.createIndex('u', { field: 'u', unique: true });
      await setup.createTextIndex('t', { fields: ['t'] });
      await setup.close();

      const db = await ClusterDb.open<Doc>({ dir, readOnly: true });
      await db.get(pre[0]!); // warm the cached reader
      const stats0 = db.stats();

      // Storm with a tiny compaction threshold: the WAL/snapshot are rotated
      // (possibly several times) while the parent's reader is cached.
      await runWorkerOk(['storm', dir, String(shards), String(hot), 'rot', '1500', '60000', '1'], { timeoutMs: 120_000 });
      await sleep(150);

      await db.get(pre[1]!);
      const stats1 = db.stats();
      assert.ok(stats1.readerReopens - stats0.readerReopens >= 1, 'rotation forced a full reopen');
      assert.equal(stats1.incrementalCatchups - stats0.incrementalCatchups, 0, 'no incremental progress across a rotation');
      await assertSameAsFreshOpen(db, dir, hot);

      // A follow-up append-only storm is caught incrementally again.
      const report2 = await runWorkerOk(['storm', dir, String(shards), String(hot), 'rot2', '800', '0', '0'], { timeoutMs: 120_000 });
      await sleep(150);
      await db.get(pre[2]!);
      const stats2 = db.stats();
      assert.ok(stats2.incrementalCatchups - stats1.incrementalCatchups > 0, 'incremental catch-up resumed after the reopen');
      assert.equal(stats2.readerReopens - stats1.readerReopens, 0, 'no further reopen');
      assert.equal(stats2.catchupFramesApplied - stats1.catchupFramesApplied, report2.frames, 'second storm applied frame-exactly');
      await assertSameAsFreshOpen(db, dir, hot);
      await db.close();
    } finally {
      await rmrf(dir);
    }
  },
);

test(
  'fingerprint: a compound-index-only definition change refreshes the cached reader (multi-process)',
  { timeout: 180_000 },
  async () => {
    const dir = await tmpDir('minidb-cluster-mp-');
    try {
      const shards = 4;
      const hot = 1;
      // Seed the hot shard and release all locks.
      const setup = await ClusterDb.open<Doc>({ dir, shardCount: shards, valueCodec: 'json', fsyncPolicy: 'no', lockHoldMs: 0 });
      const pre: string[] = [];
      for (let seq = 0; pre.length < 200; seq++) {
        const key = `pre:${seq}`;
        if (shardFor(key, shards) === hot) pre.push(key);
      }
      for (let j = 0; j < pre.length; j++) {
        await setup.set(pre[j]!, { n: j, c: `c${j % 7}` });
      }
      await setup.close();

      // The cached read-only shard reader under test. The pool IS the
      // fingerprint machinery (a ClusterDb would not expose shard-level
      // compound definitions to assert on), so drive it directly.
      const pool = new ShardLockPool({
        writerOpts: { valueCodec: 'json' },
        readerOpts: { valueCodec: 'json' },
        lockRenewMs: 0,
        lockAcquireTimeoutMs: 5_000,
        lockHoldMs: 0,
        maxWriters: 4,
        maxReaders: 4,
        readOnly: true,
        applyDefs: async () => {},
      });
      const shardDir = path.join(dir, shardDirName(hot, shards));
      const compoundNames = () => pool.withReader(hot, shardDir, (db) => db.listCompoundIndexes().map((i) => i.name));
      assert.deepEqual(await compoundNames(), []); // warms and caches the reader
      const stats0 = { ...pool.stats };

      // A writer PROCESS creates only a compound index: no data writes, just
      // the sidecar rewrite. The next read must detect it from the
      // fingerprint and FULLY reopen — an incremental WAL catch-up cannot
      // carry a definition change. (Before the fingerprint tracked
      // db.compound-indexes.json this change was invisible forever.)
      await runWorkerOk(['compound-defs', dir, String(shards), 'create'], { timeoutMs: 120_000 });
      assert.deepEqual(await compoundNames(), ['cg'], 'the refreshed reader sees the new compound index');
      assert.equal(pool.stats.readerReopens - stats0.readerReopens, 1, 'the sidecar change forced a full reopen');
      assert.equal(pool.stats.incrementalCatchups - stats0.incrementalCatchups, 0, 'no incremental catch-up on a def-only change');
      // The refreshed instance still serves data correctly.
      const v = await pool.withReader(hot, shardDir, (db) => db.get(pre[0]!));
      assert.equal((v as Doc | undefined)?.n, 0);

      // Dropping it again is detected the same way.
      await runWorkerOk(['compound-defs', dir, String(shards), 'drop'], { timeoutMs: 120_000 });
      assert.deepEqual(await compoundNames(), [], 'the refreshed reader sees the drop');
      assert.equal(pool.stats.readerReopens - stats0.readerReopens, 2);
      assert.equal(pool.stats.incrementalCatchups - stats0.incrementalCatchups, 0);
      await pool.closeAll();
    } finally {
      await rmrf(dir);
    }
  },
);