Перейти к содержимому
Русский

ParallelMultiNodeSpec

Это содержимое пока не доступно на вашем языке.

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.