Skip to content

Commit 1663e81

Browse files
fix(poll): fix the api poll queue on large query
1 parent db8c85b commit 1663e81

3 files changed

Lines changed: 130 additions & 2 deletions

File tree

api/duck.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@
1212
import queue
1313
import threading
1414
import time
15+
from concurrent.futures import ThreadPoolExecutor
1516
from urllib.parse import urlparse
1617

1718
import duckdb
18-
from litestar.exceptions import TooManyRequestsException
19+
from litestar.exceptions import HTTPException, TooManyRequestsException
1920

2021
from osmsg import query
2122
from osmsg.db.schema import _apply_runtime_pragmas
@@ -115,15 +116,50 @@ def _acquire(pool: queue.Queue) -> duckdb.DuckDBPyConnection:
115116
raise TooManyRequestsException(detail="Server at capacity; retry shortly.") from None
116117

117118

119+
# A timed-out all-time query caches nothing, so its retry is just as slow; on timeout we re-run it in the
120+
# background to fill the cache the retry reads. One at a time on its own connection so it can't starve the pool.
121+
_warm_pool = ThreadPoolExecutor(max_workers=1)
122+
_warm_inflight: set[tuple[str, str]] = set()
123+
_warm_lock = threading.Lock()
124+
125+
126+
def _warm(fn, hashtag, kwargs, key) -> None:
127+
con = _connect()
128+
try:
129+
fn(con, hashtag, _sources(), **kwargs)
130+
except duckdb.Error:
131+
pass
132+
finally:
133+
con.close()
134+
with _warm_lock:
135+
_warm_inflight.discard(key)
136+
137+
138+
def _enqueue_warm(fn, hashtag, kwargs) -> None:
139+
"""Re-run an all-time query off the request path to fill its cache, deduped per key, best-effort.
140+
No-op when caching is off."""
141+
if _QUERY_CACHE_DIR is None:
142+
return
143+
key = (fn.__name__, repr(hashtag))
144+
with _warm_lock:
145+
if key in _warm_inflight:
146+
return
147+
_warm_inflight.add(key)
148+
_warm_pool.submit(_warm, fn, hashtag, kwargs, key)
149+
150+
118151
def _run(fn, hashtag, **kwargs):
119152
"""Borrow a warm pooled connection (429 past _POOL_WAIT caps concurrency), run under a watchdog that
120153
interrupts a query past _QUERY_TIMEOUT, and return it (replaced if it errored or was interrupted)."""
121154
pool = _pool_ready()
122155
con = _acquire(pool)
123156
done = threading.Event()
157+
interrupted = False
124158

125159
def _watchdog() -> None:
160+
nonlocal interrupted
126161
if not done.wait(_QUERY_TIMEOUT):
162+
interrupted = True
127163
with contextlib.suppress(duckdb.Error):
128164
con.interrupt()
129165

@@ -134,6 +170,11 @@ def _watchdog() -> None:
134170
return fn(con, hashtag, _sources(), **kwargs)
135171
except duckdb.Error:
136172
healthy = False # interrupted or DB error -> the connection may be dirty, recycle it
173+
if interrupted:
174+
# Only all-time queries hit the shared cache, so only they are worth warming.
175+
if kwargs.get("start") is None and kwargs.get("end") is None:
176+
_enqueue_warm(fn, hashtag, kwargs)
177+
raise HTTPException(status_code=503, detail="Server is busy, please try again in a moment.") from None
137178
raise
138179
finally:
139180
done.set()

