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}