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

   ### Describe the bug
   
   `FFI_ExecutionPlan::new` (`datafusion/ffi/src/execution_plan.rs:333`) has a 
shortcut to avoid double-wrapping a plan that is a bare echo of something that 
already crossed the boundary:
   
   ```rust
   pub fn new(mut plan: Arc<dyn ExecutionPlan>, runtime: Option<Handle>) -> 
Self {
       if let Some(plan) = plan.downcast_ref::<ForeignExecutionPlan>() {
           return plan.plan.clone();
       }
   ```
   
   `plan.downcast_ref::<T>()` here is the inherent `dyn 
ExecutionPlan::downcast_ref` 
(`datafusion/physical-plan/src/execution_plan.rs:1180`) — the public, 
`downcast_delegate`-aware helper, **not** `Any::downcast_ref`:
   
   ```rust
   pub fn downcast_ref<T: ExecutionPlan>(&self) -> Option<&T> {
       match self.downcast_delegate() {
           Some(delegate) => delegate.downcast_ref::<T>(),
           None => (self as &dyn Any).downcast_ref(),
       }
   }
   ```
   
   `downcast_delegate` exists (added in #22557) so a transparent wrapper can 
redirect *public* downcasts to its inner plan while keeping its own type an 
implementation detail. `datafusion-tracing`'s `InstrumentedExec` opts in 
exactly for that — it is the case #22557 was written for.
   
   The shortcut wants something different: "is `plan`'s *own concrete type* 
literally `ForeignExecutionPlan`, so I can hand back its original FFI struct 
instead of re-wrapping." Because it asks through the delegating helper, any 
wrapper that opts into `downcast_delegate` and happens to wrap a 
`ForeignExecutionPlan` is misclassified as *being* one — and the shortcut 
returns the plan from **before the wrapper was applied**, discarding it.
   
   This is a consumer of the `downcast_delegate` API misusing it at one call 
site, not a problem with the API.
   
   #### Three affected sites, not one
   
   `dyn ExecutionPlan::is<T>()` is delegate-aware too 
(`datafusion/physical-plan/src/execution_plan.rs:1163`):
   
   ```rust
   pub fn is<T: ExecutionPlan>(&self) -> bool {
       match self.downcast_delegate() {
           Some(delegate) => delegate.is::<T>(),
           None => (self as &dyn Any).is::<T>(),
       }
   }
   ```
   
   so `pass_runtime_to_children` makes the same classification twice:
   
   | site | code | effect on a transparent wrapper |
   |---|---|---|
   | `execution_plan.rs:337` | `plan.downcast_ref::<ForeignExecutionPlan>()` | 
wrapper discarded, original foreign plan returned |
   | `execution_plan.rs:291` | `let plan_is_foreign = 
plan.is::<ForeignExecutionPlan>();` | wrapper classified as a foreign *parent* |
   | `execution_plan.rs:310` | `plan_is_foreign && 
!child.is::<ForeignExecutionPlan>()` | wrapper *child* classified as 
already-foreign, so it is not re-wrapped to receive the runtime |
   
   Only the first has a confirmed user-visible fault (below). The other two are 
the same confusion and should at minimum be audited as part of the fix — each 
wants "is this literally a `ForeignExecutionPlan`", which is an FFI-internal 
identity question that the public, delegate-aware API is the wrong tool for.
   
   ### To Reproduce
   
   Verified against `datafusion-ffi` 55.1.0 by instrumenting local patched 
copies of `datafusion-ffi`, `datafusion-physical-plan` and `datafusion-tracing` 
with `eprintln!` at the relevant call sites, and running a two-node plan 
(`ProjectionExec` over `DataSourceExec`) through `datafusion-tracing`'s 
`instrument_with_info_spans!` rule, installed via `datafusion-python`'s 
`SessionContext.add_physical_optimizer_rule`.
   
   Direct evidence at the shortcut itself — a single call, two facts printed 
side by side:
   
   ```text
   plan.downcast_ref::<ForeignExecutionPlan>().is_some() == true
   (plan as &dyn Any).type_id()            == 
TypeId(0x36d0e1a1b79082b531906aa6608a769e)  // InstrumentedExec
   TypeId::of::<ForeignExecutionPlan>()    == 
TypeId(0x585a73a9c18b03d9190859e857a74dfa)  // different!
   ```
   
   `Any`'s real type identity says `InstrumentedExec`; the delegating 
`downcast_ref` used by the shortcut says `ForeignExecutionPlan` anyway, and the 
shortcut fires.
   
   Consequence: `ctx.add_physical_optimizer_rule(<datafusion-tracing's rule, 
via FFI>)` followed by `ctx.sql(...).explain()` shows the completely 
unmodified, un-instrumented plan — no `InstrumentedExec`, no 
`FFI_ExecutionPlan:` wrapper, nothing — and the query executes with zero spans 
created.
   
   Wrapping the rule's output in one additional real, 
non-`downcast_delegate`-opted-in node (e.g. `GlobalLimitExec::new(instrumented, 
0, None)`, a no-op) works around it: `explain()` then shows `FFI_ExecutionPlan: 
GlobalLimitExec` wrapping the correctly-transparent instrumented tree, and 
spans are created and exported normally.
   
   **Suggested in-tree repro**, following #24722's `import_as_foreign!` 
pattern: build a native plan, force it foreign via 
`crate::mock_foreign_marker_id`, convert it to a `ForeignExecutionPlan` via 
`TryFrom<&FFI_ExecutionPlan>`, wrap *that* in a minimal test type whose 
`downcast_delegate` returns the inner plan, and assert that 
`FFI_ExecutionPlan::new` on the wrapper does **not** return `plan.plan.clone()`.
   
   ### Expected behavior
   
   A transparent wrapper `ExecutionPlan` — any type implementing 
`downcast_delegate`, not only `datafusion-tracing`'s — survives being handed 
back across the boundary, instead of being silently unwound to the plan from 
before it was applied.
   
   ### Proposed fix
   
   The shortcut needs FFI-internal identity, which is exactly what raw `Any` 
gives:
   
   ```rust
   if let Some(plan) = (plan.as_ref() as &dyn 
std::any::Any).downcast_ref::<ForeignExecutionPlan>() {
       return plan.plan.clone();
   }
   ```
   
   One line, backward compatible — it only *narrows* when the shortcut fires — 
independent of everything else, and needs no ABI bump. Apply the same treatment 
to the two `is::<ForeignExecutionPlan>()` sites in `pass_runtime_to_children` 
after confirming the intended semantics at each.
   
   Worth auditing the other `FFI_*::new`-style "already foreign" fast paths 
while this is fresh — see #24722, the same *family* of bug (an "already 
foreign, take the shortcut" fast path misbehaving) for 
`FFI_LogicalExtensionCodec`, `FFI_PhysicalExtensionCodec` and 
`FFI_TableProvider`, though those discard constructor *arguments* rather than a 
wrapper node.
   
   ### Related issues
   
   * #25155 — physical optimizer rules cross the FFI boundary as handles 
instead of bytes. Related; split from the same original report.
   * #22557 — added `downcast_delegate`, precisely for the 
`datafusion-tracing`/`InstrumentedExec` case this breaks.
   * #24722 (closed) — same family: an "already foreign, take the shortcut" 
fast path misbehaving in an FFI constructor, for 
`FFI_LogicalExtensionCodec`/`FFI_PhysicalExtensionCodec`/`FFI_TableProvider`.
   * #17374 — Stabilize FFI Boundary.
   
   Part of umbrella #25152.
   
   Downstream tracking: apache/datafusion-python#1739 (found via 
`add_physical_optimizer_rule` trying to install 
`datafusion-contrib/datafusion-tracing`'s instrumentation rule from Python).
   


-- 
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