Skip to main content

questdb/ingress/
polars.rs

1//! Polars sub-feature: convert a [`DataFrame`] into Arrow
2//! [`RecordBatch`]es for consumption by
3//! [`PooledSenderCore::flush_arrow_batch_at_column`][crate::ingress::column_sender::PooledSenderCore::flush_arrow_batch_at_column]
4//! (or [`PooledSenderCore::flush_arrow_batch_at_now`][crate::ingress::column_sender::PooledSenderCore::flush_arrow_batch_at_now]
5//! when the server should assign timestamps).
6//!
7//! [`dataframe_to_batches`] is the primary entry point. It returns an
8//! iterator that yields slices of at most `max_rows` rows each. Each
9//! emitted slice is taken from a single polars chunk per column. The
10//! conversion cost depends on the dtype:
11//!
12//! * **Primitive, String, Binary, Decimal at the newest compat level**:
13//!   the per-chunk Arrow C Data Interface handoff is a pure refcount
14//!   bump and the per-batch slice is zero-copy.
15//! * **`Column::Scalar` columns**: materialised once by polars (cached
16//!   in the column's `OnceLock`); subsequent batches slice that cache
17//!   zero-copy. Sending a scalar as columnar data requires the value to
18//!   exist in memory N times — there is no zero-copy alternative.
19//! * **Polars *logical* dtypes that arrow-rs lacks natively** (Datetime,
20//!   Date, Time, Duration, Categorical, Enum): incur a `cast_default`
21//!   per chunk per emitted batch. The converted Arrow chunk is cached
22//!   only for the lifetime of the current chunk within the iterator
23//!   (not across `dataframe_to_batches` calls or across chunk
24//!   boundaries within one call), so a multi-chunk DataFrame with
25//!   timestamp/categorical columns re-pays the cast each time the
26//!   iterator crosses a chunk boundary. Acceptable for typical batch
27//!   sizes (10 K rows ≈ µs of cast vs ms of wire send) but worth
28//!   knowing if you slice into many small batches.
29//!
30//! # Per-chunk dtype stability
31//!
32//! `Categorical` (and other dictionary-backed) columns may emit
33//! different Arrow value dtypes across chunks (e.g. `Utf8` vs
34//! `LargeUtf8`) depending on per-chunk statistics. The iterator pins
35//! the first chunk's dtype as the wire schema and rejects subsequent
36//! chunks whose dtype differs with [`ErrorCode::ArrowIngest`]. To
37//! avoid this, rechunk via `DataFrame::rechunk()` before calling
38//! `dataframe_to_batches`, or cast Categorical columns to plain
39//! `String` upstream.
40//!
41//! [`ErrorCode::ArrowIngest`]: crate::ErrorCode::ArrowIngest
42//!
43//! The one-call shortcut is [`QuestDb::flush_polars_dataframe`], which
44//! borrows a direct column sender from the pool internally — callers never
45//! handle the sender themselves. Use [`PolarsIngestOptions`] to control
46//! slicing, timestamp selection, overrides, and ack level while leaving commit
47//! and retry ownership with the public `QuestDb` entry point.
48//!
49//! [`QuestDb::flush_polars_dataframe`]: crate::QuestDb::flush_polars_dataframe
50
51use std::num::NonZeroUsize;
52use std::sync::Arc;
53
54use arrow::array::{ArrayRef, RecordBatch};
55use arrow::datatypes::{Field, Schema as ArrowSchema};
56use polars::frame::DataFrame;
57use polars::prelude::{Column, CompatLevel, Series};
58
59use crate::{Result, fmt};
60
61/// Suggested default chunk size for [`dataframe_to_batches`]. Shares the
62/// cross-binding default so the Python columnar path and this helper stay
63/// aligned; the column sender splits any frame exceeding the negotiated cap
64/// regardless of this value.
65pub const DEFAULT_MAX_BATCH_ROWS: usize = crate::ingress::column_sender::DEFAULT_MAX_CHUNK_ROWS;
66
67const _: () = assert!(
68    std::mem::size_of::<polars_arrow::ffi::ArrowArray>()
69        == std::mem::size_of::<arrow::ffi::FFI_ArrowArray>(),
70);
71const _: () = assert!(
72    std::mem::size_of::<polars_arrow::ffi::ArrowSchema>()
73        == std::mem::size_of::<arrow::ffi::FFI_ArrowSchema>(),
74);
75const _: () = assert!(
76    std::mem::align_of::<polars_arrow::ffi::ArrowArray>()
77        == std::mem::align_of::<arrow::ffi::FFI_ArrowArray>(),
78);
79const _: () = assert!(
80    std::mem::align_of::<polars_arrow::ffi::ArrowSchema>()
81        == std::mem::align_of::<arrow::ffi::FFI_ArrowSchema>(),
82);
83
84// polars-arrow keeps its `ArrowArray`/`ArrowSchema` fields private, so a
85// field-level copy is impossible. We rely on the Arrow C Data Interface
86// spec to fix the `#[repr(C)]` field order across crates; `transmute`
87// is sound as long as both crates implement the same spec. The
88// `polars_ffi_layout_round_trip` test fires a real data roundtrip on
89// every CI run to catch a spec violation in either crate before
90// production.
91
92#[inline]
93unsafe fn pa_array_into_rs(pa: polars_arrow::ffi::ArrowArray) -> arrow::ffi::FFI_ArrowArray {
94    unsafe { std::mem::transmute::<polars_arrow::ffi::ArrowArray, arrow::ffi::FFI_ArrowArray>(pa) }
95}
96
97#[inline]
98unsafe fn pa_schema_into_rs(pa: polars_arrow::ffi::ArrowSchema) -> arrow::ffi::FFI_ArrowSchema {
99    unsafe {
100        std::mem::transmute::<polars_arrow::ffi::ArrowSchema, arrow::ffi::FFI_ArrowSchema>(pa)
101    }
102}
103
104// `rs_array_into_pa` / `rs_schema_into_pa` moved to the transport-neutral
105// `crate::polars_ffi` so the egress polars path can share them without
106// reaching into this ingress module.
107
108/// Yield [`RecordBatch`] slices of `df`, each capped at `max_rows`
109/// rows. `None` uses [`DEFAULT_MAX_BATCH_ROWS`]. Every emitted slice
110/// is taken from a single polars chunk per column, so row data is
111/// shared via the Arrow C Data Interface and never copied. Conversion
112/// errors surface through the iterator's `Item` rather than the
113/// constructor.
114pub fn dataframe_to_batches(
115    df: &DataFrame,
116    max_rows: Option<NonZeroUsize>,
117) -> DataFrameBatches<'_> {
118    let max_rows = max_rows.map_or(DEFAULT_MAX_BATCH_ROWS, NonZeroUsize::get);
119    let compat = CompatLevel::newest();
120    let cursors: Vec<ColumnCursor<'_>> = (0..df.width())
121        .map(|i| ColumnCursor::new(df.select_at_idx(i).unwrap(), compat))
122        .collect();
123    DataFrameBatches {
124        max_rows,
125        compat,
126        total_rows: df.height(),
127        rows_emitted: 0,
128        cursors,
129        schema: None,
130        poisoned: false,
131    }
132}
133
134/// Iterator returned by [`dataframe_to_batches`]. One-shot error
135/// contract: a `Some(Err(_))` poisons the iterator; subsequent
136/// `next()` returns `None`.
137pub struct DataFrameBatches<'a> {
138    max_rows: usize,
139    compat: CompatLevel,
140    total_rows: usize,
141    rows_emitted: usize,
142    cursors: Vec<ColumnCursor<'a>>,
143    schema: Option<Arc<ArrowSchema>>,
144    poisoned: bool,
145}
146
147struct ColumnCursor<'a> {
148    name: String,
149    series: &'a Series,
150    pa_field: polars_arrow::datatypes::Field,
151    chunk_lengths: Vec<usize>,
152    chunk_idx: usize,
153    offset_in_chunk: usize,
154    current: Option<Box<dyn polars_arrow::array::Array>>,
155}
156
157impl<'a> ColumnCursor<'a> {
158    fn new(column: &'a Column, compat: CompatLevel) -> Self {
159        let series = column.as_materialized_series();
160        let pa_field = polars_arrow::datatypes::Field::new(
161            series.name().clone(),
162            series.dtype().to_arrow(compat),
163            true,
164        );
165        Self {
166            name: column.name().as_str().to_string(),
167            series,
168            pa_field,
169            chunk_lengths: series.chunk_lengths().collect(),
170            chunk_idx: 0,
171            offset_in_chunk: 0,
172            current: None,
173        }
174    }
175
176    fn skip_empty_chunks(&mut self) {
177        while self.chunk_idx < self.chunk_lengths.len() && self.chunk_lengths[self.chunk_idx] == 0 {
178            self.chunk_idx += 1;
179            self.offset_in_chunk = 0;
180            self.current = None;
181        }
182    }
183
184    fn remaining_in_chunk(&self) -> usize {
185        if self.chunk_idx >= self.chunk_lengths.len() {
186            return 0;
187        }
188        self.chunk_lengths[self.chunk_idx] - self.offset_in_chunk
189    }
190
191    fn current_chunk(&mut self, compat: CompatLevel) -> &dyn polars_arrow::array::Array {
192        let chunk_idx = self.chunk_idx;
193        let series = self.series;
194        let boxed = self
195            .current
196            .get_or_insert_with(|| series.to_arrow(chunk_idx, compat));
197        &**boxed
198    }
199
200    fn advance(&mut self, n: usize) {
201        self.offset_in_chunk += n;
202        if self.offset_in_chunk >= self.chunk_lengths[self.chunk_idx] {
203            self.chunk_idx += 1;
204            self.offset_in_chunk = 0;
205            self.current = None;
206        }
207    }
208}
209
210impl Iterator for DataFrameBatches<'_> {
211    type Item = Result<RecordBatch>;
212
213    fn next(&mut self) -> Option<Self::Item> {
214        if self.poisoned || self.cursors.is_empty() || self.rows_emitted >= self.total_rows {
215            return None;
216        }
217        for cursor in &mut self.cursors {
218            cursor.skip_empty_chunks();
219        }
220        let mut seg_len = self.max_rows;
221        for cursor in &self.cursors {
222            seg_len = seg_len.min(cursor.remaining_in_chunk());
223        }
224        if seg_len == 0 {
225            if self.rows_emitted < self.total_rows {
226                self.poisoned = true;
227                return Some(Err(fmt!(
228                    ArrowIngest,
229                    "internal: column chunk lengths disagree ({} of {} rows emitted)",
230                    self.rows_emitted,
231                    self.total_rows
232                )));
233            }
234            return None;
235        }
236        let compat = self.compat;
237        let need_schema = self.schema.is_none();
238        let mut fields: Vec<Field> = if need_schema {
239            Vec::with_capacity(self.cursors.len())
240        } else {
241            Vec::new()
242        };
243        let mut arrays: Vec<ArrayRef> = Vec::with_capacity(self.cursors.len());
244        for cursor in &mut self.cursors {
245            let offset = cursor.offset_in_chunk;
246            let chunk = cursor.current_chunk(compat);
247            let chunk_dtype = chunk.dtype().clone();
248            let sliced = chunk.sliced(offset, seg_len);
249            if chunk_dtype != cursor.pa_field.dtype {
250                self.poisoned = true;
251                return Some(Err(fmt!(
252                    ArrowIngest,
253                    "column '{}': per-chunk Arrow dtype {:?} differs from the pinned schema \
254                     dtype {:?}; call DataFrame::rechunk() or cast the column to a stable \
255                     dtype before ingest",
256                    cursor.name,
257                    chunk_dtype,
258                    cursor.pa_field.dtype
259                )));
260            }
261            let array_data = match ffi_polars_to_arrow_rs(&cursor.pa_field, sliced, &cursor.name) {
262                Ok(d) => d,
263                Err(e) => {
264                    self.poisoned = true;
265                    return Some(Err(e));
266                }
267            };
268            if need_schema {
269                fields.push(Field::new(
270                    cursor.name.clone(),
271                    array_data.data_type().clone(),
272                    true,
273                ));
274            }
275            arrays.push(arrow::array::make_array(array_data));
276        }
277        let schema = match &self.schema {
278            Some(s) => s.clone(),
279            None => {
280                let s = Arc::new(ArrowSchema::new(fields));
281                self.schema = Some(s.clone());
282                s
283            }
284        };
285        let rb = match RecordBatch::try_new(schema, arrays) {
286            Ok(rb) => rb,
287            Err(e) => {
288                self.poisoned = true;
289                return Some(Err(fmt!(ArrowIngest, "RecordBatch::try_new failed: {}", e)));
290            }
291        };
292        for cursor in &mut self.cursors {
293            cursor.advance(seg_len);
294        }
295        self.rows_emitted += seg_len;
296        Some(Ok(rb))
297    }
298}
299
300/// Number of batches between commit checkpoints. The ≤63 publish-only
301/// (deferred) frames that accumulate between checkpoints stay under the QWP
302/// 127-deferred in-flight cap; the checkpoint itself is a non-deferred ACKing
303/// flush that drains in-flight to zero. 64 keeps the pipeline full and bounds a
304/// failover re-drive to ≈64 × `max_rows` rows.
305const CHECKPOINT_BATCHES: usize = 64;
306
307/// Optional knobs for [`QuestDb::flush_polars_dataframe`].
308///
309/// Every field defaults to "off", so `PolarsIngestOptions::default()` (or
310/// [`PolarsIngestOptions::new`]) reproduces the original three-argument
311/// behaviour: [`DEFAULT_MAX_BATCH_ROWS`]-row batches, server-assigned
312/// timestamps, and wire types derived from the Arrow schema alone.
313///
314/// Build with the chainable setters:
315///
316/// ```ignore
317/// let opts = questdb::ingress::polars::PolarsIngestOptions::new()
318///     .max_rows(50_000)
319///     .timestamp_column(ColumnName::new("ts")?)
320///     .overrides(&overrides);
321/// db.flush_polars_dataframe("trades", &df, &opts)?;
322/// ```
323///
324/// [`QuestDb::flush_polars_dataframe`]: crate::QuestDb::flush_polars_dataframe
325#[derive(Clone, Copy, Default)]
326pub struct PolarsIngestOptions<'a> {
327    max_rows: Option<NonZeroUsize>,
328    timestamp_column: Option<crate::ingress::ColumnName<'a>>,
329    overrides: &'a [crate::ingress::column_sender::ArrowColumnOverride<'a>],
330    ack_level: Option<crate::ingress::AckLevel>,
331}
332
333impl<'a> PolarsIngestOptions<'a> {
334    /// A fresh option set with every knob defaulted to "off".
335    #[must_use]
336    pub fn new() -> Self {
337        Self::default()
338    }
339
340    /// Cap each emitted [`RecordBatch`] at `rows` rows. `0` (or never calling
341    /// this) uses [`DEFAULT_MAX_BATCH_ROWS`]. Taking a plain `usize` keeps the
342    /// call site free of `NonZeroUsize` ceremony.
343    #[must_use]
344    pub fn max_rows(mut self, rows: usize) -> Self {
345        self.max_rows = NonZeroUsize::new(rows);
346        self
347    }
348
349    /// Source the per-row designated timestamp from `column` (a `Timestamp(_)`
350    /// column of the frame) instead of letting the server stamp each row on
351    /// arrival. Mirrors `PooledSenderCore::flush_arrow_batch_at_column`.
352    #[must_use]
353    pub fn timestamp_column(mut self, column: crate::ingress::ColumnName<'a>) -> Self {
354        self.timestamp_column = Some(column);
355        self
356    }
357
358    /// Per-column wire-type hints, applied to every batch sliced out of the
359    /// frame. Same meaning as the `overrides` argument of
360    /// `PooledSenderCore::flush_arrow_batch_at_now` — the intended path for Polars frames
361    /// built without pyarrow, whose Arrow schema carries no `questdb.*` field
362    /// metadata.
363    #[must_use]
364    pub fn overrides(
365        mut self,
366        overrides: &'a [crate::ingress::column_sender::ArrowColumnOverride<'a>],
367    ) -> Self {
368        self.overrides = overrides;
369        self
370    }
371
372    /// Block each checkpoint (and the trailing commit) until the frame reaches
373    /// `level`. Defaults to the connect string's level — the same one the
374    /// store-and-forward senders use:
375    /// [`AckLevel::Durable`](crate::ingress::AckLevel::Durable) when the
376    /// Enterprise-only durable mode is enabled with
377    /// `request_durable_ack=on`, otherwise
378    /// [`AckLevel::Ok`](crate::ingress::AckLevel::Ok). Requesting `Durable`
379    /// requires QuestDB Enterprise and `request_durable_ack=on`; otherwise it
380    /// is rejected with
381    /// [`ErrorCode::InvalidApiCall`](crate::ErrorCode::InvalidApiCall).
382    #[must_use]
383    pub fn ack_level(mut self, level: crate::ingress::AckLevel) -> Self {
384        self.ack_level = Some(level);
385        self
386    }
387}
388
389impl crate::db::BorrowedDirectColumnSender<'_> {
390    /// Slice `df` into [`RecordBatch`]es of at most `options.max_rows` rows
391    /// each (defaults to [`DEFAULT_MAX_BATCH_ROWS`]), publish every slice, and
392    /// commit at checkpoint boundaries — re-driving transparently across a
393    /// connection failure.
394    ///
395    /// `table` accepts anything convertible into a [`TableName`], so a bare
396    /// `&str` works directly. `options` ([`PolarsIngestOptions`]) carries an
397    /// optional designated-timestamp column and per-column wire-type
398    /// `overrides`, both applied to every sliced batch;
399    /// `PolarsIngestOptions::default()` preserves the previous behaviour
400    /// (server-assigned timestamps, schema-derived wire types).
401    ///
402    /// Unlike the lower-level `flush` / `flush_arrow_batch_*`, which leave rows
403    /// uncommitted until you call [`BorrowedDirectColumnSender::commit`], this entry
404    /// owns the commit (and the failover replay boundary).
405    ///
406    /// [`BorrowedDirectColumnSender::commit`]: crate::db::BorrowedDirectColumnSender::commit
407    ///
408    /// [`TableName`]: crate::ingress::TableName
409    ///
410    /// The batch loop commits a checkpoint boundary every
411    /// [`CHECKPOINT_BATCHES`] batches — an ACKing flush that publishes the
412    /// batch and waits for the server's OK ack — and a trailing wait covers
413    /// any tail past the last checkpoint. On a transient
414    /// ([`ErrorCode::FailoverRetry`]) error it re-borrows a live connection
415    /// from the pool behind the same handle (rotating to a live endpoint) and
416    /// re-iterates `&DataFrame` from the last committed checkpoint. The entry
417    /// owns the commit (the replay boundary) and returns only once the whole
418    /// `df` is committed.
419    ///
420    /// Reconnect matches the row API: the [`ReconnectPolicy`] parsed from the
421    /// `reconnect_*` keys (default 300s budget), centered-jittered exponential
422    /// backoff that resets on a role reject, and `AuthError` /
423    /// `ProtocolVersionError` treated as terminal.
424    ///
425    /// Delivery is **at-least-once**: a re-driven tail can re-send frames
426    /// committed but unobserved before the failure, producing **duplicate rows**
427    /// unless the destination table has `DEDUP UPSERT KEYS` covering them
428    /// (QuestDB keeps duplicates by default). The reconnect budget exhausting
429    /// surfaces the terminal error.
430    ///
431    /// [`ErrorCode::FailoverRetry`]: crate::ErrorCode::FailoverRetry
432    /// [`ReconnectPolicy`]: crate::ingress::ReconnectPolicy
433    pub(crate) fn flush_polars_dataframe<'t, T>(
434        &mut self,
435        table: T,
436        df: &DataFrame,
437        options: &PolarsIngestOptions<'_>,
438    ) -> Result<()>
439    where
440        T: TryInto<crate::ingress::TableName<'t>>,
441        crate::Error: From<T::Error>,
442    {
443        let table = table.try_into()?;
444        let mut deadline =
445            std::time::Instant::now().checked_add(self.reconnect_policy().max_duration());
446        // Batches confirmed by the last successful checkpoint; a transient
447        // failure re-drives only the tail past this.
448        let mut committed = 0usize;
449
450        loop {
451            let committed_before = committed;
452            match drive_from_checkpoint(self, table, df, options, &mut committed) {
453                Ok(()) => return Ok(()),
454                Err(err) if err.code() != crate::ErrorCode::FailoverRetry => return Err(err),
455                Err(err) => {
456                    // `reborrow_with_retry` returns as soon as a replacement
457                    // connection opens, so a server that accepts connections but
458                    // never advances acks would otherwise re-drive the tail
459                    // forever (unbounded duplicate writes). Bound the retries by
460                    // the reconnect budget, refreshed whenever a checkpoint makes
461                    // progress so a steadily-advancing ingest is never cut short.
462                    if committed > committed_before {
463                        deadline = std::time::Instant::now()
464                            .checked_add(self.reconnect_policy().max_duration());
465                    } else if crate::db::reconnect_deadline_expired(deadline) {
466                        return Err(err);
467                    }
468                    self.reborrow_with_retry(deadline)?;
469                }
470            }
471        }
472    }
473}
474
475impl crate::db::QuestDb {
476    /// Flush a polars [`DataFrame`] to `table` in a single call.
477    ///
478    /// This is the recommended DataFrame ingestion entry point: it borrows a
479    /// direct column sender from the pool, drives the whole frame, and returns
480    /// the sender to the pool on completion (or error) — callers never handle a
481    /// sender. Internally this uses the same direct columnar path as Arrow
482    /// ingestion, with checkpoint commits and retry owned by this method.
483    ///
484    /// `table` accepts anything convertible into a [`TableName`] (a bare `&str`
485    /// works). `options` ([`PolarsIngestOptions`]) carries the optional
486    /// designated-timestamp column, per-column wire-type `overrides` and batch
487    /// size.
488    ///
489    /// Commit, checkpoint and at-least-once failover-replay semantics are
490    /// unchanged from the underlying driver: the call owns the commit (the
491    /// replay boundary), re-driving the uncommitted tail onto a live endpoint
492    /// across a transient [`ErrorCode::FailoverRetry`] within the pool's
493    /// configured reconnect budget, and returns only once the whole `df` is
494    /// committed. A re-driven tail can produce **duplicate rows** unless the
495    /// destination table has `DEDUP UPSERT KEYS` covering them.
496    ///
497    /// [`TableName`]: crate::ingress::TableName
498    /// [`ErrorCode::FailoverRetry`]: crate::ErrorCode::FailoverRetry
499    pub fn flush_polars_dataframe<'t, T>(
500        &self,
501        table: T,
502        df: &DataFrame,
503        options: &PolarsIngestOptions<'_>,
504    ) -> Result<()>
505    where
506        T: TryInto<crate::ingress::TableName<'t>>,
507        crate::Error: From<T::Error>,
508    {
509        let mut sender = self.borrow_direct_column_sender()?;
510        sender.flush_polars_dataframe(table, df, options)
511    }
512}
513
514/// Single forward pass over `df`, skipping the first `*committed` batches (the
515/// tail already durable from an earlier attempt). Non-checkpoint batches are
516/// published with a no-wait flush; every [`CHECKPOINT_BATCHES`]th batch is
517/// flushed with an ACKing boundary flush (`flush_arrow_batch_*_and_wait`),
518/// which folds the periodic `sync(Ok)` into the flush — one fewer empty commit
519/// frame per checkpoint. `*committed` is advanced to the batch count made
520/// durable by each successful checkpoint, so on a transient error the caller
521/// re-drives only the uncommitted tail.
522fn drive_from_checkpoint(
523    sender: &mut crate::db::BorrowedDirectColumnSender<'_>,
524    table: crate::ingress::TableName<'_>,
525    df: &DataFrame,
526    options: &PolarsIngestOptions<'_>,
527    committed: &mut usize,
528) -> Result<()> {
529    // No caller-named level falls back to the connect string's default — the
530    // same level the store-and-forward senders use for this pool.
531    let ack = options
532        .ack_level
533        .unwrap_or_else(|| sender.default_ack_level());
534
535    let skip = *committed;
536    let mut last_was_checkpoint = false;
537    for (idx, rb) in dataframe_to_batches(df, options.max_rows).enumerate() {
538        if idx < skip {
539            continue;
540        }
541        let rb = rb?;
542        // `idx` is 0-based; checkpoint after a full run of CHECKPOINT_BATCHES
543        // (batches 63, 127, …). The checkpoint boundary's ack moves the replay
544        // marker.
545        let checkpoint = (idx + 1) % CHECKPOINT_BATCHES == 0;
546        match (options.timestamp_column, checkpoint) {
547            (Some(ts), false) => {
548                sender.flush_arrow_batch_at_column(table, &rb, ts, options.overrides)?
549            }
550            (None, false) => sender.flush_arrow_batch_at_now(table, &rb, options.overrides)?,
551            (Some(ts), true) => sender.flush_arrow_batch_at_column_and_wait(
552                table,
553                &rb,
554                ts,
555                options.overrides,
556                ack,
557            )?,
558            (None, true) => {
559                sender.flush_arrow_batch_at_now_and_wait(table, &rb, options.overrides, ack)?
560            }
561        }
562        if checkpoint {
563            // The ACKing flush committed the boundary covering every batch up
564            // to and including this one.
565            *committed = idx + 1;
566        }
567        last_was_checkpoint = checkpoint;
568    }
569    // If the final batch already was an ACKing checkpoint, the boundary is
570    // committed; otherwise drain the trailing tail. This also covers the
571    // empty-/no-batch case, where `sync` is the only completion wait.
572    if !last_was_checkpoint {
573        sender.commit(ack)?;
574    }
575    Ok(())
576}
577
578fn ffi_polars_to_arrow_rs(
579    pa_field: &polars_arrow::datatypes::Field,
580    pa_array_box: Box<dyn polars_arrow::array::Array>,
581    col_name: &str,
582) -> Result<arrow::array::ArrayData> {
583    let pa_schema = polars_arrow::ffi::export_field_to_c(pa_field);
584    let pa_array = polars_arrow::ffi::export_array_to_c(pa_array_box);
585    let rs_schema = unsafe { pa_schema_into_rs(pa_schema) };
586    let rs_array = unsafe { pa_array_into_rs(pa_array) };
587    let array_data = unsafe { arrow::ffi::from_ffi(rs_array, &rs_schema) }
588        .map_err(|e| fmt!(ArrowIngest, "from_ffi('{}'): {}", col_name, e))?;
589    // Trusted in-process polars output; no `validate_full()` re-scan.
590    Ok(array_data)
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596    use arrow::array::Int64Array;
597    use arrow::array::cast::AsArray;
598    use arrow::array::types::Int64Type;
599    use polars::prelude::{IntoColumn, NamedFrom, PlSmallStr, Series};
600
601    const TWO: NonZeroUsize = NonZeroUsize::new(2).unwrap();
602    const HUNDRED: NonZeroUsize = NonZeroUsize::new(100).unwrap();
603    const THOUSAND: NonZeroUsize = NonZeroUsize::new(1000).unwrap();
604
605    fn make_df() -> DataFrame {
606        let i = Series::new(PlSmallStr::from("i"), &[1i64, 2, 3]).into_column();
607        let f = Series::new(PlSmallStr::from("f"), &[1.5f64, 2.5, 3.5]).into_column();
608        let s = Series::new(PlSmallStr::from("s"), &["a", "b", "c"]).into_column();
609        crate::polars_ffi::df_from_columns(vec![i, f, s]).unwrap()
610    }
611
612    fn collect_ok(it: DataFrameBatches<'_>) -> Vec<RecordBatch> {
613        it.map(|rb| rb.expect("conversion failed")).collect()
614    }
615
616    fn one_batch(df: &DataFrame) -> RecordBatch {
617        let mut batches = collect_ok(dataframe_to_batches(df, None));
618        assert_eq!(batches.len(), 1);
619        batches.pop().unwrap()
620    }
621
622    #[test]
623    fn dataframe_to_batches_preserves_columns_and_height() {
624        let df = make_df();
625        let rb = one_batch(&df);
626        assert_eq!(rb.num_columns(), 3);
627        assert_eq!(rb.num_rows(), 3);
628        assert_eq!(rb.schema().field(0).name(), "i");
629        assert_eq!(rb.schema().field(1).name(), "f");
630        assert_eq!(rb.schema().field(2).name(), "s");
631    }
632
633    #[test]
634    fn polars_ffi_layout_round_trip() {
635        let s = Series::new(PlSmallStr::from("x"), &[10i64, 20, 30, 40, 50]);
636        let pa_field = polars_arrow::datatypes::Field::new(
637            s.name().clone(),
638            s.dtype().to_arrow(CompatLevel::newest()),
639            true,
640        );
641        let pa_arr = s.to_arrow(0, CompatLevel::newest());
642        let exported_array = polars_arrow::ffi::export_array_to_c(pa_arr);
643        let exported_schema = polars_arrow::ffi::export_field_to_c(&pa_field);
644
645        let rs_array = unsafe { pa_array_into_rs(exported_array) };
646        let rs_schema = unsafe { pa_schema_into_rs(exported_schema) };
647        let data = unsafe { arrow::ffi::from_ffi(rs_array, &rs_schema) }
648            .expect("from_ffi after polars-arrow → arrow-rs bridge");
649        data.validate_full()
650            .expect("valid polars export passes full validation");
651
652        let arr = arrow::array::make_array(data);
653        let int_arr = arr.as_primitive::<Int64Type>();
654        assert_eq!(int_arr.len(), 5);
655        assert_eq!(int_arr.value(0), 10);
656        assert_eq!(int_arr.value(1), 20);
657        assert_eq!(int_arr.value(2), 30);
658        assert_eq!(int_arr.value(3), 40);
659        assert_eq!(int_arr.value(4), 50);
660    }
661
662    #[cfg(feature = "polars-egress")]
663    #[test]
664    fn dataframe_round_trip_int_values_match() {
665        let df = make_df();
666        let rb = one_batch(&df);
667        let back = crate::egress::arrow::polars::record_batch_to_dataframe(rb).unwrap();
668        let series = back.select_at_idx(0).unwrap().as_materialized_series();
669        let i64s = series.i64().unwrap();
670        assert_eq!(i64s.get(0), Some(1));
671        assert_eq!(i64s.get(1), Some(2));
672        assert_eq!(i64s.get(2), Some(3));
673    }
674
675    #[cfg(feature = "polars-egress")]
676    #[test]
677    fn dataframe_round_trip_string_values_match() {
678        let df = make_df();
679        let rb = one_batch(&df);
680        let back = crate::egress::arrow::polars::record_batch_to_dataframe(rb).unwrap();
681        let series = back.select_at_idx(2).unwrap().as_materialized_series();
682        let s = series.str().unwrap();
683        assert_eq!(s.get(0), Some("a"));
684        assert_eq!(s.get(1), Some("b"));
685        assert_eq!(s.get(2), Some("c"));
686    }
687
688    #[test]
689    fn dataframe_to_batches_yields_capped_slices() {
690        let df = make_df();
691        let batches = collect_ok(dataframe_to_batches(&df, Some(TWO)));
692        assert_eq!(batches.len(), 2);
693        assert_eq!(batches[0].num_rows(), 2);
694        assert_eq!(batches[1].num_rows(), 1);
695    }
696
697    #[test]
698    fn dataframe_to_batches_default_max_rows_when_none() {
699        let df = make_df();
700        let batches = collect_ok(dataframe_to_batches(&df, None));
701        assert_eq!(batches.len(), 1);
702        assert_eq!(batches[0].num_rows(), 3);
703    }
704
705    #[test]
706    fn dataframe_to_batches_single_yield_when_under_max() {
707        let df = make_df();
708        let batches = collect_ok(dataframe_to_batches(&df, Some(HUNDRED)));
709        assert_eq!(batches.len(), 1);
710        assert_eq!(batches[0].num_rows(), 3);
711    }
712
713    #[test]
714    fn dataframe_to_batches_chunk_aligned_is_zero_copy() {
715        let mut left = crate::polars_ffi::df_from_columns(vec![
716            Series::new(PlSmallStr::from("i"), &[10i64, 20]).into_column(),
717        ])
718        .unwrap();
719        let right = crate::polars_ffi::df_from_columns(vec![
720            Series::new(PlSmallStr::from("i"), &[30i64, 40]).into_column(),
721        ])
722        .unwrap();
723        left.vstack_mut(&right).unwrap();
724        assert_eq!(left.select_at_idx(0).unwrap().n_chunks(), 2);
725
726        let polars_chunks: Vec<*const i64> = {
727            let s = left.select_at_idx(0).unwrap().as_materialized_series();
728            (0..s.n_chunks())
729                .map(|i| {
730                    let arr = &s.chunks()[i];
731                    let prim: &polars_arrow::array::PrimitiveArray<i64> =
732                        arr.as_any().downcast_ref().unwrap();
733                    prim.values().as_slice().as_ptr()
734                })
735                .collect()
736        };
737
738        let batches = collect_ok(dataframe_to_batches(&left, Some(THOUSAND)));
739        assert_eq!(batches.len(), 2);
740        for (idx, rb) in batches.iter().enumerate() {
741            assert_eq!(rb.num_rows(), 2);
742            let col: &Int64Array = rb.column(0).as_primitive::<Int64Type>();
743            assert_eq!(col.values().as_ptr(), polars_chunks[idx]);
744        }
745    }
746
747    #[test]
748    fn dataframe_to_batches_chunk_aligned_splits_within_chunk() {
749        let mut left = crate::polars_ffi::df_from_columns(vec![
750            Series::new(PlSmallStr::from("i"), &[1i64, 2, 3]).into_column(),
751        ])
752        .unwrap();
753        let right = crate::polars_ffi::df_from_columns(vec![
754            Series::new(PlSmallStr::from("i"), &[4i64, 5, 6]).into_column(),
755        ])
756        .unwrap();
757        left.vstack_mut(&right).unwrap();
758
759        let batches = collect_ok(dataframe_to_batches(&left, Some(TWO)));
760        let lens: Vec<usize> = batches.iter().map(|rb| rb.num_rows()).collect();
761        assert_eq!(lens, vec![2, 1, 2, 1]);
762    }
763
764    #[test]
765    fn dataframe_to_batches_misaligned_chunks_zero_copy() {
766        let a1 = Series::new(PlSmallStr::from("a"), &[1i64, 2]);
767        let a2 = Series::new(PlSmallStr::from("a"), &[3i64, 4]);
768        let b = Series::new(PlSmallStr::from("b"), &[10i64, 20, 30, 40]);
769        let mut left =
770            crate::polars_ffi::df_from_columns(vec![a1.into_column(), b.slice(0, 2).into_column()])
771                .unwrap();
772        let right =
773            crate::polars_ffi::df_from_columns(vec![a2.into_column(), b.slice(2, 2).into_column()])
774                .unwrap();
775        left.vstack_mut(&right).unwrap();
776        left.with_column(b.into_column()).unwrap();
777        assert_ne!(
778            left.select_at_idx(0)
779                .unwrap()
780                .as_materialized_series()
781                .chunk_lengths()
782                .collect::<Vec<_>>(),
783            left.select_at_idx(1)
784                .unwrap()
785                .as_materialized_series()
786                .chunk_lengths()
787                .collect::<Vec<_>>(),
788        );
789
790        let b_chunk_ptr = {
791            let s = left.select_at_idx(1).unwrap().as_materialized_series();
792            let arr = &s.chunks()[0];
793            let prim: &polars_arrow::array::PrimitiveArray<i64> =
794                arr.as_any().downcast_ref().unwrap();
795            prim.values().as_slice().as_ptr()
796        };
797
798        let batches = collect_ok(dataframe_to_batches(&left, Some(THOUSAND)));
799        assert_eq!(batches.len(), 2);
800        let a0: &Int64Array = batches[0].column(0).as_primitive::<Int64Type>();
801        let b0: &Int64Array = batches[0].column(1).as_primitive::<Int64Type>();
802        let a1: &Int64Array = batches[1].column(0).as_primitive::<Int64Type>();
803        let b1: &Int64Array = batches[1].column(1).as_primitive::<Int64Type>();
804        assert_eq!(a0.values().as_ref(), &[1, 2]);
805        assert_eq!(b0.values().as_ref(), &[10, 20]);
806        assert_eq!(a1.values().as_ref(), &[3, 4]);
807        assert_eq!(b1.values().as_ref(), &[30, 40]);
808        assert_eq!(b0.values().as_ptr(), b_chunk_ptr);
809        assert_eq!(b1.values().as_ptr(), unsafe { b_chunk_ptr.add(2) });
810    }
811
812    #[test]
813    fn dataframe_to_batches_scalar_column_materialises_once() {
814        use polars::prelude::Scalar;
815        let values = Series::new(PlSmallStr::from("v"), &[1i64, 2, 3, 4]);
816        let scalar = Column::new_scalar(PlSmallStr::from("k"), Scalar::from(7i64), 4);
817        let df = crate::polars_ffi::df_from_columns(vec![values.into_column(), scalar]).unwrap();
818
819        let batches = collect_ok(dataframe_to_batches(&df, Some(TWO)));
820        assert_eq!(batches.len(), 2);
821        for rb in &batches {
822            assert_eq!(rb.num_rows(), 2);
823            let k: &Int64Array = rb.column(1).as_primitive::<Int64Type>();
824            assert_eq!(k.values().as_ref(), &[7, 7]);
825        }
826
827        let materialised_ptr = {
828            let s = df.select_at_idx(1).unwrap().as_materialized_series();
829            let arr = &s.chunks()[0];
830            let prim: &polars_arrow::array::PrimitiveArray<i64> =
831                arr.as_any().downcast_ref().unwrap();
832            prim.values().as_slice().as_ptr()
833        };
834        let k0: &Int64Array = batches[0].column(1).as_primitive::<Int64Type>();
835        let k1: &Int64Array = batches[1].column(1).as_primitive::<Int64Type>();
836        assert_eq!(k0.values().as_ptr(), materialised_ptr);
837        assert_eq!(k1.values().as_ptr(), unsafe { materialised_ptr.add(2) });
838    }
839
840    #[test]
841    fn polars_categorical_routes_through_dictionary() {
842        use arrow::datatypes::DataType as ArrowDataType;
843        use polars::prelude::{CategoricalPhysical, Categories, DataType as PlDataType};
844
845        // Polars Categorical → arrow Dictionary(UInt32, LargeUtf8). The
846        // downstream SYMBOL routing is covered by
847        // `dict_u32_large_utf8_routes_to_symbol` in
848        // `column_sender::arrow_batch::tests` — here we only verify the
849        // polars→arrow translation produces a Dictionary array.
850        let cats = Categories::new(
851            PlSmallStr::from("syms"),
852            PlSmallStr::from("test"),
853            CategoricalPhysical::U32,
854        );
855        let mapping = cats.mapping();
856        let dtype = PlDataType::Categorical(cats, mapping);
857
858        let strings = Series::new(PlSmallStr::from("c"), &["A", "B", "A", "C"]);
859        let cat_series = strings.cast(&dtype).unwrap();
860        assert!(matches!(cat_series.dtype(), PlDataType::Categorical(_, _)));
861
862        let df = crate::polars_ffi::df_from_columns(vec![cat_series.into_column()]).unwrap();
863        let batches = collect_ok(dataframe_to_batches(&df, None));
864        assert_eq!(batches.len(), 1);
865        let rb = &batches[0];
866
867        assert!(
868            matches!(
869                rb.schema().field(0).data_type(),
870                ArrowDataType::Dictionary(_, _)
871            ),
872            "expected Dictionary column, got {:?}",
873            rb.schema().field(0).data_type()
874        );
875        assert_eq!(rb.num_rows(), 4);
876    }
877}