Skip to content

Commit b1e25b3

Browse files
committed
Ignore Polars dynamic predicates
1 parent 50d2b28 commit b1e25b3

2 files changed

Lines changed: 199 additions & 9 deletions

File tree

duckdb/polars_io.py

Lines changed: 98 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations # noqa: D100
22

3-
import contextlib
43
import datetime
4+
import io
55
import json
66
import typing
77
from decimal import Decimal
@@ -35,16 +35,95 @@ def _predicate_to_expression(predicate: pl.Expr) -> duckdb.Expression | None:
3535
"""
3636
# Serialize the Polars expression tree to JSON
3737
tree = json.loads(predicate.meta.serialize(format="json"))
38+
return _tree_to_sql_expression(tree)
3839

40+
41+
def _tree_to_sql_expression(tree: _ExpressionTree) -> duckdb.Expression | None:
42+
"""Convert an already-parsed Polars expression tree to a DuckDB expression.
43+
44+
Returns None if the tree contains a node we cannot translate to SQL.
45+
"""
3946
try:
40-
# Convert the tree to SQL
41-
sql_filter = _pl_tree_to_sql(tree)
42-
return duckdb.SQLExpression(sql_filter)
47+
return duckdb.SQLExpression(_pl_tree_to_sql(tree))
4348
except Exception:
4449
# If the conversion fails, we return None
4550
return None
4651

4752

53+
# Polars "dynamic predicates"
54+
# ---------------------------
55+
# When a slice / TOP-N sits above a scan, polars' optimizer pushes a *dynamic
56+
# predicate* into the scan, AND-ed onto any real filter. It is an internal
57+
# optimizer node, not a materializable expression: it serializes as a `Display`
58+
# node with `fmt_str` "dynamic_pred: <uuid>", and feeding it back into
59+
# `DataFrame.filter()` panics polars (`unreachable!` in `expr_to_ir`). It is only
60+
# an early-pruning hint -- the limit above the scan still runs -- so we drop it
61+
# and keep the real predicate, which we MUST still apply (polars trusts the
62+
# source and does not re-filter above it). Reported as duckdb-python#460; for
63+
# polars-side context see:
64+
# - https://github.com/pola-rs/polars/issues/21665 (why real + dynamic arrive AND-ed)
65+
# - https://github.com/pola-rs/polars/issues/22252 (filter() panicking on un-lowerable nodes)
66+
def _is_dynamic_predicate_node(node: typing.Any) -> bool: # noqa: ANN401
67+
"""Return True if a serialized node is a dynamic predicate (see the note above).
68+
69+
Detected by shape: a ``Display`` node whose ``fmt_str`` starts with ``dynamic_pred``.
70+
"""
71+
if not isinstance(node, dict):
72+
return False
73+
display = node.get("Display")
74+
return (
75+
isinstance(display, dict)
76+
and isinstance(display.get("fmt_str"), str)
77+
and display["fmt_str"].startswith("dynamic_pred")
78+
)
79+
80+
81+
def _tree_contains_dynamic_predicate(node: typing.Any) -> bool: # noqa: ANN401
82+
"""Return True if the serialized expression tree contains a dynamic predicate anywhere."""
83+
if _is_dynamic_predicate_node(node):
84+
return True
85+
if isinstance(node, dict):
86+
return any(_tree_contains_dynamic_predicate(child) for child in node.values())
87+
if isinstance(node, list):
88+
return any(_tree_contains_dynamic_predicate(child) for child in node)
89+
return False
90+
91+
92+
def _strip_dynamic_predicates(tree: typing.Any) -> tuple[_ExpressionTree | None, bool]: # noqa: ANN401
93+
"""Remove dynamic-predicate conjuncts from a serialized predicate tree.
94+
95+
See the note above for what a dynamic predicate is and why we drop it.
96+
97+
Returns ``(stripped_tree, removed)``. ``stripped_tree`` is ``None`` when the
98+
predicate was purely dynamic. Raises ``NotImplementedError`` if a dynamic
99+
predicate appears anywhere other than a top-level ``And`` conjunct — a shape
100+
polars does not produce today, where the hint can neither be safely dropped
101+
nor applied.
102+
"""
103+
if _is_dynamic_predicate_node(tree):
104+
return None, True
105+
if isinstance(tree, dict) and "BinaryExpr" in tree:
106+
bin_expr = tree["BinaryExpr"]
107+
if isinstance(bin_expr, dict) and bin_expr.get("op") == "And":
108+
left, left_removed = _strip_dynamic_predicates(bin_expr["left"])
109+
right, right_removed = _strip_dynamic_predicates(bin_expr["right"])
110+
removed = left_removed or right_removed
111+
if left is None:
112+
return right, removed
113+
if right is None:
114+
return left, removed
115+
return {"BinaryExpr": {**bin_expr, "left": left, "right": right}}, removed
116+
if _tree_contains_dynamic_predicate(tree):
117+
msg = "Cannot handle a polars dynamic predicate outside a top-level AND conjunct"
118+
raise NotImplementedError(msg)
119+
return tree, False
120+
121+
122+
def _expression_from_tree(tree: _ExpressionTree) -> pl.Expr:
123+
"""Rebuild a polars expression from a serialized tree (inverse of meta.serialize)."""
124+
return pl.Expr.deserialize(io.BytesIO(json.dumps(tree).encode()), format="json")
125+
126+
48127
def _pl_operation_to_sql(op: str) -> str:
49128
"""Map Polars binary operation strings to SQL equivalents.
50129
@@ -286,25 +365,35 @@ def source_generator(
286365
batch_size: int | None,
287366
) -> Iterator[pl.DataFrame]:
288367
duck_predicate = None
368+
fallback_predicate = None
289369
relation_final = relation
290370
if with_columns is not None:
291371
cols = ",".join(map(_escape_sql_identifier, with_columns))
292372
relation_final = relation_final.project(cols)
293373
if n_rows is not None:
294374
relation_final = relation_final.limit(n_rows)
295375
if predicate is not None:
296-
# We have a predicate, if possible, we push it down to DuckDB
297-
with contextlib.suppress(AssertionError, KeyError):
298-
duck_predicate = _predicate_to_expression(predicate)
376+
# Strip any dynamic-predicate hint (see the dynamic-predicate note
377+
# above); the real predicate must still be applied.
378+
tree = json.loads(predicate.meta.serialize(format="json"))
379+
real_tree, had_dynamic = _strip_dynamic_predicates(tree)
380+
if real_tree is not None:
381+
# We have a real predicate; if possible, push it down to DuckDB.
382+
duck_predicate = _tree_to_sql_expression(real_tree)
383+
if duck_predicate is None:
384+
# Could not push it down: re-apply it polars-side. Rebuild the
385+
# expression from the stripped tree so we never hand polars the
386+
# dynamic node it cannot lower.
387+
fallback_predicate = _expression_from_tree(real_tree) if had_dynamic else predicate
299388
# Try to pushdown filter, if one exists
300389
if duck_predicate is not None:
301390
relation_final = relation_final.filter(duck_predicate)
302391
results = relation_final.to_arrow_reader() if batch_size is None else relation_final.to_arrow_reader(batch_size)
303392

