Zum Inhalt springen
Deutsch

Singleton — Überblick

Ein Cluster-Singleton ist ein Actor, der genau einmal im gesamten Cluster existiert. Er läuft auf dem Leader-Node; wenn dieser Node geht, spawnt der nächst-gewählte Leader ihn neu. Aufrufer auf jedem Node schicken Nachrichten über einen Proxy, der dorthin routet, wo der Singleton aktuell lebt.

Cluster aus 3 Nodes

node-1

(Leader)

node-2

node-3

Singleton (aktiv)

nur Manager

(Standby)

nur Manager

(Standby)

Drei Actors pro Node lassen das funktionieren:

  • ClusterSingletonManager — auf jedem Node. Beobachtet Cluster-Events; spawnt den Singleton, wenn dieser Node Leader wird, und stoppt ihn, wenn er aufhört, Leader zu sein.
  • ClusterSingletonProxy — auf jedem Node, der mit dem Singleton spricht. Genau das geben start und ref zurück: einen weiterleitenden ActorRef, der immer auf den Manager des aktuellen Leaders zeigt.
  • Der Singleton-Actor selbst — der Actor des Nutzers, immer nur auf dem Leader instanziiert.

Ein Node, der nur einen Proxy betreibt, kann das Singleton adressieren, es aber nie hosten — siehe Einen Ref bekommen, ohne zu hosten.

Die klassischen Anwendungsfälle:

  • Ein Koordinator — ein Job-Scheduler, ein Saga-Orchestrator, ein Rate-Limit-Budget-Tracker — der eine konsistente Sicht für den ganzen Cluster produzieren muss.
  • Ein Owner einer externen Ressource — der Actor, der eine Verbindung zu einem einzelnen externen System hält (ein Lizenzserver, eine Legacy-DB mit Einzelverbindungslizenz).
  • Ein Leader-gewählter Service — deine eigene gewählte Rolle für eine clusterweite Verantwortung.

Wenn du if (!alreadyExists) spawn(...) schreiben würdest, ist ein Singleton vermutlich das Richtige.

import { Actor, ActorSystem, Cluster, ClusterOptions, SingletonKey } from 'actor-ts';
class JobScheduler extends Actor<JobCommand> {
static readonly singleton = SingletonKey.of<JobCommand>('job-scheduler');
override onReceive(message: JobCommand): void { /* ... */ }
}
const system = ActorSystem.create('my-app');
const clusterOptions = ClusterOptions.create()
.withHost(host)
.withPort(port)
.withSeeds(seeds);
const cluster = await Cluster.join(system, clusterOptions);
// Auf jedem Node: ein Aufruf spawnt den Manager dieses Nodes und gibt den
// Ref zurück. Nur der Manager des Leaders konstruiert tatsächlich den
// JobScheduler.
const scheduler = cluster.singleton.start(JobScheduler);
// Irgendwo in der App — derselbe Aufruf auf jedem Node:
scheduler.tell({ kind: 'schedule', jobId: '42' });

start gibt einen normalen ActorRef<JobCommand> zurück — ein Singleton wird also herumgereicht und gespeichert wie jeder andere Actor, und keine Consumer-Signatur muss wissen, dass es eines ist. Hinter den Kulissen findet der Ref den Manager des aktuellen Leaders über den bekannten Pfad /system/cluster/singleton/manager-<typeName> und leitet Nachrichten dorthin. Bei Leader-Wechseln verschiebt sich das Ziel automatisch innerhalb einer Gossip-Runde.

SingletonKey.of<Command>('type-name') verbindet den Namen eines Singletons mit seinem Message-Typ. Deklariert als static readonly singleton auf dem Actor wird beides am Aufrufort nicht wiederholt, und start / ref leiten den richtigen ActorRef<Command> allein aus der Klasse ab.

Ein Actor mit Constructor-Dependencies bekommt eine Factory als zweites Argument:

class UserRepository extends PersistentActor<UserRepositoryCommand, Event, State> {
static readonly singleton = SingletonKey.of<UserRepositoryCommand>('user-repository');
constructor(private readonly users: ActorRef<UserCommand>) { super(); }
}
const users = cluster.sharding.start(UserActor);
const repository = cluster.singleton.start(UserRepository, () => new UserRepository(users));

Eine Rolle gehört auf den Key, als zweites Argument:

class Ingress extends Actor<IngressCommand> {
static readonly singleton = SingletonKey.of<IngressCommand>('ingress', 'edge');
}

Sie ist nicht Teil der Identität — zwei Keys sind genau dann gleich, wenn ihre Namen übereinstimmen — sie fährt nur mit, damit jede Node dieselbe liest. Das zählt für eine Node, die nur ref aufruft: sie hat kein Options-Objekt, eine allein über withRole gesetzte Rolle ist für sie also unsichtbar, und ihr Proxy würde einen anderen Host auflösen als die Manager.

Für lease, oder um die Rolle pro Deployment zu überschreiben, werden Options danebengelegt — oder die volle Builder-Form genutzt, wenn es keine Klasse gibt, an die ein Static passt:

const singletonOptions = StartSingletonOptions.create<JobCommand>()
.withRole('control-plane') // schlägt eine auf dem Key deklarierte Rolle
.withBufferSize(5_000); // Nachrichten, während keine Node hostet
cluster.singleton.start(JobScheduler, singletonOptions);

bufferSize begrenzt, was der Proxy hält, solange der Cluster keinen Host hat — normalerweise eine Gossip-Runde, in einem Ausfall aber unbegrenzt. Jenseits des Limits (Default 1000) gehen Nachrichten mit einer Warnung in die Dead Letters, statt den Puffer ewig wachsen zu lassen.

