timsaucer opened a new issue, #25155:
URL: https://github.com/apache/datafusion/issues/25155

   ### Describe the bug
   
   A `QueryPlanner` provided over FFI cannot return a plan containing any node 
that a **host**
   physical optimizer rule inserted, even when that node is a stock DataFusion 
node with full
   protobuf support.
   
   Three facts combine:
   
   1. `FFI_QueryPlanner` returns its plan as protobuf, not as a handle —
      `physical_plan_to_bytes_with_extension_codec` on the library side
      (`datafusion/ffi/src/query_planner.rs:168`) and
      `physical_plan_from_bytes_with_extension_codec` on the host side 
(`:314`). So **every query**
      through a foreign planner serializes the plan.
   
   2. Physical planning applies `session.physical_optimizers()`. When the 
session arrived over FFI
      those are the host's rules (`datafusion/ffi/src/session/mod.rs:765`), so 
each runs back across
      the boundary and the result returns wrapped in a `ForeignExecutionPlan`
      (`ForeignPhysicalOptimizerRule::optimize`, 
`datafusion/ffi/src/physical_optimizer.rs:312-323`).
      `EnsureCooperative` is in the default rule list
      (`datafusion/physical-optimizer/src/optimizer.rs:177`), so this happens 
on essentially every
      plan.
   
   3. `ForeignExecutionPlan` implements neither `try_to_proto`
      (`datafusion/physical-plan/src/execution_plan.rs:1025`) nor 
`downcast_delegate` (`:146`) —
      zero matches for either in `datafusion/ffi/src/execution_plan.rs`. So
      `try_from_physical_plan_with_converter`
      (`datafusion/proto/src/physical_plan/mod.rs:1324`) skips the native path 
entirely and falls
      into the extension-codec arm, which fails at `:1382`.
   
   The result is that `CooperativeExec`, which has a perfectly good 
`try_to_proto`
   (`datafusion/physical-plan/src/coop.rs:400`), becomes unserializable purely 
by having crossed an
   FFI boundary.
   
   **There is a second, larger consequence.** A `ForeignExecutionPlan` is 
opaque to `downcast_ref`,
   so the host rules that cross the boundary mostly cannot act at all. The 
stock rules are
   downcast-driven — `enforce_distribution.rs` has 20 `downcast_ref` sites, 
`sort_pushdown.rs` 15,
   `window_topn.rs` 12 — and against a tree of foreign nodes they match nothing 
and silently skip.
   The rules that *do* fire are the property-driven ones, and those read 
