Zum Inhalt springen
Deutsch

Ask-Pattern

tell ist Fire-and-Forget — es kehrt sofort zurück und du siehst die Antwort des Actors nie. Die meiste Actor-zu-Actor-Kommunikation ist damit gut bedient. Aber manchmal brauchst du eine Antwort: ein HTTP-Handler, der einen Actor nach Zustand fragt und ihn als Response serialisiert; eine Saga, die das Ergebnis von Schritt N braucht, bevor sie Schritt N+1 anstößt; ein Test, der einen Rückgabewert assertieren will.

ask ist der Helfer für diese Fälle. Es sendet eine Nachricht an einen Actor, hängt unter der Haube eine temporäre “Reply-to”-Ref an und gibt ein Promise zurück, das mit der ersten Antwort resolved oder bei Timeout rejected.

import { match } from 'ts-pattern';
import { Actor, ActorSystem, type ActorRef } from 'actor-ts';
type IncrementCommand = { readonly kind: 'increment' };
type GetCommand = { readonly kind: 'get'; readonly replyTo: ActorRef<number> };
type Command = IncrementCommand | GetCommand;
class Counter extends Actor<Command> {
private count = 0;
override onReceive(command: Command): void {
match(command)
.with({ kind: 'increment' }, () => this.onIncrement())
.with({ kind: 'get' }, (c) => this.onGet(c))
.exhaustive();
}
private onIncrement(): void { this.count++; }
private onGet(command: GetCommand): void { command.replyTo.tell(this.count); }
}
const system = ActorSystem.create('demo');
const counter = system.spawnAnonymous(Counter);
counter.tell({ kind: 'increment' });
counter.tell({ kind: 'increment' });
// Methode-auf-Ref — knapp, leitet den Reply-Typ ab:
const current = await counter.ask<number>({ kind: 'get' }, 5_000);
console.log(current); // 2
await system.terminate();

ask ist eine Methode auf jeder ActorRef:

// Methode auf jeder ActorRef — leitet Reply aus dem Type-Argument ab:
ref.ask<Reply>(message, timeoutMs?): Promise<Reply>

Der Parameter-Typ OmitReplyTo<TMessage> zieht replyTo aus jeder Variante der Message-Union ab, die es deklariert — Aufrufer schreiben das Feld selbst nie hin. Das Framework allokiert eine One-Shot-Reply-Ref, hängt sie sowohl als context.sender als auch (wenn die Message-Shape es erwartet) als message.replyTo an und löst das Promise mit der ersten Antwort auf.

ask funktioniert, indem es deine Nachricht mit einer angehängten, temporären Reply-to-Ref sendet. Der Empfänger antwortet, indem er dieser Ref tellt.

Per Konvention verwendet das Framework replyTo als Feldnamen in der Request-Nachricht, und der Empfänger pattern-matcht darauf wie auf jedes andere Feld. Schau dir den Command-Typ oben an: die 'get'-Variante hat replyTo: ActorRef<number>.

Für den Empfänger ist es einfach ein normales tell auf einer normalen Ref:

private onGet(command: GetCommand): void {
command.replyTo.tell(this.count);
}

Innerhalb des Empfängers kannst du dieselbe Ref auch über this.sender erreichen — es ist ein Option<ActorRef>, und ask hängt die temporäre Ref auch über diesen Kanal an:

private onGet(command: GetCommand): void {
this.sender.forEach((replyTo) => replyTo.tell(this.count));
}

Beides funktioniert. Ein explizites replyTo-Feld zu verwenden, macht den Typvertrag offensichtlicher (die Request-Form deklariert, dass eine Antwort vom Typ ActorRef<number> erwartet wird); this.sender zu verwenden ist impliziter, funktioniert aber für Actors, die sowohl ask-artige als auch Fire-and-Forget-Nachrichten behandeln.

import { AskTimeoutError } from 'actor-ts';
try {
const result = await counter.ask({ kind: 'get' }, 1_000);
console.log(result);
} catch (e) {
if (e instanceof AskTimeoutError) {
console.log('counter did not reply in time');
} else {
throw e;
}
}

Das Timeout ist im Geiste verpflichtend — der Default von 5 Sekunden fängt den Fall, in dem du vergessen hast, darüber nachzudenken. Tune pro Aufrufstelle:

  • Günstige In-Process-Antworten: 100-500 ms.
  • Cross-Cluster-Lookups: 1-5 s, je nach Netzwerkerwartungen.
  • Alles, was potenziell ein langsames Downstream trifft (DB-Query, HTTP-Call innerhalb des Actors): das Timeout des unterliegenden Calls + ein kleiner Puffer.

Wenn der Timeout feuert, rejected der AskTimeoutError das Promise. Die temporäre Reply-to-Ref ist ein One-Shot: sobald sie einmal abgeschlossen ist — durch den Timeout oder die erste Antwort — wird jede spätere Antwort still an Ort und Stelle verworfen (sie geht nicht in die Dead Letters). Der Actor selbst ist nicht betroffen — er hat keine Ahnung, dass der Asker aufgegeben hat.

timeoutMs muss eine positive endliche Zahl sein. 0, ein negativer Wert, NaN und Infinity werfen alle einen OptionsError an der Aufrufstelle, und zwar synchron — bevor die Nachricht verschickt wird, und als geworfener Fehler statt als rejectetes Promise, denn ein Argument außerhalb seines Wertebereichs ist ein Fehler im Aufrufer und kein fehlgeschlagener Request.

import { OptionsError } from 'actor-ts';
const budget = deadline - Date.now(); // negativ, sobald die Deadline durch ist
try {
await counter.ask({ kind: 'get' }, budget);
} catch (e) {
if (e instanceof OptionsError) {
// budget war <= 0 — die Deadline ist bereits weg, also gar nicht erst fragen
}
}

Das ist strenger, als es auf den ersten Blick wirkt, und das mit Absicht. Der Timeout ist das, was den einzigen Selbstabschluss-Pfad der Reply-Ref scharf stellt: eine Ref ohne Timer kann nur noch durch eine Antwort abgeschlossen werden und sonst durch nichts — ein unbeantworteter Ask würde das await also für die Lebensdauer des Prozesses hängen lassen und im Cluster zusätzlich einen Eintrag in der Per-Path-Handler-Map hinterlassen, gegen die jedes eingehende Envelope geprüft wird. Keiner der beiden Werte braucht einen Tippfehler, um dort anzukommen: ein berechnetes Budget wird negativ, sobald seine Deadline verstrichen ist, und ein untypisierter Konfigurationswert kommt so an, wie er geparst wurde.

Für den 5-Sekunden-Default lässt du das Argument weg (oder übergibst undefined) — der Parameter-Default greift weiterhin:

const value = await counter.ask<number>({ kind: 'get' });

Ein Empfänger kann einen Ask ablehnen, indem er mit einer Error-Instanz antwortet. Das ask-Promise wird dann mit diesem Fehler rejected, statt mit der Nachricht resolved.

class Service extends Actor<Command> {
override onReceive(command: Command): void {
match(command)
.with({ kind: 'fetch' }, (c) => this.onFetch(c))
.exhaustive();
}
private onFetch(command: FetchCommand): void {
if (this.unavailable) {
command.replyTo.tell(new Error('service unavailable'));
return;
}
command.replyTo.tell(this.data);
}
}
try {
const data = await service.ask({ kind: 'fetch' });
} catch (e) {
// e ist der "service unavailable"-Error des Actors.
}

Das ist eine bewusste Konvention — das Framework erkennt Error-typisierte Antworten und rejected das Promise. Trage Domain-Fehler den normalen Weg (typisiert über die Reply-Union) für alles andere; reserviere den Error-Reply-Pfad für echte Fehlerzustände.

ask ist bequem, hat aber Overhead — es allokiert eine temporäre Ref, registriert sie beim System und räumt sie auf, wenn sie resolved/timeout. Drei Situationen, in denen einfaches tell der richtige Aufruf ist:

  • Nachrichten — die replyTo: ActorRef<T>-Feld-Konvention im Detail.
  • Actor — this.sender und wie das Framework es verdrahtet.
  • ActorSystem — wo temporäre Ask-Refs registriert und wieder abgebaut werden.
  • CircuitBreaker — wenn der Downstream-Call des Asks flakey ist und du lieber schnell fehlschlagen als timen willst.
  • Future-Patterns — pipeTo, after, sequence — async-Ergebnisse zurück in die Actor-Welt komponieren, ohne mit await zu blockieren.

Die ActorRef-Klassen-API-Referenz dokumentiert die volle Signatur der ask-Methode und die Error-Typen.