Repository navigation
Description
Apache Iceberg Rust version
main (deab069)
Describe the bug
The spec's column projection rules say how a reader resolves a field ID that isn't in a data file. It returns the partition value if the field has an identity transform in the file's partition spec, which is what lets a Hive table migrate to Iceberg without rewriting its files. Otherwise it returns the field's initial-default, and null if there is none. An initial-default is the value that every row written before a column was added must read as.
ArrowReader gets projection right. RecordBatchTransformer uses the identity partition value for a partition column that is absent from the file and fills in initial-default for other absent columns. Filtering gets it wrong. When a scan predicate references a column that is absent from the file, two places treat the column as all null and ignore both values:
- The Arrow row filter built in
predicate_visitor.rs. Every "A missing column, treating it as null" branch, for exampleis_nullandeq, returns a constant that assumes null.PredicateConverterreceives only the map from field ID to Parquet column, so it has neither the partition value nor the schema's defaults. This runs on every read with a predicate. PageIndexEvaluator, which prunes rows using the Parquet page index (per-page min/max statistics) when row selection is enabled. For a column missing from the file,calc_row_selectionexits early and skips every row fornot_null,eq, andin.get_row_selection_for_filter_predicatetakes no partition data, so it can't use the partition value either. Row selection is off by default.
The result is a silent wrong answer. With a column b added with initial-default 7, an unfiltered scan returns b = 7 on every old row, but WHERE b = 7 returns none of them and WHERE b IS NULL returns all of them. Any data file written before the column was added is affected, including files that are still live after later appends. The same happens to a file that doesn't store its identity partition column p and has partition value 7, as after a Hive migration or add_files. Engines that push down filters, such as DataFusion, hit this on ordinary queries.
The other pruning layers don't drop these rows, as far as I can tell from reading them. RowGroupMetricsEvaluator, InclusiveMetricsEvaluator, and BloomFilterEvaluator all treat a column with no statistics as "might match".
Iceberg Java had the initial-default bug in its Parquet row-group filter and fixed it in apache/iceberg#16692 (issue apache/iceberg#16690). The fix in ParquetMetricsRowGroupFilter.predicate evaluates the predicate against initialDefault when the column is missing from the file. For a nested field it also allows null, because a nested field reads as null whenever an ancestor struct is null. Java's row-level filtering was already correct, because its Parquet readers materialize the default before the engine applies the residual filter.
Java avoids the partition case through that residual, the part of the scan predicate left to check for each file after its partition value is known. ResidualEvaluator.predicate replaces a predicate on an identity partition column with alwaysTrue or alwaysFalse using the file's partition value, so the column is never looked up in the file. iceberg-rust has no residual evaluation. FileScanTask carries the full scan predicate to the reader.
This is a gap for the default-value audit in #3250, which doesn't cover filtering yet.
To Reproduce
The three tests below fail on main. They go in the tests module of crates/iceberg/src/arrow/reader/row_filter.rs and reuse its field_with_id, write_row_groups, and read_once helpers. They need these imports added to that module:
use arrow_array::types::Int64Type;
use parquet::arrow::arrow_reader::{ArrowReaderMetadata, ArrowReaderOptions};
use parquet::file::metadata::PageIndexPolicy;
use crate::spec::{Literal, PartitionSpec, Struct, Transform};/// A file written before column `b` was added, read with a schema in which `b`
/// has `initial-default` 7. Every row reads back as `b = 7`, so each predicate
/// must keep or drop all three rows as if `b` were stored as 7.
fn setup_absent_column_with_initial_default() -> (Arc<Schema>, String, TempDir) {
let schema = Arc::new(
Schema::builder()
.with_schema_id(1)
.with_fields(vec![
NestedField::required(1, "a", Type::Primitive(PrimitiveType::Long)).into(),
NestedField::optional(2, "b", Type::Primitive(PrimitiveType::Long))
.with_initial_default(Literal::long(7))
.into(),
])
.build()
.unwrap(),
);
let tmp_dir = TempDir::new().unwrap();
let file_path = format!("{}/1.parquet", tmp_dir.path().to_str().unwrap());
let arrow_schema = Arc::new(ArrowSchema::new(vec![field_with_id(
"a",
DataType::Int64,
1,
)]));
let batch = RecordBatch::try_new(arrow_schema.clone(), vec![Arc::new(Int64Array::from(
vec![1, 2, 3],
))])
.unwrap();
write_row_groups(&file_path, arrow_schema, vec![batch], false);
(schema, file_path, tmp_dir)
}
#[tokio::test]
async fn test_predicate_on_absent_column_uses_initial_default() {
let (schema, file_path, _tmp_dir) = setup_absent_column_with_initial_default();
// Projection applies the default.
let (batches, _) = read_once(
&file_path,
schema.clone(),
vec![1, 2],
Predicate::AlwaysTrue,
false,
)
.await;
assert_eq!(
batches[0].column(1).as_primitive::<Int64Type>().values(),
&[7, 7, 7]
);
let cases = [
("b = 7", Reference::new("b").equal_to(Datum::long(7)), 3),
("b IS NOT NULL", Reference::new("b").is_not_null(), 3),
(
"b IN (7, 8)",
Reference::new("b").is_in([Datum::long(7), Datum::long(8)]),
3,
),
("b > 5", Reference::new("b").greater_than(Datum::long(5)), 3),
("b = 8", Reference::new("b").equal_to(Datum::long(8)), 0),
("b IS NULL", Reference::new("b").is_null(), 0),
];
let mut actual = Vec::new();
for (name, predicate, _) in &cases {
let (batches, _) = read_once(
&file_path,
schema.clone(),
vec![1, 2],
predicate.clone(),
false,
)
.await;
actual.push((
*name,
batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
));
}
let expected: Vec<_> = cases.iter().map(|(name, _, rows)| (*name, *rows)).collect();
assert_eq!(actual, expected);
}
#[tokio::test]
async fn test_page_index_on_absent_column_uses_initial_default() {
let (schema, file_path, _tmp_dir) = setup_absent_column_with_initial_default();
let file = File::open(&file_path).unwrap();
let metadata = ArrowReaderMetadata::load(
&file,
ArrowReaderOptions::new().with_page_index_policy(PageIndexPolicy::Required),
)
.unwrap()
.metadata()
.clone();
let field_id_map = HashMap::from([(1, 0)]);
let cases = [
("b = 7", Reference::new("b").equal_to(Datum::long(7))),
("b IS NOT NULL", Reference::new("b").is_not_null()),
(
"b IN (7, 8)",
Reference::new("b").is_in([Datum::long(7), Datum::long(8)]),
),
];
let mut actual = Vec::new();
for (name, predicate) in &cases {
let selection = ArrowReader::get_row_selection_for_filter_predicate(
&predicate.clone().bind(schema.clone(), true).unwrap(),
&metadata,
&None,
&field_id_map,
&schema,
)
.unwrap()
.expect("the file has a page index");
actual.push((*name, selection.row_count()));
}
let expected: Vec<_> = cases.iter().map(|(name, _)| (*name, 3)).collect();
assert_eq!(actual, expected);
}
/// A file that doesn't store its identity partition column `p`, as after a Hive
/// migration or `add_files`. Projection reads `p` from the partition value 7, so each
/// predicate must keep or drop all three rows as if `p` were stored as 7.
#[tokio::test]
async fn test_predicate_on_absent_identity_partition_column_uses_partition_value() {
let schema = Arc::new(
Schema::builder()
.with_schema_id(1)
.with_fields(vec![
NestedField::required(1, "a", Type::Primitive(PrimitiveType::Long)).into(),
NestedField::optional(2, "p", Type::Primitive(PrimitiveType::Long)).into(),
])
.build()
.unwrap(),
);
let partition_spec = Arc::new(
PartitionSpec::builder(schema.clone())
.add_partition_field("p", "p", Transform::Identity)
.unwrap()
.build()
.unwrap(),
);
let partition = Struct::from_iter([Some(Literal::long(7))]);
let tmp_dir = TempDir::new().unwrap();
let file_path = format!("{}/1.parquet", tmp_dir.path().to_str().unwrap());
let arrow_schema = Arc::new(ArrowSchema::new(vec![field_with_id(
"a",
DataType::Int64,
1,
)]));
let batch = RecordBatch::try_new(arrow_schema.clone(), vec![Arc::new(Int64Array::from(
vec![1, 2, 3],
))])
.unwrap();
write_row_groups(&file_path, arrow_schema, vec![batch], false);
let read = async |predicate: Predicate| {
let task = FileScanTask::builder()
.with_file_size_in_bytes(std::fs::metadata(&file_path).unwrap().len())
.with_start(0)
.with_length(0)
.with_data_file_path(file_path.clone())
.with_data_file_format(DataFileFormat::Parquet)
.with_schema(schema.clone())
.with_project_field_ids(vec![1, 2])
.with_predicate(Some(predicate.bind(schema.clone(), true).unwrap()))
.with_partition_spec(Some(partition_spec.clone()))
.with_partition(Some(partition.clone()))
.with_case_sensitive(false)
.build()
.unwrap();
let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as FileScanTaskStream;
ArrowReaderBuilder::new(FileIO::new_with_fs(), Runtime::current())
.build()
.read(tasks)
.unwrap()
.stream()
.try_collect::<Vec<RecordBatch>>()
.await
.unwrap()
};
// Projection applies the partition value.
let batches = read(Predicate::AlwaysTrue).await;
assert_eq!(
batches[0].column(1).as_primitive::<Int64Type>().values(),
&[7, 7, 7]
);
let cases = [
("p = 7", Reference::new("p").equal_to(Datum::long(7)), 3),
("p IS NOT NULL", Reference::new("p").is_not_null(), 3),
("p = 8", Reference::new("p").equal_to(Datum::long(8)), 0),
("p IS NULL", Reference::new("p").is_null(), 0),
];
let mut actual = Vec::new();
for (name, predicate, _) in &cases {
let batches = read(predicate.clone()).await;
actual.push((
*name,
batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
));
}
let expected: Vec<_> = cases.iter().map(|(name, _, rows)| (*name, *rows)).collect();
assert_eq!(actual, expected);
}Running cargo test -p iceberg --lib -- _on_absent_ on main gives the following. The projection assertions pass, and the filter assertions fail:
---- arrow::reader::row_filter::tests::test_predicate_on_absent_column_uses_initial_default stdout ----
assertion `left == right` failed
left: [("b = 7", 0), ("b IS NOT NULL", 0), ("b IN (7, 8)", 0), ("b > 5", 0), ("b = 8", 0), ("b IS NULL", 3)]
right: [("b = 7", 3), ("b IS NOT NULL", 3), ("b IN (7, 8)", 3), ("b > 5", 3), ("b = 8", 0), ("b IS NULL", 0)]
---- arrow::reader::row_filter::tests::test_page_index_on_absent_column_uses_initial_default stdout ----
assertion `left == right` failed
left: [("b = 7", 0), ("b IS NOT NULL", 0), ("b IN (7, 8)", 0)]
right: [("b = 7", 3), ("b IS NOT NULL", 3), ("b IN (7, 8)", 3)]
---- arrow::reader::row_filter::tests::test_predicate_on_absent_identity_partition_column_uses_partition_value stdout ----
assertion `left == right` failed
left: [("p = 7", 0), ("p IS NOT NULL", 0), ("p = 8", 0), ("p IS NULL", 3)]
right: [("p = 7", 3), ("p IS NOT NULL", 3), ("p = 8", 0), ("p IS NULL", 0)]
The page index test calls get_row_selection_for_filter_predicate directly. Through ArrowReader both bugs drop the same rows, so an end-to-end read can't tell them apart.
The partition test builds the FileScanTask directly, so it skips partition pruning. The file's partition value matches p = 7 and p IS NOT NULL, so pruning keeps the file for those predicates, and a real scan returns no rows for them.
Expected behavior
A predicate on a column that is missing from a data file should be evaluated as if every row held the value that projection returns for it. That is the identity partition value if the file has one, otherwise the field's initial-default. When neither exists, the current null handling is correct. Page index pruning should keep the rows whenever that value can match the predicate.
A nested field with an initial-default can also read as null when an ancestor struct is null, so a predicate on it should keep rows that match either the default or null, as Java does. #3261 tracks a related gap where RecordBatchTransformer doesn't apply initial-default to nested fields.
Willingness to contribute
I can contribute a fix for this bug independently
Activity
[-]Rows with an initial-default value are dropped when a filter references the defaulted column[/-][+]Rows are dropped when a filter references a column that is missing from the data file but has an identity partition value or initial-default[/+]