File size: 13,452 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
346
347
348
349
350
351
352
// test/lock.test.js
import { test } from 'vitest';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { MiniDb } from '../src/index.js';
import { LockError, LockFile } from '../src/lockfile.js';

async function tmpDir() {
  return fs.mkdtemp(path.join(os.tmpdir(), 'minidb-lock-'));
}

test('a second writer on the same dir is rejected with LockError', async () => {
  const dir = await tmpDir();
  const db1 = await MiniDb.open({ dir, valueCodec: 'string' });
  try {
    await assert.rejects(() => MiniDb.open({ dir, valueCodec: 'string' }), LockError);
  } finally {
    await db1.close();
    await fs.rm(dir, { recursive: true, force: true });
  }
});

test('lock is released on close, allowing another writer', async () => {
  const dir = await tmpDir();
  const db1 = await MiniDb.open({ dir, valueCodec: 'string' });
  await db1.set('a', '1');
  await db1.close();

  const db2 = await MiniDb.open({ dir, valueCodec: 'string' });
  assert.equal(db2.get('a'), '1');
  await db2.close();
  await fs.rm(dir, { recursive: true, force: true });
});

test('readOnly open succeeds alongside a writer and rejects writes', async () => {
  const dir = await tmpDir();
  const db1 = await MiniDb.open({ dir, valueCodec: 'string' });
  await db1.set('a', '1');
  try {
    const ro = await MiniDb.open({ dir, valueCodec: 'string', readOnly: true });
    assert.equal(ro.readOnly, true);
    assert.equal(ro.get('a'), '1');
    await assert.rejects(() => ro.set('b', '2'), /read-only/);
    await ro.close();
  } finally {
    await db1.close();
    await fs.rm(dir, { recursive: true, force: true });
  }
});

test("onLockFail: 'readonly' degrades instead of throwing", async () => {
  const dir = await tmpDir();
  const db1 = await MiniDb.open({ dir, valueCodec: 'string' });
  try {
    const db2 = await MiniDb.open({ dir, valueCodec: 'string', onLockFail: 'readonly' });
    assert.equal(db2.readOnly, true);
    await db2.close();
  } finally {
    await db1.close();
    await fs.rm(dir, { recursive: true, force: true });
  }
});

test('a stale lock (dead PID) is taken over', async () => {
  const dir = await tmpDir();
  await fs.writeFile(path.join(dir, 'db.lock'), JSON.stringify({ pid: 999999, ts: Date.now() }));
  const db = await MiniDb.open({ dir, valueCodec: 'string' });
  assert.equal(db.readOnly, false);
  await db.set('a', '1');
  assert.equal(db.get('a'), '1');
  await db.close();
  await fs.rm(dir, { recursive: true, force: true });
});

// ---- owner token: per-instance ownership (review #10/#11 regression) ------

test('two same-process contenders over a stale corpse: exactly one wins, zero double-wins', { timeout: 120_000 }, async () => {
  const dir = await tmpDir();
  try {
    const lockPath = path.join(dir, 'db.lock');
    for (let i = 0; i < 100; i++) {
      await fs.writeFile(lockPath, JSON.stringify({ pid: 999999, ts: Date.now() }));
      const a = new LockFile(lockPath);
      const b = new LockFile(lockPath);
      const wins = await Promise.all([a.acquire(), b.acquire()]);
      assert.equal(wins.filter(Boolean).length, 1, `iteration ${i}: exactly one winner`);
      await a.release();
      await b.release();
      // The winner's release unlinked its own lock; nothing may be left.
      assert.equal(
        await fs.stat(lockPath).then(() => true, () => false),
        false,
        `iteration ${i}: lock released`,
      );
    }
  } finally {
    await fs.rm(dir, { recursive: true, force: true });
  }
});

test('a racing renew() never re-publishes a released lock (100 iterations)', { timeout: 60_000 }, async () => {
  const dir = await tmpDir();
  try {
    for (let i = 0; i < 100; i++) {
      const lockPath = path.join(dir, `db-${i}.lock`);
      const lock = new LockFile(lockPath);
      assert.equal(await lock.acquire(), true);
      await Promise.all([lock.renew(), lock.release()]);
      assert.equal(lock.held, false);
      assert.equal(
        await fs.stat(lockPath).then(() => true, () => false),
        false,
        `iteration ${i}: no ghost lock`,
      );
    }
  } finally {
    await fs.rm(dir, { recursive: true, force: true });
  }
});

