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}