Pular para o conteúdo
Português (BR)

ParallelMultiNodeSpec

Este conteúdo não está disponível em sua língua ainda.

ParallelMultiNodeSpec is the worker-thread variant of MultiNodeSpec. Each “node” runs in its own worker_threads Worker (or a Web Worker on Bun/Deno), connected through an in-process broker. Same test shape as MultiNodeSpec, but with real OS threads so concurrency bugs the single-threaded variant papers over can surface.

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);
// Drive the worker-side scenario over an RPC:
const result = await spec.runIn('a', 'compute', { x: 42 });
await spec.stop();

For tests that need true parallelism:

  • Real threads — catches races the in-process variant serialises away on a single event loop (concurrent journal writes, scheduler-thread interleaving).
  • Serialization — every message crosses a worker boundary via structured clone, so non-serializable payloads (functions, class instances) fail here just as they would on a real wire.
  • Thread isolation — each node has its own heap and event loop.

For most cluster tests, MultiNodeSpec is faster + simpler — use ParallelMultiNodeSpec only when single-threaded tests don’t cover the case. Neither variant uses TCP; for real network conditions, use an external Docker-Compose cluster.

type ParallelMultiNodeSpecOptionsType = {
roles: ReadonlyArray<string>; // one worker per role; must be unique
seedRoles?: ReadonlyArray<string>; // bootstrap seeds — defaults to [roles[0]]
scenarioModule?: URL; // module each worker loads (setup + commands)
scenarioInitDataFor?: (role: string) => unknown; // per-role data passed to setup()
addresses?: Record<string, { host: string; port: number }>;
failureDetector?: Partial<FailureDetectorOptionsType>;
gossipIntervalMs?: number;
awaitTimeoutMs?: number; // default 30_000 (worker bootstrap is slower)
logLevel?: LogLevel;
bootstrapModule?: URL; // override the bundled worker bootstrap
};
FieldPurpose
rolesNode list — one worker per role; the string is the worker’s system name. Must be unique.
seedRolesWhich roles act as bootstrap seeds. Defaults to the first role.
scenarioModuleURL of the module each worker imports — it owns the actor-shaped setup and the commands map runIn dispatches to.
scenarioInitDataForPer-role data forwarded to the scenario’s setup(context).
failureDetectorFailure-detector overrides.
awaitTimeoutMsDefault await* timeout. Default 30 s — worker bootstrap is slower than in-process.
bootstrapModuleOverride the bundled worker entry point (advanced).

Each worker joins the cluster automatically — the harness owns the ActorSystem + Cluster bootstrap. Your test-specific actor code lives in a scenario module: a plain module that exports an optional setup(context) hook and a commands map. The worker imports it by URL, runs setup once after the cluster joins, then dispatches runIn(role, command, args) calls to the matching command.

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' });
},
// Commands may return any JSON-serialisable value back to the harness:
ping(): string {
return 'pong';
},
};

The module owns everything actor-shaped (entity classes, sharding regions, …). The harness only ever exchanges JSON-serialisable command/response pairs with it — closures can’t cross the worker boundary, which is why scenario code lives in its own file loaded by URL rather than inline in the test.

The test can’t touch a worker’s in-memory actors directly, so it drives them through the harness:

// Invoke a scenario command on a specific role and await its result:
await spec.runIn('a', 'increment');
const reply = await spec.runIn<string>('a', 'ping'); // → 'pong'
// Inspect each worker's cluster view (JSON snapshots, not live objects):
const members = await spec.getMembers('a');
const leader = await spec.getLeader('a');

runIn(role, command, args?) calls into the scenario module’s commands map and returns whatever that handler returns. Because systemFor / clusterFor can’t hand back objects that live in another thread, membership is exposed as getMembers(role) / getLeader(role) snapshots instead.

Test conceptMultiNodeSpecParallelMultiNodeSpec
Cluster membership semantics✓✓
Sharding distribution✓✓
Singleton failover✓✓
Gossip convergence✓✓
Serialization round-trip✗✓
Per-worker heap isolation✗✓
True parallelism (OS threads)✗✓
Real TCP semantics✗✗
CI speedvery fastslow

For 90 % of tests, MultiNodeSpec. For the 10 % where fidelity matters, ParallelMultiNodeSpec.

Spinning up worker threads:

  • Per-node startup: 200-500 ms (worker spawn + cluster handshake).
  • 3-node spec: ~1.5 s total.
  • Compared to MultiNodeSpec: sub-100 ms total.

For tight test loops (many test cases), the cost adds up. Use ParallelMultiNodeSpec sparingly — one or two key tests for real parallelism / serialization, MultiNodeSpec for the rest.

await spec.stop();
// → terminates every worker thread (awaited, so none leak)
// → unblocks the test process to exit

Always call stop() — orphaned worker threads leak and can starve later tests. The test framework may clean them up if the test crashes, but explicit teardown is safer.

The broker between the workers drops what it cannot route rather than throwing: a frame whose to or from is not a valid node address, and a frame for a worker crash() has already terminated, are both discarded. Until #701 either one threw inside the harness’s own message listener, where nothing catches it, so one bad frame failed the whole test process rather than the scenario that sent it. The trade is the one above in another guise: a scenario that posts a malformed frame gets silence, not a diagnostic.