Skip to content

Commit 3fe3157

Browse files
fix(user): fix bug on user stats load on all time heavy query
only loads on click
1 parent cde2ece commit 3fe3157

8 files changed

Lines changed: 499 additions & 75 deletions

File tree

api/duck.py

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@
77
import collections
88
import contextlib
99
import datetime as dt
10+
import glob
1011
import logging
1112
import os
1213
import queue
14+
import shutil
1315
import threading
1416
import time
17+
import uuid
1518
from concurrent.futures import ThreadPoolExecutor
1619
from urllib.parse import urlparse
1720

@@ -26,6 +29,7 @@
2629

2730
_log = logging.getLogger("osmsg.api.duck")
2831
_PG_ATTACH = "pg"
32+
_BUSY_DETAIL = "Server is busy, please try again in a moment."
2933

3034
# Warm connections (extensions + Postgres attached) reused across requests so cold-start is paid once;
3135
# the pool size caps concurrency, so heavy queries queue instead of thrashing the CPU.
@@ -79,12 +83,34 @@ def _frontier() -> dt.datetime:
7983
return manifest.frontier
8084

8185

86+
_DUCKDB_TEMP_BASE = os.environ.get("OSMSG_DUCKDB_TEMP_DIR")
87+
88+
89+
def _isolate_temp_dir(con: duckdb.DuckDBPyConnection) -> None:
90+
"""Give this pooled connection its own spill directory. Every pooled connection otherwise shares
91+
OSMSG_DUCKDB_TEMP_DIR, and DuckDB's spill file names are not per-connection, so concurrent spills collide."""
92+
if not _DUCKDB_TEMP_BASE:
93+
return
94+
d = os.path.join(_DUCKDB_TEMP_BASE, f"conn-{uuid.uuid4().hex}")
95+
os.makedirs(d, exist_ok=True)
96+
con.execute(f"SET temp_directory='{d.replace(chr(39), chr(39) * 2)}'")
97+
98+
99+
def _sweep_stale_temp_dirs() -> None:
100+
"""Best-effort removal of per-connection spill dirs left by a prior process, run once when the pool builds."""
101+
if not _DUCKDB_TEMP_BASE:
102+
return
103+
for d in glob.glob(os.path.join(_DUCKDB_TEMP_BASE, "conn-*")):
104+
shutil.rmtree(d, ignore_errors=True)
105+
106+
82107
def _connect() -> duckdb.DuckDBPyConnection:
83108
con = duckdb.connect()
84109
con.execute("INSTALL httpfs; LOAD httpfs; INSTALL json; LOAD json; INSTALL postgres; LOAD postgres;")
85110
con.execute("SET http_retries=10;")
86111
# Memory/temp pragmas so concurrent pooled queries cannot sum past the container memory cap.
87112
_apply_runtime_pragmas(con)
113+
_isolate_temp_dir(con)
88114
attach_postgres(con, _libpq_dsn(), read_only=True)
89115
return con
90116