304393
for record_batch in iter(results.read_next_batch, None):
305-
if predicate is not None and duck_predicate is None:
394+
if fallback_predicate is not None:
306395
# We have a predicate, but did not manage to push it down, we fallback here
307-
yield pl.from_arrow(record_batch).filter(predicate) # type: ignore[arg-type,misc,unused-ignore]
396+
yield pl.from_arrow(record_batch).filter(fallback_predicate) # type: ignore[arg-type,misc,unused-ignore]
308397
else:
309398
yield pl.from_arrow(record_batch) # type: ignore[misc,unused-ignore]
310399

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Regression test for duckdb-python issue #460 (half b).
2+
3+
A LazyFrame produced by `rel.pl(lazy=True)` panics polars when a `sort` +
4+
`limit` (a TOP-N / slice) is collected. With Polars >= 1.39 the planner pushes a
5+
*dynamic predicate* down to the IO source. It arrives not as a real expression
6+
but as a `Display` node (`fmt_str: "dynamic_pred: <uuid>"`), and the fallback in
7+
`duckdb/polars_io.py`'s `source_generator` tries to materialize it via
8+
`pl.from_arrow(record_batch).filter(predicate)`. Polars's DSL->IR lowering hits
9+
`unreachable!()` on that node shape and raises `pyo3_runtime.PanicException`
10+
(`internal error: entered unreachable code`).
11+
12+
Only reproduces on Polars >= 1.39 — earlier planners don't push a dynamic
13+
predicate to the IO source, so the test is skipped below that version.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import pytest
19+
20+
import duckdb
21+
22+
# Dynamic-predicate pushdown to IO sources starts at Polars 1.39; below that the
23+
# bug is unreachable, so skip rather than xfail.
24+
pl = pytest.importorskip("polars", minversion="1.39")
25+
pytest.importorskip("pyarrow")
26+
27+
28+
@pytest.mark.parametrize("engine", ["streaming", "in-memory"])
29+
def test_460_lazy_sort_limit_does_not_panic(engine: str) -> None:
30+
df = pl.DataFrame({"x": [3, 1, 2]})
31+
conn = duckdb.connect()
32+
try:
33+
conn.register("df_registered", df)
34+
lf = conn.sql("SELECT * FROM df_registered").pl(lazy=True)
35+
# sort + limit makes polars push a (pure) dynamic predicate into the source.
36+
out = lf.sort("x").limit(1).collect(engine=engine)
37+
finally:
38+
conn.close()
39+
40+
assert out.to_dict(as_series=False) == {"x": [1]}
41+
42+
43+
@pytest.mark.parametrize("engine", ["streaming", "in-memory"])
44+
def test_460_user_filter_combined_with_dynamic_predicate_is_not_dropped(engine: str) -> None:
45+
# Polars ANDs the dynamic predicate onto the user's real filter into one
46+
# pushed predicate. Stripping the hint must NOT drop the real filter, and
47+
# polars does not re-filter above the source. Adversarial data: the global
48+
# min-x row (x=1) fails the y>15 filter, so dropping the filter yields a
49+
# different (wrong) row.
50+
df = pl.DataFrame({"x": [3, 1, 2], "y": [100, 5, 50]})
51+
conn = duckdb.connect()
52+
try:
53+
conn.register("t", df)
54+
lf = conn.sql("SELECT * FROM t").pl(lazy=True)
55+
out = lf.filter(pl.col("y") > 15).sort("x").limit(1).collect(engine=engine)
56+
finally:
57+
conn.close()
58+
59+
ref = df.lazy().filter(pl.col("y") > 15).sort("x").limit(1).collect()
60+
assert out.to_dict(as_series=False) == ref.to_dict(as_series=False)
61+
assert out.to_dict(as_series=False) == {"x": [2], "y": [50]}
62+
63+
64+
# --- unit tests for the stripping logic -------------------------------------
65+
66+
import json # noqa: E402
67+
68+
from duckdb.polars_io import _strip_dynamic_predicates # noqa: E402
69+
70+
_DYN = {"Display": {"inputs": [{"Column": "x"}], "fmt_str": "dynamic_pred: abc-123"}}
71+
72+
73+
def _tree(expr: pl.Expr) -> dict:
74+
return json.loads(expr.meta.serialize(format="json"))
75+
76+
77+
def test_strip_bare_dynamic_predicate_returns_none() -> None:
78+
assert _strip_dynamic_predicates(_DYN) == (None, True)
79+
80+
81+
def test_strip_leaves_real_predicate_untouched() -> None:
82+
real = _tree(pl.col("y") > 15)
83+
assert _strip_dynamic_predicates(real) == (real, False)
84+
85+
86+
def test_strip_and_of_real_and_dynamic_keeps_real() -> None:
87+
real = _tree(pl.col("y") > 15)
88+
for tree in (
89+
{"BinaryExpr": {"left": real, "op": "And", "right": _DYN}},
90+
{"BinaryExpr": {"left": _DYN, "op": "And", "right": real}},
91+
):
92+
stripped, removed = _strip_dynamic_predicates(tree)
93+
assert removed is True
94+
assert stripped == real
95+
96+
97+
def test_strip_raises_on_dynamic_outside_top_level_and() -> None:
98+
# An OR with a dynamic predicate cannot be safely dropped or applied.
99+
tree = {"BinaryExpr": {"left": _tree(pl.col("y") > 15), "op": "Or", "right": _DYN}}
100+
with pytest.raises(NotImplementedError):
101+
_strip_dynamic_predicates(tree)

0 commit comments

Comments
 (0)