Skip to content

Commit ed68913

Browse files
timsaucerclaude
andcommitted
fix: name the bundle that declared something that is not a rule
A rule the importer refuses used to raise from Rust with nothing but the capsule name. A caller who passed four bundles could not tell which one was at fault, and the resolve step is the last place that is known. `_resolve_declared_rules` looks for the getter in Python first, mirroring what `_resolve_declared_functions` already does for a declared function: TypeError A declared optimizer rule must expose __datafusion_physical_optimizer_rule__, got <object ...> from <RuleExtension ...> Scoped to match the sibling rather than to go past it. A getter that is present but returns a non-capsule still falls through to the importer's `RuntimeError`, exactly as a declared function does, and `with_extensions` now documents that case instead of listing only the two errors it raises itself. `MyRuleExtension`'s two rules append to a run log they share, so the order they installed in is observable. The counters cannot show it: each rule has its own, so they say how often a rule ran but not when. `ffi-internals.md` describes the four-step commit order as a rule for the next field added to `SessionExtensionComponents`. `physical_optimizer_rules` is that field, so steps three and four name it rather than leaving the enumeration stale on the commit that invoked it. Why rules never collide is now argued once, in the extension guide. The protocol docstring and the field docstring state it and link there, and the docstring naming the test that runs its skipped example names the whole test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3a24768 commit ed68913

7 files changed

Lines changed: 150 additions & 31 deletions

File tree

docs/source/contributor-guide/ffi-internals.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,10 @@ A call therefore splits into a part that may fail and a part that may not:
128128
live on that handle rather than on the session, so this step writes nothing
129129
even though it can fail on a bad capsule or a duplicate id.
130130
3. **Resolve.** Every declared function is wrapped and every name is checked,
131-
and every `__datafusion_session_planner__` runs against the completed
132-
chains.
133-
4. **Commit.** The planner is bound and the functions are registered.
131+
every declared physical optimizer rule has its capsule imported, and every
132+
`__datafusion_session_planner__` runs against the completed chains.
133+
4. **Commit.** The planner is bound, the functions are registered, and the
134+
optimizer rules are installed in a single `SessionState` rebuild.
134135

135136
Only step 4 touches the session, and every step that can fail happens before
136137
it. This is a rule for the next field added to

examples/datafusion-ffi-example/python/tests/_test_session_extension.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,21 @@ def test_declared_rules_all_fire():
133133
assert extension.second_calls() > 0
134134

135135

