Mesh Scheduling
SWIM gossip, consistent hashing, work-stealing deques, and adaptive load balancing internals.
SWIM gossip, consistent hashing, work-stealing deques, and adaptive load balancing internals.
The mesh layer sits between the scheduler and the storage backend, forming a decentralized overlay network that reduces DB contention and enables sub-millisecond load awareness.
For usage and configuration, see the Mesh Scheduling guide.
Five workers run in different regions, linked into one mesh over HTTP. Jobs
hash-route into the cluster — but enqueues are deliberately skewed toward
us-east-1/us-west-2, so their queues build up. Watch idle regions steal
batches from the busiest peer across the network (cyan, with real inter-region
latency), SWIM gossip flicker along the mesh links, and the counters track
throughput. Hover a region to see its links and latencies; hit Add burst to
flood the queue.
The mesh is implemented as a standalone Rust crate (crates/flexiq-mesh/)
with no PyO3 dependency. Feature-gated via mesh cargo feature on
flexiq-python. Depends on flexiq-core for the
Job type only.
Each mesh worker runs a SWIM-based gossip loop on a UDP socket. SWIM was chosen over full-broadcast gossip (like Serf) because its protocol overhead is O(1) per period regardless of cluster size.
Every 500ms (configurable via protocol_period_ms in MeshConfig), a node:
protocol_period / 2 → sends PingReq to indirect_ping_count (3) random intermediaries to probe the target on its behalf.suspicion_multiplier × ln(N+1) × protocol_period → declares Dead and removes from the hash ring.All messages are bincode-encoded and fit within a single UDP datagram (<1400 bytes):
| Message | Fields | Purpose |
|---|---|---|
Ping | seq, from, from_addr | Direct health check |
Ack | seq, from | Response to Ping |
PingReq | seq, from, target, target_addr | Indirect probe request |
AckRelay | seq, original_from, via | Indirect probe succeeded |
Sync | updates: Vec<MemberUpdate> | State dissemination |
Compound | primary, updates | Any primary message + piggybacked updates |
| State | Meaning | Ring effect |
|---|---|---|
Alive | Responding to pings | In the ring, receives affinity-routed tasks |
Suspect | Missed direct and indirect pings | Still in ring (avoids flapping) |
Dead | Suspicion timeout expired | Removed from ring, virtual nodes deleted |
Left | Graceful shutdown broadcast received | Removed from ring immediately |
Every gossip message (Ping, Ack, PingReq) carries piggybacked
MemberUpdate payloads — new members, state changes, incarnation bumps.
This achieves O(log N) convergence without dedicated protocol rounds.
ACK responses include the full list of known alive peers, ensuring that even nodes with no direct connection converge quickly. Example: node C seeds from node A only, but learns about node B through A's ACK — without B and C ever communicating directly.
The pending update queue holds up to 64 updates, with a maximum of 8 piggybacked per message to stay within UDP datagram limits.
When a node sees itself reported as Suspect, it increments its own
incarnation number and broadcasts a refutation. The rule is simple: higher
incarnation always wins. At the same incarnation, higher-severity state
wins (Alive < Suspect < Dead < Left). This prevents false-positive
failure detection from cascading across the cluster.
Optional XOR symmetric encryption with a shared key (base64-encoded). Applied to every UDP datagram before send, reversed on receive. All cluster nodes must share the same key. See Gossip encryption for setup.
XOR encryption provides obfuscation, not cryptographic security. It prevents casual snooping but won't stop a determined attacker with access to the network. For production deployments, combine with network-level encryption (WireGuard, VPN, mTLS).
Implemented in ring.rs using a BTreeMap<u64, String> that maps xxh3
hash values to worker IDs:
virtual_nodes (default 150) entries in the ring,
hashed as xxh3_64("{worker_id}-{i}") for i in 0..virtual_nodespreferred_worker(key) computes xxh3_64(key) and walks
clockwise (via BTreeMap::range) to find the first entryThe ring's properties are validated by unit tests in
crates/flexiq-mesh/src/ring.rs:
The ring recalculates on every membership change (join, leave, death). The
ring_recalculations metric tracks frequency.
Each worker maintains a Mutex<VecDeque<Job>> as a local job buffer with
a configurable capacity (local_buffer_capacity, default 64):
push_sorted()push_sorted() partitions the batch — non-owned
tasks go to the front (stealable), owned tasks to the back (hot)pop() which takes from the back —
executing affinity-matched tasks firststeal(n) which takes from the front —
preferring tasks that don't benefit from local affinitycrossbeam::deque::Worker is !Send. Since the mesh bridge runs inside
tokio::spawn (which requires Send futures), Mutex<VecDeque> is used
instead. Lock contention is minimal — only the owner thread, the prefetch
path, and occasional steal requests touch the deque, and critical sections
are short (pop/push, no I/O under lock).
TCP with length-prefixed bincode framing:
Each frame is a 4-byte big-endian length prefix followed by the bincode
payload (max 1 MB). The StealRequest carries thief_id and max_count.
The StealResponse carries Vec<Job> — full job structs including
payload, metadata, and scheduling info.
Timeouts: 500ms connect, 2s response read. On any failure, returns empty vec — stealing is best-effort.
try_steal() queries the gossip state for alive peers, sorts by
local_buffer_len descending, and picks the busiest. This is eventually
consistent — the buffer length may have changed since the last gossip
update — but convergence is fast enough (<1s) that the target is usually
still the best choice.
The steal server (steal/server.rs) maintains a per-peer sliding window
rate limiter. For each thief_id, it tracks request timestamps in the last
1 second. If the count exceeds steal_rate_limit (default 10), the
request gets an empty response (no error, no disconnect). Setting
steal_rate_limit=0 disables limiting.
Two mechanisms tune dispatch without a central coordinator:
adaptive_prefetch_size() scales the batch size based on peer count:
base_size = local_buffer_capacity / 4
scale = 1.0 / (peer_count + 1)
prefetch = max(1, base_size × scale)With default capacity 64: standalone worker prefetches 16, in a 3-node cluster each prefetches ~4. This distributes DB load evenly.
poll_jitter_ms() uses the worker's hash-ring position to stagger DB
polls:
jitter = xxh3_64(worker_id) % (protocol_period_ms / 2)The jitter is deterministic per worker ID, so it's stable across restarts. This prevents thundering herd when multiple workers poll simultaneously after a period of queue emptiness.
The mesh does not modify the Scheduler
struct or the WorkerDispatcher trait.
Instead, the per-SDK worker entrypoint spawns a mesh bridge — an
intermediate tokio::sync::mpsc channel
between the scheduler and dispatcher:
The run_mesh_bridge() function:
recv from job_rx (scheduler channel) — push into the local deque.pop from the local deque — send to dispatch_tx (dispatcher channel).try_steal() from the busiest peer.This keeps the scheduler and dispatcher completely unaware of mesh logic.
Without the mesh feature flag, the scheduler sends directly to the
dispatcher as before.
MeshMetrics tracks eight AtomicU64 counters, all lock-free:
| Counter | What it counts |
|---|---|
prefetch_count | Number of prefetch rounds |
prefetch_jobs | Total jobs prefetched from DB |
local_pops | Jobs popped from local deque |
steals_initiated | Steal attempts made |
steals_succeeded | Steal attempts that returned ≥1 job |
jobs_stolen_in | Jobs received via stealing |
jobs_stolen_out | Jobs given away to thieves |
ring_recalculations | Hash ring rebuilds on membership change |
Access via MeshNode::metrics() which returns a MetricsSnapshot (all
values cloned atomically).
| Scenario | Behavior |
|---|---|
| Gossip socket fails to bind | Warning logged, mesh disabled, scheduler runs normally |
| Steal server fails to bind | Warning logged, stealing disabled, gossip + deque still work |
| All seeds unreachable | Worker operates standalone, retries on next protocol tick |
| Peer crashes | Detected in ~1.5s via SWIM, removed from ring |
| Network partition | Partitioned nodes operate independently, DB remains consistent via atomic claims |
| Gossip key mismatch | Messages fail to decode, nodes don't discover each other |
| Deque full | New prefetch jobs are dropped, worker falls back to direct DB dispatch |
In every failure case, the database remains the source of truth. The worst outcome is reduced performance (more DB polls), never data loss or two workers running the same job at once — the storage layer's atomic claim mechanism gives exactly-once dispatch. Execution itself is at-least-once: a crash mid-task is retried, so the same task body can run more than once. Design tasks to be idempotent — see the failure model.