Skip to content

Commit ca3264e

Browse files
timsaucerclaude
andcommitted
fix: resolve a planner fallback when it is installed, not constructed
MyQueryPlanner::new imported its fallback immediately, with no session to pass, so the fallback's getter was called with no arguments. That works for a SessionContext, whose getter takes the session optionally, and for a raw capsule, which has no getter at all. It fails for another foreign planner, which implements the same protocol this type does and requires the argument -- and layering on another planner is the case a distributed engine actually needs. The docstring claimed fallback "takes anything exporting __datafusion_query_planner__", which was not true. Holds the Python object instead and imports it in __datafusion_query_planner__, where the session is in hand and can be forwarded. All three fallback kinds now work. Deferring also removes a footgun rather than adding one. Passing a SessionContext now delegates to whichever planner it holds at install time, and since with_query_planner calls the getter before installing, the context still reports its previous planner, so wrapping a context in a planner installed on that same context does not recurse. Arc<Py<PyAny>> rather than Py<PyAny> because pyo3 0.29 gates Py: Clone behind the py-clone feature, and this type derives Clone. Matches how PythonTableFunctionCallable holds its callable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5448e8d commit ca3264e

2 files changed

Lines changed: 76 additions & 16 deletions

File tree

examples/datafusion-ffi-query-planner-example/python/tests/_test_three_library_query_planner.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,46 @@ def test_planner_layers_on_the_session_planner():
358358
assert physical_codec.execution_plan_decode_calls() > 0
359359

360360

361+
def test_a_planner_can_fall_back_to_another_planner_library():
362+
"""A fallback may be another foreign planner, not only a session.
363+
364+
The fallback is imported when this planner is installed rather than when
365+
it is constructed, so its own getter receives the session. Importing it at
366+
construction time would mean calling that getter with no session, which
367+
only a ``SessionContext`` or a raw capsule tolerates -- and layering on
368+
another planner is the case a distributed engine actually needs.
369+
"""
370+
ctx, logical_codec, physical_codec = configured_context(max_rows=3)
371+
inner = MyQueryPlanner()
372+
outer = MyQueryPlanner(fallback=inner)
373+
ctx = ctx.with_query_planner(outer)
374+
375+
batches = ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
376+
assert batches[0].column(0).to_pylist() == [0, 1, 2]
377+
assert outer.plan_calls() > 0
378+
assert outer.used_fallback()
379+
# The delegation reached the inner planner rather than stopping at the
380+
# default physical planner.
381+
assert inner.plan_calls() > 0
382+
assert logical_codec.table_provider_decode_calls() > 0
383+
assert physical_codec.execution_plan_decode_calls() > 0
384+
385+
386+
def test_a_session_fallback_delegates_to_its_installed_planner():
387+
"""Passing a SessionContext delegates to whatever planner it holds."""
388+
ctx, _logical_codec, _physical_codec = configured_context(max_rows=3)
389+
first = MyQueryPlanner()
390+
ctx = ctx.with_query_planner(first)
391+
392+
second = MyQueryPlanner(fallback=ctx)
393+
ctx = ctx.with_query_planner(second)
394+
395+
batches = ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
396+
assert batches[0].column(0).to_pylist() == [0, 1, 2]
397+
assert second.used_fallback()
398+
assert first.plan_calls() > 0
399+
400+
361401
def test_second_planner_replaces_the_first():
362402
"""A session holds exactly one planner, so installing another replaces it."""
363403
ctx, _logical_codec, _physical_codec = configured_context(max_rows=2)

examples/datafusion-ffi-query-planner-example/src/planner.rs

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -203,30 +203,38 @@ impl QueryPlanner for DistributedQueryPlanner {
203203
#[derive(Debug, Default, Clone)]
204204
pub(crate) struct MyQueryPlanner {
205205
observations: Arc<PlannerObservations>,
206-
fallback: Option<Arc<dyn QueryPlanner + Send + Sync>>,
206+
/// Held as the Python object rather than an imported planner, and resolved
207+
/// in `__datafusion_query_planner__` where a session is in hand.
208+
///
209+
/// Importing it here would mean calling its getter with no session, which
210+
/// only a `SessionContext` or a raw capsule accepts. Another foreign
211+
/// planner -- the case that matters, since layering is the whole point of
212+
/// a fallback -- implements the same protocol this type does and requires
213+
/// the argument.
214+
fallback: Option<Arc<Py<PyAny>>>,
207215
}
208216

209217
#[pymethods]
210218
impl MyQueryPlanner {
211219
/// Build a planner, optionally layered on top of an existing one.
212220
///
213-
/// `fallback` takes anything exporting `__datafusion_query_planner__`,
214-
/// including a `SessionContext`. Capture it *before* installing this
215-
/// planner on that context, or the capsule will describe this planner and
216-
/// planning will recurse.
221+
/// `fallback` takes anything exporting `__datafusion_query_planner__`:
222+
/// another planner library, a `SessionContext`, or a raw capsule. It is
223+
/// imported when this planner is installed, not here, so that the session
224+
/// can be handed to its getter.
225+
///
226+
/// Passing a `SessionContext` delegates to whichever planner that context
227+
/// holds at install time. If you instead capture a capsule with
228+
/// `ctx.__datafusion_query_planner__()`, capture it *before* installing
229+
/// this planner on that context, or the capsule will describe this planner
230+
/// and planning will recurse.
217231
#[new]
218232
#[pyo3(signature = (fallback=None))]
219-
fn new(fallback: Option<Bound<'_, PyAny>>) -> PyResult<Self> {
220-
let fallback = fallback
221-
.map(|planner| {
222-
ffi_query_planner_from_pycapsule(&planner, None)
223-
.map(|ffi| -> Arc<dyn QueryPlanner + Send + Sync> { (&ffi).into() })
224-
})
225-
.transpose()?;
226-
Ok(Self {
227-
fallback,
233+
fn new(fallback: Option<Bound<'_, PyAny>>) -> Self {
234+
Self {
235+
fallback: fallback.map(|obj| Arc::new(obj.unbind())),
228236
..Self::default()
229-
})
237+
}
230238
}
231239

232240
fn used_fallback(&self) -> bool {
@@ -264,9 +272,21 @@ impl MyQueryPlanner {
264272
py: Python<'py>,
265273
session: Bound<'py, PyAny>,
266274
) -> PyResult<Bound<'py, PyCapsule>> {
275+
// Resolved here rather than in `new` so the fallback's own getter
276+
// receives the session, which is what the protocol requires of every
277+
// implementation other than a `SessionContext`.
278+
let fallback = self
279+
.fallback
280+
.as_ref()
281+
.map(|planner| {
282+
ffi_query_planner_from_pycapsule(planner.bind(py), Some(&session))
283+
.map(|ffi| -> Arc<dyn QueryPlanner + Send + Sync> { (&ffi).into() })
284+
})
285+
.transpose()?;
286+
267287
let planner: Arc<dyn QueryPlanner + Send + Sync> = Arc::new(DistributedQueryPlanner {
268288
observations: Arc::clone(&self.observations),
269-
fallback: self.fallback.clone(),
289+
fallback,
270290
});
271291
let logical_codec = ffi_logical_codec_from_pycapsule(session.clone(), None)?;
272292
let physical_codec = ffi_physical_codec_from_pycapsule(session, None)?;

0 commit comments

Comments
 (0)