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 connection budget, not the CPU
29+ # compute pool. Tune with `POLARS_IO_TOOLS_MAX_SQL_CONNECTIONS`; when unset the default is
30+ # `min(pl.thread_pool_size(), 8)` -- a modest 8 on a normal machine, but self-throttling to 1 in
31+ # fan-out clusters that pin `POLARS_MAX_THREADS=1` per worker (where the real risk is N workers x
32+ # N connections). This treats `POLARS_MAX_THREADS` only as a downward ceiling / "be gentle"
33+ # signal, not as a compute-sizing driver. The shared executor is the global governor so
34+ # concurrent scans cannot collectively oversubscribe.
35+ _env_sql_connections = os .environ .get ("POLARS_IO_TOOLS_MAX_SQL_CONNECTIONS" )
36+ _DEFAULT_SQL_CONNECTIONS = max (1 , int (_env_sql_connections )) if _env_sql_connections else min (pl .thread_pool_size (), 8 )
37+ _SQL_EXECUTOR : concurrent .futures .ThreadPoolExecutor | None = None
38+ _SQL_EXECUTOR_LOCK = threading .Lock ()
39+
40+
41+ def _get_sql_executor () -> concurrent .futures .ThreadPoolExecutor :
42+ global _SQL_EXECUTOR
43+ with _SQL_EXECUTOR_LOCK :
44+ if _SQL_EXECUTOR is None :
45+ _SQL_EXECUTOR = concurrent .futures .ThreadPoolExecutor (max_workers = _DEFAULT_SQL_CONNECTIONS )
46+ return _SQL_EXECUTOR
47+
2248
2349@lru_cache (None )
2450def get_sqlglot_dialect_odbc (conn_string : str ) -> str | type [Dialect ] | None :
@@ -90,7 +116,16 @@ def get_schema_from_query_odbc(
90116 raise ValueError (f"Could not determine schema for query: { query } , with error: { e } " ) from e
91117
92118
93- def scan_db (query : str , connection : str , fetch_size : int = 10000 , ** kwargs ) -> pl .LazyFrame :
119+ def scan_db (
120+ query : str ,
121+ connection : str ,
122+ fetch_size : int = 10000 ,
123+ * ,
124+ partitions : "Partitioner | Iterable[ReadPartition] | None" = None ,
125+ max_partitions : int = 512 ,
126+ max_concurrency : int | None = None ,
127+ ** kwargs ,
128+ ) -> pl .LazyFrame :
94129 """
95130 Create a LazyFrame from a SQL query with predicate pushdown support.
96131
@@ -100,19 +135,39 @@ def scan_db(query: str, connection: str, fetch_size: int = 10000, **kwargs) -> p
100135 connection with optimized predicate pushdown. Filters applied to the LazyFrame will
101136 be translated back to SQL and pushed to the database.
102137
138+ When ``partitions`` is set, the reader splits the query into independent slices and pulls
139+ them over parallel connections, concatenating the results in order. This can dramatically
140+ speed up large, scan-like extracts whose single-cursor transfer is the bottleneck. It is
141+ fully opt-in: with ``partitions=None`` the behaviour is identical to a plain single scan.
142+
103143 Args:
104144 query (str): The SQL query to execute
105145 connection (str): A connection string (*not* a database connection object)
106146 fetch_size (int, default 10000): Number of rows to fetch at a time. This is a default needed by the \
107147 source generator function that scan_db wraps (because it is required \
108148 by the Polars IO plugins API). This value will only be used if Polars \
109149 does not pass a value for batch size; if it does, that will be used instead.
150+ partitions (Partitioner | Iterable[ReadPartition] | None, default None): How to split the read. \
151+ Pass a partitioner from :mod:`polars_io_tools.io_sources.partitions` (``by_time``, ``by_value``, \
152+ ``by_range``) to derive slices from the filter pushed down at scan time, or an explicit iterable \
153+ of :class:`ReadPartition` for hand-built slices. Each slice becomes one query on its own \
154+ connection; any part of a slice's predicate that cannot be pushed to SQL is enforced \
155+ client-side. When a partitioner cannot derive a bounded split, the query runs unpartitioned.
156+ max_partitions (int, default 512): Guardrail -- if partitioning would produce more than this \
157+ many slices, raise (raise this limit or coarsen the partitions).
158+ max_concurrency (int | None, default None): Optional cap on the number of partitions pulled \
159+ simultaneously. Hard-capped at the process-wide SQL connection budget \
160+ (``POLARS_IO_TOOLS_MAX_SQL_CONNECTIONS``; default ``min(pl.thread_pool_size(), 8)``, which \
161+ self-throttles in fan-out clusters that pin ``POLARS_MAX_THREADS=1``). Use it to throttle \
162+ *below* that on a shared server. None means use the full budget.
110163 **kwargs: Additional arguments for the database connector
111164
112165 Returns:
113166 pl.LazyFrame: A Polars LazyFrame with predicate pushdown support
114167 """
115168
169+ conn_string = connection if isinstance (connection , str ) else str (connection )
170+
116171 def _fetch_info_needing_connection () -> tuple [
117172 dict [str , pl .DataType ],
118173 exp .Expression ,
@@ -132,6 +187,52 @@ def _fetch_info_needing_connection() -> tuple[
132187
133188 schema , parsed_query , dialect = _fetch_info_needing_connection ()
134189
190+ if isinstance (partitions , Partitioner ) and partitions .on not in schema :
191+ raise ValueError (f"partition column { partitions .on !r} is not in the query schema { list (schema )} " )
192+
193+ def _select_cols (df : pl .DataFrame , with_columns : list [str ] | None ) -> pl .DataFrame :
194+ if with_columns is not None :
195+ wanted = set (with_columns )
196+ return df .select (col for col in schema if col in wanted )
197+ return df
198+
199+ def _build_sql (
200+ predicate : pl .Expr | None ,
201+ with_columns : list [str ] | None ,
202+ n_rows : int | None ,
203+ batch_size : int | None ,
204+ ) -> str :
205+ # Reuse the shared subquery machinery (MSSQL ORDER BY / OPTION hoisting, identifier
206+ # quoting) for both the pushed predicate and any partition bound folded into it.
207+ final_query_expr = apply_polars_io_source_exprs (parsed_query .copy (), dialect , with_columns , predicate , n_rows , batch_size )
208+ return final_query_expr .transform (fix_three_part_identifiers ).sql (dialect = dialect )
209+
210+ def _read (sql : str , batch_size : int | None ):
211+ from arrow_odbc import read_arrow_batches_from_odbc
212+
213+ return read_arrow_batches_from_odbc (
214+ query = sql ,
215+ batch_size = fetch_size if batch_size is None else batch_size ,
216+ connection_string = conn_string ,
217+ ** kwargs ,
218+ )
219+
220+ def _fetch_all (sql : str , client_predicate : pl .Expr | None , keep_columns : list [str ] | None , batch_size : int | None ) -> list [pl .DataFrame ]:
221+ """Run one partition on its own connection and return all its DataFrames (worker body).
222+
223+ ``client_predicate`` (the pushed predicate ANDed with the partition bound) is reapplied
224+ here so the slice is exact even if only part of it translated to SQL -- this is what
225+ guarantees partitions stay disjoint. ``keep_columns`` are the caller's requested columns;
226+ any extra columns retained only to evaluate the predicate are dropped.
227+ """
228+ out : list [pl .DataFrame ] = []
229+ for record_batch in _read (sql , batch_size ):
230+ df = pl .DataFrame (record_batch )
231+ if client_predicate is not None :
232+ df = df .filter (client_predicate )
233+ out .append (_select_cols (df , keep_columns ))
234+ return out
235+
135236 # Create the generator function for our custom IO source
136237 def source_generator (
137238 with_columns : list [str ] | None ,
@@ -142,57 +243,119 @@ def source_generator(
142243 # Short-circuit: if the caller already knows zero rows are needed
143244 # (e.g. from head(0) on a contradictory filter), skip the query entirely.
144245 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
246+ yield _select_cols (pl .DataFrame ({}, schema = schema ), with_columns )
149247 return
150248
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 ,
249+ # Resolve the partition slices. ``as_partition_list`` distinguishes three cases:
250+ # None -> a partitioner could not derive a bounded split (fall back to one query),
251+ # [] -> a known-empty partition set (yield nothing),
252+ # list -> concrete slices.
253+ partition_list = as_partition_list (partitions , predicate ) if partitions is not None else None
254+
255+ if partition_list is not None and len (partition_list ) > max_partitions :
256+ # A concrete partition set may intentionally select a subset (e.g. by_value with an
257+ # explicit value list), so it cannot be silently replaced by a single unpartitioned
258+ # query -- raise rather than risk returning extra rows.
259+ raise ValueError (
260+ f"Partition count { len (partition_list )} exceeds max_partitions={ max_partitions } ; raise max_partitions or coarsen the partitions."
171261 )
172262
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
263+ if partition_list is not None and len (partition_list ) == 0 :
264+ yield _select_cols (pl .DataFrame ({}, schema = schema ), with_columns )
265+ return
183266
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
267+ # Build (sql, client_predicate) per slice. For a partitioned read we AND the partition
268+ # bound into the pushed predicate (reused by both the server-side SQL and the client-side
269+ # safety filter) and retain the columns those predicates need through projection so the
270+ # filter can be evaluated; n_rows is applied client-side across the ordered stream.
271+ if partition_list :
272+ preds = [part .predicate for part in partition_list ]
273+ if predicate is not None :
274+ preds .append (predicate )
275+ effective_wc = retained_columns (preds , with_columns )
276+ work : list [tuple [str , pl .Expr | None ]] = []
277+ for part in partition_list :
278+ combined = part .predicate if predicate is None else (predicate & part .predicate )
279+ work .append ((_build_sql (combined , effective_wc , None , batch_size ), combined ))
280+ else :
281+ work = [(_build_sql (predicate , with_columns , n_rows , batch_size ), predicate )]
282+
283+ for sql , _ in work :
284+ log .debug ("Executing SQL with pushdown: %s" , sql )
285+ log .debug ("scan_db running %d partition(s)" , len (work ))
286+
287+ yielded_rows = 0
288+
289+ def _emit (df : pl .DataFrame ):
290+ """Yield a frame honouring the global n_rows cap across the ordered stream."""
291+ nonlocal yielded_rows
292+ if n_rows is not None :
293+ remaining = n_rows - yielded_rows
294+ if remaining <= 0 :
295+ return
296+ if df .height > remaining :
297+ df = df .head (remaining )
298+ yielded_rows += df .height
299+ yield df
190300
191- if count == 0 :
192- yield select_cols (pl .DataFrame ({}, schema = schema ))
301+ try :
302+ if len (work ) == 1 :
303+ # Single-connection streaming path (preserves original behaviour incl. empty result).
304+ sql , client_predicate = work [0 ]
305+ count = 0
306+ for record_batch in _read (sql , batch_size ):
307+ df = pl .DataFrame (record_batch )
308+ if client_predicate is not None :
309+ df = df .filter (client_predicate )
310+ yield from _emit (_select_cols (df , with_columns ))
311+ count += 1
312+ if n_rows is not None and yielded_rows >= n_rows :
313+ break
314+ if count == 0 :
315+ yield _select_cols (pl .DataFrame ({}, schema = schema ), with_columns )
316+ return
317+
318+ # Partitioned path: bounded-concurrency fan-out with strict in-order yield.
319+ executor = _get_sql_executor ()
320+ k = _DEFAULT_SQL_CONNECTIONS if max_concurrency is None else max (1 , min (max_concurrency , _DEFAULT_SQL_CONNECTIONS ))
321+ futures : dict [concurrent .futures .Future , int ] = {}
322+ completed : dict [int , list [pl .DataFrame ]] = {}
323+ next_submit = 0
324+ next_yield = 0
325+
326+ def _submit_more ():
327+ nonlocal next_submit
328+ while len (futures ) < k and next_submit < len (work ):
329+ sql , client_predicate = work [next_submit ]
330+ fut = executor .submit (_fetch_all , sql , client_predicate , with_columns , batch_size )
331+ futures [fut ] = next_submit
332+ next_submit += 1
333+
334+ _submit_more ()
335+ while next_yield < len (work ):
336+ while next_yield in completed :
337+ for df in completed .pop (next_yield ):
338+ yield from _emit (df )
339+ next_yield += 1
340+ _submit_more ()
341+ if n_rows is not None and yielded_rows >= n_rows :
342+ for fut in futures :
343+ fut .cancel ()
344+ futures .clear ()
345+ completed .clear ()
346+ return
347+ if next_yield >= len (work ) or not futures :
348+ break
349+ done , _ = concurrent .futures .wait (list (futures .keys ()), return_when = concurrent .futures .FIRST_COMPLETED )
350+ for fut in done :
351+ completed [futures .pop (fut )] = fut .result ()
352+ _submit_more ()
353+
354+ if yielded_rows == 0 :
355+ yield _select_cols (pl .DataFrame ({}, schema = schema ), with_columns )
193356
194357 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 "
358+ err_msg = f"Failed to execute SQL query. \n Predicate:\n { predicate } \n The `with_columns` used: { with_columns } \n "
196359 err_msg += f"\n \n While running the above, received error: { e .__class__ .__name__ } :{ e } "
197360 raise RuntimeError (err_msg ) from e
198361
0 commit comments