frontend/app.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,7 @@ function sleep(ms, signal) {
472472
signal?.addEventListener("abort", () => { clearTimeout(t); reject(new DOMException("Aborted", "AbortError")); }, { once: true });
473473
});
474474
}
475+
// A 429 means the API shed load ("retry shortly"); retry once after a short pause before showing busy.
475476
async function apiGet(name, params, signal) {
476477
for (let attempt = 0; ; attempt++) {
477478
const res = await fetch(endpoint(name, params), { headers: { accept: "application/json" }, mode: "cors", signal });
@@ -484,6 +485,12 @@ async function apiGet(name, params, signal) {
484485
err.busy = true;
485486
throw err;
486487
}
488+
if (res.status === 503) {
489+
// Watchdog interrupted a large query; retrying now would just time out again, so surface busy.
490+
const err = new Error("Server is busy");
491+
err.busy = true;
492+
throw err;
493+
}
487494
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText || ""}`.trim());
488495
return res.json();
489496
}
@@ -627,6 +634,8 @@ async function runQuery() {
627634
} catch (err) {
628635
if (err?.name !== "AbortError") {
629636
console.warn("OSMSG query failed:", err);
637+
// Surface a busy shed even when the first (summary) call is the one rejected, so the user is not
638+
// left staring at loading skeletons.
630639
if (err?.busy && alive()) {
631640
showError(err);
632641
$("#ov-strip-totals").innerHTML = "";

tests/test_api_ratelimit.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,34 @@
11
"""Load-shed and rate-limit keying for the API."""
22

33
import queue
4+
import time
45
from types import SimpleNamespace
56

7+
import duckdb
68
import pytest
7-
from litestar.exceptions import TooManyRequestsException
9+
from litestar.exceptions import HTTPException, TooManyRequestsException
810

911
from api import duck
1012
from api.app import _client_identifier, app, rate_limit_config
1113

1214

15+
class _FakeCon:
16+
def interrupt(self):
17+
pass
18+
19+
def close(self):
20+
pass
21+
22+
23+
def _stub_pool(monkeypatch):
24+
"""Point _run at fake connections so it never touches DuckDB/Postgres."""
25+
monkeypatch.setattr(duck, "_sources", lambda: None)
26+
monkeypatch.setattr(duck, "_connect", lambda: _FakeCon())
27+
pool: queue.Queue = queue.Queue()
28+
pool.put(_FakeCon())
29+
monkeypatch.setattr(duck, "_pool_ready", lambda: pool)
30+
31+
1332
def test_acquire_returns_free_connection():
1433
pool: queue.Queue = queue.Queue()
1534
sentinel = object()
@@ -48,3 +67,62 @@ def test_rate_limit_middleware_is_wired():
4867
assert [m.middleware for m in app.middleware] == [RateLimitMiddleware]
4968
assert rate_limit_config.rate_limit == ("minute", 120)
5069
assert rate_limit_config.identifier_for_request is _client_identifier
70+
71+
72+
def _fake_query(name):
73+
def fn(*args, **kwargs):
74+
return None
75+
76+
fn.__name__ = name
77+
return fn
78+
79+
80+
def test_enqueue_warm_single_flight_dedups(monkeypatch):
81+
monkeypatch.setattr(duck, "_QUERY_CACHE_DIR", "/tmp/qc")
82+
submitted: list = []
83+
monkeypatch.setattr(duck._warm_pool, "submit", lambda *a: submitted.append(a))
84+
duck._warm_inflight.clear()
85+
summary = _fake_query("summary")
86+
allt = {"start": None, "end": None}
87+
duck._enqueue_warm(summary, "hotosm", allt)
88+
duck._enqueue_warm(summary, "hotosm", allt) # same key -> dropped
89+
duck._enqueue_warm(summary, "osmnepal", allt) # different key -> submitted
90+
assert len(submitted) == 2
91+
assert len(duck._warm_inflight) == 2
92+
93+
94+
def test_enqueue_warm_noop_when_cache_disabled(monkeypatch):
95+
monkeypatch.setattr(duck, "_QUERY_CACHE_DIR", None)
96+
submitted: list = []
97+
monkeypatch.setattr(duck._warm_pool, "submit", lambda *a: submitted.append(a))
98+
duck._warm_inflight.clear()
99+
duck._enqueue_warm(_fake_query("summary"), "hotosm", {"start": None, "end": None})
100+
assert submitted == []
101+
assert len(duck._warm_inflight) == 0
102+
103+
104+
def test_run_maps_interrupt_to_503(monkeypatch):
105+
monkeypatch.setattr(duck, "_QUERY_TIMEOUT", 0.0) # watchdog fires immediately -> interrupted
106+
monkeypatch.setattr(duck, "_QUERY_CACHE_DIR", None) # skip the warm side effect
107+
_stub_pool(monkeypatch)
108+
109+
def boom(con, hashtag, sources, **kwargs):
110+
time.sleep(0.05) # let the zero-timeout watchdog set `interrupted` before we raise
111+
raise duckdb.Error("interrupted")
112+
113+
boom.__name__ = "summary"
114+
with pytest.raises(HTTPException) as ei:
115+
duck._run(boom, "hotosm", start=None, end=None)
116+
assert ei.value.status_code == 503
117+
118+
119+
def test_run_reraises_real_db_error_not_as_busy(monkeypatch):
120+
monkeypatch.setattr(duck, "_QUERY_TIMEOUT", 100.0) # watchdog never fires
121+
_stub_pool(monkeypatch)
122+
123+
def boom(con, hashtag, sources, **kwargs):
124+
raise duckdb.Error("real failure")
125+
126+
boom.__name__ = "summary"
127+
with pytest.raises(duckdb.Error): # a genuine error is a 500, not masked as 503 busy
128+
duck._run(boom, "hotosm", start=None, end=None)

0 commit comments

Comments
 (0)