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();Wann nutzen
Abschnitt betitelt „Wann nutzen“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.
Konfiguration
Abschnitt betitelt „Konfiguration“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};| Feld | Zweck |
|---|---|
roles | Node-Liste — ein Worker pro Rolle; der String ist der System-Name des Workers. Müssen eindeutig sein. |
seedRoles | Welche Rollen als Bootstrap-Seeds dienen. Default ist die erste Rolle. |
scenarioModule | URL des Moduls, das jeder Worker importiert — es besitzt das actor-förmige Setup und die commands-Map, an die runIn dispatcht. |
scenarioInitDataFor | Per-Rolle-Daten, die an das setup(context) des Szenarios weitergereicht werden. |
failureDetector | Failure-Detector-Overrides. |
awaitTimeoutMs | Default-await*-Timeout. Default 30 s — Worker-Bootstrap ist langsamer als in-process. |
bootstrapModule | Überschreibt den gebündelten Worker-Einstiegspunkt (fortgeschritten). |
Das Szenario-Modul
Abschnitt betitelt „Das Szenario-Modul“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.
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.
Die Worker ansteuern
Abschnitt betitelt „Die Worker ansteuern“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.
Wann was nutzen
Abschnitt betitelt „Wann was nutzen“| Test-Konzept | MultiNodeSpec | ParallelMultiNodeSpec |
|---|---|---|
| Cluster-Membership-Semantik | ✓ | ✓ |
| Sharding-Verteilung | ✓ | ✓ |
| Singleton-Failover | ✓ | ✓ |
| Gossip-Konvergenz | ✓ | ✓ |
| Serialisierungs-Roundtrip | ✗ | ✓ |
| Per-Worker-Heap-Isolation | ✗ | ✓ |
| Echte Parallelität (OS-Threads) | ✗ | ✓ |
| Echte TCP-Semantik | ✗ | ✗ |
| CI-Geschwindigkeit | sehr schnell | langsam |
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.
Cleanup
Abschnitt betitelt „Cleanup“await spec.stop();// → terminiert jeden Worker-Thread (awaited, sodass keiner leakt)// → entsperrt den Test-Prozess zum BeendenRufe 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.
Wo es weitergeht
Abschnitt betitelt „Wo es weitergeht“- Testing — Überblick — das größere Bild.
- MultiNodeSpec — die schnellere In-Process-Variante.
- TestKit — für Single-System- Tests.
