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