Skip to content

Commit 28a23e2

Browse files
committed
test: add parallel_chunks threshold experiments
Experiments to determine safe parallelism levels for the USGS API. Results: 20+ trials at n=8/16/32 with 10s sleep between queries show zero hangs across all levels. n=16 shows the best avg response time. The only failure mode is proper HTTP 429 when quota is exhausted.
1 parent c4d336d commit 28a23e2

4 files changed

Lines changed: 789 additions & 0 deletions

File tree

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
"""
2+
Experiment: Find the parallelism level that overloads the server.
3+
4+
Strategy:
5+
- For each parallelism level (starting low, escalating), query ALL states
6+
at that level with sleep between each query.
7+
- Each state gets a unique time window (never reused across runs).
8+
- Log wall-clock time, outcome, and any signs of server stress (slow responses,
9+
errors, partial results).
10+
- Sleep between queries to stay below rate limit and isolate the effect of
11+
parallelism from quota exhaustion.
12+
13+
Usage:
14+
python experiments/overload_threshold_experiment.py [--level N] [--sleep 10]
15+
16+
If --level is not given, it auto-escalates through 1, 2, 4, 8, 16, 32.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import json
22+
import signal
23+
import sys
24+
import time
25+
from datetime import datetime, timezone
26+
from pathlib import Path
27+
28+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
29+
30+
from dataretrieval import waterdata
31+
from dataretrieval.ogc.interruptions import ChunkInterrupted, QuotaExhausted
32+
33+
LOG_FILE = Path(__file__).with_name("overload_threshold_log.jsonl")
34+
35+
# Large states — these put real pressure on the server
36+
STATES = ["Ohio", "Pennsylvania", "Connecticut", "New Hampshire", "Vermont"]
37+
38+
# Time windows pool — we rotate through these, never reusing a (state, window)
39+
# pair across the entire log.
40+
WINDOWS = [
41+
"2000-01-01/2000-12-31",
42+
"2001-01-01/2001-12-31",
43+
"2002-01-01/2002-12-31",
44+
"2003-01-01/2003-12-31",
45+
"2004-01-01/2004-12-31",
46+
"2005-01-01/2005-12-31",
47+
"2006-01-01/2006-12-31",
48+
"2007-01-01/2007-12-31",
49+
"2008-01-01/2008-12-31",
50+
"2009-01-01/2009-12-31",
51+
"2010-01-01/2010-12-31",
52+
"2011-01-01/2011-12-31",
53+
"2012-01-01/2012-12-31",
54+
"2013-01-01/2013-12-31",
55+
"2014-01-01/2014-12-31",
56+
"2015-01-01/2015-12-31",
57+
"2016-01-01/2016-12-31",
58+
"2017-01-01/2017-12-31",
59+
"2018-01-01/2018-12-31",
60+
"2019-01-01/2019-12-31",
61+
"2020-01-01/2020-12-31",
62+
"2021-01-01/2021-12-31",
63+
"2022-01-01/2022-12-31",
64+
"2023-01-01/2023-12-31",
65+
"2024-01-01/2024-12-31",
66+
]
67+
68+
69+
class HangTimeout(Exception):
70+
pass
71+
72+
73+
def _timeout_handler(signum, frame):
74+
raise HangTimeout("hang")
75+
76+
77+
def _load_log() -> list[dict]:
78+
if not LOG_FILE.exists():
79+
return []
80+
entries = []
81+
with open(LOG_FILE) as f:
82+
for line in f:
83+
line = line.strip()
84+
if line:
85+
entries.append(json.loads(line))
86+
return entries
87+
88+
89+
def _append_log(entry: dict) -> None:
90+
with open(LOG_FILE, "a") as f:
91+
f.write(json.dumps(entry) + "\n")
92+
93+
94+
def _used_windows_for_state(entries: list[dict], state: str) -> set[str]:
95+
"""Return windows already used for a given state in the log."""
96+
return {e["time_window"] for e in entries if e.get("state") == state}
97+
98+
99+
def _next_window(state: str, used: set[str]) -> str | None:
100+
"""Pick the next unused window for a state."""
101+
for w in WINDOWS:
102+
if w not in used:
103+
return w
104+
return None
105+
106+
107+
def run_level(level: int, sleep_s: int, timeout_s: int) -> list[dict]:
108+
"""Run all states at a given parallelism level. Returns the trial results."""
109+
entries = _load_log()
110+
111+
# Preload sites
112+
site_cache: dict[str, list[str]] = {}
113+
for state in STATES:
114+
print(f" Fetching {state} sites...", end=" ", flush=True)
115+
sites, _ = waterdata.get_monitoring_locations(state=state, site_type_code="ST")
116+
site_cache[state] = sites["monitoring_location_id"].tolist()
117+
print(f"{len(site_cache[state])} sites")
118+
119+
print(
120+
f"\n Running all {len(STATES)} states at n={level}"
121+
f" (sleep={sleep_s}s between):\n"
122+
)
123+
124+
results = []
125+
for i, state in enumerate(STATES):
126+
# Pick a fresh window
127+
used = _used_windows_for_state(entries + results, state)
128+
window = _next_window(state, used)
129+
if window is None:
130+
print(f" {state}: no fresh windows left, skipping")
131+
continue
132+
133+
site_ids = site_cache[state]
134+
print(
135+
f" [{i + 1}/{len(STATES)}] {state:<15} ({len(site_ids):>4} sites) "
136+
f"{window} n={level}...",
137+
end=" ",
138+
flush=True,
139+
)
140+
141+
old = signal.signal(signal.SIGALRM, _timeout_handler)
142+
signal.alarm(timeout_s)
143+
t0 = time.perf_counter()
144+
145+
entry = {
146+
"timestamp": datetime.now(timezone.utc).isoformat(),
147+
"state": state,
148+
"n_sites": len(site_ids),
149+
"time_window": window,
150+
"parallelism": level,
151+
"timeout_s": timeout_s,
152+
"sleep_s": sleep_s,
153+
"outcome": None,
154+
"wall_clock_s": None,
155+
"n_records": None,
156+
"error_type": None,
157+
"error_msg": None,
158+
}
159+
160+
try:
161+
if level <= 1:
162+
df, md = waterdata.get_daily(
163+
monitoring_location_id=site_ids,
164+
parameter_code="00060",
165+
time=window,
166+
)
167+
else:
168+
with waterdata.parallel_chunks(level):
169+
df, md = waterdata.get_daily(
170+
monitoring_location_id=site_ids,
171+
parameter_code="00060",
172+
time=window,
173+
)
174+
175+
elapsed = time.perf_counter() - t0
176+
entry["outcome"] = "success"
177+
entry["wall_clock_s"] = round(elapsed, 2)
178+
entry["n_records"] = len(df)
179+
print(f"✓ {elapsed:.1f}s, {len(df):,} records")
180+
181+
except HangTimeout:
182+
elapsed = time.perf_counter() - t0
183+
entry["outcome"] = "timeout"
184+
entry["wall_clock_s"] = round(elapsed, 2)
185+
entry["error_type"] = "HangTimeout"
186+
entry["error_msg"] = f"No response within {timeout_s}s"
187+
print(f"✗ HANG after {elapsed:.0f}s")
188+
189+
except QuotaExhausted as exc:
190+
elapsed = time.perf_counter() - t0
191+
entry["outcome"] = "quota_exhausted"
192+
entry["wall_clock_s"] = round(elapsed, 2)
193+
entry["error_type"] = "QuotaExhausted"
194+
entry["error_msg"] = f"429, retry_after={exc.retry_after}"
195+
print(f"✗ 429 after {elapsed:.1f}s (retry_after={exc.retry_after})")
196+
197+
except ChunkInterrupted as exc:
198+
elapsed = time.perf_counter() - t0
199+
entry["outcome"] = "interrupted"
200+
entry["wall_clock_s"] = round(elapsed, 2)
201+
entry["error_type"] = type(exc).__name__
202+
entry["error_msg"] = str(exc)[:200]
203+
print(f"✗ {type(exc).__name__} after {elapsed:.1f}s")
204+
205+
except Exception as exc:
206+
elapsed = time.perf_counter() - t0
207+
entry["outcome"] = "error"
208+
entry["wall_clock_s"] = round(elapsed, 2)
209+
entry["error_type"] = type(exc).__name__
210+
entry["error_msg"] = str(exc)[:200]
211+
print(f"✗ {type(exc).__name__} after {elapsed:.1f}s: {str(exc)[:80]}")
212+
213+
finally:
214+
signal.alarm(0)
215+
signal.signal(signal.SIGALRM, old)
216+
217+
_append_log(entry)
218+
results.append(entry)
219+
220+
# Sleep between queries to stay under rate limit
221+
if i < len(STATES) - 1:
222+
print(f" (sleeping {sleep_s}s...)", flush=True)
223+
time.sleep(sleep_s)
224+
225+
return results
226+
227+
228+
def print_summary():
229+
"""Print summary grouped by parallelism level."""
230+
entries = _load_log()
231+
if not entries:
232+
print("No log entries.")
233+
return
234+
235+
from collections import defaultdict
236+
237+
by_level = defaultdict(list)
238+
for e in entries:
239+
by_level[e["parallelism"]].append(e)
240+
241+
print("\n" + "=" * 90)
242+
print("OVERLOAD THRESHOLD EXPERIMENT — all runs")
243+
print("=" * 90)
244+
print(
245+
f"{'level':>5} | {'trials':>6} | {'ok':>4} | {'429':>4} | "
246+
f"{'hang':>4} | {'err':>4} | {'avg_s':>7} | {'max_s':>7} | "
247+
f"{'avg_records':>11} | {'total_records':>13}"
248+
)
249+
print("-" * 90)
250+
251+
for level in sorted(by_level.keys()):
252+
trials = by_level[level]
253+
ok = [t for t in trials if t["outcome"] == "success"]
254+
quota = [t for t in trials if t["outcome"] == "quota_exhausted"]
255+
hangs = [t for t in trials if t["outcome"] == "timeout"]
256+
errs = [t for t in trials if t["outcome"] in ("error", "interrupted")]
257+
258+
times = [t["wall_clock_s"] for t in ok]
259+
avg_t = f"{sum(times) / len(times):.1f}" if times else "—"
260+
max_t = f"{max(times):.1f}" if times else "—"
261+
avg_r = int(sum(t["n_records"] for t in ok) / len(ok)) if ok else 0
262+
total_r = sum(t["n_records"] for t in ok) if ok else 0
263+
264+
print(
265+
f"{level:>5} | {len(trials):>6} | {len(ok):>4} | {len(quota):>4} | "
266+
f"{len(hangs):>4} | {len(errs):>4} | {avg_t:>7} | {max_t:>7} | "
267+
f"{avg_r:>11,} | {total_r:>13,}"
268+
)
269+
270+
print("=" * 90)
271+
272+
# Detail per level+state
273+
print("\nPer-state breakdown (successful trials only):")
274+
print(
275+
f" {'level':>5} | {'state':<15} | {'time_s':>7} | {'records':>10} | {'window'}"
276+
)
277+
print(" " + "-" * 70)
278+
for level in sorted(by_level.keys()):
279+
for e in sorted(by_level[level], key=lambda x: x.get("state", "")):
280+
if e["outcome"] == "success":
281+
print(
282+
f" {level:>5} | {e['state']:<15} | "
283+
f"{e['wall_clock_s']:>7.1f} | {e['n_records']:>10,} | "
284+
f"{e['time_window']}"
285+
)
286+
287+
288+
if __name__ == "__main__":
289+
import argparse
290+
291+
parser = argparse.ArgumentParser()
292+
parser.add_argument("--level", type=int, default=None, help="Single level to test")
293+
parser.add_argument(
294+
"--levels", type=str, default=None, help="Comma-separated levels"
295+
)
296+
parser.add_argument(
297+
"--sleep", type=int, default=10, help="Seconds between queries (default: 10)"
298+
)
299+
parser.add_argument(
300+
"--timeout", type=int, default=120, help="Per-query timeout (default: 120)"
301+
)
302+
parser.add_argument("--summary", action="store_true", help="Print summary only")
303+
args = parser.parse_args()
304+
305+
if args.summary:
306+
print_summary()
307+
sys.exit(0)
308+
309+
if args.level is not None:
310+
levels = [args.level]
311+
elif args.levels is not None:
312+
levels = [int(x) for x in args.levels.split(",")]
313+
else:
314+
levels = [1, 2, 4, 8, 16, 32]
315+
316+
print("Overload threshold experiment")
317+
print(f" Levels to test: {levels}")
318+
print(f" States: {STATES}")
319+
print(f" Sleep between queries: {args.sleep}s")
320+
print(f" Timeout: {args.timeout}s")
321+
print(f" Log: {LOG_FILE}")
322+
print()
323+
324+
for level in levels:
325+
print(f"{'=' * 60}")
326+
print(f" LEVEL {level}")
327+
print(f"{'=' * 60}")
328+
results = run_level(level, args.sleep, args.timeout)
329+
330+
# Check if we got throttled — if so, stop escalating
331+
failures = [r for r in results if r["outcome"] != "success"]
332+
if failures:
333+
print(f"\n ⚠ Failures at level {level} — stopping escalation.")
334+
break
335+
336+
print()
337+
338+
print_summary()
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{"timestamp": "2026-07-16T18:33:36.851681+00:00", "state": "Ohio", "n_sites": 2889, "time_window": "2000-01-01/2000-12-31", "parallelism": 8, "timeout_s": 120, "sleep_s": 10, "outcome": "success", "wall_clock_s": 10.88, "n_records": 42728, "error_type": null, "error_msg": null}
2+
{"timestamp": "2026-07-16T18:33:57.741196+00:00", "state": "Pennsylvania", "n_sites": 5676, "time_window": "2000-01-01/2000-12-31", "parallelism": 8, "timeout_s": 120, "sleep_s": 10, "outcome": "success", "wall_clock_s": 6.81, "n_records": 78674, "error_type": null, "error_msg": null}
3+
{"timestamp": "2026-07-16T18:34:14.560514+00:00", "state": "Connecticut", "n_sites": 1184, "time_window": "2000-01-01/2000-12-31", "parallelism": 8, "timeout_s": 120, "sleep_s": 10, "outcome": "success", "wall_clock_s": 3.03, "n_records": 17706, "error_type": null, "error_msg": null}
4+
{"timestamp": "2026-07-16T18:34:27.595726+00:00", "state": "New Hampshire", "n_sites": 1081, "time_window": "2000-01-01/2000-12-31", "parallelism": 8, "timeout_s": 120, "sleep_s": 10, "outcome": "success", "wall_clock_s": 6.98, "n_records": 13629, "error_type": null, "error_msg": null}
5+
{"timestamp": "2026-07-16T18:34:44.581715+00:00", "state": "Vermont", "n_sites": 570, "time_window": "2000-01-01/2000-12-31", "parallelism": 8, "timeout_s": 120, "sleep_s": 10, "outcome": "success", "wall_clock_s": 4.9, "n_records": 14274, "error_type": null, "error_msg": null}
6+
{"timestamp": "2026-07-16T18:34:56.219533+00:00", "state": "Ohio", "n_sites": 2889, "time_window": "2001-01-01/2001-12-31", "parallelism": 16, "timeout_s": 120, "sleep_s": 10, "outcome": "success", "wall_clock_s": 2.95, "n_records": 46368, "error_type": null, "error_msg": null}
7+
{"timestamp": "2026-07-16T18:35:09.173482+00:00", "state": "Pennsylvania", "n_sites": 5676, "time_window": "2001-01-01/2001-12-31", "parallelism": 16, "timeout_s": 120, "sleep_s": 10, "outcome": "success", "wall_clock_s": 4.09, "n_records": 78329, "error_type": null, "error_msg": null}
8+
{"timestamp": "2026-07-16T18:35:23.274345+00:00", "state": "Connecticut", "n_sites": 1184, "time_window": "2001-01-01/2001-12-31", "parallelism": 16, "timeout_s": 120, "sleep_s": 10, "outcome": "success", "wall_clock_s": 1.65, "n_records": 18242, "error_type": null, "error_msg": null}
9+
{"timestamp": "2026-07-16T18:35:34.932930+00:00", "state": "New Hampshire", "n_sites": 1081, "time_window": "2001-01-01/2001-12-31", "parallelism": 16, "timeout_s": 120, "sleep_s": 10, "outcome": "success", "wall_clock_s": 0.98, "n_records": 14514, "error_type": null, "error_msg": null}
10+
{"timestamp": "2026-07-16T18:35:45.921675+00:00", "state": "Vermont", "n_sites": 570, "time_window": "2001-01-01/2001-12-31", "parallelism": 16, "timeout_s": 120, "sleep_s": 10, "outcome": "success", "wall_clock_s": 1.38, "n_records": 14829, "error_type": null, "error_msg": null}
11+
{"timestamp": "2026-07-16T18:35:54.383526+00:00", "state": "Ohio", "n_sites": 2889, "time_window": "2002-01-01/2002-12-31", "parallelism": 32, "timeout_s": 120, "sleep_s": 10, "outcome": "success", "wall_clock_s": 3.74, "n_records": 49490, "error_type": null, "error_msg": null}
12+
{"timestamp": "2026-07-16T18:36:08.126379+00:00", "state": "Pennsylvania", "n_sites": 5676, "time_window": "2002-01-01/2002-12-31", "parallelism": 32, "timeout_s": 120, "sleep_s": 10, "outcome": "success", "wall_clock_s": 7.15, "n_records": 78595, "error_type": null, "error_msg": null}
13+
{"timestamp": "2026-07-16T18:36:25.287311+00:00", "state": "Connecticut", "n_sites": 1184, "time_window": "2002-01-01/2002-12-31", "parallelism": 32, "timeout_s": 120, "sleep_s": 10, "outcome": "success", "wall_clock_s": 3.55, "n_records": 18090, "error_type": null, "error_msg": null}
14+
{"timestamp": "2026-07-16T18:36:38.847054+00:00", "state": "New Hampshire", "n_sites": 1081, "time_window": "2002-01-01/2002-12-31", "parallelism": 32, "timeout_s": 120, "sleep_s": 10, "outcome": "success", "wall_clock_s": 1.82, "n_records": 16646, "error_type": null, "error_msg": null}
15+
{"timestamp": "2026-07-16T18:36:50.675320+00:00", "state": "Vermont", "n_sites": 570, "time_window": "2002-01-01/2002-12-31", "parallelism": 32, "timeout_s": 120, "sleep_s": 10, "outcome": "success", "wall_clock_s": 11.11, "n_records": 15065, "error_type": null, "error_msg": null}
16+
{"timestamp": "2026-07-16T18:37:38.342619+00:00", "state": "Ohio", "n_sites": 2889, "time_window": "2003-01-01/2007-12-31", "parallelism": 32, "timeout_s": 180, "sleep_s": 10, "outcome": "success", "wall_clock_s": 59.67, "n_records": 242773, "error_type": null, "error_msg": null, "note": "5yr_window"}
17+
{"timestamp": "2026-07-16T18:38:48.021952+00:00", "state": "Pennsylvania", "n_sites": 5676, "time_window": "2004-01-01/2008-12-31", "parallelism": 32, "timeout_s": 180, "sleep_s": 10, "outcome": "success", "wall_clock_s": 15.88, "n_records": 419738, "error_type": null, "error_msg": null, "note": "5yr_window"}
18+
{"timestamp": "2026-07-16T18:39:13.910850+00:00", "state": "Connecticut", "n_sites": 1184, "time_window": "2005-01-01/2009-12-31", "parallelism": 32, "timeout_s": 180, "sleep_s": 10, "outcome": "success", "wall_clock_s": 6.28, "n_records": 103846, "error_type": null, "error_msg": null, "note": "5yr_window"}
19+
{"timestamp": "2026-07-16T18:39:30.191569+00:00", "state": "New Hampshire", "n_sites": 1081, "time_window": "2006-01-01/2010-12-31", "parallelism": 32, "timeout_s": 180, "sleep_s": 10, "outcome": "success", "wall_clock_s": 2.65, "n_records": 81268, "error_type": null, "error_msg": null, "note": "5yr_window"}
20+
{"timestamp": "2026-07-16T18:39:42.849171+00:00", "state": "Vermont", "n_sites": 570, "time_window": "2007-01-01/2011-12-31", "parallelism": 32, "timeout_s": 180, "sleep_s": 10, "outcome": "success", "wall_clock_s": 2.8, "n_records": 87867, "error_type": null, "error_msg": null, "note": "5yr_window"}

0 commit comments

Comments
 (0)