Skip to content

Commit 960293e

Browse files
committed
feat(sql): add opt-in partitioned reads to scan_db
Add a `partitions=` argument to `scan_db` that splits a read into independent slices pulled over parallel connections and concatenated in order, speeding up large scan-like extracts whose single-cursor transfer is the bottleneck. `scan_db` consumes the shared read-partition vocabulary from `io_sources.partitions` (the same `ReadPartition` / `by_time` / `by_value` / `by_range` used by the distributed executor). Each slice's predicate is translated to a SQL `WHERE` and injected as an innermost subquery so it survives outer projection; a predicate that does not translate to SQL raises rather than silently reading the whole slice. Concurrency is capped by the Polars thread pool (`POLARS_MAX_THREADS`), with an optional `max_concurrency` to throttle below it. The path is fully opt-in and falls back to a single query when a partitioner cannot derive a bounded split. Add unit tests for the partitioned reader and a how-to section in the Reading and Writing Data wiki. Signed-off-by: Pascal Tomecek <40371786+ptomecek@users.noreply.github.com>
1 parent e6ebeca commit 960293e

3 files changed

Lines changed: 399 additions & 45 deletions

File tree

docs/wiki/Reading-and-Writing-Data.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,46 @@ The dialect is detected from the ODBC connection, so the generated SQL matches y
4141
database. Pass `fetch_size=` to control the batch size used when Polars does not
4242
request one.
4343

44+
## Speed up a large SQL read by partitioning it
45+
46+
When a scan-like extract is bounded by an indexed column, a single ODBC cursor is often the
47+
bottleneck. Pass `partitions=` and `scan_db` splits the read into independent slices and pulls
48+
them over parallel connections, concatenating the results in order. Build the slices from the
49+
filter you push down with `by_time`, `by_value`, or `by_range`:
50+
51+
```python
52+
from polars_io_tools import scan_db, by_time
53+
54+
lf = scan_db(
55+
"SELECT * FROM daily_prices",
56+
connection="Driver={PostgreSQL};Server=db.example.com;Database=mkt;Uid=reader;******",
57+
partitions=by_time("price_date", every="1mo"),
58+
)
59+
60+
# One slice per month, taken from the pushed-down date range:
61+
result = lf.filter(
62+
(pl.col("price_date") >= pl.date(2025, 1, 1)) & (pl.col("price_date") < pl.date(2025, 7, 1))
63+
).collect()
64+
```
65+
66+
- `by_time(column, every=)` — calendar windows; `every` is an interval string (`"1mo"`, `"2w"`,
67+
`"5d"`, `"1q"`, `"1y"`) or an integer number of days.
68+
- `by_value(column, values=None)` — one slice per discrete value; with `values=None` the values
69+
are read from the `IN` filter you push down.
70+
- `by_range(column, every=)` — fixed-width numeric buckets over the pushed-down range.
71+
72+
How many slices run at once is capped by a process-wide SQL connection budget
73+
(`POLARS_IO_TOOLS_MAX_SQL_CONNECTIONS`, default 8 — these reads are IO-bound, so the cap is a
74+
connection budget, not the CPU thread pool); pass `max_concurrency=` to throttle below it on a
75+
busy server. Partitioning is fully opt-in — with no `partitions=`, or when a partitioner cannot
76+
derive a bounded split, `scan_db` runs the query over a single connection. Prefer a handful of
77+
medium slices over many tiny ones: each slice is a separate query with its own planning and
78+
round-trip cost.
79+
80+
For hand-built slices, pass an iterable of `ReadPartition(predicate, key)`. Predicates that
81+
translate to SQL are pushed to the database; any part that cannot (for example an arbitrary
82+
Python UDF) is still enforced client-side, so each slice stays exact.
83+
4484
## Read from ClickHouse
4585

4686
`scan_clickhouse` streams query results over ClickHouse's HTTP interface as Arrow IPC.

polars_io_tools/io_sources/lazy_sql_reader.py