start nimmt den Node in die Rotation auf: er kann Host werden. Ein Node, der nur mit dem Singleton sprechen muss, ruft stattdessen ref — das Gegenstück zu ClusterSharding.startProxy:

// Kein Manager auf diesem Node — Nachrichten gehen an den, der hostet.
const scheduler = cluster.singleton.ref(JobScheduler);
scheduler.tell({ kind: 'schedule', jobId: '42' });

ref und start geben für denselben Key denselben memoisierten Ref zurück, und der lokale Manager wird pro Zustellung aufgelöst — ein Node, der erst ref und später start ruft, behält also denselben Ref, der dann einfach lokal statt über die Wire zustellt.

Zwei Dinge tut der zurückgegebene Ref außerdem nicht:

  • ref.stop() ist ein warnender No-Op. Überall sonst schickt ActorRef.stop() eine PoisonPill an sein Ziel; hier würde das töten, was der aktuelle Leader gerade hostet. Um diesen Node aus der Rotation zu nehmen, rufe cluster.singleton.stop(key).
  • Stoppen ist asynchron. Der Manager gibt Lease und Envelope-Pfad in postStop frei, und der Actor-Name bleibt belegt, bis die Terminierung abgeschlossen ist — dasselbe Singleton im selben Turn erneut zu starten wirft daher mit einer Erklärung statt mit einem Duplicate-Name-Fehler.

cluster.singleton bietet zusätzlich isStarted(key) und managerFor(key) für Diagnose.

Wenn der Host-Node den Cluster verlässt:

  1. Erkennung: Cluster-Gossip propagiert MemberLeft / MemberRemoved für den gehenden Node.
  2. Wahl: der Cluster wählt einen neuen Leader (deterministisch basierend auf der Mitglieder-Sortierreihenfolge).
  3. Spawn: der Manager des neuen Leaders spawnt den Singleton.
  4. Routing-Verschiebung: Proxies auf jedem Node sehen den Leader-Wechsel und aktualisieren ihr Forwarding-Ziel.

In-Flight-Nachrichten während des Übergangs landen in Dead Letters, es sei denn, du hast Durability konfiguriert — siehe “State über Failover” weiter unten.

Das Übergangsfenster wird vom Timeout des Failure Detectors begrenzt (typisch ein paar Sekunden für Unreachable-Erkennung). Singletons sind kein Low-Latency-Failover-Werkzeug; sie tauschen etwas Unverfügbarkeit beim Failover gegen die starke Invariante “genau eine Instanz”.

Die neue Instanz startet mit weißer Weste — so wie ein neu gestarteter Actor auf einem einzelnen Node. Für State, der überleben soll:

  • PersistentActor — der Singleton persistiert Events; die neue Instanz spielt sie aus dem Journal ab. Die meisten Produktions-Singletons nutzen das. Siehe PersistentActor.
  • DurableState — einfacher: snapshotte den aktuellen State; restore beim Neustart. Siehe DurableState.
  • DistributedData — für State, der vor dem Neustart des Singletons lesbar sein soll. Die meisten Singletons brauchen das nicht; ihr State ist privat zum Singleton.

Ohne eine dieser Lösungen ist jedes Failover ein Frischstart. Für einen kurzlebigen Koordinator, der nur eingehende Arbeit routet, ist das oft in Ordnung; für stateful Workflows persistiere.

Wenn der Cluster partitioniert wird, könnten zwei Hälften jeweils einen eigenen Leader wählen — und beide würden den Singleton spawnen. Genau der Fall, gegen den Singletons existieren.

Drei Verteidigungen, in der Reihenfolge der Komplexität:

  1. Eine Downing-Strategie, die während einer Partition eine Gewinnerseite wählt (Standardoption, keine Lease nötig). Die Verliererseite fährt sich selbst herunter; nur eine Hälfte bleibt aktiv. Siehe Downing-Strategien.
  2. Eine Lease, die mit den Start-Options übergeben wird:
    const singletonOptions = StartSingletonOptions.create<JobCommand>()
    .withLease(someLeaseImpl); // z. B. K8s-Lease oder In-Memory für Tests
    cluster.singleton.start(JobScheduler, singletonOptions);
    Der Manager muss die Lease erfolgreich erwerben, bevor er den Singleton spawnen darf. Nur eine Seite einer Partition kann die Lease halten, sodass selbst bei zwei Leadern nur ein Singleton existiert. Siehe Singleton mit Lease.

Die Kombination aus “Downing-Strategie + Lease” ist paranoid-sicher; jeder allein reicht meist.

Ein Singleton hat Overhead jenseits eines normalen Actors:

  • Jeder Node fährt einen Manager — sie sind leichtgewichtig (eine Zustandsmaschine, die Cluster-Events beobachtet), aber sie existieren auf jedem Node.
  • Jeder Node, der den Singleton aufruft, fährt einen Proxy — ebenfalls leichtgewichtig, aber pro tell ein zusätzlicher Hop.
  • Leader-Wechsel ist die Kosten des Failovers — ein Singleton ist die paar Sekunden lang unverfügbar, die der Cluster für die Konvergenz auf einen neuen Leader braucht.

Wenn Exaktheit nicht erforderlich ist (dir würden N Replicas genügen), nutze einen Cluster-Router oder Sharding — beide skalieren horizontal ohne Leader-Flaschenhals.

Die ClusterSingletonManager- und ClusterSingletonProxy- API-Referenzen decken die vollständige Konfigurationsoberfläche ab.