test('renew() keeps the owner token, so release() still recognizes the lock', async () => {
  const dir = await tmpDir();
  try {
    const lockPath = path.join(dir, 'db.lock');
    const lock = new LockFile(lockPath);
    assert.equal(await lock.acquire(), true);
    const before = JSON.parse(await fs.readFile(lockPath, 'utf8')) as { token?: string };
    await lock.renew();
    const after = JSON.parse(await fs.readFile(lockPath, 'utf8')) as { token?: string };
    assert.equal(after.token, before.token, 'renew preserves the owner token');
    await lock.release();
    assert.equal(await fs.stat(lockPath).then(() => true, () => false), false);
  } finally {
    await fs.rm(dir, { recursive: true, force: true });
  }
});

test('acquire() on an already-held lock is an idempotent true and does not re-mint the token', async () => {
  const dir = await tmpDir();
  try {
    const lockPath = path.join(dir, 'db.lock');
    const lock = new LockFile(lockPath);
    assert.equal(await lock.acquire(), true);
    const before = JSON.parse(await fs.readFile(lockPath, 'utf8')) as { token?: string };
    assert.equal(await lock.acquire(), true, 're-entrant acquire reports the held lock');
    const after = JSON.parse(await fs.readFile(lockPath, 'utf8')) as { token?: string };
    assert.equal(after.token, before.token, 'token is not re-minted');
    await lock.release();
    assert.equal(
      await fs.stat(lockPath).then(() => true, () => false),
      false,
      'release still recognizes and unlinks its own lock',
    );
  } finally {
    await fs.rm(dir, { recursive: true, force: true });
  }
});

test('a legacy tokenless lock file is respected while alive and taken over when dead', async () => {
  const dir = await tmpDir();
  try {
    // Live same-pid owner without a token: respected exactly as before — the
    // protocol does not open a "I know the old instance is gone" backdoor.
    const livePath = path.join(dir, 'live.lock');
    const legacyLive = JSON.stringify({ pid: process.pid, ts: Date.now() });
    await fs.writeFile(livePath, legacyLive);
    const contender = new LockFile(livePath);
    assert.equal(await contender.acquire(), false);
    assert.equal(await fs.readFile(livePath, 'utf8'), legacyLive, 'live legacy lock untouched');

    // Dead-pid owner without a token: taken over via the stale rules, and the
    // new lock line carries the instance token.
    const deadPath = path.join(dir, 'dead.lock');
    await fs.writeFile(deadPath, JSON.stringify({ pid: 999999, ts: Date.now() }));
    const taker = new LockFile(deadPath);
    assert.equal(await taker.acquire(), true);
    const taken = JSON.parse(await fs.readFile(deadPath, 'utf8')) as { pid: number; token?: string };
    assert.equal(taken.pid, process.pid);
    assert.ok(
      typeof taken.token === 'string' && taken.token.startsWith(`${process.pid}:`),
      'taken-over lock line carries the owner token',
    );
    await taker.release();
  } finally {
    await fs.rm(dir, { recursive: true, force: true });
  }
});

// ---- close() state machine: exception-safe cleanup (review #12 regression) -

test('close() still releases the lock when the WAL close fails; a retry finishes the cleanup', async () => {
  const dir = await tmpDir();
  try {
    const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no' });
    await db.set('a', '1');
    const internals = db as unknown as { wal: { close(): Promise<void> } };
    const originalClose = internals.wal.close.bind(internals.wal);
    let failClose = true;
    internals.wal.close = async () => {
      await originalClose();
      if (failClose) {
        failClose = false;
        throw new Error('injected close failure');
      }
    };
    const err = await db.close().catch((e: unknown) => e);
    assert.ok(err instanceof AggregateError, 'close failure is aggregated');
    assert.equal(err.errors.length, 1);
    assert.match(String(err.errors[0]), /injected close failure/);
    // The lock release was NOT skipped by the WAL failure.
    assert.equal(
      await fs.stat(path.join(dir, 'db.lock')).then(() => true, () => false),
      false,
      'lock released despite the WAL close failure',
    );
    // Idempotent continuation: the second pass completes the cleanup.
    await db.close();
    await db.close(); // fully closed now: a no-op
    // The directory is free: a fresh writer opens with no LockError.
    const reopened = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no' });
    assert.equal(reopened.get('a'), '1');
    await reopened.close();
  } finally {
    await fs.rm(dir, { recursive: true, force: true });
  }
});

