Skip to content

Commit b0a1f80

Browse files
committed
async dispatch support with Disruptor async batch handlers
1 parent 8466060 commit b0a1f80

22 files changed

Lines changed: 1668 additions & 82 deletions

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,7 @@ UpgradeLog*.XML
6262
UpgradeLog*.htm
6363

6464
Directory.Build.props
65+
66+
# MemPalace per-project files (issue #185)
67+
mempalace.yaml
68+
entities.json

RELEASE_NOTES.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
#### 5.2.0 - Apr 2026
1+
#### 5.2.0 - May 2026
2+
* Async (`Task`) support: `Spout.runReliableAsync`, `Spout.runUnreliableAsync`, `Bolt.runAsync`, `Bolt.runTerminatorAsync`
23
* Self-hosting Supervisor for Master/Standby deployments
34

45
#### 5.1.1 - Apr 2026

docs/content/clustering.fsx

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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+
*)

docs/content/concepts.fsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,37 @@ let logResult (info, input) =
112112
The arguments to your component functions are wired up when you define the topology — you choose exactly what each function receives. There's no global state or magic injection.
113113
114114
115+
Async components
116+
--------------------
117+
When your spout or bolt needs to perform I/O (database queries, HTTP calls, file access), you can use the async variants. Instead of returning a value directly, async components return a `Task`:
118+
*)
119+
120+
open System.Threading.Tasks
121+
122+
// async spout - produces a message via async I/O
123+
let asyncNumbers source : Task<BasicSchema option> =
124+
task {
125+
let value = source()
126+
return Some(BasicSchema.Original value)
127+
}
128+
129+
// async bolt - transforms via async I/O
130+
let asyncAddOne (input, emit) : Task<unit> =
131+
task {
132+
match input with
133+
| BasicSchema.Original(x) -> BasicSchema.Incremented(x + 1) |> emit
134+
| _ -> failwithf "unexpected input: %A" input
135+
}
136+
137+
(**
138+
The type signatures are:
139+
140+
* `AsyncNext<'a, 't>` — an async spout function: `'a -> Task<'t option>`
141+
* `AsyncConsume<'a>` — an async bolt function: `'a -> Task<unit>`
142+
143+
Async components run on async executors — the Disruptor thread awaits each `Task`, allowing non-blocking I/O without dedicating extra threads. All configuration (`withParallelism`, `withExecutors`, etc.) and delivery guarantees (reliable/unreliable) work identically.
144+
145+
115146
Topology DSL
116147
--------------------
117148
FsShelter provides a computation expression for defining topologies. You declare components and connect them with arrows:
@@ -159,6 +190,26 @@ The lambda arguments for the `run` methods construct the arguments passed to you
159190
160191
`log` and `cfg` are curried once at startup. The `tuple` and `emit` arguments arrive per-message.
161192
193+
The async variants have the same structure — just swap `Spout.run*` for `Spout.run*Async` and `Bolt.run` for `Bolt.runAsync`:
194+
195+
// Async spout (reliable)
196+
let s1 = asyncNumbers
197+
|> Spout.runReliableAsync (fun log cfg -> source) (fun s -> ack, nack) ignore
198+
199+
// Async spout (unreliable)
200+
let s1 = asyncNumbers
201+
|> Spout.runUnreliableAsync (fun log cfg -> source) ignore
202+
203+
// Async bolt (auto-ack on success)
204+
let b1 = asyncAddOne
205+
|> Bolt.runAsync (fun log cfg tuple emit -> (tuple, emit))
206+
207+
// Async bolt (always nack — terminator)
208+
let b2 = asyncLog
209+
|> Bolt.runTerminatorAsync (fun log cfg tuple _ -> (log, tuple))
210+
211+
Sync and async components can be mixed freely in the same topology. The hosting runtime detects the component type and uses the appropriate executor.
212+
162213
163214
Reliable vs unreliable delivery
164215
--------------------

docs/content/index.fsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ Documentation
6161
6262
* [Schema](schema.html) — Grouping expressions, record flattening, serializer details, special streams
6363
* [Running Topologies](self-hosting.html) — Entry points, configuration, tuning, diagnostics
64+
* [Clustering](clustering.html) — Master/standby failover with SWIM membership and deterministic leader election
6465
* [Routing](routing.html) — How tuples are distributed: Shuffle, Fields, All, Direct
6566
6667
### Deep dives

docs/content/self-hosting.fsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,42 @@ Key details:
104104
- After `maxRestarts` (5) consecutive failures, the topology stays down
105105
- `runNoRestart` uses `Environment.Exit(1)` instead, delegating restart to the process supervisor
106106
107+
All entry points handle both sync and async components transparently — the runtime detects `AsyncFuncRef` components and routes them to async executors. No separate entry point is needed for topologies that use `Spout.runReliableAsync`, `Bolt.runAsync`, etc.
108+
109+
For multi-node failover, see [Clustering](clustering.html) — same topology code, add configuration options and use `Supervisor.runCluster`.
110+
111+
112+
Async components
113+
--------
114+
FsShelter supports async spouts and bolts via Disruptor's `IAsyncBatchEventHandler` with `AsyncWaitStrategy`. This lets components perform native async I/O (database calls, HTTP requests, message queue reads) without blocking executor threads.
115+
116+
### Defining async components
117+
118+
Async variants mirror their sync counterparts — same topology DSL, just different function signatures:
119+
120+
| Sync | Async | Signature change |
121+
|------|-------|-----------------|
122+
| `Spout.runReliable` | `Spout.runReliableAsync` | `next: 'a -> ('id * 't) option``next: 'a -> Task<('id * 't) option>` |
123+
| `Spout.runUnreliable` | `Spout.runUnreliableAsync` | `next: 'a -> 't option``next: 'a -> Task<'t option>` |
124+
| `Bolt.run` | `Bolt.runAsync` | `consume: 'a -> unit``consume: 'a -> Task<unit>` |
125+
| `Bolt.runTerminator` | `Bolt.runTerminatorAsync` | `consume: 'a -> unit``consume: 'a -> Task<unit>` |
126+
127+
### How it works
128+
129+
The runtime detects `AsyncFuncRef` components during topology construction:
130+
131+
- **Async bolts** get a Disruptor channel with `AsyncWaitStrategy()` — the handler's `OnBatch` method is `async`, allowing `Task`-returning bolt functions to `await` without blocking.
132+
- **Async spouts** get `AsyncWaitStrategy(TimeSpan)` — combines async event handling with a timeout for polling (issuing `Next` when under `maxPending`).
133+
- **Sync components** (`FuncRef`) continue to use blocking wait strategies on their own executor threads — no change in behavior.
134+
135+
Mixed topologies (some sync bolts, some async bolts, sync spout) work seamlessly — each component type gets the appropriate channel strategy.
136+
137+
### Performance characteristics
138+
139+
- Sync path: zero regression — same throughput as pre-async baseline (~5,350 msg/s on standard bench config)
140+
- Async path: comparable throughput with higher CPU utilization (trades thread blocking for task scheduling)
141+
- GC profile: unchanged for sync; async adds minor Task allocations
142+
107143
108144
Diagnostics
109145
--------
@@ -155,6 +191,7 @@ Configuration
155191
| `TOPOLOGY_SLEEP_SPOUT_WAIT_STRATEGY_TIME_MS` | 100 | Spout executor timeout: how often the spout wakes to poll for new tuples when idle |
156192
| `TOPOLOGY_DEBUG` | false | Enable trace-level logging with timing |
157193
| `TOPOLOGY_TICK_TUPLE_FREQ_SECS` | (none) | Per-bolt tick tuple interval in seconds |
194+
| `CLUSTER_*` | (various) | Cluster supervision options — see [Clustering](clustering.html) for the full configuration reference |
158195
159196
### Component-level DSL
160197
@@ -166,6 +203,8 @@ Configuration
166203
| `withActivation tuple` | Bolt | None | Send this tuple to the bolt on activation |
167204
| `withDeactivation tuple` | Bolt | None | Send this tuple to the bolt on deactivation |
168205
206+
Async variants (`Spout.runReliableAsync`, `Spout.runUnreliableAsync`, `Bolt.runAsync`, `Bolt.runTerminatorAsync`) accept `Task`-returning functions and use async executors. All other DSL functions (`withParallelism`, `withExecutors`, etc.) work identically.
207+
169208
Example:
170209
171210
// 4 bolt tasks served by 2 executor threads (2 tasks per thread)

src/FsShelter.Multilang/Management.fs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ module ThriftModel =
122122
| Shell (prog,script) ->
123123
ComponentObject(Shell = ShellComponent(Execution_command = prog,
124124
Script = script))
125-
| FuncRef _ ->
125+
| FuncRef _ | AsyncFuncRef _ ->
126126
ComponentObject(Shell = ShellComponent(Execution_command = exeName,
127127
Script = match optionalArgs with [] -> "" | xs -> String.Join(" ", xs)))
128128

src/FsShelter.Multilang/Task.fs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ let ofTopology (t : Topology<'t>) compId =
3838
|> Seq.head
3939
|> function
4040
| FuncRef r -> r
41+
| AsyncFuncRef ar ->
42+
fun conf out ->
43+
let asyncDispatch = ar conf out
44+
// Safe: backgroundTask ignores SynchronizationContext
45+
fun msg -> asyncDispatch(msg).GetAwaiter().GetResult()
4146
| _ -> failwithf "Not a runnable component: %s" compId
4247

4348
/// Reads the handshake and runs the specified task with a logger

src/FsShelter.Tests/AckerTests.fs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1-
/// Phase 1: Acker correctness tests
1+
/// Acker correctness tests
22
/// Modeled on Apache Storm's AckerTest / TupleTreeTest
33
module FsShelter.AckerTests
44

5+
open System
6+
open System.Threading
57
open NUnit.Framework
68
open Swensen.Unquote
79
open FsShelter.TestTopology
810
open FsShelter.DSL
9-
open FsShelter.Multilang
10-
open FsShelter.Hosting
11-
open System
12-
open System.Threading
1311

1412
#nowarn "25"
1513

0 commit comments

Comments
 (0)