Delayed Task Scheduler With Priorities
Schedule delayed async tasks with priorities, bounded concurrency, cancellation, error isolation, and a drain method that waits for completion.
Code
interface Job {
id: number;
run: () => void | Promise<void>;
priority: number;
sequence: number;
timer: ReturnType<typeof setTimeout> | null;
settled: boolean;
}
export interface ScheduledHandle {
id: number;
cancel(): boolean;
}
export interface TaskSchedulerOptions {
concurrency?: number;
onError?: (error: unknown, id: number) => void;
}
export class TaskScheduler {
private readonly jobs = new Map<number, Job>();
private readonly ready: Job[] = [];
private readonly idleWaiters: Array<() => void> = [];
private readonly concurrency: number;
private readonly onError: (error: unknown, id: number) => void;
private nextId = 1;
private sequence = 0;
private running = 0;
private closed = false;
public constructor(options: TaskSchedulerOptions = {}) {
const {
concurrency = 1,
onError = (error: unknown, id: number) => {
console.error(`task ${id} failed`, error);
},
} = options;
if (!Number.isInteger(concurrency) || concurrency < 1) {
throw new RangeError("concurrency must be a positive integer");
}
if (typeof onError !== "function") {
throw new TypeError("onError must be a function");
}
this.concurrency = concurrency;
this.onError = onError;
}
public schedule(
run: () => void | Promise<void>,
options: { delayMs?: number; priority?: number } = {},
): ScheduledHandle {
if (this.closed) throw new Error("scheduler is closed");
if (typeof run !== "function") {
throw new TypeError("run must be a function");
}
const { delayMs = 0, priority = 0 } = options;
if (!Number.isFinite(delayMs) || delayMs < 0) {
throw new RangeError("delayMs must be a non-negative finite number");
}
if (!Number.isFinite(priority)) {
throw new RangeError("priority must be a finite number");
}
const id = this.nextId;
this.nextId += 1;
const job: Job = {
id,
run,
priority,
sequence: this.sequence,
timer: null,
settled: false,
};
this.sequence += 1;
this.jobs.set(id, job);
job.timer = setTimeout(() => {
job.timer = null;
if (job.settled) return;
this.ready.push(job);
this.flush();
}, delayMs);
return { id, cancel: () => this.cancel(id) };
}
public cancel(id: number): boolean {
const job = this.jobs.get(id);
if (job === undefined || job.settled) return false;
const isWaiting = job.timer !== null || this.ready.includes(job);
if (!isWaiting) return false;
if (job.timer !== null) {
clearTimeout(job.timer);
job.timer = null;
}
const readyIndex = this.ready.indexOf(job);
if (readyIndex >= 0) this.ready.splice(readyIndex, 1);
job.settled = true;
this.jobs.delete(id);
this.resolveIdleIfNeeded();
return true;
}
public drain(): Promise<void> {
if (this.jobs.size === 0) return Promise.resolve();
return new Promise<void>((resolve) => {
this.idleWaiters.push(resolve);
});
}
public close(): void {
if (this.closed) return;
this.closed = true;
for (const job of [...this.jobs.values()]) {
if (job.timer !== null) {
clearTimeout(job.timer);
job.timer = null;
}
const readyIndex = this.ready.indexOf(job);
if (readyIndex >= 0) this.ready.splice(readyIndex, 1);
job.settled = true;
}
this.jobs.clear();
this.resolveIdleIfNeeded();
}
private flush(): void {
this.ready.sort(
(left, right) =>
right.priority - left.priority || left.sequence - right.sequence,
);
while (this.running < this.concurrency && this.ready.length > 0) {
const job = this.ready.shift();
if (job === undefined || job.settled) continue;
this.running += 1;
Promise.resolve()
.then(() => job.run())
.catch((error: unknown) => {
try {
this.onError(error, job.id);
} catch {
// Error handlers must not break the scheduler loop.
}
})
.finally(() => {
this.running -= 1;
job.settled = true;
this.jobs.delete(job.id);
this.resolveIdleIfNeeded();
this.flush();
});
}
this.resolveIdleIfNeeded();
}
private resolveIdleIfNeeded(): void {
if (this.jobs.size > 0) return;
const waiters = this.idleWaiters.splice(0);
for (const resolve of waiters) resolve();
}
}
async function main(): Promise<void> {
const events: string[] = [];
const scheduler = new TaskScheduler({
concurrency: 1,
onError: (error, id) => {
events.push(
`error ${id}: ${error instanceof Error ? error.message : String(error)}`,
);
},
});
scheduler.schedule(async () => {
events.push("start");
await new Promise((resolve) => setTimeout(resolve, 80));
events.push("end");
});
scheduler.schedule(() => {
events.push("low");
}, { delayMs: 5, priority: 0 });
scheduler.schedule(() => {
events.push("high");
}, { delayMs: 10, priority: 5 });
scheduler.schedule(() => {
events.push("never");
}, { delayMs: 10, priority: 9 }).cancel();
scheduler.schedule(() => {
throw new Error("boom");
}, { delayMs: 15, priority: 1 });
await scheduler.drain();
console.log(events);
scheduler.close();
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});Report a problem with this exampleOpens a pre-filled issue on GitHub.