116 lines
3.6 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|