Skip to main content

reddb_server/wire/redwire/
output_stream.rs

1//! RedWire output-stream dispatch (issue #762, PRD #759 S3).
2//!
3//! Carries the wire-side lifecycle envelopes for an output stream:
4//!   - `OpenStream`  (client→server)  — request to start streaming
5//!     a `SELECT`'s rows back over a multiplexed `stream_id`.
6//!   - `OpenAck`     (server→client)  — server accepted; carries
7//!     the lease handle + the snapshot LSN the stream pinned.
8//!   - `StreamChunk` (server→client)  — one or more rows as JSON.
9//!     Multiple chunks per stream; `terminal: true` may be set on
10//!     the last one when the producer wishes to coalesce with
11//!     `StreamEnd`. The standalone `StreamEnd` envelope is the
12//!     canonical close-of-stream marker.
13//!   - `StreamError` (server→client)  — protocol violation or
14//!     execution error for a specific `stream_id`. Non-fatal at
15//!     the connection level (AC #6: server must not crash).
16//!   - `StreamEnd`   (server→client)  — close-of-stream marker
17//!     carrying summary stats (row_count, lease_id, snapshot_lsn).
18//!   - `StreamCancel`(client→server)  — client asks to terminate
19//!     a specific stream; other streams on the connection are
20//!     unaffected (AC #3).
21//!
22//! Reuses [`crate::server::output_stream`] for the lease + config
23//! primitives (S1 / issue #760) so HTTP and RedWire agree on TTL
24//! and chunk semantics.
25
26use std::collections::HashMap;
27use std::sync::Arc;
28
29use tokio::sync::{oneshot, Mutex};
30
31use crate::application::OperationContext;
32use crate::auth::Role;
33use crate::runtime::RedDBRuntime;
34use crate::serde_json::Value as JsonValue;
35use crate::server::output_stream::{
36    self as outs, Clock, OpenStreamError, StreamConfig, SystemClock,
37};
38pub use reddb_wire::redwire::stream::{
39    OpenStreamParseError, OpenStreamRequest, StreamCancelRequest,
40};
41use reddb_wire::redwire::{encode_frame, Frame};
42
43pub fn parse_open_stream(payload: &[u8]) -> Result<OpenStreamRequest, OpenStreamParseError> {
44    reddb_wire::redwire::stream::parse_open_stream(payload)
45}
46
47pub fn parse_stream_cancel(payload: &[u8]) -> StreamCancelRequest {
48    reddb_wire::redwire::stream::parse_stream_cancel(payload)
49}
50
51pub fn build_open_ack_frame(
52    correlation_id: u64,
53    stream_id: u16,
54    lease_id: u64,
55    snapshot_lsn: u64,
56    resumable: bool,
57) -> Result<Frame, reddb_wire::BuildError> {
58    reddb_wire::redwire::stream::build_open_ack_frame(
59        correlation_id,
60        stream_id,
61        lease_id,
62        snapshot_lsn,
63        resumable,
64    )
65}
66
67/// Per-connection registry of in-flight output streams. Keyed by
68/// `stream_id` — the wire-spec multiplex tag — so a `StreamCancel`
69/// can target one stream without disturbing the rest of the
70/// connection (AC #3).
71#[derive(Default)]
72pub struct StreamRegistry {
73    inner: Mutex<HashMap<u16, oneshot::Sender<()>>>,
74}
75
76impl StreamRegistry {
77    pub fn new() -> Self {
78        Self::default()
79    }
80
81    /// Register a new stream. Returns the receiver half the worker
82    /// task selects on for cancellation, or `Err(InUse)` if the
83    /// `stream_id` is already active on this connection.
84    pub async fn register(&self, stream_id: u16) -> Result<oneshot::Receiver<()>, RegisterError> {
85        if stream_id == 0 {
86            return Err(RegisterError::ReservedStreamId);
87        }
88        let mut guard = self.inner.lock().await;
89        if guard.contains_key(&stream_id) {
90            return Err(RegisterError::StreamInUse);
91        }
92        let (tx, rx) = oneshot::channel();
93        guard.insert(stream_id, tx);
94        Ok(rx)
95    }
96
97    /// Signal the named stream to cancel. Returns `false` if the
98    /// `stream_id` is unknown — caller should emit `StreamError`
99    /// with `unknown_stream`.
100    pub async fn cancel(&self, stream_id: u16) -> bool {
101        let mut guard = self.inner.lock().await;
102        match guard.remove(&stream_id) {
103            Some(tx) => {
104                let _ = tx.send(());
105                true
106            }
107            None => false,
108        }
109    }
110
111    /// Remove the stream from the registry once the worker task
112    /// has finished (normally or via cancel). Idempotent.
113    pub async fn unregister(&self, stream_id: u16) {
114        let mut guard = self.inner.lock().await;
115        guard.remove(&stream_id);
116    }
117
118    pub async fn active_count(&self) -> usize {
119        self.inner.lock().await.len()
120    }
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum RegisterError {
125    ReservedStreamId,
126    StreamInUse,
127}
128
129impl RegisterError {
130    pub fn code(&self) -> &'static str {
131        match self {
132            Self::ReservedStreamId => "open_stream_reserved_id",
133            Self::StreamInUse => "open_stream_id_in_use",
134        }
135    }
136    pub fn message(&self) -> &'static str {
137        match self {
138            Self::ReservedStreamId => {
139                "OpenStream cannot use stream_id 0 (reserved for unsolicited)"
140            }
141            Self::StreamInUse => "OpenStream stream_id already has an active stream",
142        }
143    }
144}
145
146/// Build a stand-alone `StreamError` frame addressed to a given
147/// `stream_id`. The correlation id echoes the request frame so a
148/// client can pair the error with the offending request.
149pub fn build_stream_error_frame(
150    correlation_id: u64,
151    stream_id: u16,
152    code: &str,
153    message: &str,
154) -> std::io::Result<Frame> {
155    reddb_wire::redwire::stream::build_stream_error_frame(
156        correlation_id,
157        stream_id,
158        None,
159        code,
160        message,
161    )
162    .map_err(|e| std::io::Error::other(format!("build StreamError: {e}")))
163}
164
165/// Run an output stream end-to-end. Emits OpenAck → StreamChunk*
166/// → StreamEnd via the supplied `send` closure, observing the
167/// `cancel_rx` between rows to honour `StreamCancel` (AC #3).
168///
169/// The function materialises `execute_query`'s result first
170/// (matching the S1 HTTP behaviour — pull-based scan executors
171/// are PRD #759 phase 3) and then dribbles rows out as
172/// `StreamChunk` envelopes via the same byte/row/latency
173/// page-aligned producer the HTTP path uses.
174pub async fn run_output_stream(
175    runtime: Arc<RedDBRuntime>,
176    correlation_id: u64,
177    stream_id: u16,
178    request: OpenStreamRequest,
179    in_transaction: bool,
180    mut cancel_rx: oneshot::Receiver<()>,
181    send: FrameTx,
182    operation_context: OperationContext,
183    role: Role,
184) {
185    let clock = SystemClock;
186    let config = StreamConfig::load(&runtime);
187    let snapshot_lsn = runtime.cdc_current_lsn();
188
189    let lease = match outs::open_stream(config, snapshot_lsn, in_transaction, &clock) {
190        Ok(l) => l,
191        Err(OpenStreamError::TransactionActive) => {
192            let err = OpenStreamError::TransactionActive;
193            let frame = match build_stream_error_frame(
194                correlation_id,
195                stream_id,
196                err.code(),
197                err.message(),
198            ) {
199                Ok(f) => f,
200                Err(_) => return,
201            };
202            send.send_frame(frame).await;
203            return;
204        }
205    };
206
207    // OpenAck — always first.
208    let ack = match reddb_wire::redwire::stream::build_open_ack_frame(
209        correlation_id,
210        stream_id,
211        lease.id,
212        lease.snapshot_lsn,
213        false,
214    ) {
215        Ok(f) => f,
216        Err(_) => return,
217    };
218    send.send_frame(ack).await;
219
220    // Materialise.
221    let result = super::session::execute_with_redwire_context(&operation_context, role, || {
222        runtime.execute_query(&request.sql)
223    });
224
225    // Stream rows out as StreamChunk envelopes.
226    let mut seq: u64 = 0;
227    let mut row_count: u64 = 0;
228    let mut cancelled = false;
229    let mut had_error: Option<(String, String)> = None;
230
231    match result {
232        Ok(qr) => {
233            let columns = qr.result.columns.clone();
234            let rows: Vec<JsonValue> = qr
235                .result
236                .records
237                .iter()
238                .map(|r| crate::presentation::query_result_json::unified_record_json(r, &columns))
239                .collect();
240
241            // One `StreamChunk` envelope per row. The page-aligned
242            // batcher used by the HTTP NDJSON path (S1) is byte-
243            // oriented; on the wire path each row already ships as
244            // its own framed envelope, so TCP / framing already
245            // handles the batching for us. Keeping one row per
246            // envelope keeps `StreamCancel` latency bounded to
247            // "between two adjacent rows".
248            for row in rows {
249                // Check cancel between rows (AC #3).
250                if let Ok(()) = cancel_rx.try_recv() {
251                    cancelled = true;
252                    break;
253                }
254                if lease.snapshot_expired(clock.now_ms()) {
255                    had_error = Some((
256                        "snapshot_expired".to_string(),
257                        "stream snapshot pin TTL elapsed".to_string(),
258                    ));
259                    break;
260                }
261                let row_bytes = row.to_string_compact().into_bytes();
262                let frame =
263                    match reddb_wire::redwire::stream::build_stream_chunk_frame_from_json_bytes(
264                        correlation_id,
265                        stream_id,
266                        seq,
267                        vec![row_bytes],
268                        false,
269                    ) {
270                        Ok(f) => f,
271                        Err(_) => break,
272                    };
273                send.send_frame(frame).await;
274                seq += 1;
275                row_count += 1;
276            }
277            // `config` is kept observed even when the batcher is
278            // bypassed so the frozen-config invariant from S1 still
279            // applies (no mid-stream KV-driven behaviour change).
280            let _ = config;
281        }
282        Err(err) => {
283            had_error = Some(("query_failed".to_string(), err.to_string()));
284        }
285    }
286
287    if let Some((code, message)) = had_error {
288        if let Ok(frame) = reddb_wire::redwire::stream::build_stream_error_frame(
289            correlation_id,
290            stream_id,
291            Some(seq),
292            &code,
293            &message,
294        ) {
295            send.send_frame(frame).await;
296        }
297    }
298
299    // StreamEnd is always emitted — including after error or
300    // cancel — so the client can drop bookkeeping on `StreamEnd`
301    // rather than on connection EOF.
302    if let Ok(frame) = reddb_wire::redwire::stream::build_stream_end_frame(
303        correlation_id,
304        stream_id,
305        row_count,
306        lease.id,
307        lease.snapshot_lsn,
308        cancelled,
309    ) {
310        send.send_frame(frame).await;
311    }
312}
313
314/// Encoded-frame transmit handle handed to stream workers. The
315/// session loop owns the matching receiver and drains it into the
316/// socket's write half — so multiple concurrent workers can
317/// interleave their output without contending on a writer mutex
318/// (AC #2 — interleaved chunks for two streams on one connection).
319#[derive(Clone)]
320pub struct FrameTx {
321    tx: tokio::sync::mpsc::Sender<Vec<u8>>,
322}
323
324impl FrameTx {
325    pub fn new(tx: tokio::sync::mpsc::Sender<Vec<u8>>) -> Self {
326        Self { tx }
327    }
328
329    /// Encode + enqueue. Drops silently if the receiver has been
330    /// dropped (connection torn down); the worker's next iteration
331    /// will hit cancellation / EOF and exit naturally.
332    pub async fn send_frame(&self, frame: Frame) {
333        let bytes = encode_frame(&frame);
334        let _ = self.tx.send(bytes).await;
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use reddb_wire::redwire::MessageKind;
342
343    #[test]
344    fn parse_open_stream_accepts_minimal_payload() {
345        let req = parse_open_stream(br#"{"sql":"SELECT 1"}"#).unwrap();
346        assert_eq!(req.sql, "SELECT 1");
347        assert!(req.opts_raw.is_empty());
348    }
349
350    #[test]
351    fn parse_open_stream_captures_opts_opaque() {
352        let req =
353            parse_open_stream(br#"{"sql":"SELECT 1","opts":{"resume_after_rid":42}}"#).unwrap();
354        assert_eq!(req.sql, "SELECT 1");
355        assert!(!req.opts_raw.is_empty());
356    }
357
358    #[test]
359    fn parse_open_stream_rejects_non_object() {
360        assert!(matches!(
361            parse_open_stream(b"\"sql\""),
362            Err(OpenStreamParseError::NotObject)
363        ));
364    }
365
366    #[test]
367    fn parse_open_stream_rejects_missing_sql() {
368        assert!(matches!(
369            parse_open_stream(b"{}"),
370            Err(OpenStreamParseError::MissingSql)
371        ));
372    }
373
374    #[test]
375    fn parse_open_stream_rejects_empty_sql() {
376        assert!(matches!(
377            parse_open_stream(br#"{"sql":""}"#),
378            Err(OpenStreamParseError::EmptySql)
379        ));
380    }
381
382    #[test]
383    fn parse_open_stream_rejects_invalid_json() {
384        assert!(matches!(
385            parse_open_stream(b"{not json"),
386            Err(OpenStreamParseError::NotJson)
387        ));
388    }
389
390    #[test]
391    fn parse_stream_cancel_with_reason() {
392        let r = parse_stream_cancel(br#"{"reason":"client-abort"}"#);
393        assert_eq!(r.reason.as_deref(), Some("client-abort"));
394    }
395
396    #[test]
397    fn parse_stream_cancel_empty_payload_is_default() {
398        assert_eq!(parse_stream_cancel(b""), StreamCancelRequest::default());
399        assert_eq!(parse_stream_cancel(b"{}"), StreamCancelRequest::default());
400    }
401
402    #[tokio::test]
403    async fn registry_rejects_reserved_id_and_duplicates() {
404        let r = StreamRegistry::new();
405        assert!(matches!(
406            r.register(0).await,
407            Err(RegisterError::ReservedStreamId)
408        ));
409        let _rx = r.register(1).await.unwrap();
410        assert!(matches!(
411            r.register(1).await,
412            Err(RegisterError::StreamInUse)
413        ));
414        assert_eq!(r.active_count().await, 1);
415    }
416
417    #[tokio::test]
418    async fn registry_cancel_signals_named_stream_only() {
419        // AC #3 — cancelling stream X must not disturb stream Y.
420        let r = StreamRegistry::new();
421        let rx1 = r.register(1).await.unwrap();
422        let mut rx2 = r.register(2).await.unwrap();
423        assert!(r.cancel(1).await);
424        // Stream 1's rx fires.
425        assert!(rx1.await.is_ok());
426        // Stream 2's rx remains pending (try_recv would yield Empty).
427        match rx2.try_recv() {
428            Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {}
429            other => panic!("stream 2 should not be cancelled: {other:?}"),
430        }
431        assert_eq!(r.active_count().await, 1);
432    }
433
434    #[tokio::test]
435    async fn registry_cancel_unknown_returns_false() {
436        let r = StreamRegistry::new();
437        assert!(!r.cancel(99).await);
438    }
439
440    #[tokio::test]
441    async fn registry_unregister_is_idempotent() {
442        let r = StreamRegistry::new();
443        let _rx = r.register(1).await.unwrap();
444        r.unregister(1).await;
445        r.unregister(1).await;
446        assert_eq!(r.active_count().await, 0);
447    }
448
449    #[test]
450    fn build_stream_error_frame_carries_stream_id_and_correlation() {
451        let frame = build_stream_error_frame(99, 7, "unknown_stream", "no such stream").unwrap();
452        assert_eq!(frame.kind, MessageKind::StreamError);
453        assert_eq!(frame.stream_id, 7);
454        assert_eq!(frame.correlation_id, 99);
455    }
456}