Skip to content

Commit 9218944

Browse files
authored
Merge pull request #31 from Point72/feat/scan-db-partitioned
Add opt-in partitioned reads to scan_db
2 parents 2a17d11 + c172725 commit 9218944

3 files changed

Lines changed: 896 additions & 45 deletions

File tree

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,67 @@ example SQL Server can still seek on `CAST(datetime AS date)`) while others fall
6767
full scan. When a filtered column is indexed and on a hot path, prefer filtering the
6868
physical column directly over its cast form.
6969

70+
## Speed up a large SQL read by partitioning it
71+
72+
When a scan-like extract is bounded by an indexed column, a single ODBC cursor is often the
73+
bottleneck. Pass `partitions=` and `scan_db` splits the read into independent slices and pulls
74+
them over parallel connections, concatenating the results in order. Build the slices from the
75+
filter you push down with `by_time`, `by_value`, or `by_range`:
76+
77+
```python
78+
from polars_io_tools import scan_db, by_time
79+
80+
lf = scan_db(
81+
"SELECT * FROM daily_prices",
82+
connection="Driver={PostgreSQL};Server=db.example.com;Database=mkt;Uid=reader;******",
83+
partitions=by_time("price_date", every="1mo"),
84+
)
85+
86+
# One slice per month, taken from the pushed-down date range:
87+
result = lf.filter(
88+
(pl.col("price_date") >= pl.date(2025, 1, 1)) & (pl.col("price_date") < pl.date(2025, 7, 1))
89+
).collect()
90+
```
91+
92+
- `by_time(column, every=)` — calendar windows; `every` is an interval string (`"1mo"`, `"2w"`,
93+
`"5d"`, `"1q"`, `"1y"`) or an integer number of days.
94+
- `by_value(column, values=None)` — one slice per discrete value; with `values=None` the values
95+
are read from the `IN` filter you push down.
96+
- `by_range(column, every=)` — fixed-width numeric buckets over the pushed-down range.
97+
98+
How many slices run at once is capped by a process-wide SQL connection budget
99+
(`POLARS_IO_TOOLS_MAX_SQL_CONNECTIONS`; default `min(pl.thread_pool_size(), 8)` — a modest 8 on a
100+
normal machine, self-throttling to 1 in fan-out clusters that pin `POLARS_MAX_THREADS=1`, since
101+
these reads are IO-bound and the cap is a connection budget, not the CPU thread pool); pass
102+
`max_concurrency=` to throttle below it on a busy server. Partitioning is fully opt-in — with no
103+
`partitions=`, or when a partitioner cannot derive a bounded split, `scan_db` runs the query over
104+
a single connection. Prefer a handful of medium slices over many tiny ones: each slice is a
105+
separate query with its own planning and round-trip cost.
106+
107+
For hand-built slices, pass an iterable of `ReadPartition(predicate, key)`. Predicates that
108+
translate to SQL are pushed to the database; any part that cannot (for example an arbitrary
109+
Python UDF) is still enforced client-side, so each slice stays exact.
110+
111+
Rows are yielded in completion order (whichever slice's batches arrive first), which is not
112+
deterministic; if you need a specific order, sort in Polars with `.sort()` after the read. If your
113+
query has a top-level `ORDER BY`, partitioning can't re-establish it across slices, so `scan_db`
114+
logs a warning and ignores it (the rows are still correct) — again, `.sort()` after the read.
115+
Clauses that would change the *result* under partitioning — a top-level `LIMIT`/`OFFSET`/`TOP`/`FETCH`,
116+
`QUALIFY`, or a window function — raise instead; apply them in Polars after the read (`.head()`, etc.),
117+
or read without `partitions=`.
118+
119+
Each slice is streamed batch-by-batch over its own connection, so peak memory stays proportional to
120+
the connection budget times the arrow batch size — a large slice is never fully buffered in memory.
121+
122+
Each slice opens its own ODBC connection, so a read split into many slices pays that many connects.
123+
If connect overhead dominates (many small slices, or TLS/Kerberos on every connect), prefer a few
124+
larger slices, and/or let the ODBC driver manager recycle physical connections by calling
125+
[`arrow_odbc.enable_odbc_connection_pooling()`](https://arrow-odbc.readthedocs.io/) once before your
126+
first read — pooled connects skip the handshake. This is a process-global arrow-odbc / driver-manager
127+
setting (it affects all ODBC use in your process and reuses physical connections, so session-scoped
128+
state such as temp tables can persist across them), so it is left to the caller rather than toggled
129+
by `scan_db`.
130+
70131
## Read from ClickHouse
71132

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

0 commit comments

Comments
 (0)