refactor(proto): destructure plan and proto structs in aggregate and window serde hooks - #24166
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #24166 +/- ##
==========================================
- Coverage 81.05% 81.05% -0.01%
==========================================
Files 1107 1107
Lines 381555 381582 +27
Branches 381555 381582 +27
==========================================
+ Hits 309277 309284 +7
- Misses 54020 54032 +12
- Partials 18258 18266 +8 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…erde hooks Start `AggregateExec::try_to_proto` with an exhaustive destructure of `Self` and `AggregateExec::try_from_proto` with an exhaustive destructure of `protobuf::AggregateExecNode`, then build from those bindings instead of calling getters / field paths. Adding a field to either struct without deciding how it is serialized is now a compile error in both directions rather than a silently dropped value. Fields that are intentionally not on the wire bind to `_` with a comment explaining why (derived at construction, runtime state, or recomputed on decode). Pure refactor: the wire format is byte-for-byte unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rde hooks `WindowAggExec::try_to_proto` and `BoundedWindowAggExec::try_to_proto` now start with an exhaustive destructure of `Self`, and the shared `WindowAggExec::try_from_proto` decoder destructures `protobuf::WindowAggExecNode` exhaustively. This documents the implicit coupling around `can_repartition`: it has no wire field of its own, because `partition_keys()` returns an empty vec when it is false and the decoder recovers it as `!partition_keys.is_empty()`. That is now an explicit comment on both sides instead of tribal knowledge. Adding a field to either struct without deciding how it is serialized is now a compile error in both directions. Pure refactor: the wire format is byte-for-byte unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…c in serde hooks Apply the same exhaustive-destructure pattern to the remaining misc plans: `try_to_proto` destructures `Self` and `try_from_proto` destructures the prost node struct, so a newly added field on either side is a compile error rather than a silent round-trip gap. The refactor surfaced one pre-existing gap, left as-is and documented with a TODO: `AnalyzeExec::metric_types` has no proto field, and `AnalyzeExecBuilder` resets it to `[Summary, Dev]`, so a non-default metric type selection does not survive a round-trip. Fixing that needs a new wire field and is deliberately out of scope here. Pure refactor: the wire format is byte-for-byte unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9c955d6 to
3588946
Compare
kumarUjjawal
left a comment
There was a problem hiding this comment.
Thansk @adriangb
This looks good!
| filter_expr, | ||
| limit_options, | ||
| input, | ||
| // Derived at construction by `create_schema` from `input_schema`, |
There was a problem hiding this comment.
I don't think schema is always safely derived here. with_new_aggr_exprs and with_new_children preserve the existing schema through try_new_with_schema specifically because rebuilding it after rewriting aggregate expressions can change field names. However, deserialization calls AggregateExec::try_new which rebuilds the schema. Is this an already existing issue?
There was a problem hiding this comment.
pre-existing. i will open an issue to track
…ted for proto serialization (apache#24168) ## Which issue does this PR close? - Follow-up cleanup for the proto hook migration EPIC apache#23494. Does not close it. ## Rationale for this change Five public accessor methods on physical plan nodes exist for one reason only: an older protobuf serialization PR needed to reach a private struct field from outside the plan's own module. Each was introduced by the serialization PR that needed it, and none was ever part of an API anyone asked for. Now that every one of these plans serializes itself through its own `try_to_proto` hook — which lives in the same module and can read the fields directly — the accessors have no callers inside DataFusion. But "no caller inside DataFusion" is not the same as "no caller". Of the five, **only one is deprecated** — the other four all turned out to have a real, non-serialization consumer downstream: | Method | Introduced by | Callers in DataFusion | Known downstream caller | This PR | | --- | --- | --- | --- | --- | | `AsyncFuncExec::async_exprs` | "[Proto]: Serialization support for `AsyncFuncExec`" (apache#19118) | none | none found | **deprecated** | | `AnalyzeExec::verbose` | "Implement protobuf serialization for AnalyzeExec" (apache#7574) | none | datafusion-distributed, openobserve | kept as-is | | `AnalyzeExec::show_statistics` | "Implement protobuf serialization for AnalyzeExec" (apache#7574) | none | openobserve | kept as-is | | `UnnestExec::list_column_indices` | "Support encoding and decoding UnnestExec" (apache#12344) | none | goldsky streamling | kept as-is | | `UnnestExec::struct_column_indices` | "Support encoding and decoding UnnestExec" (apache#12344) | none | goldsky streamling | kept as-is | An earlier revision of this PR deprecated four of the five. @kumarUjjawal's [review](apache#24168 (review)) pointed at two downstream projects I had not checked, which between them use three of those four. Those three deprecations have been reverted; see [below](#downstream-usage-check). Every one of the four kept accessors is the same shape: downstream code downcasts a planned node and needs to read its private fields in order to rebuild it as its own node. That is a legitimate use, and the fact that an accessor was *originally added* for proto doesn't make its current use wrong. Deprecating them would push a warning onto downstream projects for an API they have a real need for, with nothing to point them at instead. ## What changes are included in this PR? Adds `#[deprecated(since = "55.0.0", note = "...")]` to `AsyncFuncExec::async_exprs`. Nothing is removed, no behavior changes, and the other four accessors are untouched. The `note` is honest that there is no replacement: `AsyncFuncExec` serializes itself through `AsyncFuncExec::try_to_proto`, which reads the field directly, so there is nothing to point users at. It follows the existing phrasing used by the deprecated shims in `datafusion/proto/src/physical_plan/mod.rs` ("unused by DataFusion; ...") combined with the repo's established no-replacement idiom ("please open an issue if you have a use case for it"). `AsyncFuncExec::async_exprs` already had zero callers before apache#24166; its `try_to_proto` hook was written against the field from the start. ## Are these changes tested? There is no new behavior to test — the real verification is that the compiler agrees the method is unused. Since `deprecated` is a warning and CI builds with `-D warnings`, a clean lint over the whole workspace *is* the proof that no internal caller remains. Run locally on this branch: - `cargo fmt --all` - `cargo clippy --all-targets --workspace --features avro,integration-tests,extended_tests -- -D warnings` (CI's exact invocation) — clean across every crate, including `datafusion-cli`, `benchmarks`, `datafusion-examples` and `substrait` - `cargo test -p datafusion-proto --test proto_integration` — 219 passed, 0 failed - `cargo test -p datafusion-physical-plan` — passed, 0 failed ## Downstream usage check Before deprecating, I checked the three main downstream consumers at their current `main` (2026-08-09), by cloning each repo and grepping for all five accessor names plus every mention of `AnalyzeExec` / `UnnestExec` / `AsyncFuncExec`. GitHub code search returned 503s and silent empty results at the time, which is exactly why the survey missed two projects — @kumarUjjawal caught both in review. | Repo | Uses any of the five? | Which | | --- | --- | --- | | [datafusion-distributed](https://github.com/datafusion-contrib/datafusion-distributed) (`45bd823`) | Yes | `AnalyzeExec::verbose` | | [openobserve](https://github.com/openobserve/openobserve) (`575e8ea`) | Yes | `AnalyzeExec::verbose`, `AnalyzeExec::show_statistics` | | [goldsky streamling](https://github.com/goldsky-io/streamling) (`8d85af9`) | Yes | `UnnestExec::list_column_indices`, `UnnestExec::struct_column_indices` | | [datafusion-comet](https://github.com/apache/datafusion-comet) (`c706360`) | No | — | | [datafusion-ballista](https://github.com/apache/datafusion-ballista) (`06f8f1d`) | No | — | **datafusion-distributed — `verbose`.** `src/explain_analyze.rs:34` builds a `DistributedAnalyzeExec` from `analyze_exec.verbose()`, driven by a planner that downcasts a planned `AnalyzeExec` (`src/distributed_planner/distributed_query_planner.rs:96`). It does not read `show_statistics` — `DistributedAnalyzeExec` doesn't carry that flag. **openobserve — `verbose` *and* `show_statistics`.** [`src/search/src/datafusion/optimizer/physical_optimizer/distribute_analyze.rs:31-37`](https://github.com/openobserve/openobserve/blob/575e8ea4d5fd4b0e630aa013a1a31b1da56708e9/src/search/src/datafusion/optimizer/physical_optimizer/distribute_analyze.rs#L31-L37) does the same rewrite as datafusion-distributed, but reads both flags: ```rust if let Some(analyze) = plan.downcast_ref::<AnalyzeExec>() { let distribute_analyze = Arc::new(DistributeAnalyzeExec::new( analyze.verbose(), analyze.show_statistics(), analyze.input().clone(), )) as Arc<dyn ExecutionPlan>; ``` **goldsky streamling — both unnest accessors.** [`crates/streamling-core/src/operators/unnest.rs:85-99`](https://github.com/goldsky-io/streamling/blob/8d85af926b034085aece04233722c508ac80f2d0/crates/streamling-core/src/operators/unnest.rs#L85-L99), in `StreamingUnnestExec::from_original`, rebuilds a DataFusion `UnnestExec` as its own streaming operator and reads both index lists to do it: ```rust let list_column_indices = original_unnest .list_column_indices() .iter() .map(|idx| ListUnnest { index_in_input_schema: idx.index_in_input_schema, depth: idx.depth }) .collect(); let struct_column_indices = original_unnest.struct_column_indices().to_vec(); ``` **datafusion-comet — no usage.** It constructs `UnnestExec::new(...)` in `native/core/src/execution/planner.rs:2081` but never reads the index lists back out. Zero hits for any of the five names. **datafusion-ballista — no usage.** Two near-misses, both false positives: `ballista/core/src/planner.rs:148` reads `analyze.verbose`, but that is the public field on the **logical** `LogicalPlan::Analyze` node, not the physical accessor; `ballista/scheduler/src/state/distributed_explain.rs:155` calls `UnnestExec::new(...)`, construction only. **`AsyncFuncExec::async_exprs`.** Code search for `async_exprs` and for the literal `async_exprs()` across public Rust code returns hits only in DataFusion itself and in forks/vendored copies of it (`ClickHouse/rust_vendor`, `apache/datafusion-sandbox`, `Epsio-Labs/hiring-datafusion`, `smartdu/datafusion`). The three non-fork repos that mention `AsyncFuncExec` — `apache/sedona-db`, `influxdata/datafusion-udf-wasm`, `goldmedal/datafusion-llm-function` — have no calls to the accessor. This still covers only what public code search and these five projects show. If you know of a consumer of `AsyncFuncExec::async_exprs`, say so and I'll drop the last deprecation too, on the same reasoning applied to the other four. ## Are there any user-facing changes? Yes, and the `api change` label applies. Downstream users who call `AsyncFuncExec::async_exprs` will now see a deprecation warning. Nothing breaks in this release — the method still works exactly as before. Removal follows the normal deprecation window described in the [API health policy](https://github.com/apache/datafusion/blob/main/docs/source/contributor-guide/api-health.md) (six major versions or six months, whichever is longer), consistent with the plan in EPIC apache#23494. `AnalyzeExec::verbose`, `AnalyzeExec::show_statistics`, `UnnestExec::list_column_indices` and `UnnestExec::struct_column_indices` are unchanged, so datafusion-distributed, openobserve and goldsky streamling see no new warning. There is intentionally no replacement API for `async_exprs`. If you have a use case for reading that field from outside the plan, please open an issue — that is a real API request worth designing deliberately, rather than something to leave standing by accident. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Which issue does this PR close?
Rationale for this change
EPIC #23494 moved every built-in
ExecutionPlanoff the centraldowncast_refchain indatafusion-protoand onto per-plan hooks (ExecutionPlan::try_to_proto+ an inherentFooExec::try_from_proto) that live in the plan's own module.Those hooks currently read plan state through getters. That means adding a field to a plan struct is invisible to serialization: nothing breaks, the field is just silently not serialized, and the omission only shows up later as a plan that quietly changes shape after a round-trip.
This is not hypothetical.
HashJoinExec.fetchis dropped on round-trip today for exactly this reason (being fixed separately). The same class of bug is one commit away in every other plan.This PR removes the failure mode for the aggregate, window, and remaining misc plans by making both directions exhaustive:
try_to_protobegins with an exhaustive destructure ofself. Every field is named — no... Adding a field to the plan struct is now a compile error until the author decides what happens to it.try_from_protodestructures the prost-generated node struct exhaustively. Those structs are plain, all-puband not#[non_exhaustive], so this compiles — and a newly added proto field becomes a compile error in every decoder rather than a silently ignored wire field.Fields that genuinely are not serialized bind to
_with a short comment saying why: derived at construction, runtime state, or recomputed on decode.What changes are included in this PR?
Three commits, one per plan group, each green on its own:
AggregateExec/protobuf::AggregateExecNodeWindowAggExecandBoundedWindowAggExec/protobuf::WindowAggExecNode(they share one decoder)UnnestExec,AsyncFuncExec,AnalyzeExecand their nodesAll changes are confined to
datafusion/physical-plan/src/.The wire format is unchanged — byte for byte. No behavior changes. This is a pure refactor; the encoders build the same proto messages from the same values, just reached through destructured bindings instead of accessors.
Implicit coupling now documented
WindowAggExec::can_repartition/BoundedWindowAggExec::can_repartitionhave no wire field of their own.partition_keys()returns an empty vec whencan_repartitionis false, and the decoder recovers the flag as!partition_keys.is_empty(). That round trip was previously something you had to already know; it is now an explicit comment on both the encode and the decode side.Unserialized fields the refactor documented
Fields bound to
_because they are legitimately reconstructed rather than transmitted:AggregateExecschema,required_input_ordering,input_order_mode,cacheAggregateExecmetricsWindowAggExecschema,ordered_partition_by_indices,cacheBoundedWindowAggExecschema,ordered_partition_by_indices,cacheWindowAggExec/BoundedWindowAggExeccan_repartitionpartition_keys(see above)UnnestExeccache(derived),metrics(runtime)AsyncFuncExeccache(derived),metrics(runtime)AnalyzeExeccacheThe
AggrDynFiltercase is also now commented: only the sharedfilterexpr goes on the wire; the per-accumulator bounds are runtime state repopulated during execution.One real gap found, deliberately left alone
AnalyzeExec::metric_typesis not serialized. There is no field for it onAnalyzeExecNode, andAnalyzeExecBuilderunconditionally resets it to[MetricType::Summary, MetricType::Dev], so a non-default metric type selection does not survive a round trip.Fixing this requires a new proto field, which is a wire-format change and therefore out of scope for a cleanup PR — a refactor that silently alters the wire format would be worse than the gap it fixes. The field is left bound to
_with aTODOdescribing the current state, so it can be filed and fixed separately.Are these changes tested?
Covered by the existing round-trip test suite, which is the actual proof that the wire format did not move:
cargo test -p datafusion-proto --test proto_integration— 214 passed, 0 failedcargo test -p datafusion-physical-plan --all-features— 1648 + 9 passed, 0 failedcargo clippy -p datafusion-physical-plan --all-targets --all-features -- -D warnings— cleancargo fmt --allNo new tests are added: the refactor introduces no new behavior to test, and its safety property (a forgotten field becomes a compile error) is enforced by the compiler rather than by a test.
Are there any user-facing changes?
No. No public API changes, no wire-format changes, no behavior changes.