@@ -106,6 +132,7 @@ def _pool_ready() -> queue.Queue:
106132
if _pool is None:
107133
with _pool_lock:
108134
if _pool is None:
135+
_sweep_stale_temp_dirs()
109136
warm: queue.Queue = queue.Queue()
110137
for _ in range(_POOL_SIZE):
111138
warm.put(_connect())
@@ -201,13 +228,17 @@ def _watchdog() -> None:
201228
healthy = True
202229
try:
203230
return fn(con, hashtag, _sources(), **kwargs)
204-
except duckdb.Error:
231+
except duckdb.Error as e:
205232
healthy = False # interrupted or DB error -> the connection may be dirty, recycle it
206233
if interrupted:
207234
# Only all-time queries hit the shared cache, so only they are worth warming.
208235
if kwargs.get("start") is None and kwargs.get("end") is None:
209236
_enqueue_warm(fn, hashtag, kwargs)
210-
raise HTTPException(status_code=503, detail="Server is busy, please try again in a moment.") from None
237+
raise HTTPException(status_code=503, detail=_BUSY_DETAIL) from None
238+
if isinstance(e, duckdb.OutOfMemoryException):
239+
_log.warning("out of memory in %s under load, shedding as 503: %s", fn.__name__, e)
240+
raise HTTPException(status_code=503, detail=_BUSY_DETAIL) from None
241+
_log.exception("duckdb error in %s", fn.__name__)
211242
raise
212243
finally:
213244
done.set()
@@ -236,6 +267,7 @@ async def leaderboard(
236267
hashtag: str | list[str],
237268
*,
238269
exact: bool = False,
270+
detail: bool = False,
239271
page: int = 1,
240272
page_size: int = query.DEFAULT_PAGE_SIZE,
241273
sort: str = "map_changes",
@@ -249,6 +281,7 @@ async def leaderboard(
249281
query.leaderboard,
250282
hashtag,
251283
exact=exact,
284+
detail=detail,
252285
page=page,
253286
page_size=page_size,
254287
sort=sort,
@@ -262,6 +295,20 @@ async def leaderboard(
262295
return res
263296

264297

298+
async def user_detail(
299+
uid: int,
300+
*,
301+
hashtag: str | list[str] | None = None,
302+
exact: bool = False,
303+
start: dt.datetime | None = None,
304+
end: dt.datetime | None = None,
305+
):
306+
def run(con, s, **kw):
307+
return query.user_detail(con, uid, s, **kw)
308+
309+
return await asyncio.to_thread(_run_global, run, hashtag=hashtag, exact=exact, start=start, end=end)
310+
311+
265312
async def tags(
266313
hashtag: str | list[str],
267314
*,
@@ -341,10 +388,14 @@ def _watchdog() -> None:
341388
healthy = True
342389
try:
343390
return fn(con, _sources(), **kwargs)
344-
except duckdb.Error:
391+
except duckdb.Error as e:
345392
healthy = False
346393
if interrupted:
347-
raise HTTPException(status_code=503, detail="Server is busy, please try again in a moment.") from None
394+
raise HTTPException(status_code=503, detail=_BUSY_DETAIL) from None
395+
if isinstance(e, duckdb.OutOfMemoryException):
396+
_log.warning("out of memory in %s under load, shedding as 503: %s", fn.__name__, e)
397+
raise HTTPException(status_code=503, detail=_BUSY_DETAIL) from None
398+
_log.exception("duckdb error in %s", fn.__name__)
348399
raise
349400
finally:
350401
done.set()
@@ -402,17 +453,19 @@ async def global_leaderboard(
402453
sort: str = "map_changes",
403454
order: str = "desc",
404455
q: str | None = None,
456+
detail: bool = False,
405457
):
406458
return await _global_cached(
407459
query.global_leaderboard,
408-
(page, page_size, sort, order, q),
460+
(page, page_size, sort, order, q, detail),
409461
start,
410462
end,
411463
page=page,
412464
page_size=page_size,
413465
sort=sort,
414466
order=order,
415467
q=q,
468+
detail=detail,
416469
)
417470

418471

api/routers/global_stats.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ async def get_leaderboard(
6464
sort: str = "map_changes",
6565
order: str = "desc",
6666
q: str | None = None,
67+
detail: bool = False,
6768
) -> dict[str, Any]:
6869
if sort not in _LEADERBOARD_SORTS:
6970
raise HTTPException(status_code=400, detail=f"sort must be one of {', '.join(_LEADERBOARD_SORTS)}")
@@ -73,9 +74,17 @@ async def get_leaderboard(
7374
raise HTTPException(status_code=400, detail="page must be >= 1")
7475
s, e = _resolve(window, start, end)
7576
return await duck.global_leaderboard(
76-
start=s, end=e, page=page, page_size=page_size, sort=sort, order=order, q=q
77+
start=s, end=e, page=page, page_size=page_size, sort=sort, order=order, q=q, detail=detail
7778
)
7879

80+
@get("/user/{uid:int}")
81+
async def get_user(
82+
self, uid: int, window: str | None = None, start: datetime | None = None, end: datetime | None = None
83+
) -> dict[str, Any]:
84+
"""One contributor's whole-OSM detail (tag_stats, editors, hashtags) for the window, on demand."""
85+
s, e = _resolve(window, start, end)
86+
return await duck.user_detail(uid, start=s, end=e)
87+
7988
@get("/editors")
8089
async def get_editors(
8190
self, window: str | None = None, start: datetime | None = None, end: datetime | None = None

api/routers/hashtag.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,15 @@ async def get_leaderboard(
5353
sort: str = "map_changes",
5454
order: str = "desc",
5555
q: str | None = None,
56+
detail: bool = False,
5657
start: datetime | None = None,
5758
end: datetime | None = None,
5859
) -> dict[str, Any]:
5960
"""One page of the leaderboard as `{items, page, page_size, total, total_pages}`. `sort` is one of
6061
the element/name columns, `order` asc|desc, `q` an optional contributor-name search; all applied
61-
server-side across the whole result, so paging, sorting, and searching are consistent."""
62+
server-side across the whole result, so paging, sorting, and searching are consistent. Per-user
63+
detail (tag_stats/editors/hashtags) is attached only with `detail=true` (the export path); the UI
64+
fetches it per contributor from `/user/{uid}`."""
6265
if sort not in _LEADERBOARD_SORTS:
6366
raise HTTPException(status_code=400, detail=f"sort must be one of {', '.join(_LEADERBOARD_SORTS)}")
6467
if order not in ("asc", "desc"):
@@ -74,10 +77,25 @@ async def get_leaderboard(
7477
sort=sort,
7578
order=order,
7679
q=q,
80+
detail=detail,
7781
start=start,
7882
end=end,
7983
)
8084

85+
@get("/user/{uid:int}")
86+
async def get_user(
87+
self,
88+
hashtag: str,
89+
uid: int,
90+
exact: bool = False,
91+
start: datetime | None = None,
92+
end: datetime | None = None,
93+
) -> dict[str, Any]:
94+
"""One contributor's detail (tag_stats, editors, hashtags) for the scope, fetched on demand when a
95+
profile opens. uid-scoped, so it stays fast even on an all-time mega-hashtag."""
96+
start, end = _window(start, end)
97+
return await duck.user_detail(uid, hashtag=_hashtags(hashtag), exact=exact, start=start, end=end)
98+
8199
@get("/tags")
82100
async def get_tags(
83101
self,

0 commit comments

Comments
 (0)