Lines changed: 203 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
import concurrent.futures
12
import logging
3+
import os
4+
import threading
5+
from collections.abc import Iterable
26
from functools import lru_cache
37
from typing import Any
48

59
import polars as pl
610
from sqlglot import exp, parse_one
711
from sqlglot.dialects.dialect import Dialect
812

13+
from .partitions import Partitioner, ReadPartition, as_partition_list, retained_columns
914
from .sql_dialects import MSSQL
1015
from .sql_utils import (
1116
apply_polars_io_source_exprs,
@@ -19,6 +24,24 @@
1924
# Configure logging
2025
log = logging.getLogger(__name__)
2126

27+
# Process-wide budget for concurrent SQL connections. Partition reads are IO-bound (each worker
28+
# is mostly blocked on the database), so the right cap is an IO concurrency budget, not the CPU
29+
# compute pool: `POLARS_MAX_THREADS` / `pl.thread_pool_size()` governs Rayon compute and is the
30+
# wrong dimension here. The compute portion (Arrow decode + client-side filter) stays bounded by
31+
# Rayon regardless. Tune with the `POLARS_IO_TOOLS_MAX_SQL_CONNECTIONS` env var (default 8); the
32+
# shared executor is the global governor so concurrent scans cannot collectively oversubscribe.
33+
_DEFAULT_SQL_CONNECTIONS = max(1, int(os.environ.get("POLARS_IO_TOOLS_MAX_SQL_CONNECTIONS", "8")))
34+
_SQL_EXECUTOR: concurrent.futures.ThreadPoolExecutor | None = None
35+
_SQL_EXECUTOR_LOCK = threading.Lock()
36+
37+
38+
def _get_sql_executor() -> concurrent.futures.ThreadPoolExecutor:
39+
global _SQL_EXECUTOR
40+
with _SQL_EXECUTOR_LOCK:
41+
if _SQL_EXECUTOR is None:
42+
_SQL_EXECUTOR = concurrent.futures.ThreadPoolExecutor(max_workers=_DEFAULT_SQL_CONNECTIONS)
43+
return _SQL_EXECUTOR
44+
2245

2346
@lru_cache(None)
2447
def get_sqlglot_dialect_odbc(conn_string: str) -> str | type[Dialect] | None:
@@ -90,7 +113,16 @@ def get_schema_from_query_odbc(
90113
raise ValueError(f"Could not determine schema for query: {query}, with error: {e}") from e
91114

92115

93-
def scan_db(query: str, connection: str, fetch_size: int = 10000, **kwargs) -> pl.LazyFrame:
116+
def scan_db(
117+
query: str,
118+
connection: str,
119+
fetch_size: int = 10000,
120+
*,
121+
partitions: "Partitioner | Iterable[ReadPartition] | None" = None,
122+
max_partitions: int = 512,
123+
max_concurrency: int | None = None,
124+
**kwargs,
125+
) -> pl.LazyFrame:
94126
"""
95127
Create a LazyFrame from a SQL query with predicate pushdown support.
96128
@@ -100,19 +132,38 @@ def scan_db(query: str, connection: str, fetch_size: int = 10000, **kwargs) -> p
100132
connection with optimized predicate pushdown. Filters applied to the LazyFrame will
101133
be translated back to SQL and pushed to the database.
102134
135+
When ``partitions`` is set, the reader splits the query into independent slices and pulls
136+
them over parallel connections, concatenating the results in order. This can dramatically
137+
speed up large, scan-like extracts whose single-cursor transfer is the bottleneck. It is
138+
fully opt-in: with ``partitions=None`` the behaviour is identical to a plain single scan.
139+
103140
Args:
104141
query (str): The SQL query to execute
105142
connection (str): A connection string (*not* a database connection object)
106143
fetch_size (int, default 10000): Number of rows to fetch at a time. This is a default needed by the \
107144
source generator function that scan_db wraps (because it is required \
108145
by the Polars IO plugins API). This value will only be used if Polars \
109146
does not pass a value for batch size; if it does, that will be used instead.
147+
partitions (Partitioner | Iterable[ReadPartition] | None, default None): How to split the read. \
148+
Pass a partitioner from :mod:`polars_io_tools.io_sources.partitions` (``by_time``, ``by_value``, \
149+
``by_range``) to derive slices from the filter pushed down at scan time, or an explicit iterable \
150+
of :class:`ReadPartition` for hand-built slices. Each slice becomes one query on its own \
151+
connection; any part of a slice's predicate that cannot be pushed to SQL is enforced \
152+
client-side. When a partitioner cannot derive a bounded split, the query runs unpartitioned.
153+
max_partitions (int, default 512): Guardrail -- if partitioning would produce more than this \
154+
many slices, raise (raise this limit or coarsen the partitions).
155+
max_concurrency (int | None, default None): Optional cap on the number of partitions pulled \
156+
simultaneously. Hard-capped at the process-wide SQL connection budget \
157+
(``POLARS_IO_TOOLS_MAX_SQL_CONNECTIONS``, default 8); use it to throttle *below* that to be \
158+
gentle on a shared server. None means use the full budget.
110159
**kwargs: Additional arguments for the database connector
111160
112161
Returns:
113162
pl.LazyFrame: A Polars LazyFrame with predicate pushdown support
114163
"""
115164

165+
conn_string = connection if isinstance(connection, str) else str(connection)
166+
116167
def _fetch_info_needing_connection() -> tuple[
117168
dict[str, pl.DataType],
118169
exp.Expression,
@@ -132,6 +183,52 @@ def _fetch_info_needing_connection() -> tuple[
132183

133184
schema, parsed_query, dialect = _fetch_info_needing_connection()
134185

186+
if isinstance(partitions, Partitioner) and partitions.on not in schema:
187+
raise ValueError(f"partition column {partitions.on!r} is not in the query schema {list(schema)}")
188+
189+
def _select_cols(df: pl.DataFrame, with_columns: list[str] | None) -> pl.DataFrame:
190+
if with_columns is not None:
191+
wanted = set(with_columns)
192+
return df.select(col for col in schema if col in wanted)
193+
return df
194+
195+
def _build_sql(
196+
predicate: pl.Expr | None,
197+
with_columns: list[str] | None,
198+
n_rows: int | None,
199+
batch_size: int | None,
200+
) -> str:
201+
# Reuse the shared subquery machinery (MSSQL ORDER BY / OPTION hoisting, identifier
202+
# quoting) for both the pushed predicate and any partition bound folded into it.
203+
final_query_expr = apply_polars_io_source_exprs(parsed_query.copy(), dialect, with_columns, predicate, n_rows, batch_size)
204+
return final_query_expr.transform(fix_three_part_identifiers).sql(dialect=dialect)
205+
206+
def _read(sql: str, batch_size: int | None):
207+
from arrow_odbc import read_arrow_batches_from_odbc
208+
209+
return read_arrow_batches_from_odbc(
210+
query=sql,
211+
batch_size=fetch_size if batch_size is None else batch_size,
212+
connection_string=conn_string,
213+
**kwargs,
214+
)
215+
216+
def _fetch_all(sql: str, client_predicate: pl.Expr | None, keep_columns: list[str] | None, batch_size: int | None) -> list[pl.DataFrame]:
217+
"""Run one partition on its own connection and return all its DataFrames (worker body).
218+
219+
``client_predicate`` (the pushed predicate ANDed with the partition bound) is reapplied
220+
here so the slice is exact even if only part of it translated to SQL -- this is what
221+
guarantees partitions stay disjoint. ``keep_columns`` are the caller's requested columns;
222+
any extra columns retained only to evaluate the predicate are dropped.
223+
"""
224+
out: list[pl.DataFrame] = []
225+
for record_batch in _read(sql, batch_size):
226+
df = pl.DataFrame(record_batch)
227+
if client_predicate is not None:
228+
df = df.filter(client_predicate)
229+
out.append(_select_cols(df, keep_columns))
230+
return out
231+
135232
# Create the generator function for our custom IO source
136233
def source_generator(
137234
with_columns: list[str] | None,
@@ -142,57 +239,119 @@ def source_generator(
142239
# Short-circuit: if the caller already knows zero rows are needed
143240
# (e.g. from head(0) on a contradictory filter), skip the query entirely.
144241
if n_rows == 0:
145-
empty = pl.DataFrame({}, schema=schema)
146-
if with_columns is not None:
147-
empty = empty.select(col for col in schema if col in set(with_columns))
148-
yield empty
242+
yield _select_cols(pl.DataFrame({}, schema=schema), with_columns)
149243
return
150244

151-
# Generate a new SQL query by combining the original query with the predicate
152-
query_copy = parsed_query.copy()
153-
final_query_expr = apply_polars_io_source_exprs(query_copy, dialect, with_columns, predicate, n_rows, batch_size)
154-
# Convert back to SQL string
155-
final_sql = final_query_expr.sql(dialect=dialect)
156-
log.debug(f"Executing SQL with pushdown: {final_sql}")
157-
158-
# Create a connection string if needed
159-
conn_string = connection if isinstance(connection, str) else str(connection)
160-
try:
161-
from arrow_odbc import read_arrow_batches_from_odbc
162-
163-
# Use arrow_odbc directly to fetch results
164-
batch_reader = read_arrow_batches_from_odbc(
165-
query=final_sql,
166-
batch_size=fetch_size if batch_size is None else batch_size,
167-
connection_string=conn_string,
168-
# Pass through additional connection options
169-
# that the user specified in the parent function
170-
**kwargs,
245+
# Resolve the partition slices. ``as_partition_list`` distinguishes three cases:
246+
# None -> a partitioner could not derive a bounded split (fall back to one query),
247+
# [] -> a known-empty partition set (yield nothing),
248+
# list -> concrete slices.
249+
partition_list = as_partition_list(partitions, predicate) if partitions is not None else None
250+
251+
if partition_list is not None and len(partition_list) > max_partitions:
252+
# A concrete partition set may intentionally select a subset (e.g. by_value with an
253+
# explicit value list), so it cannot be silently replaced by a single unpartitioned
254+
# query -- raise rather than risk returning extra rows.
255+
raise ValueError(
256+
f"Partition count {len(partition_list)} exceeds max_partitions={max_partitions}; raise max_partitions or coarsen the partitions."
171257
)
172258

173-
# Track if we've yielded any batches yet
174-
# This is necessary in case the query yields
175-
# no records
176-
count = 0
177-
178-
def select_cols(df) -> pl.DataFrame:
179-
if with_columns is not None:
180-
with_cols_set = set(with_columns)
181-
return df.select(col for col in schema if col in with_cols_set)
182-
return df
259+
if partition_list is not None and len(partition_list) == 0:
260+
yield _select_cols(pl.DataFrame({}, schema=schema), with_columns)
261+
return
183262

184-
for record_batch in batch_reader:
185-
df = pl.DataFrame(record_batch)
186-
if predicate is not None:
187-
df = df.filter(predicate)
188-
yield select_cols(df)
189-
count += 1
263+
# Build (sql, client_predicate) per slice. For a partitioned read we AND the partition
264+
# bound into the pushed predicate (reused by both the server-side SQL and the client-side
265+
# safety filter) and retain the columns those predicates need through projection so the
266+
# filter can be evaluated; n_rows is applied client-side across the ordered stream.
267+
if partition_list:
268+
preds = [part.predicate for part in partition_list]
269+
if predicate is not None:
270+
preds.append(predicate)
271+
effective_wc = retained_columns(preds, with_columns)
272+
work: list[tuple[str, pl.Expr | None]] = []
273+
for part in partition_list:
274+
combined = part.predicate if predicate is None else (predicate & part.predicate)
275+
work.append((_build_sql(combined, effective_wc, None, batch_size), combined))
276+
else:
277+
work = [(_build_sql(predicate, with_columns, n_rows, batch_size), predicate)]
278+
279+
for sql, _ in work:
280+
log.debug("Executing SQL with pushdown: %s", sql)
281+
log.debug("scan_db running %d partition(s)", len(work))
282+
283+
yielded_rows = 0
284+
285+
def _emit(df: pl.DataFrame):
286+
"""Yield a frame honouring the global n_rows cap across the ordered stream."""
287+
nonlocal yielded_rows
288+
if n_rows is not None:
289+
remaining = n_rows - yielded_rows
290+
if remaining <= 0:
291+
return
292+
if df.height > remaining:
293+
df = df.head(remaining)
294+
yielded_rows += df.height
295+
yield df
190296

191-
if count == 0:
192-
yield select_cols(pl.DataFrame({}, schema=schema))
297+
try:
298+
if len(work) == 1:
299+
# Single-connection streaming path (preserves original behaviour incl. empty result).
300+
sql, client_predicate = work[0]
301+
count = 0
302+
for record_batch in _read(sql, batch_size):
303+
df = pl.DataFrame(record_batch)
304+
if client_predicate is not None:
305+
df = df.filter(client_predicate)
306+
yield from _emit(_select_cols(df, with_columns))
307+
count += 1
308+
if n_rows is not None and yielded_rows >= n_rows:
309+
break
310+
if count == 0:
311+
yield _select_cols(pl.DataFrame({}, schema=schema), with_columns)
312+
return
313+
314+
# Partitioned path: bounded-concurrency fan-out with strict in-order yield.
315+
executor = _get_sql_executor()
316+
k = _DEFAULT_SQL_CONNECTIONS if max_concurrency is None else max(1, min(max_concurrency, _DEFAULT_SQL_CONNECTIONS))
317+
futures: dict[concurrent.futures.Future, int] = {}
318+
completed: dict[int, list[pl.DataFrame]] = {}
319+
next_submit = 0
320+
next_yield = 0
321+
322+
def _submit_more():
323+
nonlocal next_submit
324+
while len(futures) < k and next_submit < len(work):
325+
sql, client_predicate = work[next_submit]
326+
fut = executor.submit(_fetch_all, sql, client_predicate, with_columns, batch_size)
327+
futures[fut] = next_submit
328+
next_submit += 1
329+
330+
_submit_more()
331+
while next_yield < len(work):
332+
while next_yield in completed:
333+
for df in completed.pop(next_yield):
334+
yield from _emit(df)
335+
next_yield += 1
336+
_submit_more()
337+
if n_rows is not None and yielded_rows >= n_rows:
338+
for fut in futures:
339+
fut.cancel()
340+
futures.clear()
341+
completed.clear()
342+
return
343+
if next_yield >= len(work) or not futures:
344+
break
345+
done, _ = concurrent.futures.wait(list(futures.keys()), return_when=concurrent.futures.FIRST_COMPLETED)
346+
for fut in done:
347+
completed[futures.pop(fut)] = fut.result()
348+
_submit_more()
349+
350+
if yielded_rows == 0:
351+
yield _select_cols(pl.DataFrame({}, schema=schema), with_columns)
193352

194353
except Exception as e:
195-
err_msg = f"Failed to execute SQL query: {final_sql}\nPredicate:\n{predicate}\n The `with_columns` used: {with_columns}\n"
354+
err_msg = f"Failed to execute SQL query.\nPredicate:\n{predicate}\n The `with_columns` used: {with_columns}\n"
196355
err_msg += f"\n\nWhile running the above, received error: {e.__class__.__name__}:{e}"
197356
raise RuntimeError(err_msg) from e
198357

0 commit comments

Comments
 (0)