test('close() aggregates every cleanup error instead of stopping at the first', async () => {
  const dir = await tmpDir();
  try {
    const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no' });
    const internals = db as unknown as {
      wal: { close(): Promise<void> };
      lock: { release(): Promise<void> } | null;
    };
    const originalWalClose = internals.wal.close.bind(internals.wal);
    let failWal = true;
    internals.wal.close = async () => {
      await originalWalClose();
      if (failWal) {
        failWal = false;
        throw new Error('injected wal close failure');
      }
    };
    const lock = internals.lock!;
    const originalRelease = lock.release.bind(lock);
    let failRelease = true;
    lock.release = async () => {
      if (failRelease) {
        failRelease = false;
        throw new Error('injected lock release failure');
      }
      return originalRelease();
    };
    const err = await db.close().catch((e: unknown) => e);
    assert.ok(err instanceof AggregateError, 'close failure is aggregated');
    assert.deepEqual(
      err.errors.map((e) => (e instanceof Error ? e.message : String(e))),
      ['injected wal close failure', 'injected lock release failure'],
      'AggregateError carries every cleanup error',
    );
    // Retry: the WAL close is now a no-op, the lock release runs for real.
    await db.close();
    assert.equal(
      await fs.stat(path.join(dir, 'db.lock')).then(() => true, () => false),
      false,
      'lock released on the retry',
    );
    // The instance refused use from the first 'closing' transition on.
    await assert.rejects(() => db.set('b', '2'), /MiniDb is closed/);
    await assert.rejects(() => db.compact(), /MiniDb is closed/);
  } finally {
    await fs.rm(dir, { recursive: true, force: true });
  }
});

test('close() waits out a failing in-flight compaction and still cleans up every resource', async () => {
  const dir = await tmpDir();
  try {
    const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no' });
    await db.set('a', '1');
    // Keep the compaction deterministically in flight when close() starts, and
    // fail it (via the onCompacted hook, a real compactError path) while
    // close() is parked on _compactDone.
    let failHook: ((e: Error) => void) | undefined;
    db.onCompacted = () =>
      new Promise<void>((_, reject) => {
        failHook = reject;
      });
    const compacted = db.compact().catch(() => {});
    // Wait until the compaction actually reaches the injected hook (the
    // compacting flag flips long before the hook is invoked).
    while (!failHook) await new Promise((r) => setTimeout(r, 1));
    const closing = db.close();
    failHook(new Error('injected compaction failure'));
    await compacted;
    // The compaction failure is accounted on the instance, but close() must
    // not reject with it and must not skip the cleanup.
    await closing;
    assert.match(String(db.lastCompactError), /injected compaction failure/);
    assert.equal(
      await fs.stat(path.join(dir, 'db.lock')).then(() => true, () => false),
      false,
      'lock released despite the failed compaction',
    );
    await db.close(); // fully closed now: a no-op
    const reopened = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no' });
    await reopened.close();
  } finally {
    await fs.rm(dir, { recursive: true, force: true });
  }
});

test('onLockAcquired fires with the held token; read-only fallback opens skip it', async () => {
  const dir = await tmpDir();
  let reported;
  const db1 = await MiniDb.open({
    dir,
    valueCodec: 'string',
    onLockAcquired: (info) => {
      reported = info.token;
    },
  });
  try {
    // Fired before open() resolved, and matches the published lock line —
    // a supervisor learns the lock identity before any heavy recovery ran.
    assert.ok(reported);
    const line = JSON.parse(await fs.readFile(path.join(dir, 'db.lock'), 'utf8'));
    assert.equal(line.token, reported);
    assert.equal(line.pid, process.pid);

    // A read-only fallback open (the lock is busy) never fires the callback.
    let second;
    const ro = await MiniDb.open({
      dir,
      valueCodec: 'string',
      onLockFail: 'readonly',
      onLockAcquired: (info) => {
        second = info.token;
      },
    });
    assert.equal(ro.readOnly, true);
    assert.equal(second, undefined);
    await ro.close();
  } finally {
    await db1.close();
    await fs.rm(dir, { recursive: true, force: true });
  }
});