File size: 18,059 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 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 | // src/cluster/lock-pool.ts
//
// Per-process cache of opened shards.
//
// Writers: MiniDb instances holding the shard's db.lock. While a process
// holds a shard's write lock it is by definition the only writer of that
// shard, so the cached writer's in-memory view is authoritative and current.
// Cached writers are LRU-evicted (only when idle) down to a soft cap.
//
// Readers: read-only MiniDb instances used for keys whose shard this process
// does not currently hold. A MiniDb reader replays snapshot+WAL only at open
// time and would go stale afterwards, so every reader use is guarded by a
// cheap file fingerprint (dev:ino:size:mtimeMs of the shard's WAL, snapshot,
// CURRENT, and every index-definition sidecar β FINGERPRINT_FILES, derived
// from the authoritative generation module so a newly added file can never be
// missed). A change refreshes the reader first:
// - when only the WAL changed as pure appends on the same inode (tracked by
// a {dev, ino, size} watermark), the appended frames are scanned and
// applied incrementally (MiniDb.catchUpFromWal) β O(delta);
// - anything else (rotation, truncation, a generation switch on CURRENT,
// snapshot/index-def changes, an offset that turns out not to be a frame
// boundary) falls back to a close + full reopen β with a published
// generation that reopen loads the new checkpoint instead of rebuilding
// every index from the full corpus.
// Because a writer's WAL append is complete before its set() resolves, a read
// that starts after another process's write resolved always observes it.
import fs from 'node:fs/promises';
import path from 'node:path';
import type { MiniDb } from '../index.js';
import { LockError } from '../lockfile.js';
import { OpTracker } from '../op-tracker.js';
import { FINGERPRINT_FILES } from '../generation.js';
import { ShardHandle } from './shard.js';
import type { ShardOpenOptions } from './shard.js';
import { sleep } from './utils.js';
export interface LockPoolOptions {
writerOpts: ShardOpenOptions;
readerOpts: ShardOpenOptions;
lockRenewMs: number;
lockAcquireTimeoutMs: number;
lockHoldMs: number;
maxWriters: number;
maxReaders: number;
/** Cluster-wide read-only: withWriter rejects, withReader never uses cached writers. */
readOnly: boolean;
/** Apply cluster-wide index definitions to a freshly opened writer. */
applyDefs: (db: MiniDb<unknown>) => Promise<void>;
}
interface WriterEntry {
handle: ShardHandle;
lastUsedAt: number;
busy: number;
/** Set once the hold window expired while busy; closed at the next idle point. */
retire: boolean;
/** When the process should yield this shard's lock (Infinity: never by time). */
expiresAt: number;
}
interface ReaderEntry {
handle: ShardHandle;
fingerprint: string;
/** Per-file fingerprint parts, in FINGERPRINT_FILES order. */
fpParts: string[];
/** WAL watermark the instance's data represents: the {dev, ino} anchor of
* the WAL inode recovery (or the last catch-up) read, and the byte offset
* up to which frames were applied. null when the shard has no WAL yet. */
walMark: { dev: number; ino: number; size: number } | null;
lastUsedAt: number;
busy: number;
}
async function statFingerprint(file: string): Promise<string> {
try {
const s = await fs.stat(file);
// dev:ino:size:mtimeMs β sidecars are replaced by rename (tmp + rename),
// so the inode eliminates the "same size, same mtime alias" window a
// size+mtime fingerprint would leave open.
return `${s.dev}:${s.ino}:${s.size}:${s.mtimeMs}`;
} catch {
return '-';
}
}
async function shardFingerprint(dir: string): Promise<string[]> {
return Promise.all(FINGERPRINT_FILES.map((f) => statFingerprint(path.join(dir, f))));
}
/** WAL watermark for a freshly opened reader: the exact inode recovery
* scanned and the offset up to which frames were replayed β never an offset
* beyond the replayed state, so frames appended during/after the open are
* picked up by a later incremental catch-up instead of being skipped. */
function readerWalMark(handle: ShardHandle): ReaderEntry['walMark'] {
const ri = handle.db.recoveryInfo;
if (!ri || !ri.walIno) return null;
return { dev: ri.walDev, ino: ri.walIno, size: ri.walScanEnd };
}
export class ShardLockPool {
private readonly writers = new Map<number, WriterEntry>();
private readonly readers = new Map<number, ReaderEntry>();
private readonly openingWriters = new Map<number, Promise<WriterEntry>>();
private readonly openingReaders = new Map<number, Promise<ReaderEntry>>();
private closed = false;
/** Lifecycle gates behind closeAll() (review #18): every withWriter /
* withReader β acquire AND callback β runs inside one enter/leave, so the
* trackers' drain in closeAll() means "no callback can still touch a
* handle". The per-entry busy counters stay: LRU eviction, retire and the
* reader-reopen drain make their synchronous decisions on them. */
private readonly writerOps = new OpTracker();
private readonly readerOps = new OpTracker();
readonly stats = {
writerOpens: 0,
readerOpens: 0,
readerReopens: 0,
incrementalCatchups: 0,
catchupFramesApplied: 0,
lockWaits: 0,
evictions: 0,
};
constructor(private readonly opts: LockPoolOptions) {}
get writersCached(): number {
return this.writers.size;
}
get readersCached(): number {
return this.readers.size;
}
/** Run fn against the shard's writer, opening it (with lock retry) if this
* process does not hold it yet. The writer cannot be evicted while busy. */
async withWriter<T>(shardId: number, dir: string, fn: (db: MiniDb<unknown>) => T | Promise<T>): Promise<T> {
if (this.opts.readOnly) throw new Error('ClusterDb is open in read-only mode');
if (!this.writerOps.enter()) throw new Error('ClusterDb is closed');
try {
const entry = await this.acquireWriter(shardId, dir);
entry.busy++;
try {
return await fn(entry.handle.db);
} finally {
entry.busy--;
entry.lastUsedAt = Date.now();
if (entry.retire && entry.busy === 0) {
// The hold window expired while ops were in flight: yield the lock so
// other processes can take the shard over.
if (this.writers.get(shardId) === entry) this.writers.delete(shardId);
await entry.handle.close().catch(() => {});
}
await this.evictWriters();
}
} finally {
this.writerOps.leave();
}
}
/** Run fn against the best available read view of the shard: the cached
* writer when this process holds the shard (current and lock-free), else a
* fingerprint-revalidated read-only instance. */
async withReader<T>(shardId: number, dir: string, fn: (db: MiniDb<unknown>) => T | Promise<T>): Promise<T> {
if (!this.readerOps.enter()) throw new Error('ClusterDb is closed');
try {
if (!this.opts.readOnly) {
const w = this.writers.get(shardId);
if (w) {
w.busy++;
try {
return await fn(w.handle.db);
} finally {
w.busy--;
w.lastUsedAt = Date.now();
}
}
}
const entry = await this.acquireReader(shardId, dir);
entry.busy++;
try {
return await fn(entry.handle.db);
} finally {
entry.busy--;
entry.lastUsedAt = Date.now();
await this.evictReaders();
}
} finally {
this.readerOps.leave();
}
}
async closeAll(): Promise<void> {
if (this.closed) return;
this.closed = true;
// Close both gates and wait for every in-flight op β including its user
// callback β to settle (review #18). New withWriter/withReader reject at
// enter() from now on, so after the drain no busy counter can be non-zero
// and no callback can still touch a handle. Before this, closeAll was the
// pool's only close path that ignored busy: it closed handles under live
// callbacks, which then died on 'MiniDb is closed'.
await Promise.all([this.writerOps.close(), this.readerOps.close()]);
// Defense in depth: opens in flight are wrapped by the trackers above,
// but a late entry landing here after the drain would outlive closeAll β
// holding its lock and recreating db.wal/lock files after the owner had
// already started tearing the directory down.
for (const opening of [...this.openingWriters.values(), ...this.openingReaders.values()]) {
await opening.catch(() => {});
}
const writers = [...this.writers.values()];
const readers = [...this.readers.values()];
this.writers.clear();
this.readers.clear();
const results = await Promise.allSettled([...writers, ...readers].map((e) => e.handle.close()));
const failed = results.find((r): r is PromiseRejectedResult => r.status === 'rejected');
if (failed) throw failed.reason;
}
// ---- writers ------------------------------------------------------------
private async acquireWriter(shardId: number, dir: string): Promise<WriterEntry> {
const cached = this.writers.get(shardId);
if (cached) {
if (cached.expiresAt > Date.now()) {
cached.lastUsedAt = Date.now();
return cached;
}
// Hold window expired: yield. If ops are in flight, serve this one too
// (it is short) and close at the next idle point.
if (cached.busy > 0) {
cached.retire = true;
return cached;
}
if (this.writers.get(shardId) === cached) this.writers.delete(shardId);
await cached.handle.close().catch(() => {});
}
const inflight = this.openingWriters.get(shardId);
if (inflight) return inflight;
const opening = this.openWriter(shardId, dir).finally(() => this.openingWriters.delete(shardId));
this.openingWriters.set(shardId, opening);
return opening;
}
private async openWriter(shardId: number, dir: string): Promise<WriterEntry> {
const deadline = Date.now() + this.opts.lockAcquireTimeoutMs;
let delay = 10;
for (;;) {
try {
const handle = await ShardHandle.openWriter(shardId, dir, this.opts.writerOpts, this.opts.lockRenewMs);
this.stats.writerOpens++;
try {
await this.opts.applyDefs(handle.db);
} catch (e) {
await handle.close().catch(() => {});
throw e;
}
const entry: WriterEntry = {
handle,
lastUsedAt: Date.now(),
busy: 0,
retire: false,
expiresAt: this.opts.lockHoldMs > 0 ? Date.now() + this.opts.lockHoldMs : Infinity,
};
if (this.opts.lockHoldMs > 0) {
// Proactively yield the lock at the end of the hold window even if
// this process goes idle, so a waiting process is never starved by
// a holder that stopped writing but kept the process alive.
const timer = setTimeout(() => {
if (entry.busy > 0) {
entry.retire = true;
return;
}
if (!this.writerOps.enter()) return;
if (this.writers.get(shardId) === entry) this.writers.delete(shardId);
void entry.handle
.close()
.catch(() => {})
.finally(() => this.writerOps.leave());
}, this.opts.lockHoldMs);
timer.unref();
}
this.writers.set(shardId, entry);
return entry;
} catch (e) {
// Apply-time failures (e.g. a unique index that does not backfill) are
// permanent; only lock contention is retried, until the deadline.
if (!(e instanceof LockError) || Date.now() + delay > deadline) throw e;
this.stats.lockWaits++;
await sleep(delay + Math.floor(Math.random() * delay));
delay = Math.min(delay * 2, 250);
}
}
}
private async evictWriters(): Promise<void> {
while (this.writers.size > this.opts.maxWriters) {
let victim: WriterEntry | null = null;
for (const entry of this.writers.values()) {
if (entry.busy > 0) continue;
if (!victim || entry.lastUsedAt < victim.lastUsedAt) victim = entry;
}
if (!victim) return; // everything busy: soft cap exceeded temporarily
this.writers.delete(victim.handle.shardId);
this.stats.evictions++;
await victim.handle.close().catch(() => {});
}
}
// ---- readers ------------------------------------------------------------
private async acquireReader(shardId: number, dir: string): Promise<ReaderEntry> {
const inflight = this.openingReaders.get(shardId);
if (inflight) return inflight;
const opening = this.refreshReader(shardId, dir).finally(() => this.openingReaders.delete(shardId));
this.openingReaders.set(shardId, opening);
return opening;
}
private async refreshReader(shardId: number, dir: string): Promise<ReaderEntry> {
const cached = this.readers.get(shardId);
const parts = await shardFingerprint(dir);
const fp = parts.join('|');
if (cached && cached.fingerprint === fp) {
cached.lastUsedAt = Date.now();
return cached;
}
if (cached) {
// Something in the shard changed. When the change is confined to the
// WAL (parts[0]) β i.e. the snapshot and EVERY sidecar are unchanged β
// it can only be WAL appends on the same inode, so apply just those
// frames instead of paying for a full replay (fallback: a clean full
// reopen below). A compound/secondary/text definition change lands on
// this reopen path too: it rewrites its sidecar, which the fingerprint
// tracks.
//
// CURRENT (parts[2]) is deliberately NOT part of the wal-only verdict:
// a pure generation publish (background build / manual rebuild) changes
// CURRENT without rotating the snapshot, and the reader's WAL-catch-up
// view stays perfectly valid β forcing a reopen would just churn. A
// compaction always rotates the snapshot, and THAT is what sends the
// reader to a reopen (which then loads the new generation instead of
// rebuilding from the corpus).
let walOnly = true;
for (let i = 1; i < parts.length; i++) {
if (i === 2) continue; // CURRENT β see above
if (parts[i] !== cached.fpParts[i]) {
walOnly = false;
break;
}
}
if (walOnly) {
if (await this.tryCatchUpReader(cached, dir, parts)) return cached;
}
// A change the watermark cannot advance over (rotation, truncation,
// snapshot/index-def rewrite, broken boundary): reopen to see it. Wait
// for any in-flight user of the stale instance to drain first.
while (cached.busy > 0) await sleep(5);
this.readers.delete(shardId);
this.stats.readerReopens++;
await cached.handle.close().catch(() => {});
}
// Retry a few times: a compaction by another process briefly swaps files,
// which can make an otherwise-fine open race with the rotation.
let lastErr: unknown;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const handle = await ShardHandle.openReader(shardId, dir, this.opts.readerOpts);
this.stats.readerOpens++;
const openedParts = await shardFingerprint(dir);
const entry: ReaderEntry = {
handle,
fingerprint: openedParts.join('|'),
fpParts: openedParts,
walMark: readerWalMark(handle),
lastUsedAt: Date.now(),
busy: 0,
};
this.readers.set(shardId, entry);
return entry;
} catch (e) {
lastErr = e;
await sleep(25);
}
}
throw lastErr;
}
/** Incremental reader refresh: apply the WAL frames appended after the
* cached reader's watermark. Only safe when the fingerprint change is
* WAL-only (caller checked), the WAL is still the inode the watermark is
* anchored to, and it never shrank below the applied offset. Returns false
* on every divergence, leaving the cached reader untouched for the caller
* to fully reopen. */
private async tryCatchUpReader(cached: ReaderEntry, dir: string, parts: string[]): Promise<boolean> {
const mark = cached.walMark;
if (!mark) return false;
const st = await fs.stat(path.join(dir, 'db.wal')).catch(() => null);
if (!st || st.dev !== mark.dev || st.ino !== mark.ino || st.size < mark.size) return false;
// Serialize against in-flight users of the cached instance (the reopen
// path does the same drain) and hold it busy so eviction skips it.
while (cached.busy > 0) await sleep(5);
cached.busy++;
let res: { offset: number; appliedFrames: number } | null = null;
try {
res = await cached.handle.db.catchUpFromWal(mark.size);
} catch {
res = null; // raced a rotation/truncation: caller falls back to a full reopen
} finally {
cached.busy--;
}
if (res === null) return false;
// Advance the watermark only to what was actually applied β a frame
// appended after this stat (or after catch-up scanned) sits beyond
// res.offset and is picked up by the next fingerprint miss.
cached.walMark = { dev: st.dev, ino: st.ino, size: res.offset };
cached.fpParts = parts;
cached.fingerprint = parts.join('|');
cached.lastUsedAt = Date.now();
this.stats.incrementalCatchups++;
this.stats.catchupFramesApplied += res.appliedFrames;
return true;
}
private async evictReaders(): Promise<void> {
while (this.readers.size > this.opts.maxReaders) {
let victim: ReaderEntry | null = null;
for (const entry of this.readers.values()) {
if (entry.busy > 0) continue;
if (!victim || entry.lastUsedAt < victim.lastUsedAt) victim = entry;
}
if (!victim) return;
this.readers.delete(victim.handle.shardId);
this.stats.evictions++;
await victim.handle.close().catch(() => {});
}
}
}
|