Backoff-Supervisor
Die Default-Supervisor-Strategie des Frameworks startet ein Kind bis zu 10-mal pro Minute neu. Für transiente Fehler kann das bedeuten, eine kaputte Abhängigkeit zu hämmern — einen Broker, der reconnected, eine DB, die sich erholt — mit Restart-nach-Restart, jeder identisch crashend.
BackoffSupervisor ist die Alternative. Er wickelt einen
einzelnen Child-Actor und plant seinen Restart mit einem
Exponential-Backoff (200 ms, 400, 800, …, bei einem Max gecappt),
plus Jitter, damit eine Herde von Clients sich nicht synchronisiert.
Ein minimales Beispiel
Abschnitt betitelt „Ein minimales Beispiel“import { ActorSystem, Actor, BackoffSupervisor } from 'actor-ts';
class Flaky extends Actor<{ kind: 'do-it' }> { override preStart(): void { if (Math.random() < 0.7) throw new Error('upstream not ready'); } override onReceive(message: { kind: 'do-it' }): void { this.log.info('ok'); }}
const system = ActorSystem.create('demo');
const supervisor = system.spawn( BackoffSupervisor.factory({ child: Flaky, minBackoff: 200, maxBackoff: 10_000, randomFactor: 0.2, }), 'flaky-supervisor',);
// Sende Nachrichten an den Supervisor — sie werden an das aktuelle// Kind weitergeleitet oder während eines Backoff-Fensters gestasht.supervisor.tell({ kind: 'do-it' });Der Supervisor:
- Spawnt ein
Flaky-Kind unterstoppingStrategy(ein Crash = ein sauberer Stop, kein Default-Restart). - Death-Watch’d das Kind.
- Bei
Terminatedplant er einen One-Shot-Timer, um ein frisches Kind nachpolicy.delayFor(restartCount)ms zu spawnen. - Puffert Nachrichten, die während des Backoff-Fensters ankommen.
Wenn das Kind schließlich erfolgreich startet und Nachrichten verarbeitet, werden die gepufferten Nachrichten an es geflusht (mit erhaltenen Original-Sender-Refs für Ask-artige Antworten).
Der Mechanismus
Abschnitt betitelt „Der Mechanismus“Fünf Schritte, in Ausführungsreihenfolge:
Das Framework benennt aufeinanderfolgende Kinder child-1,
child-2, child-3, … sodass alte Terminierungen nicht mit neuen
Spawns kollidieren.
Ein Respawn braucht einen Tod, keine Nachricht darüber
Abschnitt betitelt „Ein Respawn braucht einen Tod, keine Nachricht darüber“Der Supervisor nimmt beliebige User-Nachrichten entgegen — genau das
ist sein Zweck, er leitet sie an das Kind weiter — ein Terminated,
das das aktuelle Kind benennt, ist also etwas, das ihm alles schicken
könnte, was seine Ref hält. Darauf zu reagieren würde ein lebendes
Kind aus der Buchführung nehmen, den Backoff-Zähler hochzählen und
einen Ersatz neben ein child-1 stellen, das niemand gestoppt hat und
niemand mehr beobachtet.
Zwei Dinge verhindern das. Die Runtime
lehnt ein Terminated ab, das sie nicht ausgelöst hat,
noch bevor der Supervisor es überhaupt sieht. Und der Supervisor
fragt die Zelle des Kindes selbst, ob sie tatsächlich terminiert ist,
bevor er es aus der Buchführung nimmt — selbst eine echte Notification
über einen laufenden Actor wird also mit einer Warnung abgelehnt statt
befolgt. Ist ein Vorgänger beim Spawnen seines Nachfolgers wider
Erwarten noch am Leben, wird er gestoppt statt verwaist zurückgelassen.
Nichts davon ist konfigurierbar, und nichts davon kostet auf dem
normalen Pfad etwas: ein Kind, das wirklich gestorben ist, meldet
terminated, bevor seine Watcher davon erfahren — die Prüfung ist
also ein Boolean-Read.
Konfiguration
Abschnitt betitelt „Konfiguration“Die BackoffOptions<T>-Form:
import { ActorOptions } from 'actor-ts';
type BackoffOptions<T> = { child: ActorClassOrFactory<T>; childOptions?: ActorOptions<T>; childName?: string; minBackoff: number; maxBackoff: number; randomFactor?: number; // Default 0.2 policy?: BackoffPolicy; resetCounter?: ResetCounter; // Default 'after-min-stable' forward?: ForwardStrategy; // Default 'stash' triggerOn?: TerminationTrigger; // Default 'any' maxStashSize?: number; // Default 1000 drainGraceMs?: number; // Default min(50, minBackoff) forwardDuringGrace?: boolean; // Default true clock?: () => number;};Die interessantesten Felder:
triggerOn
Abschnitt betitelt „triggerOn“| Wert | Wann respawnen |
|---|---|
'any' (default) | Respawn bei jeder Terminierung — sowohl Crashes als auch sauberen Stops. |
'failure' | Respawn nur bei Crashes. Ein sauberer context.stopSelf() bedeutet “dieses Kind ist fertig”; der Supervisor stoppt sich danach selbst. |
'stop' | Respawn nur bei sauberen Stops (z.B. ein transienter Connection-Actor, der sich periodisch abbaut). Crashes propagieren nach oben. |
'failure' ist der richtige Default, wenn du “Restart bei
unerwartetem Tod” modellierst — ein sauberer Self-Stop ist eine
bewusste Wahl, die der Supervisor ehren sollte. 'any' restartet
bei jeder Terminierung — die breiteste Policy, nützlich, wenn der
Supervisor sich nicht darum kümmert, warum das Kind gestoppt hat.
forward — was mit Nachrichten zu tun ist, während das Kind tot ist
Abschnitt betitelt „forward — was mit Nachrichten zu tun ist, während das Kind tot ist“forward: 'stash', // bis zu maxStashSize puffern, nach Respawn drainen// oderforward: 'drop', // still verwerfen (Debug-geloggt)Stashen erhält Sender-Refs, sodass Ask-Antworten nach dem Respawn weiter funktionieren — eine Nachricht, die während des Kinds-down gefragt wurde, bekommt ihre Antwort, sobald das neue Kind sie behandelt.
Verwerfen ist der richtige Anruf für “transiente Pings, die das Aufbewahren nicht wert sind” — Telemetrie, Heartbeats, wo veraltete Nachrichten schlimmer als verlorene sind.
Wenn der Stash voll wird. Ein Backoff-Fenster, das länger dauert, als
der Verkehr warten kann, schiebt den Stash irgendwann über maxStashSize
— und die älteste gepufferte Nachricht macht der neuesten Platz. Diese
Nachricht wird nicht still verworfen: Sie wird zu einem
Dead Letter und trägt ihre Nutzlast sowie
den Sender, der die Antwort bekommen hätte. Ein Überlauf ist damit
hinterher rekonstruierbar statt eine Lücke in der Spur.
Die begleitende Warnung ist aggregiert, nicht eine Zeile pro Nachricht: Sie feuert bei der ersten Verdrängung und danach bei jeder Verdopplung, und trägt die laufende Gesamtzahl. Sonst würde eine Flut gegen einen Supervisor, der in einem langen Backoff feststeckt, aus einer Nachrichtenflut eine Log-Flut mit genau derselben Rate machen.
resetCounter
Abschnitt betitelt „resetCounter“resetCounter: 'after-min-stable', // zurücksetzen, wenn Kind alive >= minBackoff (default)resetCounter: 'never', // nie zurücksetzen (Zähler wächst monoton)resetCounter: { kind: 'after-time', ms: 60_000 }, // nach 60s alive zurücksetzenOhne Zurücksetzen bekommt ein Kind, das nach einer lang stabilen
Periode fehlschlägt, das gleiche lange Backoff wie nach einem
kürzlichen Crash — was meist falsch ist (der lang laufende Erfolg
suggeriert, dass der Fehler frisch ist). 'after-min-stable'
setzt den Count zurück, wenn das Kind mindestens minBackoff am
Leben war, sodass ein normales kurzes Backoff nach einem lang
laufenden Erfolg restartet.
drainGraceMs + forwardDuringGrace
Abschnitt betitelt „drainGraceMs + forwardDuringGrace“Nach einem Respawn wartet der Supervisor bis zu drainGraceMs
(Default 50 ms), bevor er den Stash an das neue Kind drained. Das
schützt vor Kindern, die in preStart crashen:
- Wenn das Kind während des Grace-Fensters stirbt, wird der Stash für die nächste Inkarnation zurückgehalten — gestashed Nachrichten gehen nicht zu Dead Letters verloren, wenn das Kind beim Startup immer wieder crasht.
forwardDuringGrace: true (Default) sendet neue Nachrichten
sofort während des Grace; forwardDuringGrace: false stasht sie
bis Grace abläuft. Der Default tauscht ein winziges Risiko von
Dead-Lettering während eines preStart-Crashes gegen niedrigere
Latenz auf dem Happy-Path.
Eigene Backoff-Policy
Abschnitt betitelt „Eigene Backoff-Policy“import { BackoffSupervisor, linearBackoff } from 'actor-ts';
BackoffSupervisor.factory({ child: ..., minBackoff: 500, maxBackoff: 10_000, policy: linearBackoff({ minMs: 500, maxMs: 10_000, stepMs: 500 }),});Überschreibe das Default-Exponential-Backoff mit einer beliebigen
BackoffPolicy — linear,
Fibonacci, eigene. minBackoff / maxBackoff sind immer noch
erforderlich (sie sind beratende Caps; das Framework verwendet sie
für die resetCounter-Heuristik), aber die policy steuert die
tatsächliche Delay-Berechnung.
Wann zu BackoffSupervisor greifen
Abschnitt betitelt „Wann zu BackoffSupervisor greifen“Drei gute Passungen:
- Broker-Verbindungen (Kafka, NATS, AMQP), wo ein transienter
Broker-Ausfall bedeutet, dass das
connect()des Actors für ein paar Sekunden fehlschlägt, bevor es sich erholt. Default-defaultStrategywürde aggressiv restarten; Backoff glättet es. - Datenbank-Actors, die einen Connection-Pool halten — wenn die DB hickst, crasht der Actor, und Backoff kauft Zeit vor dem erneuten Aufbau.
- Third-Party-API-Actors mit rate-limit-bewussten Retries — wenn ein Vendor 429 zurückgibt, crasht der Actor; Backoff wartet vor dem erneuten Versuch.
Wann NICHT zu verwenden
Abschnitt betitelt „Wann NICHT zu verwenden“Verglichen mit einfacher Supervision
Abschnitt betitelt „Verglichen mit einfacher Supervision“OneForOneStrategy(decider, { maxRetries, withinTimeRangeMs })
cappt Restarts bei N pro Fenster, aber verzögert nicht zwischen
ihnen — das Framework restartet sofort nach jedem Crash.
BackoffSupervisor fügt das Delay-zwischen-Restarts-Stück plus
eine Nachrichten-Puffer-Schicht hinzu. Die zwei sind komplementär:
- Für nicht-transiente Bugs ist einfache Supervision mit einem
niedrigen
maxRetriesokay (gib nach ein paar Versuchen auf und lass den Fehler eskalieren). - Für transiente Infrastruktur-Probleme ist Backoff-Supervision die zusätzlichen beweglichen Teile wert.
Du kannst sie kombinieren — wickle die eigene Strategie eines
BackoffSupervisor mit einer
OneForOneStrategy(..., { maxRetries: 10 }), um zu sagen “back off
zwischen Restarts, aber gib komplett auf nach 10 Versuchen.”
Wie es weitergeht
Abschnitt betitelt „Wie es weitergeht“- Backoff-Policy — die
exponentialBackoff/linearBackoff-Primitives, die den Policy-Wert produzieren. - Supervision — die Plain-Supervision-Basis, auf der das aufbaut.
- Circuit Breaker — um bevor ein Call fehlschlägt zurückzutreten (nicht nachher).
- Retry — Per-Call-Retry mit ähnlicher Backoff-Mathematik, aber außerhalb der Actor-Welt.
Die BackoffSupervisor-API-Referenz
deckt alle Optionen ab.
