diff --git a/decnet/web/router/bounty/api_get_bounties.py b/decnet/web/router/bounty/api_get_bounties.py index 62ac063..5560181 100644 --- a/decnet/web/router/bounty/api_get_bounties.py +++ b/decnet/web/router/bounty/api_get_bounties.py @@ -1,3 +1,5 @@ +import asyncio +import time from typing import Any, Optional from fastapi import APIRouter, Depends, Query @@ -8,6 +10,43 @@ from decnet.web.db.models import BountyResponse router = APIRouter() +# Cache the unfiltered default page — the UI/locust hit this constantly +# with no params. Filtered requests (bounty_type/search) bypass: rare +# and staleness matters for search. +_BOUNTY_TTL = 5.0 +_DEFAULT_LIMIT = 50 +_DEFAULT_OFFSET = 0 +_bounty_cache: tuple[Optional[dict[str, Any]], float] = (None, 0.0) +_bounty_lock: Optional[asyncio.Lock] = None + + +def _reset_bounty_cache() -> None: + global _bounty_cache, _bounty_lock + _bounty_cache = (None, 0.0) + _bounty_lock = None + + +async def _get_bounty_default_cached() -> dict[str, Any]: + global _bounty_cache, _bounty_lock + value, ts = _bounty_cache + now = time.monotonic() + if value is not None and now - ts < _BOUNTY_TTL: + return value + if _bounty_lock is None: + _bounty_lock = asyncio.Lock() + async with _bounty_lock: + value, ts = _bounty_cache + now = time.monotonic() + if value is not None and now - ts < _BOUNTY_TTL: + return value + _data = await repo.get_bounties( + limit=_DEFAULT_LIMIT, offset=_DEFAULT_OFFSET, bounty_type=None, search=None, + ) + _total = await repo.get_total_bounties(bounty_type=None, search=None) + value = {"total": _total, "limit": _DEFAULT_LIMIT, "offset": _DEFAULT_OFFSET, "data": _data} + _bounty_cache = (value, time.monotonic()) + return value + @router.get("/bounty", response_model=BountyResponse, tags=["Bounty Vault"], responses={401: {"description": "Could not validate credentials"}, 403: {"description": "Insufficient permissions"}, 422: {"description": "Validation error"}},) @@ -28,6 +67,9 @@ async def get_bounties( bt = _norm(bounty_type) s = _norm(search) + if bt is None and s is None and limit == _DEFAULT_LIMIT and offset == _DEFAULT_OFFSET: + return await _get_bounty_default_cached() + _data = await repo.get_bounties(limit=limit, offset=offset, bounty_type=bt, search=s) _total = await repo.get_total_bounties(bounty_type=bt, search=s) return { diff --git a/decnet/web/router/config/api_get_config.py b/decnet/web/router/config/api_get_config.py index 3e751aa..de12516 100644 --- a/decnet/web/router/config/api_get_config.py +++ b/decnet/web/router/config/api_get_config.py @@ -16,7 +16,7 @@ _DEFAULT_MUTATION_INTERVAL = "30m" # Cache config_limits / config_globals reads — these change on rare admin # writes but get polled constantly by the UI and locust. -_STATE_TTL = 1.0 +_STATE_TTL = 5.0 _state_cache: dict[str, tuple[Optional[dict[str, Any]], float]] = {} _state_locks: dict[str, asyncio.Lock] = {} diff --git a/decnet/web/router/fleet/api_get_deckies.py b/decnet/web/router/fleet/api_get_deckies.py index 1d81a3a..593ff4e 100644 --- a/decnet/web/router/fleet/api_get_deckies.py +++ b/decnet/web/router/fleet/api_get_deckies.py @@ -1,4 +1,6 @@ -from typing import Any +import asyncio +import time +from typing import Any, Optional from fastapi import APIRouter, Depends @@ -7,9 +9,40 @@ from decnet.web.dependencies import require_viewer, repo router = APIRouter() +# /deckies is full fleet inventory — polled by the UI and under locust. +# Fleet state changes on deploy/teardown (seconds to minutes); a 5s window +# collapses the read storm into one DB hit. +_DECKIES_TTL = 5.0 +_deckies_cache: tuple[Optional[list[dict[str, Any]]], float] = (None, 0.0) +_deckies_lock: Optional[asyncio.Lock] = None + + +def _reset_deckies_cache() -> None: + global _deckies_cache, _deckies_lock + _deckies_cache = (None, 0.0) + _deckies_lock = None + + +async def _get_deckies_cached() -> list[dict[str, Any]]: + global _deckies_cache, _deckies_lock + value, ts = _deckies_cache + now = time.monotonic() + if value is not None and now - ts < _DECKIES_TTL: + return value + if _deckies_lock is None: + _deckies_lock = asyncio.Lock() + async with _deckies_lock: + value, ts = _deckies_cache + now = time.monotonic() + if value is not None and now - ts < _DECKIES_TTL: + return value + value = await repo.get_deckies() + _deckies_cache = (value, time.monotonic()) + return value + @router.get("/deckies", tags=["Fleet Management"], responses={401: {"description": "Could not validate credentials"}, 403: {"description": "Insufficient permissions"}, 422: {"description": "Validation error"}},) @_traced("api.get_deckies") async def get_deckies(user: dict = Depends(require_viewer)) -> list[dict[str, Any]]: - return await repo.get_deckies() + return await _get_deckies_cached() diff --git a/decnet/web/router/logs/api_get_histogram.py b/decnet/web/router/logs/api_get_histogram.py index 28c21b2..c334987 100644 --- a/decnet/web/router/logs/api_get_histogram.py +++ b/decnet/web/router/logs/api_get_histogram.py @@ -1,3 +1,5 @@ +import asyncio +import time from typing import Any, Optional from fastapi import APIRouter, Depends, Query @@ -7,6 +9,40 @@ from decnet.web.dependencies import require_viewer, repo router = APIRouter() +# /logs/histogram aggregates over the full logs table — expensive and +# polled constantly by the UI. Cache only the unfiltered default call +# (which is what the UI and locust hit); any filter bypasses. +_HISTOGRAM_TTL = 5.0 +_DEFAULT_INTERVAL = 15 +_histogram_cache: tuple[Optional[list[dict[str, Any]]], float] = (None, 0.0) +_histogram_lock: Optional[asyncio.Lock] = None + + +def _reset_histogram_cache() -> None: + global _histogram_cache, _histogram_lock + _histogram_cache = (None, 0.0) + _histogram_lock = None + + +async def _get_histogram_cached() -> list[dict[str, Any]]: + global _histogram_cache, _histogram_lock + value, ts = _histogram_cache + now = time.monotonic() + if value is not None and now - ts < _HISTOGRAM_TTL: + return value + if _histogram_lock is None: + _histogram_lock = asyncio.Lock() + async with _histogram_lock: + value, ts = _histogram_cache + now = time.monotonic() + if value is not None and now - ts < _HISTOGRAM_TTL: + return value + value = await repo.get_log_histogram( + search=None, start_time=None, end_time=None, interval_minutes=_DEFAULT_INTERVAL, + ) + _histogram_cache = (value, time.monotonic()) + return value + @router.get("/logs/histogram", tags=["Logs"], responses={401: {"description": "Could not validate credentials"}, 403: {"description": "Insufficient permissions"}, 422: {"description": "Validation error"}},) @@ -27,4 +63,6 @@ async def get_logs_histogram( st = _norm(start_time) et = _norm(end_time) + if s is None and st is None and et is None and interval_minutes == _DEFAULT_INTERVAL: + return await _get_histogram_cached() return await repo.get_log_histogram(search=s, start_time=st, end_time=et, interval_minutes=interval_minutes) diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 186caa1..aa1ddbf 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -59,11 +59,17 @@ async def setup_db(monkeypatch) -> AsyncGenerator[None, None]: from decnet.web.router.stats import api_get_stats as _s from decnet.web.router.logs import api_get_logs as _l from decnet.web.router.attackers import api_get_attackers as _a + from decnet.web.router.bounty import api_get_bounties as _b + from decnet.web.router.logs import api_get_histogram as _lh + from decnet.web.router.fleet import api_get_deckies as _d _h._reset_db_cache() _c._reset_state_cache() _s._reset_stats_cache() _l._reset_total_cache() _a._reset_total_cache() + _b._reset_bounty_cache() + _lh._reset_histogram_cache() + _d._reset_deckies_cache() # Create schema async with engine.begin() as conn: