refactor: cache panel squad and host lookups in-memory with TTL

This commit is contained in:
3252a8
2026-05-16 00:01:40 +03:00
parent 1e97dd9fe5
commit 87f9e23bae
2 changed files with 86 additions and 0 deletions
+31
View File
@@ -9,6 +9,7 @@ from urllib.parse import urlencode
import aiohttp
from sqlalchemy.ext.asyncio import AsyncSession
from bot.utils.ttl_cache import AsyncTTLCache
from config.settings import Settings
from db.dal import panel_sync_dal
from db.models import PanelSyncStatus
@@ -27,6 +28,10 @@ class PanelApiService:
self.api_key = settings.PANEL_API_KEY
self._session: Optional[aiohttp.ClientSession] = None
self.default_client_ip = "127.0.0.1"
# Cache slow-changing reference data fetched from the panel. Errors and
# None responses are not cached, so transient failures self-heal.
self._squads_cache: AsyncTTLCache = AsyncTTLCache(ttl_seconds=300)
self._hosts_cache: AsyncTTLCache = AsyncTTLCache(ttl_seconds=300)
async def __aenter__(self):
"""Context manager entry"""
@@ -604,7 +609,13 @@ class PanelApiService:
)
return None
def _invalidate_squad_caches(self) -> None:
self._squads_cache.invalidate()
async def get_internal_squads(self) -> Optional[List[Dict[str, Any]]]:
return await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached)
async def _get_internal_squads_uncached(self) -> Optional[List[Dict[str, Any]]]:
response_data = await self._request("GET", "/internal-squads", log_full_response=False)
if response_data and not response_data.get("error") and "response" in response_data:
response = response_data.get("response")
@@ -619,6 +630,12 @@ class PanelApiService:
return None
async def get_internal_squad(self, squad_uuid: str) -> Optional[Dict[str, Any]]:
return await self._squads_cache.get_or_load(
f"detail:{squad_uuid}",
lambda: self._get_internal_squad_uncached(squad_uuid),
)
async def _get_internal_squad_uncached(self, squad_uuid: str) -> Optional[Dict[str, Any]]:
response_data = await self._request(
"GET", f"/internal-squads/{squad_uuid}", log_full_response=False
)
@@ -639,6 +656,15 @@ class PanelApiService:
async def get_internal_squad_accessible_nodes(
self,
squad_uuid: str,
) -> Optional[List[Dict[str, Any]]]:
return await self._squads_cache.get_or_load(
f"nodes:{squad_uuid}",
lambda: self._get_internal_squad_accessible_nodes_uncached(squad_uuid),
)
async def _get_internal_squad_accessible_nodes_uncached(
self,
squad_uuid: str,
) -> Optional[List[Dict[str, Any]]]:
endpoints = (
f"/internal-squads/{squad_uuid}/accessible-nodes",
@@ -665,6 +691,9 @@ class PanelApiService:
return None
async def get_hosts(self) -> Optional[List[Dict[str, Any]]]:
return await self._hosts_cache.get_or_load("list", self._get_hosts_uncached)
async def _get_hosts_uncached(self) -> Optional[List[Dict[str, Any]]]:
response_data = await self._request("GET", "/hosts", log_full_response=False)
if response_data and not response_data.get("error") and "response" in response_data:
response = response_data.get("response")
@@ -695,6 +724,7 @@ class PanelApiService:
log_full_response=False,
)
if response_data and not response_data.get("error"):
self._invalidate_squad_caches()
return True
logging.error("Failed to add users to squad %s. Response: %s", squad_uuid, response_data)
return False
@@ -710,6 +740,7 @@ class PanelApiService:
log_full_response=False,
)
if response_data and not response_data.get("error"):
self._invalidate_squad_caches()
return True
logging.error(
"Failed to remove users from squad %s. Response: %s", squad_uuid, response_data
+55
View File
@@ -0,0 +1,55 @@
import asyncio
import time
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
class AsyncTTLCache:
"""In-memory async-safe TTL cache with single-flight loader.
Concurrent get_or_load() calls for the same key share one loader execution.
"""
def __init__(self, ttl_seconds: float):
self.ttl_seconds = ttl_seconds
self._data: Dict[str, Tuple[float, Any]] = {}
self._locks: Dict[str, asyncio.Lock] = {}
def _is_fresh(self, expires_at: float) -> bool:
return time.monotonic() < expires_at
def get_fresh(self, key: str) -> Optional[Any]:
entry = self._data.get(key)
if entry is None:
return None
expires_at, value = entry
if not self._is_fresh(expires_at):
return None
return value
@staticmethod
def _is_cacheable(value: Any) -> bool:
if value is None:
return False
if isinstance(value, dict) and value.get("error"):
return False
return True
async def get_or_load(self, key: str, loader: Callable[[], Awaitable[Any]]) -> Any:
cached = self.get_fresh(key)
if cached is not None:
return cached
lock = self._locks.setdefault(key, asyncio.Lock())
async with lock:
cached = self.get_fresh(key)
if cached is not None:
return cached
value = await loader()
if self._is_cacheable(value):
self._data[key] = (time.monotonic() + self.ttl_seconds, value)
return value
def invalidate(self, key: Optional[str] = None) -> None:
if key is None:
self._data.clear()
return
self._data.pop(key, None)