Zum Inhalt springen
Deutsch

Behaviors

Der Behaviors-Namespace ist eine Sammlung von Kombinatoren, die Behavior<T>-Werte bauen. Ein Behavior beschreibt, was der Actor tut, wenn die nächste Nachricht ankommt — und welches Behavior er danach annimmt. Die Lebenszeit eines Actors ist nur eine Sequenz von Behaviors: jeder Handler gibt das nächste zurück.

import { ActorSystem, Behaviors } from 'actor-ts';
type Message = { kind: 'tick' };
const ticker = Behaviors.receive<Message>((context, message) => {
context.log.info(`tick at ${Date.now()}`);
return Behaviors.same; // bleibe ein Ticker
});
const system = ActorSystem.create('demo');
const ref = system.spawnTypedAnonymous(ticker);

Behaviors.receive baut das häufigste Behavior: einen Handler, der auf jeder Nachricht läuft und das nächste Behavior zurückgibt. Der same-Sentinel sagt “bleibe wie ich bin” — derselbe Closure behandelt die nächste Nachricht.

const counter = (n: number): Behavior<Message> => Behaviors.receive<Message>((context, message) =>
match(message)
.with({ kind: 'increment' }, () => counter(n + 1))
.with({ kind: 'decrement' }, () => counter(n - 1))
.with({ kind: 'get' }, (m) => { m.replyTo.tell(n); return Behaviors.same; })
.exhaustive());

Der Handler empfängt sowohl einen TypedActorContext<T> (zum Spawnen von Kindern, Loggen, Beobachten) als auch die Nachricht. Gibt das nächste Behavior zurück — Behaviors.same behält den Closure; ein frisches counter(n + 1) nimmt einen neuen Closure mit aktualisiertem Zustand an.

const counter = (n: number): Behavior<Message> => Behaviors.receiveMessage<Message>((message) =>
match(message)
.with({ kind: 'increment' }, () => counter(n + 1))
.otherwise(() => Behaviors.same));

Shortcut für den häufigen Fall, dass du den Context nicht brauchst. Äquivalent zu Behaviors.receive((_context, message) => ...).

const watcher = Behaviors.receiveWithSignal<Message>(
(context, message) => {
// User-Nachricht behandeln...
return Behaviors.same;
},
(context, signal) => match(signal)
.with({ kind: 'terminated' }, (s) => {
context.log.info(`watched actor ${s.ref.path} stopped`);
return Behaviors.same;
})
.otherwise(() => Behaviors.same),
);

Der Signal-Handler feuert für Lifecycle-Events:

Signal-KindWann
'post-stop'Der Actor stoppt. Verwende für Cleanup.
'pre-restart'Der Supervisor wird den Actor gleich neu starten. signal.reason ist der Fehler.
'terminated'Ein beobachteter Actor hat gestoppt. signal.ref ist seine Ref.

Das ist das typed-DSL-Äquivalent zum Überschreiben von postStop, preRestart und dem Behandeln von Terminated-Nachrichten in der untyped-Form.

Der Handler gehört zu diesem Behavior, nicht zum Actor. Ein Übergang zu einem Behaviors.receive, das kein onSignal deklariert, meldet ihn ab — eine Zustandsmaschine, die post-stop-Cleanup braucht oder den Tod eines beobachteten Actors als Signal statt als Nachricht will, deklariert onSignal also in jedem Zustand, der es braucht:

const draining = Behaviors.receiveWithSignal<Message>(
(context, message) => Behaviors.same,
(context, signal) => { cleanUp(); return Behaviors.same; },
);
// Behält das Cleanup — der nächste Zustand deklariert seinen eigenen Handler.
const working = Behaviors.receiveWithSignal<Message>(
(context, message) => draining,
(context, signal) => { cleanUp(); return Behaviors.same; },
);
// Verliert es — ein einfaches `receive` deklariert keine Signale, ab hier
// feuert `post-stop` also nirgends und ein `Terminated` erreicht den
// Receive-Handler wieder als gewöhnliche Nachricht.
const forgetful = Behaviors.receiveWithSignal<Message>(
(context, message) => Behaviors.receive((innerContext, innerMessage) => Behaviors.same),
(context, signal) => { cleanUp(); return Behaviors.same; },
);

Die Sentinels sind die eine Ausnahme: Behaviors.stopped lässt den Handler installiert, denn ein post-stop-Handler, der genau in dem Moment aufhört zu funktionieren, in dem der Actor sich selbst stoppt, wäre nutzlos.

