Skip to content

Commit 74edaff

Browse files
timsaucerclaude
andcommitted
feat: let extension bundles declare physical optimizer rules
`SessionExtensionComponents.physical_optimizer_rules` completes the group of components whose capsule getter takes no argument, so a library shipping a rule alongside anything else no longer asks the caller for a separate `add_physical_optimizer_rule` call. Rules are the one kind with no collision rule: they accumulate rather than replace, so two bundles contributing one each is the normal case and there is nothing to refuse. Installing them splits across the two new private primitives `_resolve_extension_physical_optimizer_rules` and `_install_extension_physical_optimizer_rules`, keeping the commit step infallible: the capsules are imported during resolution, so a rule that fails to import cannot leave the session with a planner already bound. All the rules in one call go on in a single `SessionState` rebuild. `add_physical_optimizer_rule` rebuilds per call, which for a bundle with several would clone the whole state that many times and leave the earlier ones installed if a later one failed. The session id is carried across the rebuild for the same reason that method carries it. `PhysicalOptimizerRuleExportable` moves from `datafusion.context` to `datafusion.extensions`, alongside the rest of the protocol family, and is now exported from the package root. It stays importable from `datafusion.context`. `MyRuleExtension` in `datafusion-ffi-example` declares two rules, which is what makes accumulation observable — each carries its own counter and both fire. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0af2a50 commit 74edaff

12 files changed

Lines changed: 334 additions & 18 deletions

File tree

crates/core/src/context.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ use datafusion::execution::options::{ArrowReadOptions, ReadOptions};
4444
use datafusion::execution::runtime_env::RuntimeEnvBuilder;
4545
use datafusion::execution::session_state::SessionStateBuilder;
4646
use datafusion::execution::{FunctionRegistry, TaskContextProvider};
47+
use datafusion::physical_optimizer::PhysicalOptimizerRule;
4748
use datafusion::physical_plan::ExecutionPlanProperties;
4849
use datafusion::prelude::{
4950
AvroReadOptions, CsvReadOptions, DataFrame, JsonReadOptions, ParquetReadOptions,
@@ -1762,6 +1763,70 @@ impl PySessionContext {
17621763
slf.borrow().set_session_query_planner(planner);
17631764
Ok(())
17641765
}
1766+
1767+
/// Import the physical optimizer rules a `with_extensions` call declared.
1768+
///
1769+
/// The fallible half of installing them, run while the call can still fail
1770+
/// harmlessly. Every capsule is imported here so that
1771+
/// [`Self::_install_extension_physical_optimizer_rules`] has nothing left
1772+
/// that can raise — a rule that failed to import after the planner was
1773+
/// bound would leave the session half-installed, and there is no derived
1774+
/// context to roll back to.
1775+
///
1776+
/// **Writes nothing.**
1777+
pub fn _resolve_extension_physical_optimizer_rules(
1778+
&self,
1779+
rules: Vec<Bound<'_, PyAny>>,
1780+
) -> PyDataFusionResult<PyPhysicalOptimizerRules> {
1781+
let rules = rules
1782+
.iter()
1783+
.map(physical_optimizer_rule_from_pycapsule)
1784+
.collect::<Result<Vec<_>, _>>()?;
1785+
Ok(PyPhysicalOptimizerRules { rules })
1786+
}
1787+
1788+
/// Commit the physical optimizer rules for a `with_extensions` call.
1789+
///
1790+
/// Rules accumulate rather than replace, so unlike a planner there is no
1791+
/// composition order to get right and no collision to refuse.
1792+
///
1793+
/// All of them go on in **one** `SessionState` rebuild.
1794+
/// [`Self::add_physical_optimizer_rule`] rebuilds per call, which for a
1795+
/// bundle contributing several would clone the whole state that many times
1796+
/// and, worse, leave the earlier rules installed if a later one failed.
1797+
/// Nothing here can fail: the capsules were imported by
1798+
/// [`Self::_resolve_extension_physical_optimizer_rules`].
1799+
pub fn _install_extension_physical_optimizer_rules(
1800+
&self,
1801+
resolved: PyRef<'_, PyPhysicalOptimizerRules>,
1802+
) {
1803+
if resolved.rules.is_empty() {
1804+
return;
1805+
}
1806+
let state_ref = self.ctx.state_ref();
1807+
let mut guard = state_ref.write();
1808+
// The session id has to be carried over for the same reason
1809+
// `add_physical_optimizer_rule` carries it: the builder mints a fresh
1810+
// one, and losing it leaves `session_id()` disagreeing with every
1811+
// `TaskContext` the session has already handed out.
1812+
let mut builder = SessionStateBuilder::new_from_existing(guard.clone())
1813+
.with_session_id(guard.session_id().to_string());
1814+
for rule in resolved.rules.iter().cloned() {
1815+
builder = builder.with_physical_optimizer_rule(rule);
1816+
}
1817+
*guard = builder.build();
1818+
}
1819+
}
1820+
1821+
/// Physical optimizer rules imported for a `with_extensions` call.
1822+
///
1823+
/// Opaque to Python, and deliberately not added to the module: it exists only
1824+
/// to carry imported rules from the resolve step to the commit step, so the
1825+
/// import can fail before anything is written. `with_extensions` is its only
1826+
/// producer and its only consumer.
1827+
#[pyclass(name = "PhysicalOptimizerRules", module = "datafusion._internal")]
1828+
pub struct PyPhysicalOptimizerRules {
1829+
rules: Vec<Arc<dyn PhysicalOptimizerRule + Send + Sync>>,
17651830
}
17661831

