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::DispatchParked { meta, .. }
162        | ClusterEvent::SupervisorStarted { meta, .. }
163        | ClusterEvent::SupervisorStopped { meta, .. }
164        | ClusterEvent::NamespaceCreated { meta, .. }
165        | ClusterEvent::NamespacePlacementChanged { meta, .. }
166        | ClusterEvent::NamespaceQuotaState { meta, .. }
167        | ClusterEvent::WorkerDeploymentPut { meta, .. }
168        | ClusterEvent::WorkerDeploymentDesiredStateChanged { meta, .. }
169        | ClusterEvent::WorkerDeploymentDeleted { meta, .. } => meta,
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use std::num::NonZeroUsize;
176
177    use aion_core::ClusterEvent;
178    use futures::StreamExt;
179
180    use super::*;
181
182    fn capacity(value: usize) -> Result<NonZeroUsize, Box<dyn std::error::Error>> {
183        NonZeroUsize::new(value).ok_or_else(|| "capacity must be non-zero".into())
184    }
185
186    fn supervisor_started(meta: ClusterEventMeta) -> ClusterEvent {
187        ClusterEvent::SupervisorStarted {
188            meta,
189            node: "node-1@127.0.0.1".to_owned(),
190        }
191    }
192
193    #[tokio::test]
194    async fn emit_stamps_monotonic_increasing_seq() -> Result<(), Box<dyn std::error::Error>> {
195        let publisher = ClusterEventPublisher::new(capacity(8)?);
196        let mut subscription = publisher.subscribe(0);
197
198        let first = publisher.emit(supervisor_started);
199        let second = publisher.emit(supervisor_started);
200
201        assert_eq!(cluster_event_meta(&first).cluster_seq, 1);
202        assert_eq!(cluster_event_meta(&second).cluster_seq, 2);
203
204        let received_first = subscription
205            .next()
206            .await
207            .ok_or("missing first")?
208            .map_err(|lag| format!("unexpected lag: {lag:?}"))?;
209        let received_second = subscription
210            .next()
211            .await
212            .ok_or("missing second")?
213            .map_err(|lag| format!("unexpected lag: {lag:?}"))?;
214        assert_eq!(cluster_event_meta(&received_first).cluster_seq, 1);
215        assert_eq!(cluster_event_meta(&received_second).cluster_seq, 2);
216        Ok(())
217    }
218
219    #[tokio::test]
220    async fn after_seq_suppresses_already_applied_splice_deltas()
221    -> Result<(), Box<dyn std::error::Error>> {
222        // Models the attach-before-snapshot splice: the receiver is attached
223        // first (a broadcast receiver only ever sees events sent AFTER it
224        // attaches), then deltas spanning the cursor arrive on the live stream.
225        // With after_seq=2 the already-applied seqs 1..=2 are suppressed and the
226        // first surfaced delta is seq 3.
227        let publisher = ClusterEventPublisher::new(capacity(8)?);
228        let mut subscription = publisher.subscribe(2);
229
230        for _ in 0..3 {
231            publisher.emit(supervisor_started);
232        }
233
234        let survivor = subscription
235            .next()
236            .await
237            .ok_or("missing survivor")?
238            .map_err(|lag| format!("unexpected lag: {lag:?}"))?;
239        assert_eq!(
240            cluster_event_meta(&survivor).cluster_seq,
241            3,
242            "deltas at or below after_seq must be suppressed at the splice seam"
243        );
244        Ok(())
245    }
246
247    #[tokio::test]
248    async fn lagged_subscriber_yields_typed_skip_count() -> Result<(), Box<dyn std::error::Error>> {
249        let publisher = ClusterEventPublisher::new(capacity(2)?);
250        let mut subscription = publisher.subscribe(0);
251
252        // Overflow the capacity-2 channel without consuming.
253        for _ in 0..5 {
254            publisher.emit(supervisor_started);
255        }
256
257        let lagged = subscription.next().await.ok_or("missing lag item")?;
258        assert_eq!(lagged, Err(ClusterStreamLagged { skipped: 3 }));
259        Ok(())
260    }
261
262    #[tokio::test]
263    async fn emit_with_no_subscribers_is_not_an_error() -> Result<(), Box<dyn std::error::Error>> {
264        let publisher = ClusterEventPublisher::new(capacity(2)?);
265        // No subscribers: emit must still advance the seq and not panic.
266        let event = publisher.emit(supervisor_started);
267        assert_eq!(cluster_event_meta(&event).cluster_seq, 1);
268        assert_eq!(publisher.current_seq(), 1);
269        Ok(())
270    }
271}