refactor: consume the substrait-prost crate for core protobuf types - #531
Conversation
Stop vendoring and compiling the core Substrait protobuf files (the `substrait` and `substrait.extensions` packages) in this repository. Instead depend on the canonical pre-generated `substrait-prost` crate and generate the validator's protobuf introspection trait impls (`InputNode`/`ProtoMessage`/`ProtoOneOf`/`ProtoEnum`) for those foreign types from the `FileDescriptorSet` that substrait-prost embeds. The `ProtoMeta` derive cannot be applied to a foreign crate's types, but the introspection traits are local, so the orphan rule permits the impls. A new build-script generator (`prost_meta`) decodes the descriptor and emits exactly what the derive would have produced, reproducing prost's code generation from the descriptor alone -- in particular the Box placement for recursive fields (a singular message field of type T in message M is boxed iff there is a path T -> ... -> M), the Rust module and field naming, and the enum-variant prefix stripping. A wrong decision is a hard compile error in the generated file rather than a silent behavioral change. The validator-specific `substrait.validator` package is not provided by substrait-prost, so it is still compiled locally with the `ProtoMeta` derive. `crate::input::proto::substrait` glob-re-exports the substrait-prost types so the rest of the validator is unchanged. Verified by the existing library tests, new regression tests pinning the descriptor-derived naming, and all 158 conformance test cases.
c9dd839 to
39e96af
Compare
| /// descriptor alone — in particular which singular message fields prost wraps | ||
| /// in `Box` to break recursive cycles, and how prost names Rust modules, | ||
| /// fields, and enum variants. Getting any of these wrong is a hard compile | ||
| /// error in the generated file, never a silent behavioral difference. |
There was a problem hiding this comment.
How did this work before?
/// substrait-prost ships plain
prost-generated types without ourProtoMeta
/// derive,
Is this something we could include in substrait-prost, potentially behind a flag, for use here? The prost_meta stuff below seems a little extreme, and this code wasn't in this repo before so something was already generating it, I think?
There was a problem hiding this comment.
The validator bolts an introspection layer onto every protobuf type at code-generation time: build.rs applies #[derive(ProtoMeta)] to every prost-generated type, producing impls of the local InputNode/ProtoMessage/ProtoOneOf/ProtoEnum traits that drive the traversal/validation engine. A derive can only run where a type is defined, so it cannot be applied to substrait-prost's foreign types.
Could we apply [derive(ProtoMeta)] in substrait-prost?
There was a problem hiding this comment.
I explored some options with my AI helper. The derive macro is tightly coupled with the validator specific proto definitions so can not as cleanly be moved without dragging those validator protos along.
How did this work before? ... something was already generating it, I think?
Yep — the impls were never checked in. build.rs attached #[derive(ProtoMeta)] to the locally-generated prost types via type_attribute, so they were produced at build time. That only works because a derive needs the type to be defined locally; once the types come from substrait-prost they're foreign, so prost_meta reconstructs the same impls from the embedded FileDescriptorSet instead.
Could we apply
#[derive(ProtoMeta)]in substrait-prost?
Two snags:
- It wouldn't let us drop the derive here anyway — the
substrait.validatorpackage isn't in substrait-prost, so we still applyProtoMetato it locally. ProtoMetaemits impls of our traits (InputNode, etc.), and those are tied to the validator engine —parse_unknowntakes&mut context::Contextand the node methods returntree::Node, which between them pull in most ofoutput::*andparse::*. For substrait-prost to host the impls it'd have to depend on the validator core: circular, and a layering inversion for neutral bindings.
The macro crate is already standalone and easy to publish, but publishing the macro isn't the blocker — the generated code needs the traits + their whole dependency cone in scope. Doing this properly means extracting a shared crate (traits + tree/primitive_data/context/...) for substrait-prost to depend on behind a flag. Doable, but a much bigger carve-out than prost_meta, which is why I kept substrait-prost validator-agnostic. Happy to revisit if we think the coupling's worth it.
There was a problem hiding this comment.
I hadn't realized that ProtoMeta was coupled to substrait-validator specific code. That doesn't feel like something we should include in substrait-prost.
I've been poking around this a little bit as well. Parsing the generated prost code at build time to dynamically build more rust code feels way too big 🧠 and gives me the heebie jeebies to be honest..
Poking around the context for how this works, I did see this comment:
substrait-validator/derive/src/lib.rs
Lines 8 to 12 in af55680
which may be a bit out of date. prost-reflect might actually give us to close to enough introspection
I threw some llm compute at this to see if it's feasible: #538 to replicate some/most of what ProtoMeta gives us with just prost-reflect.
There was a problem hiding this comment.
Thanks for the suggestion. My version of Claude agrees with you and your version of Claude 😄 I opened substrait-io/substrait-packaging#35 to add prost-reflect to substrait-prost.
There was a problem hiding this comment.
Thanks for the prost-reflect illustration in #538 — that was the nudge that settled it. I agreed the prost_meta codegen was too much machinery, and your PoC made it concrete that runtime reflection could carry the same weight without reconstructing prost's box/naming decisions.
Two things fell into place to make it real here:
1. The neutral traits now live in substrait-prost. ProtoMeta couldn't move upstream (it's welded to the validator engine), but prost::Name and prost_reflect::ReflectMessage are neutral, standard-ecosystem introspection with zero validator coupling — so they belong in the bindings crate. That shipped in substrait-io/substrait-packaging#35 and is released as substrait-prost 0.87.0-alpha.4 behind a reflect feature. With it, the foreign core types carry Name + ReflectMessage, and the orphan-rule problem disappears.
2. I've rebuilt this PR on that. prost_meta loses the MessageGraph/boxing analysis and per-field emission entirely; unknown-field handling now defers to a single generic parse_proto_message_unknown that walks the descriptor and detects presence via a DynamicMessage. The generator is now thin, uniform trait impls (generated output ~7,750 → ~4,557 lines), and the ProtoMeta derive — still used for the local substrait.validator package — is likewise reduced to reflection-based impls.
One wrinkle worth noting for anyone taking this further: descriptor().fields() enumerates oneof members, but the parse code marks a oneof under its declaration name. Take substrait.Rel: the validator handles it with proto_required_field!(x, y, rel_type, …), marking rel_type as parsed — but Rel's descriptor fields are the members read, filter, project, join, … So a naive reflection walk sees rel_type handled yet every member "unparsed", and flags all of them as unrecognized (a spurious not recognized by the validator warning on essentially every relation). The fix is to collapse each real oneof into a single unit keyed by the oneof name (rel_type) and address it through its Variant path element, matching how the typed traversal marked and rendered it.
Verified locally against substrait spec v0.87.0: cargo build/clippy/fmt clean, library tests pass, and all 158 conformance cases pass. Just pushed it here.
Full dynamic (descriptor-driven) traversal — dropping the generated InputNode impls for the foreign types too — is a sensible next step, but I've kept it out of scope for this PR.
This summary was generated by AI.
There was a problem hiding this comment.
Full dynamic (descriptor-driven) traversal — dropping the generated
InputNodeimpls for the foreign types too — is a sensible next step, but I've kept it out of scope for this PR.
there seems to be an opportunity for a much bigger refactoring which I thought we best do as a follow-up
# Conflicts: # Cargo.lock # rs/build.rs
Drive the validator's protobuf introspection from runtime reflection (`prost::Name` + `prost-reflect`) instead of reconstructing prost's code generation at build time. This bumps the substrait-* crates to 0.87.0-alpha.4 and enables substrait-prost's new `reflect` feature, which gives the foreign core types `prost::Name` + `prost_reflect::ReflectMessage` impls that the validator can consume across the crate boundary. The `prost_meta` build-script generator loses its MessageGraph/boxing analysis and per-field `parse_unknown` emission: unknown-field handling now defers to a single generic `parse_proto_message_unknown`, which walks the message descriptor and detects presence via a `DynamicMessage`. The generator shrinks to thin, uniform trait impls (generated output ~7750 -> ~4557 lines), and the `ProtoMeta` derive (still used for the local `substrait.validator` package) is likewise reduced to reflection-based impls. Details: - Cargo.toml: bump substrait-prost/-extensions/-antlr to 0.87.0-alpha.4; substrait-prost gains the `reflect` feature; add `prost-reflect`. - build.rs: thin `prost_meta`; `enable_type_names()` + embedded descriptor set for the validator package. - traversal.rs: `parse_proto_message_unknown` + `push_unknown_proto_field`; a real oneof is one unit keyed by the oneof name (matching how the parse macros mark it) and addressed through its `Variant` path element. - proto.rs: `field_descriptor_to_node`, `DESCRIPTOR_POOL`, and an `intern` helper that bounds the `&'static str` leak to the finite set of enum names. - tree.rs: `NodeType::ProtoMessage` now holds `String`. Library tests and all 158 conformance cases pass.
The `Doc` CI job runs rustdoc with `-D warnings`, which rejects broken and private intra-doc links: - The `substrait-validator-derive` module doc referenced `crate::input::…`, `prost::Name`, and `prost_reflect::ReflectMessage`, but those live in the consuming crate / aren't dependencies of the standalone macro crate. Made them plain code spans. - `field_descriptor_to_node`'s public doc linked to `parse_proto_message_unknown`, whose module is not part of the public API (private-intra-doc-link). Made the cross-references plain code spans. `cargo doc --no-deps --workspace` with `-D warnings` is now clean.
vbarua
left a comment
There was a problem hiding this comment.
The build.rs changes are a still a bit scary IMO, but they seem to work. I think it makes sense to try this and see how it goes.
substrait-prost, substrait-extensions and substrait-antlr 0.87.0 are now published as non-pre-release versions, so drop the exact pin on substrait-prost's 0.87.0-alpha.4 and move all three to plain 0.87.0 requirements, matching the spec version tracked by the substrait submodule.
clippy 1.97 added useless_borrows_in_formatting to the default set, which flags the redundant `&` on `desc.name` in the duplicate-test-name warning. The reference is dropped by the formatting machinery anyway, so remove it. This is pre-existing on main and unrelated to the substrait-prost migration, but CI pins dtolnay/rust-toolchain@stable and so fails the Lint job on every open pull request; folding the one-liner in here unblocks this one.
|
Rebuilt on the released crates now that `0.87.0` is out: the exact pin on the `0.87.0-alpha.4` pre-release is gone, and `substrait-prost`/`substrait-extensions`/`substrait-antlr` are all plain `0.87.0` — matching the `v0.87.0` spec version pinned by the `substrait` submodule. One unrelated commit rode along: the That is pre-existing on Verified by reproducing CI exactly with clippy 1.97 locally ( |
What
Stop vendoring and compiling the core Substrait protobuf files (the
substraitandsubstrait.extensionspackages) in this repository. Instead depend on the canonical pre-generatedsubstrait-prostcrate (0.87.0) and give the validator's protobuf introspection layer to those foreign types via runtime reflection.This is the prost counterpart to the recent
substrait-antlr(#527) andsubstrait-extensions(#529) migrations toward consuming the shared, version-tagged artifacts published fromsubstrait-packaging.Why it isn't just a dependency swap
The validator bolts an introspection layer onto every protobuf type at code-generation time:
build.rsapplied#[derive(ProtoMeta)]to every prost-generated type, producing impls of the localInputNode/ProtoMessage/ProtoOneOf/ProtoEnumtraits that drive the traversal/validation engine. A derive can only run where a type is defined, so it cannot be applied to substrait-prost's foreign types.The introspection traits are local, however, so the orphan rule permits
impl InputNode for substrait_prost::Plan { … }. The question is where the per-type knowledge behind those impls comes from.How: runtime reflection
An earlier revision of this PR answered that by reconstructing prost's code generation inside
build.rs— decoding the embeddedFileDescriptorSetand re-deriving Rust naming plusBoxplacement for recursive fields via aMessageGraph, mirroringprost-build. Per @vbarua's review (and theprost-reflectPoC in #538), that machinery is gone.The neutral introspection now comes from upstream instead. substrait-io/substrait-packaging#35 added
prost::Name+prost_reflect::ReflectMessageimpls to substrait-prost behind areflectfeature, released in0.87.0.ProtoMetaitself could not move upstream — it emits impls of the validator engine's traits, which would make neutral bindings depend on the validator and invert the layering — butName/ReflectMessageare standard-ecosystem traits with no validator coupling, so they belong in the bindings crate.With those available on the foreign types:
build.rs'sprost_metagenerator emits thin, uniform trait impls — no boxing analysis, no per-field emission.parse_proto_message_unknown, which enumerates fields through the descriptor and detects presence via aDynamicMessage.ProtoMetaderive — still needed for the localsubstrait.validatorpackage — is likewise reduced to reflection-based impls.Generated output dropped from ~7,750 to ~4,557 lines.
One wrinkle worth flagging
descriptor().fields()enumerates oneof members, but the parse code marks a oneof under its declaration name. Forsubstrait.Rel,proto_required_field!(x, y, rel_type, …)marksrel_typeas parsed, whileRel's descriptor fields are the membersread,filter,project,join, … A naive reflection walk therefore seesrel_typehandled yet every member "unparsed", and flags them as unrecognized — a spuriousnot recognized by the validatorwarning on essentially every relation. The fix collapses each real oneof into a single unit keyed by the oneof name and addresses it through itsVariantpath element, matching how the typed traversal marked and rendered it.Scope
substrait.validatorpackage is not provided by substrait-prost, so it is still compiled locally with theProtoMetaderive.crate::input::proto::substraitnow glob-re-exports the substrait-prost types, so the rest of the validator (~330 type references across 32 files) is unchanged.substrait-validator-deriveis retained (used for the validator package).InputNodeimpls for the foreign types too — is a sensible follow-up, deliberately out of scope here.Files
rs/Cargo.toml— addsubstrait-prost(featuresembed-descriptor,reflect) as a normal + build dependency, plusprost-reflect; movesubstrait-extensions/substrait-antlrto0.87.0.rs/build.rs— drop core-proto sync/compilation; compile only thesubstrait.validatorpackage locally; add theprost_metatrait-impl generator.rs/src/input/proto.rs— glob re-export of substrait-prost, include of the generated impls, regression tests.rs/src/parse/traversal.rs— generic reflection-based unknown-field detection.derive/src/lib.rs—ProtoMetareduced to reflection-based impls.Verification
cargo build/clippy/fmtclean across the workspace.substrait.Type.List),URNacronym casing (substrait.extensions.SimpleExtensionURN), enum defaults/variants, and mixed-boxing oneofs.v0.87.0, the version pinned by thesubstraitsubmodule.Notes
substrait-prost,substrait-extensionsandsubstrait-antlrat0.87.0. The earlier exact pin on the0.87.0-alpha.4pre-release has been dropped.🤖 Generated with AI