File size: 15,648 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
// src/maintenance.ts
//
// Unified maintenance scheduler (stage 6). Every heavy maintenance task of a
// MiniDb instance β€” a compaction, a persistent-generation build β€” runs
// through one scheduler, which enforces the resource budgets the plan calls
// for:
//
//   - at most ONE heavy maintenance task per database at a time (the rest
//     queue; a queued same-kind submission dedupes onto it);
//   - a process-wide CPU-worker slot cap (the workerized text build must
//     acquire a slot before spawning its worker thread);
//   - queue backpressure: a full queue rejects new submissions loudly;
//   - deadline/cancellation through an AbortSignal handed to the task;
//   - a disk free-space preflight before a task starts;
//   - shutdown drain/cancel: a task that reached its publishing critical
//     section is awaited; everything still queued or running is cancelled;
//   - observable states: queued/running/publishing/failed/complete.
//
// The business API stays at the MiniDb level ("compact", "rebuild the index
// generation"): callers never see workers, file paths, or thread details.
// Internal to the package β€” NOT re-exported from the root entry point; the
// public surface is MiniDb.maintenanceStatus().

import os from 'node:os';
import { AsyncLocalStorage } from 'node:async_hooks';
import { OpTracker } from './op-tracker.js';

export type MaintenanceKind = 'compact' | 'generation-build' | 'text-build';
export type MaintenanceState = 'queued' | 'running' | 'publishing' | 'failed' | 'complete';

/** Stable, serializable view of one task (MiniDb.maintenanceStatus()). */
export interface MaintenanceTaskInfo {
  id: number;
  kind: MaintenanceKind;
  state: MaintenanceState;
  queuedAt: number;
  startedAt?: number;
  finishedAt?: number;
  error?: string;
}

/** The handle a running task gets: cancellation + phase reporting. */
export interface MaintenanceContext {
  /** Aborted on scheduler close (unless the task reported publishing) and on
   *  the task's deadline. Tasks must observe it (worker termination, scan
   *  cancellation). */
  readonly signal: AbortSignal;
  /** Mark the task as inside its publishing critical section: shutdown then
   *  WAITS for the task instead of cancelling it. One-way (a publish that
   *  started must finish or fail). */
  markPublishing(): void;
}

interface Task {
  id: number;
  kind: MaintenanceKind;
  state: MaintenanceState;
  queuedAt: number;
  startedAt?: number;
  finishedAt?: number;
  error?: string;
  run: (ctx: MaintenanceContext) => Promise<void>;
  controller: AbortController;
  deadlineTimer?: ReturnType<typeof setTimeout>;
  promise?: Promise<void>;
  resolve: () => void;
  reject: (err: unknown) => void;
}

export class MaintenanceBackpressureError extends Error {
  readonly code = 'MAINTENANCE_BACKPRESSURE';
  constructor(kind: MaintenanceKind) {
    super(`maintenance queue is full; cannot queue ${kind}`);
    this.name = 'MaintenanceBackpressureError';
  }
}

export class MaintenanceClosedError extends Error {
  readonly code = 'MAINTENANCE_CLOSED';
  constructor() {
    super('maintenance scheduler is closed');
    this.name = 'MaintenanceClosedError';
  }
}

export class MaintenanceCancelledError extends Error {
  readonly code = 'MAINTENANCE_CANCELLED';
  constructor(kind: MaintenanceKind | 'generation-build') {
    super(`maintenance task ${kind} was cancelled`);
    // Named AbortError so cancellation routes like other abort flows.
    this.name = 'AbortError';
  }
}

const HISTORY_LIMIT = 16;

