-
Notifications
You must be signed in to change notification settings - Fork 532
Cache partition-type resolvability and cover dropped-source-column skip #2869
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -69,40 +69,65 @@ impl PartitionFilterCache { | |
| format!("Could not find partition spec for id {spec_id}"), | ||
| ))?; | ||
|
|
||
| let partition_type = partition_spec.partition_type(schema)?; | ||
| let partition_fields = partition_type.fields().to_owned(); | ||
| let partition_schema = Arc::new( | ||
| Schema::builder() | ||
| .with_schema_id(partition_spec.spec_id()) | ||
| .with_fields(partition_fields) | ||
| .build()?, | ||
| ); | ||
| // A historical spec may reference a source column that was later dropped from the | ||
| // schema, which is a legitimate v2+ state. Such a spec cannot be resolved to a | ||
| // partition type, so it falls back to an always-true filter: files under the spec | ||
| // are not partition-pruned but still receive the row filter. Any other resolution | ||
| // failure is unexpected and propagates. The fallback is cached by spec id like any | ||
| // other filter; this is safe only because the cache lives per-scan in `PlanContext` | ||
| // with a fixed schema and predicate. Hoisting it to table or catalog scope would | ||
| // pin a spec to always-true even for a later scan whose schema could resolve it. | ||
| // TODO(https://github.com/apache/iceberg-rust/issues/2844): derive partition types from | ||
| // transforms where possible to restore pruning on a historical spec's still-live fields, | ||
| // which also makes the fallback per-term (dropped fields only) instead of per-spec. | ||
| let has_dropped_source_column = partition_spec | ||
| .fields() | ||
| .iter() | ||
| .any(|field| schema.field_by_id(field.source_id).is_none()); | ||
|
|
||
| let mut inclusive_projection = InclusiveProjection::new(partition_spec.clone()); | ||
| let partition_filter = if has_dropped_source_column { | ||
| BoundPredicate::AlwaysTrue | ||
| } else { | ||
| let partition_type = partition_spec.partition_type(schema)?; | ||
| let partition_fields = partition_type.fields().to_owned(); | ||
| let partition_schema = Arc::new( | ||
| Schema::builder() | ||
| .with_schema_id(partition_spec.spec_id()) | ||
| .with_fields(partition_fields) | ||
| .build()?, | ||
| ); | ||
|
|
||
| let partition_filter = inclusive_projection | ||
| .project(&filter)? | ||
| .rewrite_not() | ||
| .bind(partition_schema.clone(), case_sensitive)?; | ||
| let mut inclusive_projection = InclusiveProjection::new(partition_spec.clone()); | ||
|
|
||
| self.0 | ||
| .write() | ||
| .map_err(|_| { | ||
| Error::new( | ||
| ErrorKind::Unexpected, | ||
| "PartitionFilterCache RwLock was poisoned", | ||
| ) | ||
| })? | ||
| .insert(spec_id, Arc::new(partition_filter)); | ||
| inclusive_projection | ||
| .project(&filter)? | ||
| .rewrite_not() | ||
| .bind(partition_schema.clone(), case_sensitive)? | ||
| }; | ||
|
|
||
| let read = self.0.read().map_err(|_| { | ||
| let mut write = self.0.write().map_err(|_| { | ||
| Error::new( | ||
| ErrorKind::Unexpected, | ||
| "PartitionFilterCache RwLock was poisoned", | ||
| ) | ||
| })?; | ||
|
|
||
| Ok(read.get(&spec_id).unwrap().clone()) | ||
| // Another thread may have populated the entry while the filter was computed without a | ||
| // lock held. Only warn and insert when this call is the one that populates it, so the | ||
| // always-true fallback is logged once per spec rather than once per racing thread. | ||
| let partition_filter = write.entry(spec_id).or_insert_with(|| { | ||
| if has_dropped_source_column { | ||
| tracing::warn!( | ||
| spec_id, | ||
| "Partition spec references a source column not in the scan schema; \ | ||
| skipping partition pruning for its manifests" | ||
| ); | ||
| } | ||
|
|
||
| Arc::new(partition_filter) | ||
| }); | ||
|
|
||
| Ok(partition_filter.clone()) | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -235,3 +260,81 @@ impl ExpressionEvaluatorCache { | |
| Ok(read.get(&spec_id).unwrap().clone()) | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use std::collections::HashMap; | ||
| use std::sync::Arc; | ||
|
|
||
| use super::PartitionFilterCache; | ||
| use crate::expr::{Bind, BoundPredicate, Reference}; | ||
| use crate::spec::{ | ||
| Datum, FormatVersion, NestedField, PrimitiveType, Schema, SortOrder, TableMetadataBuilder, | ||
| Transform, Type, UnboundPartitionSpec, | ||
| }; | ||
|
|
||
| /// A historical spec whose source column was dropped from the current schema resolves to an | ||
| /// always-true filter, and the fallback is cached under the spec id. | ||
| #[test] | ||
| fn dropped_partition_source_column_falls_back_to_always_true() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This exercises So if the plumbing in
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is a test that already does this? |
||
| let schema = Schema::builder() | ||
| .with_fields(vec![ | ||
| NestedField::required(1, "id", Type::Primitive(PrimitiveType::Long)).into(), | ||
| NestedField::required(2, "part", Type::Primitive(PrimitiveType::Long)).into(), | ||
| ]) | ||
| .build() | ||
| .unwrap(); | ||
|
|
||
| // Spec 0 partitions on `part` (id 2). | ||
| let spec = UnboundPartitionSpec::builder() | ||
| .with_spec_id(0) | ||
| .add_partition_field(2, "part", Transform::Identity) | ||
| .unwrap() | ||
| .build(); | ||
|
|
||
| // Evolve the schema so that `part` is no longer present, leaving spec 0 unresolvable. | ||
| let evolved_schema = Schema::builder() | ||
| .with_fields(vec![ | ||
| NestedField::required(1, "id", Type::Primitive(PrimitiveType::Long)).into(), | ||
| ]) | ||
| .build() | ||
| .unwrap(); | ||
| // Make an unpartitioned spec the default before dropping `part`, so spec 0 survives | ||
| // only as a historical spec that can no longer be resolved against the schema. | ||
| let table_metadata = Arc::new( | ||
| TableMetadataBuilder::new( | ||
| schema, | ||
| spec, | ||
| SortOrder::unsorted_order(), | ||
| "s3://bucket/test".to_string(), | ||
| FormatVersion::V2, | ||
| HashMap::new(), | ||
| ) | ||
| .unwrap() | ||
| .add_default_partition_spec(UnboundPartitionSpec::builder().build()) | ||
| .unwrap() | ||
| .add_current_schema(evolved_schema.clone()) | ||
| .unwrap() | ||
| .build() | ||
| .unwrap() | ||
| .metadata, | ||
| ); | ||
|
|
||
| let filter = Reference::new("id") | ||
| .greater_than_or_equal_to(Datum::long(5)) | ||
| .bind(Arc::new(evolved_schema.clone()), true) | ||
| .unwrap(); | ||
|
|
||
| let cache = PartitionFilterCache::new(); | ||
| let partition_filter = cache | ||
| .get(0, &table_metadata, &evolved_schema, true, filter.clone()) | ||
| .unwrap(); | ||
| assert!(matches!(*partition_filter, BoundPredicate::AlwaysTrue)); | ||
|
|
||
| // The fallback is cached, so a second lookup returns the same instance. | ||
| let cached = cache | ||
| .get(0, &table_metadata, &evolved_schema, true, filter) | ||
| .unwrap(); | ||
| assert!(Arc::ptr_eq(&partition_filter, &cached)); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.