Zum Inhalt springen
Deutsch

Receptionist

Der Receptionist ist ein cluster-weites Service-Registry. Jeder Node hostet einen Receptionist-Actor unter einem wohldefinierten Pfad; Registrierungen sind lokal autoritativ (du vertraust den Registrierungen deines eigenen Nodes); Peers erfahren über Gossip von fremden Registrierungen.

import {
Actor,
ReceptionistId,
ServiceKey,
Register,
Find,
ReceptionistSubscribe,
Listing,
} from 'actor-ts';
const receptionist = system.extension(ReceptionistId).start(cluster);
// 1. Einen getypten Key für diesen Service definieren
const apiKey = ServiceKey.of<ApiMessage>('api-service');
// 2. Einen Actor unter dem Key registrieren
const api = system.spawn(ApiActor, 'api');
receptionist.tell(new Register(apiKey, api));
// 3. Ein Consumer-Actor empfängt das Listing, mit dem Find / Subscribe antworten
class Consumer extends Actor<Listing<ApiMessage>> {
override onReceive(listing: Listing<ApiMessage>): void {
console.log(`${listing.refs.length} API-Actors im Cluster`);
}
}
const consumer = system.spawn(Consumer, 'consumer');
// 4. Von jedem Node — jeden registrierten Actor finden oder auf Änderungen subscriben
receptionist.tell(new Find(apiKey, consumer));
receptionist.tell(new ReceptionistSubscribe(apiKey, consumer));

Der Receptionist ist pro Cluster — jeder Node sieht dasselbe Listing (mit Gossip-Lag, siehe unten).

const key = ServiceKey.of<ApiMessage>('api-service');
// ^^^^^^^
// getypter Payload — Consumer wissen, was sie senden müssen

Ein ServiceKey<T> trägt:

  • Einen String-Identifier — der menschenlesbare Key-Name.
  • Einen Typ-Parameter — der Nachrichtentyp, den der registrierte Actor akzeptiert.

Typsicheres Lookup: das Listing<ApiMessage>, das als Antwort auf ein Find gesendet wird, trägt refs getypt als ActorRef<ApiMessage>[].

Keys sind Werte — einmal definieren, überall importieren. Konvention: in einem geteilten Keys.ts-Modul pro App.

receptionist.tell(new Register(key, actorRef));

Sagt dem lokalen Receptionist: “Dieser Actor auf diesem Node liefert den Service.” Der Receptionist:

  1. Fügt die Ref seiner lokalen Map unter key hinzu.
  2. Gossipt das Hinzufügen an Peers (nächste Gossip-Runde).

Übergib ein optionales replyTo als drittes Argument (new Register(key, actorRef, replyTo)), um eine Registered-Nachricht zu erhalten, sobald die Registrierung aufgezeichnet ist.

receptionist.tell(new Deregister(key, actorRef));

Freiwilliges Entfernen. Nützlich, wenn ein Actor einen Service “verlässt”, ohne zu stoppen (vorübergehender Statuswechsel).

Der Receptionist watcht registrierte Refs nicht — wenn ein Actor stoppt, bleibt seine Registrierung bestehen, bis du Deregister sendest (oder, in einem Cluster, bis sein ganzer Node den Cluster verlässt — siehe unten). Ein Find kann also eine Ref auf einen gestoppten Actor zurückgeben; sichere dich auf der Consumer-Seite dagegen ab. (Subscriber werden sehr wohl gewatcht, siehe Subscriptions sind begrenzt und gewatcht.)

Find ist ein einmaliges Lookup — das Ergebnis kommt als einzelne Listing-Nachricht zurück, zugestellt an den replyTo-Actor, den du benennst:

class ApiConsumer extends Actor<Listing<ApiMessage>> {
override onReceive(listing: Listing<ApiMessage>): void {
// listing.refs: jeder Actor, der unter dem Key registriert ist, cluster-weit
console.log(`${listing.refs.length} Actors unter ${listing.key.id}`);
}
}
const consumer = system.spawn(ApiConsumer, 'consumer');
receptionist.tell(new Find(key, consumer));

Bei der Behandlung eines Find tut der Receptionist:

  1. Liest lokale Registrierungen unter key.
  2. Fügt bekannte Remote-Registrierungen aus Gossip hinzu.
  3. Antwortet replyTo mit einem einzelnen Listing der kombinierten Liste.

Die Antwort trägt die aktuelle Sicht — keine synchrone Cluster-Abfrage. Bedeutet: Registrierungen auf anderen Nodes, die noch nicht gegossipt wurden, fehlen.

Innerhalb einer Gossip-Runde oder zwei (1-2 Sekunden Default) konvergiert jeder Node auf dieselbe Sicht.

Subscribe ist kontinuierlich — replyTo erhält jetzt ein Listing und danach bei jeder Änderung erneut. Es wird aus dem Package-Root als ReceptionistSubscribe exportiert (und Unsubscribe als ReceptionistUnsubscribe):

receptionist.tell(new ReceptionistSubscribe(key, consumer));
// Später — keine Updates mehr erhalten:
receptionist.tell(new ReceptionistUnsubscribe(key, consumer));

Ein frisches Listing wird an den Subscriber gesendet, wann immer sich die Menge der Refs für key ändert — lokal (register/deregister) oder via eingehendem Gossip und Node-Abgängen.

