1+ import concurrent .futures
12import logging
3+ import os
4+ import threading
5+ from collections .abc import Iterable
26from functools import lru_cache
37from typing import Any
48
59import polars as pl
610from sqlglot import exp , parse_one
711from sqlglot .dialects .dialect import Dialect
812
13+ from .partitions import Partitioner , ReadPartition , as_partition_list , retained_columns
914from .sql_dialects import MSSQL
1015from .sql_utils import (
1116 apply_polars_io_source_exprs ,
1924# Configure logging
2025log = 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 )
2447def 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 } \n Predicate:\n { predicate } \n The `with_columns` used: { with_columns } \n "
354+ err_msg = f"Failed to execute SQL query. \n Predicate:\n { predicate } \n The `with_columns` used: { with_columns } \n "
196355 err_msg += f"\n \n While running the above, received error: { e .__class__ .__name__ } :{ e } "
197356 raise RuntimeError (err_msg ) from e
198357
0 commit comments