const myActor = Behaviors.setup<Message>((context) => {
context.log.info(`I'm starting at ${context.path}`);
const helper = context.spawn(helperBehavior, 'helper');
return Behaviors.receive((_context, message) => {
helper.tell(message); // helper im Closure eingefangen
return Behaviors.same;
});
});

setup läuft einmal beim Start des Actors. Verwende es für einmalige Initialisierung, die der Receive-Handler einschließen soll: Kinder spawnen, context.self für die Kinder zum Wissen einfangen, externe Verbindungen öffnen.

Hier benennt sich ein Behavior auch selbst. Jedes Behavior läuft in derselben TypedActor-Klasse, es gibt also keine eigene Subklasse, auf der du Actor.displayName() überschreiben könntest — context.setDisplayName ist der Weg dorthin, mit derselben Wirkung: Log-Zeilen und der DevTools-Baum bekommen neben dem Pfad ein lesbares Label.

const cart = (customerId: string): Behavior<Message> => Behaviors.setup((context) => {
context.setDisplayName(`Cart(${customerId})`);
return Behaviors.receive((_context, message) => { /* ... */ return Behaviors.same; });
});

Ohne das meldet ein ganzer Baum typed Actors TypedActor als Klasse, was nichts darüber sagt, welcher welcher ist. Ruf es jederzeit auf, nicht nur aus setup — ein Name, der sich erst nach der ersten Nachricht ergibt, lässt sich dann setzen. Für einen Namen, den die Spawn-Stelle schon kennt, tut ActorOptions.withDisplayName(...) dasselbe, ohne das Behavior zu betreten.

Sechs Kombinatoren wickeln ein anderes Behavior mit zusätzlichen Fähigkeiten. Die ersten drei geben dem inneren Behavior etwas, das es allein nicht erreichen könnte — einen Timer-Scheduler, einen Stash-Puffer, einen Supervisor. Die letzten drei sitzen davor, auf dem Weg, den jede Nachricht nimmt.

import { Behaviors, type TimerScheduler } from 'actor-ts';
const heartbeat = Behaviors.withTimers<Message>((timers) => {
timers.startTimerWithFixedDelay('hb', { kind: 'tick' }, 5_000);
return Behaviors.receiveMessage((message) =>
match(message)
.with({ kind: 'tick' }, () => { console.log('heartbeat'); return Behaviors.same; })
.otherwise(() => Behaviors.same));
});

Die TimerScheduler-API ist dieselbe, die context.timers in der untyped-Form bietet. withTimers fängt sie in einem Closure ein, sodass der Receive-Handler Zugriff hat, ohne bei jeder Nachricht über context.timers gehen zu müssen.

const init = Behaviors.withStash<Message>(100, (stash) => {
return Behaviors.receive((context, message) =>
match(message)
.with({ kind: 'ready' }, () => {
stash.unstashAll(); // alle gepufferten Nachrichten erneut abspielen
return ready;
})
.otherwise((m) => {
stash.stash(m); // alles andere für später parken
return Behaviors.same;
}));
});
const ready = Behaviors.receive<Message>((context, message) => {
// Nachrichten normal behandeln
return Behaviors.same;
});

Kapazitäts-gebundener Stash, mit stash / unstashAll / isEmpty / isFull / size. Gleiche Semantik wie das untyped context.stash, exponiert als Wert statt über Context — einschließlich der beiden wichtigsten Zusagen: unstashAll() legt die gepufferten Nachrichten vorne in die Mailbox zurück, sodass sie vor allem behandelt werden, was währenddessen ankam, und was beim Stoppen oder Neustarten noch geparkt ist, geht zu Dead Letters, statt zu verschwinden.

Zwei Unterschiede zur Context-Form, beide beabsichtigt. Die Kapazität ist die, die du withStash übergibst — pro Behavior, statt einer für den ganzen Actor. Und stash(message) nimmt die Nachricht explizit, sie muss also nicht die gerade behandelte sein — weshalb ein aus diesem Puffer erzeugter Dead Letter auch keinen ursprünglichen Sender trägt.

import { Behaviors, OneForOneStrategy, Directive } from 'actor-ts';
const supervised = Behaviors
.supervise(myReceiveBehavior)
.onFailure(new OneForOneStrategy(
(err) => Directive.Restart,
{ maxRetries: 5, withinTimeRangeMs: 60_000 },
));

