Files
suaveplan/packages/foundation/flow/job-scheduler/src/queue/job-queue.ts
2026-05-27 23:47:53 +01:00

116 lines
3.6 KiB
TypeScript

import type { QueuedJob } from "./job-queue.types.js";
/**
* Array-backed binary min-heap keyed by `QueuedJob.runAt`.
*
* - `enqueue` / `dequeue`: O(log n).
* - `peek` / `size`: O(1).
* - `remove(jobId)`: O(n) — linear scan, swap-with-last, sift in the
* appropriate direction.
*
* The heap is intentionally minimal — no external dependency, no GC
* churn beyond the single backing array, no comparator allocation per
* operation. It exists for the scheduler's tick loop, which calls
* `peek` once per tick and `dequeue` at most `concurrency` times.
*/
export class JobQueue {
private readonly heap: QueuedJob[] = [];
/** Add `job` to the heap. Ties on `runAt` are broken by insertion order. */
public enqueue(job: QueuedJob): void {
this.heap.push(job);
this.siftUp(this.heap.length - 1);
}
/** Remove and return the earliest job, or `undefined` if empty. */
public dequeue(): QueuedJob | undefined {
const n = this.heap.length;
if (n === 0) return undefined;
const root = this.heap[0] as QueuedJob;
const last = this.heap.pop() as QueuedJob;
if (n > 1) {
this.heap[0] = last;
this.siftDown(0);
}
return root;
}
/** Earliest job without removing it, or `undefined` if empty. */
public peek(): QueuedJob | undefined {
return this.heap[0];
}
/** Current heap size. */
public size(): number {
return this.heap.length;
}
/**
* Remove the first entry whose `jobId` matches and rebalance.
* Returns `true` if an entry was found and removed.
*/
public remove(jobId: string): boolean {
const idx = this.heap.findIndex((j) => j.jobId === jobId);
if (idx === -1) return false;
const lastIdx = this.heap.length - 1;
if (idx === lastIdx) {
this.heap.pop();
return true;
}
const last = this.heap.pop() as QueuedJob;
this.heap[idx] = last;
// The replacement may belong higher OR lower; try both. Only one
// direction will do work, the other no-ops.
this.siftUp(idx);
this.siftDown(idx);
return true;
}
/** Drain all entries into a snapshot array in heap-order (NOT sorted). */
public snapshot(): readonly QueuedJob[] {
return Object.freeze([...this.heap]);
}
private siftUp(from: number): void {
let i = from;
while (i > 0) {
const parent = (i - 1) >>> 1;
const a = this.heap[i] as QueuedJob;
const b = this.heap[parent] as QueuedJob;
if (a.runAt >= b.runAt) break;
this.heap[i] = b;
this.heap[parent] = a;
i = parent;
}
}
private siftDown(from: number): void {
const n = this.heap.length;
let i = from;
for (;;) {
const left = i * 2 + 1;
const right = left + 1;
let smallest = i;
if (
left < n &&
(this.heap[left] as QueuedJob).runAt <
(this.heap[smallest] as QueuedJob).runAt
) {
smallest = left;
}
if (
right < n &&
(this.heap[right] as QueuedJob).runAt <
(this.heap[smallest] as QueuedJob).runAt
) {
smallest = right;
}
if (smallest === i) break;
const tmp = this.heap[i] as QueuedJob;
this.heap[i] = this.heap[smallest] as QueuedJob;
this.heap[smallest] = tmp;
i = smallest;
}
}
}