import { compatGlobal, type CompatTimeoutHandle } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; import { createNotifier } from "octagonal-wheels/concurrency/notifier"; import { forEachInScope } from "octagonal-wheels/concurrency/scopedIteration"; import { createAsyncQueue, type AsyncQueue } from "octagonal-wheels/iterable/asyncQueue"; import { withScope, type Awaitable } from "octagonal-wheels/resource/scope"; interface ConfigScanQueueOptions { /** The caller logs recoverable item failures and decides whether to continue. */ process: (item: T) => Awaitable; onProgress?: (remaining: number) => void; } interface PendingQueue { fifo: AsyncQueue; iterator: AsyncIterator; } interface PendingScan { value: T; queue: PendingQueue; } /** Owns FIFO admission, the initial scan delay, and completion for one catalogue. */ export class ConfigScanQueue { private pending: PendingQueue; private pendingCount = 0; private active = 0; private ready = false; private stopped = false; private stoppingAfterFailure = false; private readonly failures: unknown[] = []; private timer?: CompatTimeoutHandle; private running?: Promise; private readonly changed = createNotifier(); constructor(private readonly options: ConfigScanQueueOptions) { this.pending = this.createPendingQueue(); } private createPendingQueue(): PendingQueue { const fifo = createAsyncQueue(); return { fifo, iterator: fifo[Symbol.asyncIterator]() }; } get remaining(): number { return this.pendingCount + this.active; } enqueue(value: T): void { if (!this.canDispatch()) throw new Error("The customisation scan queue is closed."); this.pending.fifo.enqueue(value); this.pendingCount++; this.reportProgress(); this.schedule(); } /** Discard unstarted scans; active reads and migrations retain their slots. */ clearPending(): void { const discarded = this.pending; this.pending = this.createPendingQueue(); this.pendingCount = 0; this.ready = false; this.clearTimer(); discarded.fifo.dispose(); this.reportProgress(); } /** Stop admission and join active work. A processor must not await its own stop. */ stop(): Promise { this.stopped = true; this.clearPending(); return this.waitForIdle(); } /** Include later arrivals until the queue is idle; this does not close admission. */ async waitForIdle(): Promise { this.schedule(); while (this.running || this.timer !== undefined) await this.changed.wait(); if (this.failures.length === 1) throw this.failures[0]; if (this.failures.length > 1) { throw Object.assign(new Error("Customisation scans failed."), { errors: [...this.failures] }); } } private canDispatch(): boolean { return !this.stopped && !this.stoppingAfterFailure; } private clearTimer(): void { if (this.timer === undefined) return; compatGlobal.clearTimeout(this.timer); this.timer = undefined; } private recordFailure(error: unknown): void { this.stoppingAfterFailure = true; this.failures.push(error); this.clearTimer(); this.changed.notify(); } private reportProgress(): void { if (this.failures.length === 0) { try { this.options.onProgress?.(this.remaining); } catch (error) { this.recordFailure(error); } } this.changed.notify(); } private claim(entry: PendingScan): boolean { if (!this.canDispatch() || this.pending !== entry.queue) return false; this.pendingCount--; if (this.pendingCount === 0) this.ready = false; return true; } private async *availableWork() { while (this.canDispatch()) { const pending = this.pending; if (!this.ready) { if (this.active === 0 && this.pendingCount === 0) return; await this.changed.wait(); continue; } if (pending.fifo.size > 0) { const entry = await pending.iterator.next(); if (entry.done) { if (pending !== this.pending) continue; return; } // The callback checks queue identity so clearing a pulled item cancels it. yield { value: entry.value, queue: pending }; } else if (this.active > 0 || this.pendingCount > 0) { // Keep free slots available to later arrivals while a slow scan finishes. await this.changed.wait(); } else { return; } } } private schedule(): void { if (!this.canDispatch() || this.pendingCount === 0) return; if (!this.ready) { if (this.pendingCount > 10) { this.ready = true; this.clearTimer(); } else if (this.timer === undefined) { this.timer = compatGlobal.setTimeout(() => { this.timer = undefined; this.ready = true; this.schedule(); this.changed.notify(); }, 100); } } if (!this.ready || this.running) return; this.running = Promise.resolve() .then(() => withScope(undefined, (parent) => forEachInScope(parent, this.availableWork(), { concurrency: 10 }, async (entry, child) => { if (!this.claim(entry)) return; this.active++; child.defer(() => { this.active--; this.reportProgress(); }); this.reportProgress(); if (!this.canDispatch()) return; try { await this.options.process(entry.value); } catch (error) { this.stoppingAfterFailure = true; this.clearTimer(); this.changed.notify(); throw error; } }) ) ) .then( (): void => undefined, (error: unknown) => this.recordFailure(error) ) .finally(() => { this.running = undefined; this.schedule(); this.changed.notify(); }); } }