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 });
}
});
|