Zum Inhalt springen
Deutsch

Timer und Scheduling

Actors müssen oft etwas später tun — alle 30 Sekunden einen Downstream-Service neu pollen, eine Session nach 5 Minuten timen lassen, einen veralteten Heartbeat re-publishen. Das Framework gibt dir zwei APIs dafür.

APILebenszeitVerwende es, wenn
system.schedulerGebunden an das Actor-System. Überlebt jeden Actor-Stop / -Restart.One-Shot-Verzögerungen, die außerhalb eines Actors initiiert werden, oder Arbeit, die den Actor, der sie startet, überleben muss.
this.context.timers (Per-Actor-TimerScheduler)Gebunden an die Actor-Instanz. Auto-cancelt bei Stop und bei Restart.Die meisten actor-internen Timer — Heartbeats, periodische Refreshes, Idle-Timeouts.

Wenn du innerhalb eines onReceive bist, default zu context.timers. Verwende system.scheduler nur für die wenigen Fälle, in denen du wirklich willst, dass ein Timer den Actor überlebt.

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 {
// Feuere { kind: 'tick' } alle 5 s; der erste Tick kommt nach 5 s.
// Übergib 0 als viertes Argument, um den ersten Tick sofort auszulösen.
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');
}
// Ersetze den Timer mit einem neuen Intervall. Gleicher Key → alter wird gecancelt.
private onSet(message: SetMessage): void {
this.context.timers.startTimerWithFixedDelay('hb', { kind: 'tick' }, message.intervalMs);
}
}
const system = ActorSystem.create('demo');
system.spawnAnonymous(Heartbeat);

Zwei zentrale Beobachtungen:

  • Timer sind per String-Key indexiert. Ein zweiter Aufruf von startTimerWithFixedDelay('hb', ...) ersetzt den bestehenden 'hb'-Timer — keine doppelten Feuerungen. Nützlich für “Reset-on-Activity”-Patterns.
  • Timer senden tell an this.self. Der Fire läuft wie jede andere Nachricht durch die Mailbox des Actors; er wird mit der übrigen Arbeit des Actors serialisiert, vom Supervisor des Actors supervised und in Actor-Traces sichtbar.

Die vollständige Schnittstelle:

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 läuft automatisch bei postStop — du musst nie ans Aufräumen denken. Genauso bei Restart: das Framework cancelt jeden aktiven Timer, bevor die neue Actor-Instanz startet, ein neu gestarteter Actor erbt also nie veraltete Timer vom toten.

import { ActorSystem } from 'actor-ts';
const system = ActorSystem.create('demo');
// Fire-and-Forget einer Nachricht nach 10 s.
system.scheduler.scheduleOnce(10_000, someActor, { kind: 'wake-up' });
// Führe einen Callback alle 30 s aus, beginnend in 5 s.
const cancellable = system.scheduler.scheduleAtFixedRateFunction(
5_000, 30_000,
() => console.log('still alive'),
);
// Später, stoppe ihn.
cancellable.cancel();

Die vier Methoden sind Paare aus “sende eine Nachricht” / “führe eine Funktion aus”:

MethodeWas sie tut
scheduleOnce(delayMs, target, message, sender?)Sende message einmal an target, nach delayMs.
scheduleOnceFunction(delayMs, task)Führe task einmal aus, nach delayMs.
scheduleAtFixedRate(initialDelayMs, intervalMs, target, message, sender?)Sende message wiederholt.
scheduleAtFixedRateFunction(initialDelayMs, intervalMs, task)Führe task wiederholt aus.

Jede Methode gibt ein Cancellable zurück:

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

Der cancel() gibt true zurück, wenn es tatsächlich einen aktiven Schedule gecancelt hat, false, wenn er bereits gecancelt oder gefeuert hat.

Drei legitime Fälle, am context.timers vorbei zum System-Scheduler zu greifen:

  1. Arbeit aus der Außenwelt initiieren. Ein SIGTERM-Handler, der nach einer kurzen Drain-Verzögerung einen coordinatedShutdown-Call plant. Kein Actor involviert — der System-Scheduler ist die richtige Heimat.
  2. Arbeit, die einen spezifischen Actor überleben muss. Ein “Retry in 60 s”-Timer, der von Actor A aufgesetzt wurde, bei dem der Retry trotzdem feuern soll, selbst wenn A in der Zwischenzeit neu startet. Selten — meistens ist die richtige Antwort “lasse den Parent von A den Timer setzen”, aber die Option existiert.
  3. Innerhalb von Framework-Code (Extensions, Transports, …) — überall, wo es keinen Actor-Kontext gibt.

Für gewöhnliche actor-interne Timer ist context.timers immer die richtige Wahl.