17671832
impl PySessionContext {

docs/source/extension-guide/bundles.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,10 @@ Shadowing a name the session *already* has is allowed and is not a collision.
336336
The registry holds every DataFusion built-in, and overriding built-ins by name
337337
is a supported thing to do — `enable_spark_functions` is built on it.
338338

339+
Physical optimizer rules are exempt: they accumulate rather than replace, so
340+
two bundles contributing one each is the normal case and there is nothing to
341+
refuse. See {doc}`other-components`.
342+
339343
Like every other derivation, the returned context is a handle on the *same*
340344
session as the receiver — see {ref}`extension_sessions`. Only the Python-side
341345
codec chains belong to the returned handle; the planner is installed on the

docs/source/extension-guide/other-components.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,22 @@ fn __datafusion_physical_optimizer_rule__<'py>(
4747
}
4848
```
4949

50+
If your library ships a rule alongside anything else, declare it on your bundle
51+
as `physical_optimizer_rules` rather than asking the caller for a separate
52+
`add_physical_optimizer_rule` call:
53+
54+
```python
55+
return SessionExtensionComponents(physical_optimizer_rules=(MyRule(),))
56+
```
57+
58+
Rules are the one kind of component with **no collision rule at all**: they
59+
accumulate, so two libraries may each contribute one and neither has to know
60+
about the other. Every rule in a call installs in a single `SessionState`
61+
rebuild, where `add_physical_optimizer_rule` rebuilds once per call — which for
62+
a bundle contributing several would clone the whole state that many times, and
63+
would leave the earlier ones installed if a later one failed. See
64+
{ref}`extension_bundles_transaction`.
65+
5066
## Typed configuration
5167

5268
**`__datafusion_extension_options__`** contributes typed configuration entries

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

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
import pyarrow as pa
2323
import pytest
2424
from datafusion import SessionContext, SessionExtensionComponents
25-
from datafusion_ffi_example import MyFunctionExtension
25+
from datafusion_ffi_example import MyFunctionExtension, MyRuleExtension
2626

2727

2828
def _session():
@@ -112,6 +112,78 @@ def __datafusion_session_planner__(self, ctx, fallback) -> None:
112112
ctx.udf("my_custom_is_null")
113113

114114

115+
def _query(ctx):
116+
batch = pa.RecordBatch.from_arrays([pa.array([1, 2, 3])], names=["a"])
117+
ctx.register_record_batches("t", [[batch]])
118+
return ctx.sql("SELECT a FROM t").collect()
119+
120+
121+
def test_declared_rules_all_fire():
122+
"""Rules accumulate, so both of a bundle's two rules run.
123+
124+
Nothing about installing the second displaces the first, which is what
125+
makes rules different from a planner and why there is no collision to
126+
refuse.
127+
"""
128+
extension = MyRuleExtension()
129+
ctx = SessionContext().with_extensions(extension)
130+
131+
assert _query(ctx)[0].column(0).to_pylist() == [1, 2, 3]
132+
assert extension.first_calls() > 0
133+
assert extension.second_calls() > 0
134+
135+
136+
def test_rules_install_without_changing_the_session_id():
137+
"""Installing rules rebuilds ``SessionState``; the id has to survive it.
138+
139+
A fresh id would leave ``session_id()`` disagreeing with every
140+
``TaskContext`` the session already handed out, which is exactly what a
141+
codec's decode callbacks resolve against.
142+
"""
143+
ctx = SessionContext()
144+
before = ctx.session_id()
145+
result = ctx.with_extensions(MyRuleExtension())
146+
147+
assert result.session_id() == before
148+
assert ctx.session_id() == before
149+
150+
151+
def test_rules_and_functions_install_together():
152+
"""Two bundles, one contributing functions and one rules, in one call."""
153+
rules = MyRuleExtension()
154+
ctx = SessionContext().with_extensions(MyFunctionExtension(), rules)
155+
batch = pa.RecordBatch.from_arrays([pa.array([1, 2, None])], names=["a"])
156+
ctx.register_record_batches("t", [[batch]])
157+
158+
result = ctx.sql("SELECT my_custom_is_null(a) FROM t").collect()
159+
160+
assert result[0].column(0).to_pylist() == [False, False, True]
161+
assert rules.first_calls() > 0
162+
163+
164+
def test_a_failure_leaves_no_rule_installed():
165+
"""The transaction covers rules, which write through a state rebuild.
166+
167+
A rule reaching the session before the failing hook would be invisible to
168+
``session_id()`` and to the function registry, so this asserts on the
169+
counter instead: an installed rule fires on the next query.
170+
"""
171+
rules = MyRuleExtension()
172+
ctx = SessionContext()
173+
174+
class BoomPlanner:
175+
def __datafusion_session_planner__(self, ctx, fallback) -> None:
176+
msg = "boom"
177+
raise RuntimeError(msg)
178+
179+
with pytest.raises(RuntimeError, match="boom"):
180+
ctx.with_extensions(rules, BoomPlanner())
181+
182+
_query(ctx)
183+
assert rules.first_calls() == 0
184+
assert rules.second_calls() == 0
185+
186+
115187
def test_the_hook_returns_the_components_type():
116188
"""The bundle builds a real dataclass, not a duck-typed stand-in.
117189

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use pyo3::prelude::*;
1919
use pyo3::types::PyDict;
2020

2121
use crate::aggregate_udf::MySumUDF;
22+
use crate::physical_optimizer::MyPhysicalOptimizerRule;
2223
use crate::scalar_udf::IsNullUDF;
2324
use crate::window_udf::MyRankUDF;
2425

@@ -62,3 +63,61 @@ impl MyFunctionExtension {
6263
components.call((), Some(&kwargs))
6364
}
6465
}
66+
67+
/// A bundle contributing two physical optimizer rules.
68+
///
69+
/// Two, because that is what makes accumulation observable: rules never
70+
/// 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.
72+
#[pyclass(
73+
from_py_object,
74+
name = "MyRuleExtension",
75+
module = "datafusion_ffi_example",
76+
subclass
77+
)]
78+
#[derive(Debug, Clone, Default)]
79+
pub(crate) struct MyRuleExtension {
80+
first: MyPhysicalOptimizerRule,
81+
second: MyPhysicalOptimizerRule,
82+
}
83+
84+
#[pymethods]
85+
impl MyRuleExtension {
86+
#[new]
87+
fn new() -> Self {
88+
Self::default()
89+
}
90+
91+
/// How many times the first declared rule has run.
92+
fn first_calls(&self) -> usize {
93+
self.first.optimize_calls()
94+
}
95+
96+
/// How many times the second declared rule has run.
97+
fn second_calls(&self) -> usize {
98+
self.second.optimize_calls()
99+
}
100+
101+
/// `ctx` is unused: a rule getter takes no argument, so there is nothing
102+
/// session-scoped to bind.
103+
fn __datafusion_session_components__<'py>(
104+
&self,
105+
py: Python<'py>,
106+
ctx: Bound<'py, PyAny>,
107+
) -> PyResult<Bound<'py, PyAny>> {
108+
let _ = ctx;
109+
110+
let components = py
111+
.import("datafusion")?
112+
.getattr("SessionExtensionComponents")?;
113+
let kwargs = PyDict::new(py);
114+
kwargs.set_item(
115+
"physical_optimizer_rules",
116+
(
117+
Py::new(py, self.first.clone())?,
118+
Py::new(py, self.second.clone())?,
119+
),
120+
)?;
121+
components.call((), Some(&kwargs))
122+
}
123+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use pyo3::prelude::*;
2020
use crate::aggregate_udf::MySumUDF;
2121
use crate::catalog_provider::{FixedSchemaProvider, MyCatalogProvider, MyCatalogProviderList};
2222
use crate::config::MyConfig;
23-
use crate::extension::MyFunctionExtension;
23+
use crate::extension::{MyFunctionExtension, MyRuleExtension};
2424
use crate::logical_extension_codec::MyLogicalExtensionCodec;
2525
use crate::name_only_codec::{NameOnlyFunction, NameOnlyUdfCodec};
2626
use crate::physical_extension_codec::MyPhysicalExtensionCodec;
@@ -66,5 +66,6 @@ fn datafusion_ffi_example(m: &Bound<'_, PyModule>) -> PyResult<()> {
6666
m.add_class::<MyPhysicalExtensionCodec>()?;
6767
m.add_class::<MyPhysicalOptimizerRule>()?;
6868
m.add_class::<MyFunctionExtension>()?;
69+
m.add_class::<MyRuleExtension>()?;
6970
Ok(())
7071
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,11 @@ pub(crate) struct MyPhysicalOptimizerRule {
7272
#[pymethods]
7373
impl MyPhysicalOptimizerRule {
7474
#[new]
75-
fn new() -> Self {
75+
pub(crate) fn new() -> Self {
7676
Self::default()
7777
}
7878

79-
fn optimize_calls(&self) -> usize {
79+
pub(crate) fn optimize_calls(&self) -> usize {
8080
self.optimize_calls.load(Ordering::SeqCst)
8181
}
8282

python/datafusion/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
from .dataframe_formatter import configure_formatter
9494
from .expr import Expr, WindowFrame
9595
from .extensions import (
96+
PhysicalOptimizerRuleExportable,
9697
QueryPlannerExportable,
9798
SessionComponentsExportable,
9899
SessionExtensionComponents,
@@ -139,6 +140,7 @@
139140
"MetricsSet",
140141
"ParquetColumnOptions",
141142
"ParquetWriterOptions",
143+
"PhysicalOptimizerRuleExportable",
142144
"PhysicalPartitioning",
143145
"QueryPlannerExportable",
144146
"RecordBatch",

python/datafusion/context.py

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
from datafusion.dataframe import DataFrame
7171
from datafusion.expr import sort_list_to_raw_sort_list
7272
from datafusion.extensions import (
73+
PhysicalOptimizerRuleExportable,
7374
QueryPlannerExportable,
7475
SessionComponentsExportable,
7576
SessionExtensionComponents,
@@ -151,16 +152,6 @@ class TableProviderExportable(Protocol):
151152
def __datafusion_table_provider__(self, session: Any) -> object: ... # noqa: D105
152153

153154

154-
class PhysicalOptimizerRuleExportable(Protocol):
155-
"""Type hint for object that has __datafusion_physical_optimizer_rule__ PyCapsule.
156-
157-
The method returns a PyCapsule wrapping an ``FFI_PhysicalOptimizerRule``,
158-
typically produced by a separate compiled extension.
159-
"""
160-
161-
def __datafusion_physical_optimizer_rule__(self) -> object: ... # noqa: D105
162-
163-
164155
def _collect_contributions(
165156
extensions: tuple[object, ...],
166157
ctx: SessionContext,
@@ -204,6 +195,7 @@ def _collect_contributions(
204195
"udfs": [],
205196
"udafs": [],
206197
"udwfs": [],
198+
"physical_optimizer_rules": [],
207199
}
208200
for extension in extensions:
209201
if not isinstance(extension, SessionComponentsExportable):
@@ -2032,10 +2024,10 @@ def with_extensions(
20322024
20332025
Nothing is written to the session until every hook has returned and
20342026
every component has been validated, so a hook that raises leaves the
2035-
session as it was. Declared functions register after the planner is
2036-
bound, and are visible on every handle sharing this session. A hook
2037-
that *mutates* the context it is handed — registering a table, say — is
2038-
not rolled back, which is why bundle objects must be
2027+
session as it was. Declared functions and optimizer rules install after
2028+
the planner is bound, and are visible on every handle sharing this
2029+
session. A hook that *mutates* the context it is handed — registering a
2030+
table, say — is not rolled back, which is why bundle objects must be
20392031
configuration-only.
20402032
20412033
Shares its session with this context — see :py:class:`SessionContext`.
@@ -2147,6 +2139,11 @@ def with_extensions(
21472139
_udwf,
21482140
"window function",
21492141
)
2142+
# Rules accumulate, so there is no name to check and nothing to refuse
2143+
# -- only the capsules to import while failing is still free.
2144+
resolved_rules = new.ctx._resolve_extension_physical_optimizer_rules(
2145+
[rule for _, rule in declared["physical_optimizer_rules"]]
2146+
)
21502147

21512148
# Phase two: nest the planners, outermost last. Each hook runs against
21522149
# `new`, which carries the final chains, so a planner captured here
@@ -2187,6 +2184,7 @@ def with_extensions(
21872184
new.register_udaf(function)
21882185
for function in resolved_udwfs:
21892186
new.register_udwf(function)
2187+
new.ctx._install_extension_physical_optimizer_rules(resolved_rules)
21902188
return new
21912189

21922190
def table_provider(self, name: str) -> Table:

0 commit comments

Comments
 (0)