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.
| API | Lebenszeit | Verwende es, wenn |
|---|---|---|
system.scheduler | Gebunden 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.
context.timers — actor-gebundene Timer
Abschnitt betitelt „context.timers — actor-gebundene Timer“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
tellanthis.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.
system.scheduler — System-Level-Scheduling
Abschnitt betitelt „system.scheduler — System-Level-Scheduling“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”:
| Methode | Was 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.
Wann system.scheduler zu bevorzugen ist
Abschnitt betitelt „Wann system.scheduler zu bevorzugen ist“Drei legitime Fälle, am context.timers vorbei zum
System-Scheduler zu greifen:
- Arbeit aus der Außenwelt initiieren. Ein
SIGTERM-Handler, der nach einer kurzen Drain-Verzögerung einencoordinatedShutdown-Call plant. Kein Actor involviert — der System-Scheduler ist die richtige Heimat. - 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.
- 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.
Fehler in Timer-Callbacks
Abschnitt betitelt „Fehler in Timer-Callbacks“Beide Timer-Spielarten propagieren Fehler etwas anders:
-
context.timersfeuert, indem es dem Actortellt — der resultierendeonReceive-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 diecauseträ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.errorbleibt nur als letzte Instanz, für einenScheduleraußerhalb eines Actor-Systems: deronError-Slot ist ungesetzt, bis einActorSystemdie 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 denManualSchedulerzählt, den du einem System im Test leihst und danach weiter antreibst. - Der System-Logger bekommt einen
-
system.scheduler.scheduleOnce(...)/scheduleAtFixedRate(...)liefert viatarget.tell(...). Fehler werden vom Supervisor des Targets behandelt — diese beiden publizieren nie einenSchedulerError, denn ein Throw im Handler des Targets ist Sache des Parents.
Ein Tick trägt keinen diagnostischen Kontext
Abschnitt betitelt „Ein Tick trägt keinen diagnostischen Kontext“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.
Fixed-Rate-Semantik
Abschnitt betitelt „Fixed-Rate-Semantik“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.
Tests + der ManualScheduler
Abschnitt betitelt „Tests + der ManualScheduler“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 nichtsclock.advance(1); // tick feuertDerselbe 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.
Wann die Timer-Wahl wichtig ist
Abschnitt betitelt „Wann die Timer-Wahl wichtig ist“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.
Wie es weitergeht
Abschnitt betitelt „Wie es weitergeht“- 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.