export interface MaintenanceSchedulerOptions {
  /** Max queued tasks waiting for the running one; submissions beyond it are
   *  rejected with MaintenanceBackpressureError (same-kind submissions
   *  dedupe instead β€” see submit). Default 4. */
  maxQueue?: number;
  /** Free-space preflight: returns the bytes the task is expected to need,
   *  or null to skip the check. Wired by the owner (MiniDb knows the file
   *  sizes); the scheduler performs the statfs. */
  estimateBytes?: (kind: MaintenanceKind) => Promise<number | null>;
  /** statfs injection point (tests). Defaults to node:fs/promises.statfs. */
  statfs?: (dir: string) => Promise<{ bavail: number; bsize: number }>;
  /** The database directory (for the free-space preflight). */
  dir: string;
}

export class MaintenanceScheduler {
  private nextId = 1;
  private running: Task | null = null;
  private readonly queue: Task[] = [];
  private readonly history: MaintenanceTaskInfo[] = [];
  private closed = false;
  private readonly tracker = new OpTracker();
  private readonly maxQueue: number;
  private readonly estimateBytes?: (kind: MaintenanceKind) => Promise<number | null>;
  private readonly statfsFn: (dir: string) => Promise<{ bavail: number; bsize: number }>;
  private readonly dir: string;
  /** The task whose run() is currently on the stack: a NESTED submission
   *  from inside a task (compaction's onCompacted hook submitting a
   *  generation build) must not queue behind its own task β€” that
   *  self-deadlocks the one-at-a-time invariant. Nested submissions run
   *  inline within the submitting task instead. */
  private readonly als = new AsyncLocalStorage<Task>();

  constructor(opts: MaintenanceSchedulerOptions) {
    this.maxQueue = opts.maxQueue ?? 4;
    this.estimateBytes = opts.estimateBytes;
    this.statfsFn = opts.statfs ?? (async (dir: string) => (await import('node:fs/promises')).statfs(dir));
    this.dir = opts.dir;
  }

  /** Snapshot of every live task plus the most recent finished ones. */
  status(): MaintenanceTaskInfo[] {
    const out: MaintenanceTaskInfo[] = [];
    const push = (t: Task): void => {
      out.push({ id: t.id, kind: t.kind, state: t.state, queuedAt: t.queuedAt, startedAt: t.startedAt, finishedAt: t.finishedAt, error: t.error });
    };
    if (this.running) push(this.running);
    for (const t of this.queue) push(t);
    return [...out, ...this.history];
  }

  /** The currently running task's kind (diagnostics/tests). */
  get runningKind(): MaintenanceKind | null {
    return this.running?.kind ?? null;
  }

  /** Whether a task of this kind is waiting in the queue. */
  hasQueued(kind: MaintenanceKind): boolean {
    return this.queue.some((t) => t.kind === kind);
  }

  /**
   * Queue a heavy task. At most one task runs at a time; a same-kind task
   * already queued or running is NOT duplicated β€” the caller receives the
   * in-flight task's promise (dedupe backpressure). A full queue of
   * other-kind tasks rejects with MaintenanceBackpressureError.
   */
  submit(
    kind: MaintenanceKind,
    run: (ctx: MaintenanceContext) => Promise<void>,
    opts: { deadlineMs?: number } = {},
  ): Promise<void> {
    // Nested submission from inside a running task: queueing it behind the
    // current task would self-deadlock (the task awaits the submission, the
    // submission awaits the task). It runs INLINE, inside the submitter's
    // task, sharing its cancellation; it does not appear as a separate task
    // (it is part of the outer task's work, e.g. compaction's generation
    // publish hook).
    const current = this.als.getStore();
    if (current) {
      if (current.kind === kind) return current.promise ?? Promise.resolve();
      return run({
        signal: current.controller.signal,
        markPublishing: () => {
          if (current.state === 'running') current.state = 'publishing';
        },
      });
    }
    if (this.closed) return Promise.reject(new MaintenanceClosedError());
    if (this.running?.kind === kind) return this.promiseOf(this.running);
    const queued = this.queue.find((t) => t.kind === kind);
    if (queued) return this.promiseOf(queued);
    if (this.queue.length >= this.maxQueue) return Promise.reject(new MaintenanceBackpressureError(kind));
    if (!this.tracker.enter()) return Promise.reject(new MaintenanceClosedError());

    const task: Task = {
      id: this.nextId++,
      kind,
      state: 'queued',
      queuedAt: Date.now(),
      run,
      controller: new AbortController(),
      resolve: () => {},
      reject: () => {},
    };
    const promise = new Promise<void>((resolve, reject) => {
      task.resolve = resolve;
      task.reject = reject;
    });
    task.promise = promise;
    if (opts.deadlineMs !== undefined) {
      task.deadlineTimer = setTimeout(() => task.controller.abort(), opts.deadlineMs);
      task.deadlineTimer.unref?.();
    }
    this.queue.push(task);
    void this.pump();
    return promise;
  }

