File size: 5,706 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 | // src/op-tracker.ts
//
// OpTracker β the single "close gate + in-flight count" lifecycle primitive
// behind every drain path in MiniDB / cluster (plan 12). It generalizes the
// stage-8 close state machine: stage 8 made cleanup exception-safe, this
// makes the work in flight at shutdown bounded and convergent.
//
// The shape:
//
// enter(): boolean β gate open: count +1 and the caller owns one leave();
// gate closed: returns false, the caller rejects/skips
// the operation (never blocks).
// leave(): void β releases one enter(); at count 0 every idle waiter
// resolves.
// close(): Promise β TERMINAL: closes the gate for good and resolves once
// the count drains to 0. Concurrent calls share the
// same promise.
// pause()/resume() β a REOPENABLE close: pause() closes the gate and drains
// (the drain completion is a quiescence/linearization
// point β every op acknowledged before it is fenced in,
// every op after it was rejected), resume() reopens.
// pause() is reference-counted: overlapping pausers all
// drain, the gate reopens only when the LAST one
// resumes, and the gate closes synchronously at pause()
// call time (before its returned promise is awaited).
// resume() after close() is a no-op.
// whenIdle(): Promise β resolves the next time the count reaches 0 without
// touching the gate (a point-in-time quiescence wait:
// new ops may still enter while it is pending).
//
// Current consumers:
// - wal.ts: the everysec background sync registers each in-flight sync;
// WAL.close() = stop the timer β bgSync.close() (drain) β flush β final
// sync β close the fd (review #13).
// - cluster/lock-pool.ts: one tracker per handle class; every
// withWriter/withReader wraps acquire + callback in enter/leave, so
// closeAll() = close both gates β both drains β close the handles β a
// callback in flight at closeAll runs to completion instead of dying on a
// 'MiniDb is closed' handle teardown (review #18).
// - index.ts (MiniDb): set/del/batch/expire enter the write tracker;
// backup() = pause() (the fence + drain IS its linearization point) β
// copy β resume() (review #22).
//
// kap-server consumer (plan 13): GlobalSearchService gives its
// lifecycle-managed background work (sync passes, read-only refreshes) one
// tracker; op bodies run under `if (!tracker.enter()) return; try { β¦ }
// finally { tracker.leave(); }`, and dispose() is exactly `await
// tracker.close()` before closing the db β no detached promise can touch a
// closed db (review #20). A maintenance window that must exclude concurrent
// work (compact, resnapshot, generation swap) uses pause()/resume() the way
// MiniDb.backup does: pause's drain completion is the linearization point,
// work submitted meanwhile rejects synchronously at enter() β it never parks,
// so shutdown can never deadlock against it.
//
// Re-exported from the package root for that kap-server consumer.
export class OpTracker {
private count = 0;
private open = true;
private pausers = 0;
private permanentlyClosed = false;
private idleWaiters: (() => void)[] = [];
private closePromise: Promise<void> | null = null;
/** In-flight op count (diagnostics and tests). */
get inFlight(): number {
return this.count;
}
/** False once the gate is closed (pause or close). */
get gateOpen(): boolean {
return this.open;
}
/** Try to register one op. True: the caller owns exactly one leave().
* False: the gate is closed β reject or skip the op, never block. */
enter(): boolean {
if (!this.open) return false;
this.count++;
return true;
}
/** Release one enter(). Resolves every idle waiter when the count hits 0. */
leave(): void {
if (this.count <= 0) throw new Error('OpTracker: leave() without a matching enter()');
this.count--;
if (this.count === 0) {
const waiters = this.idleWaiters;
this.idleWaiters = [];
for (const resolve of waiters) resolve();
}
}
/** Resolve the next time the in-flight count reaches 0 (gate untouched). */
whenIdle(): Promise<void> {
if (this.count === 0) return Promise.resolve();
return new Promise<void>((resolve) => this.idleWaiters.push(resolve));
}
/** Close the gate and wait for the drain; reopen with resume(). The gate
* closes SYNCHRONOUSLY at call time (the async keyword only defers the
* whenIdle await), and pause is reference-counted: overlapping pausers all
* drain and the gate reopens only when the last one resumes. The drain
* completion is the caller's quiescence point. */
async pause(): Promise<void> {
this.pausers++;
this.open = false;
await this.whenIdle();
}
/** Release one pause(); reopens the gate when the last pauser resumes.
* No-op after close() (close is terminal). */
resume(): void {
if (this.permanentlyClosed) return;
if (this.pausers > 0) this.pausers--;
if (this.pausers === 0) this.open = true;
}
/** Permanently close the gate and resolve once drained. Idempotent and
* shared: concurrent close() calls await the same drain. */
close(): Promise<void> {
if (!this.closePromise) {
this.permanentlyClosed = true;
this.open = false;
this.closePromise = this.whenIdle();
}
return this.closePromise;
}
}
|