|
9 | 9 | cartesian product of chunks. Requests that already fit get a trivial |
10 | 10 | single-step plan — ``ChunkedCall`` has one code path either way. |
11 | 11 |
|
| 12 | +Parallel chunks: the planner is conservative by default — it splits only as far as |
| 13 | +the byte limit forces. A caller who knows their result is large can opt into a |
| 14 | +finer split via the ``parallel_chunks(n)`` context manager, which fans the query |
| 15 | +out into ``n`` parallel sub-requests; ``n`` drives :meth:`ChunkPlan._refine`. See |
| 16 | +``parallel_chunks`` for the why and the when. |
| 17 | +
|
12 | 18 | This module owns the *execution* half — the event loop and bounded |
13 | 19 | concurrency that drive a plan to completion (``ChunkedCall``) plus the |
14 | 20 | public ``multi_value_chunked`` decorator. The neighboring concerns live in |
|
69 | 75 | import functools |
70 | 76 | import os |
71 | 77 | from collections.abc import Callable, Iterator |
| 78 | +from contextlib import contextmanager |
72 | 79 | from contextvars import copy_context |
73 | 80 | from typing import Any, cast |
74 | 81 |
|
75 | 82 | import httpx |
76 | 83 | import pandas as pd |
77 | 84 | from anyio.from_thread import start_blocking_portal |
78 | 85 |
|
79 | | -from dataretrieval.utils import HTTPX_DEFAULTS, Ambient |
| 86 | +from dataretrieval.utils import HTTPX_DEFAULTS, Ambient, _require_positive_int |
80 | 87 |
|
81 | 88 | from . import progress as _progress |
82 | 89 | from .interruptions import ( |
@@ -172,6 +179,118 @@ def get_active_client() -> httpx.AsyncClient | None: |
172 | 179 | return _chunked_client.get() |
173 | 180 |
|
174 | 181 |
|
| 182 | +# Parallel-chunks dial: opt-in to fan a query out *more finely* than the byte |
| 183 | +# limit alone requires. Scoped to a ``with parallel_chunks(...):`` block (a |
| 184 | +# ContextVar), deliberately NOT an env var (see :func:`parallel_chunks` for |
| 185 | +# why). The ambient holds ``n`` — the requested cap on the plan's total |
| 186 | +# sub-request count; ``1`` (the default, outside any block) means "off — chunk |
| 187 | +# only as much as the byte limit needs, no extra fan-out". |
| 188 | +_parallel_chunks: Ambient[int] = Ambient("ogc_parallel_chunks", 1) |
| 189 | + |
| 190 | + |
| 191 | +@contextmanager |
| 192 | +def parallel_chunks(n: int) -> Iterator[None]: |
| 193 | + """ |
| 194 | + Fan the OGC getters' multi-value requests out into ``n`` parallel sub-requests. |
| 195 | +
|
| 196 | + By default the Water Data / NGWMN getters chunk a request only as much as |
| 197 | + the server's ~8 KB URL-byte limit forces — the fewest sub-requests that |
| 198 | + fit. That is the safe default, but it can be *needlessly* conservative: |
| 199 | + because every sub-request paginates, splitting a large result further costs |
| 200 | + little or no extra quota *as long as each sub-request still spans many |
| 201 | + pages* — rows-per-chunk far exceeding the page size (ten states pulled as |
| 202 | + one request then page nearly as many times as ten per-state requests |
| 203 | + would). When a split leaves each sub-request only a page or two, its partial |
| 204 | + final page is extra, so finer chunks do add some requests. This context |
| 205 | + manager lets a caller who *knows* their pull is large ask for that finer |
| 206 | + split — trading roughly the same pages for more, smaller sub-requests, which |
| 207 | + gives smoother progress, more even concurrency, and a smaller unit of |
| 208 | + retry/resume. |
| 209 | +
|
| 210 | + Because the library can't tell in advance whether a query is large (ten |
| 211 | + states over a short window might fit in a single page, where extra chunks |
| 212 | + would only burn quota), this is a *deliberate* per-call knob rather than an |
| 213 | + automatic behavior or a process-wide environment variable — scoping it to a |
| 214 | + ``with`` block keeps an aggressive setting from leaking into unrelated calls |
| 215 | + and accidentally spending quota. Outside any block the getters use the |
| 216 | + conservative default. Only the OGC getters (Water Data, NGWMN) read this; |
| 217 | + wrapping a legacy NWIS call in the block is a harmless no-op. |
| 218 | +
|
| 219 | + Parameters |
| 220 | + ---------- |
| 221 | + n : int |
| 222 | + The number of sub-requests to fan the whole call out into — a positive |
| 223 | + integer such as ``2``, ``8``, or ``32``. It caps the plan's *total* |
| 224 | + sub-request count (the cartesian product across every multi-value |
| 225 | + argument combined, not per argument), so several multi-value arguments |
| 226 | + cannot multiply past it. The cap is a ceiling, never exceeded: the |
| 227 | + actual count is bounded below by what the ~8 KB URL limit already |
| 228 | + forces and above by ``n``, so an ``n`` larger than the input allows |
| 229 | + simply yields one sub-request per value, and with several multi-value |
| 230 | + arguments the total may land somewhat below ``n`` because splits are |
| 231 | + whole (the plan can't always divide evenly onto ``n``); ``n=1`` asks |
| 232 | + for no extra fan-out. |
| 233 | +
|
| 234 | + Each sub-request fetches at least one page, so it costs at least one |
| 235 | + request against your hourly rate limit — a larger ``n`` spends more |
| 236 | + quota. And because how many sub-requests run *at once* is capped |
| 237 | + separately by ``API_USGS_CONCURRENT`` (default 32), an ``n`` beyond that |
| 238 | + adds quota without adding parallelism; the useful range is roughly ``2`` |
| 239 | + up to ``API_USGS_CONCURRENT``. |
| 240 | +
|
| 241 | + Yields |
| 242 | + ------ |
| 243 | + None |
| 244 | +
|
| 245 | + Raises |
| 246 | + ------ |
| 247 | + ValueError |
| 248 | + If ``n`` is not a positive integer — raised on ``with`` entry, before |
| 249 | + any request is issued, so a bad value fails loudly rather than silently |
| 250 | + doing nothing. |
| 251 | +
|
| 252 | + Notes |
| 253 | + ----- |
| 254 | + Fanning out carries the same consequences as the byte-limit chunking the |
| 255 | + getters already do for oversized requests; opting in just brings them to a |
| 256 | + request that would otherwise be a single call: |
| 257 | +
|
| 258 | + - ``max_rows``: each sub-request paginates up to ``max_rows`` rows |
| 259 | + independently, then the combined result is sorted and truncated to |
| 260 | + ``max_rows``. So a call with ``max_rows`` set returns a *different* |
| 261 | + (though still valid and deterministically sorted) row set inside a |
| 262 | + ``parallel_chunks`` block than without one — the cap is drawn from the |
| 263 | + union of the sub-requests, not a single stream. Don't pair a tight |
| 264 | + ``max_rows`` preview with ``parallel_chunks`` if you need exactly the |
| 265 | + rows the un-fanned call would return. |
| 266 | + - Resumability: a single request either fully succeeds or fully fails, |
| 267 | + but a fanned-out call can fail partway (e.g. a mid-call rate-limit) and |
| 268 | + raise a resumable :class:`~dataretrieval.exceptions.ChunkInterrupted` |
| 269 | + (or ``QuotaExhausted``) carrying the completed sub-requests, which you |
| 270 | + finish with ``exc.call.resume()``. |
| 271 | + - Cross-sub-request de-duplication keys on the feature ``id``; features |
| 272 | + with no ``id`` can't be deduped, so overlapping filter clauses split |
| 273 | + across chunks may yield duplicate rows. |
| 274 | +
|
| 275 | + Examples |
| 276 | + -------- |
| 277 | + >>> from dataretrieval import waterdata |
| 278 | + >>> with waterdata.parallel_chunks(32): |
| 279 | + ... df, md = waterdata.get_daily( |
| 280 | + ... monitoring_location_id=many_sites, parameter_code="00060" |
| 281 | + ... ) # doctest: +SKIP |
| 282 | +
|
| 283 | + See Also |
| 284 | + -------- |
| 285 | + ChunkPlan._refine : the planning-side effect of ``n``. |
| 286 | + """ |
| 287 | + # Fail loudly on a bad ``n`` at ``with`` entry, before any request. Shared |
| 288 | + # rules with ``max_rows`` via the helper (accepts numpy ints, rejects bool). |
| 289 | + _require_positive_int(n, "parallel_chunks(n)", examples="2, 8, 32") |
| 290 | + with _parallel_chunks(n): |
| 291 | + yield |
| 292 | + |
| 293 | + |
175 | 294 | class ChunkedCall: |
176 | 295 | """ |
177 | 296 | Stateful handle for a chunked call. |
@@ -591,8 +710,9 @@ def multi_value_chunked( |
591 | 710 | ``async def fetch(args) -> (df, response)``, and drives it to |
592 | 711 | completion via :meth:`ChunkedCall.resume`. The plan splits multi-value |
593 | 712 | list params and the cql-text filter so each sub-request URL fits the |
594 | | - byte limit; an already-fitting request is a one-step plan. See the |
595 | | - module docstring for the concurrency model. |
| 713 | + byte limit; an already-fitting request is a one-step plan, unless an |
| 714 | + active :func:`parallel_chunks` block asks the plan to fan out more |
| 715 | + finely. See the module docstring for the concurrency model. |
596 | 716 |
|
597 | 717 | Parameters |
598 | 718 | ---------- |
@@ -636,7 +756,14 @@ def wrapper( |
636 | 756 | finalize: _Finalize = _passthrough_result, |
637 | 757 | ) -> tuple[pd.DataFrame, Any]: |
638 | 758 | limit = _OGC_URL_BYTE_LIMIT if url_limit is None else url_limit |
639 | | - plan = ChunkPlan(args, build_request, limit) |
| 759 | + # Read the parallel_chunks dial ``n`` from the ambient set by |
| 760 | + # ``parallel_chunks`` (1 = off outside any such block; otherwise the |
| 761 | + # requested total sub-request cap). It only affects *planning*, done |
| 762 | + # here up front, so a later resume — which re-issues the |
| 763 | + # already-planned sub-requests — needs no snapshot. |
| 764 | + plan = ChunkPlan( |
| 765 | + args, build_request, limit, max_chunks=_parallel_chunks.get() |
| 766 | + ) |
640 | 767 | retry_policy = RetryPolicy.from_env() |
641 | 768 | # The concurrency cap is resolved inside ``resume()`` from |
642 | 769 | # ``API_USGS_CONCURRENT``; ``1`` is a sequential gather, |
|
0 commit comments