Skip to main content

liminal_server/cluster/
sync.rs

1//! SRV-005 R5/R6: cross-node subscription propagation and message fan-out.
2//!
3//! Each channel maps to a beamr process group (the channel name interned into an
4//! atom in the default pg scope). A local subscriber joining a channel joins
5//! that pg group; beamr broadcasts the membership to connected peers, so every
6//! node knows which peers hold subscribers for which channel. Publishing then
7//! consults the group's REMOTE members and forwards the published envelope to
8//! each one over the existing distribution link.
9//!
10//! * R5 propagation: [`ClusterSync::on_subscribe`] -> `pg.join`; the broadcast is
11//!   beamr's, not ours.
12//! * R5 backfill: a fresh `pg.join` only broadcasts on the insert edge, so a peer
13//!   that joins the cluster AFTER our subscribers registered would never learn
14//!   them. [`ClusterSync::on_peer_join`] re-sends a pg-join control frame for each
15//!   of our local members directly to the newcomer.
16//! * R5 delivery: [`ClusterSync::on_publish`] sends the envelope (encoded by
17//!   [`liminal::channel::encode_envelope`]) as a beamr `SEND` to each remote
18//!   member's pid. On that member's home node the frame lands in the subscriber
19//!   process's mailbox, which decodes it back into its inbox.
20//! * R6 cleanup: when a peer drops, beamr's connection-down hook calls
21//!   `purge_remote_node`, so its remote members vanish from every group with no
22//!   liminal code on the path. [`ClusterSync::on_peer_leave`] only logs.
23
24use std::collections::HashMap;
25use std::sync::{Arc, Mutex, OnceLock};
26
27use beamr::atom::{Atom, AtomTable};
28use beamr::distribution::connection::ConnectionManager;
29use beamr::distribution::control::{encode_pg_update_frame, encode_send_frame};
30use beamr::distribution::pg::{PgRegistry, PgUpdate, RemoteMember};
31use beamr::native::ProcessContext;
32use beamr::term::Term;
33
34use crate::cluster::discovery::ClusterResolver;
35use liminal::channel::{ClusterObserver, encode_envelope};
36use liminal::envelope::Envelope;
37
38/// Propagates channel subscriptions and fans published messages out across the
39/// cluster via beamr process groups (SRV-005 R5/R6).
40#[derive(Clone)]
41pub struct ClusterSync {
42    inner: Arc<SyncInner>,
43}
44
45struct SyncInner {
46    pg: Arc<PgRegistry>,
47    atoms: Arc<AtomTable>,
48    connections: ConnectionManager,
49    /// This node's distribution atom — the node component every locally-joined
50    /// member carries on the wire.
51    local_node: Atom,
52    /// The same resolver the scheduler uses, retained so a future enhancement can
53    /// register learned peer addresses; held to keep the discovery/scheduler
54    /// resolver identity explicit at this seam.
55    _resolver: Arc<ClusterResolver>,
56    /// Local subscriptions, keyed by channel group atom, each holding the set of
57    /// local subscriber pids. The source of truth for R5 peer-join backfill.
58    local: Mutex<HashMap<Atom, Vec<u64>>>,
59    /// Told each peer join and leave by name (SRV-012 R7: the console
60    /// topology's devices and links). Set once, before the initial view.
61    peers: OnceLock<PeerListener>,
62}
63
64/// Hears a cluster peer join (`true`) or leave (`false`), by node name.
65pub type PeerListener = Arc<dyn Fn(&str, bool) + Send + Sync>;
66
67impl std::fmt::Debug for ClusterSync {
68    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        formatter
70            .debug_struct("ClusterSync")
71            .field("local_node", &self.inner.local_node)
72            .finish_non_exhaustive()
73    }
74}
75
76impl ClusterSync {
77    /// Builds a sync over the scheduler's pg registry and distribution
78    /// connections. `local_node` is this node's distribution atom (interned from
79    /// the configured node name in the SAME atom table).
80    #[must_use]
81    pub fn new(
82        pg: Arc<PgRegistry>,
83        atoms: Arc<AtomTable>,
84        connections: ConnectionManager,
85        local_node: Atom,
86        resolver: Arc<ClusterResolver>,
87    ) -> Self {
88        Self {
89            inner: Arc::new(SyncInner {
90                pg,
91                atoms,
92                connections,
93                local_node,
94                _resolver: resolver,
95                local: Mutex::new(HashMap::new()),
96                peers: OnceLock::new(),
97            }),
98        }
99    }
100
101    /// Installs the listener every peer join and leave is reported to. A
102    /// second listener is refused (answers false): the first keeps the seat.
103    pub fn set_peer_listener(&self, listener: PeerListener) -> bool {
104        self.inner.peers.set(listener).is_ok()
105    }
106
107    /// Reports peer `name` joining (`present`) or leaving to the listener.
108    pub(crate) fn peer_changed(&self, name: &str, present: bool) {
109        if let Some(listener) = self.inner.peers.get() {
110            listener(name, present);
111        }
112    }
113
114    /// The default pg scope atom.
115    fn scope(&self) -> Atom {
116        self.inner.pg.default_scope()
117    }
118
119    /// Interns a channel name into its pg group atom.
120    fn group(&self, channel: &str) -> Atom {
121        self.inner.atoms.intern(channel)
122    }
123
124    /// Remote members of `channel`'s group (for publish fan-out and tests).
125    #[must_use]
126    pub fn remote_targets(&self, channel: &str) -> Vec<RemoteMember> {
127        let group = self.group(channel);
128        self.inner.pg.remote_members(self.scope(), group)
129    }
130
131    /// Records a local subscriber pid under `group` for backfill.
132    fn record_local(&self, group: Atom, pid: u64) {
133        let mut local = self.lock_local();
134        let pids = local.entry(group).or_default();
135        if !pids.contains(&pid) {
136            pids.push(pid);
137        }
138        drop(local);
139    }
140
141    /// Forgets a local subscriber pid under `group`.
142    fn forget_local(&self, group: Atom, pid: u64) {
143        let mut local = self.lock_local();
144        if let Some(pids) = local.get_mut(&group) {
145            pids.retain(|candidate| *candidate != pid);
146            if pids.is_empty() {
147                local.remove(&group);
148            }
149        }
150    }
151
152    /// A snapshot of every local `(group, pid)` membership for backfill.
153    fn local_memberships(&self) -> Vec<(Atom, u64)> {
154        let local = self.lock_local();
155        local
156            .iter()
157            .flat_map(|(group, pids)| pids.iter().map(move |pid| (*group, *pid)))
158            .collect()
159    }
160
161    /// Sends an encoded envelope to one remote member's pid as a beamr `SEND`.
162    fn send_to_member(&self, member: RemoteMember, frame_bytes: &[u8]) {
163        // The SEND control targets the member's pid_number; on the member's home
164        // node that maps to the local subscriber process, whose mailbox receives
165        // the binary payload.
166        let Some(to_pid) = Term::try_pid(member.pid_number) else {
167            tracing::warn!(
168                pid_number = member.pid_number,
169                "remote member pid out of immediate range; skipping cross-node delivery"
170            );
171            return;
172        };
173        let mut context = ProcessContext::new();
174        let Ok(payload) = context.alloc_binary(frame_bytes) else {
175            tracing::warn!("failed to allocate cross-node envelope payload");
176            return;
177        };
178        let Ok(frame) = encode_send_frame(
179            Term::atom(beamr::atom::Atom::OK),
180            to_pid,
181            payload,
182            &self.inner.atoms,
183        ) else {
184            tracing::warn!("failed to encode cross-node send frame");
185            return;
186        };
187        self.write_frame(member.node, &frame);
188    }
189
190    /// Writes a pre-encoded distribution frame to `node`'s connection, if live.
191    fn write_frame(&self, node: Atom, frame: &[u8]) {
192        let Some(connection) = self.inner.connections.get_connection(node) else {
193            // No live link, so the frame is dropped here and nothing above is
194            // told. Both causes are accepted. A peer that DEPARTED between the
195            // membership snapshot and this send is owed nothing — the frame was
196            // addressed to a node that is gone. A STALE membership entry names a
197            // node that never had a link, and that delivery is genuinely lost:
198            // no retry is possible against an entry that resolves to no
199            // connection. The loss window is bounded by R6 cleanup, which drops
200            // the stale entry shortly, after which the node stops being selected
201            // at all.
202            return;
203        };
204        write_raw_blocking(&connection, frame);
205    }
206
207    /// Re-sends a pg-join control frame for one local member to a single node
208    /// (R5 backfill). Mirrors the frame beamr broadcasts on a fresh `pg.join`,
209    /// but targeted at the newcomer only.
210    fn backfill_member(&self, node: Atom, group: Atom, pid: u64) {
211        let update = PgUpdate::Join {
212            scope: self.scope(),
213            group,
214            pid,
215        };
216        if let Ok(frame) = encode_pg_update_frame(update, self.inner.local_node, &self.inner.atoms)
217        {
218            self.write_frame(node, &frame);
219        } else {
220            tracing::warn!("failed to encode cluster backfill frame");
221        }
222    }
223
224    fn lock_local(&self) -> std::sync::MutexGuard<'_, HashMap<Atom, Vec<u64>>> {
225        self.inner
226            .local
227            .lock()
228            .unwrap_or_else(std::sync::PoisonError::into_inner)
229    }
230}
231
232impl ClusterObserver for ClusterSync {
233    fn on_subscribe(&self, channel: &str, subscriber_pid: u64) {
234        let group = self.group(channel);
235        // pg.join broadcasts the membership to connected peers (R5 propagation).
236        self.inner.pg.join(self.scope(), group, subscriber_pid);
237        self.record_local(group, subscriber_pid);
238        tracing::debug!(
239            channel = %channel,
240            pid = subscriber_pid,
241            "advertised local subscription to cluster"
242        );
243    }
244
245    fn on_unsubscribe(&self, channel: &str, subscriber_pid: u64) {
246        let group = self.group(channel);
247        self.inner.pg.leave(self.scope(), group, subscriber_pid);
248        self.forget_local(group, subscriber_pid);
249        tracing::debug!(
250            channel = %channel,
251            pid = subscriber_pid,
252            "withdrew local subscription from cluster"
253        );
254    }
255
256    fn on_publish(&self, channel: &str, envelope: &Envelope) {
257        // The console's own records are this device's alone (SRV-012 R4): a
258        // peer is never sent one, so nothing is encoded for them.
259        if !crate::console::tappable(channel) {
260            return;
261        }
262        let targets = self.remote_targets(channel);
263        if targets.is_empty() {
264            return;
265        }
266        let frame_bytes = encode_envelope(envelope);
267        for member in targets {
268            self.send_to_member(member, &frame_bytes);
269        }
270    }
271}
272
273impl ClusterSync {
274    /// R5 backfill: re-advertise every local subscription to a newly-joined peer.
275    pub fn on_peer_join(&self, node: Atom) {
276        for (group, pid) in self.local_memberships() {
277            self.backfill_member(node, group, pid);
278        }
279    }
280
281    /// R6 is automatic (beamr purges the departed node's remote members via its
282    /// connection-down hook). This logs the cleanup for operators.
283    pub fn on_peer_leave(&self, node: Atom) {
284        let name = self
285            .inner
286            .atoms
287            .resolve(node)
288            .map_or_else(|| format!("<atom {node:?}>"), str::to_owned);
289        tracing::info!(
290            peer = %name,
291            "peer departed; its remote subscriptions were purged by beamr"
292        );
293    }
294}
295
296/// Writes `frame` to `connection`, driving the async write to completion off any
297/// ambient runtime. The membership poll thread that calls this owns no tokio
298/// runtime, so a fresh current-thread runtime drives the single write — the same
299/// shape as beamr's own synchronous distribution-send bridge.
300fn write_raw_blocking(
301    connection: &Arc<beamr::distribution::connection::DistConnection>,
302    frame: &[u8],
303) {
304    let connection = Arc::clone(connection);
305    let frame = frame.to_vec();
306    let write = async move {
307        // A lost distribution write is a silently dropped cross-node fan-out
308        // or R5 backfill; the connection teardown side effects are write_raw's
309        // own, but the loss itself must be visible here.
310        if let Err(error) = connection.write_raw(&frame).await {
311            tracing::warn!(%error, "cross-node distribution write failed");
312        }
313    };
314    if let Ok(handle) = tokio::runtime::Handle::try_current() {
315        if matches!(
316            handle.runtime_flavor(),
317            tokio::runtime::RuntimeFlavor::MultiThread
318        ) {
319            tokio::task::block_in_place(|| handle.block_on(write));
320            return;
321        }
322    }
323    match tokio::runtime::Builder::new_current_thread()
324        .enable_all()
325        .build()
326    {
327        Ok(runtime) => runtime.block_on(write),
328        Err(error) => tracing::warn!(error = %error, "failed to build cluster send runtime"),
329    }
330}
331
332#[cfg(test)]
333#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
334mod tests {
335    use super::ClusterSync;
336    use crate::cluster::discovery::ClusterResolver;
337    use beamr::atom::AtomTable;
338    use beamr::distribution::connection::ConnectionManager;
339    use beamr::distribution::pg::PgRegistry;
340    use beamr::distribution::resolver::StaticResolver;
341    use liminal::channel::ClusterObserver;
342    use std::collections::HashMap;
343    use std::sync::Arc;
344
345    fn sync_fixture() -> (ClusterSync, Arc<PgRegistry>, Arc<AtomTable>) {
346        let atoms = Arc::new(AtomTable::with_common_atoms());
347        let pg = Arc::new(PgRegistry::new(&atoms));
348        let connections = ConnectionManager::new(
349            Arc::clone(&atoms),
350            Arc::new(StaticResolver::new(HashMap::new())),
351            "test-cookie",
352            "local@127.0.0.1",
353            1,
354        );
355        let local_node = atoms.intern("local@127.0.0.1");
356        let resolver = Arc::new(ClusterResolver::new());
357        let sync = ClusterSync::new(
358            Arc::clone(&pg),
359            Arc::clone(&atoms),
360            connections,
361            local_node,
362            resolver,
363        );
364        (sync, pg, atoms)
365    }
366
367    #[test]
368    fn subscribe_joins_the_channel_pg_group() {
369        let (sync, pg, atoms) = sync_fixture();
370        sync.on_subscribe("orders", 42);
371        let group = atoms.intern("orders");
372        assert_eq!(pg.local_members(pg.default_scope(), group), vec![42]);
373    }
374
375    #[test]
376    fn unsubscribe_leaves_the_channel_pg_group() {
377        let (sync, pg, atoms) = sync_fixture();
378        sync.on_subscribe("orders", 42);
379        sync.on_unsubscribe("orders", 42);
380        let group = atoms.intern("orders");
381        assert!(pg.local_members(pg.default_scope(), group).is_empty());
382    }
383
384    #[test]
385    fn local_memberships_track_subscriptions_for_backfill() {
386        let (sync, _pg, _atoms) = sync_fixture();
387        sync.on_subscribe("orders", 1);
388        sync.on_subscribe("orders", 2);
389        sync.on_subscribe("events", 3);
390        let mut memberships = sync.local_memberships();
391        memberships.sort_by_key(|(group, pid)| (*group, *pid));
392        assert_eq!(memberships.len(), 3);
393        // After unsubscribing the only member of a group, the group is dropped.
394        sync.on_unsubscribe("events", 3);
395        let remaining = sync.local_memberships();
396        assert_eq!(remaining.len(), 2);
397        assert!(remaining.iter().all(|(_, pid)| *pid == 1 || *pid == 2));
398    }
399
400    #[test]
401    fn remote_targets_empty_without_remote_members() {
402        let (sync, _pg, _atoms) = sync_fixture();
403        sync.on_subscribe("orders", 1);
404        assert!(sync.remote_targets("orders").is_empty());
405    }
406
407    /// A failed cross-node distribution write must be warned about, never
408    /// silently discarded (message fan-out and R5 backfill would be lost with
409    /// no trace). Drives the REAL write path: a live loopback link (the
410    /// membership tests' `LivePair` pattern), whose write half is then
411    /// deterministically taken down via the public `mark_down_write_timeout`
412    /// seam so `write_raw` yields `NotConnected`.
413    #[test]
414    fn distribution_write_failure_warns() {
415        const COOKIE: &str = "sync-write-cookie";
416        const PEER_NAME: &str = "sync-peer@127.0.0.1";
417        let runtime = tokio::runtime::Builder::new_multi_thread()
418            .worker_threads(2)
419            .enable_all()
420            .build()
421            .expect("build loopback test runtime");
422
423        let peer_atoms = Arc::new(AtomTable::with_common_atoms());
424        let peer = ConnectionManager::new(
425            peer_atoms,
426            Arc::new(StaticResolver::new(HashMap::new())),
427            COOKIE,
428            PEER_NAME,
429            1,
430        );
431        peer.set_runtime_handle(runtime.handle().clone());
432        // Kept alive for the whole test: dropping the accept handle would tear
433        // the loopback link down underneath the write below.
434        let accept = runtime
435            .block_on(peer.listen("127.0.0.1:0".parse().expect("loopback addr")))
436            .expect("peer binds a loopback listener");
437
438        let mut routes = HashMap::new();
439        routes.insert(PEER_NAME.to_owned(), accept.local_addr());
440        let dialer_atoms = Arc::new(AtomTable::with_common_atoms());
441        let dialer = ConnectionManager::new(
442            Arc::clone(&dialer_atoms),
443            Arc::new(StaticResolver::new(routes)),
444            COOKIE,
445            "sync-dialer@127.0.0.1",
446            1,
447        );
448        dialer.set_runtime_handle(runtime.handle().clone());
449        runtime
450            .block_on(dialer.connect(PEER_NAME))
451            .expect("loopback handshake succeeds");
452
453        let peer_node = dialer_atoms.intern(PEER_NAME);
454        let connection = dialer
455            .get_connection(peer_node)
456            .expect("dialer holds the live connection");
457        // Take the write half down; the next write_raw returns NotConnected.
458        connection.mark_down_write_timeout();
459
460        let log = crate::test_log::CapturedLog::default();
461        log.capture(|| super::write_raw_blocking(&connection, b"frame-bytes"));
462
463        let output = log.contents();
464        assert!(
465            output.contains("WARN") && output.contains("cross-node distribution write failed"),
466            "expected the discarded write error to be warned about, got: {output}"
467        );
468    }
469
470    /// SRV-012 R4: a console publish reaches no peer. Both channels have a
471    /// remote member whose pid is out of the immediate range, so a fan-out
472    /// that got as far as the member warns; the ordinary channel's does (the
473    /// positive control) and the console channel's never starts.
474    #[test]
475    fn console_publishes_never_reach_the_fan_out() {
476        let (sync, pg, atoms) = sync_fixture();
477        let remote_node = atoms.intern("node-b@127.0.0.1");
478        for channel in ["orders", "console.topology"] {
479            let group = atoms.intern(channel);
480            pg.apply_remote_join(pg.default_scope(), group, remote_node, u64::MAX, 0);
481            assert_eq!(sync.remote_targets(channel).len(), 1);
482        }
483        let envelope = || {
484            liminal::envelope::Envelope::new(
485                b"{}".to_vec(),
486                None,
487                liminal::channel::SchemaId::new(),
488                liminal::envelope::PublisherId::default(),
489            )
490        };
491        let skipped = "remote member pid out of immediate range";
492
493        let console = crate::test_log::CapturedLog::default();
494        console.capture(|| sync.on_publish("console.topology", &envelope()));
495        assert!(
496            !console.contents().contains(skipped),
497            "a console record fanned out"
498        );
499
500        let ordinary = crate::test_log::CapturedLog::default();
501        ordinary.capture(|| sync.on_publish("orders", &envelope()));
502        assert!(ordinary.contents().contains(skipped), "positive control");
503    }
504
505    #[test]
506    fn remote_targets_reflect_applied_remote_joins() {
507        let (sync, pg, atoms) = sync_fixture();
508        let group = atoms.intern("orders");
509        let remote_node = atoms.intern("node-b@127.0.0.1");
510        pg.apply_remote_join(pg.default_scope(), group, remote_node, 99, 0);
511        let targets = sync.remote_targets("orders");
512        assert_eq!(targets.len(), 1);
513        assert_eq!(targets[0].node, remote_node);
514        assert_eq!(targets[0].pid_number, 99);
515    }
516}