🧹 Cache Registry & Admin Endpoints
Backend services cache provider responses and computed aggregates in named TTL caches built
on theine (Rust-backed core). A small registry in
backend/app/utils/runtime/cache.py tracks every cache by name, and three endpoints under
/api/v1/settings/cache/* expose that registry to the UI.
🧱 The named-cache registry
Services obtain a cache through the registry, never by constructing one ad hoc:
from backend.app.utils.runtime.cache import get_ttl_cache
_fx_fetch_cache = get_ttl_cache("fx_provider_responses", maxsize=200, ttl=300)
get_ttl_cache(name, maxsize, ttl) returns a process-global NamedCache — a thin wrapper
around theine.Cache that binds a default TTL and exposes metadata for the admin views:
cache.set(key, value)/value, ok = cache.get(key)/cache.delete(key)/cache.clear()/len(cache);cache.maxsize/cache.ttlfor stats.
Why theine: W-TinyLFU eviction (adaptive LRU/LFU) when maxsize is reached, per-item TTL
on write with expiry handled by a hierarchical timer wheel (no scanning), thread-safe by
default including free-threaded Python 3.13.6+.
clear() rebuilds the cache instance — by design
theine's own clear() empties the entry map but leaves the W-TinyLFU admission filter
loaded with the frequencies of the dropped keys: once a cache has filled up at least once,
later set() calls are refused against those ghosts and the cache silently stops storing
(measured on theine 2.0.0). NamedCache.clear() therefore swaps in a fresh Cache and
closes the old one (so its timer-wheel thread does not leak). Do not reach for
cache._cache.clear() directly.
Registry-level helpers: clear_cache(name), clear_all_caches(), list_caches() /
get_cache_stats(name) (stats for the admin views), and close_all_caches() — called at
application shutdown to stop the timer-wheel threads.
The FX service also calls clear_cache(name) itself, because its writes make cached portfolio results
stale: every rate write — a provider sync that changed points, a manual upsert, a deletion that removed
rows — clears portfolio_layer2 and portfolio_blob (see
FX Architecture).
🗂️ Caches registered today
Caches register lazily at import time, so the list below is the full registry as of 2026-09-03
(grep get_ttl_cache( under backend/app to re-derive it):
| Cache | maxsize | TTL | Owner |
|---|---|---|---|
search_results |
5000 | 24 h | asset_source — individual search items |
search_queries |
500 | 15 min | asset_source — query → results |
asset_history_fetch |
500 | 15 min | asset_source |
asset_current_fetch |
300 | 2 min | asset_source |
asset_metadata_fetch |
200 | 30 min | asset_source |
fx_provider_responses |
200 | 5 min | fx |
portfolio_blob |
30 | 24 h | financial_math/portfolio/engine.py |
portfolio_layer2 |
20 | 30 min | portfolio_service |
portfolio_wac |
200 | 1 h | portfolio_service |
upload_metadata |
500 | 1 h | static_uploads |
justetf_overview / justetf_chart |
500 | 1 h | justETF provider |
justetf_etf_list |
100 | 1 h | justETF provider |
yfinance_currency |
2000 | 24 h | Yahoo Finance provider |
scheduled_investment |
256 | 48 h | scheduled-investment provider |
risk_optimization / risk_simulation |
32 | 30 min | risk quant engines |
Two module-local caches stay outside the registry, so the admin panel does not list them: the
image-preview cache (PreviewCache in backend/app/api/v1/uploads.py, sized by
PREVIEW_CACHE_MAX_MB, 1 h TTL) and the link-finder's 15-minute query cache in
backend/app/services/web_link_finder.py.
🌐 The three admin endpoints
Defined in backend/app/api/v1/settings.py; schemas (CacheStatusEntry,
CacheStatusResponse, CacheClearResponse) live in backend/app/schemas/settings.py.
| Endpoint | Access | Behaviour |
|---|---|---|
GET /settings/cache/status |
any authenticated user | Lists every registered cache with current_size, maxsize, ttl_seconds |
POST /settings/cache/clear/{name} |
admin only (require_admin) |
Clears one cache; 404 on an unknown name |
POST /settings/cache/clear-all |
admin only | Clears every registered cache; returns the count |
The read-vs-clear split is a deliberate access decision (2026-09-03): cache sizes are operational trivia useful to anyone ("is the search cache even populated?"), while clearing forces provider re-fetches for the whole instance and stays behind the admin gate. Both clears are audit-logged with the admin's id and username.
The registry is per process (_cache_registry is a module-level dict): under
uvicorn --workers N, a status read or a clear reaches only the worker that served the request.
A restart empties every worker's caches.
A clear is a slowdown, not a reset button
After a clear, the next fetch of the affected data hits the providers again — expect slowdowns comparable to a server restart. The UI says so in a danger-styled confirmation modal before either clear action.
🖥️ Frontend: CachePanel.svelte
frontend/src/lib/components/settings/CachePanel.svelte renders the registry as a table
(name, current_size / maxsize, human-formatted TTL) inside the Settings → Global tab
(GlobalSettingsTab.svelte). Status loads for everyone; the per-row Clear buttons and
Clear all are gated by the canEdit prop, which mirrors the Global-settings lock — the
panel shows data to all users and destructive actions to unlocked admins only. See
Settings components.
🧪 Tests
backend/test_scripts/test_api/test_cache_admin_api.py (CAPI-001…CAPI-008) covers the
contract: status readable by a normal user, clears rejected for non-admins (403), single
clear on an unknown name (404), and a functional round-trip that populates the
upload_metadata cache through a real upload, clears it via the endpoint, and verifies the
size drop in a second status read. Tests assert an intersection with a known-cache set —
never the full registry — because caches register lazily.