Skip to content

Commit 5a4d2e6

Browse files
committed
docs(example): quarantine foreign plan workaround
Move the temporary execution-plan token registry into a deliberately named module and document its process-local behavior and removal criteria. The rationale that #1720 added to the try_encode comment (why the ForeignExecutionPlan arm is load-bearing, what it costs, and what retires it) now lives in the module docs, and the codec points there instead. Signed-off-by: Akash Kumar <116457960+akashchamp@users.noreply.github.com>
1 parent 516d20d commit 5a4d2e6

3 files changed

Lines changed: 130 additions & 70 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
//! # NOT A PATTERN
19+
//!
20+
//! **Blocked on:** <https://github.com/apache/datafusion/issues/25152>
21+
//!
22+
//! **Delete when:** `FFI_PlanProperties` carries `scheduling_type`, or
23+
//! `ForeignExecutionPlan` gains a reachable `try_to_proto`. Fixing either one
24+
//! retires this module.
25+
//!
26+
//! **Copying this will:** claim every other library's plan nodes, and produce
27+
//! payloads that decode only in the writing process, exactly once each.
28+
//!
29+
//! `DataSourceExec` is this library's own node. The `ForeignExecutionPlan`
30+
//! arm is a workaround, and it is load-bearing: a host physical optimizer
31+
//! rule that runs during a foreign planner's `create_physical_plan` --
32+
//! `EnsureCooperative` always does -- hands the library back a
33+
//! `ForeignExecutionPlan` wrapping the host's `CooperativeExec`. That type has
34+
//! no reachable `try_to_proto`, so nothing can encode it natively and
35+
//! `FFI_QueryPlanner` must serialize the plan it returns. Claiming the opaque
36+
//! wrapper here and parking it in a process-local registry, rather than
37+
//! serializing it, is what lets those plans round-trip at all.
38+
//!
39+
//! The cost is that this codec also claims every *other* library's nodes,
40+
//! since that is the type any node arrives as once it has crossed the
41+
//! boundary -- see `extension_codec_order`. Narrowing [`claims`] to
42+
//! `DataSourceExec` alone makes 31 tests in
43+
//! `datafusion-ffi-query-planner-example` fail because the wrapped plan can
44+
//! no longer be encoded.
45+
//!
46+
//! A library whose planner controls its own physical optimizer rules never
47+
//! sees a foreign node and needs no such arm.
48+
//!
49+
//! Both halves are upstream defects, tracked together in the issue above:
50+
//! `FFI_PlanProperties` carries no `scheduling_type`, so `EnsureCooperative`
51+
//! reads every foreign leaf as non-cooperative and wraps it, and the resulting
52+
//! `ForeignExecutionPlan` then has no way to serialize itself.
53+
//!
54+
//! The `DataSourceExec` arm could use durable metadata and does not, because
55+
//! the registry must exist for the `ForeignExecutionPlan` arm regardless.
56+
//! Splitting the two arms across wire formats costs real code and removes
57+
//! nothing; this module keeps the temporary compromise obvious.
58+
//!
59+
//! The registry is the execution-plan counterpart of the logical codec's
60+
//! provider registry, with the same lifecycle: encoding inserts, decoding
61+
//! removes, so a decode consumes its token and an encode that is never decoded
62+
//! leaks. See [`crate::logical_extension_codec`] for why that is acceptable
63+
//! here and not in a real codec.
64+
65+
use std::collections::HashMap;
66+
use std::sync::atomic::{AtomicU64, Ordering};
67+
use std::sync::{Arc, Mutex, OnceLock};
68+
69+
use datafusion::common::{DataFusionError, Result};
70+
use datafusion::datasource::source::DataSourceExec;
71+
use datafusion::physical_plan::ExecutionPlan;
72+
use datafusion_ffi::execution_plan::ForeignExecutionPlan;
73+
74+
const EXECUTION_PLAN_TOKEN: &[u8] = b"DFPYEXEP";
75+
static NEXT_EXECUTION_PLAN_ID: AtomicU64 = AtomicU64::new(1);
76+
static EXECUTION_PLANS: OnceLock<Mutex<HashMap<u64, Arc<dyn ExecutionPlan>>>> = OnceLock::new();
77+
78+
fn execution_plans() -> &'static Mutex<HashMap<u64, Arc<dyn ExecutionPlan>>> {
79+
EXECUTION_PLANS.get_or_init(|| Mutex::new(HashMap::new()))
80+
}
81+
82+
fn token_id(buf: &[u8]) -> Option<u64> {
83+
let id: [u8; 8] = buf.strip_prefix(EXECUTION_PLAN_TOKEN)?.try_into().ok()?;
84+
Some(u64::from_le_bytes(id))
85+
}
86+
87+
pub(crate) fn claims(node: &Arc<dyn ExecutionPlan>) -> bool {
88+
node.is::<DataSourceExec>() || node.is::<ForeignExecutionPlan>()
89+
}
90+
91+
pub(crate) fn park(node: Arc<dyn ExecutionPlan>, buf: &mut Vec<u8>) -> Result<()> {
92+
let id = NEXT_EXECUTION_PLAN_ID.fetch_add(1, Ordering::SeqCst);
93+
execution_plans()
94+
.lock()
95+
.map_err(|err| DataFusionError::Internal(err.to_string()))?
96+
.insert(id, node);
97+
buf.extend_from_slice(EXECUTION_PLAN_TOKEN);
98+
buf.extend_from_slice(&id.to_le_bytes());
99+
Ok(())
100+
}
101+
102+
pub(crate) fn take(buf: &[u8]) -> Result<Option<Arc<dyn ExecutionPlan>>> {
103+
let Some(id) = token_id(buf) else {
104+
return Ok(None);
105+
};
106+
let plan = execution_plans()
107+
.lock()
108+
.map_err(|err| DataFusionError::Internal(err.to_string()))?
109+
.remove(&id)
110+
.ok_or_else(|| {
111+
DataFusionError::Internal(format!(
112+
"Unknown datafusion-ffi-example execution plan token {id}"
113+
))
114+
})?;
115+
Ok(Some(plan))
116+
}

