Cluster Membership, Bootstrap, and Safety

Movie cluster membership is a production-alpha extension above the actor and remoting runtime. It supplies static or dynamic seed discovery, stable bootstrap, process-incarnation identity, convergent membership, deterministic leadership, reachability, graceful leave, manual downing, rolling protocol gates, typed events, and telemetry. An explicit split-brain extension resolves network partitions with dependency-free keep-majority or an external lease.

Cluster traffic remains at-most-once. Use persistence and transactional outbox contracts when application delivery must survive process loss. Cluster sharding, singletons, the receptionist, and routers build on this membership contract.

Static seeds

Every node enables remoting before membership. Seed addresses include the actor-system name because association negotiation verifies it:

seed_remote = seed_system.enable_remoting("127.0.0.1", 2551)
seed = seed_system.enable_cluster(Movie::Cluster::ClusterSettings.new(
  cluster_name: "orders",
  roles: ["seed", "backend"]
))

worker_system.enable_remoting("127.0.0.1", 2552)
worker = worker_system.enable_cluster(Movie::Cluster::ClusterSettings.new(
  cluster_name: "orders",
  seed_nodes: [seed_remote.address],
  roles: ["worker"]
))
worker.await_up(10.seconds)

An empty static seed list forms a one-node cluster immediately. A configured seed whose list contains itself starts as Up and retries the other seeds. A non-seed starts as Joining until the leader admits its exact address and node UID. Join, welcome, and gossip processing is idempotent.

Dynamic discovery and bootstrap

Dynamic discovery is a separate SPI. DnsSeedDiscovery resolves A/AAAA records. KubernetesSeedDiscovery lists all discovery.k8s.io/v1 EndpointSlices with the selected Service label, deduplicates them, and keeps ready, non-terminating endpoints for the named TCP port. A custom provider implements SeedDiscovery#lookup.

All dynamically discovered nodes use the same actor-system name and remoting port. Static seeds and dynamic discovery are mutually exclusive:

discovery = Movie::Cluster::DnsSeedDiscovery.new(
  "orders-system",
  "orders-headless.default.svc.cluster.local",
  2552
)

system.enable_remoting("0.0.0.0", 2552)
cluster = system.enable_cluster(Movie::Cluster::ClusterSettings.new(
  cluster_name: "orders",
  discovery: discovery,
  bootstrap: Movie::Cluster::BootstrapSettings.new(
    required_contact_points: 3,
    stable_after: 5.seconds,
    join_timeout: 3.seconds
  )
))

Bootstrap sorts an unchanged contact set, tries an existing contact first, and allows only the lowest address to form after the join window. Configure required_contact_points as the minimum expected initial deployment size. Set new_cluster_enabled: false on nodes that may join but must never form a new cluster.

Automatic Kubernetes configuration expects a headless Service with a named TCP port and a service account allowed to get and list EndpointSlices in the namespace:

name: orders-system
remoting:
  enabled: true
  host: 0.0.0.0
  port: 2552
cluster:
  enabled: true
  name: orders
  discovery:
    method: kubernetes
    kubernetes:
      service: orders-headless
      namespace: default
      port-name: remoting
  bootstrap:
    required-contact-points: 3

Identity and membership

UniqueAddress combines the advertised remoting Address with the random process node UID authenticated by its association. Reusing a host and port after restart creates a different incarnation. Membership records move monotonically through:

Joining -> Up -> Leaving -> Exiting -> Removed
                  \-> Down -> Removed

Bounded tombstones prevent delayed gossip from resurrecting an old UID. cluster.max-members covers live members plus tombstones and fails closed before partial overflow. The lowest reachable Up or Leaving UniqueAddress is the local leader. ACKs carry a deterministic digest; cluster.converged? is true when all reachable Up members have acknowledged the current state.

Reachability and split-brain resolution

Cluster heartbeats use /system/cluster. A monotonic timeout adds an exact UID to the local snapshot.unreachable set; reachability alone never changes membership. Without SBR, an operator must resolve the ambiguity and invoke down(unique_address) on the current leader.

The default automatic policy needs no persistence or external coordinator. Enable it explicitly on every node after cluster membership:

split_brain = system.enable_split_brain

After the unreachable set remains unchanged for stable_after, keep-majority counts active (Up, Leaving, or Exiting) members. The larger partition survives. An even split is won by the side containing the lowest eligible UniqueAddress. Only the surviving partition's reachable cluster leader issues down commands, but every unreachable member is downed. If cluster.split-brain.role is set, only members with that role vote; non-voting unreachable members are still downed. The losing partition shuts its actor systems down. A continuously changing partition view also shuts down after stable-after + down-all-when-unstable rather than running ambiguously forever.

Keep-majority resolves membership without PostgreSQL, but it is not a linearizable storage fence. Persistent sharding and persistent singleton writes still use their independent shared-backend fencing epoch. Ordinary in-memory actors on a process paused longer than failure detection can execute until that process resumes and observes its terminal membership, so commands that require durable exactly-once effects still need idempotency or storage fencing.

For a stronger infrastructure policy, supply one strongly consistent external lease to every node. The PostgreSQL adapter reuses Movie persistence's pool and fenced lease table:

require "movie/persistence/postgres"

database = Movie::Database.get(system) # backend must be postgres
lease = Movie::Cluster::PostgresSplitBrainLease.new(
  database,
  cluster.settings.cluster_name,
  "primary"
)
split_brain = system.enable_split_brain(lease)

With lease-majority, after the unreachable set stays unchanged for stable_after, only the lowest reachable eligible member attempts the lease. Nodes that can observe that holder as reachable survive. A side that sees the holder as unreachable shuts its actor system down. Lease loss or renewal failure also fails closed. The holder downs unreachable incarnations; a paused loser shuts down when it resumes and observes its terminal membership record.

Use the same cluster and lease names everywhere, an HA PostgreSQL deployment independent of either application partition, and remoting.shared-secret. SQLite and process-local locks are not production leases. The process supervisor or Kubernetes controller must restart terminated nodes. Resolution time includes failure detection, stabilization, minority delay, and lease I/O, so probes must allow that envelope.

split_brain.stats reports state, partitions, acquisitions, renewals, failures, downed members, self-shutdowns, and releases. Persistent sharding and singleton writes retain their independent PostgreSQL fencing.

Rolling updates

Each member advertises minimum_protocol_version, protocol_version, and app_version. Compatibility is mutual: each current cluster protocol must fall inside the other's accepted range. JoinRejected gives await_up an immediate reason instead of a generic timeout. Membership admits at most two active application versions.

Keep serializers and actor message contracts readable in both directions during a rollout. Raise the minimum protocol only after all nodes understand the new version. This gate is independent of remoting's exact association protocol; changing that transport protocol still needs a capability-compatible rollout or a full restart.

Graceful leave

Use a two-step shutdown so peers receive the tombstone before transport stops:

cluster.leave
cluster.await_removed(10.seconds)
system.shutdown

Direct actor-system shutdown is abrupt. A non-leader retries its leave request, and peers retry the final Removed state for graceful leavers until its digest ACK arrives. Distributed extensions may install a fast non-blocking departure barrier with register_departure_guard.

Events and telemetry

cluster.snapshot returns defensive member and unreachable arrays. Subscribe a local actor for current state followed by effective membership, reachability, and leader transitions:

cluster.subscribe(events)
# CurrentState, MemberJoined, MemberUp, MemberLeaving, MemberExiting,
# MemberDown, MemberRemoved, UnreachableMember, ReachableMember, LeaderChanged
cluster.unsubscribe(events)

cluster.stats includes join/gossip/heartbeat, membership, discovery/bootstrap, compatibility rejection, capacity rejection, and subscriber counters.

Verification

crystal spec spec/movie/cluster -Dpreview_mt -Dexecution_context
MOVIE_CLUSTER_STRESS=1 crystal spec spec/movie/cluster/stress_spec.cr \
  -Dpreview_mt -Dexecution_context
MOVIE_CLUSTER_SAFETY_STRESS=1 MOVIE_POSTGRES_TEST_URL="$DATABASE_URL" \
  crystal spec spec/movie/cluster/safety_stress_spec.cr \
  -Dpreview_mt -Dexecution_context

The safety scenario runs three OS processes, pauses one, verifies PostgreSQL lease acquisition and majority survival, resumes the loser, and verifies its fail-closed shutdown. MOVIE_CLUSTER_SAFETY_STRESS_ROUNDS repeats the scenario for soak runs.

Movie does not yet provide multi-data-center topology, distributed pub/sub, CRDT data, or automatic remoting-protocol migration. Those are separate features, not implied by SBR or bootstrap.