aion_server/stream/cluster_stream.rs
1//! WS3 cluster subscription: deploy-gated snapshot + live-delta forward loop.
2//!
3//! This is the cluster-channel counterpart to [`super::socket`]'s workflow
4//! forward loop. It is a NEW ARM on the existing single subscription frame of
5//! `/events/stream` (the socket stays one-subscription-per-socket; there is no
6//! multiplexing layer). A client that wants both the workflow stream and the
7//! cluster stream opens two `/events/stream` sockets.
8//!
9//! # Authorization: deploy-scope only (strict)
10//!
11//! Cluster topology is deployment-wide: peer names, shard ownership, worker
12//! identities across every namespace. Exposing that to any single-namespace
13//! tenant is a cross-tenant topology leak. So the cluster channel requires the
14//! caller's **deploy grant** ([`CallerIdentity::deploy_granted`]) — the same
15//! deployment-wide grant the deploy API uses — and nothing less. A caller
16//! without it receives exactly one terminal `namespace_denied` frame then close,
17//! byte-identical to the workflow path's rejection shape (no existence leak).
18//!
19//! Because the gate is a pure deploy-grant check it needs no engine handle (it
20//! reads supervisor/registry/store state, never a namespace engine), sidestepping
21//! the `guard.scope(...).engine()?` requirement the workflow path has.
22
23use aion_core::{ClusterSnapshot, ClusterStreamError, ClusterWorker, WorkerTransport};
24use aion_proto::{StreamedClusterEvent, StreamedClusterSnapshot, WireError};
25use axum::extract::ws::{CloseFrame, Message, WebSocket, close_code};
26use futures::{SinkExt, StreamExt};
27
28use crate::cluster_publisher::ClusterStreamLagged;
29use crate::error::ServerError;
30use crate::namespace::CallerIdentity;
31use crate::state::ServerState;
32use crate::worker::WorkerDelivery;
33
34/// Serve a cluster subscription on an already-upgraded socket.
35///
36/// Flow (mirrors `subscribe.rs` ordering): deploy-gate FIRST; then attach to the
37/// live broadcast BEFORE reading the snapshot (gap-free splice — any delta that
38/// races the snapshot is buffered by the receiver and deduped by the snapshot's
39/// `as_of_seq`); send the priming snapshot; forward live deltas until the client
40/// closes or the subscriber lags (one typed `cluster_lagged` frame then close).
41///
42/// # Errors
43///
44/// Returns [`ServerError`] when the deploy gate denies the caller (after the
45/// terminal frame is sent) or the stream ends with a lag terminal frame.
46pub async fn serve_cluster_socket(
47 mut socket: WebSocket,
48 state: &ServerState,
49 caller: &CallerIdentity,
50 after_seq: u64,
51) -> Result<(), ServerError> {
52 // GATE FIRST: deploy grant or nothing. Denial is one terminal frame + close.
53 if !caller.deploy_granted() {
54 let error = ServerError::namespace_denied(
55 "cluster topology subscription requires the deployment-wide deploy grant",
56 );
57 super::socket::send_wire_error(&mut socket, &error.to_wire_error()).await?;
58 return Err(error);
59 }
60
61 // T0: attach to the live broadcast BEFORE snapshotting, so a delta emitted
62 // between the snapshot read and the first live poll is retained by the
63 // receiver and applied after the snapshot (deduped on `cluster_seq`).
64 let publisher = state.cluster_publisher();
65 let mut live = publisher.subscribe(after_seq);
66
67 // T1 (> T0): read the calm-state snapshot. `as_of_seq` is the publisher's
68 // current seq; the client applies only deltas with `cluster_seq > as_of_seq`.
69 let snapshot = build_snapshot(state, caller)?;
70 let priming = StreamedClusterSnapshot::new(snapshot);
71 let priming = serde_json::to_string(&priming).map_err(|source| ServerError::Wire {
72 wire: WireError::backend(format!(
73 "failed to serialize cluster snapshot frame: {source}"
74 )),
75 })?;
76 if socket.send(Message::Text(priming.into())).await.is_err() {
77 // Client gone before the priming frame landed: a clean end.
78 return Ok(());
79 }
80
81 let (mut socket_tx, mut socket_rx) = socket.split();
82 loop {
83 tokio::select! {
84 client_message = socket_rx.next() => {
85 match client_message {
86 // Close, socket error, or any inbound frame ends the read
87 // side; the cluster channel takes no further client frames
88 // (one-subscription-per-socket), so an inbound frame after
89 // subscribe is treated as a benign close, exactly like the
90 // workflow `drive_socket`.
91 Some(Ok(Message::Close(_))) | None => return send_normal_close(&mut socket_tx).await,
92 Some(Ok(_other)) => {}
93 Some(Err(_error)) => return Ok(()),
94 }
95 }
96 item = live.next() => {
97 match item {
98 Some(Ok(event)) => {
99 let frame = StreamedClusterEvent::new(event);
100 let frame = match serde_json::to_string(&frame) {
101 Ok(frame) => frame,
102 Err(source) => {
103 let error = ServerError::Wire {
104 wire: WireError::backend(format!(
105 "failed to serialize cluster event frame: {source}"
106 )),
107 };
108 super::socket::send_wire_error(&mut socket_tx, &error.to_wire_error()).await?;
109 return Err(error);
110 }
111 };
112 if socket_tx.send(Message::Text(frame.into())).await.is_err() {
113 return Ok(());
114 }
115 }
116 Some(Err(ClusterStreamLagged { skipped })) => {
117 // Typed terminal `cluster_lagged` frame carrying the
118 // skipped count, then close — the client re-requests a
119 // fresh snapshot (no durable cluster history to resume).
120 let lagged = ClusterStreamError::ClusterLagged { skipped };
121 return deliver_cluster_terminal(&mut socket_tx, &lagged).await;
122 }
123 None => {
124 // The publisher channel closed (server shutting down):
125 // finish the close handshake cleanly.
126 return send_normal_close(&mut socket_tx).await;
127 }
128 }
129 }
130 }
131 }
132}
133
134/// Build the calm-state snapshot: self node identity, the deploy-granted view of
135/// connected workers, and (Phase 1) empty peers/shards.
136///
137/// Peers/shards are intentionally empty here: the supervisor's watched-peer set
138/// and the shard-owner directory are only meaningful on a distributed haematite
139/// boot, and surfacing them honestly requires threading that state in a later
140/// increment. A single-node server has no peers and owns every shard implicitly,
141/// so an empty peers/shards snapshot is the truthful calm state, not a stub. The
142/// dashboard derives liveness from the live `Peer*`/`Shard*` deltas the
143/// supervisor emits once wired.
144pub(crate) fn build_snapshot(
145 state: &ServerState,
146 _caller: &CallerIdentity,
147) -> Result<ClusterSnapshot, ServerError> {
148 let node = state.cluster_self_node().map_or_else(
149 || STANDALONE_NODE_LABEL.to_owned(),
150 std::borrow::ToOwned::to_owned,
151 );
152 // Deploy-granted callers see every connected worker (cluster topology is
153 // deployment-wide for a deploy-scoped caller). The deploy gate already ran,
154 // so no per-namespace redaction applies on this strict-scope path.
155 let workers = state
156 .worker_registry()
157 .all_workers()?
158 .into_iter()
159 .map(|handle| ClusterWorker {
160 worker_id: handle.id().value().to_string(),
161 namespaces: handle.namespaces().iter().cloned().collect(),
162 task_queue: handle.task_queue().to_owned(),
163 transport: transport_of(handle.delivery()),
164 node: handle.node().map(str::to_owned),
165 })
166 .collect();
167 Ok(ClusterSnapshot {
168 node,
169 as_of_seq: state.cluster_publisher().current_seq(),
170 peers: Vec::new(),
171 shards: Vec::new(),
172 workers,
173 })
174}
175
176/// Self-label reported as the snapshot `node` on a single-node boot that carries
177/// no configured cluster distribution name.
178const STANDALONE_NODE_LABEL: &str = "standalone";
179
180/// Map a registry [`WorkerDelivery`] to the wire [`WorkerTransport`] discriminant.
181const fn transport_of(delivery: &WorkerDelivery) -> WorkerTransport {
182 match delivery {
183 WorkerDelivery::Grpc(_) => WorkerTransport::Grpc,
184 #[cfg(feature = "liminal-transport")]
185 WorkerDelivery::Liminal(_) => WorkerTransport::Liminal,
186 }
187}
188
189/// Send the typed cluster terminal error frame + close, then surface it typed.
190async fn deliver_cluster_terminal<Tx>(
191 socket_tx: &mut Tx,
192 error: &ClusterStreamError,
193) -> Result<(), ServerError>
194where
195 Tx: futures::Sink<Message> + Unpin,
196 <Tx as futures::Sink<Message>>::Error: std::fmt::Debug,
197{
198 // The cluster terminal frame is the typed `ClusterStreamError` wrapped as
199 // `{"error": ...}` — the same wrapper shape every SDK detects as terminal.
200 let payload = serde_json::json!({ "error": error });
201 let payload = serde_json::to_string(&payload).map_err(|source| ServerError::Wire {
202 wire: WireError::backend(format!(
203 "failed to serialize cluster stream error: {source}"
204 )),
205 })?;
206 if socket_tx.send(Message::Text(payload.into())).await.is_ok() {
207 let close = CloseFrame {
208 code: close_code::ERROR,
209 reason: "cluster_lagged".into(),
210 };
211 let close_result = socket_tx.send(Message::Close(Some(close))).await;
212 drop(close_result);
213 }
214 Err(ServerError::lagged_stream())
215}
216
217/// Finish a graceful cluster subscription end with a close-1000 frame.
218async fn send_normal_close<Tx>(socket_tx: &mut Tx) -> Result<(), ServerError>
219where
220 Tx: futures::Sink<Message> + Unpin,
221 <Tx as futures::Sink<Message>>::Error: std::fmt::Debug,
222{
223 let close = CloseFrame {
224 code: close_code::NORMAL,
225 reason: "subscription complete".into(),
226 };
227 let close_result = socket_tx.send(Message::Close(Some(close))).await;
228 drop(close_result);
229 Ok(())
230}