Skip to main content

franken_snowflake_sqlapi/
lifecycle.rs

1//! The statement lifecycle state machine: submit -> poll/await -> partition
2//! assembly, as a **pure, synchronous** Mealy machine.
3//!
4//! Implements bead `fsnow-statement-lifecycle-ofl`. The machine holds *all* the
5//! lifecycle logic — status routing, the bounded poll loop, and multi-partition
6//! row assembly — but performs **no IO**: a caller feeds it each response
7//! (status class + body bytes) and it returns the next [`Progress`] step. That
8//! makes the whole flow testable end-to-end against the deterministic, no-socket
9//! `franken_snowflake_testkit::mock::MockSqlApi` without a runtime.
10//!
11//! The async glue that pumps this machine against the live
12//! `franken-snowflake-http` transport (and fires the remote cancel endpoint on
13//! local cancellation) lives in [`crate::driver`]. Gzip partition bodies are
14//! decompressed by the transport, so the machine always receives **decoded**
15//! partition bytes.
16
17use std::time::Duration;
18
19use franken_snowflake_core::error::{SnowflakeError, SnowflakeErrorCode};
20use franken_snowflake_core::ids::StatementHandle;
21
22use crate::response::{QueryFailureStatus, QueryStatus, ResultSet};
23use crate::status::ResponseClass;
24
25/// Lower bound on the inter-poll wait so a `202` poll loop can never degrade into
26/// a tight, API-hammering spin (which would also burn the whole poll quota in
27/// milliseconds and provoke server-side `429` rate limiting).
28pub const MIN_POLL_INTERVAL: Duration = Duration::from_millis(50);
29
30/// How a `202` handle is polled: how many times, and how long to wait between
31/// `GET`s.
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub struct PollPlan {
34    /// Maximum number of poll `GET`s before [`LifecycleErrorCode::PollQuotaExhausted`].
35    pub max_polls: u32,
36    /// Wall-clock delay the async driver waits between successive poll `GET`s on a
37    /// still-running (`202`) handle. The pure machine carries this as data only;
38    /// the [`crate::driver`] performs the cancel-aware sleep. The transport only
39    /// backs off on *retryable* statuses (`429`/`5xx`); a `202` returns
40    /// immediately, so without this interval the poll loop would spin with no gap.
41    pub poll_interval: Duration,
42    /// How many non-inline partitions the driver fetches concurrently once a
43    /// result set completes (a window; assembly stays in partition order).
44    pub partition_concurrency: usize,
45    /// Stop fetching further partitions once at least this many rows are
46    /// assembled. The statement then completes early and reports itself
47    /// partial (`CompletedStatement::is_partial`).
48    pub row_cap: Option<usize>,
49}
50
51/// Default partition fetch window.
52pub const DEFAULT_PARTITION_CONCURRENCY: usize = 4;
53/// Largest partition fetch window a caller may request.
54pub const MAX_PARTITION_CONCURRENCY: usize = 16;
55
56impl Default for PollPlan {
57    fn default() -> Self {
58        Self {
59            max_polls: 120,
60            poll_interval: Duration::from_millis(1_000),
61            partition_concurrency: DEFAULT_PARTITION_CONCURRENCY,
62            row_cap: None,
63        }
64    }
65}
66
67impl PollPlan {
68    /// A plan with an explicit poll ceiling (clamped to at least 1), keeping the
69    /// default inter-poll interval.
70    #[must_use]
71    pub fn with_max_polls(max_polls: u32) -> Self {
72        Self {
73            max_polls: max_polls.max(1),
74            ..Self::default()
75        }
76    }
77
78    /// Set the inter-poll wait, clamped to [`MIN_POLL_INTERVAL`] so the `202` poll
79    /// loop can never become a tight spin.
80    #[must_use]
81    pub fn with_poll_interval(mut self, poll_interval: Duration) -> Self {
82        self.poll_interval = poll_interval.max(MIN_POLL_INTERVAL);
83        self
84    }
85
86    /// The effective inter-poll wait, never below [`MIN_POLL_INTERVAL`] even if the
87    /// public field was set directly.
88    #[must_use]
89    pub fn effective_poll_interval(&self) -> Duration {
90        self.poll_interval.max(MIN_POLL_INTERVAL)
91    }
92
93    /// Set the partition fetch window, clamped to `1..=MAX_PARTITION_CONCURRENCY`.
94    #[must_use]
95    pub fn with_partition_concurrency(mut self, concurrency: usize) -> Self {
96        self.partition_concurrency = concurrency.clamp(1, MAX_PARTITION_CONCURRENCY);
97        self
98    }
99
100    /// Stop fetching partitions once `row_cap` rows are assembled (`None` fetches
101    /// every partition).
102    #[must_use]
103    pub fn with_row_cap(mut self, row_cap: Option<usize>) -> Self {
104        self.row_cap = row_cap;
105        self
106    }
107
108    /// The effective window size, never zero even if the field was set directly.
109    #[must_use]
110    pub fn effective_partition_concurrency(&self) -> usize {
111        self.partition_concurrency
112            .clamp(1, MAX_PARTITION_CONCURRENCY)
113    }
114}
115
116/// The fully-assembled result of a completed statement: the parsed terminal
117/// [`ResultSet`] (metadata + inline partition-0 rows) plus every row across all
118/// fetched partitions, concatenated in partition order.
119#[derive(Clone, Debug, PartialEq)]
120pub struct CompletedStatement {
121    /// The statement handle (also the re-fetch / cancel id).
122    pub statement_handle: StatementHandle,
123    /// The terminal `200` result set, including column metadata.
124    pub result_set: ResultSet,
125    /// All rows across every fetched partition (`data` ++ each fetched partition).
126    pub rows: Vec<Vec<Option<String>>>,
127    /// Partitions whose rows are in `rows`, counting the inline partition 0.
128    pub fetched_partitions: u32,
129    /// Partitions the result set has in total (`partitionInfo` length, min 1).
130    pub total_partitions: u32,
131}
132
133impl CompletedStatement {
134    /// `true` when a row cap stopped the fetch before every partition was
135    /// downloaded: `rows` is a prefix and `result_set` still carries the
136    /// server-side `numRows`.
137    #[must_use]
138    pub fn is_partial(&self) -> bool {
139        self.fetched_partitions < self.total_partitions
140    }
141}
142
143/// The next step a caller should take after feeding the machine a response.
144// `Complete` carries the assembled `CompletedStatement` (~408 bytes) while the
145// poll/fetch variants are tiny. A `Progress` is produced once per network
146// round-trip, so the hot-stack-copy premise of `large_enum_variant` does not
147// apply; boxing would add indirection to every consumer for no real benefit.
148#[allow(clippy::large_enum_variant)]
149#[derive(Clone, Debug, PartialEq)]
150pub enum Progress {
151    /// Poll this handle again (the statement is still running).
152    PollAgain(StatementHandle),
153    /// Fetch this result partition next (multi-partition assembly in progress).
154    FetchPartition {
155        /// The statement handle to fetch from.
156        handle: StatementHandle,
157        /// The 1-based partition index to fetch.
158        partition: u32,
159    },
160    /// The statement completed and all partitions are assembled.
161    Complete(CompletedStatement),
162    /// Terminal: the statement hit its server-side `STATEMENT_TIMEOUT` (`408`).
163    TimedOut(QueryFailureStatus),
164    /// Terminal: the statement failed to compile or execute (`422`).
165    Failed(QueryFailureStatus),
166}
167
168/// A lifecycle-orchestration error (distinct from a *protocol* timeout/failure,
169/// which are [`Progress::TimedOut`] / [`Progress::Failed`]).
170#[derive(Clone, Debug, PartialEq, Eq)]
171pub struct LifecycleError {
172    /// Stable error class.
173    pub code: LifecycleErrorCode,
174    /// A value-free explanation (never echoes row data).
175    pub message: String,
176}
177
178/// Stable [`LifecycleError`] classes.
179#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
180pub enum LifecycleErrorCode {
181    /// A response body did not parse into its expected schema.
182    DecodeFailed,
183    /// A response carried a status the current phase cannot accept.
184    UnexpectedStatus,
185    /// The poll quota was exhausted while the statement was still running.
186    PollQuotaExhausted,
187    /// Assembled row count did not match `resultSetMetaData.numRows`.
188    PartitionRowMismatch,
189}
190
191impl LifecycleError {
192    fn new(code: LifecycleErrorCode, message: impl Into<String>) -> Self {
193        Self {
194            code,
195            message: message.into(),
196        }
197    }
198
199    /// Map to the shared connector error registry for the CLI/MCP edge.
200    #[must_use]
201    pub fn into_snowflake_error(self) -> SnowflakeError {
202        let code = match self.code {
203            LifecycleErrorCode::DecodeFailed
204            | LifecycleErrorCode::UnexpectedStatus
205            | LifecycleErrorCode::PartitionRowMismatch => SnowflakeErrorCode::UpstreamError,
206            LifecycleErrorCode::PollQuotaExhausted => SnowflakeErrorCode::RetryBudgetExhausted,
207        };
208        SnowflakeError::new(code, self.message)
209    }
210}
211
212impl std::fmt::Display for LifecycleError {
213    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214        write!(f, "{:?}: {}", self.code, self.message)
215    }
216}
217
218impl std::error::Error for LifecycleError {}
219
220/// Internal phase of the lifecycle.
221// `Assembling` holds the in-flight `ResultSet`; `Pending`/`Done` are unit-like.
222// The machine transitions once per network round-trip, so boxing the result set
223// would add indirection for no real benefit (see `Progress` above).
224#[allow(clippy::large_enum_variant)]
225#[derive(Clone, Debug)]
226enum Phase {
227    /// Before submit, or polling a `202` handle.
228    Pending,
229    /// Completed; fetching the non-inline partitions `next..total`.
230    Assembling {
231        result_set: ResultSet,
232        handle: StatementHandle,
233        total: u32,
234        next: u32,
235        rows: Vec<Vec<Option<String>>>,
236    },
237    /// Terminal (completed, failed, timed out, or errored).
238    Done,
239}
240
241/// The pure statement lifecycle driver. Construct with [`StatementMachine::new`],
242/// then feed each response with [`StatementMachine::on_submit`] /
243/// [`StatementMachine::on_poll`] / [`StatementMachine::on_partition`].
244#[derive(Clone, Debug)]
245pub struct StatementMachine {
246    poll_plan: PollPlan,
247    polls_done: u32,
248    phase: Phase,
249    /// Rows handed out by [`StatementMachine::drain_rows`] (streaming); they
250    /// still count toward the row cap and the final `numRows` check.
251    drained_rows: usize,
252}
253
254impl StatementMachine {
255    /// A fresh machine awaiting the submit response.
256    #[must_use]
257    pub fn new(poll_plan: PollPlan) -> Self {
258        Self {
259            poll_plan,
260            polls_done: 0,
261            phase: Phase::Pending,
262            drained_rows: 0,
263        }
264    }
265
266    /// Streaming (reality-check bead E5): take the rows assembled so far, in
267    /// partition order, so a caller can write them out before the next
268    /// partitions arrive. Drained rows still count toward
269    /// [`StatementMachine::rows_assembled`] and the final `numRows` check; the
270    /// completed statement then holds only the rows not yet drained.
271    pub fn drain_rows(&mut self) -> Vec<Vec<Option<String>>> {
272        match &mut self.phase {
273            Phase::Assembling { rows, .. } => {
274                let drained = std::mem::take(rows);
275                self.drained_rows = self.drained_rows.saturating_add(drained.len());
276                drained
277            }
278            Phase::Pending | Phase::Done => Vec::new(),
279        }
280    }
281
282    /// While assembling: the terminal result set (metadata and column types).
283    #[must_use]
284    pub fn result_set(&self) -> Option<&ResultSet> {
285        match &self.phase {
286            Phase::Assembling { result_set, .. } => Some(result_set),
287            Phase::Pending | Phase::Done => None,
288        }
289    }
290
291    /// Number of poll `GET`s consumed so far.
292    #[must_use]
293    pub const fn polls_done(&self) -> u32 {
294        self.polls_done
295    }
296
297    /// While assembling: `(next partition to fetch, total partitions)`.
298    #[must_use]
299    pub fn assembling_window(&self) -> Option<(u32, u32)> {
300        match &self.phase {
301            Phase::Assembling { next, total, .. } => Some((*next, *total)),
302            Phase::Pending | Phase::Done => None,
303        }
304    }
305
306    /// Rows assembled so far (the inline partition plus every fetched partition).
307    #[must_use]
308    pub fn rows_assembled(&self) -> usize {
309        match &self.phase {
310            Phase::Assembling { rows, .. } => self.drained_rows.saturating_add(rows.len()),
311            Phase::Pending | Phase::Done => 0,
312        }
313    }
314
315    /// Finish with the partitions fetched so far (row-cap stop). The result is
316    /// partial: `fetched_partitions < total_partitions`, `rows` is a prefix.
317    ///
318    /// # Errors
319    /// [`LifecycleErrorCode::UnexpectedStatus`] outside the assembling phase.
320    pub fn complete_early(&mut self) -> Result<CompletedStatement, LifecycleError> {
321        let Phase::Assembling {
322            result_set,
323            handle,
324            total,
325            next,
326            rows,
327        } = std::mem::replace(&mut self.phase, Phase::Done)
328        else {
329            return Err(LifecycleError::new(
330                LifecycleErrorCode::UnexpectedStatus,
331                "early completion requested outside the assembling phase",
332            ));
333        };
334        Ok(CompletedStatement {
335            statement_handle: handle,
336            result_set,
337            rows,
338            fetched_partitions: next,
339            total_partitions: total,
340        })
341    }
342
343    /// Feed the response to `POST /api/v2/statements`.
344    ///
345    /// # Errors
346    /// [`LifecycleError`] if the body fails to decode or the status is one a
347    /// submit can never legitimately return.
348    pub fn on_submit(
349        &mut self,
350        class: ResponseClass,
351        body: &[u8],
352    ) -> Result<Progress, LifecycleError> {
353        self.ensure_not_terminal()?;
354        match class {
355            ResponseClass::Completed => self.enter_terminal_result(parse_result_set(body)?),
356            ResponseClass::Running => {
357                let status = parse_query_status(body)?;
358                Ok(Progress::PollAgain(status.statement_handle))
359            }
360            ResponseClass::StatementTimeout => self.enter_terminal_timeout(parse_failure(body)?),
361            ResponseClass::StatementFailed => self.enter_terminal_failure(parse_failure(body)?),
362            ResponseClass::RateLimited | ResponseClass::Other(_) => Err(LifecycleError::new(
363                LifecycleErrorCode::UnexpectedStatus,
364                "submit returned a non-terminal, non-running status",
365            )),
366        }
367    }
368
369    /// Feed the response to a poll `GET /api/v2/statements/{handle}`.
370    ///
371    /// # Errors
372    /// [`LifecycleError`] on decode failure, an unexpected status, or an
373    /// exhausted poll quota.
374    pub fn on_poll(
375        &mut self,
376        class: ResponseClass,
377        body: &[u8],
378    ) -> Result<Progress, LifecycleError> {
379        self.ensure_not_terminal()?;
380        self.polls_done = self.polls_done.saturating_add(1);
381        match class {
382            ResponseClass::Completed => self.enter_terminal_result(parse_result_set(body)?),
383            ResponseClass::StatementTimeout => self.enter_terminal_timeout(parse_failure(body)?),
384            ResponseClass::StatementFailed => self.enter_terminal_failure(parse_failure(body)?),
385            // Still running, or a transient 429 the transport will have backed off
386            // on: keep polling unless the quota is spent.
387            ResponseClass::Running | ResponseClass::RateLimited => {
388                if self.polls_done > self.poll_plan.max_polls {
389                    return Err(LifecycleError::new(
390                        LifecycleErrorCode::PollQuotaExhausted,
391                        format!(
392                            "statement still running after {} polls",
393                            self.poll_plan.max_polls
394                        ),
395                    ));
396                }
397                let status = parse_query_status(body)?;
398                Ok(Progress::PollAgain(status.statement_handle))
399            }
400            ResponseClass::Other(_) => Err(LifecycleError::new(
401                LifecycleErrorCode::UnexpectedStatus,
402                "poll returned an unexpected status",
403            )),
404        }
405    }
406
407    /// Feed the response to a partition `GET ...?partition=N`. `body` is the
408    /// **decoded** (post-gzip) partition payload: a bare JSON array of rows.
409    ///
410    /// # Errors
411    /// [`LifecycleError`] on decode failure, a non-`200` status, an out-of-order
412    /// partition, a per-partition row count that disagrees with
413    /// `partitionInfo[*].rowCount`, or a final row count that disagrees with
414    /// `numRows`.
415    pub fn on_partition(
416        &mut self,
417        class: ResponseClass,
418        partition: u32,
419        body: &[u8],
420    ) -> Result<Progress, LifecycleError> {
421        if !matches!(class, ResponseClass::Completed) {
422            self.phase = Phase::Done;
423            return Err(LifecycleError::new(
424                LifecycleErrorCode::UnexpectedStatus,
425                format!("partition {partition} returned a non-200 status"),
426            ));
427        }
428        let Phase::Assembling {
429            result_set,
430            handle,
431            total,
432            next,
433            mut rows,
434        } = std::mem::replace(&mut self.phase, Phase::Done)
435        else {
436            return Err(LifecycleError::new(
437                LifecycleErrorCode::UnexpectedStatus,
438                "partition response arrived outside the assembling phase",
439            ));
440        };
441        if partition != next {
442            self.phase = Phase::Assembling {
443                result_set,
444                handle,
445                total,
446                next,
447                rows,
448            };
449            return Err(LifecycleError::new(
450                LifecycleErrorCode::UnexpectedStatus,
451                format!("expected partition {next}, received {partition}"),
452            ));
453        }
454
455        let mut partition_rows = parse_partition_rows(body)?;
456        validate_partition_row_count(&result_set, partition, partition_rows.len())?;
457        rows.append(&mut partition_rows);
458        let upcoming = next.saturating_add(1);
459        if upcoming >= total {
460            validate_total_row_count(
461                self.drained_rows.saturating_add(rows.len()),
462                result_set.result_set_meta_data.num_rows,
463            )?;
464            Ok(Progress::Complete(CompletedStatement {
465                statement_handle: handle,
466                result_set,
467                rows,
468                fetched_partitions: total,
469                total_partitions: total,
470            }))
471        } else {
472            let resume = handle.clone();
473            self.phase = Phase::Assembling {
474                result_set,
475                handle,
476                total,
477                next: upcoming,
478                rows,
479            };
480            Ok(Progress::FetchPartition {
481                handle: resume,
482                partition: upcoming,
483            })
484        }
485    }
486
487    /// Refuse a submit/poll response once the machine has reached a terminal
488    /// state. `on_partition` enforces this structurally (it only proceeds from
489    /// `Assembling`), but `on_submit`/`on_poll` reset the phase via
490    /// `enter_terminal_result`, so without this guard a late or duplicate `200`
491    /// would re-open a closed statement — silently turning a `408` timeout or
492    /// `422` failure into a success. A `Done` machine is terminal: terminal.
493    fn ensure_not_terminal(&self) -> Result<(), LifecycleError> {
494        if matches!(self.phase, Phase::Done) {
495            return Err(LifecycleError::new(
496                LifecycleErrorCode::UnexpectedStatus,
497                "statement machine already reached a terminal state",
498            ));
499        }
500        Ok(())
501    }
502
503    fn enter_terminal_timeout(
504        &mut self,
505        failure: QueryFailureStatus,
506    ) -> Result<Progress, LifecycleError> {
507        self.phase = Phase::Done;
508        Ok(Progress::TimedOut(failure))
509    }
510
511    fn enter_terminal_failure(
512        &mut self,
513        failure: QueryFailureStatus,
514    ) -> Result<Progress, LifecycleError> {
515        self.phase = Phase::Done;
516        Ok(Progress::Failed(failure))
517    }
518
519    /// Enter partition assembly (or finish immediately for a single partition).
520    fn enter_terminal_result(&mut self, result_set: ResultSet) -> Result<Progress, LifecycleError> {
521        let handle = result_set.statement_handle.clone();
522        // A multi-statement parent's rows are only a status message ("Multiple
523        // statements executed successfully."); the results are its children's,
524        // fetched by the handles it lists. Nothing to assemble or reconcile.
525        if result_set.is_multi_statement() {
526            self.phase = Phase::Done;
527            let rows = result_set.data.clone();
528            return Ok(Progress::Complete(CompletedStatement {
529                statement_handle: handle,
530                result_set,
531                rows,
532                fetched_partitions: 1,
533                total_partitions: 1,
534            }));
535        }
536        let total = partition_total(&result_set);
537        let rows = result_set.data.clone();
538        validate_partition_row_count(&result_set, 0, rows.len())?;
539        if total <= 1 {
540            self.phase = Phase::Done;
541            // Apply the same aggregate integrity check as the multi-partition path:
542            // for a single inline partition, `data` must hold exactly `numRows`.
543            validate_total_row_count(rows.len(), result_set.result_set_meta_data.num_rows)?;
544            Ok(Progress::Complete(CompletedStatement {
545                statement_handle: handle,
546                result_set,
547                rows,
548                fetched_partitions: 1,
549                total_partitions: 1,
550            }))
551        } else {
552            let resume = handle.clone();
553            self.phase = Phase::Assembling {
554                result_set,
555                handle,
556                total,
557                next: 1,
558                rows,
559            };
560            Ok(Progress::FetchPartition {
561                handle: resume,
562                partition: 1,
563            })
564        }
565    }
566}
567
568/// Total partitions for a result set (partition 0 is the inline `data`). An
569/// absent or single-entry `partitionInfo` means everything is inline.
570#[must_use]
571fn partition_total(result_set: &ResultSet) -> u32 {
572    u32::try_from(result_set.result_set_meta_data.partition_info.len().max(1)).unwrap_or(u32::MAX)
573}
574
575fn parse_result_set(body: &[u8]) -> Result<ResultSet, LifecycleError> {
576    serde_json::from_slice(body)
577        .map_err(|error| LifecycleError::new(LifecycleErrorCode::DecodeFailed, error.to_string()))
578}
579
580fn parse_query_status(body: &[u8]) -> Result<QueryStatus, LifecycleError> {
581    serde_json::from_slice(body)
582        .map_err(|error| LifecycleError::new(LifecycleErrorCode::DecodeFailed, error.to_string()))
583}
584
585fn parse_failure(body: &[u8]) -> Result<QueryFailureStatus, LifecycleError> {
586    serde_json::from_slice(body)
587        .map_err(|error| LifecycleError::new(LifecycleErrorCode::DecodeFailed, error.to_string()))
588}
589
590/// Decode a non-inline partition body: a bare JSON array of rows.
591///
592/// # Errors
593/// [`LifecycleErrorCode::DecodeFailed`] if the body is not a JSON row array.
594pub fn parse_partition_rows(body: &[u8]) -> Result<Vec<Vec<Option<String>>>, LifecycleError> {
595    // The live SQL API returns a non-inline partition as a JSON **object**
596    // `{"data": [[...]]}` (observed against a real account, 2026-06-25 — a
597    // `GET ...?partition=N` over a 13-partition result yielded `{"data":[...]}`,
598    // gzip-encoded, decoded by the transport). The original bare-top-level-array
599    // assumption (carried by replay/mock fixtures) made every live partition
600    // fetch fail with `invalid type: map, expected a sequence`. Accept the object
601    // form first, then fall back to the bare-array form for fixtures/replays.
602    #[derive(serde::Deserialize)]
603    struct PartitionEnvelope {
604        data: Vec<Vec<Option<String>>>,
605    }
606    if let Ok(envelope) = serde_json::from_slice::<PartitionEnvelope>(body) {
607        return Ok(envelope.data);
608    }
609    serde_json::from_slice(body)
610        .map_err(|error| LifecycleError::new(LifecycleErrorCode::DecodeFailed, error.to_string()))
611}
612
613fn validate_partition_row_count(
614    result_set: &ResultSet,
615    partition: u32,
616    actual_rows: usize,
617) -> Result<(), LifecycleError> {
618    let Some(expected) = usize::try_from(partition)
619        .ok()
620        .and_then(|index| result_set.result_set_meta_data.partition_info.get(index))
621        .map(|info| info.row_count)
622    else {
623        return Ok(());
624    };
625    if expected < 0 {
626        return Err(LifecycleError::new(
627            LifecycleErrorCode::PartitionRowMismatch,
628            format!("partition {partition} rowCount is negative"),
629        ));
630    }
631    if i64::try_from(actual_rows).ok() != Some(expected) {
632        return Err(LifecycleError::new(
633            LifecycleErrorCode::PartitionRowMismatch,
634            format!("partition {partition} returned {actual_rows} rows but rowCount is {expected}"),
635        ));
636    }
637    Ok(())
638}
639
640fn validate_total_row_count(actual_rows: usize, expected: i64) -> Result<(), LifecycleError> {
641    if expected < 0 {
642        return Err(LifecycleError::new(
643            LifecycleErrorCode::PartitionRowMismatch,
644            "numRows is negative",
645        ));
646    }
647    if i64::try_from(actual_rows).ok() != Some(expected) {
648        return Err(LifecycleError::new(
649            LifecycleErrorCode::PartitionRowMismatch,
650            format!("assembled {actual_rows} rows but numRows is {expected}"),
651        ));
652    }
653    Ok(())
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659
660    #[test]
661    fn poll_plan_interval_is_always_sane() {
662        // The default paces the 202 poll loop (never a tight spin).
663        assert_eq!(
664            PollPlan::default().poll_interval,
665            Duration::from_millis(1_000)
666        );
667        assert!(PollPlan::default().effective_poll_interval() >= MIN_POLL_INTERVAL);
668        // with_max_polls keeps the default interval.
669        assert_eq!(
670            PollPlan::with_max_polls(5).poll_interval,
671            PollPlan::default().poll_interval
672        );
673        // A too-small (or zero) interval is clamped to the floor, both via the
674        // setter and via the effective accessor (guarding a direct field write).
675        assert_eq!(
676            PollPlan::default()
677                .with_poll_interval(Duration::ZERO)
678                .poll_interval,
679            MIN_POLL_INTERVAL
680        );
681        let hand_set = PollPlan {
682            poll_interval: Duration::ZERO,
683            ..PollPlan::default()
684        };
685        assert_eq!(hand_set.effective_poll_interval(), MIN_POLL_INTERVAL);
686    }
687
688    #[test]
689    fn partition_total_treats_absent_or_single_info_as_inline() -> Result<(), String> {
690        let body = br#"{"resultSetMetaData":{"numRows":0,"format":"jsonv2","rowType":[]},
691            "data":[],"code":"090001","statementHandle":"h"}"#;
692        let result_set = parse_result_set(body).map_err(|error| error.to_string())?;
693        assert_eq!(partition_total(&result_set), 1);
694        Ok(())
695    }
696
697    #[test]
698    fn single_partition_completes_immediately() -> Result<(), String> {
699        let body = br#"{"resultSetMetaData":{"numRows":1,"format":"jsonv2",
700            "rowType":[{"name":"A","type":"TEXT","nullable":false}],
701            "partitionInfo":[{"rowCount":1,"compressedSize":1,"uncompressedSize":1}]},
702            "data":[["x"]],"code":"090001","statementHandle":"h"}"#;
703        let mut machine = StatementMachine::new(PollPlan::default());
704        match machine.on_submit(ResponseClass::Completed, body) {
705            Ok(Progress::Complete(done)) => {
706                assert_eq!(done.rows.len(), 1);
707                assert_eq!(done.statement_handle, StatementHandle::new("h"));
708                Ok(())
709            }
710            other => Err(format!("expected Complete, got {other:?}")),
711        }
712    }
713
714    #[test]
715    fn running_then_completed_polls_then_finishes() -> Result<(), String> {
716        let running = br#"{"code":"333334","statementHandle":"h2"}"#;
717        let completed = br#"{"resultSetMetaData":{"numRows":0,"format":"jsonv2","rowType":[]},
718            "data":[],"code":"090001","statementHandle":"h2"}"#;
719        let mut machine = StatementMachine::new(PollPlan::default());
720        match machine.on_submit(ResponseClass::Running, running) {
721            Ok(Progress::PollAgain(h)) => assert_eq!(h, StatementHandle::new("h2")),
722            other => return Err(format!("expected PollAgain, got {other:?}")),
723        }
724        match machine.on_poll(ResponseClass::Completed, completed) {
725            Ok(Progress::Complete(_)) => Ok(()),
726            other => Err(format!("expected Complete, got {other:?}")),
727        }
728    }
729
730    #[test]
731    fn poll_quota_is_enforced() -> Result<(), String> {
732        let running = br#"{"code":"333334","statementHandle":"h3"}"#;
733        let mut machine = StatementMachine::new(PollPlan::with_max_polls(2));
734        machine
735            .on_poll(ResponseClass::Running, running)
736            .map_err(|e| e.to_string())?;
737        machine
738            .on_poll(ResponseClass::Running, running)
739            .map_err(|e| e.to_string())?;
740        match machine.on_poll(ResponseClass::Running, running) {
741            Err(error) => {
742                assert_eq!(error.code, LifecycleErrorCode::PollQuotaExhausted);
743                Ok(())
744            }
745            Ok(progress) => Err(format!("expected quota error, got {progress:?}")),
746        }
747    }
748
749    #[test]
750    fn timeout_and_failure_are_distinct_terminal_states() -> Result<(), String> {
751        let timeout = br#"{"code":"000630","message":"timed out","statementHandle":"h"}"#;
752        let failure = br#"{"code":"001003","message":"bad sql","statementHandle":"h"}"#;
753        let mut machine = StatementMachine::new(PollPlan::default());
754        assert!(matches!(
755            machine.on_submit(ResponseClass::StatementTimeout, timeout),
756            Ok(Progress::TimedOut(_))
757        ));
758        let mut other = StatementMachine::new(PollPlan::default());
759        assert!(matches!(
760            other.on_submit(ResponseClass::StatementFailed, failure),
761            Ok(Progress::Failed(_))
762        ));
763        Ok(())
764    }
765
766    #[test]
767    fn timeout_and_failure_close_the_machine() -> Result<(), String> {
768        let timeout = br#"{"code":"000630","message":"timed out","statementHandle":"h"}"#;
769        let completed = br#"{"resultSetMetaData":{"numRows":0,"format":"jsonv2","rowType":[]},
770            "data":[],"code":"090001","statementHandle":"h"}"#;
771        let mut machine = StatementMachine::new(PollPlan::default());
772        assert!(matches!(
773            machine.on_submit(ResponseClass::StatementTimeout, timeout),
774            Ok(Progress::TimedOut(_))
775        ));
776        match machine.on_poll(ResponseClass::Completed, completed) {
777            Err(error) => {
778                assert_eq!(error.code, LifecycleErrorCode::UnexpectedStatus);
779                Ok(())
780            }
781            Ok(progress) => Err(format!(
782                "expected terminal machine refusal, got {progress:?}"
783            )),
784        }
785    }
786
787    #[test]
788    fn terminal_machine_refuses_every_reentry_path() -> Result<(), String> {
789        let failure = br#"{"code":"001003","message":"bad sql","statementHandle":"h"}"#;
790        let completed = br#"{"resultSetMetaData":{"numRows":0,"format":"jsonv2","rowType":[]},
791            "data":[],"code":"090001","statementHandle":"h"}"#;
792
793        // A 422 failure closes the machine; a follow-up 200 poll must not re-open
794        // it into a success (the original re-open hole).
795        let mut after_failure = StatementMachine::new(PollPlan::default());
796        assert!(matches!(
797            after_failure.on_submit(ResponseClass::StatementFailed, failure),
798            Ok(Progress::Failed(_))
799        ));
800        assert_eq!(
801            after_failure
802                .on_poll(ResponseClass::Completed, completed)
803                .map(|_| ())
804                .unwrap_err()
805                .code,
806            LifecycleErrorCode::UnexpectedStatus
807        );
808
809        // A completed statement is terminal too: a duplicate submit/poll is refused
810        // rather than silently restarting the lifecycle.
811        let mut after_success = StatementMachine::new(PollPlan::default());
812        assert!(matches!(
813            after_success.on_submit(ResponseClass::Completed, completed),
814            Ok(Progress::Complete(_))
815        ));
816        assert_eq!(
817            after_success
818                .on_submit(ResponseClass::Completed, completed)
819                .map(|_| ())
820                .unwrap_err()
821                .code,
822            LifecycleErrorCode::UnexpectedStatus
823        );
824        assert_eq!(
825            after_success
826                .on_poll(ResponseClass::Completed, completed)
827                .map(|_| ())
828                .unwrap_err()
829                .code,
830            LifecycleErrorCode::UnexpectedStatus
831        );
832        // A refused poll must not be billed against the poll quota.
833        assert_eq!(after_success.polls_done(), 0);
834        Ok(())
835    }
836
837    #[test]
838    fn multi_partition_assembles_rows_in_order() -> Result<(), String> {
839        // 3 partitions, numRows 5: 2 inline + 2 (partition 1) + 1 (partition 2).
840        let terminal = br#"{"resultSetMetaData":{"numRows":5,"format":"jsonv2",
841            "rowType":[{"name":"ID","type":"FIXED","nullable":false},
842                       {"name":"NAME","type":"TEXT","nullable":false}],
843            "partitionInfo":[{"rowCount":2,"compressedSize":1,"uncompressedSize":1},
844                             {"rowCount":2,"compressedSize":1,"uncompressedSize":1},
845                             {"rowCount":1,"compressedSize":1,"uncompressedSize":1}]},
846            "data":[["1","a"],["2","b"]],"code":"090001","statementHandle":"hp"}"#;
847        let mut machine = StatementMachine::new(PollPlan::default());
848        let first = machine.on_submit(ResponseClass::Completed, terminal);
849        let handle = match first {
850            Ok(Progress::FetchPartition {
851                handle,
852                partition: 1,
853            }) => handle,
854            other => return Err(format!("expected FetchPartition 1, got {other:?}")),
855        };
856        assert_eq!(handle, StatementHandle::new("hp"));
857        match machine.on_partition(ResponseClass::Completed, 1, br#"[["3","c"],["4","d"]]"#) {
858            Ok(Progress::FetchPartition { partition: 2, .. }) => {}
859            other => return Err(format!("expected FetchPartition 2, got {other:?}")),
860        }
861        match machine.on_partition(ResponseClass::Completed, 2, br#"[["5","e"]]"#) {
862            Ok(Progress::Complete(done)) => {
863                assert_eq!(done.rows.len(), 5);
864                assert_eq!(
865                    done.rows[4],
866                    vec![Some("5".to_owned()), Some("e".to_owned())]
867                );
868                Ok(())
869            }
870            other => Err(format!("expected Complete, got {other:?}")),
871        }
872    }
873
874    #[test]
875    fn parse_partition_rows_accepts_live_object_data_form() -> Result<(), String> {
876        // Live-decode regression (bug #2): a real `GET ...?partition=N` returns the
877        // rows wrapped in `{"data":[[...]]}`, not a bare top-level array. The
878        // bare-array assumption made every live partition fetch fail with
879        // `invalid type: map, expected a sequence`.
880        let rows = parse_partition_rows(br#"{"data":[["3","c"],["4","d"]]}"#)
881            .map_err(|error| error.to_string())?;
882        assert_eq!(
883            rows,
884            vec![
885                vec![Some("3".to_owned()), Some("c".to_owned())],
886                vec![Some("4".to_owned()), Some("d".to_owned())],
887            ]
888        );
889        Ok(())
890    }
891
892    #[test]
893    fn parse_partition_rows_still_accepts_bare_array_form() -> Result<(), String> {
894        // Backward-compat: replay/mock fixtures emit the bare array form, which the
895        // object-form fix must not regress.
896        let rows = parse_partition_rows(br#"[["5","e"]]"#).map_err(|error| error.to_string())?;
897        assert_eq!(rows, vec![vec![Some("5".to_owned()), Some("e".to_owned())]]);
898        Ok(())
899    }
900
901    #[test]
902    fn partition_info_decodes_when_compressed_size_is_omitted() -> Result<(), String> {
903        // Live-decode regression (bug #1): the real API omits `compressedSize` for
904        // the inline, uncompressed partition 0. A required field made every live
905        // response fail with `missing field compressedSize`; the field is now
906        // optional and absent decodes to `None`.
907        let body = br#"{"resultSetMetaData":{"numRows":2,"format":"jsonv2",
908            "rowType":[{"name":"A","type":"TEXT","nullable":false}],
909            "partitionInfo":[{"rowCount":2,"uncompressedSize":64}]},
910            "data":[["x"],["y"]],"code":"090001","statementHandle":"h"}"#;
911        let result_set = parse_result_set(body).map_err(|error| error.to_string())?;
912        let info = result_set
913            .result_set_meta_data
914            .partition_info
915            .first()
916            .ok_or("expected partition_info[0]")?;
917        assert_eq!(info.row_count, 2);
918        assert_eq!(info.compressed_size, None);
919        assert_eq!(info.uncompressed_size, Some(64));
920        Ok(())
921    }
922
923    #[test]
924    fn live_shaped_multi_partition_flow_decodes_end_to_end() -> Result<(), String> {
925        // Both live-discovered decode bugs together, exercised through the real
926        // StatementMachine path (mirrors what the live proof did end-to-end):
927        // (1) partition 0's info omits `compressedSize`, and
928        // (2) the fetched partition body uses the `{"data":[...]}` object form.
929        let terminal = br#"{"resultSetMetaData":{"numRows":3,"format":"jsonv2",
930            "rowType":[{"name":"ID","type":"FIXED","nullable":false}],
931            "partitionInfo":[{"rowCount":2,"uncompressedSize":16},
932                             {"rowCount":1,"compressedSize":8,"uncompressedSize":16}]},
933            "data":[["1"],["2"]],"code":"090001","statementHandle":"hp"}"#;
934        let mut machine = StatementMachine::new(PollPlan::default());
935        let handle = match machine.on_submit(ResponseClass::Completed, terminal) {
936            Ok(Progress::FetchPartition {
937                handle,
938                partition: 1,
939            }) => handle,
940            other => return Err(format!("expected FetchPartition 1, got {other:?}")),
941        };
942        assert_eq!(handle, StatementHandle::new("hp"));
943        match machine.on_partition(ResponseClass::Completed, 1, br#"{"data":[["3"]]}"#) {
944            Ok(Progress::Complete(done)) => {
945                assert_eq!(done.rows.len(), 3);
946                assert_eq!(done.rows[2], vec![Some("3".to_owned())]);
947                Ok(())
948            }
949            other => Err(format!("expected Complete, got {other:?}")),
950        }
951    }
952
953    #[test]
954    fn row_count_mismatch_is_rejected() {
955        let terminal = br#"{"resultSetMetaData":{"numRows":99,"format":"jsonv2",
956            "rowType":[{"name":"A","type":"TEXT","nullable":false}],
957            "partitionInfo":[{"rowCount":1,"compressedSize":1,"uncompressedSize":1},
958                             {"rowCount":1,"compressedSize":1,"uncompressedSize":1}]},
959            "data":[["x"]],"code":"090001","statementHandle":"hp"}"#;
960        let mut machine = StatementMachine::new(PollPlan::default());
961        let _ = machine.on_submit(ResponseClass::Completed, terminal);
962        let result = machine.on_partition(ResponseClass::Completed, 1, br#"[["y"]]"#);
963        assert!(matches!(
964            result,
965            Err(LifecycleError {
966                code: LifecycleErrorCode::PartitionRowMismatch,
967                ..
968            })
969        ));
970    }
971
972    #[test]
973    fn fetched_partition_row_count_mismatch_is_rejected_before_total_can_compensate() {
974        let terminal = br#"{"resultSetMetaData":{"numRows":3,"format":"jsonv2",
975            "rowType":[{"name":"A","type":"TEXT","nullable":false}],
976            "partitionInfo":[{"rowCount":1,"compressedSize":1,"uncompressedSize":1},
977                             {"rowCount":1,"compressedSize":1,"uncompressedSize":1},
978                             {"rowCount":1,"compressedSize":1,"uncompressedSize":1}]},
979            "data":[["inline"]],"code":"090001","statementHandle":"hp"}"#;
980        let mut machine = StatementMachine::new(PollPlan::default());
981        assert!(matches!(
982            machine.on_submit(ResponseClass::Completed, terminal),
983            Ok(Progress::FetchPartition { partition: 1, .. })
984        ));
985
986        let result = machine.on_partition(
987            ResponseClass::Completed,
988            1,
989            br#"[["too-many"],["would-hide-empty-next"]]"#,
990        );
991
992        assert!(matches!(
993            result,
994            Err(LifecycleError {
995                code: LifecycleErrorCode::PartitionRowMismatch,
996                ..
997            })
998        ));
999    }
1000
1001    #[test]
1002    fn empty_fetched_partition_with_positive_row_count_is_rejected() {
1003        let terminal = br#"{"resultSetMetaData":{"numRows":2,"format":"jsonv2",
1004            "rowType":[{"name":"A","type":"TEXT","nullable":false}],
1005            "partitionInfo":[{"rowCount":1,"compressedSize":1,"uncompressedSize":1},
1006                             {"rowCount":1,"compressedSize":1,"uncompressedSize":1}]},
1007            "data":[["inline"]],"code":"090001","statementHandle":"hp"}"#;
1008        let mut machine = StatementMachine::new(PollPlan::default());
1009        assert!(matches!(
1010            machine.on_submit(ResponseClass::Completed, terminal),
1011            Ok(Progress::FetchPartition { partition: 1, .. })
1012        ));
1013
1014        let result = machine.on_partition(ResponseClass::Completed, 1, br#"[]"#);
1015
1016        assert!(matches!(
1017            result,
1018            Err(LifecycleError {
1019                code: LifecycleErrorCode::PartitionRowMismatch,
1020                ..
1021            })
1022        ));
1023    }
1024
1025    #[test]
1026    fn inline_partition_info_row_count_mismatch_is_rejected() {
1027        let terminal = br#"{"resultSetMetaData":{"numRows":1,"format":"jsonv2",
1028            "rowType":[{"name":"A","type":"TEXT","nullable":false}],
1029            "partitionInfo":[{"rowCount":2,"compressedSize":1,"uncompressedSize":1}]},
1030            "data":[["x"]],"code":"090001","statementHandle":"h"}"#;
1031        let mut machine = StatementMachine::new(PollPlan::default());
1032
1033        assert!(matches!(
1034            machine.on_submit(ResponseClass::Completed, terminal),
1035            Err(LifecycleError {
1036                code: LifecycleErrorCode::PartitionRowMismatch,
1037                ..
1038            })
1039        ));
1040    }
1041
1042    #[test]
1043    fn single_partition_row_count_mismatch_is_rejected() {
1044        // numRows claims 5 but the single inline partition has 1 row: the
1045        // integrity check applies to the single-partition path too.
1046        let terminal = br#"{"resultSetMetaData":{"numRows":5,"format":"jsonv2",
1047            "rowType":[{"name":"A","type":"TEXT","nullable":false}],
1048            "partitionInfo":[{"rowCount":5,"compressedSize":1,"uncompressedSize":1}]},
1049            "data":[["x"]],"code":"090001","statementHandle":"h"}"#;
1050        let mut machine = StatementMachine::new(PollPlan::default());
1051        assert!(matches!(
1052            machine.on_submit(ResponseClass::Completed, terminal),
1053            Err(LifecycleError {
1054                code: LifecycleErrorCode::PartitionRowMismatch,
1055                ..
1056            })
1057        ));
1058    }
1059}