Cluster Routers¶
Documentation index · Cluster membership · Cluster receptionist · Cluster sharding · Cluster singleton
Movie::ClusterRouters is a typed actor-system extension above the cluster receptionist. A group router follows one ServiceKey(T) through a local subscription and selects from the latest reachable listing without performing network discovery on the caller's message path.
Use a router when multiple interchangeable actors already exist and callers should send through one stable typed reference. Use the receptionist directly when callers need the complete listing, sharding when each entity id must have one logical owner, and singleton when exactly one cluster-wide coordinator must be active.
Dynamic group routing¶
Enable remoting and cluster membership, register the wire command and reply types on every node, and create the same service key used by routees:
Movie::Remote::MessageRegistry.register(WorkerCommand)
Movie::Remote::MessageRegistry.register(WorkerReply)
system.enable_remoting("0.0.0.0", 2551)
cluster = system.enable_cluster(cluster_settings)
cluster.await_up
workers = Movie::Cluster::ServiceKey(WorkerCommand).new("workers")
router = Movie::ClusterRouters.get(system).group(workers)
router << WorkerCommand.new("job-42")
reply = router.ask(WorkerCommand.new("job-43"), WorkerReply, 2.seconds).await
The router maintains a defensive, path-sorted snapshot through a dedicated local observer actor. routee_count and routee_paths expose that snapshot for operations and diagnostics. close is idempotent and removes the subscription; sending after close raises ClusterRouterStoppedError.
An empty eligible listing raises ClusterRouterUnavailableError immediately. Movie does not silently drop, buffer, retry, or replay router messages. A routee may still disappear after selection, remote tells remain at-most-once, and ask timeouts remain ambiguous.
Strategies¶
ClusterRouterSettings selects one of four strategies:
| Strategy | Behavior |
|---|---|
RoundRobin |
Thread-safe rotation through the current path-sorted listing. This is the default. |
Random |
Uniform random selection from the current eligible listing. |
Broadcast |
Sends a tell to every current routee and continues after an individual routee rejects delivery. ask fails closed because no single response contract exists. |
RendezvousHash |
Deterministically chooses the highest-scoring routee for an application routing key. |
Rendezvous hashing requires an explicit stable extractor. Its FNV-1a score uses the extracted key and canonical actor path rather than Crystal's process-randomized object hash:
settings = Movie::Cluster::ClusterRouterSettings.new(
strategy: Movie::Cluster::ClusterRoutingStrategy::RendezvousHash
)
router = Movie::ClusterRouters.get(system).group(
workers,
settings,
routing_key: ->(command : WorkerCommand) { command.customer_id }
)
Only the rendezvous strategy accepts routing_key; missing or unused extractors raise ClusterRouterConfigurationError during router construction.
Roles and local preference¶
Receptionist filtering happens first, so every candidate is owned by a member currently observed as Up and reachable. A role filter then matches the registration's complete owner identity (address and node UID), preventing a restarted node on the same host and port from inheriting the previous incarnation's roles:
backend_only = Movie::Cluster::ClusterRouterSettings.new(routee_role: "backend")
router = Movie::ClusterRouters.get(system).group(workers, backend_only)
prefer_local: true selects only local routees while at least one is available and falls back to all eligible remote routees otherwise. It is a locality preference, not an ownership or failover guarantee. Role and reachability changes arrive through receptionist listing updates.
Explicit per-node pools¶
Movie does not deploy actors remotely. A pool creates actors only on the node where pool is called, registers them in the receptionist, and owns their cleanup:
pool = Movie::ClusterRouters.get(system).pool(
workers,
size: 4,
name_prefix: "worker"
) do |index|
Worker.new(index)
end
pool.router << WorkerCommand.new("job-42")
pool.local_routees # defensive local ActorRef snapshot
pool.close
Call the same pool setup explicitly on every node intended to host routees. A pool is limited to 1,024 local routees and its name prefix to 128 bytes; receptionist registration limits still apply. Construction failure deregisters and stops every partially created routee. close deregisters and stops owned routees, closes the shared router, and is idempotent.
Lifecycle and telemetry¶
Cluster-router subscriptions disappear automatically when their observer actor stops. The extension is registered after the receptionist, so actor-system shutdown closes pools and routers before their discovery dependency. A group or pool racing shutdown is either published completely or rolled back.
Movie::ClusterRouters.get(system).stats reports current standalone groups, pools, discovered routees, cumulative group/pool creation, routed messages, broadcast deliveries, unavailable selections, listing updates, and per-strategy selections.
crystal spec spec/movie/cluster/router_spec.cr \
-Dpreview_mt -Dexecution_context
MOVIE_ROUTER_STRESS=1 crystal spec \
spec/movie/cluster/router_stress_spec.cr \
-Dpreview_mt -Dexecution_context
The opt-in real-process scenario covers remote ask, role filtering, temporary unreachability and recovery, graceful leave, same-address restart with a new UID, abrupt loss, and explicit downing. See examples/cluster_router_example.cr for a runnable two-node pool example.