Skip to main content

fraiseql_wire/stream/json_stream/
mod.rs

1//! JSON stream implementation
2
3use crate::protocol::BackendMessage;
4use crate::{Result, WireError};
5use bytes::Bytes;
6use futures::stream::Stream;
7use serde_json::Value;
8use std::pin::Pin;
9use std::sync::atomic::{AtomicU64, AtomicU8, AtomicUsize, Ordering};
10use std::sync::Arc;
11use std::task::{Context, Poll};
12use std::time::Duration;
13use tokio::sync::{mpsc, Mutex, Notify};
14
15// Lightweight state machine constants
16// Used for fast state tracking without full Mutex overhead
17pub const STATE_RUNNING: u8 = 0;
18pub const STATE_PAUSED: u8 = 1;
19pub const STATE_COMPLETED: u8 = 2;
20pub const STATE_FAILED: u8 = 3;
21
22/// Stream state machine
23///
24/// Tracks the current state of the JSON stream.
25/// Streams start in Running state and can transition to Paused
26/// or terminal states (Completed, Failed).
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28#[non_exhaustive]
29pub enum StreamState {
30    /// Background task is actively reading from Postgres
31    Running,
32    /// Background task is paused (suspended, connection alive)
33    Paused,
34    /// Query completed normally
35    Completed,
36    /// Query failed with error
37    Failed,
38}
39
40/// Stream statistics snapshot
41///
42/// Provides a read-only view of current stream state without consuming items.
43/// All values are point-in-time measurements.
44#[derive(Debug, Clone)]
45pub struct StreamStats {
46    /// Number of items currently buffered in channel (0-256)
47    pub items_buffered: usize,
48    /// Estimated memory used by buffered items in bytes
49    pub estimated_memory: usize,
50    /// Total rows yielded to consumer so far
51    pub total_rows_yielded: u64,
52    /// Total rows filtered out by Rust predicates
53    pub total_rows_filtered: u64,
54}
55
56impl StreamStats {
57    /// Create zero-valued stats
58    ///
59    /// Useful for testing and initialization.
60    #[must_use]
61    pub const fn zero() -> Self {
62        Self {
63            items_buffered: 0,
64            estimated_memory: 0,
65            total_rows_yielded: 0,
66            total_rows_filtered: 0,
67        }
68    }
69}
70
71/// JSON value stream
72pub struct JsonStream {
73    receiver: mpsc::Receiver<Result<Value>>,
74    _cancel_tx: mpsc::Sender<()>,  // Dropped when stream is dropped
75    entity: String,                // Entity name for metrics
76    rows_yielded: Arc<AtomicU64>,  // Counter of items yielded to consumer
77    rows_filtered: Arc<AtomicU64>, // Counter of items filtered
78    max_memory: Option<usize>,     // Optional memory limit in bytes
79    soft_limit_fail_threshold: Option<f32>, // Fail at threshold % (0.0-1.0)
80
81    // Lightweight state tracking (cheap AtomicU8)
82    // Used for fast state checks on all queries
83    // Values: 0=Running, 1=Paused, 2=Complete, 3=Error
84    state_atomic: Arc<AtomicU8>,
85
86    // Pause/resume state machine, eagerly allocated (audit H43)
87    pause_resume: PauseResumeState,
88
89    // Sampling counter for metrics recording (sample 1 in N polls)
90    poll_count: AtomicU64, // Counter for sampling metrics
91}
92
93/// Pause/resume state, eagerly allocated in [`JsonStream::new`].
94///
95/// Every handle here is an `Arc` so the same instances can be cloned to the
96/// background reader at spawn time *and* retained by the stream. The previous
97/// design allocated this lazily on the first `pause()` call — but by then the
98/// reader had already captured `None` clones, so pause/resume never reached it
99/// and the pause-timeout/occupancy metrics were permanently dead (audit H43).
100pub struct PauseResumeState {
101    state: Arc<Mutex<StreamState>>,     // Current stream state
102    resume_signal: Arc<Notify>,         // Wakes the reader when resumed
103    paused_occupancy: Arc<AtomicUsize>, // Buffered rows captured at pause time
104    pause_timeout_ms: Arc<AtomicU64>, // Auto-resume timeout in ms (0 = none), read live by the reader
105}
106
107impl JsonStream {
108    /// Create new JSON stream
109    pub(crate) fn new(
110        receiver: mpsc::Receiver<Result<Value>>,
111        cancel_tx: mpsc::Sender<()>,
112        entity: String,
113        max_memory: Option<usize>,
114        _soft_limit_warn_threshold: Option<f32>,
115        soft_limit_fail_threshold: Option<f32>,
116    ) -> Self {
117        Self {
118            receiver,
119            _cancel_tx: cancel_tx,
120            entity,
121            rows_yielded: Arc::new(AtomicU64::new(0)),
122            rows_filtered: Arc::new(AtomicU64::new(0)),
123            max_memory,
124            soft_limit_fail_threshold,
125
126            // Initialize lightweight atomic state
127            state_atomic: Arc::new(AtomicU8::new(STATE_RUNNING)),
128
129            // Pause/resume infrastructure is allocated eagerly so the background
130            // reader receives live handles at spawn time (audit H43).
131            pause_resume: PauseResumeState {
132                state: Arc::new(Mutex::new(StreamState::Running)),
133                resume_signal: Arc::new(Notify::new()),
134                paused_occupancy: Arc::new(AtomicUsize::new(0)),
135                pause_timeout_ms: Arc::new(AtomicU64::new(0)),
136            },
137
138            // Initialize sampling counter
139            poll_count: AtomicU64::new(0),
140        }
141    }
142
143    /// Get current stream state
144    ///
145    /// Returns the current state of the stream (Running, Paused, Completed, or Failed).
146    /// This is a synchronous getter that doesn't require awaiting.
147    ///
148    /// Note: This is a best-effort snapshot that may return slightly stale state
149    /// due to the non-blocking nature of atomic reads.
150    pub fn state_snapshot(&self) -> StreamState {
151        // Read from lightweight atomic state (fast path, no locks)
152        match self.state_atomic.load(Ordering::Acquire) {
153            STATE_RUNNING => StreamState::Running,
154            STATE_PAUSED => StreamState::Paused,
155            STATE_COMPLETED => StreamState::Completed,
156            STATE_FAILED => StreamState::Failed,
157            _ => {
158                // Unknown state - fall back to checking if channel is closed
159                if self.receiver.is_closed() {
160                    StreamState::Completed
161                } else {
162                    StreamState::Running
163                }
164            }
165        }
166    }
167
168    /// Get buffered rows when paused
169    ///
170    /// Returns the number of rows buffered in the channel when the stream was paused.
171    /// Only meaningful when stream is in Paused state.
172    pub fn paused_occupancy(&self) -> usize {
173        self.pause_resume.paused_occupancy.load(Ordering::Relaxed)
174    }
175
176    /// Set timeout for pause (auto-resume after duration)
177    ///
178    /// When a stream is paused, the background task will automatically resume
179    /// after the specified duration expires, even if `resume()` is not called.
180    ///
181    /// # Arguments
182    ///
183    /// * `duration` - How long to stay paused before auto-resuming
184    ///
185    /// # Examples
186    ///
187    /// ```text
188    /// // Requires: a JsonStream instance from execute_query.
189    /// // Note: set_pause_timeout is on JsonStream, not QueryStream.
190    /// // Use FraiseClient::execute_query (internal) to obtain a JsonStream directly.
191    /// use std::time::Duration;
192    /// stream.set_pause_timeout(Duration::from_secs(5));
193    /// stream.pause().await?;  // Will auto-resume after 5 seconds
194    /// ```
195    pub fn set_pause_timeout(&mut self, duration: Duration) {
196        // Stored as a shared atomic (ms) so the already-spawned reader picks up
197        // the value live; a zero-millisecond request is clamped to 1 ms so it is
198        // never confused with "no timeout" (audit H43).
199        let ms = u64::try_from(duration.as_millis())
200            .unwrap_or(u64::MAX)
201            .max(1);
202        self.pause_resume
203            .pause_timeout_ms
204            .store(ms, Ordering::Relaxed);
205        tracing::debug!("pause timeout set to {:?}", duration);
206    }
207
208    /// Clear pause timeout (no auto-resume)
209    pub fn clear_pause_timeout(&mut self) {
210        self.pause_resume
211            .pause_timeout_ms
212            .store(0, Ordering::Relaxed);
213        tracing::debug!("pause timeout cleared");
214    }
215
216    /// Pause the stream
217    ///
218    /// Suspends the background task from reading more data from Postgres.
219    /// The connection remains open and can be resumed later.
220    /// Buffered rows are preserved and can be consumed normally.
221    ///
222    /// This method is idempotent: calling `pause()` on an already-paused stream is a no-op.
223    ///
224    /// Returns an error if the stream has already completed or failed.
225    ///
226    /// # Errors
227    ///
228    /// Returns [`WireError::Protocol`] if the stream is in a terminal state (completed or failed).
229    ///
230    /// # Example
231    ///
232    /// ```no_run
233    /// // Requires: live Postgres streaming connection.
234    /// # async fn example(client: fraiseql_wire::FraiseClient) -> fraiseql_wire::Result<()> {
235    /// let mut stream = client.query::<serde_json::Value>("entity").execute().await?;
236    /// stream.pause().await?;
237    /// // Background task stops reading
238    /// // Consumer can still poll for remaining buffered items
239    /// # Ok(())
240    /// # }
241    /// ```
242    pub async fn pause(&mut self) -> Result<()> {
243        let entity = self.entity.clone();
244
245        // Capture the buffered-row count at the moment of pause so
246        // `paused_occupancy()` reflects reality (it was never recorded before —
247        // audit H43).
248        let occupancy = self.receiver.len();
249
250        // Update lightweight atomic state first (fast path)
251        self.state_atomic_set_paused();
252
253        let pr = &self.pause_resume;
254        let mut state = pr.state.lock().await;
255
256        match *state {
257            StreamState::Running => {
258                pr.paused_occupancy.store(occupancy, Ordering::Relaxed);
259                // Update state
260                *state = StreamState::Paused;
261
262                // Record metric
263                crate::metrics::counters::stream_paused(&entity);
264                Ok(())
265            }
266            StreamState::Paused => {
267                // Idempotent: already paused
268                Ok(())
269            }
270            StreamState::Completed | StreamState::Failed => {
271                // Cannot pause a terminal stream
272                Err(WireError::Protocol(
273                    "cannot pause a completed or failed stream".to_string(),
274                ))
275            }
276        }
277    }
278
279    /// Resume the stream
280    ///
281    /// Resumes the background task to continue reading data from Postgres.
282    /// Only has an effect if the stream is currently paused.
283    ///
284    /// This method is idempotent: calling `resume()` before `pause()` or on an
285    /// already-running stream is a no-op.
286    ///
287    /// Returns an error if the stream has already completed or failed.
288    ///
289    /// # Errors
290    ///
291    /// Returns [`WireError::Protocol`] if the stream is in a terminal state (completed or failed).
292    ///
293    /// # Example
294    ///
295    /// ```no_run
296    /// // Requires: live Postgres streaming connection.
297    /// # async fn example(client: fraiseql_wire::FraiseClient) -> fraiseql_wire::Result<()> {
298    /// let mut stream = client.query::<serde_json::Value>("entity").execute().await?;
299    /// stream.resume().await?;
300    /// // Background task resumes reading
301    /// // Consumer can poll for more items
302    /// # Ok(())
303    /// # }
304    /// ```
305    pub async fn resume(&mut self) -> Result<()> {
306        // Update lightweight atomic state first (fast path)
307        let current = self.state_atomic_get();
308        let entity = self.entity.clone();
309
310        // Note: Set to RUNNING to reflect resumed state
311        if current == STATE_PAUSED {
312            // Only update atomic if currently paused
313            self.state_atomic.store(STATE_RUNNING, Ordering::Release);
314        }
315
316        let pr = &self.pause_resume;
317        let mut state = pr.state.lock().await;
318
319        match *state {
320            StreamState::Paused => {
321                // Wake the background reader, which is parked on this signal.
322                pr.resume_signal.notify_one();
323                // Update state
324                *state = StreamState::Running;
325
326                // Record metric
327                crate::metrics::counters::stream_resumed(&entity);
328                Ok(())
329            }
330            StreamState::Running => {
331                // Idempotent: already running (or resume before pause)
332                Ok(())
333            }
334            StreamState::Completed | StreamState::Failed => {
335                // Cannot resume a terminal stream
336                Err(WireError::Protocol(
337                    "cannot resume a completed or failed stream".to_string(),
338                ))
339            }
340        }
341    }
342
343    /// Pause the stream with a diagnostic reason
344    ///
345    /// Like `pause()`, but logs the provided reason for diagnostic purposes.
346    /// This helps track why streams are being paused (e.g., "backpressure",
347    /// "maintenance", "rate limit").
348    ///
349    /// # Arguments
350    ///
351    /// * `reason` - Optional reason for pausing (logged at debug level)
352    ///
353    /// # Errors
354    ///
355    /// Returns [`WireError::Protocol`] if the stream is in a terminal state (completed or failed).
356    ///
357    /// # Example
358    ///
359    /// ```no_run
360    /// // Requires: live Postgres streaming connection.
361    /// # async fn example(client: fraiseql_wire::FraiseClient) -> fraiseql_wire::Result<()> {
362    /// let mut stream = client.query::<serde_json::Value>("entity").execute().await?;
363    /// stream.pause_with_reason("backpressure: consumer busy").await?;
364    /// # Ok(())
365    /// # }
366    /// ```
367    pub async fn pause_with_reason(&mut self, reason: &str) -> Result<()> {
368        tracing::debug!("pausing stream: {}", reason);
369        self.pause().await
370    }
371
372    /// Clone the shared stream-state handle for the background reader.
373    pub(crate) fn clone_state(&self) -> Arc<Mutex<StreamState>> {
374        Arc::clone(&self.pause_resume.state)
375    }
376
377    /// Clone the resume signal the background reader parks on while paused.
378    pub(crate) fn clone_resume_signal(&self) -> Arc<Notify> {
379        Arc::clone(&self.pause_resume.resume_signal)
380    }
381
382    /// Clone the live pause-timeout handle (ms, 0 = none) for the background reader.
383    pub(crate) fn clone_pause_timeout(&self) -> Arc<AtomicU64> {
384        Arc::clone(&self.pause_resume.pause_timeout_ms)
385    }
386
387    // =========================================================================
388    // Lightweight state machine methods
389    // =========================================================================
390
391    /// Clone atomic state for passing to background task
392    pub(crate) fn clone_state_atomic(&self) -> Arc<AtomicU8> {
393        Arc::clone(&self.state_atomic)
394    }
395
396    /// Get current state from atomic (fast path, no locks)
397    pub(crate) fn state_atomic_get(&self) -> u8 {
398        self.state_atomic.load(Ordering::Acquire)
399    }
400
401    /// Set state to paused using atomic
402    pub(crate) fn state_atomic_set_paused(&self) {
403        self.state_atomic.store(STATE_PAUSED, Ordering::Release);
404    }
405
406    /// Set state to completed using atomic
407    pub(crate) fn state_atomic_set_completed(&self) {
408        self.state_atomic.store(STATE_COMPLETED, Ordering::Release);
409    }
410
411    /// Set state to failed using atomic
412    pub(crate) fn state_atomic_set_failed(&self) {
413        self.state_atomic.store(STATE_FAILED, Ordering::Release);
414    }
415
416    /// Record that one row was filtered out by a downstream Rust predicate.
417    ///
418    /// Called by [`crate::stream::QueryStream`] when its predicate rejects a row,
419    /// so `stats().total_rows_filtered` reflects reality (audit L-wire-stats).
420    pub(crate) fn record_filtered(&self) {
421        self.rows_filtered.fetch_add(1, Ordering::Relaxed);
422    }
423
424    /// Get current stream statistics
425    ///
426    /// Returns a snapshot of stream state without consuming any items.
427    /// This can be called at any time to monitor progress.
428    ///
429    /// # Example
430    ///
431    /// ```no_run
432    /// // Requires: live Postgres streaming connection.
433    /// # async fn example(client: fraiseql_wire::FraiseClient) -> fraiseql_wire::Result<()> {
434    /// let stream = client.query::<serde_json::Value>("entity").execute().await?;
435    /// let stats = stream.stats();
436    /// println!("Buffered: {}, Yielded: {}", stats.items_buffered, stats.total_rows_yielded);
437    /// # Ok(())
438    /// # }
439    /// ```
440    pub fn stats(&self) -> StreamStats {
441        let items_buffered = self.receiver.len();
442        let estimated_memory = items_buffered * 2048; // Conservative: 2KB per item
443        let total_rows_yielded = self.rows_yielded.load(Ordering::Relaxed);
444        let total_rows_filtered = self.rows_filtered.load(Ordering::Relaxed);
445
446        StreamStats {
447            items_buffered,
448            estimated_memory,
449            total_rows_yielded,
450            total_rows_filtered,
451        }
452    }
453}
454
455impl Stream for JsonStream {
456    type Item = Result<Value>;
457
458    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
459        // Sample metrics: record 1 in every 1000 polls to avoid hot path overhead
460        // For 100K rows, this records ~100 times instead of 100K times
461        let poll_idx = self.poll_count.fetch_add(1, Ordering::Relaxed);
462        if poll_idx.is_multiple_of(1000) {
463            let occupancy = self.receiver.len() as u64;
464            crate::metrics::histograms::channel_occupancy(&self.entity, occupancy);
465            crate::metrics::gauges::stream_buffered_items(&self.entity, occupancy as usize);
466        }
467
468        // Check memory limit BEFORE receiving (pre-enqueue strategy)
469        // This stops consuming when buffer reaches limit
470        if let Some(limit) = self.max_memory {
471            let items_buffered = self.receiver.len();
472            let estimated_memory = items_buffered * 2048; // Conservative: 2KB per item
473
474            // Check soft limit thresholds first (warn before fail)
475            if let Some(fail_threshold) = self.soft_limit_fail_threshold {
476                let threshold_bytes = (limit as f32 * fail_threshold) as usize;
477                if estimated_memory > threshold_bytes {
478                    // Record metric for memory limit exceeded
479                    crate::metrics::counters::memory_limit_exceeded(&self.entity);
480                    self.state_atomic_set_failed();
481                    return Poll::Ready(Some(Err(WireError::MemoryLimitExceeded {
482                        limit,
483                        estimated_memory,
484                    })));
485                }
486            } else if estimated_memory > limit {
487                // Hard limit (no soft limits configured)
488                crate::metrics::counters::memory_limit_exceeded(&self.entity);
489                self.state_atomic_set_failed();
490                return Poll::Ready(Some(Err(WireError::MemoryLimitExceeded {
491                    limit,
492                    estimated_memory,
493                })));
494            }
495
496            // Note: Warn threshold would be handled by instrumentation/logging layer
497            // This is for application-level monitoring, not a hard error
498        }
499
500        match self.receiver.poll_recv(cx) {
501            Poll::Ready(Some(Ok(value))) => {
502                // Count rows handed to the consumer so `stats().total_rows_yielded`
503                // is truthful (audit L-wire-stats — it was never incremented).
504                self.rows_yielded.fetch_add(1, Ordering::Relaxed);
505                Poll::Ready(Some(Ok(value)))
506            }
507            Poll::Ready(Some(Err(e))) => {
508                // Stream encountered an error
509                self.state_atomic_set_failed();
510                Poll::Ready(Some(Err(e)))
511            }
512            Poll::Ready(None) => {
513                // Stream completed normally
514                self.state_atomic_set_completed();
515                Poll::Ready(None)
516            }
517            Poll::Pending => Poll::Pending,
518        }
519    }
520}
521
522/// Extract JSON bytes from `DataRow` message
523///
524/// # Errors
525///
526/// Returns [`WireError::Protocol`] if the message is not a `DataRow`, the row does not
527/// have exactly one field, or the field value is null.
528pub fn extract_json_bytes(msg: &BackendMessage) -> Result<Bytes> {
529    match msg {
530        BackendMessage::DataRow(fields) => match fields.as_slice() {
531            [only] => only
532                .clone()
533                .ok_or_else(|| WireError::Protocol("null data field".into())),
534            _ => Err(WireError::Protocol(format!(
535                "expected 1 field, got {}",
536                fields.len()
537            ))),
538        },
539        _ => Err(WireError::Protocol("expected DataRow".into())),
540    }
541}
542
543/// Parse JSON bytes into Value
544///
545/// # Errors
546///
547/// Returns [`WireError`] if the bytes are not valid JSON.
548pub fn parse_json(data: Bytes) -> Result<Value> {
549    let value: Value = serde_json::from_slice(&data)?;
550    Ok(value)
551}
552
553#[cfg(test)]
554mod tests;