feat: add columnar Delta->AMT stats pivot for content_stats - #3351
emkornfield wants to merge 2 commits into
Conversation
| /// Evaluation is attempted optimistically (assuming every Delta stat source the AMT layout wants is | ||
| /// present, since the data may carry more than `input_schema` declares) and, if that fails, retried | ||
| /// conservatively against only the stat columns `input_schema` declares. | ||
| pub(crate) fn try_pre_convert_stats_column( |
There was a problem hiding this comment.
nit: preconvert (no "_"
|
|
||
| /// Converts a batch's parsed Delta-stats column into the flat AMT `content_stats` layout in place. | ||
| /// | ||
| /// `data` conforms to `input_schema`; `stats_column_name` names a struct column holding parsed |
There was a problem hiding this comment.
lets make this more concise with an example of input and output.
| /// Returns `Ok(None)` when `input_schema`'s stats column is not in Delta-stats shape (no | ||
| /// `numRecords`) -- e.g. it is already AMT format or absent -- or when the built expression fails | ||
| /// to evaluate, so the caller leaves `content_stats` null (manifest-level data skipping is then | ||
| /// unavailable). Errors from *building* the pivot expression -- a precondition violation such as a |
There was a problem hiding this comment.
I don't think we need to document errors here.
| Err(e) => debug!("AMT stats pivot optimistic pass failed, retrying conservatively: {e}"), | ||
| } | ||
|
|
||
| // Conservative pass: reference only the stat sources the input schema declares. |
There was a problem hiding this comment.
we should only do this pass. I don't think we need to do the full pass.
|
|
||
| /// Builds the `tight_bounds` expression for a leaf. | ||
| /// | ||
| /// String, binary, `TIMESTAMP`, and `TIMESTAMP_NTZ` bounds may be truncated by Delta, so they are |
There was a problem hiding this comment.
we need variant and geography here also I think.
| let engine = SyncEngine::new(); | ||
| let data = engine | ||
| .evaluation_handler() | ||
| .create_one(Arc::new(data_schema.clone()), values) |
There was a problem hiding this comment.
need to fix this.
|
|
||
| /// Extracts the `content_stats` struct column from a successful pivot result. | ||
| fn content_stats_of(result: Box<dyn EngineData>) -> StructArray { | ||
| let batch: RecordBatch = ArrowEngineData::try_from_engine_data(result) |
There was a problem hiding this comment.
ideally we wouldn't use arrow here but instead use a visitor or compare engine data to a preconstructed value.
| let content_stats = run_pivot(&input_schema, values); | ||
|
|
||
| let id = leaf(&content_stats, "id"); | ||
| assert_eq!( |
There was a problem hiding this comment.
we should do these comparison in bulck instead of unwrapping to types.
Replace StatsSchemaCollector with a generic StatsLeafWalker<'a, F> that drives the table-schema walk (struct descent, Delta stat projection threading, and the drop-if-no-category rule) and invokes an `on_leaf` sink per surviving leaf. `collect_stats_schema` drives it with a closure that pushes `leaf_stats_field` results. Behavior of `stats_schema` / `projected_stats_schema` is unchanged; this lets the upcoming Delta->AMT stats pivot reuse the same leaf enumeration instead of duplicating it. Co-authored-by: Isaac <no-reply@databricks.com>
Add try_pre_convert_stats_column, which converts a batch's parsed Delta-stats column (numRecords/tightBounds + nested minValues/maxValues/ nullCount) into the flat AMT content_stats layout by building a StructPatch expression and evaluating it on the engine. Rewritten for the flat schema (each leaf reads its nested Delta source path), reusing the shared StatsLeafWalker and leaf_stats_field so the expression and output schema align by construction. Conversion is attempted optimistically then retried conservatively against only the declared stat columns. tight_bounds is type-aware: string/TIMESTAMP/TIMESTAMP_NTZ leaves are forced false (Delta may truncate their bounds); other leaves take the file's tightBounds, defaulting to true when absent/null so a false file value still forces false everywhere. Full DBR rules are a follow-up. Co-authored-by: Isaac <no-reply@databricks.com>
8d30f7f to
0a6e4a2
Compare
| /// Drives the table-schema walk shared by AMT `content_stats` schema generation | ||
| /// ([`collect_stats_schema`]) and the Delta -> AMT stats pivot ([`build_amt_flat_stats_expr`]): it | ||
| /// descends structs, threads the optional Delta stat projection through each descent, and invokes | ||
| /// `on_leaf` once for every non-struct leaf the projection did not drop, with the leaf field, its |
There was a problem hiding this comment.
too much detail?
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3351 +/- ##
==========================================
+ Coverage 90.51% 90.53% +0.02%
==========================================
Files 255 255
Lines 91213 91772 +559
Branches 91213 91772 +559
==========================================
+ Hits 82558 83086 +528
- Misses 5642 5669 +27
- Partials 3013 3017 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| // is non-absent (nulls count as present; a row where an enclosing nullable struct is | ||
| // null makes the leaf absent). For top-level leaves `numRecords` is exact; for a leaf | ||
| // under a nullable struct it over-approximates, but Delta records no better source. | ||
| VALUE_COUNT if field_exists(NUM_RECORDS, false) => { |
There was a problem hiding this comment.
double check if this is accurate.
|
|
||
| let exprs = stats_struct.fields().map(|f| { | ||
| let expr = match f.name().as_str() { | ||
| // TODO: for float/double leaves, Delta excludes NaN from min/max, so a bound can |
There was a problem hiding this comment.
delta actually includes NaN, Iceberg doesn't update the comment.
| // is non-absent (nulls count as present; a row where an enclosing nullable struct is | ||
| // null makes the leaf absent). For top-level leaves `numRecords` is exact; for a leaf | ||
| // under a nullable struct it over-approximates, but Delta records no better source. | ||
| VALUE_COUNT if field_exists(NUM_RECORDS, false) => { |
There was a problem hiding this comment.
can we eliminate the boolean inputs to exists and just call exists directly in these cases?
| } | ||
|
|
||
| #[test] | ||
| fn tight_bounds_expr_by_type_and_presence() { |
There was a problem hiding this comment.
this is better tested in an end-to-end with a resulting values instead of testing the helper method.
| fn pivot_round_trip_maps_categories_and_counts() { | ||
| let input_schema = delta_stats_input_schema(true, true, true); | ||
| // numRecords=10, tightBounds=true, min{1,"aaa"}, max{5,"zzz"}, nullCount{0,2}. | ||
| let values: &[Scalar] = &[ |
There was a problem hiding this comment.
might be clearer if we did create_many instead of the flattened schema.
| } | ||
|
|
||
| #[test] | ||
| fn pivot_round_trip_nested_leaf_reads_nested_source_path() { |
There was a problem hiding this comment.
these tests seem to have a common pattern can we convert to rs_test?
🥞 Stacked PR
Use this link to review incremental changes.
What changes are proposed in this pull request?
Add try_pre_convert_stats_column, which converts a batch's parsed
Delta-stats column (numRecords/tightBounds + nested minValues/maxValues/
nullCount) into the flat AMT content_stats layout by building a
StructPatch expression and evaluating it on the engine. Rewritten for the
flat schema (each leaf reads its nested Delta source path), reusing the
shared StatsLeafWalker and leaf_stats_field so the expression and output
schema align by construction. Conversion is attempted optimistically then
retried conservatively against only the declared stat columns.
tight_bounds is type-aware: string/TIMESTAMP/TIMESTAMP_NTZ leaves are
forced false (Delta may truncate their bounds); other leaves take the
file's tightBounds, defaulting to true when absent/null so a false file
value still forces false everywhere. Full DBR rules are a follow-up.
Co-authored-by: Isaac no-reply@databricks.com
How was this change tested?