Skip to content

@studnicky/scheduler

Scheduler primitives: real-time (setTimeout/setInterval) and virtual (min-heap, deterministic) implementations.

Install

bash
pnpm add @studnicky/scheduler

Usage

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:

ts
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:

ts
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

SubpathContents
@studnicky/schedulerRealTimeScheduler, VirtualScheduler
@studnicky/scheduler/interfacesSchedulerProviderType, 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:

ts
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

HookWhen it firesArgs
onSchedule(id, atMs, variant)After a task is inserted into the heap via scheduleAt or scheduleEveryid: string, atMs: number, variant: 'timeout' | 'interval'
onAdvance(deltaMs)At the start of advance(), before the counter is incrementeddeltaMs: number
onRunUntil(atMs)At the start of runUntil()atMs: number
onFire(id)Immediately before a task's fire callback is invokedid: string
onFireError(id, error)When a task's fire callback throws synchronously or returns a rejected Promiseid: string, error: unknown
onReschedule(id, atMs)After an interval task is re-inserted into the heap following a successful fireid: string, atMs: number (next scheduled time)
onCancel(id)When a task's cancel() method is invokedid: string
onCancelAll()At the end of cancelAll()
onIdle()After runUntil / runAll drains the heap, or after cancelAll

RealTimeScheduler hooks

HookWhen it firesArgs
onSchedule(id, atMs, variant)After a task is registered via scheduleAt or scheduleEveryid: string, atMs: number, variant: 'timeout' | 'interval'
onFire(id)Inside the timer callback, immediately before fire is invokedid: string
onFireError(id, error)When a task's fire callback throws synchronously or returns a rejected Promiseid: string, error: unknown
onDrift(id, dueMs, actualMs, driftMs)When a one-shot task fires later than its scheduled atMsid: string, dueMs: number, actualMs: number, driftMs: number
onMiss(id, atMs, nowMs)When scheduleAt receives an atMs already in the pastid: string, atMs: number, nowMs: number
onCancel(id)When a task's cancel() method is invokedid: string
onCancelAll()At the end of cancelAll(), after all timers are cleared
onIdle()After cancelAll fully drains all tracked tasks

Demo trace (virtual scheduler)

ts
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.

VirtualScheduler builder
/**
 * 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');
Output
Press Execute to run this example against the real library.

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.

Scheduler lifecycle hooks
/** 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');
Output
Press Execute to run this example against the real library.

Source on GitHub