Wickele ein Behavior mit einer Supervisor-Strategie. Vom inneren Handler geworfene Fehler werden durch die Strategie geleitet — Restart re-initialisiert das Behavior (zurück zur initialen Form), Stop terminiert, Resume überspringt die fehlschlagende Nachricht.

maxRetries und withinTimeRangeMs begrenzen, wie oft Restart feuern darf. Das Beispiel oben gewährt fünf Restarts pro gleitender Minute; der sechste Fehler innerhalb dieser Minute eskaliert stattdessen — der Fehler wird an die Zelle des Actors weitergeworfen, wo die Strategie des Parents entscheidet. maxRetries: -1, der Default einer handgebauten OneForOneStrategy, ist unbegrenzt; maxRetries: 0 restartet gar nicht erst.

Die Zählung gehört zum supervise-Wrapper, nicht zur Strategie — ein Strategie-Wert, den du mehreren Behaviors gibst, verschafft also jedem sein eigenes Kontingent. Sie summiert sich über die Restarts, die sie zählt; nur ein neuer supervise-Wrapper beginnt von vorn.

Die Eskalation jenseits der Grenze schichtet die beiden Supervisoren, statt sie zu duplizieren. Ein Restart, den der Parent gewährt, baut einen brandneuen Actor — dieses Kontingent pro Behavior beginnt damit von vorn, und die äußere Grenze ist die Strategie des Parents, standardmäßig zehn Restarts pro Minute. Ein Behavior, das immer wirft, hört also nach maxRetries auf, an Ort und Stelle zu kreisen, und stoppt endgültig, sobald auch das Kontingent des Parents aufgebraucht ist.

Geltungsbereich: der Wrapper überlebt den Teilbaum

Abschnitt betitelt „Geltungsbereich: der Wrapper überlebt den Teilbaum“

supervise installiert einen Geltungsbereich, und der hält so lange wie der Actor. Ein Behavior, zu dem das umschlossene übergeht, ist weiterhin überwacht, obwohl der Wrapper im zurückgegebenen Wert nirgends auftaucht:

const supervised = Behaviors
.supervise(Behaviors.receiveMessage<Message>((message) => nextPhase))
.onFailure(strategy);
// Wirft `nextPhase`, wird das ebenfalls durch `strategy` geleitet.

Das ist dieselbe Regel, der intercept folgt, und aus demselben Grund: der Wrapper steuert seinen Seiteneffekt einmal bei, und die Runtime erinnert sich an die Strategie. Behaviors.stopped ist der Weg aus einem Supervisions-Geltungsbereich heraus; ein Übergang ist es nicht.

Verschachtelung: die Geltungsbereiche schichten sich

Abschnitt betitelt „Verschachtelung: die Geltungsbereiche schichten sich“

Zwei Wrapper sind zwei Geltungsbereiche, und der innere hat das erste Wort:

const layered = Behaviors
.supervise(
Behaviors.supervise(leaf).onFailure(retryTwice), // innen
)
.onFailure(giveUpAndStop); // außen

Die innerste Strategie entscheidet. Directive.Escalate — und ein Restart-Kontingent, das der Geltungsbereich aufgebraucht hat — geben denselben Fehler an den nächsten Bereich nach außen weiter, genau das, was Escalate überall sonst im Framework bedeutet. Erst wenn es hinter dem äußersten Wrapper herausfällt, verlässt der Fehler den Actor, wo die Parent-Strategie der Zelle übernimmt. layered oben versucht es also zweimal innerhalb des Actors und stoppt ihn dann, statt dass die Erschöpfung des inneren Wrappers direkt zum Parent geht.

Ein Geltungsbereich, den ein laufendes Behavior installiert — ein Handler, der ein frisches Behaviors.supervise(...) zurückgibt — verschachtelt sich in die bereits aktiven, statt sie zu verdrängen.

Ein Restart, den ein äußerer Bereich entscheidet, löst dessen eigenes Kind neu auf und baut damit die Wrapper darunter neu auf — die inneren Bereiche kommen also mit vollem Restart-Kontingent zurück. Das ist dieselbe Aufteilung, die ein Restart bereits auf Interceptoren anwendet: was innerhalb des Wrappers lebt, gehört zu dem, was neu startet.

Siehe Supervision für die Direktiven-Semantik; sie gelten in der typed-Form identisch — mit einem Unterschied darin, wie die Grenze gezählt wird (dort vermerkt).

const guarded = Behaviors.intercept<Message>(inner, (context, message, next) => {
if (message.kind === 'ping') return Behaviors.same; // verwerfen — inner sieht sie nie
return next(context, message); // oder delegieren
});

