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        // 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}