diff --git a/core/cache.py b/core/cache.py new file mode 100644 index 0000000..9497c9f --- /dev/null +++ b/core/cache.py @@ -0,0 +1,25 @@ +"""Simple in-memory cache with TTL.""" + +import time + + +class TTLCache: + def __init__(self, ttl_seconds: float): + self._ttl = ttl_seconds + self._entries: dict[str, tuple[object, float]] = {} + + def get(self, key: str): + entry = self._entries.get(key) + if entry is None: + return None + value, expires = entry + if time.monotonic() > expires: + del self._entries[key] + return None + return value + + def set(self, key: str, value): + self._entries[key] = (value, time.monotonic() + self._ttl) + + def clear(self): + self._entries.clear() diff --git a/core/resolver.py b/core/resolver.py index 7a05de6..7e8e7c8 100644 --- a/core/resolver.py +++ b/core/resolver.py @@ -13,12 +13,28 @@ from core.models import ( NetworkError, ) from core import lexicon +from core.cache import TTLCache from core.constellation import get_news from core.records import list_pds_records from core.slingshot import get_record, get_records_batch, resolve_identity +_bbs_cache = TTLCache(ttl_seconds=300) # 5 minutes + + +def invalidate_bbs_cache(): + _bbs_cache.clear() + async def resolve_bbs(client: httpx.AsyncClient, handle: str) -> BBS: + cached = _bbs_cache.get(handle) + if cached: + return cached + bbs = await _resolve_bbs(client, handle) + _bbs_cache.set(handle, bbs) + return bbs + + +async def _resolve_bbs(client: httpx.AsyncClient, handle: str) -> BBS: """Handle -> fully resolved BBS config.""" try: identity = await resolve_identity(client, handle) diff --git a/core/slingshot.py b/core/slingshot.py index b1f583c..40d27a8 100644 --- a/core/slingshot.py +++ b/core/slingshot.py @@ -2,10 +2,13 @@ import asyncio import httpx +from core.cache import TTLCache from core.models import BacklinkRef, MiniDoc, Record BASE_URL = "https://slingshot.microcosm.blue/xrpc" +_identity_cache = TTLCache(ttl_seconds=300) # 5 minutes + async def get_record( client: httpx.AsyncClient, repo: str, collection: str, rkey: str @@ -33,18 +36,25 @@ async def get_record_by_uri(client: httpx.AsyncClient, at_uri: str) -> Record: async def resolve_identity(client: httpx.AsyncClient, identifier: str) -> MiniDoc: """Resolve a handle or DID to a MiniDoc.""" + cached = _identity_cache.get(identifier) + if cached: + return cached + resp = await client.get( f"{BASE_URL}/blue.microcosm.identity.resolveMiniDoc", params={"identifier": identifier}, ) resp.raise_for_status() data = resp.json() - return MiniDoc( + doc = MiniDoc( did=data["did"], handle=data["handle"], pds=data.get("pds"), signing_key=data.get("signing_key"), ) + _identity_cache.set(identifier, doc) + _identity_cache.set(doc.did, doc) + return doc async def resolve_identities_batch( diff --git a/tui/screens/sysop/edit.py b/tui/screens/sysop/edit.py index 0afd196..e2f616a 100644 --- a/tui/screens/sysop/edit.py +++ b/tui/screens/sysop/edit.py @@ -7,6 +7,7 @@ from textual.widgets import Footer, Input, Static, TextArea from core import lexicon, limits from core.models import AuthError, BBS from core.records import delete_record, put_board_record, put_site_record +from core.resolver import invalidate_bbs_cache from core.util import now_iso from tui.util import make_session_updater, require_session from tui.widgets.breadcrumb import Breadcrumb @@ -188,6 +189,7 @@ class SysopEditScreen(Screen): }, updater, ) + invalidate_bbs_cache() self.notify("BBS updated.") self.app.pop_screen() except AuthError: diff --git a/tui/screens/sysop/moderate.py b/tui/screens/sysop/moderate.py index c264cb4..a983188 100644 --- a/tui/screens/sysop/moderate.py +++ b/tui/screens/sysop/moderate.py @@ -14,6 +14,7 @@ from core.records import ( delete_record, list_pds_records, ) +from core.resolver import invalidate_bbs_cache from core.slingshot import resolve_identities_batch, resolve_identity from tui.util import make_session_updater from tui.widgets.breadcrumb import Breadcrumb @@ -137,6 +138,7 @@ class SysopModerateScreen(Screen): ) del self._hide_rkeys[value] self.notify("Post unhidden.") + invalidate_bbs_cache() await item.remove() except AuthError: self.notify("Session expired. Please log in again.", severity="error") @@ -171,6 +173,7 @@ class SysopModerateScreen(Screen): try: await create_ban_record(client, session, did, updater) + invalidate_bbs_cache() self.notify(f"Banned {did}.") self.query_one("#ban-input", Input).value = "" self._load_data() @@ -197,6 +200,7 @@ class SysopModerateScreen(Screen): try: await create_hidden_record(self.app.http_client, session, uri, updater) + invalidate_bbs_cache() self.notify("Post hidden.") self.query_one("#hide-input", Input).value = "" self._load_data() diff --git a/tui/screens/thread.py b/tui/screens/thread.py index 8a2845a..f975a36 100644 --- a/tui/screens/thread.py +++ b/tui/screens/thread.py @@ -17,6 +17,7 @@ from core.records import ( delete_record, reply_from_record, ) +from core.resolver import invalidate_bbs_cache from core.records import hydrate_replies as fetch_replies from core.slingshot import get_record, resolve_identity from tui.screens.compose import ComposeReplyScreen @@ -189,6 +190,7 @@ class ThreadScreen(Screen): updater = make_session_updater(self.app.session_store) try: await create_ban_record(self.app.http_client, session, did, updater) + invalidate_bbs_cache() self.notify(f"Banned {did}.") except Exception: self.notify("Could not ban user.", severity="error") @@ -211,6 +213,7 @@ class ThreadScreen(Screen): await create_hidden_record( self.app.http_client, session, post.record_uri, updater ) + invalidate_bbs_cache() await post.remove() self.notify("Post hidden.") except Exception: