Zum Inhalt springen
Deutsch

ParallelMultiNodeSpec

ParallelMultiNodeSpec ist die Worker-Thread-Variante von MultiNodeSpec. Jeder „Node” läuft in seinem eigenen worker_threads-Worker (oder einem Web-Worker auf Bun/Deno), verbunden über einen In-Process-Broker. Dieselbe Test-Form wie MultiNodeSpec, aber mit echten OS-Threads, sodass Nebenläufigkeits-Bugs, welche die single-threaded Variante überdeckt, auftauchen können.

import { ParallelMultiNodeSpec } from 'actor-ts/testkit';
const spec = new ParallelMultiNodeSpec({
roles: ['a', 'b', 'c'],
scenarioModule: new URL('./my-scenario.ts', import.meta.url),
});
await spec.start();
await spec.awaitMembers('a', 3);
// Das Worker-seitige Szenario über einen RPC ansteuern:
const result = await spec.runIn('a', 'compute', { x: 42 });
await spec.stop();

Für Tests, die echte Parallelität brauchen:

  • Echte Threads — fängt Races, die die In-Process-Variante auf einer einzelnen Event-Loop wegserialisiert (nebenläufige Journal-Schreibvorgänge, Scheduler-Thread-Interleaving).
  • Serialisierung — jede Nachricht überquert eine Worker-Grenze via Structured Clone, sodass nicht-serialisierbare Payloads (Funktionen, Klasseninstanzen) hier genauso scheitern wie über eine echte Wire.
  • Thread-Isolation — jeder Node hat seinen eigenen Heap und seine eigene Event-Loop.

Für die meisten Cluster-Tests ist MultiNodeSpec schneller + einfacher — nutze ParallelMultiNodeSpec nur, wenn single-threaded Tests den Fall nicht abdecken. Keine der beiden Varianten nutzt TCP; für echte Netzwerkbedingungen nutze einen externen Docker-Compose-Cluster.

type ParallelMultiNodeSpecOptionsType = {
roles: ReadonlyArray<string>; // ein Worker pro Rolle; müssen eindeutig sein
seedRoles?: ReadonlyArray<string>; // Bootstrap-Seeds — Default [roles[0]]
scenarioModule?: URL; // Modul, das jeder Worker lädt (setup + commands)
scenarioInitDataFor?: (role: string) => unknown; // Per-Rolle-Daten für setup()
addresses?: Record<string, { host: string; port: number }>;
failureDetector?: Partial<FailureDetectorOptionsType>;
gossipIntervalMs?: number;
awaitTimeoutMs?: number; // Default 30_000 (Worker-Bootstrap ist langsamer)
logLevel?: LogLevel;
bootstrapModule?: URL; // überschreibt den gebündelten Worker-Bootstrap
};
FeldZweck
rolesNode-Liste — ein Worker pro Rolle; der String ist der System-Name des Workers. Müssen eindeutig sein.
seedRolesWelche Rollen als Bootstrap-Seeds dienen. Default ist die erste Rolle.
scenarioModuleURL des Moduls, das jeder Worker importiert — es besitzt das actor-förmige Setup und die commands-Map, an die runIn dispatcht.
scenarioInitDataForPer-Rolle-Daten, die an das setup(context) des Szenarios weitergereicht werden.
failureDetectorFailure-Detector-Overrides.
awaitTimeoutMsDefault-await*-Timeout. Default 30 s — Worker-Bootstrap ist langsamer als in-process.
bootstrapModuleÜberschreibt den gebündelten Worker-Einstiegspunkt (fortgeschritten).

Jeder Worker joint den Cluster automatisch — die Harness besitzt den ActorSystem- + Cluster-Bootstrap. Dein test-spezifischer Actor-Code lebt in einem Szenario-Modul: einem schlichten Modul, das einen optionalen setup(context)-Hook und eine commands-Map exportiert. Der Worker importiert es per URL, führt setup einmal nach dem Cluster-Join aus und dispatcht dann runIn(role, command, args)-Aufrufe an das passende Kommando.

my-scenario.ts
import type { ScenarioModule } from 'actor-ts/testkit';
import { type ActorRef } from 'actor-ts';
export const setup: ScenarioModule['setup'] = (context) => {
// context = { role, system, cluster, initData, state }
context.state.counter = context.system.spawnAnonymous(Counter);
};
export const commands: ScenarioModule['commands'] = {
increment(args, context): void {
(context.state.counter as ActorRef<Command>).tell({ kind: 'increment' });
},
// Kommandos dürfen jeden JSON-serialisierbaren Wert an die Harness zurückgeben:
ping(): string {
return 'pong';
},
};

Das Modul besitzt alles Actor-Förmige (Entity-Klassen, Sharding-Regionen, …). Die Harness tauscht mit ihm nur JSON-serialisierbare Kommando/Antwort-Paare aus — Closures können die Worker-Grenze nicht überqueren, weshalb Szenario-Code in einer eigenen, per URL geladenen Datei lebt statt inline im Test.

Der Test kann die In-Memory-Actor eines Workers nicht direkt anfassen, also steuert er sie über die Harness:

// Ein Szenario-Kommando auf einer bestimmten Rolle aufrufen und das Ergebnis abwarten:
await spec.runIn('a', 'increment');
const reply = await spec.runIn<string>('a', 'ping'); // → 'pong'
// Die Cluster-Sicht jedes Workers inspizieren (JSON-Snapshots, keine Live-Objekte):
const members = await spec.getMembers('a');
const leader = await spec.getLeader('a');

runIn(role, command, args?) ruft in die commands-Map des Szenario-Moduls und gibt zurück, was dieser Handler zurückgibt. Weil systemFor / clusterFor keine Objekte zurückreichen können, die in einem anderen Thread leben, wird Membership stattdessen als getMembers(role)- / getLeader(role)-Snapshots bereitgestellt.

Test-KonzeptMultiNodeSpecParallelMultiNodeSpec
Cluster-Membership-Semantik
Sharding-Verteilung
Singleton-Failover
Gossip-Konvergenz
Serialisierungs-Roundtrip
Per-Worker-Heap-Isolation
Echte Parallelität (OS-Threads)
Echte TCP-Semantik
CI-Geschwindigkeitsehr schnelllangsam

Für 90 % der Tests, MultiNodeSpec. Für die 10 %, wo Fidelity zählt, ParallelMultiNodeSpec.

Worker-Threads hochfahren:

  • Per-Node-Startup: 200-500 ms (Worker-Spawn + Cluster- Handshake).
  • 3-Node-Spec: ~1,5 s gesamt.
  • Im Vergleich zu MultiNodeSpec: sub-100 ms gesamt.

Für enge Test-Loops (viele Test-Fälle) summieren sich die Kosten. Nutze ParallelMultiNodeSpec sparsam — ein oder zwei Schlüssel-Tests für echte Parallelität / Serialisierung, MultiNodeSpec für den Rest.

await spec.stop();
// → terminiert jeden Worker-Thread (awaited, sodass keiner leakt)
// → entsperrt den Test-Prozess zum Beenden

Rufe immer stop() — verwaiste Worker-Threads leaken und können spätere Tests aushungern. Das Test-Framework räumt sie womöglich auf, wenn der Test crasht, aber explizites Teardown ist sicherer.