Beide Timer-Spielarten propagieren Fehler etwas anders:

  • context.timers feuert, indem es dem Actor tellt — der resultierende onReceive-Aufruf läuft durch den normalen Supervisions-Pfad. Ein Throw im Handler wird vom Supervisor gefangen, genau wie bei einer regulären Nachricht.

  • system.scheduler.scheduleAtFixedRateFunction(...) / scheduleOnceFunction(...) fängt Throws und meldet sie, ohne zu eskalieren. Der Task läuft in einem rohen Timer-Callback, es gibt also keine Cell und keinen Supervisor darüber — aber der Fehler ist nicht mehr unsichtbar. Er wird doppelt gemeldet, genau wie eine fehlgeschlagene Dispatcher-Arbeitseinheit:

    • Der System-Logger bekommt einen error-Record, damit der Fehler jeden konfigurierten Log-Sink erreicht.
    • Der Event-Stream bekommt einen SchedulerError, der die cause trägt.

    Ein fehlschlagender Scheduler-Callback crasht das System weiterhin nicht, und ein wiederholender Schedule tickt darüber hinaus weiter.

    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 bleibt nur als letzte Instanz, für einen Scheduler außerhalb eines Actor-Systems: der onError-Slot ist ungesetzt, bis ein ActorSystem die Instanz adoptiert, und ein Fehler, der niemanden erreicht, ist schlimmer als einer, der ein Terminal erreicht. Ein Sink, den du selbst setzt, wird nie übernommen, und er wird zurückgegeben, wenn das System terminiert — was für den ManualScheduler zählt, den du einem System im Test leihst und danach weiter antreibst.

  • system.scheduler.scheduleOnce(...) / scheduleAtFixedRate(...) liefert via target.tell(...). Fehler werden vom Supervisor des Targets behandelt — diese beiden publizieren nie einen SchedulerError, denn ein Throw im Handler des Targets ist Sache des Parents.

Was auch immer einen Zeitplan scharfgemacht hat — der Zeitplan gehört ihm nicht. Ein gefeuerter Timer läuft daher mit geleerter MDC, nicht mit dem Kontext, der beim Armieren aktuell war:

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
}

Das gilt für alle vier system.scheduler-Einstiegspunkte ebenso wie für context.timers. Ohne das würde ein einziges setInterval, das beim Behandeln des Requests eines Tenants scharfgemacht wurde, dessen Identifier für die Lebensdauer des Prozesses an jeden Tick nageln — und weil ein tell den Kontext, unter dem es läuft, erneut snapshotet, auch an alle weitergeben, die der Tick als Nächstes anschrieb.

Braucht ein Tick den Armierungs-Kontext wirklich, sag es ausdrücklich: schnappschieße ihn beim Armieren und öffne ihn im Handler erneut.

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

Siehe Logging — was das Framework garantiert für die vollständige Liste der betroffenen Auslieferungen — und für die Hälfte, die weiterhin du schließen musst: ein rohes setTimeout oder ein nicht awaitetes Promise erbt den Kontext der Runde genau wie bisher.

scheduleAtFixedRate jagt keine verpassten Feuerungen. Wenn sich die Mailbox des Targets staut und ein tick noch in der Queue ist, wenn das nächste Intervall verstreicht, bekommst du zwei Ticks in der Mailbox, statt dass das Framework den verpassten überspringt — aber das Framework komprimiert sie auch nicht. Dasselbe gilt für startTimerWithFixedDelay.

Wenn du Missed-Firing-Detection brauchst (z.B. “wenn mehr als 30 s seit dem letzten Tick verstrichen sind, feuere ein Catch-Up-Event”), zeichne den Last-Fire-Timestamp im Actor auf und inspiziere ihn bei jedem Tick. Es gibt keinen eingebauten Modus dafür.

In Tests ist Echtzeit-Warten ein Albtraum — flakey, langsam und order-abhängig. Das Framework liefert einen ManualScheduler, der dieselbe Scheduler-Schnittstelle implementiert, aber die Zeit nur voranschreitet, wenn du advance(ms) aufrufst:

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); // noch nichts
clock.advance(1); // tick feuert

Derselbe ManualScheduler lässt sich in context.timers stecken — actor-gebundene Timer respektieren ihn über die Scheduler-Injection des Systems. Siehe TestKit für die volle Test-Zeit-Geschichte.

Default: context.timers. Auto-Cancel bei Stop, integriert sich mit Supervision und taucht in Actor-Traces auf. 90 % deines Timer-Codes lebt hier.

Greife zu system.scheduler, wenn:

  • Du komplett außerhalb eines Actor-Kontexts bist (SIGTERM-Handler, Extension-Startup, Top-Level-Glue-Code).
  • Ein Schedule einen spezifischen Actor überleben muss.

Greife zu ManualScheduler in Tests, um Zeit deterministisch zu machen.

  • Receive-Timeout — der höhergeordnete “ich war N Sekunden im Leerlauf, feuere ein internes Event”-Wrapper, der Timer darunter verwendet.
  • Mailboxes — wo die vom Timer ausgelösten Nachrichten landen.
  • Dispatcher — wann der dispatched Timer-Fire tatsächlich verarbeitet wird.
  • TestKit — der ManualScheduler
    • Assertion-Helfer für deterministische Timer-Tests.

Die Scheduler- und TimerScheduler-API-Referenzen decken die vollen Signaturen ab.