properties that the FFI
   bridge fills with wrong defaults (filed separately; see umbrella #25152). So 
the FFI planner
   path today gets close to zero real optimization, and the one rule that fires 
does the wrong thing
   and then breaks serialization.
   
   ### To Reproduce
   
   Observed through `datafusion-python`, whose 
`SessionContext.set_query_planner` installs an
   `FFI_QueryPlanner`. With a foreign planner installed and no extension codec, 
planning any query
   over a Parquet table fails.
   
   Reproduced in-tree by patching the test planner to apply the session's rules 
— which is what any
   library planner built on `DefaultPhysicalPlanner` does — at
   `datafusion/ffi/src/tests/query_planner.rs:92`, replacing the bare
   `Ok(Arc::new(EmptyExec::new(schema)))`:
   
   ```rust
   let mut plan: Arc<dyn ExecutionPlan> = Arc::new(EmptyExec::new(schema));
   let config = session.config().options();
   for rule in session.physical_optimizers() {
       plan = rule.optimize(plan, config)?;
   }
   Ok(plan)
   ```
   
   Then
   `cargo test -p datafusion-ffi --features integration-tests --test 
ffi_query_planner test_ffi_query_planner`:
   
   ```text
   Error: Ffi("Internal error: Unsupported plan and extension codec failed with
     [FFI error: This feature is not implemented: PhysicalExtensionCodec is not 
provided].
     Plan: ForeignExecutionPlan { name: \"CooperativeExec\", ...,
           children: [EmptyExec { ... }] }")
   ```
   
   Note the child is concrete — only the host-inserted wrapper is opaque.
   
   Defect (3) alone can be shown with no dylib changes, using the existing 
`AddLimitRule`
   (`datafusion/ffi/src/tests/physical_optimizer.rs:31`), which inserts a stock 
`GlobalLimitExec`
   across the boundary. Apply that foreign rule to a natively serializable leaf 
and the result
   reports `name() == "GlobalLimitExec"`, is not a `GlobalLimitExec`, and fails
   `physical_plan_to_bytes_with_extension_codec` — while the identical plan 
shape built locally
   serializes without a codec.
   
   ### Expected behavior
   
   A foreign planner should be able to return a plan containing stock nodes, 
and host rules should
   be able to act on the plans they are handed.
   
   ### Additional context
   
   **The root cause is not "no codec was provided."** A codec path for foreign 
plans does exist:
   `ForeignPhysicalExtensionCodec::try_encode`
   (`datafusion/ffi/src/proto/physical_extension_codec.rs:377-383`) unwraps the 
handle back to its
   home image via `FFI_ExecutionPlan::new` 
(`datafusion/ffi/src/execution_plan.rs:338`) and the peer
   encodes it. That works for *extension* nodes. It cannot work for *stock* 
nodes, because the
   bridge only ever reaches the peer's `PhysicalExtensionCodec`, never the 
peer's native proto
   encoder — and no extension codec is contractually expected to encode a 
built-in node. Supplying
   `DefaultPhysicalExtensionCodec` fails identically.
   
   **This is the `ExecutionPlan`-layer counterpart of #22367.** That issue 
documents the same root
   cause one layer down: `FFI_PhysicalExpr` carries behavior across the 
boundary but not identity, so
   `as_any().downcast_ref::<T>()` mis-classifies every foreign-wrapped 
expression. The symptoms
   differ — #22367 reports silent predicate corruption in the simplifier, this 
issue reports a hard
   serialization failure plus inert optimizer rules — but the fix shapes should 
be considered
   together.
   
   **Two of the three fixes suggested in the original report do not work.**
   
   - *Report `downcast_delegate`*: not implementable. It returns `Option<&dyn 
ExecutionPlan>`;
     `ForeignExecutionPlan` holds only an opaque `FFI_ExecutionPlan`, so there 
is no local trait
     object to borrow. Even given one, `TypeId` is not stable across dylib 
images, so a cross-image
     downcast would be unsound.
   - *Re-attempt the native path after unwrapping the handle*: collapses into 
the `try_to_proto`
     option. Unwrapping inside the library yields a plan whose concrete type 
that image cannot name;
     the encoding has to happen on the peer side either way.
   
   **Directions worth discussing** (both need ABI additions; `datafusion-ffi` 
gates compatibility on
   the crate major version at `datafusion/ffi/src/lib.rs:64`, which bumps each 
DataFusion major, so
   they should be batched into one bump):
   
   1. **Substitute local instances for built-in rules.** Have 
`ForeignSession::physical_optimizers()`
      (`datafusion/ffi/src/session/mod.rs:765`) return a *local* instance for 
each host rule it
      recognises as built-in, preserving the host's list and order, and wrap 
only unrecognised rules.
      Stock rules would then run in the library image on library-local nodes: 
fully downcast-capable,
      producing no foreign node, at no serialization cost. Recognition should 
use an identifier set
      on `FFI_PhysicalOptimizerRule` at wrap time, where the host knows the 
concrete type — matching
      on `rule.name()` would silently mistake a customised rule for the stock 
one, which is the same
      objection #22367 raises against `name()`-based dispatch at the plan layer.
   
      This is structurally the rule-list analogue of the **tiered 
reconstruction** proposal in
      #22367: known built-ins are rebuilt as consumer-local instances, unknown 
third-party items stay
      opaque behind the existing vtable. If that model is adopted for 
`PhysicalExpr`, applying the
      same tiering here would be consistent rather than a second, competing 
mechanism.
   
   2. **A `try_to_proto` backstop for genuinely custom host rules.** Add an 
`FFI_ExecutionPlan`
      vtable entry meaning "serialize your subtree with this codec, return 
bytes", and have
      `ForeignExecutionPlan::try_to_proto` call it and prost-decode the result 
into a
      `PhysicalPlanNode`. Recursion terminates because each hop unwraps to a 
node native to that
      image.
   
   **Considered and set aside**, recorded so they are not re-proposed:
   
   - *Serialize at the optimizer boundary*, per rule or batched at the session. 
Costs roughly 3×
     the serialization passes per query, dominated by `repeated FileGroup 
file_groups`
     (`datafusion/proto-models/proto/datafusion.proto:1249`), so the cost 
scales with **file count**
     rather than plan size. Collapses per-rule error attribution and the 
`observer` hook
     (`datafusion/core/src/physical_planner.rs:2963-2980`), and loses rule 
interleaving, which the
     distributed-planner use case described in the module docs
     (`datafusion/ffi/src/query_planner.rs:20-27`) needs. It also cannot 
deliver the last default
     rule's output at all until the dynamic-filter converter issue is fixed.
   - *Serialize any built-in `FFI_ExecutionPlan`.* Requires threading a codec 
through every
     `FFI_ExecutionPlan::new` call site and degenerates into mixed bytes/handle 
trees.
   - *Library-owned default rules* — the `apache/datafusion-python#1721` 
workaround, which wraps the
     session so `physical_optimizers()` returns 
`PhysicalOptimizer::default().rules`. It works, but
     it silently discards any custom host rule, and the in-tree test asserts 
that rules *do* cross
     (`datafusion/ffi/src/tests/query_planner.rs:89`).
   
   **This is not misuse.** Rules crossing the boundary is intended behaviour, 
per that same test.
   Both three-library tests currently avoid the problem by clearing the rule 
list
   (`datafusion/ffi/tests/ffi_query_planner.rs:195,266`); un-clearing them is a 
reasonable
   acceptance criterion for a fix.
   
   **Related issues.** #22367 (same root cause at the `PhysicalExpr` layer; 
tiered-reconstruction
   proposal). #22329 (`FFI_ExecutionPlan` missing optimizer-relevant methods — 
a different set of
   gaps in the same struct, and a reason foreign nodes are poor optimizer 
subjects even once they
   serialize). #24762 and #24106 (codec plumbing at the same planner boundary). 
#17374 (Stabilize
   FFI Boundary).
   
   Part of umbrella #25152 covering the FFI planner boundary.
   
   Downstream tracking: apache/datafusion-python#1719 (G1).
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to