|
| 1 | +(** |
| 2 | +Clustering |
| 3 | +------- |
| 4 | +FsShelter topologies can run under cluster supervision for automatic master/standby failover — no external coordinator (ZooKeeper, etcd, etc.) required. Add a few configuration options to your existing topology and swap `Hosting.run` for `Supervisor.runCluster`. |
| 5 | +
|
| 6 | +Only one peer in the cluster — the **master** — runs the topology at any time. Standby peers gossip among themselves and monitor liveness. If the master goes down, the remaining peers elect a new master and activate the topology there. |
| 7 | +
|
| 8 | +**Key properties:** |
| 9 | +
|
| 10 | +- **No external dependencies** — peers communicate directly over TCP; no coordinator service needed. |
| 11 | +- **Deterministic election** — Rendezvous (HRW) hashing; every peer computes the same result independently. |
| 12 | +- **Transparent fallthrough** — when `CLUSTER_SEEDS` is empty, `Supervisor.runCluster` delegates to `Hosting.runWith` for single-process parity. Same topology code, zero cluster overhead. |
| 13 | +- **Same topology code** — sync, async, reliable, unreliable — all component types work under cluster supervision without changes. |
| 14 | +
|
| 15 | +
|
| 16 | +Quick start |
| 17 | +-------------------- |
| 18 | +Configure your topology with cluster options and use `Supervisor.runCluster` instead of `Hosting.run`: |
| 19 | +
|
| 20 | + open FsShelter.Cluster |
| 21 | +
|
| 22 | + let shutdown = |
| 23 | + myTopology |
| 24 | + |> withConf [ CLUSTER_LISTEN "0.0.0.0:6700" |
| 25 | + CLUSTER_SEEDS "node1:6700,node2:6700,node3:6700" |
| 26 | + CLUSTER_QUORUM 2 ] |
| 27 | + |> Supervisor.runCluster (fun _ _ -> ignore) |
| 28 | +
|
| 29 | + match shutdown with |
| 30 | + | Ok stop -> stop() // graceful shutdown |
| 31 | + | Error msg -> eprintfn "Cluster startup failed: %s" msg |
| 32 | +
|
| 33 | +`runCluster` returns `Result<unit -> unit, string>` — startup failures (bad config, transport bind, peer-id I/O) are returned as `Error` rather than thrown. |
| 34 | +
|
| 35 | +
|
| 36 | +How it works |
| 37 | +-------------------- |
| 38 | +The supervisor is a pure state machine (`Supervisor.step`) driven by external events. The impure orchestration (`runClusterWith`) wires together the TCP transport, periodic heartbeats, membership tracking, and election recompute. |
| 39 | +
|
| 40 | +### Supervisor states |
| 41 | +
|
| 42 | +```mermaid |
| 43 | +stateDiagram-v2 |
| 44 | + [*] --> Standby |
| 45 | + Standby --> Activating : ElectionUpdate(self=master) |
| 46 | + Activating --> Master : StabilizeElapsed (window passed) |
| 47 | + Activating --> Standby : ElectionUpdate(self≠master) |
| 48 | + Master --> Standby : ElectionUpdate(self≠master) |
| 49 | + Master --> Stopping : Shutdown |
| 50 | + Standby --> Stopping : Shutdown |
| 51 | + Activating --> Stopping : Shutdown |
| 52 | + Stopping --> [*] |
| 53 | +``` |
| 54 | +
|
| 55 | +| State | What's happening | |
| 56 | +|-------|-----------------| |
| 57 | +| **Standby** | Not the elected master. Only gossip and transport machinery are running. | |
| 58 | +| **Activating** | Just won the election. Debouncing for `CLUSTER_STABILIZE_MS` before starting the topology — this prevents rapid start/stop if membership is still settling. | |
| 59 | +| **Master** | Elected master and the topology is running. | |
| 60 | +| **Stopping** | Shutdown was requested. Terminal state — no further transitions. | |
| 61 | +
|
| 62 | +The transition from **Activating → Standby** (lost mastership before stabilize window expires) is key: the topology was never started, so no `StopTopology` effect is emitted. |
| 63 | +
|
| 64 | +
|
| 65 | +Failure detection (SWIM) |
| 66 | +-------------------- |
| 67 | +Peers detect failures using a lightweight gossip protocol inspired by [SWIM](https://www.cs.cornell.edu/projects/Quicksilver/public_pdfs/SWIM.pdf): |
| 68 | +
|
| 69 | +1. Each peer sends **heartbeat pings** at `CLUSTER_HEARTBEAT_MS` intervals to all known peers, piggy-backing membership digests (peer state + incarnation numbers). |
| 70 | +2. If a peer stops responding, it transitions from **Alive → Suspect** after `CLUSTER_SUSPECT_TIMEOUT_MS` with no heartbeat received. |
| 71 | +3. A suspected peer can **refute** by responding with a newer incarnation — transitioning back to **Alive**. |
| 72 | +4. If no refutation arrives, the peer transitions from **Suspect → Dead** and is evicted. |
| 73 | +
|
| 74 | +### Member states |
| 75 | +
|
| 76 | +```mermaid |
| 77 | +stateDiagram-v2 |
| 78 | + [*] --> Alive |
| 79 | + Alive --> Suspect : No heartbeat for SuspectTimeoutMs |
| 80 | + Suspect --> Alive : Newer incarnation received (refute) |
| 81 | + Suspect --> Dead : SuspectTimeoutMs elapsed without refutation |
| 82 | + Dead --> [*] : Evicted |
| 83 | +``` |
| 84 | +
|
| 85 | +**Quorum:** Only **Alive** and **Suspect** peers count toward quorum. The topology activates only when the cluster has at least `CLUSTER_QUORUM` live members AND the local peer is the elected master. |
| 86 | +
|
| 87 | +**Epochs:** Membership changes (joins, deaths) bump the cluster epoch. Only epoch changes trigger election recompute — suspect flaps do not. |
| 88 | +
|
| 89 | +
|
| 90 | +Leader election (HRW) |
| 91 | +-------------------- |
| 92 | +FsShelter uses **Rendezvous Hashing** (Highest Random Weight) for deterministic, decentralized leader election. |
| 93 | +
|
| 94 | +### How it works |
| 95 | +
|
| 96 | +Each peer independently computes a score for every live peer: |
| 97 | +
|
| 98 | + score(peer) = SHA-256(topologyName | epoch_BE8 | peer_GUID) → first 8 bytes as uint64 |
| 99 | +
|
| 100 | +The peer with the highest score wins. Ties are broken by `PeerId` ordinal comparison. |
| 101 | +
|
| 102 | +### Key properties |
| 103 | +
|
| 104 | +- **Deterministic** — all peers compute the same result independently; no voting or coordination messages needed. |
| 105 | +- **Epoch stickiness** — folding the epoch into the hash input makes mastership stable: a higher-scored peer rejoining without a membership change cannot steal the role until the live set shifts (which bumps the epoch). |
| 106 | +- **Minimal disruption** — when a peer joins or leaves, on average only 1/N of the election results change. |
| 107 | +
|
| 108 | +### Quorum requirement |
| 109 | +
|
| 110 | +The topology only activates when `MemberView.hasQuorum` is satisfied. A cluster of 3 peers with `CLUSTER_QUORUM 2` can tolerate 1 failure. A cluster of 5 with `CLUSTER_QUORUM 3` can tolerate 2. |
| 111 | +
|
| 112 | +
|
| 113 | +Stabilization window |
| 114 | +-------------------- |
| 115 | +After winning an election, the master waits `CLUSTER_STABILIZE_MS` (default: 2000ms) before activating the topology. This **debounce window** prevents rapid start/stop when membership is still settling (e.g., peers starting up in quick succession). |
| 116 | +
|
| 117 | +If mastership is lost during the stabilization window, the peer returns to Standby without ever starting the topology — avoiding unnecessary initialization and teardown. |
| 118 | +
|
| 119 | +
|
| 120 | +Configuration reference |
| 121 | +-------------------- |
| 122 | +
|
| 123 | +| Option | Default | Effect | |
| 124 | +|--------|---------|--------| |
| 125 | +| `CLUSTER_SEEDS` | (none) | Comma-separated `host:port` list of initial peers. **Required for clustering.** Empty = single-process fallthrough. | |
| 126 | +| `CLUSTER_LISTEN` | (none) | `host:port` endpoint to bind for peer communication. **Required for clustering.** | |
| 127 | +| `CLUSTER_QUORUM` | (none) | Minimum live peers required before topology activation. Typically `⌊N/2⌋ + 1`. | |
| 128 | +| `CLUSTER_HEARTBEAT_MS` | 1000 | Heartbeat ping interval in milliseconds. | |
| 129 | +| `CLUSTER_SUSPECT_TIMEOUT_MS` | 5000 | Time without heartbeat before marking a peer as Suspect, then Dead. | |
| 130 | +| `CLUSTER_STABILIZE_MS` | 2000 | Post-election debounce window in milliseconds. | |
| 131 | +| `CLUSTER_STATE_DIR` | `"."` | Directory for persistent state (`peer-id.dat`, `peers.dat`). | |
| 132 | +| `CLUSTER_SEND_QUEUE_BOUND` | 1024 | Per-peer outbound message queue limit. | |
| 133 | +| `CLUSTER_MAX_FRAME_BYTES` | 262144 (256KB) | Maximum single frame size on the wire. | |
| 134 | +
|
| 135 | +Example with all options: |
| 136 | +
|
| 137 | + myTopology |
| 138 | + |> withConf [ CLUSTER_LISTEN "0.0.0.0:6700" |
| 139 | + CLUSTER_SEEDS "node1:6700,node2:6700,node3:6700" |
| 140 | + CLUSTER_QUORUM 2 |
| 141 | + CLUSTER_HEARTBEAT_MS 1500 |
| 142 | + CLUSTER_SUSPECT_TIMEOUT_MS 7500 |
| 143 | + CLUSTER_STABILIZE_MS 3000 |
| 144 | + CLUSTER_STATE_DIR "/var/lib/myapp/cluster" |
| 145 | + CLUSTER_SEND_QUEUE_BOUND 2048 |
| 146 | + CLUSTER_MAX_FRAME_BYTES (512 * 1024) ] |
| 147 | +
|
| 148 | +
|
| 149 | +Graceful shutdown |
| 150 | +-------------------- |
| 151 | +When the shutdown function is called: |
| 152 | +
|
| 153 | +1. A **Leaving** message is sent to all known peers — they can immediately mark this peer as Dead and re-elect without waiting for the suspect timeout. |
| 154 | +2. The heartbeat timer is disposed. |
| 155 | +3. A `Shutdown` input is posted to the supervisor mailbox. |
| 156 | +4. If the peer is the current master, the topology is stopped (using the standard shutdown sequence: stop spouts → drain window → stop bolts → stop ackers). |
| 157 | +5. The TCP transport is stopped. |
| 158 | +
|
| 159 | +
|
| 160 | +Single-process fallthrough |
| 161 | +-------------------- |
| 162 | +If `CLUSTER_SEEDS` is empty (and `CLUSTER_LISTEN` is not set), `Supervisor.runCluster` delegates to `Hosting.runWith` — your topology runs in single-process mode with zero cluster overhead. This means you can use `Supervisor.runCluster` as your only entry point and control the mode via configuration: |
| 163 | +
|
| 164 | + // Development: no cluster config → single-process |
| 165 | + myTopology |> Supervisor.runCluster log |
| 166 | +
|
| 167 | + // Production: add cluster config → supervised failover |
| 168 | + myTopology |
| 169 | + |> withConf [ CLUSTER_LISTEN "0.0.0.0:6700" |
| 170 | + CLUSTER_SEEDS "node1:6700,node2:6700,node3:6700" |
| 171 | + CLUSTER_QUORUM 2 ] |
| 172 | + |> Supervisor.runCluster log |
| 173 | +
|
| 174 | +
|
| 175 | +Advanced: custom dependencies |
| 176 | +-------------------- |
| 177 | +For testing or alternate hosting scenarios, `Supervisor.runClusterWith` accepts a `ClusterDeps<'t>` record that provides all external capabilities: |
| 178 | +
|
| 179 | + type ClusterDeps<'t> = |
| 180 | + { NowMs: unit -> int64 // clock |
| 181 | + LoadOrCreatePeerId: string -> Result<PeerId, StoreError> // persistent identity |
| 182 | + LoadPeersCache: string -> Result<PeerEndpoint list, StoreError> |
| 183 | + SavePeersCache: string -> PeerEndpoint list -> Result<unit, StoreError> |
| 184 | + CreateTransport: ... -> PeerTransport // TCP transport |
| 185 | + RunTopology: (int -> Log) -> Topology<'t> -> (unit -> unit) } // topology host |
| 186 | +
|
| 187 | +Production defaults are provided by `Supervisor.defaultDeps`, which uses the system clock, on-disk stores, real TCP transport, and `Hosting.runWith` for topology hosting. Override individual fields for integration testing or custom environments. |
| 188 | +
|
| 189 | +
|
| 190 | +See also |
| 191 | +-------------------- |
| 192 | +
|
| 193 | +- [Running Topologies](self-hosting.html) — Entry points, configuration, diagnostics for single-process hosting |
| 194 | +- [Architecture](architecture.html) — Internal runtime structure: tasks, executors, channels, lifecycle |
| 195 | +- [Message Flow](message-flow.html) — End-to-end processing scenarios with sequence diagrams |
| 196 | +
|
| 197 | +*) |
0 commit comments