Repository navigation
Split proto serialization to encapsulate private state #21835
Description
Note: this plan / investigation was AI generated based on my human direction and guidance.
Problem
There have been multiple instances where we've made changes to a PhysicalExpr, ExecutionPlan, etc (anything that gets serialized) and have forgotten to update the protobuf serialization. I know of #19379 and #22065 as some examples, I believe there are more. This happens because there is nothing forcing consideration of serialization impact when a change is made to an expression.
This happens because datafusion-proto serializes every built-in PhysicalExpr through a single ~300-line downcast_ref chain in serialize_physical_expr_with_converter, with a symmetric match on ExprType in from_proto.rs. Because the serializer lives outside the crate where each expression is defined every piece of internal state an expression wants to round-trip has to be made pub and the protobuf machinery has to read that state and set it back.
That results in:
- Bugs like the two linked to above. These bugs are hard to catch, they only show up when you try to serialize and deserialize a plan (which for systems like https://github.com/datafusion-contrib/datafusion-distributed depends on the query itself, the size of the scans, etc.) and they can show up as unrecoverable errors.
- A lot of the public surface area of DataFusion exists just to expose internal state to protobuf. Take for example feat(proto): Add protobuf serialization for HashExpr #19379: we had to expose
HashExprto the public API just so that it can be serialized. proto: serialize dynamic filters on Sort, Aggregate, HashJoin plan nodes #22011 adds several public methods just for getting and setting internal state onExecutionPlanimplementations, so does proto: serialize and dedupe dynamic filters v2 #21807; more discussion at proto: serialize and dedupe dynamic filters v2 #21807 (comment) / proto: serialize and dedupe dynamic filters v2 #21807 (comment). - Third-party expressions go through
PhysicalExtensionCodec::try_encode_expr. Implementing a custom plan or expression means also implementing a custom codec and trying to get them to play nice via composition.
Proposal
Two changes, in sequence.
1. Extract datafusion-proto-models
Mirror the existing datafusion-proto-common split, but for the physical/logical plan schemas. The new crate contains only:
- the
.protofile(s) and theprost-generated Rust types, - optional
pbjson/serdederives behind ajsonfeature, - zero datafusion deps beyond
datafusion-proto-common.
datafusion-proto keeps its current public API by re-exporting from datafusion-proto-models. Downstream consumers (datafusion-ffi, datafusion-examples, benchmarks) need no changes.
This is a pure refactor — no semantic change, no behavior change.
2. Add PhysicalExpr::to_proto (feature-gated)
Add a method to the PhysicalExpr trait, gated on a new proto feature:
#[cfg(feature = "proto")]
fn to_proto(
&self,
ctx: &dyn PhysicalExprEncoder,
) -> Result<Option<PhysicalExprNode>> {
Ok(None)
}- Default returns
Ok(None)→ "fall through to the existing codec path" (matches today's behavior for extension expressions). - Explicit
Ok(Some(...))→ the expression has serialized itself. - Expressions that should serialize but don't implement this override the default with
internal_err!("{typename} does not implement to_proto").
PhysicalExprEncoder is a small trait defined in datafusion-proto-models, wrapping the existing PhysicalExtensionCodec + PhysicalProtoConverterExtension plumbing, with helpers for encode_child, encode_udf, encode_udaf, encode_udwf. This keeps physical-expr-common free of datafusion-proto as a dep.
The existing serialize_physical_expr_with_converter tries expr.to_proto(ctx) first; if it returns Ok(None), it falls through to the current downcast chain. This lets expressions migrate one at a time without breaking anything.
What this unlocks
- Private state stays private.
DynamicFilterPhysicalExpr::to_protoreads theRwLockdirectly — no morepub struct Inner. Once migrated, proto: serialize and dedupe dynamic filters v2 #21807's "pub for proto" scaffolding (Inner,from_parts,inner(),original_children,remapped_children) can be reverted. - One-file changes. Adding serialization for a new expression is a method impl next to the expression, not a round trip through
datafusion-proto. - Parity between built-in and third-party. Everyone uses the same hook shape.
Decode side (follow-up)
The encode-side win is clear. The decode side is still a central match on ExprType, which is fine — oneof ExprType gives us an exhaustive Rust enum, a strict improvement over today's runtime downcast chain.
As a follow-up (probably best after the encode migrations land), we can push decoding back to the expressions too, via associated fns like:
impl BinaryExpr {
fn from_proto(
proto: &PhysicalBinaryExprNode,
ctx: &ProtoDecodeCtx<'_>,
) -> Result<Arc<Self>> { ... }
}The central match becomes a dispatch table. The main payoff is that public god-constructors for private state go away: DynamicFilterPhysicalExpr::from_proto reads the proto fields and builds Inner internally, so we can fully delete the from_parts/Inner-as-pub scaffolding.
Alternatives considered
- Bytes-level
try_encode_selfhook, noprostinphysical-expr. Each expression encodes into an opaqueVec<u8>. Preserves privacy without pullingprostinto more crates. Downside: you lose the single.protofile as the schema source of truth — cross-language debuggability and interop suffer. Rejected. - Keep the central switch, mark unstable accessors
#[doc(hidden)]. Cheap. Doesn't fix the third-party/built-in asymmetry or the central-edit-per-new-expression problem. Reasonable as a stopgap, not a solution. - Separate
PhysicalExprPrototrait with a blanket fallback impl. Would avoid adding toPhysicalExpr. Requires a runtime "does it implement this" check (narrowAnycast). Slightly more complex than option (2) above and delivers the same thing.
Scope
This issue proposes the change only for PhysicalExpr. ExecutionPlan, LogicalPlan, and logical Expr have the same shape and would benefit from the same treatment, but each is its own migration (~40 ExecutionPlan impls alone). Worth tackling after the PhysicalExpr path is proven.
datafusion-substrait is unaffected — it operates independently and does not share code with datafusion-proto.
Open questions
- Should
PhysicalExprEncoderlive indatafusion-proto-modelsor be a narrow sub-trait thatPhysicalExtensionCodecre-implements? Leaning toward the former for minimum dep surface. - Window / aggregate expressions are serialized through a separate code path in
to_proto.rsand are not regularPhysicalExprimpls. Probably get a parallelWindowExpr::to_protohook, or stay central. Defer until the corePhysicalExprmigration is done. protofeature default. I'd default it off onphysical-expr-common/physical-expr/physical-plan(so developers who don't care pay nothing) and flip it on fromdatafusion-proto. CI would test with the feature on; off-mode is a "it still compiles" smoke test.
Related
- remove
datafusiondependency fromdatafusion-proto#17713 (removedatafusiondependency fromdatafusion-proto) — this refactor is consistent with that direction. - proto: serialize and dedupe dynamic filters v2 #21807 (the PR that motivated this discussion).
- Serialize dynamic filters across network boundaries #20418 (serialize+dedupe dynamic filters — the underlying feature work proto: serialize and dedupe dynamic filters v2 #21807 addresses).
Full implementation plan
Long Claude generated plan
Plan: split datafusion-proto and add PhysicalExpr::to_proto
Context
Today, datafusion-proto serializes every built-in PhysicalExpr via a ~300-line downcast_ref chain in datafusion/proto/src/physical_plan/to_proto.rs::serialize_physical_expr_with_converter (and a symmetric ExprType match in from_proto.rs). Because the serializer lives outside the crate where each expression is defined, every piece of internal state an expression wants to round-trip must be pub. The motivating example is the DynamicFilterPhysicalExpr PR (#21807), which had to expose pub struct Inner, pub fn inner(), pub fn from_parts(), pub fn original_children(), pub fn remapped_children() — all "warning: not stable; proto-only". The same shape will recur for every stateful expression.
Two problems to solve together:
- Private state stays private. Serialization code should live next to the data it serializes.
- Central switch is a maintenance sink. 20 production
PhysicalExprimpls already; every new one requires editingproto/src/physical_plan/to_proto.rs+from_proto.rs. Third parties have to usePhysicalExtensionCodecwith a different shape than built-ins.
Decision (user-approved):
- Extract
datafusion-proto-models(mirror ofdatafusion-proto-common): just theprost-generated structs + optionalpbjson. No datafusion deps beyondproto-common. - Add
fn to_proto(&self, ctx: &ProtoEncodeCtx<'_>) -> Result<PhysicalExprNode>directly on thePhysicalExprtrait,#[cfg(feature = "proto")]. Default impl returnsinternal_err!("<typename> does not implement proto serialization"). - CI tests with the
protofeature on; off-mode is a best-effort "it still compiles" signal.
This plan covers PhysicalExpr only. ExecutionPlan, LogicalPlan, and logical Expr have the same shape and can follow once this lands, but each is its own body of work (~40 ExecutionPlan impls alone).
Scope boundaries
- In scope: PhysicalExpr encode path, infrastructure for the decode path, migration of all ~18 currently-serialized
PhysicalExprtypes. - Out of scope for this plan:
ExecutionPlanand logical-side refactors, changes toPhysicalExtensionCodecshape for third-party codecs,datafusion-substrait(operates independently; unaffected).
Sequencing
Three PRs, each independently mergeable and reviewable.
PR 1 — Extract datafusion-proto-models
Pure refactor: move generated prost code + proto schema into a new crate. No semantic change. datafusion-proto becomes a thin layer on top.
New crate: datafusion/proto-models/
Cargo.toml— deps:prost,arrow(workspace),datafusion-proto-common, optionalpbjson/serde/serde_jsonbehindjsonfeature. Nodatafusion-common, no datafusion crates beyondproto-common. Mirrordatafusion/proto-common/Cargo.toml.proto/datafusion.proto— moved fromdatafusion/proto/proto/datafusion.proto.gen/src/main.rs+gen/Cargo.toml— moved fromdatafusion/proto/gen/.src/lib.rs— re-exports generated types under aprotobufmodule (same shape asdatafusion-proto-common'sprotobuf_common).src/generated/prost.rs+src/generated/pbjson.rs— moved fromdatafusion/proto/src/generated/.regen.sh— mirrored fromdatafusion/proto-common/regen.sh.
Modified:
Cargo.toml(workspace root) — registerdatafusion/proto-modelsmember + workspace dep entry.datafusion/proto/Cargo.toml— depend ondatafusion-proto-models. Removeprostdirect dep if fully delegated (keep ifdatafusion-protoitself still usesprost::Messagedirectly — it does, forDataEncoderTuple, so keep).datafusion/proto/src/lib.rs—pub use datafusion_proto_models::protobuf::*;(plus keep existingproto_error,FromProtoError,ToProtoErrorre-exports fromproto-common). Maintain the existing publicprotobufmodule so downstream users don't break.datafusion/proto/regen.sh— update to invokeproto-modelsregen.datafusion/proto/gen/— deleted (moved).datafusion/proto/proto/datafusion.proto— deleted (moved).datafusion/proto/src/generated/— deleted (moved).
Invariants: public API surface of datafusion-proto is unchanged. datafusion/ffi, datafusion-examples, benchmarks — the only three external consumers — keep compiling without any Cargo.toml change.
PR 2 — Add PhysicalExpr::to_proto hook + ProtoEncodeCtx
Introduce the trait method. Keep the existing downcast chain in place as a fallback so this PR is non-breaking — no expression migrates yet.
Modified:
datafusion/physical-expr-common/Cargo.toml— add optionaldatafusion-proto-modelsdep +protofeature. Feature is enabled indatafusion/physical-expranddatafusion/physical-planwhen they want serialization (gated through them as well).datafusion/physical-expr-common/src/physical_expr.rs— insidepub trait PhysicalExpr, add:#[cfg(feature = "proto")] fn to_proto( &self, _ctx: &datafusion_proto_models::ProtoEncodeCtx<'_>, ) -> Result<datafusion_proto_models::protobuf::PhysicalExprNode> { internal_err!( "PhysicalExpr `{}` does not implement to_proto; \ implement it or use PhysicalExtensionCodec", std::any::type_name::<Self>(), ) }
New (in datafusion-proto-models or a small sibling crate — TBD during implementation):
ProtoEncodeCtx<'a>— thin context type carrying&'a dyn PhysicalExtensionCodecand a reference to thePhysicalProtoConverterExtensionconverter, exposing helpers:fn encode_child(&self, expr: &Arc<dyn PhysicalExpr>) -> Result<PhysicalExprNode>— recurses through the converter (preserves dedup support).fn encode_udf(&self, udf: &ScalarUDF) -> Result<Vec<u8>>— wrapscodec.try_encode_udf.- Similar
encode_udaf/encode_udwf.
Open question for implementation: ProtoEncodeCtx references PhysicalExtensionCodec, which is defined in datafusion-proto. To avoid a cyclic dep, either:
- (a) Move
PhysicalExtensionCodectrait (or a stripped-downProtoCodecExprsub-trait) intoproto-models, or - (b) Keep
ProtoEncodeCtxindatafusion-protoand havePhysicalExpr::to_prototake&dyn PhysicalExprEncoder(a narrow trait defined inproto-models) that the realProtoEncodeCtximplements.
Leaning toward (b) — cheaper surface change, smaller blast radius on PhysicalExtensionCodec.
Modified:
-
datafusion/proto/src/physical_plan/to_proto.rs::serialize_physical_expr_with_converter— before the downcast chain, tryexpr.to_proto(&ctx). If it returns the default "not implemented" error (detect via sentinel error kind, or via aResult<Option<PhysicalExprNode>>whereOk(None)means "fall through"), fall through to the existing chain. Going withOk(None)is cleaner.Revised trait signature:
fn to_proto(&self, ctx: &dyn PhysicalExprEncoder) -> Result<Option<PhysicalExprNode>> { Ok(None) }
Default = fall through. Explicit
Ok(Some(...))= serialized.Err(...)= genuine error.
Decode path (unchanged structurally): keep parse_physical_expr_with_converter as the central match proto.expr_type in from_proto.rs. Per-expression decoders become associated fns fn try_from_proto(node: &X, ctx: &ProtoDecodeCtx<'_>) -> Result<Arc<Self>> colocated with the type (next PR). The central match stays exhaustive on ExprType, which is a strict improvement over today's runtime downcast chain.
Follow-up for decode side (not blocking): once expressions own both ends, consider formalizing the decoder as a sibling trait so the symmetry with to_proto is explicit — e.g.
// On each concrete PhysicalExpr:
impl BinaryExpr {
fn from_proto(proto: &PhysicalBinaryExprNode, ctx: &ProtoDecodeCtx<'_>) -> Result<Arc<Self>> { ... }
}and the central match in from_proto.rs becomes a dispatch table: ExprType::BinaryExpr(n) => BinaryExpr::from_proto(n, ctx), etc. The key payoff is that no public god-constructor is needed for private state — e.g. DynamicFilterPhysicalExpr::from_proto reads the proto fields and constructs Inner directly, so we can delete from_parts/Inner-as-pub entirely. Doing this after PR 3+ migrations rather than during keeps each PR small.
PR 3+ — Migrate expressions one at a time
One commit per type (or small group). Each migration:
- Implement
fn to_proto(&self, ctx) -> Result<Option<PhysicalExprNode>>on the expression. - Implement
fn try_from_proto(node, ctx) -> Result<Arc<Self>>as an associated fn. - Remove the corresponding
downcast_ref::<T>()arm fromto_proto.rs. - Change the corresponding arm in
from_proto.rs's central match to callType::try_from_proto. - Roll back any "pub for proto" scaffolding that is no longer needed (e.g., eventually the
pub struct Inner+from_parts+inner()bits added in proto: serialize and dedupe dynamic filters v2 #21807 can become private fields, sinceDynamicFilterPhysicalExpr::to_protoreads theRwLockdirectly).
Suggested migration order (easiest → hardest):
Column,UnKnownColumn,Literal,NoOp— trivial fields.NotExpr,IsNullExpr,IsNotNullExpr,NegativeExpr— single child.BinaryExpr(the linearization logic moves too),CastExpr,TryCastExpr,LikeExpr,InListExpr,CaseExpr.ScalarFunctionExpr— involves UDF encoding viactx.encode_udf(...).HashExpr,HashTableLookupExpr— lives indatafusion-physical-plan, so enable theprotofeature there.HashTableLookupExpr's current "replace withlit(true)" hack (to_proto.rs:277) moves into its ownto_protoimpl.DynamicFilterPhysicalExpr— after migration, revert proto: serialize and dedupe dynamic filters v2 #21807'spub Inner/from_partsscaffolding.- Window + aggregate exprs (
PlainAggregateWindowExpr,SlidingAggregateWindowExpr,StandardWindowExpr,WindowUDFExpr,AggregateFunctionExpr) — shaped slightly differently (notPhysicalExprimpls in the same way); may need a sibling trait or stay central.
After all in-workspace expressions migrate, the fallback downcast chain in to_proto.rs shrinks to just HashTableLookupExpr's special case (and eventually goes away entirely).
Critical files
Created:
datafusion/proto-models/Cargo.tomldatafusion/proto-models/proto/datafusion.proto(moved)datafusion/proto-models/gen/{Cargo.toml,src/main.rs}(moved)datafusion/proto-models/src/{lib.rs,generated/*.rs}(moved)datafusion/proto-models/regen.sh
Modified (PR 1):
Cargo.toml(workspace root)datafusion/proto/Cargo.tomldatafusion/proto/src/lib.rsdatafusion/proto/regen.sh
Modified (PR 2):
datafusion/physical-expr-common/Cargo.tomldatafusion/physical-expr-common/src/physical_expr.rs(trait definition, line 163+)datafusion/physical-expr/Cargo.toml(protofeature passthrough)datafusion/physical-plan/Cargo.toml(protofeature passthrough — needed forHashExpr/HashTableLookupExprmigration in PR 3)datafusion/proto/src/physical_plan/to_proto.rs(serialize_physical_expr_with_convertertries trait method first)- New:
PhysicalExprEncodertrait +ProtoEncodeCtx(location TBD; see open question above)
Modified (PRs 3+): each expression's source file + corresponding removal from datafusion/proto/src/physical_plan/{to_proto.rs,from_proto.rs}.
Reused existing code
datafusion-proto-commonorganization (proto/,gen/,src/generated/,regen.sh,src/{from_proto,to_proto}/) — mirror forproto-models. Ref:datafusion/proto-common/.PhysicalExtensionCodec::try_encode_udf/udaf/udwf(datafusion/proto/src/physical_plan/mod.rs:3683–3723) — wrapped byProtoEncodeCtxhelpers, not changed.PhysicalProtoConverterExtension(datafusion/proto/src/physical_plan/mod.rs:3751) — stays as-is; itsphysical_expr_to_protobecomes the entry point that calls into the trait method.DeduplicatingSerializer/DeduplicatingDeserializer(mod.rs:3844+) — unchanged.ProtoEncodeCtx::encode_childroutes through them, preserving dedup.serialize_physical_exprshelper (to_proto.rs:220) — stays, becomes a thin wrapper overto_proto.
Verification
PR 1 (proto-models extraction):
cargo check -p datafusion-proto-models
cargo check -p datafusion-proto
cargo test -p datafusion-proto
cargo test -p datafusion-ffi
cargo check -p datafusion-examples
# Regen to confirm reproducibility:
./datafusion/proto-models/regen.sh && git diff --exit-codeConfirm no pub surface of datafusion-proto changed (cargo public-api or manual diff of src/lib.rs).
PR 2 (trait hook added, no migration):
cargo check -p datafusion-physical-expr-common --features proto
cargo check -p datafusion-physical-expr --features proto
cargo test -p datafusion-proto # all roundtrip tests still pass (fallback path)
cargo test -p datafusion-proto --no-default-features # smoke: still compilesAlso: verify the proto feature is wired into datafusion-proto's dev build so its roundtrip tests go through the trait hook with Ok(None) fallback — behavior identical to today.
PR 3+ (per-expression migration):
For each migrated expression, the existing roundtrip tests in datafusion/proto/tests/cases/roundtrip_physical_plan.rs and roundtrip_physical_expr.rs must continue to pass. Because the central from_proto.rs match stays exhaustive, missing migration arms cause compile errors, not silent regressions.
After DynamicFilterPhysicalExpr migrates: revert the "pub for proto" scaffolding added in #21807 (Inner becomes private, from_parts/inner()/original_children()/remapped_children() removed) and run the existing dynamic-filter roundtrip tests.
End-to-end signal: workspace CI runs ./dev/rust_lint.sh + cargo test --workspace --features proto green; cargo test -p datafusion-proto green on both feature settings.
Risks / open items
PhysicalExprEncodertrait placement (see open question above) — decided during PR 2 implementation. Pre-decision: put it indatafusion-proto-modelssophysical-expr-commonhas one dep underprotofeature.- Window / aggregate expressions are not regular
PhysicalExprimpls and are serialized through a different code path into_proto.rs(lines 117–151). Migrating them may need a parallelWindowExpr::to_protoor can stay central — defer decision to PR 4+. ForeignPhysicalExpr(datafusion-ffi) is aPhysicalExprimpl but not serialized today (not in the downcast chain). The defaultOk(None)behavior preserves that — nothing to do.datafusion-ffidepends ondatafusion-proto— the re-export surface has to stay stable through PR 1. Verified above.- Feature-flag propagation is the main source of mechanical churn. Keep
protooff by default onphysical-expr-common/physical-expr/physical-plan;datafusion-protoflips it on. Developers who runcargo test --workspacewithout features get the no-proto variant; CI should explicitly--features protofor the proto crate's test job.
Activity
adriangb commentedon Apr 24, 2026
@timsaucer I'm curious your thoughts
timsaucer commentedon Apr 25, 2026
From the outset, I don't see any blockers. I do think we'd want to make sure we address the window/aggregates as listed in the open questions. I think we need the plan to make sure the CI builds with this feature enabled so that as expressions are added in the core repo we guarantee they have the method implemented.
adriangb commentedon Apr 29, 2026
Follow-up: Consolidate proto types and remove
FromProto/TryFromProtoIn #21929 the proto-models extraction ran into the orphan rule on a number of
impl From<&protobuf::X> for Yblocks where both sides became foreign todatafusion-proto. We worked around this with a pair of localFromProto<T>/TryFromProto<T>traits in a newdatafusion_proto::convertmodule, mirroringFrom/TryFrom. That gets the PR to compile and preserves wire compatibility, but it's a workaround rather than a clean architecture: callers seeY::from_proto(p)instead ofY::from(p), and the impls live indatafusion-protorather than next to the target types they convert into.This issue tracks the follow-up cleanup that gets us back to standard
From/TryFromeverywhere, with each impl living next to either its target type or its proto type.What blocks doing it in #21929
The blocker is dep-graph cycles, not the orphan rule by itself. Each option that keeps standard
From/TryFromsyntax requires somewhere the impl can live where one of the types is local. The natural homes (the target-type crates with aprotofeature) cycle today:So
datafusion-common[proto] → datafusion-proto-modelsis rejected by cargo becausedatafusion-proto-models → datafusion-proto-common → datafusion-commonalready exists in the other direction.This is the same cycle re-emerging anywhere we'd want a target-type crate to host From/TryFrom impls touching
datafusion-commontypes.The clean architecture
Collapse
datafusion-proto-commonintodatafusion-proto-modelsand push impls down to target-type crates:datafusion-proto-models.protofiles. Arrow conversion impls (which must live here becausearrow::*is foreign andproto-modelsis the only crate where the proto type is local). Optionalpbjson/serde.prost,arrow, no datafusion-* cratesdatafusion-common[proto]From/TryFromimpls between proto types anddatafusion-commontypes (~25 impls currently inproto-common'sfrom_proto.rs/to_proto.rs).proto-modelsdatafusion-expr[proto]WindowFrame,WindowFrameBound,WindowFrameUnits,WriteOp,NullTreatment,JoinConstraint(~6 fromdatafusion-proto'sconvert.rs).proto-modelsdatafusion-datasource[proto]PartitionedFile,FileGroup,FileRange,FileSinkConfig(~4 fromdatafusion-proto).proto-modelsdatafusion-datasource-{csv,json,parquet}[proto]JsonSink,CsvSink,ParquetSinkimpls (3, one each).proto-modelsdatafusion-protoconvert.rs, noFromProto/TryFromPrototraits, noFromimpls.datafusion-proto-commonCycle check after the move:
datafusion-common[proto] → proto-models → {prost, arrow}— proto-models has no datafusion deps, no cycle. ✓datafusion-expr[proto] → proto-models— same, no cycle. ✓datafusion-datasource[proto] → proto-models— same, no cycle. Even with the eventualphysical-expr-common[proto] → proto-modelsedge from Split proto serialization to encapsulate private state (#21835) #21929, there's no path back from proto-models to a datafusion crate. ✓From/TryFrom.End state for callers:
Why it's a separate PR
The work is mechanical but voluminous:
proto-modelsstandalone — drop dep onproto-common, add an in-cratepub mod datafusion_commonto resolvesuper::datafusion_common::Fooreferences in the prost-generated code, regeneratepbjsonfor both.datafusionand.datafusion_commonpackages so the embedded common types get serde impls under thejsonfeature.proto-models— about ~6 impl blocks (Schema, Field, ArrowType, DataType, TimeUnit, IntervalUnit) currently inproto-common/src/from_proto/mod.rsandto_proto/mod.rs. These must live inproto-modelsbecause botharrow::Schemaandproto::Schemaare foreign to every other crate. Addarrowdep.datafusion-commonimpls — the remaining ~25 impls inproto-common's from/to_proto modules go into a newdatafusion-common[proto]module.datafusion-commongains an optionalproto-modelsdep under theprotofeature.datafusion-ffi— currently usesdatafusion_proto_common::from_proto::parse_proto_fields_to_fieldsanddatafusion_proto_common::Field. Migrate to the new locations.datafusion-proto— itsprotobufmodule currently re-exports the explicit listSchema, ScalarValue, ArrowType, ...fromproto-common'sprotobuf_common, plus a wildcard fromproto-models. After consolidation it's justpub use proto_models::generated::datafusion::*(and the equivalent for the embedded common types). All ~280 callsites that go throughproto-common's types vialet s: protobuf::Schema = arrow.try_into()?continue to compile transparently becauseprotobuf::Schemanow resolves to a single type.datafusion-proto/src/convert.rsdeletes; the ~13 impls there relocate todatafusion-expr[proto],datafusion-datasource[proto], and the file-format crates. ~50 callsites indatafusion-protoswitch fromY::from_proto(p)back toY::from(p).proto-common(or stub it as a thin re-export shim and#[deprecated]it).Verified mid-attempt during #21929: the standalone-
proto-modelsstep works (cargo check -p datafusion-proto-models --features jsonpasses), but it surfaces ~280 type mismatches indatafusion-protobecause of two parallelSchematypes (proto-common's andproto-models's embedded copy) — those mismatches go away once the consolidation in step 5 is done. So the steps must land together; you can't do step 1 without doing through step 5 in the same PR.Sequencing in a single follow-up PR
Recommended commit shape (4-6 hours of careful work):
Make datafusion-proto-models standalone— splitdatafusion_commoninto a sibling module, drop proto-common dep, regenerate pbjson with both packages.Move arrow conversion impls into proto-models— extract from proto-common.Add proto feature to datafusion-common, move target-type impls there— extract the rest from proto-common.Migrate datafusion-ffi to the new paths— small, ~5 imports.Use proto-models types throughout datafusion-proto, drop FromProto/TryFromProto— this is where the ~280 mismatches resolve andconvert.rsdeletes.Add proto feature to datafusion-expr/datasource/{csv,json,parquet}, move plan-level impls— relocates the ~13 impls in datafusion-proto.Delete (or stub) datafusion-proto-common— depending on whether we want a back-compat shim.Each commit should keep the workspace compiling. Tests stay green throughout because wire format is preserved.
Out-of-scope notes
PhysicalPlanNodeExttrait introduced in Split proto serialization to encapsulate private state (#21835) #21929 (for inherent-on-foreign-type orphan-rule cases) is unrelated and stays. Its public-API break (use datafusion_proto::physical_plan::PhysicalPlanNodeExt;) is genuinely required.to_protohook onPhysicalExpr(the actual goal of this issue) is delivered in Split proto serialization to encapsulate private state (#21835) #21929 and unaffected by this follow-up.cc #21929
chore(proto): remove transient FromProto/TryFromProto traits
jayshrivastava commentedon Apr 30, 2026
Overall, I support having
from_protoandto_prototo avoid leaking internals.Merging
datafusion-proto-commonintodatafusion-proto/datafusion-proto-modelsmakes sense too. I doubt there's many use cases for importingdatafusion-proto-commonto serialize primitives without importingdatafusion-protofor plan nodes / expressions.adriangb commentedon Apr 30, 2026
I spoke with Andrew about this.
He suggested that maybe instead of implementing
to_proto/from_protowe could implement serde traits.This is also a big change but it might have the upshot of decoupling from protobuf, which could both simplify our crate dependency graph and enable better support for json, etc.
adriangb commentedon May 2, 2026
@alamb having experimented in #21966 the now somewhat obvious issue with a generic Serde trait approach is that it is incompatible with fixed schema serialization formats like Protobuf. E.g. consider one branch in the trait implementation ads a field and another doesn't. Since there is no static schema it is not possible to form a single valid protobuf model even dynamically.
So if we want this for protobuf (the only current offender as far as I'm concerned) I think we need to go with something like #21949 and leave a generic Serde approach for later.
Extract datafusion-proto-models crate
Extract datafusion-proto-models crate
Extract datafusion-proto-models crate
Extract datafusion-proto-models crate
Extract datafusion-proto-models crate
Extract datafusion-proto-models crate
Extract datafusion-proto-models crate
Split proto serialization to encapsulate private state (apache#21835) (…
refactor: port InListExpr to use try_to_proto/try_from_proto hooks (a…
feat(physical-expr-common): add list/required expr helpers to proto ctx
refactor(physical-expr): add proto ctx expr helpers and adopt in InLi…