Aller au contenu
Français

Timers and scheduling

Ce contenu n’est pas encore disponible dans votre langue.

Actors often need to do something later — re-poll a downstream service every 30 seconds, time out a session after 5 minutes, re-publish a stale heartbeat. The framework gives you two APIs for that.

APILifetimeUse it when
system.schedulerTied to the actor system. Survives any actor stop / restart.One-shot delays initiated outside an actor, or work that must outlive the actor that started it.
this.context.timers (per-actor TimerScheduler)Tied to the actor instance. Auto-cancels on stop and on restart.Most actor-internal timers — heartbeats, periodic refreshes, idle timeouts.

If you’re inside an onReceive, default to context.timers. Use system.scheduler only for the few cases where you really want a timer to outlive the actor.

import { match } from 'ts-pattern';
import { Actor, ActorSystem } from 'actor-ts';
type TickMessage = { readonly kind: 'tick' };
type SetMessage = { readonly kind: 'set'; readonly intervalMs: number };
type Message = TickMessage | SetMessage;
class Heartbeat extends Actor<Message> {
override preStart(): void {
// Fire { kind: 'tick' } every 5 s; the first tick lands after 5 s.
// Pass 0 as the 4th argument to fire the first tick immediately.
this.context.timers.startTimerWithFixedDelay('hb', { kind: 'tick' }, 5_000);
}
override onReceive(message: Message): void {
match(message)
.with({ kind: 'tick' }, () => this.onTick())
.with({ kind: 'set' }, (m) => this.onSet(m))
.exhaustive();
}
private onTick(): void {
this.log.info('heartbeat');
}
// Replace the timer with a new interval. Same key → old one cancels.
private onSet(message: SetMessage): void {
this.context.timers.startTimerWithFixedDelay('hb', { kind: 'tick' }, message.intervalMs);
}
}
const system = ActorSystem.create('demo');
system.spawnAnonymous(Heartbeat);

Two key observations:

  • Timers are keyed by a string. Calling startTimerWithFixedDelay('hb', ...) a second time replaces the existing 'hb' timer — no duplicate fires. Useful for “reset on activity” patterns.
  • Timers send tell to this.self. The fire goes through the actor’s mailbox like any other message; it’s serialized with the actor’s other work, supervised by the actor’s supervisor, and visible in actor traces.

The full surface:

interface TimerScheduler<TMessage = unknown> {
startSingleTimer(key: string, message: TMessage, delayMs: number): void;
startTimerWithFixedDelay(
key: string, message: TMessage, intervalMs: number, initialDelayMs?: number,
): void;
cancel(key: string): boolean;
cancelAll(): void;
isTimerActive(key: string): boolean;
activeKeys(): string[];
}

cancelAll runs automatically on postStop — you never have to remember to clean up. Same on restart: the framework cancels every active timer before the new actor instance starts, so a restarted actor never inherits stale timers from the dead one.

system.scheduler — system-level scheduling

Section titled “system.scheduler — system-level scheduling”
import { ActorSystem } from 'actor-ts';
const system = ActorSystem.create('demo');
// Fire-and-forget a message after 10 s.
system.scheduler.scheduleOnce(10_000, someActor, { kind: 'wake-up' });
// Run a callback every 30 s, starting in 5 s.
const cancellable = system.scheduler.scheduleAtFixedRateFunction(
5_000, 30_000,
() => console.log('still alive'),
);
// Later, stop it.
cancellable.cancel();

The four methods are pairs of “send a message” / “run a function”:

MethodWhat it does
scheduleOnce(delayMs, target, message, sender?)Send message to target once, after delayMs.
scheduleOnceFunction(delayMs, task)Run task once after delayMs.
scheduleAtFixedRate(initialDelayMs, intervalMs, target, message, sender?)Send message repeatedly.
scheduleAtFixedRateFunction(initialDelayMs, intervalMs, task)Run task repeatedly.

Every method returns a Cancellable:

interface Cancellable {
cancel(): boolean;
readonly isCancelled: boolean;
}

The cancel() returns true if it actually cancelled an active schedule, false if it was already cancelled or fired.

Three legitimate cases for reaching past context.timers to the system scheduler:

  1. Initiating work from outside the actor world. A SIGTERM handler that schedules a coordinatedShutdown call after a short drain delay. No actor involved — the system scheduler is the right home.
  2. Work that must outlive a specific actor. A “retry in 60 s” timer set up by actor A, where the retry should still fire even if A restarts in the meantime. Rare — usually the right answer is “have A’s parent set the timer”, but the option exists.
  3. Inside framework code (extensions, transports, …) — anywhere that doesn’t have an actor context.

For ordinary actor-internal timers, context.timers is always the right choice.

