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::query_request::{PreparedRegistry, QueryRequest, QueryRequestExecutor};
34use crate::runtime::RedDBRuntime;
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::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 an admitted request 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 = match StreamConfig::load(&runtime) {
187        Ok(config) => config,
188        Err(error) => {
189            if let Ok(frame) = build_stream_error_frame(
190                correlation_id,
191                stream_id,
192                "stream_config_unavailable",
193                &error.to_string(),
194            ) {
195                if send.send_frame(frame).await.is_err() {
196                    return;
197                }
198            }
199            return;
200        }
201    };
202    let snapshot_lsn = runtime.cdc_current_lsn();
203
204    let lease = match outs::open_stream(config, snapshot_lsn, in_transaction, &clock) {
205        Ok(l) => l,
206        Err(OpenStreamError::TransactionActive) => {
207            let err = OpenStreamError::TransactionActive;
208            let frame = match build_stream_error_frame(
209                correlation_id,
210                stream_id,
211                err.code(),
212                err.message(),
213            ) {
214                Ok(f) => f,
215                Err(_) => return,
216            };
217            let _ = send.send_frame(frame).await;
218            return;
219        }
220    };
221
222    // OpenAck — always first.
223    let ack = match reddb_wire::redwire::stream::build_open_ack_frame(
224        correlation_id,
225        stream_id,
226        lease.id,
227        lease.snapshot_lsn,
228        false,
229    ) {
230        Ok(f) => f,
231        Err(_) => return,
232    };
233    if send.send_frame(ack).await.is_err() {
234        return;
235    }
236
237    // Keep the completed result's shared admission until the stream finishes
238    // or its worker is cancelled, including while a slow peer blocks sending.
239    let result = super::session::execute_with_redwire_context(&operation_context, role, || {
240        QueryRequestExecutor::new(&runtime, &PreparedRegistry::new())
241            .execute(QueryRequest::sql(request.sql, Vec::new()))
242    });
243
244    // Stream rows out as StreamChunk envelopes.
245    let mut seq: u64 = 0;
246    let mut row_count: u64 = 0;
247    let mut cancelled = false;
248    let mut had_error: Option<(String, String)> = None;
249
250    match result {
251        Ok(qr) => {
252            // One `StreamChunk` envelope per row. The page-aligned
253            // batcher used by the HTTP NDJSON path (S1) is byte-
254            // oriented; on the wire path each row already ships as
255            // its own framed envelope, so TCP / framing already
256            // handles the batching for us. Keeping one row per
257            // envelope keeps `StreamCancel` latency bounded to
258            // "between two adjacent rows".
259            for record in &qr.result.records {
260                // Check cancel between rows (AC #3).
261                if let Ok(()) = cancel_rx.try_recv() {
262                    cancelled = true;
263                    break;
264                }
265                if lease.snapshot_expired(clock.now_ms()) {
266                    had_error = Some((
267                        "snapshot_expired".to_string(),
268                        "stream snapshot pin TTL elapsed".to_string(),
269                    ));
270                    break;
271                }
272                // Render only the next row. Retaining a second complete JSON
273                // result beside the admitted records doubles stream residency.
274                let row_bytes = crate::presentation::query_result_json::unified_record_json(
275                    record,
276                    &qr.result.columns,
277                )
278                .to_string_compact()
279                .into_bytes();
280                let frame =
281                    match reddb_wire::redwire::stream::build_stream_chunk_frame_from_json_bytes(
282                        correlation_id,
283                        stream_id,
284                        seq,
285                        vec![row_bytes],
286                        false,
287                    ) {
288                        Ok(f) => f,
289                        Err(error) => {
290                            had_error = Some((
291                                "stream_chunk_encoding_failed".to_string(),
292                                error.to_string(),
293                            ));
294                            break;
295                        }
296                    };
297                if send.send_frame(frame).await.is_err() {
298                    return;
299                }
300                seq += 1;
301                row_count += 1;
302            }
303            // `config` is kept observed even when the batcher is
304            // bypassed so the frozen-config invariant from S1 still
305            // applies (no mid-stream KV-driven behaviour change).
306            let _ = config;
307        }
308        Err(err) => {
309            had_error = Some(("query_failed".to_string(), err.to_string()));
310        }
311    }
312
313    if let Some((code, message)) = had_error {
314        if let Ok(frame) = reddb_wire::redwire::stream::build_stream_error_frame(
315            correlation_id,
316            stream_id,
317            Some(seq),
318            &code,
319            &message,
320        ) {
321            let _ = send.send_frame(frame).await;
322        }
323    }
324
325    // StreamEnd is always emitted — including after error or
326    // cancel — so the client can drop bookkeeping on `StreamEnd`
327    // rather than on connection EOF.
328    if let Ok(frame) = reddb_wire::redwire::stream::build_stream_end_frame(
329        correlation_id,
330        stream_id,
331        row_count,
332        lease.id,
333        lease.snapshot_lsn,
334        cancelled,
335    ) {
336        let _ = send.send_frame(frame).await;
337    }
338}
339
340/// Encoded-frame transmit handle handed to stream workers. The
341/// session loop owns the matching receiver and drains it into the
342/// socket's write half — so multiple concurrent workers can
343/// interleave their output without contending on a writer mutex
344/// (AC #2 — interleaved chunks for two streams on one connection).
345#[derive(Clone)]
346pub struct FrameTx {
347    tx: tokio::sync::mpsc::Sender<bytes::Bytes>,
348    runtime: Arc<RedDBRuntime>,
349    failure: tokio::sync::watch::Sender<bool>,
350}
351
352impl FrameTx {
353    pub fn new(tx: tokio::sync::mpsc::Sender<bytes::Bytes>, runtime: Arc<RedDBRuntime>) -> Self {
354        Self {
355            tx,
356            runtime,
357            failure: tokio::sync::watch::channel(false).0,
358        }
359    }
360
361    pub(crate) fn spawn_writer<W>(
362        &self,
363        mut receiver: tokio::sync::mpsc::Receiver<bytes::Bytes>,
364        mut writer: W,
365    ) -> tokio::task::JoinHandle<()>
366    where
367        W: tokio::io::AsyncWrite + Unpin + Send + 'static,
368    {
369        use tokio::io::AsyncWriteExt;
370        let failure = self.failure.clone();
371        tokio::spawn(async move {
372            let mut failed = failure.subscribe();
373            // Do not retain a frame sender here: clean Bye/half-close must
374            // drain to EOF. Failure cancels even a blocked socket write and
375            // drops both its current buffer and all queued buffers.
376            tokio::select! {
377                biased;
378                _ = failed.wait_for(|value| *value) => {},
379                _ = async {
380                    while let Some(bytes) = receiver.recv().await {
381                        if writer.write_all(&bytes).await.is_err() {
382                            failure.send_replace(true);
383                            break;
384                        }
385                    }
386                } => {},
387            }
388        })
389    }
390
391    pub(crate) async fn failed(&self) {
392        let _ = self.failure.subscribe().wait_for(|value| *value).await;
393    }
394
395    /// Reserve both the payload and the encoded allocation before copying.
396    /// Exhaustion fails this connection; never deliver a truncated success or
397    /// let a stream worker silently discard a frame and continue with StreamEnd.
398    pub async fn send_frame(&self, frame: Frame) -> std::io::Result<()> {
399        let result = self.send_frame_inner(frame).await;
400        if result.is_err() {
401            self.failure.send_replace(true);
402        }
403        result
404    }
405
406    /// Keep the original result reservation while admitting the RedWire copy.
407    /// Existing direct-scan/error buffers are explicitly charged as scratch;
408    /// already admitted materialized replies must not be charged twice.
409    pub(crate) async fn send_legacy_response(
410        &self,
411        response: crate::wire::listener::LegacyQueryResponse,
412        correlation_id: u64,
413    ) -> std::io::Result<()> {
414        let result = self
415            .send_legacy_response_inner(response, correlation_id)
416            .await;
417        if result.is_err() {
418            self.failure.send_replace(true);
419        }
420        result
421    }
422
423    async fn send_legacy_response_inner(
424        &self,
425        response: crate::wire::listener::LegacyQueryResponse,
426        correlation_id: u64,
427    ) -> std::io::Result<()> {
428        use reddb_wire::redwire::builder::BorrowedLegacyReply;
429        if *self.failure.borrow() {
430            return Err(std::io::Error::other("redwire: write channel failed"));
431        }
432        let capacity = BorrowedLegacyReply::new(&response, correlation_id)
433            .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?
434            .encoded_len();
435        let bytes = self
436            .runtime
437            .serialize_query_buffer(capacity, response.unadmitted_capacity(), move |buffer| {
438                BorrowedLegacyReply::new(&response, correlation_id)
439                    .expect("validated immutable legacy response")
440                    .encode_into(buffer);
441                drop(response);
442            })
443            .map_err(|_| {
444                std::io::Error::new(
445                    std::io::ErrorKind::OutOfMemory,
446                    "redwire: response exceeds shared memory budget",
447                )
448            })?;
449        self.send_bytes(bytes).await
450    }
451
452    async fn send_frame_inner(&self, mut frame: Frame) -> std::io::Result<()> {
453        use reddb_wire::redwire::{Flags, FRAME_HEADER_SIZE, MAX_FRAME_SIZE};
454        if *self.failure.borrow() {
455            return Err(std::io::Error::other("redwire: write channel failed"));
456        }
457        // Session builders emit uncompressed replies. A producer requesting
458        // compression gets the protocol's existing plaintext fallback until
459        // the compression workspace has its own admission. Preserve all other
460        // flags and avoid allocating an unaccounted compressor workspace.
461        frame.flags = Flags::from_bits(frame.flags.bits() & !Flags::COMPRESSED.bits());
462        let capacity = frame
463            .payload
464            .len()
465            .checked_add(FRAME_HEADER_SIZE)
466            .filter(|size| *size <= MAX_FRAME_SIZE as usize)
467            .ok_or_else(|| {
468                std::io::Error::new(
469                    std::io::ErrorKind::InvalidInput,
470                    "redwire: response exceeds frame limit",
471                )
472            })?;
473        let bytes = self
474            .runtime
475            .serialize_query_buffer(capacity, frame.payload.capacity(), move |buffer| {
476                reddb_wire::redwire::codec::encode_uncompressed_frame_into(&frame, buffer);
477                drop(frame);
478            })
479            .map_err(|_| {
480                std::io::Error::new(
481                    std::io::ErrorKind::OutOfMemory,
482                    "redwire: response exceeds shared memory budget",
483                )
484            })?;
485        self.send_bytes(bytes).await
486    }
487
488    async fn send_bytes(&self, bytes: bytes::Bytes) -> std::io::Result<()> {
489        tokio::select! {
490            biased;
491            _ = self.failed() => Err(std::io::Error::other("redwire: write channel failed")),
492            result = self.tx.send(bytes) => result.map_err(|_| std::io::Error::other("redwire: write channel closed")),
493        }
494    }
495}
496
497#[cfg(test)]
498mod memory_tests;
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use reddb_wire::redwire::MessageKind;
504
505    #[tokio::test]
506    async fn outbound_memory_survives_queue_receive_and_response_clones() {
507        use crate::storage::memory_pools::MemoryPool;
508        let runtime = RedDBRuntime::with_options(
509            crate::RedDBOptions::in_memory().with_memory_budget(1024 * 1024),
510        )
511        .expect("bounded runtime");
512        let (tx, mut rx) = tokio::sync::mpsc::channel(4);
513        let runtime = Arc::new(runtime);
514        let sender = FrameTx::new(tx, Arc::clone(&runtime));
515        sender
516            .send_frame(
517                reddb_wire::redwire::build_reply_frame(7, MessageKind::Result, vec![1; 128 * 1024])
518                    .expect("reply fixture"),
519            )
520            .await
521            .expect("admitted response");
522        assert!(
523            runtime
524                .memory_accounting()
525                .used_bytes(MemoryPool::QueryBuffers)
526                >= 128 * 1024,
527            "queued encoded responses must remain charged to the runtime budget"
528        );
529        let bytes = rx.recv().await.expect("queued response");
530        let copy = bytes.clone();
531        drop(bytes);
532        assert!(
533            runtime
534                .memory_accounting()
535                .used_bytes(MemoryPool::QueryBuffers)
536                >= copy.len() as u64,
537            "socket ownership and clones must retain admission after dequeue"
538        );
539        let (frame, consumed) = reddb_wire::redwire::decode_frame(&copy).expect("wire bytes");
540        assert_eq!(consumed, copy.len());
541        assert_eq!(frame.correlation_id, 7);
542        assert_eq!(frame.payload, vec![1; 128 * 1024]);
543        drop(copy);
544        assert_eq!(
545            runtime
546                .memory_accounting()
547                .used_bytes(MemoryPool::QueryBuffers),
548            0
549        );
550    }
551
552    #[test]
553    fn parse_open_stream_accepts_minimal_payload() {
554        let req = parse_open_stream(br#"{"sql":"SELECT 1"}"#).unwrap();
555        assert_eq!(req.sql, "SELECT 1");
556        assert!(req.opts_raw.is_empty());
557    }
558
559    #[test]
560    fn parse_open_stream_captures_opts_opaque() {
561        let req =
562            parse_open_stream(br#"{"sql":"SELECT 1","opts":{"resume_after_rid":42}}"#).unwrap();
563        assert_eq!(req.sql, "SELECT 1");
564        assert!(!req.opts_raw.is_empty());
565    }
566
567    #[test]
568    fn parse_open_stream_rejects_non_object() {
569        assert!(matches!(
570            parse_open_stream(b"\"sql\""),
571            Err(OpenStreamParseError::NotObject)
572        ));
573    }
574
575    #[test]
576    fn parse_open_stream_rejects_missing_sql() {
577        assert!(matches!(
578            parse_open_stream(b"{}"),
579            Err(OpenStreamParseError::MissingSql)
580        ));
581    }
582
583    #[test]
584    fn parse_open_stream_rejects_empty_sql() {
585        assert!(matches!(
586            parse_open_stream(br#"{"sql":""}"#),
587            Err(OpenStreamParseError::EmptySql)
588        ));
589    }
590
591    #[test]
592    fn parse_open_stream_rejects_invalid_json() {
593        assert!(matches!(
594            parse_open_stream(b"{not json"),
595            Err(OpenStreamParseError::NotJson)
596        ));
597    }
598
599    #[test]
600    fn parse_stream_cancel_with_reason() {
601        let r = parse_stream_cancel(br#"{"reason":"client-abort"}"#);
602        assert_eq!(r.reason.as_deref(), Some("client-abort"));
603    }
604
605    #[test]
606    fn parse_stream_cancel_empty_payload_is_default() {
607        assert_eq!(parse_stream_cancel(b""), StreamCancelRequest::default());
608        assert_eq!(parse_stream_cancel(b"{}"), StreamCancelRequest::default());
609    }
610
611    #[tokio::test]
612    async fn registry_rejects_reserved_id_and_duplicates() {
613        let r = StreamRegistry::new();
614        assert!(matches!(
615            r.register(0).await,
616            Err(RegisterError::ReservedStreamId)
617        ));
618        let _rx = r.register(1).await.unwrap();
619        assert!(matches!(
620            r.register(1).await,
621            Err(RegisterError::StreamInUse)
622        ));
623        assert_eq!(r.active_count().await, 1);
624    }
625
626    #[tokio::test]
627    async fn registry_cancel_signals_named_stream_only() {
628        // AC #3 — cancelling stream X must not disturb stream Y.
629        let r = StreamRegistry::new();
630        let rx1 = r.register(1).await.unwrap();
631        let mut rx2 = r.register(2).await.unwrap();
632        assert!(r.cancel(1).await);
633        // Stream 1's rx fires.
634        assert!(rx1.await.is_ok());
635        // Stream 2's rx remains pending (try_recv would yield Empty).
636        match rx2.try_recv() {
637            Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {}
638            other => panic!("stream 2 should not be cancelled: {other:?}"),
639        }
640        assert_eq!(r.active_count().await, 1);
641    }
642
643    #[tokio::test]
644    async fn registry_cancel_unknown_returns_false() {
645        let r = StreamRegistry::new();
646        assert!(!r.cancel(99).await);
647    }
648
649    #[tokio::test]
650    async fn registry_unregister_is_idempotent() {
651        let r = StreamRegistry::new();
652        let _rx = r.register(1).await.unwrap();
653        r.unregister(1).await;
654        r.unregister(1).await;
655        assert_eq!(r.active_count().await, 0);
656    }
657
658    #[test]
659    fn build_stream_error_frame_carries_stream_id_and_correlation() {
660        let frame = build_stream_error_frame(99, 7, "unknown_stream", "no such stream").unwrap();
661        assert_eq!(frame.kind, MessageKind::StreamError);
662        assert_eq!(frame.stream_id, 7);
663        assert_eq!(frame.correlation_id, 99);
664    }
665}