zcbot/core/external_systems/runtime_cache.py

207 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""外部系统进程内运行态缓存与同步 single-flight。"""
from __future__ import annotations
import atexit
import time
from collections import OrderedDict
from concurrent.futures import Future
from contextlib import contextmanager
from dataclasses import dataclass
from threading import RLock
from typing import Any, Callable, Iterator, TypeVar
T = TypeVar("T")
@dataclass
class _RuntimeEntry:
client: Any = None
active_client_leases: int = 0
evicted: bool = False
auth_headers: dict[str, str] | None = None
auth_expires_at: float = 0.0
auth_generation: int = 0
spec: dict[str, Any] | None = None
spec_expires_at: float = 0.0
catalog: Any = None
catalog_spec: dict[str, Any] | None = None
class ExternalRuntimeCache:
"""按连接身份隔离的有界 LRU凭据、Token 和规格均只驻留当前进程。"""
def __init__(self, *, max_entries: int = 256):
self.max_entries = max_entries
self._entries: OrderedDict[str, _RuntimeEntry] = OrderedDict()
self._inflight: dict[tuple[str, str], Future[Any]] = {}
self._lock = RLock()
@staticmethod
def _close_client(client: Any) -> None:
close = getattr(client, "close", None)
if callable(close):
try:
close()
except Exception:
pass
def _entry_locked(self, identity: str) -> _RuntimeEntry:
entry = self._entries.get(identity)
if entry is None:
entry = _RuntimeEntry()
self._entries[identity] = entry
self._entries.move_to_end(identity)
while len(self._entries) > self.max_entries:
_, evicted = self._entries.popitem(last=False)
evicted.evicted = True
if evicted.active_client_leases == 0:
self._close_client(evicted.client)
return entry
@contextmanager
def client(self, identity: str, factory: Callable[[], T]) -> Iterator[T]:
with self._lock:
entry = self._entry_locked(identity)
if entry.client is None:
entry.client = factory()
entry.active_client_leases += 1
client = entry.client
try:
yield client
finally:
close_client = None
with self._lock:
entry.active_client_leases -= 1
if entry.evicted and entry.active_client_leases == 0:
close_client = entry.client
entry.client = None
if close_client is not None:
self._close_client(close_client)
def get_auth(self, identity: str) -> dict[str, str] | None:
now = time.monotonic()
with self._lock:
entry = self._entries.get(identity)
if entry is None or entry.auth_expires_at <= now:
if entry is not None:
entry.auth_headers = None
entry.auth_expires_at = 0.0
return None
self._entries.move_to_end(identity)
return dict(entry.auth_headers or {})
def set_auth(
self, identity: str, headers: dict[str, str], *, ttl_seconds: float
) -> None:
with self._lock:
entry = self._entry_locked(identity)
entry.auth_headers = dict(headers)
entry.auth_expires_at = time.monotonic() + max(0.0, ttl_seconds)
entry.auth_generation += 1
def auth_state(self, identity: str) -> tuple[dict[str, str] | None, int]:
headers = self.get_auth(identity)
with self._lock:
entry = self._entries.get(identity)
return headers, entry.auth_generation if entry is not None else 0
def invalidate_auth(self, identity: str) -> None:
with self._lock:
entry = self._entries.get(identity)
if entry is not None:
entry.auth_headers = None
entry.auth_expires_at = 0.0
def get_spec(self, identity: str) -> dict[str, Any] | None:
now = time.monotonic()
with self._lock:
entry = self._entries.get(identity)
if entry is None or entry.spec_expires_at <= now:
if entry is not None:
entry.spec = None
entry.spec_expires_at = 0.0
entry.catalog = None
entry.catalog_spec = None
return None
self._entries.move_to_end(identity)
return entry.spec
def set_spec(
self, identity: str, spec: dict[str, Any], *, ttl_seconds: float
) -> None:
with self._lock:
entry = self._entry_locked(identity)
entry.spec = spec
entry.spec_expires_at = time.monotonic() + max(0.0, ttl_seconds)
entry.catalog = None
entry.catalog_spec = None
def invalidate_spec(self, identity: str) -> None:
with self._lock:
entry = self._entries.get(identity)
if entry is not None:
entry.spec = None
entry.spec_expires_at = 0.0
entry.catalog = None
entry.catalog_spec = None
def get_catalog(self, identity: str, spec: dict[str, Any]) -> Any:
with self._lock:
entry = self._entries.get(identity)
if entry is None or entry.catalog_spec is not spec:
return None
self._entries.move_to_end(identity)
return entry.catalog
def set_catalog(self, identity: str, spec: dict[str, Any], catalog: Any) -> None:
with self._lock:
entry = self._entry_locked(identity)
entry.catalog_spec = spec
entry.catalog = catalog
def singleflight(self, namespace: str, key: str, compute: Callable[[], T]) -> T:
flight_key = (namespace, key)
with self._lock:
future = self._inflight.get(flight_key)
leader = future is None
if leader:
future = Future()
self._inflight[flight_key] = future
assert future is not None
if not leader:
return future.result()
try:
result = compute()
except BaseException as exc:
future.set_exception(exc)
raise
else:
future.set_result(result)
return result
finally:
with self._lock:
if self._inflight.get(flight_key) is future:
self._inflight.pop(flight_key, None)
def clear(self) -> None:
with self._lock:
entries = list(self._entries.values())
self._entries.clear()
for entry in entries:
self._close_client(entry.client)
def spec_count(self) -> int:
now = time.monotonic()
with self._lock:
return sum(
1
for entry in self._entries.values()
if entry.spec is not None and entry.spec_expires_at > now
)
RUNTIME_CACHE = ExternalRuntimeCache()
atexit.register(RUNTIME_CACHE.clear)