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

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

ts

/** 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

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

Loading example…

Source on GitHub

Entities

@studnicky/scheduler/entities exports every schema namespace in src/entities.

typescript
import { SchedulerTaskDataEntity } from '@studnicky/scheduler/entities';

Interfaces

@studnicky/scheduler/interfaces exports every TypeScript interface in src/interfaces, including configuration and state contracts.

typescript
import type { PendingTaskInterface } from '@studnicky/scheduler/interfaces';

Exports

SymbolPurposeImport path
DelayProvides delay functionality.@studnicky/scheduler
RealTimeSchedulerProvides real time scheduler functionality.@studnicky/scheduler
SchedulerErrorRepresents scheduler failures.@studnicky/scheduler
SchedulerProviderInterfaceDefines the scheduler provider contract.@studnicky/scheduler
VirtualSchedulerProvides virtual scheduler functionality.@studnicky/scheduler