Both timer flavors propagate errors slightly differently:

  • context.timers fires by telling the actor — the resulting onReceive invocation goes through the normal supervision path. A throw inside the handler is caught by the supervisor, just like a regular message.

  • system.scheduler.scheduleAtFixedRateFunction(...) / scheduleOnceFunction(...) catches throws and reports them without escalating. The task runs inside a raw timer callback, so there is no cell and no supervisor above it — but the failure is no longer invisible. It is reported twice over, exactly like a failed dispatcher work unit:

    • The system logger gets an error record, so the failure reaches every configured log sink.
    • The event stream gets a SchedulerError, carrying the cause.

    A failing scheduler callback still won’t crash the system, and a repeating schedule keeps ticking past it.

    import { Actor, SchedulerError } from 'actor-ts';
    class SchedulerAlarm extends Actor<SchedulerError> {
    override preStart(): void {
    this.system.eventStream.subscribe(this.self, SchedulerError);
    }
    override onReceive(event: SchedulerError): void {
    this.log.error('a scheduled task threw', event.cause);
    }
    }

    console.error remains only as the last resort, for a Scheduler used outside an actor system: the onError slot is unset until an ActorSystem adopts the instance, and a failure that reaches nobody is worse than one that reaches a terminal. A sink you set yourself is never taken over, and it is handed back when the system terminates — which matters for the ManualScheduler you lend a system in a test and keep driving afterwards.

  • system.scheduler.scheduleOnce(...) / scheduleAtFixedRate(...) delivers via target.tell(...). Errors handled by the target’s supervisor — these two never publish a SchedulerError, because a throw inside the target’s handler is the parent’s business.

Whatever armed a schedule, the schedule does not belong to it. So a fired timer runs with the MDC emptied, not with the context that was current when the timer was armed:

override onReceive(message: Request): void {
// Called inside LogContext.run({ tenant: 'acme' }, ...) upstream.
this.context.timers.startTimerWithFixedDelay('sweep', { kind: 'sweep' }, 60_000);
}
private onSweep(): void {
this.log.info('sweeping'); // → no tenant field, on this and every later tick
this.auditor.tell('swept'); // → and the envelope carries none either
}

That holds for all four system.scheduler entry points as well as context.timers. Without it, one setInterval armed while handling one tenant’s request would pin that tenant’s identifiers to every tick for the life of the process — and, because a tell re-snapshots the context it runs under, forward them to whoever the tick messaged next.

If a tick genuinely needs the arming context, say so explicitly: capture it at arm time and reopen it in the handler.

private readonly armedUnder = LogContext.snapshot(); // at arm time
private onSweep(): void {
LogContext.run(this.armedUnder, () => this.log.info('sweeping'));
}

See Logging — what the framework guarantees for the full list of deliveries this applies to, and for the half that is still yours to close — a raw setTimeout or an un-awaited promise inherits the turn’s context exactly as it always did.

scheduleAtFixedRate does not chase missed firings. If the target’s mailbox backs up and a tick is still queued when the next interval elapses, you get two ticks in the mailbox rather than the framework skipping the missed one — but the framework also doesn’t compress them. Same goes for startTimerWithFixedDelay.

If you need missed-firing-detection (e.g., “if more than 30 s elapsed since the last tick, fire a catch-up event”), record the last-fire timestamp in the actor and inspect it on each tick. There’s no built-in mode for that.

In tests, real-time waiting is a nightmare — flakey, slow, and order-dependent. The framework ships a ManualScheduler that implements the same Scheduler interface but only advances time when you call advance(ms):

import { ActorSystem, ActorSystemOptions } from 'actor-ts';
import { ManualScheduler } from 'actor-ts/testkit';
const clock = new ManualScheduler();
const actorSystemOptions = ActorSystemOptions.create().withScheduler(clock);
const system = ActorSystem.create('test', actorSystemOptions);
system.scheduler.scheduleOnce(1_000, someActor, { kind: 'tick' });
clock.advance(999); // nothing yet
clock.advance(1); // tick fires

The same ManualScheduler plugs into context.timers — actor-bound timers respect it via the system’s Scheduler injection. See TestKit for the full test-time story.

Default: context.timers. It auto-cancels on stop, integrates with supervision, and shows up in actor traces. 90 % of your timer code lives here.

Reach for system.scheduler when:

  • You’re outside an actor context entirely (SIGTERM handler, extension startup, top-level glue code).
  • A schedule must outlive a specific actor.

Reach for ManualScheduler in tests to make time deterministic.

  • Receive timeout — the higher-level “I’ve been idle for N seconds, fire an internal event” wrapper that uses timers underneath.
  • Mailboxes — where the timer-fired messages land.
  • Dispatchers — when the dispatched timer-fire is actually processed.
  • TestKit — the ManualScheduler
    • assertion helpers for deterministic timer tests.

The Scheduler and TimerScheduler API references cover the full signatures.