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 self.peers()
264 .into_iter()
265 .filter_map(|peer| self.inner.atoms.resolve(peer).map(str::to_owned))
266 .collect()
267 }
268
269 /// How many connection events this tracker has been handed. Flat means the
270 /// backend told us nothing, which is the only reason membership may be flat.
271 #[must_use]
272 pub fn events_observed(&self) -> u64 {
273 self.inner.events_observed.load(Ordering::Relaxed)
274 }
275
276 /// How many times the effect FIFO has been drained — the consumer-side wake
277 /// count. A drain runs only because a delta was pushed or shutdown was
278 /// signalled; spurious condvar wakeups are absorbed by the wait predicate and
279 /// perform no work, so they are not counted. Includes the one synchronous
280 /// drain that applies the initial view at bring-up.
281 #[must_use]
282 pub fn consumer_wakes(&self) -> u64 {
283 self.inner.consumer_wakes.load(Ordering::Relaxed)
284 }
285
286 /// How many times this tracker has asked the backend for an initial view.
287 /// Exactly one per arm, for the lifetime of the tracker — there is no
288 /// cadence, so this never grows again.
289 #[must_use]
290 pub fn source_snapshots(&self) -> u64 {
291 self.inner.source_snapshots.load(Ordering::Relaxed)
292 }
293
294 /// The deepest the lossless effect FIFO has ever been. The disclosed bound on
295 /// the unbounded-queue tradeoff.
296 #[must_use]
297 pub fn queue_high_water(&self) -> usize {
298 self.inner.queue_high_water.load(Ordering::Relaxed)
299 }
300
301 /// Effects queued but not yet applied by the consumer.
302 #[must_use]
303 pub fn pending_effects(&self) -> usize {
304 self.lock_queue().pending.len()
305 }
306
307 /// Blocks until there is work or shutdown. `None` means the consumer is done:
308 /// shutdown was signalled and every queued effect has already been handed
309 /// out. There is no timeout and no flag sampling — the thread is TOLD.
310 fn wait_for_effects(&self) -> Option<Vec<MembershipDelta>> {
311 let mut queue = self
312 .inner
313 .wake
314 .wait_while(self.lock_queue(), |queue| {
315 queue.pending.is_empty() && !queue.shutdown
316 })
317 .unwrap_or_else(PoisonError::into_inner);
318 self.inner.consumer_wakes.fetch_add(1, Ordering::Relaxed);
319 if queue.pending.is_empty() {
320 return None;
321 }
322 Some(queue.pending.drain(..).collect())
323 }
324
325 /// Takes whatever is queued right now without blocking, counting the pass.
326 /// Used once at bring-up to apply the atomic initial view on the starting
327 /// thread, before the continuation is consumed.
328 fn take_pending(&self) -> Vec<MembershipDelta> {
329 self.inner.consumer_wakes.fetch_add(1, Ordering::Relaxed);
330 self.lock_queue().pending.drain(..).collect()
331 }
332
333 /// Signals the consumer to finish and wakes it, even with nothing pending.
334 fn signal_shutdown(&self) {
335 self.lock_queue().shutdown = true;
336 self.inner.wake.notify_all();
337 }
338
339 /// Detaches from the event hub. Idempotent.
340 fn unsubscribe(&self) {
341 let id = self
342 .inner
343 .subscription
344 .lock()
345 .unwrap_or_else(PoisonError::into_inner)
346 .take();
347 if let Some(id) = id {
348 self.inner.connections.unsubscribe_connection_events(id);
349 }
350 }
351
352 fn warn_once_on_depth(&self, depth: usize) {
353 if depth >= EFFECT_QUEUE_WARN_DEPTH
354 && !self.inner.depth_warned.swap(true, Ordering::Relaxed)
355 {
356 tracing::warn!(
357 depth,
358 high_water = self.queue_high_water(),
359 "cluster membership effect queue is unusually deep; the queue is \
360 lossless so nothing was discarded, but the consumer is being starved"
361 );
362 }
363 }
364
365 fn name(&self, peer: Atom) -> String {
366 self.inner
367 .atoms
368 .resolve(peer)
369 .map_or_else(|| format!("<atom {peer:?}>"), str::to_owned)
370 }
371
372 fn lock_peers(&self) -> std::sync::MutexGuard<'_, BTreeSet<Atom>> {
373 self.inner
374 .peers
375 .lock()
376 .unwrap_or_else(PoisonError::into_inner)
377 }
378
379 fn lock_queue(&self) -> std::sync::MutexGuard<'_, EffectQueue> {
380 self.inner
381 .queue
382 .lock()
383 .unwrap_or_else(PoisonError::into_inner)
384 }
385}
386
387/// Owns the cluster's live background resources. Dropping it stops the membership
388/// consumer, detaches from the event source, and tears down the inbound
389/// distribution listener.
390pub struct ClusterHandle {
391 accept: AcceptHandle,
392 consumer: Option<MembershipConsumer>,
393 membership: Membership,
394 /// The runtime that drove cluster bring-up and that the inbound accept loop
395 /// keeps running on. It MUST outlive the listener: the accept and per-link
396 /// read tasks are spawned onto this runtime's handle, so dropping it would
397 /// abort them and silently stop accepting peers. Kept here so it lives for
398 /// the cluster's whole lifetime. Dropped last (fields drop in declaration
399 /// order) so the listener and consumer wind down before the runtime does.
400 _runtime: Arc<tokio::runtime::Runtime>,
401}
402
403impl std::fmt::Debug for ClusterHandle {
404 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405 formatter
406 .debug_struct("ClusterHandle")
407 .field("listen_addr", &self.accept.local_addr())
408 .field("membership", &self.membership)
409 .finish_non_exhaustive()
410 }
411}
412
413impl ClusterHandle {
414 /// The address the distribution listener bound for inbound peer links.
415 #[must_use]
416 pub fn listen_addr(&self) -> SocketAddr {
417 self.accept.local_addr()
418 }
419
420 /// The membership tracker, for inspection and tests.
421 #[must_use]
422 pub const fn membership(&self) -> &Membership {
423 &self.membership
424 }
425
426 /// Stops the membership consumer, detaches from the event source, and stops
427 /// the inbound listener. Idempotent.
428 pub fn shutdown(&mut self) {
429 if let Some(consumer) = self.consumer.take() {
430 consumer.stop();
431 }
432 self.membership.unsubscribe();
433 self.accept.shutdown();
434 }
435}
436
437impl Drop for ClusterHandle {
438 fn drop(&mut self) {
439 self.shutdown();
440 }
441}
442
443/// The thread that applies membership effects off beamr's delivery thread.
444///
445/// It owns no timer and samples no flag: it blocks on the tracker's condvar and
446/// runs only when a delta is pushed or shutdown is signalled.
447struct MembershipConsumer {
448 membership: Membership,
449 handle: Option<JoinHandle<()>>,
450}
451
452impl MembershipConsumer {
453 /// Applies whatever the atomic initial view produced, on the CALLING thread,
454 /// before any continuation is consumed — so bring-up logs the established
455 /// membership and backfills each seed before the consumer thread exists.
456 fn prime(membership: &Membership, sync: &ClusterSync) {
457 for delta in membership.take_pending() {
458 apply_delta(membership, sync, delta);
459 }
460 }
461
462 /// Spawns the consumer for the ordered continuation.
463 fn start(membership: Membership, sync: ClusterSync) -> Self {
464 let membership_for_thread = membership.clone();
465 let handle = std::thread::Builder::new()
466 .name("liminal-cluster-membership".to_owned())
467 .spawn(move || {
468 run_consumer(&membership_for_thread, &sync);
469 })
470 .ok();
471 Self { membership, handle }
472 }
473
474 /// Wakes the consumer even with nothing pending, and joins it.
475 fn stop(mut self) {
476 self.membership.signal_shutdown();
477 if let Some(handle) = self.handle.take() {
478 let _ = handle.join();
479 }
480 }
481}
482
483fn run_consumer(membership: &Membership, sync: &ClusterSync) {
484 while let Some(batch) = membership.wait_for_effects() {
485 membership.warn_once_on_depth(batch.len());
486 for delta in batch {
487 apply_delta(membership, sync, delta);
488 }
489 }
490}
491
492/// Logs and dispatches a single membership delta (R3/R4/R5).
493///
494/// The one funnel every membership fact passes through, whether it came from the
495/// atomic initial view or from an ordered delta. Runs on the consumer, never on
496/// beamr's delivery thread — `on_peer_join` writes to a socket, which
497/// INV-SUB-DISCIPLINE forbids in a subscriber callback.
498fn apply_delta(membership: &Membership, sync: &ClusterSync, delta: MembershipDelta) {
499 for peer in delta.joined {
500 let name = membership.name(peer);
501 tracing::info!(peer = %name, peers = ?membership.peer_names(), "cluster peer joined");
502 // R5: re-advertise our local subscriptions to the newcomer — a fresh
503 // pg.join only broadcasts on the insert edge, so a node that joins after
504 // our subscribers already registered would otherwise never learn them.
505 sync.on_peer_join(peer);
506 }
507 for peer in delta.left {
508 let name = membership.name(peer);
509 // R4: a lost peer is a warning; R6 cleanup of its remote pg members has
510 // already happened via beamr's connection-down hook (purge_remote_node),
511 // which INV-SCHED-FIRST guarantees ran before this subscriber saw the
512 // event at all.
513 tracing::warn!(peer = %name, peers = ?membership.peer_names(), "cluster peer left");
514 sync.on_peer_leave(peer);
515 }
516}
517
518/// Starts clustering on the channel-supervisor `scheduler` (SRV-005).
519///
520/// Steps, in order:
521/// 1. Bind the inbound distribution listener (so peers can dial us) BEFORE we
522/// dial seeds, mirroring beamr's own bring-up order.
523/// 2. Dial each configured seed (R1); an unreachable seed is non-fatal, but if
524/// seeds were configured and none was reachable we return
525/// [`ServerError::ClusterJoin`].
526/// 3. Arm the membership event source and build the subscription sync, install
527/// sync as the channel-supervisor's observer, apply the atomic initial view,
528/// and start the consumer for the ordered continuation.
529///
530/// `resolver` MUST be the same [`ClusterResolver`] handed to the scheduler's
531/// `DistributionConfig` (so handshake-learned names resolve everywhere).
532///
533/// `on_established` is invoked exactly once, on the success path, at the moment
534/// this node's cluster machinery is up: the listener is bound, the seed-dial pass
535/// has completed under the non-fatal policy above (zero seeds is a valid
536/// single-node bootstrap), and membership plus sync are built and installed. It
537/// signals per-node cluster readiness (G2) and is NOT called on any error path.
538///
539/// # Errors
540/// Returns [`ServerError::ClusterJoin`] when the listener cannot bind or when no
541/// configured seed was reachable.
542pub fn start(
543 scheduler: &Arc<Scheduler>,
544 resolver: Arc<ClusterResolver>,
545 config: &ClusterConfig,
546 install_observer: impl FnOnce(ClusterSync),
547 on_established: impl FnOnce(),
548) -> Result<ClusterHandle, ServerError> {
549 // Typed absence (beamr 0.14 honest-None surface): a scheduler composed
550 // WITHOUT distribution cannot join a cluster — refused at bring-up, the
551 // same refuse-at-birth posture readiness composition uses (plan §2).
552 let connections =
553 scheduler
554 .try_distribution_connections()
555 .ok_or_else(|| ServerError::ClusterJoin {
556 message: "scheduler was composed without a distribution service; \
557 cluster membership requires one"
558 .to_owned(),
559 })?;
560 let atoms = Arc::clone(scheduler.atom_table());
561 let pg = scheduler.pg_registry();
562 let local_node = atoms.intern(&config.node_name);
563
564 // Register a synthetic dial label per seed onto the SHARED resolver the
565 // scheduler already uses, so seed dialing resolves on that same instance.
566 let labels = discovery::register_seed_labels(&resolver, &config.seed_nodes);
567
568 // A multi-thread runtime that drives cluster bring-up AND stays alive for the
569 // cluster's lifetime: the inbound accept loop and the per-link read tasks are
570 // spawned onto this runtime, so it must outlive the listener. A current-thread
571 // runtime would also deadlock the bring-up handshake (the outbound connect and
572 // the inbound accept must interleave reads/writes concurrently).
573 let runtime = Arc::new(
574 tokio::runtime::Builder::new_multi_thread()
575 .worker_threads(2)
576 .enable_all()
577 .build()
578 .map_err(|error| ServerError::ClusterJoin {
579 message: format!("failed to build cluster runtime: {error}"),
580 })?,
581 );
582 // Bind this runtime to the distribution connection manager so the accept and
583 // read lifecycle tasks run on it (and survive for the cluster's lifetime),
584 // rather than on any transient ambient runtime.
585 connections.set_runtime_handle(runtime.handle().clone());
586
587 let accept = runtime
588 .block_on(scheduler.start_distribution_listener(config.listen_address))
589 .map_err(|error| ServerError::ClusterJoin {
590 message: format!(
591 "failed to bind cluster distribution listener on {}: {error}",
592 config.listen_address
593 ),
594 })?;
595
596 let outcome = runtime.block_on(discovery::connect_seeds(
597 &connections,
598 &resolver,
599 &atoms,
600 &labels,
601 ));
602 if !outcome.is_satisfied() {
603 return Err(ServerError::ClusterJoin {
604 message: format!(
605 "no configured seed node was reachable ({} attempted)",
606 outcome.attempted
607 ),
608 });
609 }
610
611 // Arming the source captures the peers seed discovery just established as an
612 // atomic initial view (synthetic Ups delivered under beamr's dispatch gate),
613 // and queues their join effects for the consumer.
614 let membership = Membership::new(connections.clone(), Arc::clone(&atoms));
615 let sync = ClusterSync::new(pg, Arc::clone(&atoms), connections, local_node, resolver);
616 install_observer(sync.clone());
617
618 // Apply the initial view synchronously, before any continuation is consumed:
619 // log the initial membership (R2) and backfill our state to each peer (R5).
620 MembershipConsumer::prime(&membership, &sync);
621 tracing::info!(
622 node_name = %config.node_name,
623 peers = ?membership.peer_names(),
624 "cluster membership established"
625 );
626
627 // G2: the node's cluster stack is now up (listener bound, seed-dial pass
628 // done, membership + sync installed). Signal established readiness. This is
629 // per-node liveness of the cluster machinery, NOT quorum: a single-node
630 // bootstrap with zero reachable peers is legitimately established.
631 on_established();
632
633 let consumer = MembershipConsumer::start(membership.clone(), sync);
634 Ok(ClusterHandle {
635 accept,
636 consumer: Some(consumer),
637 membership,
638 _runtime: runtime,
639 })
640}
641
642#[cfg(test)]
643#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
644mod tests {
645 use super::{Membership, MembershipDelta};
646 use beamr::atom::AtomTable;
647 use beamr::distribution::connection::{AcceptHandle, ConnectionManager};
648 use beamr::distribution::connection_events::{ConnectionEvent, ConnectionGeneration};
649 use beamr::distribution::resolver::StaticResolver;
650 use std::collections::HashMap;
651 use std::sync::Arc;
652
653 const COOKIE: &str = "srv008-membership-cookie";
654 const DIALER_NAME: &str = "dialer@127.0.0.1";
655 const PEER_NAME: &str = "peer@127.0.0.1";
656
657 fn empty_manager(atoms: &Arc<AtomTable>) -> ConnectionManager {
658 ConnectionManager::new(
659 Arc::clone(atoms),
660 Arc::new(StaticResolver::new(HashMap::new())),
661 "test-cookie",
662 "local@127.0.0.1",
663 1,
664 )
665 }
666
667 /// A live loopback pair of REAL connection managers — real sockets, real OTP
668 /// handshake, no mock. `dialer` is the manager whose membership the tests arm;
669 /// the peer just listens.
670 ///
671 /// This is the deterministic barrier SRV-008 needs. beamr's INV-SYNC says that
672 /// when the call causing a transition returns, every event it produced has
673 /// already been delivered to every subscriber — so a `connect` that returns Ok
674 /// is, on the DIALER's side, a completed delivery of the session's `Up`. No
675 /// sleep and no retry loop are needed to observe a membership change.
676 struct LivePair {
677 runtime: tokio::runtime::Runtime,
678 dialer: ConnectionManager,
679 dialer_atoms: Arc<AtomTable>,
680 /// Kept alive: dropping the peer manager or its accept handle would tear
681 /// the link down underneath the assertions.
682 _peer: ConnectionManager,
683 _accept: AcceptHandle,
684 }
685
686 impl LivePair {
687 fn new() -> Self {
688 let runtime = tokio::runtime::Builder::new_multi_thread()
689 .worker_threads(2)
690 .enable_all()
691 .build()
692 .expect("build loopback test runtime");
693
694 let peer_atoms = Arc::new(AtomTable::with_common_atoms());
695 let peer = ConnectionManager::new(
696 peer_atoms,
697 Arc::new(StaticResolver::new(HashMap::new())),
698 COOKIE,
699 PEER_NAME,
700 1,
701 );
702 peer.set_runtime_handle(runtime.handle().clone());
703 let accept = runtime
704 .block_on(peer.listen("127.0.0.1:0".parse().expect("loopback addr")))
705 .expect("peer binds a loopback listener");
706
707 let mut routes = HashMap::new();
708 routes.insert(PEER_NAME.to_owned(), accept.local_addr());
709 let dialer_atoms = Arc::new(AtomTable::with_common_atoms());
710 let dialer = ConnectionManager::new(
711 Arc::clone(&dialer_atoms),
712 Arc::new(StaticResolver::new(routes)),
713 COOKIE,
714 DIALER_NAME,
715 1,
716 );
717 dialer.set_runtime_handle(runtime.handle().clone());
718
719 Self {
720 runtime,
721 dialer,
722 dialer_atoms,
723 _peer: peer,
724 _accept: accept,
725 }
726 }
727
728 /// Dials the peer and returns once the session is installed in the
729 /// dialer's table AND its connection event has been delivered (INV-SYNC).
730 fn connect(&self) {
731 self.runtime
732 .block_on(self.dialer.connect(PEER_NAME))
733 .expect("loopback handshake succeeds");
734 }
735
736 /// Closes the dialer's link. INV-SYNC again: `disconnect_node` returns
737 /// only after the `Down` has reached every subscriber.
738 fn disconnect(&self) {
739 let node = self.dialer_atoms.intern(PEER_NAME);
740 assert!(
741 self.dialer.disconnect_node(node),
742 "the live loopback link must be closable"
743 );
744 }
745 }
746
747 #[test]
748 fn delta_is_empty_by_default() {
749 assert!(MembershipDelta::default().is_empty());
750 }
751
752 /// SRV-008 R5 tombstone replacement. `first_poll_of_empty_table_yields_no_peers`
753 /// asserted the retired sampler's contract directly; the empty-initial-view
754 /// coverage it carried is preserved here through the event source instead.
755 /// Arming over an empty table must yield an empty view, no queued effects,
756 /// and exactly one initial-view acquisition — never a repeated one.
757 #[test]
758 fn empty_initial_view_yields_no_peers() {
759 let atoms = Arc::new(AtomTable::with_common_atoms());
760 let membership = Membership::new(empty_manager(&atoms), Arc::clone(&atoms));
761
762 assert!(membership.peers().is_empty());
763 assert_eq!(
764 membership.events_observed(),
765 0,
766 "an empty table synthesizes no catch-up event"
767 );
768 assert_eq!(membership.pending_effects(), 0);
769 assert_eq!(
770 membership.source_snapshots(),
771 1,
772 "arming asks for the initial view exactly once"
773 );
774 }
775
776 /// The atomic initial view is what makes a late arm safe: a peer that was
777 /// already live before the tracker existed is delivered as a synthetic `Up`
778 /// under beamr's dispatch gate, so the tracker never has to go looking.
779 #[test]
780 fn a_late_arm_sees_a_live_peer_in_its_initial_view() {
781 let pair = LivePair::new();
782 pair.connect();
783
784 let membership = Membership::new(pair.dialer.clone(), Arc::clone(&pair.dialer_atoms));
785
786 assert_eq!(
787 membership.peers().len(),
788 1,
789 "the initial view must contain the peer that was already live"
790 );
791 assert_eq!(membership.events_observed(), 1);
792 assert_eq!(
793 membership.pending_effects(),
794 1,
795 "the initial view's join effect is queued for the consumer, not run \
796 on beamr's delivery thread"
797 );
798 }
799
800 /// INV-ALTERNATION end to end on real sockets: join, leave, and rejoin are
801 /// observed in that order, each released by a real transition rather than by
802 /// a cadence. The tracked set is correct at every step with no waiting.
803 #[test]
804 fn join_leave_and_rejoin_are_observed_in_order() {
805 let pair = LivePair::new();
806 let membership = Membership::new(pair.dialer.clone(), Arc::clone(&pair.dialer_atoms));
807 let peer = pair.dialer_atoms.intern(PEER_NAME);
808
809 pair.connect();
810 assert_eq!(membership.peers(), vec![peer], "join is visible at once");
811
812 pair.disconnect();
813 assert!(
814 membership.peers().is_empty(),
815 "leave is visible at once, with no sampling in between"
816 );
817
818 pair.connect();
819 assert_eq!(membership.peers(), vec![peer], "rejoin is visible at once");
820
821 let effects = membership.take_pending();
822 assert_eq!(
823 effects,
824 vec![
825 MembershipDelta {
826 joined: vec![peer],
827 left: Vec::new(),
828 },
829 MembershipDelta {
830 joined: Vec::new(),
831 left: vec![peer],
832 },
833 MembershipDelta {
834 joined: vec![peer],
835 left: Vec::new(),
836 },
837 ],
838 "the consumer receives the transitions in the order they happened"
839 );
840 assert_eq!(
841 membership.events_observed(),
842 3,
843 "exactly one event per transition — no duplicates to dedupe"
844 );
845 }
846
847 /// The handoff FIFO is lossless: every queued effect survives to the consumer
848 /// and the depth is reported, which is the whole of the documented overflow
849 /// rule (nothing is ever discarded, so there is no drop path to test).
850 #[test]
851 fn queued_effects_are_lossless_and_report_their_depth() {
852 let atoms = Arc::new(AtomTable::with_common_atoms());
853 let membership = Membership::new(empty_manager(&atoms), Arc::clone(&atoms));
854
855 for index in 0_u64..64 {
856 let peer = atoms.intern(&format!("peer-{index}@127.0.0.1"));
857 membership.inner.observe(ConnectionEvent::up(
858 peer,
859 ConnectionGeneration::from_raw(index + 1),
860 1,
861 ));
862 }
863
864 assert_eq!(
865 membership.pending_effects(),
866 64,
867 "no membership effect may be discarded"
868 );
869 assert_eq!(membership.queue_high_water(), 64);
870 assert_eq!(membership.peers().len(), 64);
871
872 let drained = membership.take_pending();
873 assert_eq!(
874 drained.len(),
875 64,
876 "every queued effect reaches the consumer"
877 );
878 assert_eq!(membership.pending_effects(), 0);
879 assert_eq!(
880 membership.queue_high_water(),
881 64,
882 "the high-water mark is a disclosure, not a counter that resets"
883 );
884 }
885
886 /// A redundant event cannot double-count: the set is already right, so there
887 /// is no effect to run and nothing is queued. INV-ALTERNATION rules these out
888 /// upstream; this pins that liminal absorbs rather than amplifies one.
889 #[test]
890 fn a_redundant_event_queues_no_effect() {
891 let atoms = Arc::new(AtomTable::with_common_atoms());
892 let membership = Membership::new(empty_manager(&atoms), Arc::clone(&atoms));
893 let peer = atoms.intern(PEER_NAME);
894
895 membership.inner.observe(ConnectionEvent::up(
896 peer,
897 ConnectionGeneration::from_raw(1),
898 1,
899 ));
900 membership.inner.observe(ConnectionEvent::up(
901 peer,
902 ConnectionGeneration::from_raw(2),
903 1,
904 ));
905
906 assert_eq!(membership.peers(), vec![peer]);
907 assert_eq!(
908 membership.pending_effects(),
909 1,
910 "a repeated Up for a tracked peer must not queue a second join effect"
911 );
912 }
913
914 /// R3: explicit shutdown wakes and releases the consumer even when no
915 /// membership event is pending. Nothing polls a stop flag — the waiter is
916 /// told.
917 #[test]
918 fn shutdown_wakes_a_consumer_with_nothing_pending() {
919 let atoms = Arc::new(AtomTable::with_common_atoms());
920 let membership = Membership::new(empty_manager(&atoms), Arc::clone(&atoms));
921 assert_eq!(membership.pending_effects(), 0, "nothing is pending");
922
923 let waiter = membership.clone();
924 let joined = std::thread::spawn(move || waiter.wait_for_effects());
925 membership.signal_shutdown();
926
927 assert!(
928 joined.join().expect("the waiter thread joins").is_none(),
929 "shutdown must release a consumer that has no work"
930 );
931 }
932
933 /// SRV-008 deletion check — absence proof over this module's own production
934 /// source, mirroring the accept-path guard in `listener.rs`. The retired
935 /// polling family must never come back.
936 #[test]
937 fn membership_source_has_no_retired_poll_family() {
938 const SOURCE: &str = include_str!("membership.rs");
939 let production = SOURCE.split("mod tests").next().unwrap_or(SOURCE);
940 for forbidden in [
941 "POLL_INTERVAL",
942 "poll_once",
943 "thread::sleep",
944 "PollLoop",
945 "run_poll_loop",
946 "connected_nodes",
947 ] {
948 assert!(
949 !production.contains(forbidden),
950 "retired membership poll-family source `{forbidden}` reappeared"
951 );
952 }
953 }
954
955 /// RED PIN (SRV-008 R3) — the polling-cadence defect, pinned.
956 ///
957 /// A membership tracker is armed over a real connection manager BEFORE any
958 /// peer link exists. Then a peer link is genuinely established: `connect`
959 /// returns only after beamr has installed the session and delivered its
960 /// connection event to every subscriber (INV-SYNC). An event-driven tracker
961 /// has therefore already been TOLD, and reports the peer with no sleep, no
962 /// retry, and no call into a sampling entry point.
963 ///
964 /// The polling tracker cannot: its set only moves when someone samples the
965 /// connection table, so on the current implementation this reports zero peers
966 /// and the membership change is observable only at the next 250ms tick. That
967 /// gap IS the defect SRV-008 retires.
968 #[test]
969 fn armed_membership_observes_a_join_without_sampling() {
970 let pair = LivePair::new();
971 let membership = Membership::new(pair.dialer.clone(), Arc::clone(&pair.dialer_atoms));
972
973 pair.connect();
974
975 assert_eq!(
976 membership.peers().len(),
977 1,
978 "an armed membership source must observe the join the instant beamr \
979 installs it, without any sampling of the connection table"
980 );
981 }
982
983 #[test]
984 fn peer_names_resolve_through_the_atom_table() {
985 let atoms = Arc::new(AtomTable::with_common_atoms());
986 let membership = Membership::new(empty_manager(&atoms), Arc::clone(&atoms));
987 // No connections, so no names — but the accessor must not panic.
988 assert!(membership.peer_names().is_empty());
989 }
990}