66from __future__ import annotations
77
88import asyncio
9+ import collections
910import contextlib
1011import datetime as dt
1112import logging
4445_HASHTAG_CHANGESET = os .getenv ("OSMSG_HASHTAG_CHANGESET" , f"{ _ROLLUP } /hashtag_changeset/data.parquet" )
4546_USERS = os .getenv ("OSMSG_USERS" , f"{ _ROLLUP } /users/data.parquet" )
4647_FRONTIER_TTL_SECONDS = int (os .getenv ("OSMSG_FRONTIER_TTL_SECONDS" , "3600" ))
48+ # work_mem for the API's own pooled Postgres connections only (not the worker, not the db global config), so
49+ # the heaviest global aggregate stays mostly in memory instead of spilling. Sized to the pool + db cap.
50+ _PG_WORK_MEM = os .getenv ("OSMSG_PG_WORK_MEM" , "64MB" )
4751
4852_frontier_cache : tuple [float , dt .datetime ] | None = None
4953
@@ -58,7 +62,8 @@ def _libpq_dsn() -> str:
5862 "user" : u .username ,
5963 "password" : u .password ,
6064 }
61- return " " .join (f"{ k } ={ v } " for k , v in parts .items () if v is not None )
65+ dsn = " " .join (f"{ k } ={ v } " for k , v in parts .items () if v is not None )
66+ return f"{ dsn } options='-c work_mem={ _PG_WORK_MEM } '"
6267
6368
6469def _frontier () -> dt .datetime :
@@ -81,7 +86,7 @@ def _connect() -> duckdb.DuckDBPyConnection:
8186 con .execute ("SET http_retries=10;" )
8287 # Memory/temp pragmas so concurrent pooled queries cannot sum past the container memory cap.
8388 _apply_runtime_pragmas (con )
84- con .execute (f"ATTACH '{ _libpq_dsn ()} ' AS pg (TYPE postgres, READ_ONLY)" )
89+ con .execute (f"ATTACH '{ _libpq_dsn (). replace ( chr ( 39 ), chr ( 39 ) * 2 ) } ' AS pg (TYPE postgres, READ_ONLY)" )
8590 return con
8691
8792
@@ -279,3 +284,109 @@ async def map_points(
279284 hashtag : str | list [str ], * , limit : int = 2000 , start : dt .datetime | None = None , end : dt .datetime | None = None
280285):
281286 return await asyncio .to_thread (_run , query .map_points , hashtag , limit = limit , start = start , end = end )
287+
288+
289+ def _run_global (fn , ** kwargs ):
290+ """Like _run but for the no-hashtag global endpoints: runs fn(con, _sources(), **kwargs) under the
291+ watchdog on a pooled connection. No warm path (global windows are recent, uncached)."""
292+ pool = _pool_ready ()
293+ con = _acquire (pool )
294+ done = threading .Event ()
295+ interrupted = False
296+
297+ def _watchdog () -> None :
298+ nonlocal interrupted
299+ if not done .wait (_QUERY_TIMEOUT ):
300+ interrupted = True
301+ with contextlib .suppress (duckdb .Error ):
302+ con .interrupt ()
303+
304+ watcher = threading .Thread (target = _watchdog , daemon = True )
305+ watcher .start ()
306+ healthy = True
307+ try :
308+ return fn (con , _sources (), ** kwargs )
309+ except duckdb .Error :
310+ healthy = False
311+ if interrupted :
312+ raise HTTPException (status_code = 503 , detail = "Server is busy, please try again in a moment." ) from None
313+ raise
314+ finally :
315+ done .set ()
316+ watcher .join ()
317+ if healthy :
318+ pool .put (con )
319+ else :
320+ with contextlib .suppress (duckdb .Error ):
321+ con .close ()
322+ pool .put (_connect ())
323+
324+
325+ # Memoize whole-OSM results by a grain-rounded window so repeat hits are instant.
326+ _GLOBAL_CACHE_TTL = float (os .getenv ("OSMSG_GLOBAL_CACHE_TTL" , "120" ))
327+ _GLOBAL_GRAIN = 60
328+ _global_cache : collections .OrderedDict [tuple , tuple [float , object ]] = collections .OrderedDict ()
329+ _global_cache_lock = threading .Lock ()
330+
331+
332+ def _round_window (start : dt .datetime , end : dt .datetime ) -> tuple [dt .datetime , dt .datetime ]:
333+ def floor (t : dt .datetime ) -> dt .datetime :
334+ return t .replace (second = (t .second // _GLOBAL_GRAIN ) * _GLOBAL_GRAIN , microsecond = 0 )
335+
336+ return floor (start ), floor (end )
337+
338+
339+ async def _global_cached (fn , key_extra , start , end , ** kwargs ):
340+ rs , re = _round_window (start , end )
341+ key = (fn .__name__ , rs , re , key_extra )
342+ now = time .monotonic ()
343+ with _global_cache_lock :
344+ hit = _global_cache .get (key )
345+ if hit and hit [0 ] > now :
346+ _global_cache .move_to_end (key )
347+ return hit [1 ]
348+ result = await asyncio .to_thread (_run_global , fn , start = rs , end = re , ** kwargs )
349+ with _global_cache_lock :
350+ _global_cache [key ] = (now + _GLOBAL_CACHE_TTL , result )
351+ while len (_global_cache ) > 512 :
352+ _global_cache .popitem (last = False )
353+ return result
354+
355+
356+ async def global_summary (* , start : dt .datetime , end : dt .datetime ):
357+ return await _global_cached (query .global_summary , None , start , end )
358+
359+
360+ async def global_leaderboard (
361+ * ,
362+ start : dt .datetime ,
363+ end : dt .datetime ,
364+ page : int = 1 ,
365+ page_size : int = query .DEFAULT_PAGE_SIZE ,
366+ sort : str = "map_changes" ,
367+ order : str = "desc" ,
368+ q : str | None = None ,
369+ ):
370+ return await _global_cached (
371+ query .global_leaderboard ,
372+ (page , page_size , sort , order , q ),
373+ start ,
374+ end ,
375+ page = page ,
376+ page_size = page_size ,
377+ sort = sort ,
378+ order = order ,
379+ q = q ,
380+ )
381+
382+
383+ async def global_editors (* , start : dt .datetime , end : dt .datetime ):
384+ return await _global_cached (query .global_editors , None , start , end )
385+
386+
387+ async def global_tags (* , start : dt .datetime , end : dt .datetime , limit : int = 100 ):
388+ return await _global_cached (query .global_tags , limit , start , end , limit = limit )
389+
390+
391+ async def global_trending (* , start : dt .datetime , end : dt .datetime , limit : int = 15 ):
392+ return await _global_cached (query .global_trending , limit , start , end , limit = limit )
0 commit comments