Skip to main content

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}