Der Interceptor läuft bei jeder Nachricht zuerst und entscheidet, was danach passiert: next(context, message) aufrufen, um zu delegieren, eine andere Nachricht übergeben, um sie auf dem Weg hinein zu transformieren, oder ein Behavior zurückgeben, ohne next überhaupt aufzurufen, um sie zu verwerfen. Was er zurückgibt, wird zum nächsten Behavior des inneren Behaviors.

Der Wrapper überlebt die Übergänge des inneren Behaviors. Das ist der Teil, den man sich merken sollte: ein inneres Behaviors.receive, das bei jeder Nachricht ein frisches Behavior zurückgibt — die normale Form einer Zustandsmaschine — wird auch bei der nächsten abgefangen, und bei jeder danach. Der einzige Weg hinaus ist Behaviors.stopped, wo es nichts mehr abzufangen gibt.

Vom Interceptor geworfene Fehler werden genau wie Fehler des inneren Handlers behandelt: sie erreichen ein umschließendes supervise. Das Abfangen erfasst nur User-Nachrichten; Lifecycle-Signale gehen direkt an den Handler von receiveWithSignal.

Der Typ ist T → T — ein Interceptor beobachtet, transformiert oder verwirft, er ändert nie den Nachrichtentyp des Actors.

const audit = system.spawnTyped(auditBehavior, 'audit');
const monitored = Behaviors.monitor(audit, orders);

Leitet jede Nachricht an audit weiter, bevor orders sie behandelt. Nützlich für Audit-Trails und, in Tests, für eine Probe, die auf den vom Actor empfangenen Verkehr prüft:

const probe = kit.createTestProbe();
const ref = kit.system.spawnTypedAnonymous(Behaviors.monitor(probe, orders));

Erst weiterleiten, dann zustellen ist die bewusste Reihenfolge: der Monitor sieht eine Nachricht auch dann, wenn ihre Behandlung den Actor zum Absturz bringt — genau der Fall, für den man die Spur am dringendsten will. Die Zustellung ist Fire-and-Forget und ihre Fehler werden geschluckt: ein kaputter Abgriff darf den Actor nicht mit sich reißen.

const traced = Behaviors.logMessages(orders);
const audited = Behaviors.logMessages(orders, {
level: 'info',
formatter: (message) => `order ${message.orderId}`,
});
OptionDefaultBedeutung
level'debug''debug' oder 'info'. Jede Nachricht zu loggen ist eine Diagnose; sie auf warn oder error zu melden würde genau das Signal vergiften, nach dem ein Operator filtert — deshalb gibt es die nicht.
formattereingebautRendert die ganze Zeile. Darf nicht werfen — tut er es doch, wird stattdessen die eingebaute Zeile ausgegeben, denn eine Diagnose, die den beobachteten Actor umbringt, ist schlimmer als eine, die sich etwas schlechter liest.

Die eingebaute Zeile ist received <kind> und benennt die Nachricht über ihre Diskriminante. Bei einer Klasseninstanz fällt sie auf den Klassennamen zurück, danach auf typeof — ein blankes Objektliteral meldet Object als Konstruktor, was nichts aussagen würde.

Die Zeile wird nur gebaut, wenn der Logger des Actors sie tatsächlich ausgeben würde; das Ganze in einem System stehen zu lassen, das auf warn loggt, kostet also einen Vergleich pro Nachricht statt eines formatierten Strings.

Werte, die du aus einem Handler zurückgibst, um eine Übergangsentscheidung auszudrücken:

SentinelBedeutung
Behaviors.sameBehalte das aktuelle Behavior. Der Handler-Closure läuft erneut bei der nächsten Nachricht.
Behaviors.stoppedStoppe den Actor. Äquivalent zu context.stopSelf() in der untyped-Form.
Behaviors.unhandledDiese Nachricht wird hier nicht behandelt; route zu Dead Letters.
Behaviors.emptyDas Behavior akzeptiert Nachrichten, tut aber nichts. Nützlich als Platzhalter.
Behaviors.ignoreVerwirf jede Nachricht still (kein Dead-Letter-Routing).

Die ersten drei sind im Alltag am nützlichsten. empty und ignore existieren für Sonderfälle — ein “dieser Actor ist absichtlich erstmal still”-Stub oder eine Senke, die Traffic schlucken soll.