136+
def test_declared_rules_run_in_declaration_order():
137+
"""The order a bundle lists its rules in is the order they install in.
138+
139+
Rules rewrite the plan one after another, so the order is part of what a
140+
bundle declares. The counters cannot show it — each rule has its own — so
141+
the two here append to a log they share.
142+
"""
143+
extension = MyRuleExtension()
144+
ctx = SessionContext().with_extensions(extension)
145+
146+
_query(ctx)
147+
148+
assert extension.run_order() == [0, 1]
149+
150+
136151
def test_rules_install_without_changing_the_session_id():
137152
"""Installing rules rebuilds ``SessionState``; the id has to survive it.
138153

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
// specific language governing permissions and limitations
1616
// under the License.
1717

18+
use std::sync::{Arc, Mutex};
19+
1820
use pyo3::types::{PyAnyMethods, PyDict, PyDictMethods};
1921
use pyo3::{Bound, Py, PyAny, PyResult, Python, pyclass, pymethods};
2022

@@ -68,24 +70,32 @@ impl MyFunctionExtension {
6870
///
6971
/// Two, because that is what makes accumulation observable: rules never
7072
/// collide the way function names do, so both of these install and both fire.
71-
/// Each carries its own counter, which is how a test tells them apart.
73+
/// Each carries its own counter, which is how a test tells them apart, and
74+
/// both append to one run log, which is how a test sees the order they
75+
/// installed in.
7276
#[pyclass(
7377
from_py_object,
7478
name = "MyRuleExtension",
7579
module = "datafusion_ffi_example",
7680
subclass
7781
)]
78-
#[derive(Debug, Clone, Default)]
82+
#[derive(Debug, Clone)]
7983
pub(crate) struct MyRuleExtension {
8084
first: MyPhysicalOptimizerRule,
8185
second: MyPhysicalOptimizerRule,
86+
run_log: Arc<Mutex<Vec<usize>>>,
8287
}
8388

8489
#[pymethods]
8590
impl MyRuleExtension {
8691
#[new]
8792
fn new() -> Self {
88-
Self::default()
93+
let run_log = Arc::new(Mutex::new(Vec::new()));
94+
Self {
95+
first: MyPhysicalOptimizerRule::with_run_log(0, Arc::clone(&run_log)),
96+
second: MyPhysicalOptimizerRule::with_run_log(1, Arc::clone(&run_log)),
97+
run_log,
98+
}
8999
}
90100

91101
/// How many times the first declared rule has run.
@@ -98,6 +108,12 @@ impl MyRuleExtension {
98108
self.second.optimize_calls()
99109
}
100110

111+
/// The labels of the two declared rules, in the order they ran: `0` is the
112+
/// first declared and `1` the second.
113+
fn run_order(&self) -> Vec<usize> {
114+
self.run_log.lock().expect("run log poisoned").clone()
115+
}
116+
101117
/// `ctx` is unused: a rule getter takes no argument, so there is nothing
102118
/// session-scoped to bind.
103119
fn __datafusion_session_components__<'py>(

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
// specific language governing permissions and limitations
1616
// under the License.
1717

18-
use std::sync::Arc;
1918
use std::sync::atomic::{AtomicUsize, Ordering};
19+
use std::sync::{Arc, Mutex};
2020

2121
use datafusion::common::Result;
2222
use datafusion::common::config::ConfigOptions;
@@ -31,9 +31,15 @@ use pyo3::types::PyCapsule;
3131
/// shared counter each time it runs. Tests use the counter to prove that a
3232
/// session built with this rule actually routed physical planning through a
3333
/// user-supplied [`PhysicalOptimizerRule`] over FFI.
34+
///
35+
/// A rule declared alongside siblings also appends its label to a log they
36+
/// all share, which is what lets a test see the order they ran in. Counters
37+
/// alone cannot: each rule has its own, so they say how often but not when.
3438
#[derive(Debug)]
3539
struct CountingPhysicalOptimizerRule {
3640
optimize_calls: Arc<AtomicUsize>,
41+
label: usize,
42+
run_log: Option<Arc<Mutex<Vec<usize>>>>,
3743
}
3844

3945
impl PhysicalOptimizerRule for CountingPhysicalOptimizerRule {
@@ -43,6 +49,9 @@ impl PhysicalOptimizerRule for CountingPhysicalOptimizerRule {
4349
_config: &ConfigOptions,
4450
) -> Result<Arc<dyn ExecutionPlan>> {
4551
self.optimize_calls.fetch_add(1, Ordering::SeqCst);
52+
if let Some(run_log) = &self.run_log {
53+
run_log.lock().expect("run log poisoned").push(self.label);
54+
}
4655
Ok(plan)
4756
}
4857

@@ -67,6 +76,21 @@ impl PhysicalOptimizerRule for CountingPhysicalOptimizerRule {
6776
#[derive(Debug, Default, Clone)]
6877
pub(crate) struct MyPhysicalOptimizerRule {
6978
optimize_calls: Arc<AtomicUsize>,
79+
label: usize,
80+
run_log: Option<Arc<Mutex<Vec<usize>>>>,
81+
}
82+
83+
impl MyPhysicalOptimizerRule {
84+
/// A rule that records where it ran relative to the siblings sharing
85+
/// `run_log`. `label` is what it appends. Not exposed to Python: only a
86+
/// bundle declaring several rules at once has siblings to order against.
87+
pub(crate) fn with_run_log(label: usize, run_log: Arc<Mutex<Vec<usize>>>) -> Self {
88+
Self {
89+
optimize_calls: Arc::new(AtomicUsize::new(0)),
90+
label,
91+
run_log: Some(run_log),
92+
}
93+
}
7094
}
7195

7296
#[pymethods]
@@ -87,6 +111,8 @@ impl MyPhysicalOptimizerRule {
87111
let rule: Arc<dyn PhysicalOptimizerRule + Send + Sync> =
88112
Arc::new(CountingPhysicalOptimizerRule {
89113
optimize_calls: Arc::clone(&self.optimize_calls),
114+
label: self.label,
115+
run_log: self.run_log.clone(),
90116
});
91117

92118
let runtime = get_tokio_runtime().handle().clone();

python/datafusion/context.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,40 @@ def _resolve_declared_functions(
356356
return resolved
357357

358358

359+
def _resolve_declared_rules(
360+
declared: list[tuple[int, object, Any]], resolve: Any
361+
) -> Any:
362+
"""Import the capsule of every physical optimizer rule an extension declared.
363+
364+
There is no name to check — rules accumulate — so unlike
365+
:py:func:`_resolve_declared_functions` this only refuses a declaration that
366+
cannot be a rule. The getter is looked for here rather than left to the
367+
importer so that the error names the bundle that declared it; a caller who
368+
passed four bundles cannot otherwise tell which one is at fault.
369+
370+
Args:
371+
declared: ``(position, extension, rule)`` triples in declaration order.
372+
resolve: The primitive that imports a list of rules at once, returning
373+
an opaque object for the commit step.
374+
375+
Returns:
376+
The imported rules, opaque, in declaration order.
377+
378+
Raises:
379+
TypeError: If a declaration does not expose
380+
``__datafusion_physical_optimizer_rule__``.
381+
"""
382+
for _, extension, rule in declared:
383+
if not hasattr(rule, "__datafusion_physical_optimizer_rule__"):
384+
msg = (
385+
"A declared optimizer rule must expose "
386+
f"__datafusion_physical_optimizer_rule__, got {rule!r} "
387+
f"from {extension!r}"
388+
)
389+
raise TypeError(msg)
390+
return resolve([rule for _, _, rule in declared])
391+
392+
359393
class SessionConfig:
360394
"""Session configuration options."""
361395

@@ -2137,13 +2171,17 @@ def with_extensions(
21372171
TypeError: If an argument implements neither hook, if a hook
21382172
returns the wrong type, if a codec is contributed as a bare
21392173
``PyCapsule`` rather than an object exposing the getter, or if
2140-
a declared function is neither a wrapper nor exposes its
2141-
capsule getter.
2174+
a declared function or optimizer rule does not expose its
2175+
capsule getter and is not already a wrapper.
21422176
ValueError: If two codecs claim the same id, if two extensions
21432177
declare a function of one kind under the same name, or if a
21442178
getter returns a capsule of the wrong kind. See
21452179
:py:meth:`with_logical_extension_codec` for how ids are
21462180
assigned.
2181+
RuntimeError: If a getter is present but returns something that is
2182+
not a ``PyCapsule`` at all. The message comes from the importer
2183+
and does not name the bundle, because by then the declaration
2184+
has already been accepted as the right shape.
21472185
21482186
Examples:
21492187
The returned handle is a different object sharing one session, and
@@ -2210,8 +2248,9 @@ def with_extensions(
22102248
]
22112249
# Rules accumulate, so there is no name to check and nothing to refuse
22122250
# -- only the capsules to import while failing is still free.
2213-
resolved_rules = new.ctx._resolve_extension_physical_optimizer_rules(
2214-
[rule for _, _, rule in declared["physical_optimizer_rules"]]
2251+
resolved_rules = _resolve_declared_rules(
2252+
declared["physical_optimizer_rules"],
2253+
new.ctx._resolve_extension_physical_optimizer_rules,
22152254
)
22162255

22172256
# Phase two: nest the planners, outermost last. Each hook runs against

python/datafusion/extensions.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,11 @@ class PhysicalOptimizerRuleExportable(Protocol):
7979
argument**: a rule needs neither a codec nor a task-context provider, so
8080
there is nothing session-scoped to hand it.
8181
82-
Rules accumulate rather than replace, so several libraries may each
83-
contribute one and none of them has to know about the others. Install one
84-
with :py:meth:`~datafusion.context.SessionContext.add_physical_optimizer_rule`,
82+
Rules accumulate rather than replace. Install one with
83+
:py:meth:`~datafusion.context.SessionContext.add_physical_optimizer_rule`,
8584
or declare it on a bundle as
86-
:py:attr:`SessionExtensionComponents.physical_optimizer_rules`.
85+
:py:attr:`SessionExtensionComponents.physical_optimizer_rules` — see
86+
:ref:`extension_other_hooks`.
8787
8888
Examples:
8989
The getter is the whole protocol, and a capsule is what it must return
@@ -98,7 +98,7 @@ class PhysicalOptimizerRuleExportable(Protocol):
9898
RuntimeError: "Invalid datafusion_physical_optimizer_rule...
9999
100100
Real usage. Skipped here (needs a built extension library); run for
101-
real by ``test_ffi_physical_optimizer_rule`` in
101+
real by ``test_ffi_physical_optimizer_rule_runs_during_planning`` in
102102
``datafusion-ffi-example``.
103103
104104
>>> from datafusion_ffi_example import MyPhysicalOptimizerRule # doctest: +SKIP
@@ -299,9 +299,8 @@ class SessionExtensionComponents:
299299
"""Physical optimizer rules to install on the session.
300300
301301
Objects exposing ``__datafusion_physical_optimizer_rule__``. Unlike
302-
functions these never collide: rules accumulate, so two extensions may each
303-
contribute one without either having to know about the other. All the rules
304-
in one call install together, in declaration order.
302+
functions these never collide — they accumulate. All the rules in one call
303+
install together, in declaration order. See :ref:`extension_other_hooks`.
305304
"""
306305

307306
def __post_init__(self) -> None:

python/tests/test_context.py

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1678,20 +1678,20 @@ def test_session_extension_components_rejects_a_single_optimizer_rule():
16781678
SessionExtensionComponents(physical_optimizer_rules=object())
16791679

16801680

1681-
def test_with_extensions_rejects_a_rule_that_is_not_a_capsule(ctx):
1682-
"""A rule that will not import is refused, and nothing is installed.
1681+
def test_with_extensions_rejects_a_rule_that_is_not_a_rule(ctx):
1682+
"""A declaration that is not a rule at all names the bundle that made it.
16831683
1684-
Importing the capsules is the only part of installing a rule that can
1685-
fail, so it happens during resolution. A failure here has to leave the
1686-
session alone even though the extension ahead of it declared a function
1687-
that was perfectly good.
1684+
Which of several bundles is at fault is the whole content of the message,
1685+
and the only place it is still known is here. A failure also has to leave
1686+
the session alone even though the extension ahead of it declared a
1687+
function that was perfectly good.
16881688
"""
16891689

