Zum Inhalt springen
Deutsch

ActorSystem

Das ActorSystem ist der Top-Level-Container für Actors. Eines pro logischer Anwendung — manchmal eines pro Prozess, manchmal ein paar, die nebeneinander laufen (z.B. ein Worker-Thread-Isolations-Setup). Jeder Actor lebt innerhalb eines Systems; das System besitzt den Dispatcher (der die Nachrichtenverarbeitung plant), den Scheduler (der Timer ausführt), den Supervisionsbaum (der Actor-Fehler abfängt), den Event-Stream und alle Extensions, die du registriert hast.

import { ActorSystem } from 'actor-ts';
const system = ActorSystem.create('my-app');

Der String ist der Name des Systems — er erscheint in Actor-Pfaden (actor-ts://my-app/user/...), Log-Zeilen und der Cluster-Identifikation. Verschiedene Systeme können mit verschiedenen Namen koexistieren; derselbe Name in einem Cluster-Setup bedeutet “ich trete dem bestehenden Cluster bei”, ein anderer Name bedeutet “ich bin ein separater Cluster”.

create kehrt synchron zurück. Die Root-Guardians des Systems werden eifrig erzeugt; User-Actors existieren noch nicht — du spawnst sie über spawn (unten beschrieben).

ActorSystem.create nimmt ein optionales Settings-Objekt als zweites Argument:

const actorSystemOptions = ActorSystemOptions.create()
.withLogLevel(LogLevel.Info)
.withConfigFile('./application.conf');
const system = ActorSystem.create('my-app', actorSystemOptions);

Die vollständige Settings-Form:

FeldZweck
loggerEigene Logger-Instanz. Standardmäßig ein Console-Logger, der logLevel respektiert.
logLevelEiner von debug / info / warn / error / off.
dispatcherEigener Dispatcher. Standardmäßig der Immediate-Dispatcher; tausche einen Microtask- oder Throughput-basierten ein, um das Scheduling anzupassen.
schedulerEigener Scheduler. Standardmäßig ein Echtzeit-Scheduler; Tests injizieren ManualScheduler, um die Zeit zu kontrollieren.
configEntweder eine vorgefertigte Config oder ein einfaches Objekt mit HOCON-Overrides. Wird über die Referenz-Defaults + einer eventuellen application.conf gelegt.
configFileExpliziter Pfad zu einer application.conf-Datei. Überschreibt die ACTOR_TS_CONFIG-Env-Variable und das CWD-Lookup.

Konstruktor-Settings gewinnen immer gegenüber allem in der Config — sie sind die expliziten Code-Level-Overrides.

Für größere Anwendungen bevorzuge eine application.conf-Datei im Projekt-Root:

actor-ts {
logger {
level = "info"
}
dispatcher {
throughput = 100
}
cluster {
gossip-interval = 500ms
failure-detector.unreachable-after = 1500ms
}
}

Das Framework lädt sie automatisch, wenn vorhanden. ENV-Substitution (${?ENV_NAME}) funktioniert wie in der HOCON-Spec definiert — aus der Umgebung gezogene Werte fallen auf den Default zurück, wenn sie nicht gesetzt sind. Siehe Konfiguration für jeden Schlüssel, den das Framework liest.

Top-Level-Actors werden über system.spawn gespawnt:

const root = system.spawn(
MyRootActor,
'root', // optionaler Name; Framework wählt einen, wenn weggelassen
);

Die zurückgegebene ActorRef ist ein Handle, keine Instanz. Gib es weiter, speichere es, übergib es an andere Actors.

Innerhalb eines Actors werden Child-Actors über context.spawn gespawnt, nicht über system.spawn:

class Parent extends Actor<...> {
override onReceive(message) {
const child = this.context.spawn(Child, 'worker');
}
}

Kinder sind an den Lebenszyklus des Parents gebunden — wenn der Parent stoppt, stoppen zuerst alle Kinder. Fehler von Kindern eskalieren an die Supervisor-Strategie des Parents. Top-Level-Actors (aus system.spawn) eskalieren stattdessen an den Root-Guardian des Systems.

Jeder Actor hat einen Pfad unter dem System-Root. Drei “Guardian”-Top-Level-Actors sitzen direkt unter dem Root:

actor-ts://my-app/

/user

Actors deiner Anwendung

/system

framework-interne Actors

/deadLetters

Nachrichten an tote Refs

/system/cluster

Sharding, Singleton, PubSub, …

/system/devtools

Hub + Probes

Wenn du system.spawn(actor, name) aufrufst, wird der Actor unter /user erzeugt. /user enthält nur, was dein Code erzeugt hat; alles, was das Framework für sich selbst erzeugt, liegt unter /system — eine Gruppe pro Subsystem, siehe Actor-Pfade für das vollständige Layout. Es gibt keine öffentliche API, um in /system zu spawnen.

Beim Terminieren gehen die beiden Guardians nacheinander, nicht gleichzeitig: /user wird vollständig geleert, dann /system. Diese Reihenfolge ist es, die einem postStop eines User-Actors erlaubt, die Framework-Actors zu erreichen, von denen er abhängt — sich beim PubSub-Mediator abmelden, einen Shard zurückgeben — statt mit ihnen um Dead Letters zu rennen.

Der /deadLetters-”Actor” ist speziell — Nachrichten an ein tell auf einer gestoppten Ref oder an eine nie existierte Ref werden dorthin geleitet. Jede davon wird als DeadLetter auf dem Event-Stream veröffentlicht und benennt den Empfänger, den sie nicht erreicht hat — und das ist alles, was standardmäßig passiert: nichts loggt sie und nichts bewahrt sie auf, ohne Abonnent ist die Nachricht also weg. Abonniere, um programmatisch zu reagieren, oder schalte die begrenzte — optional dauerhafte — Queue ein, die sie aufbewahrt und erneut zustellt; siehe Dead Letters.

Extensions sind das Plugin-System des Frameworks. Cluster, Persistenz, DistributedData, DistributedPubSub, HTTP — sie sind alle Extensions. Du registrierst sie einmal auf System-Ebene und erreichst sie dann über system.extension(...):

import { Cluster, ClusterOptions } from 'actor-ts/cluster';
import { DistributedDataId } from 'actor-ts/crdt';
const cluster = await Cluster.join(system, ClusterOptions.create() /* ... */);
const dd = system.extension(DistributedDataId).start(cluster);

Extensions sind lazy: sie initialisieren sich nicht, bis du nach ihnen greifst. Eine App, die nie system.extension(DistributedDataId) aufruft, startet nie einen DD-Replicator. Das hält Single-Process-Apps klein; übernimm Features, indem du nach ihnen greifst, lass sie weg, indem du es nicht tust.

import { extensionId, type Extension, type ExtensionId } from 'actor-ts';
class MetricsCollector implements Extension {
constructor(private readonly system: ActorSystem) {}
incCounter(name: string): void { /* ... */ }
}
const MetricsCollectorId: ExtensionId<MetricsCollector> = extensionId(
'MetricsCollector',
(system) => new MetricsCollector(system),
);
// Lookup ist idempotent — der erste Aufruf erzeugt, folgende Aufrufe
// geben die gecachte Instanz zurück.
const metrics = system.extension(MetricsCollectorId);
metrics.incCounter('login.success');

Extensions sind nützlich, wenn:

  • Du übergreifenden Zustand brauchst, der von vielen Actors geteilt wird (ein Connection-Pool, ein Metrics-Collector).
  • Der Zustand teuer zu initialisieren ist und nicht existieren sollte, wenn niemand danach greift (ein Cluster-Join, ein DD-Replicator).
  • Du eine saubere Möglichkeit willst, Test-Doubles in Unit-Tests zu injizieren (überschreibe den ExtensionId-Resolver).
await system.terminate();

terminate führt einen geordneten Shutdown durch:

  1. Cluster benachrichtigen (falls beigetreten) — “ich verlasse” gossipen, damit Peers nicht mehr zu diesem Node routen.
  2. /user leeren — warten, bis dort kein Actor mehr einen Turn laufen oder eine Nachricht in der Queue hat, sodass ref.tell(x); await system.terminate() das x noch verarbeitet. Das Warten ist transitiv: eine Zelle gilt in dem Moment als beschäftigt, in dem ihr jemand etwas sendet, also halten Antworten, Router-Fan-outs und Restarts den Drain am Laufen, statt jede Mailbox nur einmal durchzuspülen.
  3. /user rekursiv stoppen — deine Actors bekommen postStop, Kinder zuerst. Actors mit laufenden async onReceives beenden ihre aktuelle Nachricht, bevor sie stoppen.
  4. /system stoppen — Framework-Internas wickeln sich ab. Das beginnt erst, wenn /user vollständig geleert ist, sodass ein postStop aus Schritt 3 noch mit den Sharding-, PubSub- oder Delivery-Actors reden kann, von denen es abhängt.
  5. Dispatcher und Scheduler schließen — keine neuen Nachrichten, keine neuen Timer.
  6. Das zurückgegebene Promise resolven.

Der Drain in Schritt 2 ist durch actor-ts.system.shutdown-drain-timeout begrenzt (Standard 2 s) und kehrt sofort zurück, sobald der Baum ruhig ist — ein untätiges System zahlt einen Tick, nicht das Budget. Setze ihn auf 0, um das Leeren ganz zu überspringen; was beim Ablauf des Budgets noch in der Queue steht, landet über Schritt 3 in den Dead Letters — genau das passierte vor dem Drain mit dem gesamten Rückstau.

Auf drei Dinge wird bewusst nicht gewartet:

  • eine durch context.throttle(...) geparkte Mailbox und eine, die suspendiert ist, während ein Supervisor entscheidet — keine von beiden leert sich in einem Tempo, auf das ein Shutdown warten kann, also gelten beide als ruhig;
  • alles unter /system — Heartbeats, Failure Detectors und Broker-Reconnect-Schleifen sind konstruktionsbedingt nie ruhig;
  • Arbeit, die noch in keiner Mailbox liegt — ein noch nicht gefeuerter context.timers-Tick oder ein tell aus einem Promise, das ein Handler ohne await gestartet hat.

Um einen Actor nach dem Leeren seiner Mailbox zu stoppen und darauf zu warten, nimm gracefulStop(ref, timeoutMs) — siehe PoisonPill & Kill.

Laufen, bis der Prozess zum Stoppen aufgefordert wird

Abschnitt betitelt „Laufen, bis der Prozess zum Stoppen aufgefordert wird“

Für einen Service ist terminate() nichts, was du aufrufst — es ist das, was am Ende passiert. Die ganze main nach dem Verdrahten der Actors lautet:

await system.runUntilTerminated();

Das installiert SIGTERM/SIGINT-Handler, löst auf, sobald das System unten ist, und hängt die Handler auf dem Weg hinaus wieder ab. Ein handgeschriebenes process.on('SIGTERM', () => system.terminate()) sieht gleichwertig aus und ist es nicht: es stoppt zuerst die Actors und gibt erst danach — falls du daran gedacht hast, es zu schreiben — den HTTP-Port frei und verlässt den Cluster. runUntilTerminated() führt die Pipeline aus Coordinated Shutdown aus, die Listener abbindet, Broker schließt und den Cluster verlässt, bevor die Actors stoppen — ein Rolling Deploy nimmt den Knoten also aus der Rotation, während seine Actors noch zu Ende bringen können, was sie halten.

Es ist außerdem die Variante, die überall funktioniert: Deno stellt Signale über Deno.addSignalListener zu, nicht über process.on, und ein Listener hält dort die Event-Loop am Leben, bis er entfernt wird.

Die übliche Antwort ist eins. Ein zweites System im selben Prozess bedeutet einen separaten Cluster, einen separaten Dispatcher, einen separaten Supervisionsbaum — typischerweise mehr Overhead, als der Use Case rechtfertigt.

Zwei Situationen, in denen ein zweites System Sinn macht:

  • Worker-Thread-Isolation: der Hauptthread läuft mit einem System, ein Worker-Thread mit einem anderen, beide spannen denselben Cluster über den MessageChannelTransport auf. Das ist das Worker-Mesh-Pattern — mehrere Systeme pro OS-Prozess, alle Teil desselben Clusters.
  • Test-Fixtures: ein TestActorSystem pro Testfall, damit das Cleanup garantiert ist. Siehe TestKit.
  • Actor — die Klasse, die du in das System spawnst.
  • Coordinated Shutdown — Graceful-Shutdown-DSL jenseits eines einfachen terminate.
  • Cluster-Überblick — wenn du von einem System pro Prozess zu vielen Systemen in einem Cluster gehst.
  • Konfiguration — jeder HOCON-Schlüssel, den das Framework liest, gruppiert nach Extension.

Die ActorSystem-Klassen-API-Referenz dokumentiert jede hier diskutierte öffentliche Methode.