Skip to main content

fraiseql_wire/connection/conn/
core.rs

1//! Core `Connection` type and implementation
2
3use super::config::ConnectionConfig;
4use super::helpers::extract_entity_from_query;
5use crate::auth::ScramClient;
6use crate::connection::state::ConnectionState;
7use crate::connection::transport::Transport;
8use crate::protocol::{
9    decode_message, encode_message, AuthenticationMessage, BackendMessage, FrontendMessage,
10};
11use crate::{Result, WireError};
12use bytes::{Buf, BytesMut};
13use std::sync::atomic::{AtomicU64, Ordering};
14use tracing::Instrument;
15
16// Global counter for chunk metrics sampling (1 per 10 chunks)
17// Used to reduce per-chunk metric recording overhead
18static CHUNK_COUNT: AtomicU64 = AtomicU64::new(0);
19
20/// Postgres connection
21pub struct Connection {
22    transport: Transport,
23    state: ConnectionState,
24    read_buf: BytesMut,
25    process_id: Option<i32>,
26    secret_key: Option<i32>,
27}
28
29impl Connection {
30    /// Create connection from transport
31    pub fn new(transport: Transport) -> Self {
32        Self {
33            transport,
34            state: ConnectionState::Initial,
35            read_buf: BytesMut::with_capacity(8192),
36            process_id: None,
37            secret_key: None,
38        }
39    }
40
41    /// Get current connection state
42    pub const fn state(&self) -> ConnectionState {
43        self.state
44    }
45
46    /// Perform startup and authentication
47    ///
48    /// # Errors
49    ///
50    /// Returns [`WireError::InvalidState`] if the connection is not in the `Initial` state.
51    /// Returns [`WireError::Authentication`] if authentication is rejected by the server.
52    /// Returns [`WireError`] on any I/O or protocol error during the handshake.
53    pub async fn startup(&mut self, config: &ConnectionConfig) -> Result<()> {
54        async {
55            self.state.transition(ConnectionState::AwaitingAuth)?;
56
57            // Build startup parameters
58            let mut params = vec![
59                ("user".to_string(), config.user.clone()),
60                ("database".to_string(), config.database.clone()),
61            ];
62
63            // Add configured application name if specified
64            if let Some(app_name) = &config.application_name {
65                params.push(("application_name".to_string(), app_name.clone()));
66            }
67
68            // Add statement timeout if specified (in milliseconds)
69            if let Some(timeout) = config.statement_timeout {
70                params.push((
71                    "statement_timeout".to_string(),
72                    timeout.as_millis().to_string(),
73                ));
74            }
75
76            // Add extra_float_digits if specified
77            if let Some(digits) = config.extra_float_digits {
78                params.push(("extra_float_digits".to_string(), digits.to_string()));
79            }
80
81            // Add user-provided parameters
82            for (k, v) in &config.params {
83                params.push((k.clone(), v.clone()));
84            }
85
86            // Send startup message
87            let startup = FrontendMessage::Startup {
88                version: crate::protocol::constants::PROTOCOL_VERSION,
89                params,
90            };
91            self.send_message(&startup).await?;
92
93            // Authentication loop
94            self.state.transition(ConnectionState::Authenticating)?;
95            self.authenticate(config).await?;
96
97            self.state.transition(ConnectionState::Idle)?;
98            tracing::info!("startup complete");
99            Ok(())
100        }
101        .instrument(tracing::info_span!(
102            "startup",
103            user = %config.user,
104            database = %config.database
105        ))
106        .await
107    }
108
109    /// Handle authentication
110    async fn authenticate(&mut self, config: &ConnectionConfig) -> Result<()> {
111        let auth_start = std::time::Instant::now();
112        let mut auth_mechanism = "unknown";
113
114        loop {
115            let msg = self.receive_message().await?;
116
117            match msg {
118                BackendMessage::Authentication(auth) => match auth {
119                    AuthenticationMessage::Ok => {
120                        tracing::debug!("authentication successful");
121                        crate::metrics::counters::auth_successful(auth_mechanism);
122                        crate::metrics::histograms::auth_duration(
123                            auth_mechanism,
124                            auth_start.elapsed().as_millis() as u64,
125                        );
126                        // Don't break here! Must continue reading until ReadyForQuery
127                    }
128                    AuthenticationMessage::CleartextPassword => {
129                        auth_mechanism = crate::metrics::labels::MECHANISM_CLEARTEXT;
130                        crate::metrics::counters::auth_attempted(auth_mechanism);
131
132                        let password = config
133                            .password
134                            .as_ref()
135                            .ok_or_else(|| WireError::Authentication("password required".into()))?;
136                        // SECURITY: Convert from Zeroizing wrapper while preserving password content
137                        let pwd_msg = FrontendMessage::Password(password.as_str().to_string());
138                        self.send_message(&pwd_msg).await?;
139                    }
140                    AuthenticationMessage::Md5Password { .. } => {
141                        return Err(WireError::Authentication(
142                            "MD5 authentication not supported. Use SCRAM-SHA-256 or cleartext password".into(),
143                        ));
144                    }
145                    AuthenticationMessage::Sasl { mechanisms } => {
146                        auth_mechanism = crate::metrics::labels::MECHANISM_SCRAM;
147                        crate::metrics::counters::auth_attempted(auth_mechanism);
148                        self.handle_sasl(&mechanisms, config).await?;
149                    }
150                    AuthenticationMessage::SaslContinue { .. } => {
151                        return Err(WireError::Protocol(
152                            "unexpected SaslContinue outside of SASL flow".into(),
153                        ));
154                    }
155                    AuthenticationMessage::SaslFinal { .. } => {
156                        return Err(WireError::Protocol(
157                            "unexpected SaslFinal outside of SASL flow".into(),
158                        ));
159                    }
160                },
161                BackendMessage::BackendKeyData {
162                    process_id,
163                    secret_key,
164                } => {
165                    self.process_id = Some(process_id);
166                    self.secret_key = Some(secret_key);
167                }
168                BackendMessage::ParameterStatus { name, value } => {
169                    tracing::debug!("parameter status: {} = {}", name, value);
170                }
171                BackendMessage::ReadyForQuery { status: _ } => {
172                    break;
173                }
174                BackendMessage::ErrorResponse(err) => {
175                    crate::metrics::counters::auth_failed(auth_mechanism, "server_error");
176                    return Err(WireError::Authentication(err.to_string()));
177                }
178                _ => {
179                    return Err(WireError::Protocol(format!(
180                        "unexpected message during auth: {:?}",
181                        msg
182                    )));
183                }
184            }
185        }
186
187        Ok(())
188    }
189
190    /// Handle SASL authentication (SCRAM-SHA-256)
191    async fn handle_sasl(
192        &mut self,
193        mechanisms: &[String],
194        config: &ConnectionConfig,
195    ) -> Result<()> {
196        // Check if server supports SCRAM-SHA-256
197        if !mechanisms.contains(&"SCRAM-SHA-256".to_string()) {
198            return Err(WireError::Authentication(format!(
199                "server does not support SCRAM-SHA-256. Available: {}",
200                mechanisms.join(", ")
201            )));
202        }
203
204        // Get password
205        let password = config.password.as_ref().ok_or_else(|| {
206            WireError::Authentication("password required for SCRAM authentication".into())
207        })?;
208
209        // Create SCRAM client
210        // SECURITY: Convert from Zeroizing wrapper while preserving password content
211        let mut scram = ScramClient::new(config.user.clone(), password.as_str().to_string());
212        tracing::debug!("initiating SCRAM-SHA-256 authentication");
213
214        // Send SaslInitialResponse with client first message
215        let client_first = scram.client_first();
216        let msg = FrontendMessage::SaslInitialResponse {
217            mechanism: "SCRAM-SHA-256".to_string(),
218            data: client_first.into_bytes(),
219        };
220        self.send_message(&msg).await?;
221
222        // Receive SaslContinue with server first message
223        let server_first_msg = self.receive_message().await?;
224        let server_first_data = match server_first_msg {
225            BackendMessage::Authentication(AuthenticationMessage::SaslContinue { data }) => data,
226            BackendMessage::ErrorResponse(err) => {
227                return Err(WireError::Authentication(format!(
228                    "SASL server error: {}",
229                    err
230                )));
231            }
232            _ => {
233                return Err(WireError::Protocol(
234                    "expected SaslContinue message during SASL authentication".into(),
235                ));
236            }
237        };
238
239        let server_first = String::from_utf8(server_first_data).map_err(|e| {
240            WireError::Authentication(format!("invalid UTF-8 in server first message: {}", e))
241        })?;
242
243        tracing::debug!("received SCRAM server first message");
244
245        // Generate client final message
246        let (client_final, scram_state) = scram
247            .client_final(&server_first)
248            .map_err(|e| WireError::Authentication(format!("SCRAM error: {}", e)))?;
249
250        // Send SaslResponse with client final message
251        let msg = FrontendMessage::SaslResponse {
252            data: client_final.into_bytes(),
253        };
254        self.send_message(&msg).await?;
255
256        // Receive SaslFinal with server verification
257        let server_final_msg = self.receive_message().await?;
258        let server_final_data = match server_final_msg {
259            BackendMessage::Authentication(AuthenticationMessage::SaslFinal { data }) => data,
260            BackendMessage::ErrorResponse(err) => {
261                return Err(WireError::Authentication(format!(
262                    "SASL server error: {}",
263                    err
264                )));
265            }
266            _ => {
267                return Err(WireError::Protocol(
268                    "expected SaslFinal message during SASL authentication".into(),
269                ));
270            }
271        };
272
273        let server_final = String::from_utf8(server_final_data).map_err(|e| {
274            WireError::Authentication(format!("invalid UTF-8 in server final message: {}", e))
275        })?;
276
277        // Verify server signature
278        scram
279            .verify_server_final(&server_final, &scram_state)
280            .map_err(|e| WireError::Authentication(format!("SCRAM verification failed: {}", e)))?;
281
282        tracing::debug!("SCRAM-SHA-256 authentication successful");
283        Ok(())
284    }
285
286    /// Execute a simple query (returns all backend messages)
287    ///
288    /// # Errors
289    ///
290    /// Returns [`WireError::ConnectionBusy`] if the connection is not idle.
291    /// Returns [`WireError::InvalidState`] if the state machine transition fails.
292    /// Returns [`WireError`] on any I/O or protocol error during execution.
293    pub async fn simple_query(&mut self, query: &str) -> Result<Vec<BackendMessage>> {
294        if self.state != ConnectionState::Idle {
295            return Err(WireError::ConnectionBusy(format!(
296                "connection in state: {}",
297                self.state
298            )));
299        }
300
301        self.state.transition(ConnectionState::QueryInProgress)?;
302
303        let query_msg = FrontendMessage::Query(query.to_string());
304        self.send_message(&query_msg).await?;
305
306        self.state.transition(ConnectionState::ReadingResults)?;
307
308        let mut messages = Vec::new();
309
310        loop {
311            let msg = self.receive_message().await?;
312            let is_ready = matches!(msg, BackendMessage::ReadyForQuery { .. });
313            messages.push(msg);
314
315            if is_ready {
316                break;
317            }
318        }
319
320        self.state.transition(ConnectionState::Idle)?;
321        Ok(messages)
322    }
323
324    /// Send a frontend message
325    async fn send_message(&mut self, msg: &FrontendMessage) -> Result<()> {
326        let buf = encode_message(msg)?;
327        self.transport.write_all(&buf).await?;
328        self.transport.flush().await?;
329        Ok(())
330    }
331
332    /// Receive a backend message
333    ///
334    /// Decode errors are classified by [`std::io::ErrorKind`]: only
335    /// `UnexpectedEof` means "the frame is incomplete, read more bytes". Every
336    /// other kind (`InvalidData` for a malformed/unknown message, `Unsupported`
337    /// for a recognized-but-unimplemented one such as the COPY family) is fatal
338    /// and surfaces as [`WireError::Protocol`]. The previous `if let Ok(..)`
339    /// swallowed the kind and treated *every* decode error as "need more bytes",
340    /// so a malformed or unrecognized message looped forever, buffering toward
341    /// the size cap (audit H42).
342    async fn receive_message(&mut self) -> Result<BackendMessage> {
343        loop {
344            // Try to decode a message from buffer (without cloning!)
345            match decode_message(&mut self.read_buf) {
346                Ok((msg, consumed)) => {
347                    self.read_buf.advance(consumed);
348                    return Ok(msg);
349                }
350                // Incomplete frame: fall through and read more bytes.
351                Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {}
352                // Malformed / unknown / unsupported / oversized: fatal.
353                Err(e) => {
354                    crate::metrics::counters::protocol_error("decode");
355                    return Err(WireError::Protocol(format!(
356                        "failed to decode backend message: {e}"
357                    )));
358                }
359            }
360
361            // Bound read-buffer growth: a single backend message may not exceed
362            // MAX_MESSAGE_LEN. If we have buffered more than that without
363            // decoding one, the peer is sending an oversized (or
364            // never-terminating) message — fail instead of buffering toward
365            // ~2 GiB (audit M-wire-msg-cap). An oversized *declared* length is
366            // already rejected up front by `decode_message`; this is the
367            // backstop for a stream that never frames a complete message.
368            if self.read_buf.len() > crate::protocol::decode::MAX_MESSAGE_LEN {
369                return Err(WireError::Protocol(format!(
370                    "backend message exceeds maximum length of {} bytes",
371                    crate::protocol::decode::MAX_MESSAGE_LEN
372                )));
373            }
374
375            // Need more data
376            let n = self.transport.read_buf(&mut self.read_buf).await?;
377            if n == 0 {
378                return Err(WireError::ConnectionClosed);
379            }
380        }
381    }
382
383    /// Close the connection
384    ///
385    /// # Errors
386    ///
387    /// Returns [`WireError::InvalidState`] if the state machine transition to `Closed` fails.
388    /// Returns [`WireError`] if the transport shutdown fails.
389    pub async fn close(mut self) -> Result<()> {
390        self.state.transition(ConnectionState::Closed)?;
391        let _ = self.send_message(&FrontendMessage::Terminate).await;
392        self.transport.shutdown().await?;
393        Ok(())
394    }
395
396    /// Execute a streaming query
397    ///
398    /// Note: This method consumes the connection. The stream maintains the connection
399    /// internally. Once the stream is exhausted or dropped, the connection is closed.
400    ///
401    /// # Errors
402    ///
403    /// Returns `WireError::Io` if sending the query or reading the response fails.
404    /// Returns `WireError::Database` if the server returns an error response.
405    /// Returns `WireError::InvalidSchema` if the row description is not a single JSON column.
406    #[allow(clippy::too_many_arguments)] // Reason: streaming query requires all chunking parameters; a config struct would add allocation overhead
407    pub async fn streaming_query(
408        mut self,
409        query: &str,
410        chunk_size: usize,
411        max_memory: Option<usize>,
412        soft_limit_warn_threshold: Option<f32>,
413        soft_limit_fail_threshold: Option<f32>,
414        enable_adaptive_chunking: bool,
415        adaptive_min_chunk_size: Option<usize>,
416        adaptive_max_chunk_size: Option<usize>,
417    ) -> Result<crate::stream::JsonStream> {
418        async {
419            let startup_start = std::time::Instant::now();
420
421            use crate::json::validate_row_description;
422            use crate::stream::{extract_json_bytes, AdaptiveChunking, ChunkingStrategy, JsonStream};
423            use serde_json::Value;
424            use tokio::sync::mpsc;
425
426            if self.state != ConnectionState::Idle {
427                return Err(WireError::ConnectionBusy(format!(
428                    "connection in state: {}",
429                    self.state
430                )));
431            }
432
433            self.state.transition(ConnectionState::QueryInProgress)?;
434
435            let query_msg = FrontendMessage::Query(query.to_string());
436            self.send_message(&query_msg).await?;
437
438            self.state.transition(ConnectionState::ReadingResults)?;
439
440            // Read RowDescription, but handle other messages that may come first
441            // (e.g., ParameterStatus, BackendKeyData, ErrorResponse, NoticeResponse)
442            let row_desc;
443            loop {
444                let msg = self.receive_message().await?;
445
446                match msg {
447                    BackendMessage::ErrorResponse(err) => {
448                        // Query failed - consume ReadyForQuery and return error
449                        tracing::debug!("PostgreSQL error response: {}", err);
450                        loop {
451                            let msg = self.receive_message().await?;
452                            if matches!(msg, BackendMessage::ReadyForQuery { .. }) {
453                                break;
454                            }
455                        }
456                        return Err(WireError::Sql(err.to_string()));
457                    }
458                    BackendMessage::BackendKeyData { process_id, secret_key: _ } => {
459                        // This provides the key needed for cancel requests - store it and continue
460                        tracing::debug!("PostgreSQL backend key data received: pid={}", process_id);
461                        // Note: We would store this if we need to support cancellation
462                        continue;
463                    }
464                    BackendMessage::ParameterStatus { .. } => {
465                        // Parameter status changes are informational - skip them
466                        tracing::debug!("PostgreSQL parameter status change received");
467                        continue;
468                    }
469                    BackendMessage::NoticeResponse(notice) => {
470                        // Notices are non-fatal warnings - skip them
471                        tracing::debug!("PostgreSQL notice: {}", notice);
472                        continue;
473                    }
474                    BackendMessage::RowDescription(_) => {
475                        row_desc = msg;
476                        break;
477                    }
478                    BackendMessage::ReadyForQuery { .. } => {
479                        // Received ReadyForQuery without RowDescription
480                        // This means the query didn't produce a result set
481                        return Err(WireError::Protocol(
482                            "no result set received from query - \
483                             check that the entity name is correct and the table/view exists"
484                                .into(),
485                        ));
486                    }
487                    _ => {
488                        return Err(WireError::Protocol(format!(
489                            "unexpected message type in query response: {:?}",
490                            msg
491                        )));
492                    }
493                }
494            }
495
496            validate_row_description(&row_desc)?;
497
498            // Record startup timing
499            let startup_duration = startup_start.elapsed().as_millis() as u64;
500            let entity = extract_entity_from_query(query).unwrap_or_else(|| "unknown".to_string());
501            crate::metrics::histograms::query_startup_duration(&entity, startup_duration);
502
503            // Create channels
504            let (result_tx, result_rx) = mpsc::channel::<Result<Value>>(chunk_size);
505            let (cancel_tx, mut cancel_rx) = mpsc::channel::<()>(1);
506
507            // Create stream instance first so we can clone its pause/resume signals
508            let entity_for_metrics = extract_entity_from_query(query).unwrap_or_else(|| "unknown".to_string());
509            let entity_for_stream = entity_for_metrics.clone();  // Clone for stream
510
511            let stream = JsonStream::new(
512                result_rx,
513                cancel_tx,
514                entity_for_stream,
515                max_memory,
516                soft_limit_warn_threshold,
517                soft_limit_fail_threshold,
518            );
519
520            // Shared pause/resume handles for the background reader. These are
521            // allocated eagerly by `JsonStream::new`, so the reader sees the same
522            // instances the caller's `pause()`/`resume()` drive (audit H43 — the
523            // reader previously captured `None` clones and never paused).
524            let state_lock = stream.clone_state();
525            let resume_signal = stream.clone_resume_signal();
526
527            // Clone atomic state for fast state checks in background task
528            let state_atomic = stream.clone_state_atomic();
529
530            // Live auto-resume timeout (ms, 0 = none) — read fresh on each pause so
531            // `set_pause_timeout` applies after the stream is handed back.
532            let pause_timeout_ms = stream.clone_pause_timeout();
533
534            // Spawn background task to read rows
535            let query_start = std::time::Instant::now();
536
537            tokio::spawn(async move {
538                let mut strategy = ChunkingStrategy::new(chunk_size);
539                let mut chunk = strategy.new_chunk();
540                let mut total_rows = 0u64;
541
542            // Adaptive chunking (audit L-wire-builder): when enabled, the batch size
543            // self-tunes from observed channel occupancy. The builder's
544            // enable/min/max options now actually reach this point and take effect
545            // instead of being dropped at the `execute_query` boundary.
546            let mut adaptive = if enable_adaptive_chunking {
547                let mut adp = AdaptiveChunking::new();
548                if let (Some(min), Some(max)) = (adaptive_min_chunk_size, adaptive_max_chunk_size) {
549                    adp = adp.with_bounds(min, max);
550                }
551                Some(adp)
552            } else {
553                None
554            };
555            let mut current_chunk_size = chunk_size;
556
557            loop {
558                // Fast path: a single relaxed atomic load gates the (rare) pause
559                // handling. STATE_PAUSED == 1.
560                if state_atomic.load(std::sync::atomic::Ordering::Acquire) == 1 {
561                    let is_paused =
562                        { *state_lock.lock().await == crate::stream::StreamState::Paused };
563                    if is_paused {
564                        tracing::debug!("stream paused, waiting for resume");
565
566                        // Park until resumed, the pause timeout expires, or the
567                        // stream is cancelled/dropped. Waiting on `cancel_rx` too
568                        // means a drop-while-paused tears the reader down cleanly
569                        // instead of leaking a task blocked forever.
570                        let timeout_ms = pause_timeout_ms.load(std::sync::atomic::Ordering::Relaxed);
571                        let cancelled = if timeout_ms > 0 {
572                            tokio::select! {
573                                () = resume_signal.notified() => {
574                                    tracing::debug!("stream resumed");
575                                    false
576                                }
577                                _ = cancel_rx.recv() => true,
578                                () = tokio::time::sleep(std::time::Duration::from_millis(timeout_ms)) => {
579                                    tracing::debug!("pause timeout expired, auto-resuming");
580                                    crate::metrics::counters::stream_pause_timeout_expired(&entity_for_metrics);
581                                    false
582                                }
583                            }
584                        } else {
585                            tokio::select! {
586                                () = resume_signal.notified() => {
587                                    tracing::debug!("stream resumed");
588                                    false
589                                }
590                                _ = cancel_rx.recv() => true,
591                            }
592                        };
593
594                        if cancelled {
595                            tracing::debug!("query cancelled while paused");
596                            crate::metrics::counters::query_completed("cancelled", &entity_for_metrics);
597                            break;
598                        }
599
600                        // Back to Running (covers both explicit resume and the
601                        // timeout auto-resume).
602                        *state_lock.lock().await = crate::stream::StreamState::Running;
603                        state_atomic.store(0, std::sync::atomic::Ordering::Release);
604                    }
605                }
606
607                tokio::select! {
608                    // Check for cancellation
609                    _ = cancel_rx.recv() => {
610                        tracing::debug!("query cancelled");
611                        crate::metrics::counters::query_completed("cancelled", &entity_for_metrics);
612                        break;
613                    }
614
615                    // Read next message
616                    msg_result = self.receive_message() => {
617                        match msg_result {
618                            Ok(msg) => match msg {
619                                BackendMessage::DataRow(_) => {
620                                    match extract_json_bytes(&msg) {
621                                        Ok(json_bytes) => {
622                                            chunk.push(json_bytes);
623
624                                            if strategy.is_full(&chunk) {
625                                                let chunk_start = std::time::Instant::now();
626                                                let rows = chunk.into_rows();
627                                                let chunk_size_rows = rows.len() as u64;
628
629                                                // Occupancy at flush time, for adaptive tuning (before the send drains it).
630                                                let buffered = result_tx
631                                                    .max_capacity()
632                                                    .saturating_sub(result_tx.capacity());
633
634                                                if !stream_chunk_rows(
635                                                    rows,
636                                                    &result_tx,
637                                                    &entity_for_metrics,
638                                                    &mut total_rows,
639                                                )
640                                                .await
641                                                {
642                                                    break;
643                                                }
644
645                                                record_chunk_metrics(
646                                                    &entity_for_metrics,
647                                                    chunk_start,
648                                                    chunk_size_rows,
649                                                );
650
651                                                // Adaptive chunking (L-wire-builder): adjust the
652                                                // batch size from observed occupancy when enabled.
653                                                if let Some(adaptive) = adaptive.as_mut() {
654                                                    if let Some(new_size) = adaptive
655                                                        .observe(buffered, result_tx.max_capacity())
656                                                    {
657                                                        crate::metrics::counters::adaptive_chunk_adjusted(
658                                                            &entity_for_metrics,
659                                                            current_chunk_size,
660                                                            new_size,
661                                                        );
662                                                        current_chunk_size = new_size;
663                                                        strategy = ChunkingStrategy::new(new_size);
664                                                    }
665                                                }
666
667                                                chunk = strategy.new_chunk();
668                                            }
669                                        }
670                                        Err(e) => {
671                                            crate::metrics::counters::json_parse_error(&entity_for_metrics);
672                                            let _ = result_tx.send(Err(e)).await;
673                                            crate::metrics::counters::query_completed("error", &entity_for_metrics);
674                                            break;
675                                        }
676                                    }
677                                }
678                                BackendMessage::CommandComplete(_) => {
679                                    // Flush the trailing partial chunk through the
680                                    // same batched path as full chunks. On a consumer
681                                    // drop we stop instead of (as the old duplicated
682                                    // block did) falling through to record success —
683                                    // reconciling the drifted error termination
684                                    // (audit L-wire-chunk-dup).
685                                    if !chunk.is_empty() {
686                                        let chunk_start = std::time::Instant::now();
687                                        let rows = chunk.into_rows();
688                                        let chunk_size_rows = rows.len() as u64;
689                                        if !stream_chunk_rows(
690                                            rows,
691                                            &result_tx,
692                                            &entity_for_metrics,
693                                            &mut total_rows,
694                                        )
695                                        .await
696                                        {
697                                            break;
698                                        }
699                                        record_chunk_metrics(
700                                            &entity_for_metrics,
701                                            chunk_start,
702                                            chunk_size_rows,
703                                        );
704                                        chunk = strategy.new_chunk();
705                                    }
706
707                                    // Record query completion metrics
708                                    let query_duration = query_start.elapsed().as_millis() as u64;
709                                    crate::metrics::counters::rows_processed(&entity_for_metrics, total_rows, "ok");
710                                    crate::metrics::histograms::query_total_duration(&entity_for_metrics, query_duration);
711                                    crate::metrics::counters::query_completed("success", &entity_for_metrics);
712                                }
713                                BackendMessage::ReadyForQuery { .. } => {
714                                    break;
715                                }
716                                BackendMessage::ErrorResponse(err) => {
717                                    crate::metrics::counters::query_error(&entity_for_metrics, "server_error");
718                                    crate::metrics::counters::query_completed("error", &entity_for_metrics);
719                                    let _ = result_tx.send(Err(WireError::Sql(err.to_string()))).await;
720                                    break;
721                                }
722                                _ => {
723                                    crate::metrics::counters::query_error(&entity_for_metrics, "protocol_error");
724                                    crate::metrics::counters::query_completed("error", &entity_for_metrics);
725                                    let _ = result_tx.send(Err(WireError::Protocol(
726                                        format!("unexpected message: {:?}", msg)
727                                    ))).await;
728                                    break;
729                                }
730                            },
731                            Err(e) => {
732                                crate::metrics::counters::query_error(&entity_for_metrics, "connection_error");
733                                crate::metrics::counters::query_completed("error", &entity_for_metrics);
734                                let _ = result_tx.send(Err(e)).await;
735                                break;
736                            }
737                        }
738                    }
739                }
740            }
741            });
742
743            Ok(stream)
744        }
745        .instrument(tracing::debug_span!(
746            "streaming_query",
747            query = %query,
748            chunk_size = %chunk_size
749        ))
750        .await
751    }
752}
753
754/// Parse a finished chunk's rows and stream them to the consumer in batches.
755///
756/// Batches `BATCH_SIZE` parsed values per group to amortize channel-send overhead.
757/// Returns `true` when the whole chunk was delivered, or `false` when the consumer
758/// dropped the channel or a row failed to parse — in which case the caller must
759/// stop the read loop. The terminal `query_completed("error")` metric is recorded
760/// here on those paths, so both the full-chunk and final-chunk flush sites behave
761/// identically (audit L-wire-chunk-dup — the two sites had drifted error handling).
762async fn stream_chunk_rows(
763    rows: Vec<bytes::Bytes>,
764    result_tx: &tokio::sync::mpsc::Sender<Result<serde_json::Value>>,
765    entity: &str,
766    total_rows: &mut u64,
767) -> bool {
768    const BATCH_SIZE: usize = 8;
769    let mut batch: Vec<Result<serde_json::Value>> = Vec::with_capacity(BATCH_SIZE);
770
771    for row_bytes in rows {
772        match crate::stream::parse_json(row_bytes) {
773            Ok(value) => {
774                *total_rows += 1;
775                batch.push(Ok(value));
776                if batch.len() == BATCH_SIZE {
777                    for item in batch.drain(..) {
778                        if result_tx.send(item).await.is_err() {
779                            crate::metrics::counters::query_completed("error", entity);
780                            return false;
781                        }
782                    }
783                }
784            }
785            Err(e) => {
786                crate::metrics::counters::json_parse_error(entity);
787                let _ = result_tx.send(Err(e)).await;
788                crate::metrics::counters::query_completed("error", entity);
789                return false;
790            }
791        }
792    }
793
794    // Flush the trailing partial batch.
795    for item in batch {
796        if result_tx.send(item).await.is_err() {
797            crate::metrics::counters::query_completed("error", entity);
798            return false;
799        }
800    }
801    true
802}
803
804/// Record sampled per-chunk metrics (1 in 10 chunks) for a just-flushed chunk.
805fn record_chunk_metrics(entity: &str, chunk_start: std::time::Instant, chunk_size_rows: u64) {
806    let chunk_duration = chunk_start.elapsed().as_millis() as u64;
807    let chunk_idx = CHUNK_COUNT.fetch_add(1, Ordering::Relaxed);
808    if chunk_idx.is_multiple_of(10) {
809        crate::metrics::histograms::chunk_processing_duration(entity, chunk_duration);
810        crate::metrics::histograms::chunk_size(entity, chunk_size_rows);
811    }
812}