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