Skip to content

Commit 98ee2d0

Browse files
fix(live-search): add global endpoint for trending
1 parent 5ddc543 commit 98ee2d0

14 files changed

Lines changed: 658 additions & 73 deletions

File tree

api/app.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from .db import close_pool, ensure_schema, open_pool
1414
from .queries import fetch_state
15+
from .routers.global_stats import global_router
1516
from .routers.hashtag import v2_router
1617
from .schemas import HealthResponse
1718

@@ -95,7 +96,7 @@ async def health() -> HealthResponse:
9596

9697

9798
app = Litestar(
98-
route_handlers=[*_root_handlers(), health, v2_router],
99+
route_handlers=[*_root_handlers(), health, v2_router, global_router],
99100
lifespan=[lifespan],
100101
middleware=[rate_limit_config.middleware],
101102
cors_config=CORSConfig(

api/duck.py

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from __future__ import annotations
77

88
import asyncio
9+
import collections
910
import contextlib
1011
import datetime as dt
1112
import logging
@@ -44,6 +45,9 @@
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

6469
def _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)

api/routers/global_stats.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Whole-OSM (no-hashtag) stats over a recent window, capped at GLOBAL_MAX_DAYS."""
2+
3+
from datetime import UTC, datetime, timedelta
4+
from typing import Any
5+
6+
from litestar import Controller, Router, get
7+
from litestar.exceptions import HTTPException
8+
9+
from osmsg.query import GLOBAL_MAX_DAYS
10+
from osmsg.query import LEADERBOARD_SORTS as _LEADERBOARD_SORTS
11+
12+
from .. import duck
13+
14+
_WINDOWS = {
15+
"1h": timedelta(hours=1),
16+
"24h": timedelta(hours=24),
17+
"7d": timedelta(days=7),
18+
}
19+
_MAX = timedelta(days=GLOBAL_MAX_DAYS)
20+
21+
22+
def _resolve(window: str | None, start: datetime | None, end: datetime | None) -> tuple[datetime, datetime]:
23+
if window is not None:
24+
if window not in _WINDOWS:
25+
raise HTTPException(status_code=400, detail=f"window must be one of {', '.join(_WINDOWS)}")
26+
now = datetime.now(UTC)
27+
return now - _WINDOWS[window], now
28+
if start is None or end is None:
29+
raise HTTPException(status_code=400, detail="provide window (1h|24h|7d) or both start and end")
30+
if start >= end:
31+
raise HTTPException(status_code=400, detail="start must be before end")
32+
if end - start > _MAX:
33+
raise HTTPException(status_code=400, detail=f"global window cannot exceed {GLOBAL_MAX_DAYS} days")
34+
if start < datetime.now(UTC) - _MAX - timedelta(days=2):
35+
raise HTTPException(status_code=400, detail=f"global stats cover only the last {GLOBAL_MAX_DAYS} days")
36+
return start, end
37+
38+
39+
class GlobalController(Controller):
40+
path = "/global"
41+
42+
@get("/summary")
43+
async def get_summary(
44+
self, window: str | None = None, start: datetime | None = None, end: datetime | None = None
45+
) -> dict[str, Any]:
46+
s, e = _resolve(window, start, end)
47+
return await duck.global_summary(start=s, end=e)
48+
49+
@get("/leaderboard")
50+
async def get_leaderboard(
51+
self,
52+
window: str | None = None,
53+
start: datetime | None = None,
54+
end: datetime | None = None,
55+
page: int = 1,
56+
page_size: int = 25,
57+
sort: str = "map_changes",
58+
order: str = "desc",
59+
q: str | None = None,
60+
) -> dict[str, Any]:
61+
if sort not in _LEADERBOARD_SORTS:
62+
raise HTTPException(status_code=400, detail=f"sort must be one of {', '.join(_LEADERBOARD_SORTS)}")
63+
if order not in ("asc", "desc"):
64+
raise HTTPException(status_code=400, detail="order must be 'asc' or 'desc'")
65+
if page < 1:
66+
raise HTTPException(status_code=400, detail="page must be >= 1")
67+
s, e = _resolve(window, start, end)
68+
return await duck.global_leaderboard(
69+
start=s, end=e, page=page, page_size=page_size, sort=sort, order=order, q=q
70+
)
71+
72+
@get("/editors")
73+
async def get_editors(
74+
self, window: str | None = None, start: datetime | None = None, end: datetime | None = None
75+
) -> list[dict[str, Any]]:
76+
s, e = _resolve(window, start, end)
77+
return await duck.global_editors(start=s, end=e)
78+
79+
@get("/tags")
80+
async def get_tags(
81+
self, window: str | None = None, start: datetime | None = None, end: datetime | None = None, limit: int = 100
82+
) -> list[dict[str, Any]]:
83+
s, e = _resolve(window, start, end)
84+
return await duck.global_tags(start=s, end=e, limit=limit)
85+
86+
@get("/trending")
87+
async def get_trending(
88+
self, window: str | None = None, start: datetime | None = None, end: datetime | None = None, limit: int = 15
89+
) -> list[dict[str, Any]]:
90+
s, e = _resolve(window, start, end)
91+
return await duck.global_trending(start=s, end=e, limit=limit)
92+
93+
94+
global_router = Router(path="/api/v2", route_handlers=[GlobalController])

frontend/app.js

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,10 @@ const BUSY_RETRIES = 1;
460460
const BUSY_BACKOFF_MS = 2500;
461461

462462
function endpoint(name, params) {
463-
const base = `/api/v2/hashtag/${encodeURIComponent(state.hashtags.join(","))}/${name}`;
463+
// No hashtags -> whole-OSM (global) endpoints; the co-occurring "hashtags" section maps to global trending.
464+
const base = state.hashtags.length
465+
? `/api/v2/hashtag/${encodeURIComponent(state.hashtags.join(","))}/${name}`
466+
: `/api/v2/global/${name === "hashtags" ? "trending" : name}`;
464467
const u = new URL(base, API_BASE);
465468
params.forEach((v, k) => u.searchParams.set(k, v));
466469
return u;
@@ -505,7 +508,7 @@ function freezeWindow() {
505508
function windowParams() {
506509
if (!state.windowStart || !state.windowEnd) freezeWindow();
507510
const p = new URLSearchParams();
508-
// All-time omits the window: an explicit full range bypasses the API's all-time cache and warm.
511+
// All-time omits the window: an explicit full range bypasses the API's all-time cache.
509512
if (state.range !== "all") {
510513
p.set("start", isoUTC(state.windowStart));
511514
p.set("end", isoUTC(state.windowEnd));
@@ -568,11 +571,17 @@ async function runQuery() {
568571
writeURL();
569572
renderWindowBar();
570573
fetchHealth();
571-
if (!state.hashtags.length) {
572-
showEmptyPrompt();
573-
return;
574+
const global = !state.hashtags.length;
575+
if (global) {
576+
// Whole-OSM stats are Postgres-only and limited to the last 7 days; wider or all-time is not supported.
577+
const spanMs = state.windowEnd - state.windowStart;
578+
if (state.range === "all" || spanMs > 7 * 86400 * 1000 + 60000) {
579+
showGlobalPrompt();
580+
return;
581+
}
582+
} else {
583+
saveRecentSearch();
574584
}
575-
saveRecentSearch();
576585
state.query?.abort?.();
577586
const ctrl = new AbortController();
578587
state.query = ctrl;
@@ -668,7 +677,6 @@ function sliceBatchToRows() {
668677
// the UI then pages 10/20/50 WITHIN a batch client-side. Only crossing a batch boundary, or a new
669678
// sort/search (`forceFetch`), hits the API. `setPodium` seeds the top-3 from the first batch.
670679
async function loadLeaderboardPage(setPodium = false, forceFetch = false) {
671-
if (!state.hashtags.length) return;
672680
const startRow = (state.page - 1) * state.pageSize;
673681
const batchIndex = Math.floor(startRow / state.batchSize);
674682
// Serve from the loaded batch when possible.
@@ -932,7 +940,17 @@ function showEmptyPrompt() {
932940
$("#podium")?.closest("section")?.style.setProperty("display", "none");
933941
$("#ov-details").hidden = true;
934942
$("#podium").innerHTML = "";
935-
$("#lb-body").innerHTML = `<tr><td colspan="8"><div class="empty"><i data-lucide="arrow-down-to-line"></i><h3>Extract a hashtag</h3><p>Type one or more hashtags above and press Extract.</p></div></td></tr>`;
943+
$("#lb-body").innerHTML = `<tr><td colspan="8"><div class="empty"><i data-lucide="arrow-down-to-line"></i><h3>Extract a hashtag</h3><p>Type one or more hashtags above and press Extract, or leave it empty for whole-OSM stats (last 30 days).</p></div></td></tr>`;
944+
$("#pagination").hidden = true;
945+
refreshIcons();
946+
}
947+
948+
function showGlobalPrompt() {
949+
$("#overview")?.closest("section")?.style.setProperty("display", "none");
950+
$("#podium")?.closest("section")?.style.setProperty("display", "none");
951+
$("#ov-details").hidden = true;
952+
$("#podium").innerHTML = "";
953+
$("#lb-body").innerHTML = `<tr><td colspan="8"><div class="empty"><i data-lucide="globe"></i><h3>Whole-OSM stats</h3><p>Global stats (no hashtag) cover the last 7 days. Choose 1h, 24h, or 7d, then Extract.</p></div></td></tr>`;
936954
$("#pagination").hidden = true;
937955
refreshIcons();
938956
}
@@ -1569,7 +1587,7 @@ function renderEditorStats() {
15691587
}
15701588

15711589
async function fetchEditorStats() {
1572-
if (!state.hashtags.length) { state.editorStats = null; renderEditorStats(); return; }
1590+
if (!state.query) { state.editorStats = null; renderEditorStats(); return; }
15731591
try {
15741592
const editors = await apiGet("editors", windowParams(), state.query?.signal);
15751593
const all = (editors || [])

0 commit comments

Comments
 (0)