@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:
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:
// --- 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);Scheduler-aware sleep
Delay.sleep(ms, { clock?, scheduler?, signal? }) resolves through the selected scheduler. A native AbortSignal rejects with its exact signal.reason: a pre-aborted signal schedules nothing, while an abort during the delay cancels the pending scheduled task.
With a VirtualScheduler and VirtualClockProvider sharing one counter, completion stays deterministic without wall-clock timers. Passing a native AbortController.signal in the same options object makes cancellation deterministic too; advancing virtual time after abort does not fire the cancelled task.
// Real-time: resolves after ~10ms of wall-clock time.
await Delay.sleep(10);
console.log('Real-time sleep resolved');
// Virtual-time: resolves as soon as advance() crosses the requested delay,
// with no real wall-clock wait.
const counter = VirtualTimeCounter.create({ 'startMs': 0 });
const scheduler = VirtualScheduler.create({ 'counter': counter });
const clock = VirtualClockProvider.create(counter);
let resolved = false;
const sleepPromise = Delay.sleep(1_000, { 'clock': clock, 'scheduler': scheduler }).then(() => {
resolved = true;
});
console.log('Resolved before advance:', resolved);
scheduler.advance(1_000);
await sleepPromise;
console.log('Resolved after advance:', resolved);Public API
Import Delay, RealTimeScheduler, VirtualScheduler, SchedulerError, PendingTaskInterface, ScheduledTaskInterface, and SchedulerProviderInterface from @studnicky/scheduler. The package declares separate root, entity, and interface import surfaces. Construct schedulers through RealTimeScheduler.create() or VirtualScheduler.create({ counter }).
Extending
Both schedulers expose protected hooks for every lifecycle event. The di-provider example demonstrates the injectable SchedulerProviderInterface pattern with a LoggingScheduler subclass that records schedule and fire events:
/** 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 SchedulerProviderInterface — injectable for production/test swap. */
class WorkQueue {
readonly #scheduler: SchedulerProviderInterface;
public readonly processed: string[] = [];
public constructor(scheduler: SchedulerProviderInterface) {
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)
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: Error): void {
const message = error.message;
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 RuntimeError.create('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 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.
Entities
@studnicky/scheduler/entities exports every schema namespace in src/entities.
import { SchedulerTaskDataEntity } from '@studnicky/scheduler/entities';Interfaces
@studnicky/scheduler/interfaces exports every TypeScript interface in src/interfaces, including configuration and state contracts.
import type { PendingTaskInterface } from '@studnicky/scheduler/interfaces';Exports
| Symbol | Purpose | Import path |
|---|---|---|
Delay | Provides delay functionality. | @studnicky/scheduler |
RealTimeScheduler | Provides real time scheduler functionality. | @studnicky/scheduler |
SchedulerError | Represents scheduler failures. | @studnicky/scheduler |
SchedulerProviderInterface | Defines the scheduler provider contract. | @studnicky/scheduler |
VirtualScheduler | Provides virtual scheduler functionality. | @studnicky/scheduler |