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