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(() => {});
    }
  }
}