16901690
class RuleExtension:
16911691
def __datafusion_session_components__(self, ctx):
16921692
return SessionExtensionComponents(physical_optimizer_rules=(object(),))
16931693

1694-
with pytest.raises(RuntimeError, match="datafusion_physical_optimizer_rule"):
1694+
with pytest.raises(TypeError, match=r"got .* from .*RuleExtension"):
16951695
ctx.with_extensions(
16961696
_FunctionExtension(udfs=(_doubler(),)),
16971697
RuleExtension(),
@@ -1701,13 +1701,36 @@ def __datafusion_session_components__(self, ctx):
17011701
ctx.udf("double")
17021702

17031703

1704+
def test_with_extensions_rejects_a_rule_whose_getter_returns_a_non_capsule(ctx):
1705+
"""A rule shaped right but returning junk is refused by the importer.
1706+
1707+
The bundle is past the point where it can be named — it declared the right
1708+
shape — so this is the one rule failure that surfaces as a ``RuntimeError``
1709+
from the import rather than a ``TypeError`` from the resolve.
1710+
"""
1711+
1712+
class NotACapsule:
1713+
def __datafusion_physical_optimizer_rule__(self):
1714+
return object()
1715+
1716+
class RuleExtension:
1717+
def __datafusion_session_components__(self, ctx):
1718+
return SessionExtensionComponents(physical_optimizer_rules=(NotACapsule(),))
1719+
1720+
with pytest.raises(RuntimeError, match="datafusion_physical_optimizer_rule"):
1721+
ctx.with_extensions(_FunctionExtension(udfs=(_doubler(),)), RuleExtension())
1722+
1723+
with pytest.raises(KeyError):
1724+
ctx.udf("double")
1725+
1726+
17041727
def test_with_extensions_declaring_no_rules_leaves_the_session_id(ctx):
1705-
"""Installing rules rebuilds ``SessionState``; declaring none must not.
1728+
"""Installing rules rebuilds ``SessionState``; the id has to survive it.
17061729
1707-
The rebuild mints a fresh session id unless it is carried over, and a
1708-
changed id would break every ``TaskContext`` the session has handed out.
1709-
Asserted for the empty case too, because that is the one where the rebuild
1710-
would be pure cost.
1730+
The rebuild mints a fresh id unless it is carried over, and a changed id
1731+
would break every ``TaskContext`` the session has handed out. Asserted for
1732+
a call declaring no rules as well, so the guarantee does not depend on
1733+
whether the rebuild was skipped.
17111734
"""
17121735
before = ctx.session_id()
17131736
result = ctx.with_extensions(_FunctionExtension(udfs=(_doubler(),)))

0 commit comments

Comments
 (0)