Skip to main content

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