|
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 | +Granularity: the planner is conservative by default — it splits only as far |
| 13 | +as the byte limit forces, minimizing sub-requests. A caller who knows their |
| 14 | +result is large can widen that split via the ``chunk_granularity`` context |
| 15 | +manager (``"low"`` / ``"medium"`` / ``"high"``): because every sub-request |
| 16 | +paginates, a finer split of a large result is usually quota-neutral, and buys |
| 17 | +smoother progress, more even concurrency, and a smaller unit of retry/resume. |
| 18 | +It is a scoped ``with`` block — not an env var like ``API_USGS_CONCURRENT`` — |
| 19 | +precisely so an aggressive setting can't leak past the call it was meant for |
| 20 | +and burn quota on a query that would have fit in one page. The level drives |
| 21 | +:meth:`ChunkPlan._refine`; see ``chunk_granularity`` for the full rationale. |
| 22 | +
|
12 | 23 | This module owns the *execution* half — the event loop and bounded |
13 | 24 | concurrency that drive a plan to completion (``ChunkedCall``) plus the |
14 | 25 | public ``multi_value_chunked`` decorator. The neighboring concerns live in |
|
69 | 80 | import functools |
70 | 81 | import os |
71 | 82 | from collections.abc import Callable, Iterator |
| 83 | +from contextlib import contextmanager |
72 | 84 | from contextvars import copy_context |
73 | | -from typing import Any, cast |
| 85 | +from typing import Any, Literal, cast |
74 | 86 |
|
75 | 87 | import httpx |
76 | 88 | import pandas as pd |
@@ -172,6 +184,128 @@ def get_active_client() -> httpx.AsyncClient | None: |
172 | 184 | return _chunked_client.get() |
173 | 185 |
|
174 | 186 |
|
| 187 | +# Chunk-granularity dial: opt-in to fan a query out *more finely* than the byte |
| 188 | +# limit alone requires. Scoped to a ``with chunk_granularity(...):`` block (a |
| 189 | +# ContextVar), deliberately NOT an env var — unlike ``API_USGS_CONCURRENT`` / |
| 190 | +# ``API_USGS_RETRIES``, a persistent process-wide setting here would be a quota |
| 191 | +# footgun (see :func:`chunk_granularity`). The ambient holds the resolved cap on |
| 192 | +# sub-chunks per axis; ``0`` (the default, outside any block) means "chunk only |
| 193 | +# as much as the byte limit needs". |
| 194 | +_granularity: Ambient[int] = Ambient("ogc_chunk_granularity", 0) |
| 195 | + |
| 196 | +#: The three accepted granularity levels, as a typing ``Literal`` so a type |
| 197 | +#: checker rejects any other value at the call site. |
| 198 | +GranularityLevel = Literal["low", "medium", "high"] |
| 199 | + |
| 200 | +# Each level caps the sub-chunks a multi-value axis is split into, derived from |
| 201 | +# the default fan-out width ``_CONCURRENCY_DEFAULT`` (``API_USGS_CONCURRENT``) |
| 202 | +# rather than hardcoded — past that many in-flight sub-requests the extra chunks |
| 203 | +# mostly just queue, so it's a natural ceiling for the most aggressive level, |
| 204 | +# and deriving from the same constant keeps the two in step if it moves. Levels |
| 205 | +# are spaced 4x apart: ``"high"`` = the full width, ``"medium"`` = a quarter, |
| 206 | +# ``"low"`` = a sixteenth (floored at 1 so a tiny width can't yield a 0 cap). |
| 207 | +_GRANULARITY_LEVELS: dict[str, int] = { |
| 208 | + "low": max(1, _CONCURRENCY_DEFAULT // 16), |
| 209 | + "medium": max(1, _CONCURRENCY_DEFAULT // 4), |
| 210 | + "high": _CONCURRENCY_DEFAULT, |
| 211 | +} |
| 212 | + |
| 213 | + |
| 214 | +def _resolve_granularity(level: GranularityLevel) -> int: |
| 215 | + """ |
| 216 | + Map a granularity level name to its per-axis sub-chunk cap. |
| 217 | +
|
| 218 | + Parameters |
| 219 | + ---------- |
| 220 | + level : {"low", "medium", "high"} |
| 221 | + The user-supplied level. |
| 222 | +
|
| 223 | + Returns |
| 224 | + ------- |
| 225 | + int |
| 226 | + The maximum sub-chunks per multi-value axis for that level |
| 227 | + (``4`` / ``16`` / ``32``). |
| 228 | +
|
| 229 | + Raises |
| 230 | + ------ |
| 231 | + ValueError |
| 232 | + If ``level`` is anything other than ``"low"``, ``"medium"``, or |
| 233 | + ``"high"`` — raised eagerly (at the ``with`` statement) so a typo fails |
| 234 | + loudly rather than silently doing nothing. |
| 235 | + """ |
| 236 | + try: |
| 237 | + return _GRANULARITY_LEVELS[level] |
| 238 | + except (KeyError, TypeError): |
| 239 | + valid = ", ".join(map(repr, _GRANULARITY_LEVELS)) |
| 240 | + raise ValueError( |
| 241 | + f"chunk_granularity level must be one of {valid}; got {level!r}." |
| 242 | + ) from None |
| 243 | + |
| 244 | + |
| 245 | +@contextmanager |
| 246 | +def chunk_granularity(level: GranularityLevel) -> Iterator[None]: |
| 247 | + """ |
| 248 | + Scope how finely the OGC getters chunk multi-value requests. |
| 249 | +
|
| 250 | + By default the Water Data / NGWMN getters chunk a request only as much as |
| 251 | + the server's ~8 KB URL-byte limit forces — the fewest sub-requests that |
| 252 | + fit. That is the safe default, but it can be *needlessly* conservative: |
| 253 | + because every sub-request paginates, splitting a large result further is |
| 254 | + usually quota-neutral (ten states pulled as one under-limit request page |
| 255 | + just as many times as ten per-state requests would). This context manager |
| 256 | + lets a caller who *knows* their pull is large ask for that finer split — |
| 257 | + trading the same pages for more, smaller sub-requests, which gives smoother |
| 258 | + progress, more even concurrency, and a smaller unit of retry/resume. |
| 259 | +
|
| 260 | + Because the library can't tell in advance whether a query is large (ten |
| 261 | + states over a short window might fit in a single page, where extra chunks |
| 262 | + would only burn quota), this is a *deliberate* per-call knob rather than an |
| 263 | + automatic behavior or a process-wide environment variable — scoping it to a |
| 264 | + ``with`` block keeps an aggressive setting from leaking into unrelated calls |
| 265 | + and accidentally spending quota. Outside any block the getters use the |
| 266 | + conservative default; there is no "off" level because *not* entering the |
| 267 | + block is off. |
| 268 | +
|
| 269 | + Parameters |
| 270 | + ---------- |
| 271 | + level : {"low", "medium", "high"} |
| 272 | + How aggressively to chunk within the block. Each level caps how many |
| 273 | + sub-chunks a single multi-value argument is split into, as a fraction |
| 274 | + of the default fan-out width (``API_USGS_CONCURRENT``, ``32``): |
| 275 | + ``"high"`` up to the full width, ``"medium"`` a quarter, and ``"low"`` a |
| 276 | + sixteenth of it — ``32`` / ``8`` / ``2`` by default — or one value per |
| 277 | + sub-request once the argument has fewer values than the cap. Tying the |
| 278 | + ceiling to the concurrency width is a guardrail: extra chunks beyond it |
| 279 | + mostly just queue, and it keeps an accidental ``"high"`` on a very long |
| 280 | + list from exploding into thousands of sub-requests. (With several |
| 281 | + multi-value arguments the per-argument counts still multiply.) |
| 282 | +
|
| 283 | + Yields |
| 284 | + ------ |
| 285 | + None |
| 286 | +
|
| 287 | + Raises |
| 288 | + ------ |
| 289 | + ValueError |
| 290 | + If ``level`` isn't ``"low"``, ``"medium"``, or ``"high"`` — raised on |
| 291 | + ``with`` entry, before any request is issued. |
| 292 | +
|
| 293 | + Examples |
| 294 | + -------- |
| 295 | + >>> from dataretrieval import waterdata |
| 296 | + >>> with waterdata.chunk_granularity("high"): |
| 297 | + ... df, md = waterdata.get_daily( |
| 298 | + ... monitoring_location_id=many_sites, parameter_code="00060" |
| 299 | + ... ) # doctest: +SKIP |
| 300 | +
|
| 301 | + See Also |
| 302 | + -------- |
| 303 | + ChunkPlan._refine : the planning-side effect of the level. |
| 304 | + """ |
| 305 | + with _granularity(_resolve_granularity(level)): |
| 306 | + yield |
| 307 | + |
| 308 | + |
175 | 309 | class ChunkedCall: |
176 | 310 | """ |
177 | 311 | Stateful handle for a chunked call. |
@@ -591,8 +725,9 @@ def multi_value_chunked( |
591 | 725 | ``async def fetch(args) -> (df, response)``, and drives it to |
592 | 726 | completion via :meth:`ChunkedCall.resume`. The plan splits multi-value |
593 | 727 | 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. |
| 728 | + byte limit; an already-fitting request is a one-step plan, unless an |
| 729 | + active :func:`chunk_granularity` block asks the plan to fan out more |
| 730 | + finely. See the module docstring for the concurrency model. |
596 | 731 |
|
597 | 732 | Parameters |
598 | 733 | ---------- |
@@ -636,7 +771,14 @@ def wrapper( |
636 | 771 | finalize: _Finalize = _passthrough_result, |
637 | 772 | ) -> tuple[pd.DataFrame, Any]: |
638 | 773 | limit = _OGC_URL_BYTE_LIMIT if url_limit is None else url_limit |
639 | | - plan = ChunkPlan(args, build_request, limit) |
| 774 | + # Read the granularity dial from the ambient set by |
| 775 | + # ``chunk_granularity`` (0 = off outside any such block; otherwise the |
| 776 | + # per-axis sub-chunk cap). It only affects *planning*, done here up |
| 777 | + # front, so a later resume — which re-issues the already-planned |
| 778 | + # sub-requests — needs no snapshot. |
| 779 | + plan = ChunkPlan( |
| 780 | + args, build_request, limit, max_chunks_per_axis=_granularity.get() |
| 781 | + ) |
640 | 782 | retry_policy = RetryPolicy.from_env() |
641 | 783 | # The concurrency cap is resolved inside ``resume()`` from |
642 | 784 | # ``API_USGS_CONCURRENT``; ``1`` is a sequential gather, |
|
0 commit comments