This is an automated email from the ASF dual-hosted git repository.
alamb 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 2629d03dcb Parquet: tolerate mixed row-group ordinal metadata on read
(#10449)
2629d03dcb is described below
commit 2629d03dcb69a7186b1d14a84407db6f586c1960
Author: Qi Zhu <[email protected]>
AuthorDate: Fri Aug 7 19:03:17 2026 +0800
Parquet: tolerate mixed row-group ordinal metadata on read (#10449)
# Which issue does this PR close?
- Closes #10381.
# Rationale for this change
`RowGroup.ordinal` is **optional** in the parquet-format Thrift spec
with no uniformity requirement, but since #8715 the reader hard-errors
on files whose row groups disagree on whether it is populated
(`Inconsistent ordinal assignment: ...`). Such files are produced in the
wild (e.g. Go parquet writers flushing row groups incrementally) and
were readable before 57.1.
This implements the plan agreed on the issue with @alamb, @etseidl and
@vustef:
- **All ordinals present** → honor them as written.
- **None present** → sequential-fill at decode time, *unconditionally* —
not just when row numbers are enabled — so downstream consumers behave
identically whether the metadata was decoded fresh or reused from a
prior read (the metadata-reuse hazard @vustef pointed out).
- **Mixed** → leave the metadata untouched. Positional backfill could
disagree with the ordinals that are present, and a partial backfill
would make row-number results depend on which row groups a query happens
to select. Instead, consumers that require complete ordinals fail
deterministically; plain reads succeed.
# What changes are included in this PR?
- `thrift/mod.rs`: replace the per-row-group `OrdinalAssigner` (which
errored on the first inconsistency) with a post-decode
`ensure_row_group_ordinals` implementing the three cases above.
- `array_reader/row_number.rs`: `RowNumberReader::try_new` rejects
mixed-ordinal files up front — **even when every selected row group
carries an ordinal** — so row numbering for a given file either always
works or always fails, regardless of row-group pruning (@vustef's
determinism concern).
- `thrift/encryption.rs`: the encrypted column-metadata path used
`rg.ordinal.unwrap()` for the AAD; with mixed metadata now reaching this
code, return a proper error instead of panicking (@etseidl's encryption
concern — files with fully-populated or fully-missing ordinals are
unaffected).
# Are these changes tested?
- Decode round-trip tests for all three shapes (honored /
sequential-filled / left untouched), including the exact Go-writer shape
from the issue (first row group missing the ordinal). The mixed cases
fail on main with the `Inconsistent ordinal assignment` error.
- `RowNumberReader` unit tests: mixed metadata errors even for an
ordinal-only selection; all-missing metadata errors for any selection.
- End-to-end test: a real 4-row-group file with one ordinal stripped
reads fine without virtual columns and fails deterministically with the
row-number column, even when selecting only row groups that carry
ordinals.
- Full `parquet` test suite passes with `--all-features`.
# Are there any user-facing changes?
Files with mixed or absent row-group ordinal metadata are readable again
(as before 57.1). Row-number virtual columns keep their strict
guarantee: they now fail deterministically per file instead of depending
on row-group selection. No API changes.
---
parquet/src/arrow/array_reader/row_number.rs | 80 ++++++++++-
parquet/src/arrow/arrow_reader/mod.rs | 79 +++++++++++
parquet/src/file/metadata/thrift/encryption.rs | 22 ++-
parquet/src/file/metadata/thrift/mod.rs | 177 +++++++++++++++++--------
4 files changed, 298 insertions(+), 60 deletions(-)
diff --git a/parquet/src/arrow/array_reader/row_number.rs
b/parquet/src/arrow/array_reader/row_number.rs
index 3ac55c0b0e..ed5fae8644 100644
--- a/parquet/src/arrow/array_reader/row_number.rs
+++ b/parquet/src/arrow/array_reader/row_number.rs
@@ -47,14 +47,30 @@ impl RowNumberReader {
// This is O(M) where M is the total number of row groups in the file
let mut ordinal_to_offset: HashMap<i32, i64> = HashMap::new();
let mut first_row_index: i64 = 0;
+ let mut missing_ordinals: usize = 0;
for rg in parquet_metadata.row_groups() {
if let Some(ordinal) = rg.ordinal() {
ordinal_to_offset.insert(ordinal, first_row_index);
+ } else {
+ missing_ordinals += 1;
}
first_row_index += rg.num_rows();
}
+ // Mixed ordinals: refuse to compute row numbers for the whole file,
+ // even if every *selected* row group carries an ordinal. Otherwise the
+ // same file would yield row numbers or an error depending on which row
+ // groups a query's pruning happens to select.
+ if missing_ordinals > 0 && !ordinal_to_offset.is_empty() {
+ return Err(ParquetError::General(format!(
+ "Cannot compute row numbers: file has inconsistent row-group \
+ ordinals ({} of {} row groups are missing the ordinal field)",
+ missing_ordinals,
+ parquet_metadata.num_row_groups(),
+ )));
+ }
+
// Pass 2: Build ranges in the order specified by the row_groups
iterator
// This is O(N) where N is the number of selected row groups
// This preserves the user's requested order instead of sorting by
ordinal
@@ -182,6 +198,15 @@ mod tests {
}
fn create_test_parquet_metadata(row_groups: Vec<(i32, i64)>) ->
ParquetMetaData {
+ create_test_parquet_metadata_opt(
+ row_groups
+ .into_iter()
+ .map(|(ordinal, num_rows)| (Some(ordinal), num_rows))
+ .collect(),
+ )
+ }
+
+ fn create_test_parquet_metadata_opt(row_groups: Vec<(Option<i32>, i64)>)
-> ParquetMetaData {
let schema_descr = create_test_schema();
let mut row_group_metas = vec![];
@@ -192,14 +217,14 @@ mod tests {
.map(|col|
ColumnChunkMetaData::builder(col.clone()).build().unwrap())
.collect();
- let row_group = RowGroupMetaData::builder(schema_descr.clone())
+ let mut builder = RowGroupMetaData::builder(schema_descr.clone())
.set_num_rows(num_rows)
- .set_ordinal(ordinal)
.set_total_byte_size(100)
- .set_column_metadata(columns)
- .build()
- .unwrap();
- row_group_metas.push(row_group);
+ .set_column_metadata(columns);
+ if let Some(ordinal) = ordinal {
+ builder = builder.set_ordinal(ordinal);
+ }
+ row_group_metas.push(builder.build().unwrap());
}
let total_rows: i64 = row_group_metas.iter().map(|rg|
rg.num_rows()).sum();
@@ -302,4 +327,47 @@ mod tests {
assert_eq!(reader.read_records(4).unwrap(), 4);
assert_eq!(consume_row_numbers(&mut reader), vec![3, 4, 5, 6]);
}
+
+ /// A file with *mixed* row-group ordinals must never produce row numbers —
+ /// even when every selected row group carries an ordinal. Otherwise the
+ /// same file would succeed or fail depending on which row groups a query's
+ /// pruning selects.
+ #[test]
+ fn test_mixed_ordinals_always_error() {
+ let metadata = create_test_parquet_metadata_opt(vec![
+ (Some(0), 2), // has ordinal
+ (None, 2), // missing
+ (Some(2), 2), // has ordinal
+ ]);
+
+ // Select only row groups WITH ordinals — must still fail.
+ let selected = vec![&metadata.row_groups()[0],
&metadata.row_groups()[2]];
+ let Err(err) = RowNumberReader::try_new(&metadata,
selected.into_iter()) else {
+ panic!("mixed ordinals with ordinal-only selection must fail");
+ };
+ assert!(
+ err.to_string().contains("inconsistent row-group ordinals"),
+ "unexpected error: {err}"
+ );
+
+ // Selecting a row group without an ordinal fails too.
+ let selected = vec![&metadata.row_groups()[1]];
+ assert!(RowNumberReader::try_new(&metadata,
selected.into_iter()).is_err());
+ }
+
+ /// A file where *no* row group carries an ordinal fails for any selection
+ /// (fresh decode sequential-fills, so this only occurs for
+ /// programmatically-built metadata).
+ #[test]
+ fn test_no_ordinals_error() {
+ let metadata = create_test_parquet_metadata_opt(vec![(None, 2), (None,
2)]);
+ let selected = vec![&metadata.row_groups()[0]];
+ let Err(err) = RowNumberReader::try_new(&metadata,
selected.into_iter()) else {
+ panic!("row numbers without any ordinals must fail");
+ };
+ assert!(
+ err.to_string().contains("missing ordinal field"),
+ "unexpected error: {err}"
+ );
+ }
}
diff --git a/parquet/src/arrow/arrow_reader/mod.rs
b/parquet/src/arrow/arrow_reader/mod.rs
index 3d84874746..bbff081b2c 100644
--- a/parquet/src/arrow/arrow_reader/mod.rs
+++ b/parquet/src/arrow/arrow_reader/mod.rs
@@ -5618,6 +5618,85 @@ pub(crate) mod tests {
Ok(())
}
+ /// A file with *mixed* row-group ordinal metadata (spec-valid — the
+ /// `RowGroup.ordinal` thrift field is optional; Go parquet writers emit
+ /// such files) must read fine without row numbers, and must fail
+ /// deterministically with them — even when every *selected* row group
+ /// carries an ordinal. See
<https://github.com/apache/arrow-rs/issues/10381>.
+ #[test]
+ fn test_mixed_row_group_ordinals() -> Result<()> {
+ use crate::file::metadata::{ParquetMetaDataReader, RowGroupMetaData};
+
+ // 100 rows split across 4 row groups of 25
+ let array = Int64Array::from_iter_values(5000..5100);
+ let batch = RecordBatch::try_from_iter([("col", Arc::new(array) as
ArrayRef)])?;
+ let mut buffer = Vec::new();
+ let props = WriterProperties::builder()
+ .set_max_row_group_row_count(Some(25))
+ .build();
+ let mut writer = ArrowWriter::try_new(&mut buffer,
batch.schema().clone(), Some(props))?;
+ for batch_chunk in (0..10).map(|i| batch.slice(i * 10, 10)) {
+ writer.write(&batch_chunk)?;
+ }
+ writer.close()?;
+ let buffer = Bytes::from(buffer);
+
+ // Strip the ordinal from row group 1 to simulate a mixed-ordinal
+ // writer (the builder starts with no ordinal; copy everything else).
+ let metadata = ParquetMetaDataReader::new().parse_and_finish(&buffer)?;
+ let schema_descr = metadata.file_metadata().schema_descr_ptr();
+ let mut row_groups = metadata.row_groups().to_vec();
+ let stripped = row_groups[1].clone();
+ let mut builder = RowGroupMetaData::builder(schema_descr)
+ .set_num_rows(stripped.num_rows())
+ .set_total_byte_size(stripped.total_byte_size())
+ .set_sorting_columns(stripped.sorting_columns().cloned())
+ .set_column_metadata(stripped.columns().to_vec());
+ if let Some(offset) = stripped.file_offset() {
+ builder = builder.set_file_offset(offset);
+ }
+ row_groups[1] = builder.build()?;
+ assert_eq!(row_groups[1].ordinal(), None);
+ let metadata =
Arc::new(metadata.into_builder().set_row_groups(row_groups).build());
+
+ // Plain read (no row numbers): succeeds and returns all values.
+ let arrow_metadata =
+ ArrowReaderMetadata::try_new(Arc::clone(&metadata),
ArrowReaderOptions::new())?;
+ let reader =
+ ParquetRecordBatchReaderBuilder::new_with_metadata(buffer.clone(),
arrow_metadata)
+ .build()?;
+ let values: Vec<i64> = reader
+ .flat_map(|batch| {
+ let batch = batch.expect("could not read batch");
+ batch
+ .column(0)
+ .as_primitive::<types::Int64Type>()
+ .values()
+ .to_vec()
+ })
+ .collect();
+ assert_eq!(values, (5000..5100).collect::<Vec<_>>());
+
+ // Row-number read: fails deterministically, even when selecting only
+ // row groups that DO carry ordinals.
+ let row_number_field = Arc::new(
+ Field::new("row_number", ArrowDataType::Int64,
false).with_extension_type(RowNumber),
+ );
+ let options =
ArrowReaderOptions::new().with_virtual_columns(vec![row_number_field])?;
+ let arrow_metadata =
ArrowReaderMetadata::try_new(Arc::clone(&metadata), options)?;
+ let result =
ParquetRecordBatchReaderBuilder::new_with_metadata(buffer, arrow_metadata)
+ .with_row_groups(vec![0]) // row group 0 has an ordinal
+ .build()
+ .and_then(|mut reader| reader.next().transpose().map_err(|e|
e.into()));
+ let err = result.expect_err("row numbers over mixed ordinals must
fail");
+ assert!(
+ err.to_string().contains("inconsistent row-group ordinals"),
+ "unexpected error: {err}"
+ );
+
+ Ok(())
+ }
+
#[derive(Debug, PartialEq)]
struct ValuesAndRowNumbers {
values: Vec<i64>,
diff --git a/parquet/src/file/metadata/thrift/encryption.rs
b/parquet/src/file/metadata/thrift/encryption.rs
index 37e91ba99c..00258d2981 100644
--- a/parquet/src/file/metadata/thrift/encryption.rs
+++ b/parquet/src/file/metadata/thrift/encryption.rs
@@ -163,10 +163,30 @@ fn row_group_from_encrypted_thrift(
}
};
+ // The ordinal is part of the AAD for encrypted column metadata.
+ // It can be missing here only for files with *mixed* row-group
+ // ordinals, which decode leaves untouched — fail cleanly rather
+ // than panic (see `ensure_row_group_ordinals`).
+ let rg_ordinal = rg.ordinal.ok_or_else(|| {
+ general_err!(
+ "Row group ordinal is required to decrypt column metadata
for \
+ column '{}', but the file's row-group ordinals are
inconsistent",
+ d.path().string()
+ )
+ })?;
+ // Reject a negative ordinal cleanly rather than sign-extending it
into
+ // a bogus AAD via `as usize`.
+ let rg_ordinal = usize::try_from(rg_ordinal).map_err(|_| {
+ general_err!(
+ "Row group ordinal {rg_ordinal} is invalid (must be
non-negative) \
+ for decrypting column metadata for column '{}'",
+ d.path().string()
+ )
+ })?;
let column_aad = crate::encryption::modules::create_module_aad(
decryptor.file_aad(),
crate::encryption::modules::ModuleType::ColumnMetaData,
- rg.ordinal.unwrap() as usize,
+ rg_ordinal,
i,
None,
)?;
diff --git a/parquet/src/file/metadata/thrift/mod.rs
b/parquet/src/file/metadata/thrift/mod.rs
index eb5dc9e689..f8d50e07dc 100644
--- a/parquet/src/file/metadata/thrift/mod.rs
+++ b/parquet/src/file/metadata/thrift/mod.rs
@@ -824,12 +824,10 @@ pub(crate) fn parquet_metadata_from_bytes(
validate_list_type(ElementType::Struct, &list_ident)?;
let mut rg_vec = Vec::with_capacity(list_ident.size as usize);
- // Read row groups and handle ordinal assignment
- let mut assigner = OrdinalAssigner::new();
- for ordinal in 0..list_ident.size {
- let rg = read_row_group(&mut prot, schema_descr, options)?;
- rg_vec.push(assigner.ensure(ordinal, rg)?);
+ for _ in 0..list_ident.size {
+ rg_vec.push(read_row_group(&mut prot, schema_descr,
options)?);
}
+ ensure_row_group_ordinals(&mut rg_vec)?;
row_groups = Some(rg_vec);
}
5 => {
@@ -923,56 +921,36 @@ pub(crate) fn parquet_metadata_from_bytes(
Ok(ParquetMetaData::new(fmd, row_groups))
}
-/// Assign [`RowGroupMetaData::ordinal`] if it is missing.
-#[derive(Debug, Default)]
-pub(crate) struct OrdinalAssigner {
- first_has_ordinal: Option<bool>,
-}
-
-impl OrdinalAssigner {
- fn new() -> Self {
- Default::default()
+/// Ensure [`RowGroupMetaData::ordinal`] is usable after decode without
+/// rejecting spec-valid files (`RowGroup.ordinal` is optional in the
+/// parquet-format Thrift definition, with no uniformity requirement):
+///
+/// - **All row groups carry ordinals** → honor them as written.
+/// - **No row group carries an ordinal** → assign each row group its
+/// position in the file. This happens unconditionally (not only when a
+/// consumer needs it) so downstream users of the ordinal — the row
+/// number virtual column, encryption chunk-key lookup — behave the same
+/// whether the metadata was decoded fresh or reused from a prior read.
+/// - **Mixed** → leave the metadata untouched. Positional backfill could
+/// disagree with the ordinals that are present, and a partial backfill
+/// would make row-number results depend on which row groups a query
+/// happens to select. Consumers that require complete ordinals fail
+/// deterministically instead (see `RowNumberReader::try_new`); plain
+/// reads that never touch ordinals succeed.
+fn ensure_row_group_ordinals(row_groups: &mut [RowGroupMetaData]) ->
Result<()> {
+ // All set (honor them as written) or mixed (leave as-is): either way there
+ // is nothing to backfill. Only when *no* row group carries an ordinal do
we
+ // assign positions below.
+ if row_groups.iter().any(|rg| rg.ordinal.is_some()) {
+ return Ok(());
}
-
- /// Sets [`RowGroupMetaData::ordinal`] if it is missing.
- ///
- /// # Arguments
- /// - actual_ordinal: The ordinal (index) of the row group being processed
- /// in the file metadata.
- /// - rg: The [`RowGroupMetaData`] to potentially modify.
- ///
- /// Ensures:
- /// 1. If the first row group has an ordinal, all subsequent row groups
must
- /// also have ordinals.
- /// 2. If the first row group does NOT have an ordinal, all subsequent row
- /// groups must also not have ordinals.
- fn ensure(
- &mut self,
- actual_ordinal: i32,
- mut rg: RowGroupMetaData,
- ) -> Result<RowGroupMetaData> {
- let rg_has_ordinal = rg.ordinal.is_some();
-
- // Only set first_has_ordinal if it's None (first row group that
arrives)
- if self.first_has_ordinal.is_none() {
- self.first_has_ordinal = Some(rg_has_ordinal);
- }
-
- // assign ordinal if missing and consistent with first row group
- let first_has_ordinal = self.first_has_ordinal.unwrap();
- if !first_has_ordinal && !rg_has_ordinal {
- rg.ordinal = Some(actual_ordinal);
- } else if first_has_ordinal != rg_has_ordinal {
- return Err(general_err!(
- "Inconsistent ordinal assignment: first_has_ordinal is set to \
- {} but row-group with actual ordinal {} has rg_has_ordinal set
to {}",
- first_has_ordinal,
- actual_ordinal,
- rg_has_ordinal
- ));
- }
- Ok(rg)
+ for (idx, rg) in row_groups.iter_mut().enumerate() {
+ let ordinal: i32 = idx
+ .try_into()
+ .map_err(|_| general_err!("Row group ordinal {} exceeds i32 max
value", idx))?;
+ rg.ordinal = Some(ordinal);
}
+ Ok(())
}
thrift_struct!(
@@ -2026,4 +2004,97 @@ pub(crate) mod tests {
.expect_err("malformed bool field should return an error");
assert_malformed_bool_error(err);
}
+
+ /// Round-trip [`crate::file::metadata::ParquetMetaData`] with the given
+ /// per-row-group ordinals through thrift encode → decode, returning the
+ /// decoded ordinals. Exercises `ensure_row_group_ordinals`.
+ fn roundtrip_rg_ordinals(ordinals: &[Option<i32>]) -> Vec<Option<i32>> {
+ use crate::file::metadata::ParquetMetaDataWriter;
+ use crate::file::metadata::{FileMetaData, ParquetMetaData,
ParquetMetaDataReader};
+ use crate::schema::types::Type as SchemaType;
+
+ let field = SchemaType::primitive_type_builder("c",
PhysicalType::INT32)
+ .build()
+ .unwrap();
+ let schema = SchemaType::group_type_builder("schema")
+ .with_fields(vec![Arc::new(field)])
+ .build()
+ .unwrap();
+ let schema_descr = Arc::new(SchemaDescriptor::new(Arc::new(schema)));
+
+ let row_groups = ordinals
+ .iter()
+ .map(|ordinal| {
+ let columns = schema_descr
+ .columns()
+ .iter()
+ .map(|col|
ColumnChunkMetaData::builder(col.clone()).build().unwrap())
+ .collect();
+ let mut builder =
+
crate::file::metadata::RowGroupMetaData::builder(schema_descr.clone())
+ .set_num_rows(10)
+ .set_total_byte_size(100)
+ .set_column_metadata(columns);
+ if let Some(ordinal) = ordinal {
+ builder = builder.set_ordinal(*ordinal);
+ }
+ builder.build().unwrap()
+ })
+ .collect();
+
+ let file_metadata = FileMetaData::new(
+ 1,
+ 10 * ordinals.len() as i64,
+ None,
+ None,
+ schema_descr,
+ None,
+ );
+ let metadata = ParquetMetaData::new(file_metadata, row_groups);
+
+ let mut buffer = Vec::new();
+ ParquetMetaDataWriter::new(&mut buffer, &metadata)
+ .finish()
+ .unwrap();
+ // strip the 8-byte footer tail (length + magic)
+ let decoded =
ParquetMetaDataReader::decode_metadata(&buffer[..buffer.len() - 8]).unwrap();
+ decoded.row_groups().iter().map(|rg| rg.ordinal()).collect()
+ }
+
+ /// All row groups carry ordinals: honored as written, even when they do
+ /// not match file position.
+ #[test]
+ fn ordinals_all_present_are_honored() {
+ assert_eq!(
+ roundtrip_rg_ordinals(&[Some(5), Some(1), Some(3)]),
+ vec![Some(5), Some(1), Some(3)],
+ );
+ }
+
+ /// No row group carries an ordinal: sequential-filled at decode time so
+ /// downstream consumers behave identically on fresh vs reused metadata.
+ #[test]
+ fn ordinals_none_present_are_sequentially_filled() {
+ assert_eq!(
+ roundtrip_rg_ordinals(&[None, None, None]),
+ vec![Some(0), Some(1), Some(2)],
+ );
+ }
+
+ /// Mixed ordinals (spec-valid; produced by e.g. Go parquet writers):
+ /// decode succeeds — the pre-#8715 behavior restored by #10381 — and the
+ /// metadata is left untouched so row numbering fails deterministically
+ /// rather than producing numbers that depend on row-group selection.
+ #[test]
+ fn ordinals_mixed_decode_succeeds_untouched() {
+ assert_eq!(
+ roundtrip_rg_ordinals(&[Some(0), None, Some(2)]),
+ vec![Some(0), None, Some(2)],
+ );
+ // first missing, rest present — the exact Go-writer shape from #10381
+ assert_eq!(
+ roundtrip_rg_ordinals(&[None, Some(1), Some(2)]),
+ vec![None, Some(1), Some(2)],
+ );
+ }
}