@studnicky/scheduler
Scheduler primitives: real-time (setTimeout/setInterval) and virtual (min-heap, deterministic) implementations.
Install
pnpm add @studnicky/schedulerUsage
Virtual scheduler (deterministic)
Schedule one-shot tasks at specific virtual timestamps and advance time in steps. Only tasks due at or before the advanced time are fired:
import { VirtualTimeCounter } from '../../clock/src/index.js';
import { VirtualScheduler } from '../src/index.js';
const counter = VirtualTimeCounter.create({ 'startMs': 0 });
const scheduler = VirtualScheduler.create({ 'counter': counter });
const fireOrder: number[] = [];
scheduler.scheduleAt(100, () => { fireOrder.push(100); });
scheduler.scheduleAt(200, () => { fireOrder.push(200); });
// Advance to 150 — only the task at 100 should fire.
scheduler.advance(150);
console.log('Fire order after advance(150):', fireOrder);
// Advance another 100 (total 250) — the task at 200 should now fire.
scheduler.advance(100);
console.log('Fire order after advance(100) more:', fireOrder);Interval tasks and cancellation
Use scheduleEvery for repeating tasks and cancelAll to stop all pending tasks:
import { VirtualTimeCounter } from '../../clock/src/index.js';
import { VirtualScheduler } from '../src/index.js';
// --- Part 1: interval fires the expected number of times ---
const counter = VirtualTimeCounter.create({ 'startMs': 0 });
const scheduler = VirtualScheduler.create({ 'counter': counter });
let count = 0;
// Counter starts at 0; first fire at 0+50=50. Subsequent fires at 100, 150, 200.
scheduler.scheduleEvery(50, () => { count++; });
scheduler.advance(200);
console.log('Interval fire count:', count);
// --- Part 2: cancelAll() prevents further fires ---
const counter2 = VirtualTimeCounter.create({ 'startMs': 0 });
const scheduler2 = VirtualScheduler.create({ 'counter': counter2 });
let countAfterCancel = 0;
scheduler2.scheduleEvery(50, () => { countAfterCancel++; });
// Cancel before any advance — no tasks should fire.
scheduler2.cancelAll();
scheduler2.advance(200);
console.log('Fires after cancelAll():', countAfterCancel);Subpath exports
| Subpath | Contents |
|---|---|
@studnicky/scheduler | RealTimeScheduler, VirtualScheduler |
@studnicky/scheduler/interfaces | SchedulerProviderType, ScheduledTaskType |
Extending
Both schedulers expose protected hooks for every lifecycle event. The di-provider example demonstrates the injectable SchedulerProviderType pattern with a LoggingScheduler subclass that records schedule and fire events:
import type { SchedulerLogEntryEntity } from '../src/entities/SchedulerLogEntryEntity.js';
import type { SchedulerProviderType } from '../src/index.js';
import { VirtualTimeCounter } from '../../clock/src/index.js';
import { VirtualScheduler } from '../src/index.js';
/** VirtualScheduler subclass that appends lifecycle events to a log array. */
class LoggingScheduler extends VirtualScheduler {
public readonly log: SchedulerLogEntryEntity.Type[] = [];
public constructor(counter: Readonly<VirtualTimeCounter>) { super(counter); }
protected override onSchedule(id: string, _atMs: number, _variant: 'interval' | 'timeout'): void {
this.log.push({ 'event': 'schedule', 'id': id });
}
protected override onFire(id: string): void {
this.log.push({ 'event': 'fire', 'id': id });
}
}
/** Accepts any SchedulerProviderType — injectable for production/test swap. */
class WorkQueue {
readonly #scheduler: SchedulerProviderType;
public readonly processed: string[] = [];
public constructor(scheduler: SchedulerProviderType) {
this.#scheduler = scheduler;
}
public enqueue(atMs: number, label: string): void {
this.#scheduler.scheduleAt(atMs, () => { this.processed.push(label); });
}
}
const counter = VirtualTimeCounter.create({ 'startMs': 0 });
const loggingScheduler = new LoggingScheduler(counter);
const queue = new WorkQueue(loggingScheduler);
queue.enqueue(100, 'alpha');
queue.enqueue(200, 'beta');
loggingScheduler.advance(250);
console.log('Scheduler log:', loggingScheduler.log);
console.log('Processed labels:', queue.processed);Observability hooks
Both VirtualScheduler and RealTimeScheduler expose the same set of protected lifecycle hooks. Override any of them in a subclass to add logging, metrics, or alerting without coupling the scheduler to any external library.
VirtualScheduler hooks
| Hook | When it fires | Args |
|---|---|---|
onSchedule(id, atMs, variant) | After a task is inserted into the heap via scheduleAt or scheduleEvery | id: string, atMs: number, variant: 'timeout' | 'interval' |
onAdvance(deltaMs) | At the start of advance(), before the counter is incremented | deltaMs: number |
onRunUntil(atMs) | At the start of runUntil() | atMs: number |
onFire(id) | Immediately before a task's fire callback is invoked | id: string |
onFireError(id, error) | When a task's fire callback throws synchronously or returns a rejected Promise | id: string, error: unknown |
onReschedule(id, atMs) | After an interval task is re-inserted into the heap following a successful fire | id: string, atMs: number (next scheduled time) |
onCancel(id) | When a task's cancel() method is invoked | id: string |
onCancelAll() | At the end of cancelAll() | — |
onIdle() | After runUntil / runAll drains the heap, or after cancelAll | — |
RealTimeScheduler hooks
| Hook | When it fires | Args |
|---|---|---|
onSchedule(id, atMs, variant) | After a task is registered via scheduleAt or scheduleEvery | id: string, atMs: number, variant: 'timeout' | 'interval' |
onFire(id) | Inside the timer callback, immediately before fire is invoked | id: string |
onFireError(id, error) | When a task's fire callback throws synchronously or returns a rejected Promise | id: string, error: unknown |
onDrift(id, dueMs, actualMs, driftMs) | When a one-shot task fires later than its scheduled atMs | id: string, dueMs: number, actualMs: number, driftMs: number |
onMiss(id, atMs, nowMs) | When scheduleAt receives an atMs already in the past | id: string, atMs: number, nowMs: number |
onCancel(id) | When a task's cancel() method is invoked | id: string |
onCancelAll() | At the end of cancelAll(), after all timers are cleared | — |
onIdle() | After cancelAll fully drains all tracked tasks | — |
Demo trace (virtual scheduler)
import { VirtualTimeCounter } from '../../clock/src/index.js';
import { VirtualScheduler } from '../src/index.js';
class ObservedScheduler extends VirtualScheduler {
readonly events: string[] = [];
public constructor(counter: Readonly<VirtualTimeCounter>) {
super(counter);
}
protected override onSchedule(id: string, atMs: number, variant: 'interval' | 'timeout'): void {
const line = `[scheduler] schedule id=${id} atMs=${atMs.toString()} variant=${variant}`;
console.log(line);
this.events.push(line);
}
protected override onAdvance(deltaMs: number): void {
const line = `[scheduler] advance deltaMs=${deltaMs.toString()}`;
console.log(line);
this.events.push(line);
}
protected override onRunUntil(atMs: number): void {
const line = `[scheduler] runUntil atMs=${atMs.toString()}`;
console.log(line);
this.events.push(line);
}
protected override onFire(id: string): void {
const line = `[scheduler] fire id=${id}`;
console.log(line);
this.events.push(line);
}
protected override onFireError(id: string, error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
const line = `[scheduler] fireError id=${id} error="${message}"`;
console.log(line);
this.events.push(line);
}
protected override onReschedule(id: string, atMs: number): void {
const line = `[scheduler] reschedule id=${id} nextAtMs=${atMs.toString()}`;
console.log(line);
this.events.push(line);
}
protected override onCancel(id: string): void {
const line = `[scheduler] cancel id=${id}`;
console.log(line);
this.events.push(line);
}
protected override onCancelAll(): void {
const line = '[scheduler] cancelAll';
console.log(line);
this.events.push(line);
}
protected override onIdle(): void {
const line = '[scheduler] idle';
console.log(line);
this.events.push(line);
}
}
const counter = VirtualTimeCounter.create({ 'startMs': 0 });
const scheduler = new ObservedScheduler(counter);
// Schedule a one-shot task at t=100
scheduler.scheduleAt(100, () => {
console.log('[task:one-shot] fired at t=100');
});
// Schedule an interval task every 150 ms
scheduler.scheduleEvery(150, () => {
console.log('[task:interval] fired');
});
// Schedule a task that throws — exercises onFireError
scheduler.scheduleAt(200, () => {
console.log('[task:failing] about to throw');
throw new Error('task failure');
});
// Advance to t=300 — fires the one-shot at 100, the interval at 150, the failing at 200,
// and reschedules the interval to 300.
scheduler.advance(300);
// Cancel everything to exercise onCancelAll + onIdle
scheduler.cancelAll();The base class never calls any logger or metrics library. All hooks are no-ops by default.
Try it
The builder demo constructs a VirtualScheduler via VirtualScheduler.builder().withCounter(...).build(). Watch how scheduleAt fires the one-shot exactly once and scheduleEvery fires the interval four times as virtual time advances 200 ms in a single advance() call.
/**
* builderScheduler — constructs a VirtualScheduler via VirtualScheduler.builder().withCounter(...).build()
* and schedules both one-shot and interval tasks.
* Run: npx tsx examples/builderScheduler.ts
*/
import assert from 'node:assert/strict';
// #region usage
import { VirtualTimeCounter } from '../../clock/src/index.js';
import { VirtualScheduler } from '../src/index.js';
const counter = VirtualTimeCounter.create({ 'startMs': 0 });
// Build a VirtualScheduler using the fluent builder
const scheduler = VirtualScheduler.builder()
.withCounter(counter)
.build();
console.log('Scheduler built. Current time:', counter.nowMs());
const fired: string[] = [];
// Schedule a one-shot task at t=100
scheduler.scheduleAt(100, () => { fired.push('one-shot@100'); });
// Schedule an interval task every 50 ms
scheduler.scheduleEvery(50, () => { fired.push(`interval@${counter.nowMs()}`); });
// Advance virtual time by 200 ms — fires one-shot once, interval 4 times
scheduler.advance(200);
console.log('Fired events:', fired);
console.log('Current virtual time:', counter.nowMs());
// Cancel remaining tasks
scheduler.cancelAll();
console.log('All tasks cancelled');
// #endregion usage
// Interval fires at t=50, 100, 150, 200 → 4 times; one-shot fires once at t=100
assert.equal(fired.filter((e) => { const result = e.startsWith('interval');
return result; }).length, 4, 'interval fires 4 times over 200 ms');
assert.equal(fired.filter((e) => { const result = e.startsWith('one-shot');
return result; }).length, 1, 'one-shot fires once');
assert.equal(counter.nowMs(), 200, 'virtual time advanced to 200');
console.log('builderScheduler: all assertions passed');
The hooks demo subclasses VirtualScheduler and overrides nine protected lifecycle methods. Observe the full trace: every scheduleAt/scheduleEvery call emits schedule; each advance() emits advance then runUntil; the failing task triggers both fire and fireError; the interval task emits reschedule after each fire; and cancelAll followed by idle appear at the end.
/** observedScheduler — override all lifecycle hooks to emit a debug trace. Run: npx tsx examples/observedScheduler.ts */
import assert from 'node:assert/strict';
// #region usage
import { VirtualTimeCounter } from '../../clock/src/index.js';
import { VirtualScheduler } from '../src/index.js';
class ObservedScheduler extends VirtualScheduler {
readonly events: string[] = [];
public constructor(counter: Readonly<VirtualTimeCounter>) {
super(counter);
}
protected override onSchedule(id: string, atMs: number, variant: 'interval' | 'timeout'): void {
const line = `[scheduler] schedule id=${id} atMs=${atMs.toString()} variant=${variant}`;
console.log(line);
this.events.push(line);
}
protected override onAdvance(deltaMs: number): void {
const line = `[scheduler] advance deltaMs=${deltaMs.toString()}`;
console.log(line);
this.events.push(line);
}
protected override onRunUntil(atMs: number): void {
const line = `[scheduler] runUntil atMs=${atMs.toString()}`;
console.log(line);
this.events.push(line);
}
protected override onFire(id: string): void {
const line = `[scheduler] fire id=${id}`;
console.log(line);
this.events.push(line);
}
protected override onFireError(id: string, error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
const line = `[scheduler] fireError id=${id} error="${message}"`;
console.log(line);
this.events.push(line);
}
protected override onReschedule(id: string, atMs: number): void {
const line = `[scheduler] reschedule id=${id} nextAtMs=${atMs.toString()}`;
console.log(line);
this.events.push(line);
}
protected override onCancel(id: string): void {
const line = `[scheduler] cancel id=${id}`;
console.log(line);
this.events.push(line);
}
protected override onCancelAll(): void {
const line = '[scheduler] cancelAll';
console.log(line);
this.events.push(line);
}
protected override onIdle(): void {
const line = '[scheduler] idle';
console.log(line);
this.events.push(line);
}
}
const counter = VirtualTimeCounter.create({ 'startMs': 0 });
const scheduler = new ObservedScheduler(counter);
// Schedule a one-shot task at t=100
scheduler.scheduleAt(100, () => {
console.log('[task:one-shot] fired at t=100');
});
// Schedule an interval task every 150 ms
scheduler.scheduleEvery(150, () => {
console.log('[task:interval] fired');
});
// Schedule a task that throws — exercises onFireError
scheduler.scheduleAt(200, () => {
console.log('[task:failing] about to throw');
throw new Error('task failure');
});
// Advance to t=300 — fires the one-shot at 100, the interval at 150, the failing at 200,
// and reschedules the interval to 300.
scheduler.advance(300);
// Cancel everything to exercise onCancelAll + onIdle
scheduler.cancelAll();
// #endregion usage
// Verify event sequence
const evts = scheduler.events;
// schedule: one-shot (vtask-1), interval (vtask-2), failing (vtask-3)
assert.ok(evts.some((e) => { return e.includes('schedule') && e.includes('vtask-1') && e.includes('timeout'); }), 'one-shot scheduled');
assert.ok(evts.some((e) => { return e.includes('schedule') && e.includes('vtask-2') && e.includes('interval'); }), 'interval scheduled');
assert.ok(evts.some((e) => { return e.includes('schedule') && e.includes('vtask-3') && e.includes('timeout'); }), 'failing task scheduled');
// advance + runUntil appear
assert.ok(evts.some((e) => { return e.includes('advance') && e.includes('300'); }), 'advance(300) traced');
assert.ok(evts.some((e) => { return e.includes('runUntil') && e.includes('atMs'); }), 'runUntil traced');
// fire events for all three tasks
assert.ok(evts.some((e) => { return e.includes('fire') && e.includes('vtask-1'); }), 'one-shot fired');
assert.ok(evts.some((e) => { return e.includes('fire') && e.includes('vtask-2'); }), 'interval fired');
assert.ok(evts.some((e) => { return e.includes('fire') && e.includes('vtask-3'); }), 'failing task fired');
// fireError for the failing task
assert.ok(evts.some((e) => { return e.includes('fireError') && e.includes('vtask-3') && e.includes('task failure'); }), 'fireError traced');
// reschedule for the interval task
assert.ok(evts.some((e) => { return e.includes('reschedule') && e.includes('vtask-2'); }), 'interval rescheduled');
// idle after advance drains pending tasks — heap has only the interval pending at 300
// then cancelAll clears it
assert.ok(evts.some((e) => { return e.includes('cancelAll') && e.startsWith('[scheduler]'); }), 'cancelAll traced');
assert.ok(evts.some((e) => { return e.includes('idle') && e.startsWith('[scheduler]'); }), 'idle traced');
console.log('observedScheduler: all assertions passed');