import { match } from 'ts-pattern';
import { ActorSystem, Behaviors, type Behavior } from 'actor-ts';
type ConfigureMessage = { kind: 'configure'; url: string };
type RequestMessage = { kind: 'request'; payload: string };
type Message = ConfigureMessage | RequestMessage;
const initializing = Behaviors.withStash<Message>(100, (stash) =>
Behaviors.receive<Message>((context, message) =>
match(message)
.with({ kind: 'configure' }, (m) => {
stash.unstashAll();
return ready(m.url);
})
.otherwise((m) => {
stash.stash(m);
return Behaviors.same;
})),
);
const ready = (url: string): Behavior<Message> =>
Behaviors.receive<Message>((context, message) =>
match(message)
.with({ kind: 'request' }, (m) => {
context.log.info(`POST ${url}: ${m.payload}`);
return Behaviors.same;
})
.otherwise(() => Behaviors.same));
const system = ActorSystem.create('demo');
system.spawnTypedAnonymous(initializing);

Zwei Behaviors:

  • initializing stasht alles, bis ein configure ankommt, dann wechselt es nach ready(url) nach dem Wiederabspielen des Stashs.
  • ready behandelt Requests unter Verwendung der eingefangenen url.

Die Übergänge sind explizite Returns; der Zustand lebt in Closure-Parametern; es gibt kein this, um das man sich kümmern muss.

Dekoratoren komponieren von außen nach innen. Behaviors.supervise(Behaviors.withTimers(...)) bedeutet “supervise das Timer-verwendende Behavior”; Behaviors.withTimers(Behaviors.supervise(...)) bedeutet “gib dem supervised Inneren die Timer.” In der Praxis:

const supervised = Behaviors
.supervise(Behaviors.withTimers((timers) =>
Behaviors.receive((context, message) => Behaviors.same)
))
.onFailure(strategy);

supervise ist außen um das withTimers, die Strategie überwacht also die ganze Konstruktion. Das ist fast immer die richtige Verschachtelung.

Interceptoren liest man genauso, und weil sie bei jeder Nachricht laufen, ist die Reihenfolge direkt beobachtbar:

const traced = Behaviors.logMessages(Behaviors.monitor(auditRef, inner));

logMessages ist ganz außen, loggt also zuerst, dann leitet der Monitor weiter, dann behandelt inner. Jeder Wrapper entscheidet, ob der nächste weiter innen die Nachricht überhaupt bekommt — ein Interceptor, der ohne Aufruf von next zurückkehrt, stoppt alles darunter.

supervise um supervise liest sich genauso und ist die eine Komposition, in der die Reihenfolge bestimmt, wer entscheidet: der innerste Wrapper wird zuerst gefragt und gibt den Fehler nach außen weiter. Siehe Verschachtelung oben.

Eine Asymmetrie ist wissenswert. Die anderen Dekoratoren werden beim Start des Actors aufgelöst: sie steuern ihren Seiteneffekt bei (Timer einfangen, Strategie installieren) und kollabieren in das Behavior, das sie erzeugt haben. Ein Interceptor kann das nicht, denn er muss auch bei der nächsten Nachricht da sein — er bleibt also um das gewickelt, was das innere Behavior wird.

Kollabieren ist aber nicht dasselbe wie Ablaufen. Ein supervise-Geltungsbereich überlebt den Wert, der ihn installiert hat, und deckt ab, wohin der Actor als nächstes übergeht; nur der Wrapper-Knoten ist weg. Ein Signal-Handler ist das Gegenteil — er ist ein Feld des receive-Behaviors, das ihn deklariert hat, und verschwindet daher mit diesem Behavior.

Dieselbe Unterscheidung entscheidet, was ein Restart neu aufbaut. supervise startet das neu, was es umschließt: ein Interceptor innerhalb des Wrappers gehört damit zum frischen Behavior und wird mit ihm neu aufgebaut; einer außerhalb nicht — er beobachtet über den Restart hinweg weiter. In beiden Verschachtelungen bleibt er danach genau einmal installiert: ein crash-loopender Actor sammelt keine Kopien seines eigenen Monitors an, und monitor fängt nicht an, eine Nachricht doppelt zuzustellen, weil der Actor vorher neu gestartet ist.

  • Typed Actor — die Runtime, die ein Behavior interpretiert.
  • Spawn Typed — system.spawnTyped, context.spawnTyped, typedActor.
  • Supervision — was Behaviors.supervise(...).onFailure(...) intern verwendet.
  • Become und Stash (untyped) — die OO-Äquivalente zu Behaviors.withStash + Behavior-Switching via Return-Werte.