  private promiseOf(task: Task): Promise<void> {
    return task.promise!;
  }

  private async pump(): Promise<void> {
    if (this.running !== null) return;
    const task = this.queue.shift();
    if (!task) return;
    this.running = task;
    task.state = 'running';
    task.startedAt = Date.now();
    try {
      await this.preflight(task.kind);
    } catch (e) {
      this.finish(task, e);
      void this.pump();
      return;
    }
    const ctx: MaintenanceContext = {
      signal: task.controller.signal,
      markPublishing: () => {
        if (task.state === 'running') task.state = 'publishing';
      },
    };
    try {
      await this.als.run(task, () => task.run(ctx));
      this.finish(task, null);
    } catch (e) {
      this.finish(task, e);
    }
    void this.pump();
  }

  /** Disk free-space preflight: a task whose estimated footprint exceeds the
   *  filesystem's available bytes fails BEFORE doing any work (the ENOSPC
   *  surfaces as a clean task failure, never a half-written artifact). A
   *  statfs failure or a null estimate skips the check β€” the preflight must
   *  never block maintenance on platforms without statfs. */
  private async preflight(kind: MaintenanceKind): Promise<void> {
    if (!this.estimateBytes) return;
    const need = await this.estimateBytes(kind);
    if (need === null || need <= 0) return;
    let free: number;
    try {
      const st = await this.statfsFn(this.dir);
      free = st.bavail * st.bsize;
    } catch {
      return;
    }
    if (free < need) {
      const err = new Error(`insufficient disk space for ${kind}: need ~${need} bytes, have ${free} bytes`);
      (err as { code?: string }).code = 'ENOSPC_PREFLIGHT';
      throw err;
    }
  }

  private finish(task: Task, error: unknown): void {
    if (task.deadlineTimer) clearTimeout(task.deadlineTimer);
    task.state = error ? 'failed' : 'complete';
    task.finishedAt = Date.now();
    if (error) {
      task.error = error instanceof Error ? error.message : `non-Error thrown: ${Object.prototype.toString.call(error)}`;
      task.reject(error);
    } else {
      task.resolve();
    }
    this.history.unshift({ id: task.id, kind: task.kind, state: task.state, queuedAt: task.queuedAt, startedAt: task.startedAt, finishedAt: task.finishedAt, error: task.error });
    if (this.history.length > HISTORY_LIMIT) this.history.length = HISTORY_LIMIT;
    if (this.running === task) this.running = null;
    this.tracker.leave();
  }

  /**
   * Shutdown (plan 12's drain/cancel): cancel every queued task and every
   * running task that has NOT reached its publishing critical section;
   * publishing tasks are awaited. Resolves once every task settled.
   * Idempotent; after close() new submissions reject with
   * MaintenanceClosedError.
   */
  async close(): Promise<void> {
    if (this.closed) return;
    this.closed = true;
    for (const t of this.queue.splice(0)) {
      if (t.deadlineTimer) clearTimeout(t.deadlineTimer);
      t.state = 'failed';
      t.finishedAt = Date.now();
      t.error = 'cancelled by shutdown';
      t.reject(new MaintenanceCancelledError(t.kind));
      this.history.unshift({ id: t.id, kind: t.kind, state: t.state, queuedAt: t.queuedAt, startedAt: t.startedAt, finishedAt: t.finishedAt, error: t.error });
      this.tracker.leave();
    }
    const running = this.running;
    if (running && running.state !== 'publishing') running.controller.abort();
    await this.tracker.close();
  }
}

