Download packages/minidb/src/cluster/lock-pool.ts from SaylorTwift/kimi-code: direct link, hf CLI and curl.
- Browser
- Download file 18.1 kB
-
https://huggingface.co/SaylorTwift/kimi-code/resolve/main/packages/minidb/src/cluster/lock-pool.ts
- Command line
-
hf download hf://SaylorTwift/kimi-code/packages/minidb/src/cluster/lock-pool.ts
-
curl -L -o lock-pool.ts https://huggingface.co/SaylorTwift/kimi-code/resolve/main/packages/minidb/src/cluster/lock-pool.ts
18.1 kB
| // 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(() => {}); | |
| } | |
| } | |
| } | |