Skip to main content

aion_server/
cluster_publisher.rs

1//! WS3 cluster topology/ownership broadcast publisher.
2//!
3//! [`ClusterEventPublisher`] is the cluster-channel analog of the workflow
4//! [`aion::BroadcastEventPublisher`]: it owns a deployment-global
5//! `broadcast::Sender<ClusterEvent>` plus a single monotonic `cluster_seq`
6//! stamper (an [`AtomicU64`]). Every cluster state-change site (the supervisor
7//! `tick`, the worker registry register/deregister) calls [`Self::emit`] with a
8//! *constructor* that receives the freshly-allocated [`ClusterEventMeta`] and
9//! returns the fully-formed [`ClusterEvent`]; the publisher fans it out to every
10//! live subscriber.
11//!
12//! # Why a constructor closure, not a pre-built event
13//!
14//! The `cluster_seq` and `observed_at` must be stamped atomically with the
15//! broadcast so two concurrent emitters cannot interleave a higher seq ahead of
16//! a lower one on the wire. Taking a `FnOnce(ClusterEventMeta) -> ClusterEvent`
17//! lets the publisher allocate the meta under its own monotonic counter and hand
18//! it to the caller, who fills in the variant-specific payload. There is no path
19//! by which a caller can fabricate a seq.
20//!
21//! # No timer anywhere
22//!
23//! This type contains no `tokio::time::interval` and no polling loop. Events are
24//! emitted *only* when a real subsystem mutation occurs (edge-triggered). This is
25//! the structural guarantee against the polling-as-push regression WS3 exists to
26//! remove.
27
28use std::sync::Arc;
29use std::sync::atomic::{AtomicU64, Ordering};
30
31use aion_core::{ClusterEvent, ClusterEventMeta};
32use futures::stream::{self, BoxStream};
33use tokio::sync::broadcast;
34
35/// A lag item on the cluster broadcast: `skipped` deltas were dropped because
36/// the subscriber fell behind the bounded buffer. Surfaced to the client as the
37/// typed `ClusterLagged` terminal frame, never a silent skip.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub struct ClusterStreamLagged {
40    /// Number of cluster deltas dropped.
41    pub skipped: u64,
42}
43
44/// Deployment-global cluster-event broadcaster with a monotonic seq stamper.
45#[derive(Clone, Debug)]
46pub struct ClusterEventPublisher {
47    events: broadcast::Sender<ClusterEvent>,
48    next_seq: Arc<AtomicU64>,
49}
50
51impl ClusterEventPublisher {
52    /// Build a publisher over a fresh bounded broadcast channel of `capacity`.
53    ///
54    /// `capacity` is the operator-configured `websocket.cluster_broadcast_capacity`
55    /// (validated non-zero at startup), so this never receives a zero.
56    #[must_use]
57    pub fn new(capacity: std::num::NonZeroUsize) -> Self {
58        let (events, _receiver) = broadcast::channel(capacity.get());
59        Self {
60            events,
61            next_seq: Arc::new(AtomicU64::new(1)),
62        }
63    }
64
65    /// Stamp and broadcast one cluster event.
66    ///
67    /// `build` receives the publisher-allocated [`ClusterEventMeta`] (carrying the
68    /// next monotonic `cluster_seq` and the observation instant) and returns the
69    /// fully-formed event. The return value is the broadcast event (for tests);
70    /// a send with no live subscribers is not an error (the calm single-node
71    /// case has no dashboard attached).
72    pub fn emit<F>(&self, build: F) -> ClusterEvent
73    where
74        F: FnOnce(ClusterEventMeta) -> ClusterEvent,
75    {
76        let meta = ClusterEventMeta {
77            cluster_seq: self.next_seq.fetch_add(1, Ordering::SeqCst),
78            observed_at: chrono::Utc::now(),
79        };
80        let event = build(meta);
81        // A closed channel (no subscribers) is the expected calm-state case, not
82        // a failure: the seq still advanced so a later reconnect's gap math is
83        // consistent.
84        let send_result = self.events.send(event.clone());
85        drop(send_result);
86        event
87    }
88
89    /// Subscribe to the live cluster delta stream, suppressing any delivered
90    /// delta with `cluster_seq <= after_seq`.
91    ///
92    /// `after_seq` dedups the splice seam: the cluster subscription attaches this
93    /// receiver BEFORE reading the priming snapshot (gap-free splice), so the
94    /// live stream may carry a delta the snapshot already reflects (one with
95    /// `cluster_seq <= snapshot.as_of_seq`). Passing `after_seq = as_of_seq`
96    /// suppresses exactly those already-applied deltas. Like every tokio
97    /// `broadcast` receiver, this sees only events sent AFTER it attaches — there
98    /// is no replay of pre-subscription history, which is why a lagged reconnect
99    /// re-requests a full snapshot rather than resuming.
100    ///
101    /// A receiver that falls behind the bounded buffer yields one
102    /// `Err(`[`ClusterStreamLagged`]`)` with the skipped count and then closes —
103    /// the same lag contract as the workflow path, surfaced typed, never silent.
104    #[must_use]
105    pub fn subscribe(
106        &self,
107        after_seq: u64,
108    ) -> BoxStream<'static, Result<ClusterEvent, ClusterStreamLagged>> {
109        let receiver = self.events.subscribe();
110        Box::pin(stream::unfold(
111            (receiver, after_seq),
112            |(mut receiver, after_seq)| async move {
113                loop {
114                    match receiver.recv().await {
115                        Ok(event) => {
116                            if event_seq(&event) > after_seq {
117                                return Some((Ok(event), (receiver, after_seq)));
118                            }
119                            // Buffered backlog already applied by the client:
120                            // suppress without surfacing it.
121                        }
122                        Err(broadcast::error::RecvError::Lagged(skipped)) => {
123                            return Some((
124                                Err(ClusterStreamLagged { skipped }),
125                                (receiver, after_seq),
126                            ));
127                        }
128                        Err(broadcast::error::RecvError::Closed) => return None,
129                    }
130                }
131            },
132        ))
133    }
134
135    /// The next `cluster_seq` that will be assigned (i.e. one past the last
136    /// stamped). Used by the snapshot path to stamp `as_of_seq` consistently with
137    /// the live stream the subscriber spliced onto.
138    #[must_use]
139    pub fn current_seq(&self) -> u64 {
140        self.next_seq.load(Ordering::SeqCst).saturating_sub(1)
141    }
142}
143
144/// The `cluster_seq` carried by any cluster event's meta.
145fn event_seq(event: &ClusterEvent) -> u64 {
146    cluster_event_meta(event).cluster_seq
147}
148
149/// Borrow the shared meta off any cluster event variant.
150#[must_use]
151pub fn cluster_event_meta(event: &ClusterEvent) -> &ClusterEventMeta {
152    match event {
153        ClusterEvent::PeerAdded { meta, .. }
154        | ClusterEvent::PeerConnected { meta, .. }
155        | ClusterEvent::PeerDisconnected { meta, .. }
156        | ClusterEvent::ShardAdopted { meta, .. }
157        | ClusterEvent::ShardAdoptionFailed { meta, .. }
158        | ClusterEvent::ShardAdoptionSkipped { meta, .. }
159        | ClusterEvent::WorkerConnected { meta, .. }
160        | ClusterEvent::WorkerDisconnected { meta, .. }
161        | ClusterEvent::SupervisorStarted { meta, .. }
162        | ClusterEvent::SupervisorStopped { meta, .. }
163        | ClusterEvent::NamespaceCreated { meta, .. }
164        | ClusterEvent::NamespacePlacementChanged { meta, .. }
165        | ClusterEvent::NamespaceQuotaState { meta, .. } => meta,
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use std::num::NonZeroUsize;
172
173    use aion_core::ClusterEvent;
174    use futures::StreamExt;
175
176    use super::*;
177
178    fn capacity(value: usize) -> Result<NonZeroUsize, Box<dyn std::error::Error>> {
179        NonZeroUsize::new(value).ok_or_else(|| "capacity must be non-zero".into())
180    }
181
182    fn supervisor_started(meta: ClusterEventMeta) -> ClusterEvent {
183        ClusterEvent::SupervisorStarted {
184            meta,
185            node: "node-1@127.0.0.1".to_owned(),
186        }
187    }
188
189    #[tokio::test]
190    async fn emit_stamps_monotonic_increasing_seq() -> Result<(), Box<dyn std::error::Error>> {
191        let publisher = ClusterEventPublisher::new(capacity(8)?);
192        let mut subscription = publisher.subscribe(0);
193
194        let first = publisher.emit(supervisor_started);
195        let second = publisher.emit(supervisor_started);
196
197        assert_eq!(cluster_event_meta(&first).cluster_seq, 1);
198        assert_eq!(cluster_event_meta(&second).cluster_seq, 2);
199
200        let received_first = subscription
201            .next()
202            .await
203            .ok_or("missing first")?
204            .map_err(|lag| format!("unexpected lag: {lag:?}"))?;
205        let received_second = subscription
206            .next()
207            .await
208            .ok_or("missing second")?
209            .map_err(|lag| format!("unexpected lag: {lag:?}"))?;
210        assert_eq!(cluster_event_meta(&received_first).cluster_seq, 1);
211        assert_eq!(cluster_event_meta(&received_second).cluster_seq, 2);
212        Ok(())
213    }
214
215    #[tokio::test]
216    async fn after_seq_suppresses_already_applied_splice_deltas()
217    -> Result<(), Box<dyn std::error::Error>> {
218        // Models the attach-before-snapshot splice: the receiver is attached
219        // first (a broadcast receiver only ever sees events sent AFTER it
220        // attaches), then deltas spanning the cursor arrive on the live stream.
221        // With after_seq=2 the already-applied seqs 1..=2 are suppressed and the
222        // first surfaced delta is seq 3.
223        let publisher = ClusterEventPublisher::new(capacity(8)?);
224        let mut subscription = publisher.subscribe(2);
225
226        for _ in 0..3 {
227            publisher.emit(supervisor_started);
228        }
229
230        let survivor = subscription
231            .next()
232            .await
233            .ok_or("missing survivor")?
234            .map_err(|lag| format!("unexpected lag: {lag:?}"))?;
235        assert_eq!(
236            cluster_event_meta(&survivor).cluster_seq,
237            3,
238            "deltas at or below after_seq must be suppressed at the splice seam"
239        );
240        Ok(())
241    }
242
243    #[tokio::test]
244    async fn lagged_subscriber_yields_typed_skip_count() -> Result<(), Box<dyn std::error::Error>> {
245        let publisher = ClusterEventPublisher::new(capacity(2)?);
246        let mut subscription = publisher.subscribe(0);
247
248        // Overflow the capacity-2 channel without consuming.
249        for _ in 0..5 {
250            publisher.emit(supervisor_started);
251        }
252
253        let lagged = subscription.next().await.ok_or("missing lag item")?;
254        assert_eq!(lagged, Err(ClusterStreamLagged { skipped: 3 }));
255        Ok(())
256    }
257
258    #[tokio::test]
259    async fn emit_with_no_subscribers_is_not_an_error() -> Result<(), Box<dyn std::error::Error>> {
260        let publisher = ClusterEventPublisher::new(capacity(2)?);
261        // No subscribers: emit must still advance the seq and not panic.
262        let event = publisher.emit(supervisor_started);
263        assert_eq!(cluster_event_meta(&event).cluster_seq, 1);
264        assert_eq!(publisher.current_seq(), 1);
265        Ok(())
266    }
267}