|
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 as |
| 13 | +the byte limit forces. A caller who knows their result is large can opt into a |
| 14 | +finer split via the ``chunk_granularity`` context manager |
| 15 | +(``"low"`` / ``"medium"`` / ``"high"``); the resolved cap drives |
| 16 | +:meth:`ChunkPlan._refine`. See ``chunk_granularity`` 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 | | -from typing import Any, cast |
| 80 | +from typing import Any, Literal, cast, get_args |
74 | 81 |
|
75 | 82 | import httpx |
76 | 83 | import pandas as pd |
@@ -172,6 +179,130 @@ def get_active_client() -> httpx.AsyncClient | None: |
172 | 179 | return _chunked_client.get() |
173 | 180 |
|
174 | 181 |
|
| 182 | +# Chunk-granularity dial: opt-in to fan a query out *more finely* than the byte |
| 183 | +# limit alone requires. Scoped to a ``with chunk_granularity(...):`` block (a |
| 184 | +# ContextVar), deliberately NOT an env var (see :func:`chunk_granularity` for |
| 185 | +# why). The ambient holds the resolved cap on sub-chunks per axis; ``0`` (the |
| 186 | +# default, outside any block) means "chunk only as much as the byte limit needs". |
| 187 | +_granularity: Ambient[int] = Ambient("ogc_chunk_granularity", 0) |
| 188 | + |
| 189 | +#: The three accepted granularity levels, as a typing ``Literal`` so a type |
| 190 | +#: checker rejects any other value at the call site. |
| 191 | +GranularityLevel = Literal["low", "medium", "high"] |
| 192 | + |
| 193 | +#: Valid levels derived from the type, so ``GranularityLevel`` stays the single |
| 194 | +#: source of truth for what ``_resolve_granularity`` accepts (mirrors the |
| 195 | +#: ``get_args``-based ``_VALID_ON_TIE`` / ``_VALID_FILE_TYPES`` in sibling |
| 196 | +#: modules). |
| 197 | +_VALID_LEVELS: tuple[GranularityLevel, ...] = get_args(GranularityLevel) |
| 198 | + |
| 199 | +# Granularity's own ceiling on sub-chunks per axis — deliberately NOT tied to the |
| 200 | +# concurrency default: fan-out *volume* (how many sub-requests a query becomes) |
| 201 | +# is orthogonal to how many run at once (``API_USGS_CONCURRENT``). 32 is a sane |
| 202 | +# single-axis ceiling that also bounds the blast radius of an accidental |
| 203 | +# ``"high"`` on a very long list; the milder levels are a quarter and a |
| 204 | +# sixteenth of it (the three are spaced 4x apart). |
| 205 | +_GRANULARITY_MAX_CHUNKS = 32 |
| 206 | +_GRANULARITY_LEVELS: dict[str, int] = { |
| 207 | + "low": _GRANULARITY_MAX_CHUNKS // 16, # 2 |
| 208 | + "medium": _GRANULARITY_MAX_CHUNKS // 4, # 8 |
| 209 | + "high": _GRANULARITY_MAX_CHUNKS, # 32 |
| 210 | +} |
| 211 | + |
| 212 | + |
| 213 | +def _resolve_granularity(level: GranularityLevel) -> int: |
| 214 | + """ |
| 215 | + Map a granularity level name to its per-axis sub-chunk cap. |
| 216 | +
|
| 217 | + Parameters |
| 218 | + ---------- |
| 219 | + level : {"low", "medium", "high"} |
| 220 | + The user-supplied level. |
| 221 | +
|
| 222 | + Returns |
| 223 | + ------- |
| 224 | + int |
| 225 | + The maximum sub-chunks per multi-value axis for that level |
| 226 | + (``2`` / ``8`` / ``32``). |
| 227 | +
|
| 228 | + Raises |
| 229 | + ------ |
| 230 | + ValueError |
| 231 | + If ``level`` is anything other than ``"low"``, ``"medium"``, or |
| 232 | + ``"high"`` — raised eagerly (at the ``with`` statement) so a typo fails |
| 233 | + loudly rather than silently doing nothing. |
| 234 | + """ |
| 235 | + if level not in _VALID_LEVELS: |
| 236 | + raise ValueError( |
| 237 | + f"chunk_granularity level must be one of {_VALID_LEVELS}; got {level!r}." |
| 238 | + ) |
| 239 | + return _GRANULARITY_LEVELS[level] |
| 240 | + |
| 241 | + |
| 242 | +@contextmanager |
| 243 | +def chunk_granularity(level: GranularityLevel) -> Iterator[None]: |
| 244 | + """ |
| 245 | + Scope how finely the OGC getters chunk multi-value requests. |
| 246 | +
|
| 247 | + By default the Water Data / NGWMN getters chunk a request only as much as |
| 248 | + the server's ~8 KB URL-byte limit forces — the fewest sub-requests that |
| 249 | + fit. That is the safe default, but it can be *needlessly* conservative: |
| 250 | + because every sub-request paginates, splitting a large result further is |
| 251 | + usually quota-neutral (ten states pulled as one under-limit request page |
| 252 | + just as many times as ten per-state requests would). This context manager |
| 253 | + lets a caller who *knows* their pull is large ask for that finer split — |
| 254 | + trading the same pages for more, smaller sub-requests, which gives smoother |
| 255 | + progress, more even concurrency, and a smaller unit of retry/resume. |
| 256 | +
|
| 257 | + Because the library can't tell in advance whether a query is large (ten |
| 258 | + states over a short window might fit in a single page, where extra chunks |
| 259 | + would only burn quota), this is a *deliberate* per-call knob rather than an |
| 260 | + automatic behavior or a process-wide environment variable — scoping it to a |
| 261 | + ``with`` block keeps an aggressive setting from leaking into unrelated calls |
| 262 | + and accidentally spending quota. Outside any block the getters use the |
| 263 | + conservative default; there is no "off" level because *not* entering the |
| 264 | + block is off. Only the OGC getters (Water Data, NGWMN) read this; wrapping a |
| 265 | + legacy NWIS call in the block is a harmless no-op. |
| 266 | +
|
| 267 | + Parameters |
| 268 | + ---------- |
| 269 | + level : {"low", "medium", "high"} |
| 270 | + How aggressively to chunk within the block. Each level caps how many |
| 271 | + sub-chunks a single multi-value argument is split into — ``2`` / ``8`` / |
| 272 | + ``32`` for ``"low"`` / ``"medium"`` / ``"high"`` — or one value per |
| 273 | + sub-request once the argument has fewer values than the cap. The ceiling |
| 274 | + is fixed (it is *not* tied to ``API_USGS_CONCURRENT``: how finely a query |
| 275 | + splits is orthogonal to how many sub-requests run at once); capping |
| 276 | + ``"high"`` at ``32`` keeps an accidental aggressive level on a very long |
| 277 | + list from exploding into thousands of sub-requests. (With several |
| 278 | + multi-value arguments the per-argument counts still multiply.) |
| 279 | +
|
| 280 | + Yields |
| 281 | + ------ |
| 282 | + None |
| 283 | +
|
| 284 | + Raises |
| 285 | + ------ |
| 286 | + ValueError |
| 287 | + If ``level`` isn't ``"low"``, ``"medium"``, or ``"high"`` — raised on |
| 288 | + ``with`` entry, before any request is issued. |
| 289 | +
|
| 290 | + Examples |
| 291 | + -------- |
| 292 | + >>> from dataretrieval import waterdata |
| 293 | + >>> with waterdata.chunk_granularity("high"): |
| 294 | + ... df, md = waterdata.get_daily( |
| 295 | + ... monitoring_location_id=many_sites, parameter_code="00060" |
| 296 | + ... ) # doctest: +SKIP |
| 297 | +
|
| 298 | + See Also |
| 299 | + -------- |
| 300 | + ChunkPlan._refine : the planning-side effect of the level. |
| 301 | + """ |
| 302 | + with _granularity(_resolve_granularity(level)): |
| 303 | + yield |
| 304 | + |
| 305 | + |
175 | 306 | class ChunkedCall: |
176 | 307 | """ |
177 | 308 | Stateful handle for a chunked call. |
@@ -591,8 +722,9 @@ def multi_value_chunked( |
591 | 722 | ``async def fetch(args) -> (df, response)``, and drives it to |
592 | 723 | completion via :meth:`ChunkedCall.resume`. The plan splits multi-value |
593 | 724 | 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. |
| 725 | + byte limit; an already-fitting request is a one-step plan, unless an |
| 726 | + active :func:`chunk_granularity` block asks the plan to fan out more |
| 727 | + finely. See the module docstring for the concurrency model. |
596 | 728 |
|
597 | 729 | Parameters |
598 | 730 | ---------- |
@@ -636,7 +768,14 @@ def wrapper( |
636 | 768 | finalize: _Finalize = _passthrough_result, |
637 | 769 | ) -> tuple[pd.DataFrame, Any]: |
638 | 770 | limit = _OGC_URL_BYTE_LIMIT if url_limit is None else url_limit |
639 | | - plan = ChunkPlan(args, build_request, limit) |
| 771 | + # Read the granularity dial from the ambient set by |
| 772 | + # ``chunk_granularity`` (0 = off outside any such block; otherwise the |
| 773 | + # per-axis sub-chunk cap). It only affects *planning*, done here up |
| 774 | + # front, so a later resume — which re-issues the already-planned |
| 775 | + # sub-requests — needs no snapshot. |
| 776 | + plan = ChunkPlan( |
| 777 | + args, build_request, limit, max_chunks_per_axis=_granularity.get() |
| 778 | + ) |
640 | 779 | retry_policy = RetryPolicy.from_env() |
641 | 780 | # The concurrency cap is resolved inside ``resume()`` from |
642 | 781 | # ``API_USGS_CONCURRENT``; ``1`` is a sequential gather, |
|
0 commit comments