examples/datafusion-ffi-example/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ use crate::window_udf::MyRankUDF;
3333
pub(crate) mod aggregate_udf;
3434
pub(crate) mod catalog_provider;
3535
pub(crate) mod config;
36+
pub(crate) mod foreign_plan_workaround;
3637
pub(crate) mod logical_extension_codec;
3738
pub(crate) mod name_only_codec;
3839
pub(crate) mod physical_extension_codec;

examples/datafusion-ffi-example/src/physical_extension_codec.rs

Lines changed: 13 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,14 @@
1515
// specific language governing permissions and limitations
1616
// under the License.
1717

18-
use std::collections::HashMap;
1918
use std::fmt;
20-
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
21-
use std::sync::{Arc, Mutex, OnceLock};
19+
use std::sync::Arc;
20+
use std::sync::atomic::{AtomicUsize, Ordering};
2221

23-
use datafusion::common::{DataFusionError, Result};
24-
use datafusion::datasource::source::DataSourceExec;
22+
use datafusion::common::Result;
2523
use datafusion::execution::TaskContext;
2624
use datafusion::logical_expr::ScalarUDF;
2725
use datafusion::physical_plan::ExecutionPlan;
28-
use datafusion_ffi::execution_plan::ForeignExecutionPlan;
2926
use datafusion_ffi::proto::physical_extension_codec::FFI_PhysicalExtensionCodec;
3027
use datafusion_proto::physical_plan::{
3128
DefaultPhysicalExtensionCodec, PhysicalExtensionCodec, PhysicalProtoConverterExtension,
@@ -34,26 +31,9 @@ use datafusion_python_util::{ffi_task_context_provider_from_pycapsule, get_tokio
3431
use pyo3::prelude::*;
3532
use pyo3::types::PyCapsule;
3633

34+
use crate::foreign_plan_workaround;
3735
use crate::required_udf::{TaskContextProbe, resolve_required_udf};
3836

39-
const EXECUTION_PLAN_TOKEN: &[u8] = b"DFPYEXEP";
40-
static NEXT_EXECUTION_PLAN_ID: AtomicU64 = AtomicU64::new(1);
41-
static EXECUTION_PLANS: OnceLock<Mutex<HashMap<u64, Arc<dyn ExecutionPlan>>>> = OnceLock::new();
42-
43-
/// Execution-plan counterpart of the logical codec's provider registry, with
44-
/// the same lifecycle: encoding inserts, decoding removes, so a decode
45-
/// consumes its token and an encode that is never decoded leaks. See
46-
/// [`crate::logical_extension_codec`] for why that is acceptable here and not
47-
/// in a real codec.
48-
fn execution_plans() -> &'static Mutex<HashMap<u64, Arc<dyn ExecutionPlan>>> {
49-
EXECUTION_PLANS.get_or_init(|| Mutex::new(HashMap::new()))
50-
}
51-
52-
fn token_id(buf: &[u8]) -> Option<u64> {
53-
let id: [u8; 8] = buf.strip_prefix(EXECUTION_PLAN_TOKEN)?.try_into().ok()?;
54-
Some(u64::from_le_bytes(id))
55-
}
56-
5737
#[derive(Debug, Default)]
5838
pub(crate) struct PhysicalCallCounters {
5939
pub encode_udf: AtomicUsize,
@@ -69,9 +49,9 @@ pub(crate) struct PhysicalCallCounters {
6949
/// owning cdylib can restore their concrete Rust type after the plan travels
7050
/// through the independent query-planner and datafusion-python libraries.
7151
///
72-
/// See [`execution_plans`] for the token lifecycle, which is narrower than it
73-
/// looks: a decode consumes its token, so the same encoded plan cannot be
74-
/// decoded twice.
52+
/// See [`crate::foreign_plan_workaround`] for the token lifecycle, which is
53+
/// narrower than it looks: a decode consumes its token, so the same encoded
54+
/// plan cannot be decoded twice.
7555
struct CountingPhysicalExtensionCodec {
7656
inner: DefaultPhysicalExtensionCodec,
7757
counters: Arc<PhysicalCallCounters>,
@@ -99,19 +79,11 @@ impl PhysicalExtensionCodec for CountingPhysicalExtensionCodec {
9979
proto_converter: &dyn PhysicalProtoConverterExtension,
10080
) -> Result<Arc<dyn ExecutionPlan>> {
10181
resolve_required_udf(self.required_udf.as_deref(), ctx, &self.counters.task_ctx)?;
102-
if let Some(id) = token_id(buf) {
82+
if let Some(plan) = foreign_plan_workaround::take(buf)? {
10383
self.counters
10484
.decode_execution_plan
10585
.fetch_add(1, Ordering::SeqCst);
106-
return execution_plans()
107-
.lock()
108-
.map_err(|err| DataFusionError::Internal(err.to_string()))?
109-
.remove(&id)
110-
.ok_or_else(|| {
111-
DataFusionError::Internal(format!(
112-
"Unknown datafusion-ffi-example execution plan token {id}"
113-
))
114-
});
86+
return Ok(plan);
11587
}
11688
self.inner.try_decode(buf, inputs, ctx, proto_converter)
11789
}
@@ -122,42 +94,13 @@ impl PhysicalExtensionCodec for CountingPhysicalExtensionCodec {
12294
buf: &mut Vec<u8>,
12395
proto_converter: &dyn PhysicalProtoConverterExtension,
12496
) -> Result<()> {
125-
// `DataSourceExec` is this library's own node. The `ForeignExecutionPlan`
126-
// arm is a workaround, not a pattern to copy, and it is load-bearing:
127-
// a host physical optimizer rule that runs during a foreign planner's
128-
// `create_physical_plan` -- `EnsureCooperative` always does -- hands the
129-
// library back a `ForeignExecutionPlan` wrapping the host's
130-
// `CooperativeExec`. That type has no reachable `try_to_proto`, so
131-
// nothing can encode it natively and `FFI_QueryPlanner` must serialize
132-
// the plan it returns. Claiming it here is what lets those plans
133-
// round-trip at all.
134-
//
135-
// The cost is that this codec also claims every *other* library's
136-
// nodes, since that is the type any node arrives as once it has crossed
137-
// the boundary -- see `extension_codec_order`. Narrowing this to
138-
// `DataSourceExec` alone makes 31 tests in
139-
// `datafusion-ffi-query-planner-example` fail with the error above.
140-
//
141-
// A library whose planner controls its own physical optimizer rules
142-
// never sees a foreign node and needs no such arm.
143-
//
144-
// Both halves are upstream defects, tracked together in
145-
// https://github.com/apache/datafusion/issues/25152: `FFI_PlanProperties`
146-
// carries no `scheduling_type`, so `EnsureCooperative` reads every
147-
// foreign leaf as non-cooperative and wraps it, and the resulting
148-
// `ForeignExecutionPlan` then has no way to serialize itself. Fixing
149-
// either one retires this arm.
150-
if node.is::<DataSourceExec>() || node.is::<ForeignExecutionPlan>() {
97+
// See [`crate::foreign_plan_workaround`] for why the
98+
// `ForeignExecutionPlan` arm exists and what retires it.
99+
if foreign_plan_workaround::claims(&node) {
151100
self.counters
152101
.encode_execution_plan
153102
.fetch_add(1, Ordering::SeqCst);
154-
let id = NEXT_EXECUTION_PLAN_ID.fetch_add(1, Ordering::SeqCst);
155-
execution_plans()
156-
.lock()
157-
.map_err(|err| DataFusionError::Internal(err.to_string()))?
158-
.insert(id, node);
159-
buf.extend_from_slice(EXECUTION_PLAN_TOKEN);
160-
buf.extend_from_slice(&id.to_le_bytes());
103+
foreign_plan_workaround::park(node, buf)?;
161104
return Ok(());
162105
}
163106
self.inner.try_encode(node, buf, proto_converter)

0 commit comments

Comments
 (0)