/** Process-wide cap on concurrently spawned maintenance worker threads
 * (stage 6's CPU-worker budget). A worker build acquires a slot before
 * spawning and releases it when the worker exits; additional builds wait (or
 * the caller falls back to the in-thread path). */
export class WorkerSlots {
  private available: number;
  private readonly waiters: (() => void)[] = [];

  constructor(readonly total: number) {
    this.available = total;
  }

  /** Try to take a slot without waiting. */
  tryAcquire(): (() => void) | null {
    if (this.available <= 0) return null;
    this.available--;
    return () => this.release();
  }

  /** Wait for a slot (observing the optional abort signal). */
  async acquire(signal?: AbortSignal): Promise<() => void> {
    const immediate = this.tryAcquire();
    if (immediate) return immediate;
    if (signal?.aborted) throw new MaintenanceCancelledError('generation-build');
    return new Promise<() => void>((resolve, reject) => {
      const onAbort = (): void => {
        const i = this.waiters.indexOf(grant);
        if (i >= 0) this.waiters.splice(i, 1);
        reject(new MaintenanceCancelledError('generation-build'));
      };
      const grant = (): void => {
        signal?.removeEventListener('abort', onAbort);
        resolve(() => this.release());
      };
      signal?.addEventListener('abort', onAbort, { once: true });
      this.waiters.push(grant);
    });
  }

  /** TUI-safe slot policy: queue for a slot up to `waitMs` (observing the
   *  optional abort signal) instead of dropping a large worker-eligible
   *  build onto the main thread the moment every slot is busy. Resolves null
   *  on timeout β€” the caller decides whether its bounded inline core is an
   *  acceptable last resort. Rejects MaintenanceCancelledError on abort. */
  async acquireBounded(waitMs: number, signal?: AbortSignal): Promise<(() => void) | null> {
    const immediate = this.tryAcquire();
    if (immediate) return immediate;
    if (signal?.aborted) throw new MaintenanceCancelledError('generation-build');
    return new Promise<(() => void) | null>((resolve, reject) => {
      const cleanup = (): void => {
        clearTimeout(timer);
        signal?.removeEventListener('abort', onAbort);
        const i = this.waiters.indexOf(grant);
        if (i >= 0) this.waiters.splice(i, 1);
      };
      const timer = setTimeout(() => {
        cleanup();
        resolve(null);
      }, waitMs);
      timer.unref?.();
      const onAbort = (): void => {
        cleanup();
        reject(new MaintenanceCancelledError('generation-build'));
      };
      const grant = (): void => {
        cleanup();
        resolve(() => this.release());
      };
      signal?.addEventListener('abort', onAbort, { once: true });
      this.waiters.push(grant);
    });
  }

  private release(): void {
    const next = this.waiters.shift();
    if (next) {
      next();
      return;
    }
    this.available++;
  }
}

/** The process-wide default worker slot pool: half the CPUs, at least one,
 *  capped at two β€” one worker build already saturates a core for seconds,
 *  and the main thread must stay responsive. */
export const defaultWorkerSlots = new WorkerSlots(Math.max(1, Math.min(2, Math.floor(os.cpus().length / 2))));

/** Default budget of the TUI-safe slot queue (WorkerSlots.acquireBounded):
 *  a worker-eligible text build waits this long for a slot before falling
 *  back to the bounded inline core as the explicit last resort β€” large
 *  full-text builds are never dropped onto the main thread UNCONDITIONALLY. */
export const TEXT_BUILD_SLOT_WAIT_MS = 30_000;