liminal_server/cluster/membership.rs
1//! SRV-005 R2/R3/R4 + SRV-008: cluster membership driven by beamr's ORDERED
2//! connection events, plus the [`start`] entry point and the [`ClusterHandle`]
3//! that owns the cluster's background resources.
4//!
5//! ## The source: an atomic initial view, then ordered deltas
6//!
7//! Membership is TOLD, never sampled. [`Membership::new`] arms
8//! `ConnectionManager::subscribe_connection_events_with_snapshot`, beamr's
9//! blessed late-subscriber path. Before that call returns, our callback is
10//! invoked with a synthetic `Up` for every currently live peer, and the
11//! registration completes — all under beamr's event-dispatch gate, so no real
12//! event can interleave between the snapshot and the registration. The tracker
13//! therefore starts from a race-free initial view and continues on an ordered
14//! stream that misses no session and repeats none:
15//!
16//! * INV-ALTERNATION — per node the delivered events are `Up(g1) Down(g1)
17//! Up(g2) …` with strictly increasing generations, so a set insert on `Up` and
18//! a set remove on `Down` is the whole state machine.
19//! * INV-EXACTLY-ONCE — one `Up` and one `Down` per generation, so there are no
20//! duplicates to dedupe and nothing to coalesce.
21//! * INV-SYNC — delivery is synchronous with the transition, so the tracked set
22//! is already correct when the call that caused the transition returns.
23//!
24//! ## Why we still never take beamr's single connection-down slot
25//!
26//! Beamr's connection manager has a SINGLE legacy connection-down callback slot,
27//! and the scheduler already owns it: on node down it calls
28//! `PgRegistry::purge_remote_node`, which is exactly the R6 remote-subscription
29//! cleanup this cluster needs for free. Registering our own callback would
30//! REPLACE that one and break R6. Membership never touches the slot; it uses the
31//! multi-subscriber hub instead, and INV-SCHED-FIRST guarantees the scheduler's
32//! composed subscriber (pg-purge included) runs before ours — so a peer's remote
33//! pg members are already purged by the time we observe its departure.
34//!
35//! ## Why the callback hands off instead of acting
36//!
37//! beamr's INV-SUB-DISCIPLINE binds every subscriber: callbacks MUST NOT block,
38//! MUST NOT perform socket I/O, and MUST capture only `Weak` handles — "a
39//! blocked callback stalls reads, writes, heartbeats, accepts AND concurrent
40//! transition callers for EVERY peer". R5's join backfill
41//! ([`ClusterSync::on_peer_join`]) writes frames to the newcomer's socket, so it
42//! categorically cannot run on the delivery thread.
43//!
44//! So the callback does the smallest possible amount of work — update the peer
45//! set under a short-hold mutex (explicitly permitted), push the resulting delta
46//! onto a local FIFO, and signal a condvar — and a consumer thread owned by this
47//! module runs the logging and the backfill off the delivery thread.
48//!
49//! ### Overflow rule: lossless, unbounded, loud depth
50//!
51//! The handoff FIFO never drops and never blocks its producer, and that pair of
52//! constraints forces it to be unbounded:
53//!
54//! * Dropping is inadmissible. beamr's INV-NO-REPLAY means a discarded join
55//! delta is gone forever — the newcomer would permanently miss its R5
56//! backfill — and a discarded leave delta would permanently corrupt the
57//! tracked set.
58//! * Blocking the producer is forbidden by INV-SUB-DISCIPLINE, and would stall
59//! every peer's I/O, not just this one's.
60//!
61//! Growth is bounded in practice because an entry is produced only by a real
62//! connection-table transition and the consumer does nothing but drain. The
63//! residual risk is disclosed rather than silent: [`Membership::queue_high_water`]
64//! reports the deepest the FIFO has ever been, and the consumer warns once if a
65//! single drain ever exceeds [`EFFECT_QUEUE_WARN_DEPTH`].
66//!
67//! The authoritative peer set is NOT behind the FIFO — it is mutated in the
68//! callback — so [`Membership::peers`] cannot be skewed by consumer lag. The
69//! FIFO carries only the effects: join logging, R5 backfill, leave logging.
70
71use std::collections::{BTreeSet, VecDeque};
72use std::net::SocketAddr;
73use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
74use std::sync::{Arc, Condvar, Mutex, PoisonError, Weak};
75use std::thread::JoinHandle;
76
77use beamr::atom::{Atom, AtomTable};
78use beamr::distribution::connection::{AcceptHandle, ConnectionManager};
79use beamr::distribution::connection_events::{ConnectionEvent, SubscriberId};
80use beamr::scheduler::Scheduler;
81
82use crate::ServerError;
83use crate::cluster::discovery::{self, ClusterResolver};
84use crate::cluster::sync::ClusterSync;
85use crate::config::types::ClusterConfig;
86
87/// Depth at which a single drain of the membership effect FIFO is reported as
88/// pathological. Not a capacity: the queue is lossless and nothing is discarded
89/// at or above this depth — it exists so unbounded growth is loud instead of
90/// silent. A real cluster produces one entry per connection transition, so a
91/// drain this deep means the consumer is being starved and an operator should
92/// know.
93const EFFECT_QUEUE_WARN_DEPTH: usize = 1024;
94
95/// A membership transition carried from the event source to the consumer.
96///
97/// beamr delivers one transition at a time (INV-ALTERNATION, INV-EXACTLY-ONCE),
98/// so a delta produced by the source names exactly one peer on exactly one side.
99/// The batching shape is retained because it is the consumer-facing surface and
100/// the initial view legitimately yields several.
101#[derive(Clone, Debug, Default, PartialEq, Eq)]
102pub struct MembershipDelta {
103 /// Peers that joined.
104 pub joined: Vec<Atom>,
105 /// Peers that left.
106 pub left: Vec<Atom>,
107}
108
109impl MembershipDelta {
110 /// True when no peer joined or left.
111 #[must_use]
112 pub fn is_empty(&self) -> bool {
113 self.joined.is_empty() && self.left.is_empty()
114 }
115}
116
117/// Tracks cluster peers from beamr's ordered connection-event stream.
118///
119/// Cloning shares one tracker: the arm, the peer set, the effect FIFO, and the
120/// counters all live behind a single `Arc`.
121#[derive(Clone)]
122pub struct Membership {
123 inner: Arc<MembershipInner>,
124}
125
126/// The pending effects and the consumer's terminal flag, under one mutex so a
127/// single condvar covers both "work arrived" and "shut down".
128#[derive(Default)]
129struct EffectQueue {
130 pending: VecDeque<MembershipDelta>,
131 shutdown: bool,
132}
133
134struct MembershipInner {
135 connections: ConnectionManager,
136 atoms: Arc<AtomTable>,
137 /// The authoritative peer set. Written in the subscriber callback under this
138 /// short-hold mutex so readers are correct the instant a transition returns.
139 peers: Mutex<BTreeSet<Atom>>,
140 queue: Mutex<EffectQueue>,
141 wake: Condvar,
142 /// The live subscription, taken on shutdown so unsubscribing is idempotent.
143 subscription: Mutex<Option<SubscriberId>>,
144 events_observed: AtomicU64,
145 consumer_wakes: AtomicU64,
146 source_snapshots: AtomicU64,
147 queue_high_water: AtomicUsize,
148 depth_warned: AtomicBool,
149}
150
151impl MembershipInner {
152 /// The subscriber callback body. INV-SUB-DISCIPLINE: no blocking, no socket
153 /// I/O, no logging, no allocation beyond one small delta — only two
154 /// short-hold mutexes, a few relaxed atomics, and a condvar signal.
155 fn observe(&self, event: ConnectionEvent) {
156 self.events_observed.fetch_add(1, Ordering::Relaxed);
157 let node = event.node();
158 let delta = {
159 let mut tracked = self.peers.lock().unwrap_or_else(PoisonError::into_inner);
160 match event {
161 ConnectionEvent::Up(_) if tracked.insert(node) => MembershipDelta {
162 joined: vec![node],
163 left: Vec::new(),
164 },
165 ConnectionEvent::Down(_) if tracked.remove(&node) => MembershipDelta {
166 joined: Vec::new(),
167 left: vec![node],
168 },
169 // INV-ALTERNATION rules the redundant cases out upstream; if one
170 // ever arrives the set is already right and there is no effect to
171 // run, so it is absorbed rather than double-counted.
172 _ => return,
173 }
174 };
175 let depth = {
176 let mut queue = self.queue.lock().unwrap_or_else(PoisonError::into_inner);
177 queue.pending.push_back(delta);
178 queue.pending.len()
179 };
180 self.queue_high_water.fetch_max(depth, Ordering::Relaxed);
181 self.wake.notify_one();
182 }
183}
184
185impl Drop for MembershipInner {
186 /// Detach from the event hub when the last tracker handle goes away, so a
187 /// dropped tracker leaves no registration behind on a still-live manager.
188 fn drop(&mut self) {
189 let id = self
190 .subscription
191 .lock()
192 .unwrap_or_else(PoisonError::into_inner)
193 .take();
194 if let Some(id) = id {
195 self.connections.unsubscribe_connection_events(id);
196 }
197 }
198}
199
200impl std::fmt::Debug for Membership {
201 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202 formatter
203 .debug_struct("Membership")
204 .field("peer_count", &self.peers().len())
205 .finish()
206 }
207}
208
209impl Membership {
210 /// Arms a membership tracker on `connections`.
211 ///
212 /// The subscription is established here, not later: before this returns, the
213 /// callback has been handed a synthetic `Up` for every live peer under
214 /// beamr's dispatch gate, so the tracker's initial view is atomic with
215 /// respect to the ordered stream that follows. Peers already connected by
216 /// seed discovery therefore appear immediately, and their join effects are
217 /// queued for the consumer.
218 #[must_use]
219 pub fn new(connections: ConnectionManager, atoms: Arc<AtomTable>) -> Self {
220 let inner = Arc::new(MembershipInner {
221 connections,
222 atoms,
223 peers: Mutex::new(BTreeSet::new()),
224 queue: Mutex::new(EffectQueue::default()),
225 wake: Condvar::new(),
226 subscription: Mutex::new(None),
227 events_observed: AtomicU64::new(0),
228 consumer_wakes: AtomicU64::new(0),
229 source_snapshots: AtomicU64::new(0),
230 queue_high_water: AtomicUsize::new(0),
231 depth_warned: AtomicBool::new(false),
232 });
233
234 // Weak, never Arc: INV-SUB-DISCIPLINE requires it, and it is also what
235 // breaks the cycle (the manager owns the hub, the hub owns this
236 // callback, and this tracker owns a handle to the manager).
237 let weak: Weak<MembershipInner> = Arc::downgrade(&inner);
238 inner.source_snapshots.fetch_add(1, Ordering::Relaxed);
239 let id = inner
240 .connections
241 .subscribe_connection_events_with_snapshot(move |event| {
242 if let Some(inner) = weak.upgrade() {
243 inner.observe(event);
244 }
245 });
246 *inner
247 .subscription
248 .lock()
249 .unwrap_or_else(PoisonError::into_inner) = Some(id);
250
251 Self { inner }
252 }
253
254 /// The currently-tracked peers, sorted by atom index.
255 #[must_use]
256 pub fn peers(&self) -> Vec<Atom> {
257 self.lock_peers().iter().copied().collect()
258 }
259
260 /// The currently-tracked peers as resolved node-name strings.
261 #[must_use]
262 pub fn peer_names(&self) -> Vec<String> {
263 // Rendered through the same fallback as `Membership::name`, never
264 // filtered: dropping an unresolvable peer made every log line built from
265 // this under-report the cluster, turning a name-resolution problem into
266 // an apparently smaller but healthy cluster.
267 self.peers()
268 .into_iter()
269 .map(|peer| self.name(peer))
270 .collect()
271 }
272
273 /// How many connection events this tracker has been handed. Flat means the
274 /// backend told us nothing, which is the only reason membership may be flat.
275 #[must_use]
276 pub fn events_observed(&self) -> u64 {
277 self.inner.events_observed.load(Ordering::Relaxed)
278 }
279
280 /// How many times the effect FIFO has been drained — the consumer-side wake
281 /// count. A drain runs only because a delta was pushed or shutdown was
282 /// signalled; spurious condvar wakeups are absorbed by the wait predicate and
283 /// perform no work, so they are not counted. Includes the one synchronous
284 /// drain that applies the initial view at bring-up.
285 #[must_use]
286 pub fn consumer_wakes(&self) -> u64 {
287 self.inner.consumer_wakes.load(Ordering::Relaxed)
288 }
289
290 /// How many times this tracker has asked the backend for an initial view.
291 /// Exactly one per arm, for the lifetime of the tracker — there is no
292 /// cadence, so this never grows again.
293 #[must_use]
294 pub fn source_snapshots(&self) -> u64 {
295 self.inner.source_snapshots.load(Ordering::Relaxed)
296 }
297
298 /// The deepest the lossless effect FIFO has ever been. The disclosed bound on
299 /// the unbounded-queue tradeoff.
300 #[must_use]
301 pub fn queue_high_water(&self) -> usize {
302 self.inner.queue_high_water.load(Ordering::Relaxed)
303 }
304
305 /// Effects queued but not yet applied by the consumer.
306 #[must_use]
307 pub fn pending_effects(&self) -> usize {
308 self.lock_queue().pending.len()
309 }
310
311 /// Blocks until there is work or shutdown. `None` means the consumer is done:
312 /// shutdown was signalled and every queued effect has already been handed
313 /// out. There is no timeout and no flag sampling — the thread is TOLD.
314 fn wait_for_effects(&self) -> Option<Vec<MembershipDelta>> {
315 let mut queue = self
316 .inner
317 .wake
318 .wait_while(self.lock_queue(), |queue| {
319 queue.pending.is_empty() && !queue.shutdown
320 })
321 .unwrap_or_else(PoisonError::into_inner);
322 self.inner.consumer_wakes.fetch_add(1, Ordering::Relaxed);
323 if queue.pending.is_empty() {
324 return None;
325 }
326 Some(queue.pending.drain(..).collect())
327 }
328
329 /// Takes whatever is queued right now without blocking, counting the pass.
330 /// Used once at bring-up to apply the atomic initial view on the starting
331 /// thread, before the continuation is consumed.
332 fn take_pending(&self) -> Vec<MembershipDelta> {
333 self.inner.consumer_wakes.fetch_add(1, Ordering::Relaxed);
334 self.lock_queue().pending.drain(..).collect()
335 }
336
337 /// Signals the consumer to finish and wakes it, even with nothing pending.
338 fn signal_shutdown(&self) {
339 self.lock_queue().shutdown = true;
340 self.inner.wake.notify_all();
341 }
342
343 /// Detaches from the event hub. Idempotent.
344 fn unsubscribe(&self) {
345 let id = self
346 .inner
347 .subscription
348 .lock()
349 .unwrap_or_else(PoisonError::into_inner)
350 .take();
351 if let Some(id) = id {
352 self.inner.connections.unsubscribe_connection_events(id);
353 }
354 }
355
356 fn warn_once_on_depth(&self, depth: usize) {
357 if depth >= EFFECT_QUEUE_WARN_DEPTH
358 && !self.inner.depth_warned.swap(true, Ordering::Relaxed)
359 {
360 tracing::warn!(
361 depth,
362 high_water = self.queue_high_water(),
363 "cluster membership effect queue is unusually deep; the queue is \
364 lossless so nothing was discarded, but the consumer is being starved"
365 );
366 }
367 }
368
369 fn name(&self, peer: Atom) -> String {
370 self.inner
371 .atoms
372 .resolve(peer)
373 .map_or_else(|| format!("<atom {peer:?}>"), str::to_owned)
374 }
375
376 fn lock_peers(&self) -> std::sync::MutexGuard<'_, BTreeSet<Atom>> {
377 self.inner
378 .peers
379 .lock()
380 .unwrap_or_else(PoisonError::into_inner)
381 }
382
383 fn lock_queue(&self) -> std::sync::MutexGuard<'_, EffectQueue> {
384 self.inner
385 .queue
386 .lock()
387 .unwrap_or_else(PoisonError::into_inner)
388 }
389}
390
391/// Owns the cluster's live background resources. Dropping it stops the membership
392/// consumer, detaches from the event source, and tears down the inbound
393/// distribution listener.
394pub struct ClusterHandle {
395 accept: AcceptHandle,
396 consumer: Option<MembershipConsumer>,
397 membership: Membership,
398 /// The runtime that drove cluster bring-up and that the inbound accept loop
399 /// keeps running on. It MUST outlive the listener: the accept and per-link
400 /// read tasks are spawned onto this runtime's handle, so dropping it would
401 /// abort them and silently stop accepting peers. Kept here so it lives for
402 /// the cluster's whole lifetime. Dropped last (fields drop in declaration
403 /// order) so the listener and consumer wind down before the runtime does.
404 _runtime: Arc<tokio::runtime::Runtime>,
405}
406
407impl std::fmt::Debug for ClusterHandle {
408 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
409 formatter
410 .debug_struct("ClusterHandle")
411 .field("listen_addr", &self.accept.local_addr())
412 .field("membership", &self.membership)
413 .finish_non_exhaustive()
414 }
415}
416
417impl ClusterHandle {
418 /// The address the distribution listener bound for inbound peer links.
419 #[must_use]
420 pub fn listen_addr(&self) -> SocketAddr {
421 self.accept.local_addr()
422 }
423
424 /// The membership tracker, for inspection and tests.
425 #[must_use]
426 pub const fn membership(&self) -> &Membership {
427 &self.membership
428 }
429
430 /// Stops the membership consumer, detaches from the event source, and stops
431 /// the inbound listener. Idempotent.
432 pub fn shutdown(&mut self) {
433 if let Some(consumer) = self.consumer.take() {
434 consumer.stop();
435 }
436 self.membership.unsubscribe();
437 self.accept.shutdown();
438 }
439}
440
441impl Drop for ClusterHandle {
442 fn drop(&mut self) {
443 self.shutdown();
444 }
445}
446
447/// The thread that applies membership effects off beamr's delivery thread.
448///
449/// It owns no timer and samples no flag: it blocks on the tracker's condvar and
450/// runs only when a delta is pushed or shutdown is signalled.
451struct MembershipConsumer {
452 membership: Membership,
453 handle: Option<JoinHandle<()>>,
454}
455
456impl MembershipConsumer {
457 /// Applies whatever the atomic initial view produced, on the CALLING thread,
458 /// before any continuation is consumed — so bring-up logs the established
459 /// membership and backfills each seed before the consumer thread exists.
460 fn prime(membership: &Membership, sync: &ClusterSync) {
461 for delta in membership.take_pending() {
462 apply_delta(membership, sync, delta);
463 }
464 }
465
466 /// Spawns the consumer for the ordered continuation.
467 fn start(membership: Membership, sync: ClusterSync) -> Self {
468 let membership_for_thread = membership.clone();
469 let handle = match std::thread::Builder::new()
470 .name("liminal-cluster-membership".to_owned())
471 .spawn(move || {
472 run_consumer(&membership_for_thread, &sync);
473 }) {
474 Ok(handle) => Some(handle),
475 Err(error) => {
476 // Control flow unchanged (a None handle, as before): the
477 // cluster runs on without a consumer, but that must be loud —
478 // no membership effect will ever be applied.
479 tracing::error!(
480 %error,
481 "failed to spawn cluster membership consumer thread; membership effects will not be applied"
482 );
483 None
484 }
485 };
486 Self { membership, handle }
487 }
488
489 /// Wakes the consumer even with nothing pending, and joins it.
490 fn stop(mut self) {
491 self.membership.signal_shutdown();
492 if let Some(handle) = self.handle.take() {
493 if handle.join().is_err() {
494 tracing::warn!("cluster membership consumer thread panicked");
495 }
496 }
497 }
498}
499
500fn run_consumer(membership: &Membership, sync: &ClusterSync) {
501 while let Some(batch) = membership.wait_for_effects() {
502 membership.warn_once_on_depth(batch.len());
503 for delta in batch {
504 apply_delta(membership, sync, delta);
505 }
506 }
507}
508
509/// Logs and dispatches a single membership delta (R3/R4/R5).
510///
511/// The one funnel every membership fact passes through, whether it came from the
512/// atomic initial view or from an ordered delta. Runs on the consumer, never on
513/// beamr's delivery thread — `on_peer_join` writes to a socket, which
514/// INV-SUB-DISCIPLINE forbids in a subscriber callback.
515fn apply_delta(membership: &Membership, sync: &ClusterSync, delta: MembershipDelta) {
516 for peer in delta.joined {
517 let name = membership.name(peer);
518 tracing::info!(peer = %name, peers = ?membership.peer_names(), "cluster peer joined");
519 sync.peer_changed(&name, true);
520 // R5: re-advertise our local subscriptions to the newcomer — a fresh
521 // pg.join only broadcasts on the insert edge, so a node that joins after
522 // our subscribers already registered would otherwise never learn them.
523 sync.on_peer_join(peer);
524 }
525 for peer in delta.left {
526 let name = membership.name(peer);
527 // R4: a lost peer is a warning; R6 cleanup of its remote pg members has
528 // already happened via beamr's connection-down hook (purge_remote_node),
529 // which INV-SCHED-FIRST guarantees ran before this subscriber saw the
530 // event at all.
531 tracing::warn!(peer = %name, peers = ?membership.peer_names(), "cluster peer left");
532 sync.peer_changed(&name, false);
533 sync.on_peer_leave(peer);
534 }
535}
536
537/// Starts clustering on the channel-supervisor `scheduler` (SRV-005).
538///
539/// Steps, in order:
540/// 1. Bind the inbound distribution listener (so peers can dial us) BEFORE we
541/// dial seeds, mirroring beamr's own bring-up order.
542/// 2. Dial each configured seed (R1); an unreachable seed is non-fatal, but if
543/// seeds were configured and none was reachable we return
544/// [`ServerError::ClusterJoin`].
545/// 3. Arm the membership event source and build the subscription sync, install
546/// sync as the channel-supervisor's observer, apply the atomic initial view,
547/// and start the consumer for the ordered continuation.
548///
549/// `resolver` MUST be the same [`ClusterResolver`] handed to the scheduler's
550/// `DistributionConfig` (so handshake-learned names resolve everywhere).
551///
552/// `on_established` is invoked exactly once, on the success path, at the moment
553/// this node's cluster machinery is up: the listener is bound, the seed-dial pass
554/// has completed under the non-fatal policy above (zero seeds is a valid
555/// single-node bootstrap), and membership plus sync are built and installed. It
556/// signals per-node cluster readiness (G2) and is NOT called on any error path.
557///
558/// # Errors
559/// Returns [`ServerError::ClusterJoin`] when the listener cannot bind or when no
560/// configured seed was reachable.
561pub fn start(
562 scheduler: &Arc<Scheduler>,
563 resolver: Arc<ClusterResolver>,
564 config: &ClusterConfig,
565 install_observer: impl FnOnce(ClusterSync),
566 on_established: impl FnOnce(),
567) -> Result<ClusterHandle, ServerError> {
568 // Typed absence (beamr 0.14 honest-None surface): a scheduler composed
569 // WITHOUT distribution cannot join a cluster — refused at bring-up, the
570 // same refuse-at-birth posture readiness composition uses (plan §2).
571 let connections =
572 scheduler
573 .try_distribution_connections()
574 .ok_or_else(|| ServerError::ClusterJoin {
575 message: "scheduler was composed without a distribution service; \
576 cluster membership requires one"
577 .to_owned(),
578 })?;
579 let atoms = Arc::clone(scheduler.atom_table());
580 let pg = scheduler.pg_registry();
581 let local_node = atoms.intern(&config.node_name);
582
583 // Register a synthetic dial label per seed onto the SHARED resolver the
584 // scheduler already uses, so seed dialing resolves on that same instance.
585 let labels = discovery::register_seed_labels(&resolver, &config.seed_nodes);
586
587 // A multi-thread runtime that drives cluster bring-up AND stays alive for the
588 // cluster's lifetime: the inbound accept loop and the per-link read tasks are
589 // spawned onto this runtime, so it must outlive the listener. A current-thread
590 // runtime would also deadlock the bring-up handshake (the outbound connect and
591 // the inbound accept must interleave reads/writes concurrently).
592 let runtime = Arc::new(
593 tokio::runtime::Builder::new_multi_thread()
594 .worker_threads(2)
595 .enable_all()
596 .build()
597 .map_err(|error| ServerError::ClusterJoin {
598 message: format!("failed to build cluster runtime: {error}"),
599 })?,
600 );
601 // Bind this runtime to the distribution connection manager so the accept and
602 // read lifecycle tasks run on it (and survive for the cluster's lifetime),
603 // rather than on any transient ambient runtime.
604 connections.set_runtime_handle(runtime.handle().clone());
605
606 let accept = runtime
607 .block_on(scheduler.start_distribution_listener(config.listen_address))
608 .map_err(|error| ServerError::ClusterJoin {
609 message: format!(
610 "failed to bind cluster distribution listener on {}: {error}",
611 config.listen_address
612 ),
613 })?;
614
615 let outcome = runtime.block_on(discovery::connect_seeds(
616 &connections,
617 &resolver,
618 &atoms,
619 &labels,
620 ));
621 if !outcome.is_satisfied() {
622 return Err(ServerError::ClusterJoin {
623 message: format!(
624 "no configured seed node was reachable ({} attempted)",
625 outcome.attempted
626 ),
627 });
628 }
629
630 // Arming the source captures the peers seed discovery just established as an
631 // atomic initial view (synthetic Ups delivered under beamr's dispatch gate),
632 // and queues their join effects for the consumer.
633 let membership = Membership::new(connections.clone(), Arc::clone(&atoms));
634 let sync = ClusterSync::new(pg, Arc::clone(&atoms), connections, local_node, resolver);
635 install_observer(sync.clone());
636
637 // Apply the initial view synchronously, before any continuation is consumed:
638 // log the initial membership (R2) and backfill our state to each peer (R5).
639 MembershipConsumer::prime(&membership, &sync);
640 tracing::info!(
641 node_name = %config.node_name,
642 peers = ?membership.peer_names(),
643 "cluster membership established"
644 );
645
646 // G2: the node's cluster stack is now up (listener bound, seed-dial pass
647 // done, membership + sync installed). Signal established readiness. This is
648 // per-node liveness of the cluster machinery, NOT quorum: a single-node
649 // bootstrap with zero reachable peers is legitimately established.
650 on_established();
651
652 let consumer = MembershipConsumer::start(membership.clone(), sync);
653 Ok(ClusterHandle {
654 accept,
655 consumer: Some(consumer),
656 membership,
657 _runtime: runtime,
658 })
659}
660
661#[cfg(test)]
662#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
663mod tests {
664 use super::{Membership, MembershipConsumer, MembershipDelta};
665 use beamr::atom::AtomTable;
666 use beamr::distribution::connection::{AcceptHandle, ConnectionManager};
667 use beamr::distribution::connection_events::{ConnectionEvent, ConnectionGeneration};
668 use beamr::distribution::resolver::StaticResolver;
669 use std::collections::HashMap;
670 use std::sync::Arc;
671
672 const COOKIE: &str = "srv008-membership-cookie";
673 const DIALER_NAME: &str = "dialer@127.0.0.1";
674 const PEER_NAME: &str = "peer@127.0.0.1";
675
676 fn empty_manager(atoms: &Arc<AtomTable>) -> ConnectionManager {
677 ConnectionManager::new(
678 Arc::clone(atoms),
679 Arc::new(StaticResolver::new(HashMap::new())),
680 "test-cookie",
681 "local@127.0.0.1",
682 1,
683 )
684 }
685
686 /// A live loopback pair of REAL connection managers — real sockets, real OTP
687 /// handshake, no mock. `dialer` is the manager whose membership the tests arm;
688 /// the peer just listens.
689 ///
690 /// This is the deterministic barrier SRV-008 needs. beamr's INV-SYNC says that
691 /// when the call causing a transition returns, every event it produced has
692 /// already been delivered to every subscriber — so a `connect` that returns Ok
693 /// is, on the DIALER's side, a completed delivery of the session's `Up`. No
694 /// sleep and no retry loop are needed to observe a membership change.
695 struct LivePair {
696 runtime: tokio::runtime::Runtime,
697 dialer: ConnectionManager,
698 dialer_atoms: Arc<AtomTable>,
699 /// Kept alive: dropping the peer manager or its accept handle would tear
700 /// the link down underneath the assertions.
701 _peer: ConnectionManager,
702 _accept: AcceptHandle,
703 }
704
705 impl LivePair {
706 fn new() -> Self {
707 let runtime = tokio::runtime::Builder::new_multi_thread()
708 .worker_threads(2)
709 .enable_all()
710 .build()
711 .expect("build loopback test runtime");
712
713 let peer_atoms = Arc::new(AtomTable::with_common_atoms());
714 let peer = ConnectionManager::new(
715 peer_atoms,
716 Arc::new(StaticResolver::new(HashMap::new())),
717 COOKIE,
718 PEER_NAME,
719 1,
720 );
721 peer.set_runtime_handle(runtime.handle().clone());
722 let accept = runtime
723 .block_on(peer.listen("127.0.0.1:0".parse().expect("loopback addr")))
724 .expect("peer binds a loopback listener");
725
726 let mut routes = HashMap::new();
727 routes.insert(PEER_NAME.to_owned(), accept.local_addr());
728 let dialer_atoms = Arc::new(AtomTable::with_common_atoms());
729 let dialer = ConnectionManager::new(
730 Arc::clone(&dialer_atoms),
731 Arc::new(StaticResolver::new(routes)),
732 COOKIE,
733 DIALER_NAME,
734 1,
735 );
736 dialer.set_runtime_handle(runtime.handle().clone());
737
738 Self {
739 runtime,
740 dialer,
741 dialer_atoms,
742 _peer: peer,
743 _accept: accept,
744 }
745 }
746
747 /// Dials the peer and returns once the session is installed in the
748 /// dialer's table AND its connection event has been delivered (INV-SYNC).
749 fn connect(&self) {
750 self.runtime
751 .block_on(self.dialer.connect(PEER_NAME))
752 .expect("loopback handshake succeeds");
753 }
754
755 /// Closes the dialer's link. INV-SYNC again: `disconnect_node` returns
756 /// only after the `Down` has reached every subscriber.
757 fn disconnect(&self) {
758 let node = self.dialer_atoms.intern(PEER_NAME);
759 assert!(
760 self.dialer.disconnect_node(node),
761 "the live loopback link must be closable"
762 );
763 }
764 }
765
766 #[test]
767 fn delta_is_empty_by_default() {
768 assert!(MembershipDelta::default().is_empty());
769 }
770
771 /// A consumer thread that panicked must not be reaped silently: `stop()`'s
772 /// join sees the Err and must warn.
773 #[test]
774 fn stop_warns_when_the_consumer_thread_panicked() {
775 let atoms = Arc::new(AtomTable::with_common_atoms());
776 let membership = Membership::new(empty_manager(&atoms), Arc::clone(&atoms));
777 // Stand a panicked thread in for the consumer: join() yields the same
778 // Err a panicking run_consumer would produce.
779 let handle = std::thread::spawn(|| panic!("simulated consumer panic"));
780 while !handle.is_finished() {
781 std::thread::yield_now();
782 }
783 let consumer = MembershipConsumer {
784 membership,
785 handle: Some(handle),
786 };
787
788 let log = crate::test_log::CapturedLog::default();
789 log.capture(|| consumer.stop());
790
791 let output = log.contents();
792 assert!(
793 output.contains("WARN") && output.contains("membership consumer thread panicked"),
794 "expected a warn about the panicked consumer thread, got: {output}"
795 );
796 }
797
798 /// SRV-008 R5 tombstone replacement. `first_poll_of_empty_table_yields_no_peers`
799 /// asserted the retired sampler's contract directly; the empty-initial-view
800 /// coverage it carried is preserved here through the event source instead.
801 /// Arming over an empty table must yield an empty view, no queued effects,
802 /// and exactly one initial-view acquisition — never a repeated one.
803 #[test]
804 fn empty_initial_view_yields_no_peers() {
805 let atoms = Arc::new(AtomTable::with_common_atoms());
806 let membership = Membership::new(empty_manager(&atoms), Arc::clone(&atoms));
807
808 assert!(membership.peers().is_empty());
809 assert_eq!(
810 membership.events_observed(),
811 0,
812 "an empty table synthesizes no catch-up event"
813 );
814 assert_eq!(membership.pending_effects(), 0);
815 assert_eq!(
816 membership.source_snapshots(),
817 1,
818 "arming asks for the initial view exactly once"
819 );
820 }
821
822 /// The atomic initial view is what makes a late arm safe: a peer that was
823 /// already live before the tracker existed is delivered as a synthetic `Up`
824 /// under beamr's dispatch gate, so the tracker never has to go looking.
825 #[test]
826 fn a_late_arm_sees_a_live_peer_in_its_initial_view() {
827 let pair = LivePair::new();
828 pair.connect();
829
830 let membership = Membership::new(pair.dialer.clone(), Arc::clone(&pair.dialer_atoms));
831
832 assert_eq!(
833 membership.peers().len(),
834 1,
835 "the initial view must contain the peer that was already live"
836 );
837 assert_eq!(membership.events_observed(), 1);
838 assert_eq!(
839 membership.pending_effects(),
840 1,
841 "the initial view's join effect is queued for the consumer, not run \
842 on beamr's delivery thread"
843 );
844 }
845
846 /// INV-ALTERNATION end to end on real sockets: join, leave, and rejoin are
847 /// observed in that order, each released by a real transition rather than by
848 /// a cadence. The tracked set is correct at every step with no waiting.
849 #[test]
850 fn join_leave_and_rejoin_are_observed_in_order() {
851 let pair = LivePair::new();
852 let membership = Membership::new(pair.dialer.clone(), Arc::clone(&pair.dialer_atoms));
853 let peer = pair.dialer_atoms.intern(PEER_NAME);
854
855 pair.connect();
856 assert_eq!(membership.peers(), vec![peer], "join is visible at once");
857
858 pair.disconnect();
859 assert!(
860 membership.peers().is_empty(),
861 "leave is visible at once, with no sampling in between"
862 );
863
864 pair.connect();
865 assert_eq!(membership.peers(), vec![peer], "rejoin is visible at once");
866
867 let effects = membership.take_pending();
868 assert_eq!(
869 effects,
870 vec![
871 MembershipDelta {
872 joined: vec![peer],
873 left: Vec::new(),
874 },
875 MembershipDelta {
876 joined: Vec::new(),
877 left: vec![peer],
878 },
879 MembershipDelta {
880 joined: vec![peer],
881 left: Vec::new(),
882 },
883 ],
884 "the consumer receives the transitions in the order they happened"
885 );
886 assert_eq!(
887 membership.events_observed(),
888 3,
889 "exactly one event per transition — no duplicates to dedupe"
890 );
891 }
892
893 /// The handoff FIFO is lossless: every queued effect survives to the consumer
894 /// and the depth is reported, which is the whole of the documented overflow
895 /// rule (nothing is ever discarded, so there is no drop path to test).
896 #[test]
897 fn queued_effects_are_lossless_and_report_their_depth() {
898 let atoms = Arc::new(AtomTable::with_common_atoms());
899 let membership = Membership::new(empty_manager(&atoms), Arc::clone(&atoms));
900
901 for index in 0_u64..64 {
902 let peer = atoms.intern(&format!("peer-{index}@127.0.0.1"));
903 membership.inner.observe(ConnectionEvent::up(
904 peer,
905 ConnectionGeneration::from_raw(index + 1),
906 1,
907 ));
908 }
909
910 assert_eq!(
911 membership.pending_effects(),
912 64,
913 "no membership effect may be discarded"
914 );
915 assert_eq!(membership.queue_high_water(), 64);
916 assert_eq!(membership.peers().len(), 64);
917
918 let drained = membership.take_pending();
919 assert_eq!(
920 drained.len(),
921 64,
922 "every queued effect reaches the consumer"
923 );
924 assert_eq!(membership.pending_effects(), 0);
925 assert_eq!(
926 membership.queue_high_water(),
927 64,
928 "the high-water mark is a disclosure, not a counter that resets"
929 );
930 }
931
932 /// A redundant event cannot double-count: the set is already right, so there
933 /// is no effect to run and nothing is queued. INV-ALTERNATION rules these out
934 /// upstream; this pins that liminal absorbs rather than amplifies one.
935 #[test]
936 fn a_redundant_event_queues_no_effect() {
937 let atoms = Arc::new(AtomTable::with_common_atoms());
938 let membership = Membership::new(empty_manager(&atoms), Arc::clone(&atoms));
939 let peer = atoms.intern(PEER_NAME);
940
941 membership.inner.observe(ConnectionEvent::up(
942 peer,
943 ConnectionGeneration::from_raw(1),
944 1,
945 ));
946 membership.inner.observe(ConnectionEvent::up(
947 peer,
948 ConnectionGeneration::from_raw(2),
949 1,
950 ));
951
952 assert_eq!(membership.peers(), vec![peer]);
953 assert_eq!(
954 membership.pending_effects(),
955 1,
956 "a repeated Up for a tracked peer must not queue a second join effect"
957 );
958 }
959
960 /// R3: explicit shutdown wakes and releases the consumer even when no
961 /// membership event is pending. Nothing polls a stop flag — the waiter is
962 /// told.
963 #[test]
964 fn shutdown_wakes_a_consumer_with_nothing_pending() {
965 let atoms = Arc::new(AtomTable::with_common_atoms());
966 let membership = Membership::new(empty_manager(&atoms), Arc::clone(&atoms));
967 assert_eq!(membership.pending_effects(), 0, "nothing is pending");
968
969 let waiter = membership.clone();
970 let joined = std::thread::spawn(move || waiter.wait_for_effects());
971 membership.signal_shutdown();
972
973 assert!(
974 joined.join().expect("the waiter thread joins").is_none(),
975 "shutdown must release a consumer that has no work"
976 );
977 }
978
979 /// SRV-008 deletion check — absence proof over this module's own production
980 /// source, mirroring the accept-path guard in `listener.rs`. The retired
981 /// polling family must never come back.
982 #[test]
983 fn membership_source_has_no_retired_poll_family() {
984 const SOURCE: &str = include_str!("membership.rs");
985 let production = SOURCE.split("mod tests").next().unwrap_or(SOURCE);
986 for forbidden in [
987 "POLL_INTERVAL",
988 "poll_once",
989 "thread::sleep",
990 "PollLoop",
991 "run_poll_loop",
992 "connected_nodes",
993 ] {
994 assert!(
995 !production.contains(forbidden),
996 "retired membership poll-family source `{forbidden}` reappeared"
997 );
998 }
999 }
1000
1001 /// RED PIN (SRV-008 R3) — the polling-cadence defect, pinned.
1002 ///
1003 /// A membership tracker is armed over a real connection manager BEFORE any
1004 /// peer link exists. Then a peer link is genuinely established: `connect`
1005 /// returns only after beamr has installed the session and delivered its
1006 /// connection event to every subscriber (INV-SYNC). An event-driven tracker
1007 /// has therefore already been TOLD, and reports the peer with no sleep, no
1008 /// retry, and no call into a sampling entry point.
1009 ///
1010 /// The polling tracker cannot: its set only moves when someone samples the
1011 /// connection table, so on the current implementation this reports zero peers
1012 /// and the membership change is observable only at the next 250ms tick. That
1013 /// gap IS the defect SRV-008 retires.
1014 #[test]
1015 fn armed_membership_observes_a_join_without_sampling() {
1016 let pair = LivePair::new();
1017 let membership = Membership::new(pair.dialer.clone(), Arc::clone(&pair.dialer_atoms));
1018
1019 pair.connect();
1020
1021 assert_eq!(
1022 membership.peers().len(),
1023 1,
1024 "an armed membership source must observe the join the instant beamr \
1025 installs it, without any sampling of the connection table"
1026 );
1027 }
1028
1029 #[test]
1030 fn peer_names_resolve_through_the_atom_table() {
1031 let atoms = Arc::new(AtomTable::with_common_atoms());
1032 let membership = Membership::new(empty_manager(&atoms), Arc::clone(&atoms));
1033 // No connections, so no names — but the accessor must not panic.
1034 assert!(membership.peer_names().is_empty());
1035 }
1036
1037 /// `peer_names` DROPS a peer whose atom this table cannot resolve, while
1038 /// `Membership::name` renders the same atom as `<atom N>`. The two disagree,
1039 /// and the disagreement is silent: every log line built from `peer_names`
1040 /// under-reports the cluster, so a three-node cluster with one unresolvable
1041 /// peer reads as a healthy two-node cluster rather than as a resolution
1042 /// problem. Peers arrive as atoms interned by the distribution manager, so
1043 /// an id this table has never seen is exactly the production shape.
1044 #[test]
1045 fn peer_names_render_an_unresolvable_atom_rather_than_dropping_it() {
1046 let atoms = Arc::new(AtomTable::with_common_atoms());
1047 let membership = Membership::new(empty_manager(&atoms), Arc::clone(&atoms));
1048
1049 // An atom minted in a DIFFERENT table: a valid id here, but one this
1050 // table cannot resolve.
1051 let foreign_table = AtomTable::with_common_atoms();
1052 let unresolvable = foreign_table.intern("peer-known-only-to-another-table");
1053 assert!(
1054 atoms.resolve(unresolvable).is_none(),
1055 "the fixture must produce an atom this table cannot resolve"
1056 );
1057 membership.lock_peers().insert(unresolvable);
1058
1059 assert_eq!(
1060 membership.peers().len(),
1061 1,
1062 "the peer is tracked, whatever its name resolves to"
1063 );
1064 assert_eq!(
1065 membership.peer_names(),
1066 vec![membership.name(unresolvable)],
1067 "peer_names must agree with Membership::name and keep the peer"
1068 );
1069 }
1070}