Initial Commit
This commit is contained in:
115
packages/foundation/flow/job-scheduler/src/queue/job-queue.ts
Normal file
115
packages/foundation/flow/job-scheduler/src/queue/job-queue.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user