77import collections
88import contextlib
99import datetime as dt
10+ import glob
1011import logging
1112import os
1213import queue
14+ import shutil
1315import threading
1416import time
17+ import uuid
1518from concurrent .futures import ThreadPoolExecutor
1619from urllib .parse import urlparse
1720
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+
82107def _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+
265312async 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
0 commit comments