Aller au contenu
Français

MultiNodeSpec

Ce contenu n’est pas encore disponible dans votre langue.

MultiNodeSpec runs N cluster nodes inside one test process. Each is a real ActorSystem; they communicate via an in-process transport (no TCP). Useful for testing cluster behavior — sharding, singletons, gossip convergence, failover — without Docker.

import { MultiNodeSpec, TestProbe } from 'actor-ts/testkit';
// Each role name is one node — and doubles as that node's system name.
const spec = new MultiNodeSpec({ roles: ['a', 'b', 'c'] });
await spec.start();
await spec.awaitMembers('a', 3); // wait for the cluster to converge
// Reach any node's ActorSystem with systemFor(role):
const node2 = spec.systemFor('b');
// Spawn actors on a specific node and assert with a probe:
const probe = new TestProbe(spec.systemFor('a'));
const remote = node2.spawnAnonymous(Worker);
remote.tell({ kind: 'do', replyTo: probe });
await probe.expectMessage({ kind: 'done' });
await spec.stop();

Three primary cases:

  1. Testing cluster behavior — sharding rebalances, singleton failover, gossip convergence — verifying these without real network setup.
  2. Reproducing distributed bugs — easier to isolate when the entire cluster runs in one process.
  3. CI-friendly cluster tests — fast (sub-second), no Docker, no ports.

For tests that need true parallelism (real OS threads — concurrent journal writes, scheduler interleaving), use ParallelMultiNodeSpec. For real TCP/TLS or cross-host latency, use an external Docker-Compose cluster.

type MultiNodeSpecOptionsType = {
roles: ReadonlyArray<string>; // one node per role; must be unique
seedRoles?: ReadonlyArray<string>; // bootstrap seeds — defaults to [roles[0]]
addresses?: Record<string, { host: string; port: number }>;
failureDetector?: ClusterOptionsType['failureDetector'];
gossipIntervalMs?: number; // default 100 (vs. 1 s in production)
awaitTimeoutMs?: number; // default 10_000 — how long await* helpers wait
logLevel?: LogLevel; // defaults to a quiet NoopLogger
downing?: (role: string) => DowningProvider | undefined;
};
FieldPurpose
rolesNode list — each entry is one node, and the string doubles as its system name. Must be unique.
seedRolesWhich roles act as bootstrap seeds. Defaults to the first role.
addressesPer-role host/port overrides. Auto-allocated if omitted.
failureDetectorFailure-detector overrides — tests usually tighten these so crashes are detected quickly.
gossipIntervalMsGossip round interval. Default 100 ms.
awaitTimeoutMsDefault timeout for the await* helpers. Default 10 s.
logLevelLog level — quiet by default.
downingPer-role split-brain resolver factory.

Pass a plain object as shown, or build it fluently with MultiNodeSpecOptions.create().withRoles(['a', 'b', 'c']).

Each entry in roles is a distinct node — the string is that node’s identity and its ActorSystem name, so it must be unique (the constructor throws on duplicates or an empty list). By default the first role is the sole bootstrap seed; override that with seedRoles:

const spec = new MultiNodeSpec({
roles: ['seed', 'worker-1', 'worker-2'],
seedRoles: ['seed'],
});
await spec.start();

Ports are auto-allocated on 127.0.0.1; pin specific ones with addresses when a test needs fixed endpoints:

const spec = new MultiNodeSpec({
roles: ['a', 'b'],
addresses: { a: { host: '127.0.0.1', port: 2551 } },
});

All nodes share:

  • A shared in-process message bus — they can talk to each other.
  • The same gossip protocol — membership converges.
  • Independent actor systems — separate dispatchers, schedulers, supervisor trees.

This means:

  • Real cluster semantics — members come up, gossip converges, failure detector observes, sharding rebalances.
  • No serialization — messages between “nodes” pass by reference (in-process). Not a fit for testing serialization-dependent behavior.

Two ways to take a node down: crash(role) yanks its transport out abruptly (peers detect it via the failure detector), and leave(role) performs a graceful cluster exit.

// Verify singleton failover:
const spec = new MultiNodeSpec({
roles: ['a', 'b', 'c'],
failureDetector: { heartbeatIntervalMs: 50, unreachableAfterMs: 200, downAfterMs: 400 },
});
await spec.start();
await Promise.all([
spec.awaitMembers('a', 3),
spec.awaitMembers('b', 3),
spec.awaitMembers('c', 3),
]);
// ... start the singleton on every node ...
// Identify the current host, then crash it:
const host = spec.clusterFor('a').leader().toNullable();
await spec.crash('a');
// Wait for the survivors to down 'a' and re-converge:
await spec.awaitMembers('b', 2);
await spec.awaitMemberStatus('b', 'a', 'down');
// ... assert the singleton has moved to a survivor ...
await spec.stop();

crash(role) removes the node abruptly; the others observe the unreachable status, gossip the change, then trigger downing + failover. The await* helpers replace fragile setTimeout sleeps — they poll until the condition holds (or throw after awaitTimeoutMs).

MultiNodeSpec has no probe factory — construct a TestProbe against whichever node’s system you want to observe:

const probeA = new TestProbe(spec.systemFor('a')); // probe on node 'a'
const probeB = new TestProbe(spec.systemFor('b')); // probe on node 'b'

Each probe is bound to one node’s actor system. Useful when testing routing — verify that a message ends up on the expected node.

Start a sharding region on every node via its Cluster — reach each one with clusterFor(role):

const spec = new MultiNodeSpec({ roles: ['a', 'b', 'c'] });
await spec.start();
await spec.awaitMembers('a', 3);
const startShardingOptions = StartShardingOptions.create<Command>()
.withTypeName('entity')
.withEntityActor(Entity)
.withExtractEntityId((message) => message.id);
const regions = spec.allRoles().map((role) =>
spec.clusterFor(role).sharding.start(startShardingOptions)
);
// Spawn entities; verify they spread across nodes:
for (const id of ['e1', 'e2', 'e3', 'e4', 'e5']) {
regions[0].tell({ id, kind: 'wake-up' });
}
await spec.stop();

The framework’s coordinator distributes shards across the nodes just like a real cluster.