Nutze es für dynamisches Routing: ein Actor, der auf einen Key subscribet und seine Routing-Entscheidungen anpasst, wenn Refs auftauchen / verschwinden.

Zwei Mechanismen halten die Subscriber-Menge davon ab, unbegrenzt zu wachsen.

Death Watch. Der Receptionist watcht jeden Subscriber. Stoppt einer, ohne Unsubscribe zu senden — Crash, vergessenes Cleanup, ein pro Request gespawnter Actor —, wird sein Platz frei, sobald das Terminated ankommt. Du musst in postStop nicht mehr unsubscriben, es bleibt aber der schnellere Weg.

Obergrenzen. Zwei Limits begrenzen den Rest, die lebenden Subscriber:

OptionHOCON-LeafDefault
maxSubscribersPerKeycluster.receptionist.max-subscribers-per-key1000
maxSubscribersTotalcluster.receptionist.max-subscribers-total10000
const receptionistOptions = ReceptionistOptions.create()
.withMaxSubscribersPerKey(200)
.withMaxSubscribersTotal(2_000);
const receptionist = system.extension(ReceptionistId).start(cluster, receptionistOptions);

Ein Subscribe über einer der beiden Grenzen wird abgelehnt, nicht verworfen: replyTo erhält statt des ersten Listing ein ReceptionistSubscribeRejected, und es folgen keine weiteren Listings.

import { ReceptionistSubscribeRejected } from 'actor-ts';
class Consumer extends Actor<Listing<ApiMessage> | ReceptionistSubscribeRejected<ApiMessage>> {
override onReceive(message: Listing<ApiMessage> | ReceptionistSubscribeRejected<ApiMessage>): void {
if (message instanceof ReceptionistSubscribeRejected) {
// message.reason: 'maxSubscribersPerKey' | 'maxSubscribersTotal'
// message.limit: der Wert, auf den diese Grenze gesetzt ist
this.log.warn(`discovery refused: ${message.reason} (${message.limit})`);
return;
}
// …message.refs verwenden
}
}

Die Antwort ist wichtiger, als sie aussieht: ein stillschweigend verworfenes Subscribe ist nicht davon zu unterscheiden, dass der Key schlicht noch keine Registrierungen hat — und zwischen beidem liegt ein langer Nachmittag.

Die Klasse wird aus dem Package-Root als ReceptionistSubscribeRejected exportiert — das unqualifizierte SubscribeRejected gehört DistributedPubSub, genau wie Subscribe zu ReceptionistSubscribe aliasiert ist.

// In einem Cluster:
const receptionist = system.extension(ReceptionistId).start(cluster);
// Ohne Cluster (Single Node):
const receptionist = system.extension(ReceptionistId).start(null);

Ohne Cluster funktioniert der Receptionist nur lokal — nützlich für Tests oder Single-Node-Apps, die trotzdem die ServiceKey-basierte Lookup-API wollen.

Für Cluster-Setups immer den Cluster übergeben — sonst sind Remote-Registrierungen unsichtbar.

Wenn MemberRemoved für einen Peer feuert:

  • Vergisst der Receptionist jede Registrierung, die dieser Node beigesteuert hat.
  • Subscriber feuern mit dem aktualisierten (kleineren) Listing.

Das deckt den Fall “Node ist abgestürzt, hatte keine Chance zu deregistrieren” ab — Gossip + Cluster-Membership erledigen die Bereinigung.

receptionist.tell(new Register(apiKey, instance1));
receptionist.tell(new Register(apiKey, instance2));
receptionist.tell(new Register(apiKey, instance3)); // alle drei unter demselben Key
receptionist.tell(new Find(apiKey, consumer)); // Listing.refs → [instance1, instance2, instance3]

Ein häufiges Muster: N Worker registrieren sich alle unter demselben Key. Consumer sehen den ganzen Pool und können routen, wie sie wollen — Round-Robin, Broadcast, Zufallsauswahl.

// Ein Singleton für den Cluster registriert
receptionist.tell(new Register(coordinatorKey, theCoordinator));
receptionist.tell(new Find(coordinatorKey, consumer)); // Listing.refs → [theCoordinator]

Für Singleton-artige Services hat der Key eine Ref. Consumer nehmen refs[0] (mit Fallback für den leeren Fall).

Der Receptionist erzwingt keine einzelne Instanz — das ist die Aufgabe des Singleton Managers. Aber einen Singleton unter einem Key zu registrieren gibt Consumern einen Discovery-Pfad, der Leadership-Wechsel überlebt.

BedarfWerkzeug
Feste Routees pro Node, jeder Node hat sieClusterRouter mit einem wohldefinierten Pfad
Dynamische Registrierungen, Lookup per Service-NameReceptionist
Genau ein Actor cluster-weitClusterSingleton (bei Bedarf zum Lookup im Receptionist registriert)
Per-Key-Actors mit Auto-SpawnClusterSharding

Der Receptionist ist die flexibelste Discovery — du zahlst aber Gossip-Kosten für die Dynamik. Für statisches Routing ist der Cluster Router günstiger.

Die Receptionist-API-Referenz deckt die vollständige Oberfläche ab.