This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git


The following commit(s) were added to refs/heads/main by this push:
     new 01b1275aab test(parquet): differential tests of `ParquetPushDecoder` 
against the sync reader (#11485)
01b1275aab is described below

commit 01b1275aab90c033da3f64f5edbfb4f061606cae
Author: Adrian Garcia Badaracco <[email protected]>
AuthorDate: Mon Oct 12 10:45:12 2026 +0000

    test(parquet): differential tests of `ParquetPushDecoder` against the sync 
reader (#11485)
    
    # Which issue does this PR close?
    
    - Part of https://github.com/apache/arrow-rs/issues/11234.
    - Stack of PRs and review order:
    https://github.com/apache/arrow-rs/issues/11234#issuecomment-6097375160
    
    # Rationale for this change
    
    `ParquetPushDecoder` has no test that compares it with the sync reader
    across many scan configurations. This PR adds one. It has value on
    `main`, and https://github.com/apache/arrow-rs/pull/11238 will use the
    same harness to check its new mode. This PR is split out of that PR to
    make it smaller.
    
    # What changes are included in this PR?
    
    Tests only: one new file and one new dev-dependency.
    
    ```text
    Scan { batch_size, projection, row_groups, selection, limit, offset, 
predicates, policy, .. }
      ├── push_decoder()  ──▶ drive_file ──▶ rows ─┐
      └── sync_batches()  ─────────────────▶ rows ─┴─ assert_same_rows
    ```
    
    - Two test files with list and struct columns: one with a new page each
    25 rows or less, one where the page boundaries differ a lot between
    columns.
    - Two property tests with
    [`proptest`](https://crates.io/crates/proptest)
    (`test_fuzz_equivalence`, `test_fuzz_equivalence_heterogeneous_pages`),
    1,000 scans each. The strategies combine batch size, projection, row
    groups, row selection, selection policy, predicates, predicate cache
    size, offset and limit. They include the edge values: batch sizes next
    to the size of a page and of a row group, a limit of 0, and an offset
    after the last row.
    - If a scan fails, `proptest` shrinks it to a smaller scan that fails.
    
    `proptest` is a new dev-dependency of `parquet`. `parquet-variant`
    already uses it. Two choices that differ from the `proptest` defaults:
    
    | Choice | Reason |
    |---|---|
    | The scans are the same in each run (fixed seed) | A failure in CI
    comes from the change under test |
    | No `proptest-regressions` files | The tests are in `src/`, and the
    fixed seed makes a failure reproducible without them |
    
    Each test fails if less than two thirds of its scans return rows, so
    that it does not compare empty results only.
    
    The tests compare rows, not batch boundaries: the sync reader lets a
    batch span two row groups, and the push decoder does not.
    
    # Are these changes tested?
    
    The 4 new tests pass on `main` in about 3 seconds (debug build). To
    check that they can fail, I removed the last row of each batch of
    exactly 7 rows in `drive_file`. Both property tests failed, and
    `proptest` reduced the failing scan to one with a batch size of 26 and
    two predicates.
    
    # Are there any user-facing changes?
    
    No.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 Cargo.lock                                         |   1 +
 parquet/Cargo.toml                                 |   1 +
 parquet/tests/arrow_reader/mod.rs                  |   1 +
 .../tests/arrow_reader/push_decoder_equivalence.rs | 632 +++++++++++++++++++++
 4 files changed, 635 insertions(+)

diff --git a/Cargo.lock b/Cargo.lock
index 4e89351a8d..ac62747684 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2585,6 +2585,7 @@ dependencies = [
  "parquet-geospatial",
  "parquet-variant",
  "parquet-variant-compute",
+ "proptest",
  "rand 0.10.3",
  "ring",
  "seq-macro",
diff --git a/parquet/Cargo.toml b/parquet/Cargo.toml
index ec1c869c95..e34565c260 100644
--- a/parquet/Cargo.toml
+++ b/parquet/Cargo.toml
@@ -80,6 +80,7 @@ criterion = { workspace = true, default-features = false, 
features = ["async_fut
 snap = { version = "1.0", default-features = false }
 tempfile = { version = "3.0", default-features = false }
 insta = { workspace = true, default-features = true }
+proptest = { version = "1.0", default-features = false, features = ["std"] }
 brotli = { version = "9.0", default-features = false, features = ["std"] }
 flate2 = { version = "1.0", default-features = false, features = 
["rust_backend"] }
 lz4_flex = { version = "0.14", default-features = false, features = ["std", 
"frame"] }
diff --git a/parquet/tests/arrow_reader/mod.rs 
b/parquet/tests/arrow_reader/mod.rs
index c1b63dbe8f..d4a4460c4a 100644
--- a/parquet/tests/arrow_reader/mod.rs
+++ b/parquet/tests/arrow_reader/mod.rs
@@ -54,6 +54,7 @@ mod parquet_testing;
 mod partial_offset_index;
 #[cfg(feature = "async")]
 mod predicate_cache;
+mod push_decoder_equivalence;
 mod row_filter;
 mod statistics;
 
diff --git a/parquet/tests/arrow_reader/push_decoder_equivalence.rs 
b/parquet/tests/arrow_reader/push_decoder_equivalence.rs
new file mode 100644
index 0000000000..f2e51540f5
--- /dev/null
+++ b/parquet/tests/arrow_reader/push_decoder_equivalence.rs
@@ -0,0 +1,632 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Differential tests for [`ParquetPushDecoder`].
+//!
+//! Each test describes a scan as a [`Scan`], and [`assert_same_rows`] checks
+//! that the push decoder returns the same rows, in the same order, as
+//! [`ParquetRecordBatchReader`] for the same scan. The `test_fuzz_*` tests
+//! use [`proptest`] to combine the scan options at random and to shrink a
+//! scan that fails.
+//!
+//! [`ParquetRecordBatchReader`]: 
parquet::arrow::arrow_reader::ParquetRecordBatchReader
+
+use arrow_array::builder::{Int64Builder, ListBuilder};
+use arrow_array::cast::AsArray;
+use arrow_array::types::Int64Type;
+use arrow_array::{
+    Array, ArrayRef, BooleanArray, Int64Array, RecordBatch, RecordBatchReader, 
StringArray,
+    StructArray,
+};
+use arrow_schema::{DataType, Field, SchemaRef};
+use arrow_select::concat::concat_batches;
+use bytes::Bytes;
+use parquet::DecodeResult;
+use parquet::arrow::arrow_reader::{
+    ArrowPredicate, ArrowPredicateFn, ArrowReaderMetadata, ArrowReaderOptions,
+    ParquetRecordBatchReaderBuilder, RowFilter, RowSelection, 
RowSelectionPolicy, RowSelector,
+};
+use parquet::arrow::push_decoder::{ParquetPushDecoder, 
ParquetPushDecoderBuilder};
+use parquet::arrow::{ArrowWriter, ProjectionMask};
+use parquet::file::metadata::PageIndexPolicy;
+use parquet::file::properties::WriterProperties;
+use parquet::schema::types::SchemaDescriptor;
+use proptest::collection::vec;
+use proptest::option::weighted;
+use proptest::prelude::*;
+use proptest::sample::select;
+use proptest::test_runner::{Config, TestRng, TestRunner};
+use std::cell::Cell;
+use std::ops::Range;
+use std::sync::{Arc, LazyLock};
+
+const ROWS_PER_ROW_GROUP: usize = 600;
+const ROWS_PER_PAGE: usize = 25;
+const NUM_ROWS: usize = 1800;
+
+/// Three row groups of 600 rows, with a new data page each 25 rows or less,
+/// columns:
+///
+/// * `a`: 0, 1, 2, ... (plain)
+/// * `b`: `a % 10` (dictionary)
+/// * `c`: a string (plain)
+/// * `l`: a list of 0 to 3 `a` values, some lists null (plain)
+/// * `s`: a struct of `a * 2` and `a % 7` (plain)
+struct TestFile {
+    data: Bytes,
+    batch: RecordBatch,
+}
+
+static TEST_FILE: LazyLock<TestFile> = LazyLock::new(|| {
+    let a: Vec<i64> = (0..NUM_ROWS as i64).collect();
+    let mut lists = ListBuilder::new(Int64Builder::new());
+    for &v in &a {
+        if v % 11 == 5 {
+            lists.append_null();
+        } else {
+            for i in 0..(v % 4) {
+                lists.values().append_value(v * 10 + i);
+            }
+            lists.append(true);
+        }
+    }
+    let s = StructArray::from(vec![
+        (
+            Arc::new(Field::new("x", DataType::Int64, false)),
+            Arc::new(Int64Array::from_iter_values(a.iter().map(|v| v * 2))) as 
ArrayRef,
+        ),
+        (
+            Arc::new(Field::new("y", DataType::Int64, false)),
+            Arc::new(Int64Array::from_iter_values(a.iter().map(|v| v % 7))) as 
ArrayRef,
+        ),
+    ]);
+    let batch = RecordBatch::try_from_iter(vec![
+        ("a", Arc::new(Int64Array::from(a.clone())) as ArrayRef),
+        (
+            "b",
+            Arc::new(Int64Array::from_iter_values(a.iter().map(|v| v % 10))) 
as ArrayRef,
+        ),
+        (
+            "c",
+            Arc::new(StringArray::from_iter_values(
+                a.iter().map(|v| format!("row-{v:08}-padding")),
+            )) as ArrayRef,
+        ),
+        ("l", Arc::new(lists.finish()) as ArrayRef),
+        ("s", Arc::new(s) as ArrayRef),
+    ])
+    .unwrap();
+
+    let props = WriterProperties::builder()
+        .set_max_row_group_row_count(Some(ROWS_PER_ROW_GROUP))
+        .set_data_page_row_count_limit(ROWS_PER_PAGE)
+        .set_write_batch_size(ROWS_PER_PAGE)
+        .set_dictionary_enabled(false)
+        .set_column_dictionary_enabled("b".into(), true)
+        .build();
+    let mut buffer = vec![];
+    let mut writer = ArrowWriter::try_new(&mut buffer, batch.schema(), 
Some(props)).unwrap();
+    writer.write(&batch).unwrap();
+    writer.close().unwrap();
+    TestFile {
+        data: Bytes::from(buffer),
+        batch,
+    }
+});
+
+/// Row groups of [`HETEROGENEOUS_FILE`].
+const HETEROGENEOUS_ROW_GROUP_ROWS: [usize; 3] = [700, 700, 400];
+
+/// The rows of [`TEST_FILE`] in row groups of 700, 700 and 400 rows. A page
+/// ends after 40 rows or at about 200 bytes (checked every 5 rows), so each
+/// column has its own page boundaries.
+static HETEROGENEOUS_FILE: LazyLock<Bytes> = LazyLock::new(|| {
+    let props = WriterProperties::builder()
+        .set_max_row_group_row_count(Some(HETEROGENEOUS_ROW_GROUP_ROWS[0]))
+        .set_data_page_row_count_limit(40)
+        .set_data_page_size_limit(200)
+        .set_write_batch_size(5)
+        .set_dictionary_enabled(false)
+        .set_column_dictionary_enabled("b".into(), true)
+        .build();
+    let mut buffer = vec![];
+    let mut writer =
+        ArrowWriter::try_new(&mut buffer, TEST_FILE.batch.schema(), 
Some(props)).unwrap();
+    writer.write(&TEST_FILE.batch).unwrap();
+    writer.close().unwrap();
+    Bytes::from(buffer)
+});
+
+fn load_metadata(data: &Bytes, policy: PageIndexPolicy) -> ArrowReaderMetadata 
{
+    let options = ArrowReaderOptions::new().with_page_index_policy(policy);
+    ArrowReaderMetadata::load(data, options).unwrap()
+}
+
+static WITH_PAGE_INDEX: LazyLock<ArrowReaderMetadata> =
+    LazyLock::new(|| load_metadata(&TEST_FILE.data, 
PageIndexPolicy::Required));
+static WITHOUT_PAGE_INDEX: LazyLock<ArrowReaderMetadata> =
+    LazyLock::new(|| load_metadata(&TEST_FILE.data, PageIndexPolicy::Skip));
+static HETEROGENEOUS_WITH_PAGE_INDEX: LazyLock<ArrowReaderMetadata> =
+    LazyLock::new(|| load_metadata(&HETEROGENEOUS_FILE, 
PageIndexPolicy::Required));
+static HETEROGENEOUS_WITHOUT_PAGE_INDEX: LazyLock<ArrowReaderMetadata> =
+    LazyLock::new(|| load_metadata(&HETEROGENEOUS_FILE, 
PageIndexPolicy::Skip));
+
+fn metadata(page_index: bool) -> ArrowReaderMetadata {
+    if page_index {
+        WITH_PAGE_INDEX.clone()
+    } else {
+        WITHOUT_PAGE_INDEX.clone()
+    }
+}
+
+fn schema() -> SchemaDescriptor {
+    metadata(true)
+        .metadata()
+        .file_metadata()
+        .schema_descr()
+        .clone()
+}
+
+fn columns(names: &[&str]) -> ProjectionMask {
+    ProjectionMask::columns(&schema(), names.iter().copied())
+}
+
+/// Decode `file`, pushing exactly the requested ranges. Returns the batches
+/// and every range that the decoder requested, in order.
+fn drive_file(
+    mut decoder: ParquetPushDecoder,
+    file: &Bytes,
+) -> (Vec<RecordBatch>, Vec<Range<u64>>) {
+    let mut batches = vec![];
+    let mut requested = vec![];
+    let mut buffered_after_last_batch = 0;
+    loop {
+        match decoder.try_decode().unwrap() {
+            DecodeResult::NeedsData(ranges) => {
+                assert!(!ranges.is_empty());
+                let data = ranges
+                    .iter()
+                    .map(|r| file.slice(r.start as usize..r.end as usize))
+                    .collect();
+                requested.extend_from_slice(&ranges);
+                decoder.push_ranges(ranges, data).unwrap();
+            }
+            DecodeResult::Data(batch) => {
+                batches.push(batch);
+                buffered_after_last_batch = decoder.buffered_bytes();
+            }
+            DecodeResult::Finished => break,
+        }
+    }
+    // The last batch finishes the last row group, which releases its bytes.
+    assert_eq!(
+        buffered_after_last_batch, 0,
+        "bytes left after the last batch"
+    );
+    (batches, requested)
+}
+
+/// A predicate on one column: an Int64 column unless noted.
+#[derive(Clone, Copy, Debug)]
+enum Cmp {
+    Lt(i64),
+    Ge(i64),
+    ModNotZero(i64),
+    All,
+    None,
+    /// The list column `l` is not null.
+    ListNotNull,
+    /// Field `x` of the struct column `s` is less than the value.
+    StructXLt(i64),
+    /// Null where the value is a multiple of the argument, else true.
+    NullEvery(i64),
+}
+
+#[derive(Clone, Debug)]
+struct PredicateSpec {
+    column: &'static str,
+    cmp: Cmp,
+}
+
+impl PredicateSpec {
+    fn new(column: &'static str, cmp: Cmp) -> Self {
+        Self { column, cmp }
+    }
+
+    fn build(&self) -> Box<dyn ArrowPredicate> {
+        let cmp = self.cmp;
+        Box::new(ArrowPredicateFn::new(
+            columns(&[self.column]),
+            move |batch: RecordBatch| {
+                let column = batch.column(0);
+                Ok(match cmp {
+                    Cmp::ListNotNull => {
+                        let list = column.as_list::<i32>();
+                        (0..list.len()).map(|i| 
Some(!list.is_null(i))).collect()
+                    }
+                    Cmp::StructXLt(x) => column
+                        .as_struct()
+                        .column(0)
+                        .as_primitive::<Int64Type>()
+                        .iter()
+                        .map(|v| Some(v.unwrap() < x))
+                        .collect(),
+                    Cmp::NullEvery(m) => column
+                        .as_primitive::<Int64Type>()
+                        .iter()
+                        .map(|v| (v.unwrap() % m != 0).then_some(true))
+                        .collect(),
+                    _ => column
+                        .as_primitive::<Int64Type>()
+                        .iter()
+                        .map(|v| {
+                            let v = v.unwrap();
+                            Some(match cmp {
+                                Cmp::Lt(x) => v < x,
+                                Cmp::Ge(x) => v >= x,
+                                Cmp::ModNotZero(x) => v % x != 0,
+                                Cmp::All => true,
+                                Cmp::None => false,
+                                _ => unreachable!(),
+                            })
+                        })
+                        .collect::<BooleanArray>(),
+                })
+            },
+        ))
+    }
+}
+
+/// Apply the options of a [`Scan`] to an `ArrowReaderBuilder`. A macro,
+/// because the builders of the two readers have different types.
+macro_rules! apply_options {
+    ($scan:expr, $builder:expr) => {{
+        let scan: &Scan = $scan;
+        let mut builder = $builder;
+        if let Some(batch_size) = scan.batch_size {
+            builder = builder.with_batch_size(batch_size);
+        }
+        if let Some(projection) = &scan.projection {
+            builder = builder.with_projection(projection.clone());
+        }
+        if let Some(row_groups) = &scan.row_groups {
+            builder = builder.with_row_groups(row_groups.clone());
+        }
+        if let Some(selection) = &scan.selection {
+            builder = builder.with_row_selection(selection.clone());
+        }
+        if let Some(limit) = scan.limit {
+            builder = builder.with_limit(limit);
+        }
+        if let Some(offset) = scan.offset {
+            builder = builder.with_offset(offset);
+        }
+        if let Some(policy) = scan.policy {
+            builder = builder.with_row_selection_policy(policy);
+        }
+        if let Some(size) = scan.max_predicate_cache_size {
+            builder = builder.with_max_predicate_cache_size(size);
+        }
+        if !scan.predicates.is_empty() {
+            let predicates = scan.predicates.iter().map(|p| 
p.build()).collect();
+            builder = builder.with_row_filter(RowFilter::new(predicates));
+        }
+        builder
+    }};
+}
+
+/// A scan configuration, so that the same scan can be built for more than
+/// one reader.
+#[derive(Clone, Debug, Default)]
+struct Scan {
+    /// Read [`HETEROGENEOUS_FILE`] instead of [`TEST_FILE`].
+    heterogeneous: bool,
+    page_index_off: bool,
+    batch_size: Option<usize>,
+    projection: Option<ProjectionMask>,
+    row_groups: Option<Vec<usize>>,
+    selection: Option<RowSelection>,
+    limit: Option<usize>,
+    offset: Option<usize>,
+    /// Built again for each reader, as predicates have state.
+    predicates: Vec<PredicateSpec>,
+    policy: Option<RowSelectionPolicy>,
+    max_predicate_cache_size: Option<usize>,
+}
+
+impl Scan {
+    fn metadata(&self) -> ArrowReaderMetadata {
+        match (self.heterogeneous, self.page_index_off) {
+            (false, _) => metadata(!self.page_index_off),
+            (true, false) => HETEROGENEOUS_WITH_PAGE_INDEX.clone(),
+            (true, true) => HETEROGENEOUS_WITHOUT_PAGE_INDEX.clone(),
+        }
+    }
+
+    fn data(&self) -> &'static Bytes {
+        if self.heterogeneous {
+            &HETEROGENEOUS_FILE
+        } else {
+            &TEST_FILE.data
+        }
+    }
+
+    fn push_decoder(&self) -> ParquetPushDecoder {
+        let builder = 
ParquetPushDecoderBuilder::new_with_metadata(self.metadata());
+        apply_options!(self, builder).build().unwrap()
+    }
+
+    /// The batches of the scan from the sync reader.
+    fn sync_batches(&self) -> (SchemaRef, Vec<RecordBatch>) {
+        let builder = ParquetRecordBatchReaderBuilder::new_with_metadata(
+            self.data().clone(),
+            self.metadata(),
+        );
+        let reader = apply_options!(self, builder).build().unwrap();
+        let schema = reader.schema();
+        (schema, reader.map(|batch| batch.unwrap()).collect())
+    }
+}
+
+/// What the push decoder did for a scan.
+struct Decoded {
+    /// Rows returned.
+    rows: usize,
+    /// Every range requested, in order.
+    requested: Vec<Range<u64>>,
+}
+
+/// Assert that the push decoder gives the same rows as the sync reader for
+/// `scan`, in the same order.
+///
+/// The batch boundaries can differ: the push decoder ends a batch at the end
+/// of each row group.
+#[track_caller]
+fn assert_same_rows(scan: &Scan) -> Decoded {
+    let (schema, expected) = scan.sync_batches();
+    let (actual, requested) = drive_file(scan.push_decoder(), scan.data());
+    if let Some(batch_size) = scan.batch_size {
+        assert!(
+            actual.iter().all(|b| b.num_rows() <= batch_size),
+            "a batch has more than {batch_size} rows for {scan:?}"
+        );
+    }
+    let expected = concat_batches(&schema, &expected).unwrap();
+    let actual = concat_batches(&schema, &actual).unwrap();
+    // Do not print the rows: proptest runs a failing scan many times to
+    // shrink it.
+    assert!(
+        actual == expected,
+        "rows differ for {scan:?}: {} rows, expected {}",
+        actual.num_rows(),
+        expected.num_rows()
+    );
+    Decoded {
+        rows: actual.num_rows(),
+        requested,
+    }
+}
+
+#[test]
+#[cfg_attr(miri, ignore)] // Takes too long
+fn test_empty_selection_requests_nothing() {
+    let decoded = assert_same_rows(&Scan {
+        batch_size: Some(64),
+        selection: Some(RowSelection::from(vec![RowSelector::skip(NUM_ROWS)])),
+        ..Default::default()
+    });
+    assert_eq!(decoded.rows, 0);
+    assert!(decoded.requested.is_empty());
+}
+
+/// A selection of `total` rows: runs of skipped and selected rows in turn.
+/// It shrinks to a selection with fewer and shorter runs.
+fn selection_strategy(total: usize) -> impl Strategy<Value = RowSelection> {
+    let runs = select(vec![3usize, 40, 400]).prop_flat_map(|max_run| 
vec(1..=max_run, 1..200));
+    (any::<bool>(), runs).prop_map(move |(mut skip, runs)| {
+        let mut selectors = vec![];
+        let mut rows_left = total;
+        // The last run takes the rows that are left.
+        let last = std::iter::once(total);
+        for run in runs.into_iter().chain(last) {
+            let run = run.min(rows_left);
+            if run == 0 {
+                break;
+            }
+            selectors.push(match skip {
+                true => RowSelector::skip(run),
+                false => RowSelector::select(run),
+            });
+            rows_left -= run;
+            skip = !skip;
+        }
+        RowSelection::from(selectors)
+    })
+}
+
+/// No batch size (the default), a batch size next to the size of a page or of
+/// a row group, or any batch size.
+fn batch_size_strategy() -> impl Strategy<Value = Option<usize>> {
+    let boundaries = [1, ROWS_PER_PAGE, ROWS_PER_ROW_GROUP, 700, NUM_ROWS]
+        .into_iter()
+        .flat_map(|size| [size.saturating_sub(1).max(1), size, size + 1])
+        .collect::<Vec<_>>();
+    prop_oneof![
+        1 => Just(None),
+        4 => select(boundaries).prop_map(Some),
+        4 => (1..2 * NUM_ROWS).prop_map(Some),
+    ]
+}
+
+fn predicate_strategy() -> impl Strategy<Value = PredicateSpec> {
+    let value = 0..NUM_ROWS as i64;
+    prop_oneof![
+        value
+            .clone()
+            .prop_map(|v| PredicateSpec::new("a", Cmp::Lt(v))),
+        value
+            .clone()
+            .prop_map(|v| PredicateSpec::new("a", Cmp::Ge(v))),
+        (2..7i64).prop_map(|m| PredicateSpec::new("b", Cmp::ModNotZero(m))),
+        (2..40i64).prop_map(|m| PredicateSpec::new("a", Cmp::ModNotZero(m))),
+        Just(PredicateSpec::new("b", Cmp::All)),
+        Just(PredicateSpec::new("l", Cmp::ListNotNull)),
+        value.prop_map(|v| PredicateSpec::new("s", Cmp::StructXLt(2 * v))),
+        (2..9i64).prop_map(|m| PredicateSpec::new("a", Cmp::NullEvery(m))),
+        Just(PredicateSpec::new("b", Cmp::None)),
+    ]
+}
+
+/// A scan with batch size, projection, row groups, row selection, selection
+/// policy, predicates, predicate cache size, offset and limit at random.
+fn scan_strategy(heterogeneous: bool) -> impl Strategy<Value = Scan> {
+    let row_group_rows = match heterogeneous {
+        true => HETEROGENEOUS_ROW_GROUP_ROWS,
+        false => [ROWS_PER_ROW_GROUP; 3],
+    };
+    // Some of the row groups, in any order.
+    let row_groups = (Just(vec![0, 1, 2]).prop_shuffle(), 1..=3usize)
+        .prop_map(|(row_groups, len)| row_groups[..len].to_vec());
+    weighted(0.3, row_groups)
+        .prop_flat_map(move |row_groups| {
+            // A selection must cover exactly the row groups of the scan.
+            let total: usize = match &row_groups {
+                Some(row_groups) => row_groups.iter().map(|&i| 
row_group_rows[i]).sum(),
+                None => NUM_ROWS,
+            };
+            (
+                Just(row_groups),
+                proptest::bool::weighted(0.1),
+                batch_size_strategy(),
+                select(vec![
+                    None,
+                    Some(vec!["a"]),
+                    Some(vec!["c"]),
+                    Some(vec!["a", "c"]),
+                    Some(vec!["b", "c"]),
+                    Some(vec!["l"]),
+                    Some(vec!["s", "b"]),
+                    Some(vec!["l", "a", "s"]),
+                ]),
+                weighted(0.5, selection_strategy(total)),
+                // Mostly a limit and an offset that leave rows. Sometimes a
+                // limit of 0, or an offset after the last row.
+                weighted(0.3, prop_oneof![9 => 1..=total, 1 => Just(0)]),
+                weighted(0.3, prop_oneof![9 => 0..total / 2, 1 => total..total 
+ 300]),
+                vec(predicate_strategy(), 0..4),
+                select(vec![
+                    None,
+                    Some(RowSelectionPolicy::Selectors),
+                    Some(RowSelectionPolicy::Mask),
+                ]),
+                select(vec![None, Some(0), Some(1)]),
+            )
+        })
+        .prop_map(
+            move |(
+                row_groups,
+                page_index_off,
+                batch_size,
+                projection,
+                selection,
+                limit,
+                offset,
+                predicates,
+                policy,
+                max_predicate_cache_size,
+            )| Scan {
+                heterogeneous,
+                page_index_off,
+                batch_size,
+                projection: projection.map(|names| columns(&names)),
+                row_groups,
+                selection,
+                limit,
+                offset,
+                predicates,
+                policy,
+                max_predicate_cache_size,
+            },
+        )
+}
+
+/// Scans of each fuzz test, so that it runs in a few seconds in a debug
+/// build.
+const FUZZ_CASES: u32 = 1000;
+
+/// Check [`FUZZ_CASES`] random scans. If a scan fails, proptest shrinks it to
+/// a smaller scan that fails. Most of the scans must return rows, so that the
+/// test does not compare empty results only.
+///
+/// The scans are the same in each run, so that a failure in CI comes from
+/// the change under test.
+fn fuzz(heterogeneous: bool) {
+    let config = Config {
+        cases: FUZZ_CASES,
+        failure_persistence: None,
+        ..Config::default()
+    };
+    let rng = TestRng::deterministic_rng(config.rng_algorithm);
+    let mut runner = TestRunner::new_with_rng(config, rng);
+    let scans_with_rows = Cell::new(0);
+    runner
+        .run(&scan_strategy(heterogeneous), |scan| {
+            if assert_same_rows(&scan).rows > 0 {
+                scans_with_rows.set(scans_with_rows.get() + 1);
+            }
+            Ok(())
+        })
+        .unwrap();
+    assert!(
+        scans_with_rows.get() * 3 >= FUZZ_CASES * 2,
+        "only {} of {FUZZ_CASES} scans returned rows",
+        scans_with_rows.get()
+    );
+}
+
+#[test]
+#[cfg_attr(miri, ignore)] // Takes too long
+fn test_fuzz_equivalence() {
+    fuzz(false);
+}
+
+#[test]
+#[cfg_attr(miri, ignore)] // Takes too long
+fn test_fuzz_equivalence_heterogeneous_pages() {
+    fuzz(true);
+}
+
+#[test]
+#[cfg_attr(miri, ignore)] // Takes too long
+fn test_page_boundaries_differ_per_column() {
+    let metadata = HETEROGENEOUS_WITH_PAGE_INDEX.metadata();
+    let row_groups: Vec<i64> = metadata
+        .row_groups()
+        .iter()
+        .map(|rg| rg.num_rows())
+        .collect();
+    assert_eq!(row_groups, [700, 700, 400]);
+    // Pages per column in row group 0: a, b, c, l, s.x, s.y
+    let page_index = metadata.page_index_for_row_group(0);
+    let pages: Vec<usize> = (0..6)
+        .map(|column| page_index.page_locations(column).unwrap().len())
+        .collect();
+    assert!(
+        pages[0] != pages[2] && pages[1] != pages[2] && pages[0] != pages[3],
+        "{pages:?}"
+    );
+}

Reply via email to