Skip to content

[EPIC/reference] Port ExecutionPlan serialization to try_to_proto / try_from_proto hooks (full)#23496

Draft
adriangb wants to merge 13 commits into
apache:mainfrom
pydantic:execution-plan-proto
Draft

[EPIC/reference] Port ExecutionPlan serialization to try_to_proto / try_from_proto hooks (full)#23496
adriangb wants to merge 13 commits into
apache:mainfrom
pydantic:execution-plan-proto

Conversation

@adriangb

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

This draft shows the complete end state of #23494 in one place: every built-in ExecutionPlan, all five FileSource scans, all three file sinks, and MemorySourceConfig moved onto the try_to_proto / try_from_proto hooks, with the central downcast_ref chain deleted. It is a reference for the incremental PRs — those land one plan/family at a time off #23495; this is not meant to be merged directly.

What changes are included in this PR?

  • The ctx + hooks foundation (== Add ExecutionPlan try_to_proto / try_from_proto hooks + ProjectionExec reference #23495) plus the typed function-codec ctx methods and a scalar-subquery-results scope.
  • ~35 plans migrated: projection, filter, all five joins, unions, sorts (incl. SortPreservingMergeExec), limits, repartition, both coalesces, unnest, explain, analyze, cooperative, buffer, placeholder, empty, coalesce-batches, AggregateExec (UDAF), both window execs (UDWF), AsyncFuncExec, ScalarSubqueryExec.
  • DataSource family: a try_to_proto hook on the DataSource/FileSource traits + the FileScanConfig serde ported into datafusion-datasource; csv/json/arrow/avro/parquet sources + MemorySourceConfig. DataSourceExec now serializes purely by delegating DataSource → FileSource — no downcast in the central encode path.
  • DataSink family: the mirror — a DataSink hook + FileSinkConfig serde port + the three file sinks.
  • The 58 now-dead per-plan PhysicalPlanNodeExt methods and the try_from_data_source_exec / try_from_data_sink_exec dispatchers are deleted; mod.rs shrinks by ~1900 lines.

Left as typed dispatch (by design): GenerateSeries/LazyMemoryExec (their generator types live above physical-plan — a hook can't name them without a crate cycle) and Extension (the terminal third-party fallback).

Notable findings baked in: enum conversions are inlined as by-name matches (proto vs. datafusion_common numbering differs); the encode dispatch resolves downcast_delegate() before the hook (a wrapper-plan bug this caught); Partitioning/PhysicalSortExpr/Schema/ScalarValue all serialize from datafusion-proto-models/-common with no datafusion-proto-only helper; no migrated plan needs the raw PhysicalExtensionCodec.

Are these changes tested?

Yes — the full proto_integration roundtrip suite passes (193 tests incl. TPC-H, with avro/parquet), plus new scan/sink roundtrip tests (roundtrip_{csv,json,avro}_scan) that exercise the new hook paths. clippy -D warnings + fmt clean; the workspace builds.

Are there any user-facing changes?

Yes — a documented breaking api change: the per-plan PhysicalPlanNodeExt serialization methods and the TryFromProto<&protobuf::{Json,Csv,Parquet}Sink> impls are removed (pub-for-proto scaffolding from #21929, released in 54.0.0). The load-bearing public API (AsExecutionPlan, PhysicalExtensionCodec, PhysicalProtoConverterExtension) is unchanged, and the wire format is byte-for-byte identical. An Upgrade Guide note will accompany the incremental removals.

adriangb and others added 13 commits July 10, 2026 21:55
…nExec reference (apache#22419)

General part: ExecutionPlanEncodeCtx/DecodeCtx + internal dispatch traits in
datafusion-physical-plan (mirrors the PhysicalExpr pattern), the try_to_proto
trait hook, expect_plan_variant! macro, and the ConverterPlanEncoder/Decoder
adapters wiring the hook into the central dispatch in datafusion-proto.

ProjectionExec migrated as the reference: encode downcast arm deleted, decode
arm reduced to a one-liner delegating to ProjectionExec::try_from_proto. All
189 proto_integration roundtrip tests pass (incl. TPC-H).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfBPC2yYCwHUn4V8pQCVj3
apache#22419)

Fleet-migrated Filter, Empty, CoalesceBatches, Union/Interleave, Sort,
Repartition, and HashJoin onto try_to_proto/try_from_proto. Central encode
downcast arms deleted; decode arms reduced to one-liners. All 190
proto_integration tests pass.

Findings proven by the prototype:
- Partitioning and PhysicalSortExpr wrapping (serialize_partitioning /
  serialize_physical_sort_exprs) inline cleanly from datafusion-proto-models;
  no datafusion-proto-only helper needed.
- Schema and ScalarValue conversions come from datafusion-proto-common
  (already a physical-plan dep under the proto feature).
- Enum conversions (JoinType/NullEquality/PartitionMode/JoinSide) must be
  inlined as by-NAME matches; proto vs common variant ordering differs.
- The hook must follow downcast_delegate() before dispatch, else wrapper
  plans serialize wrong (caught by serialize_uses_downcast_delegate).
- No migrated plan needed the PhysicalExtensionCodec or function registry.

Added ExecutionPlanDecodeCtx::decode_children for the N-inputs shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfBPC2yYCwHUn4V8pQCVj3
…roto ctx (apache#22419)

Adds encode_udf/udaf/udwf + decode_udf/udaf/udwf to ExecutionPlanEncodeCtx/
DecodeCtx (and the internal dispatch traits). These take datafusion-expr types
+ Vec<u8> and never name a proto type; the datafusion-proto adapter backs them
over PhysicalExtensionCodec + the registry, owning the payload/registry
lookup-order policy in one place. Wire-identical to the existing fun_definition
semantics ((!buf.is_empty()).then_some(buf)).

This lets function-carrying plans (Aggregate, window, ScalarSubquery) ride the
polymorphic hook instead of staying as typed arms — possible because
physical-plan sits above datafusion-expr (unlike the expr-side ctx). apache#23421's
ScalarUdfCodec stays closed; ScalarFunctionExpr stays special-cased on the expr
side where the crate cycle forces it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfBPC2yYCwHUn4V8pQCVj3
…ggregateExec (apache#22419)

Bucket A: CrossJoin, NestedLoopJoin, SortMergeJoin, SymmetricHashJoin,
CoalescePartitions, Cooperative, Buffer, GlobalLimit, LocalLimit,
PlaceholderRow, Unnest, Explain, Analyze.

Bucket B (function-carrying, proves the ctx function-codec design):
AggregateExec — UDAF round-trips purely through ctx.encode_udaf/decode_udaf
with zero proto/codec/registry types crossing the boundary.

All join enum conversions inlined by-name (StreamPartitionMode, JoinType,
NullEquality, JoinSide). StringifiedPlan and UnnestOptions conversions (which
lived only in datafusion-proto) inlined from proto-common/models. All 189
roundtrip tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfBPC2yYCwHUn4V8pQCVj3
…o hook (apache#22419)

Both share the Window proto variant; a single WindowAggExec::try_from_proto
dispatches on input_order_mode. UDWF via ctx.encode_udwf/decode_udwf and
UDAF-as-window via ctx.encode_udaf/decode_udaf — no raw codec. Window frame
+ InputOrderMode enums inlined by-name. 189 roundtrip tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfBPC2yYCwHUn4V8pQCVj3
…ache#22419 stack C)

Adds a proto feature + try_to_proto hook to the DataSource and FileSource
traits (datafusion-datasource), so DataSourceExec rides the ExecutionPlan hook
and delegates polymorphically: DataSourceExec -> DataSource -> FileSource. No
downcast in the encode path for migrated sources.

Ports the shared FileScanConfig <-> proto conversion into datafusion-datasource
(FileScanConfig::{to_proto_conf, from_proto_conf, parse_table_schema_from_proto});
orderings/partition exprs go through the ctx, schema/stats/scalars via
proto-common. Needs NO PhysicalExtensionCodec.

CsvSource migrated as proof: its central encode branch is deleted and the
CsvScan decode arm repointed to CsvSource::try_from_proto. New roundtrip_csv_scan
test proves the hook chain end-to-end. All 191 proto_integration tests pass.

Foundation confirms all 4 remaining formats (json/arrow/avro/parquet) are clean
(parquet encryption is a non-issue: crypto isn't in the proto today).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfBPC2yYCwHUn4V8pQCVj3
… hook (apache#22419)

Completes the file-scan family: all 5 FileSources now serialize via
FileSource::try_to_proto and decode via <Source>::try_from_proto. The central
scan encode branches in try_from_data_source_exec are deleted (only
MemorySourceConfig remains) and the 5 scan decode arms repointed. Parquet
carries its predicate via ctx.encode_expr and options via proto-common; none of
the formats need the raw codec.

New roundtrip_json_scan / roundtrip_avro_scan tests (+ existing csv/arrow/parquet
scan tests) exercise every FileSource hook end-to-end. 192 tests pass (193 with
avro).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfBPC2yYCwHUn4V8pQCVj3
…hook (apache#22419)

AsyncFuncExec: clean single-child + N-exprs, no codec.

ScalarSubqueryExec needed one small typed ctx addition:
decode_child_with_scalar_subquery_results (dispatch method + ctx wrapper +
one-line datafusion-proto adapter that re-enters with a scalar-subquery-results
scope). The scope handle is a datafusion-expr type, so no proto type leaks and
no codec is exposed — same precedent as the UDF ctx methods. The existing
roundtrip_scalar_subquery_exec test exercises the scoped decode.

192 roundtrip tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfBPC2yYCwHUn4V8pQCVj3
…ok (apache#22419)

MemorySourceConfig rides DataSource::try_to_proto (in datafusion-datasource,
where the type actually lives). Record-batch IPC serde is pure Arrow, inlined
locally like CsvSource's byte helpers — no codec.

With every DataSource migrated (5 file formats + memory), try_from_data_source_exec
and the DataSourceExec encode downcast arm are deleted entirely: DataSourceExec
now serializes purely via the ExecutionPlan hook, delegating through
DataSource -> FileSource with zero downcasts in the central encode path.
roundtrip_memory_source + the 5 scan tests stay green (192 total).

GenerateSeries/LazyMemoryExec remain typed arms (their generator types live in
datafusion-functions-table, above physical-plan — the hook can't name them
without a crate cycle). Extension stays as the terminal codec.try_encode fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfBPC2yYCwHUn4V8pQCVj3
…o the proto hook (apache#22419)

Mirrors the scan side: DataSink::try_to_proto hook on the trait, DataSinkExec
rides the ExecutionPlan hook and delegates to its sink (encoding input +
sort_order via the ctx, since a sink carries no child exprs). FileSinkConfig
serde ported into datafusion-datasource (file_sink_config/proto.rs), reusing the
scan side's PartitionedFile helpers; no codec needed.

CsvSink/JsonSink/ParquetSink each implement DataSink::try_to_proto + an inherent
try_from_proto. The central try_from_data_sink_exec, the DataSinkExec encode arm,
the try_into_*_sink_physical_plan decoders, and the colliding
TryFromProto<&protobuf::*Sink> for *Sink decode impls are all deleted; the 3 sink
decode arms repoint to <Fmt>Sink::try_from_proto. roundtrip_{csv,json,parquet}_sink
stay green (193 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfBPC2yYCwHUn4V8pQCVj3
…e#22419)

Same shape as SortExec (input + ExprType::Sort-wrapped orderings + fetch
sentinel). No codec. 193 roundtrip tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfBPC2yYCwHUn4V8pQCVj3
…wncast chain (apache#22419)

With every plan/source/sink migrated to the try_to_proto/try_from_proto hooks,
delete the 58 now-dead per-plan try_from_*_exec / try_into_*_physical_plan
methods from PhysicalPlanNodeExt and try_from_data_sink_exec. The central
encode path is now a single hook call + the extension codec fallback; decode is
a one-liner dispatch table. Only GenerateSeries/LazyMemoryExec (crate cycle) and
Extension (third-party fallback) remain as typed arms.

This removes released-in-54.0.0 pub API (the per-plan PhysicalPlanNodeExt
methods) and the TryFromProto<&protobuf::*Sink> impls — a deliberate, documented
api-change (coherent redesign; these were pub-for-proto scaffolding from apache#21929).
mod.rs shrinks by ~1900 lines. 193 tests pass; clippy -D warnings + fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfBPC2yYCwHUn4V8pQCVj3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api change Changes the API exposed to users of the crate datasource Changes to the datasource crate physical-plan Changes to the physical-plan crate proto Related to proto crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant