from __future__ import annotations import os import time from collections import OrderedDict from dataclasses import dataclass from typing import Any, Dict, Optional, Protocol @dataclass class ReplayEntry: message_id: str response_body: Dict[str, Any] created_at: float node_id: str class IdempotencyStore(Protocol): def get(self, key: str) -> Optional[ReplayEntry]: ... def set(self, key: str, entry: ReplayEntry) -> None: ... class InMemoryIdempotencyStore: def __init__(self, ttl_seconds: int = 900, max_size: int = 5000) -> None: self._ttl_seconds = max(60, int(ttl_seconds)) self._max_size = max(100, int(max_size)) self._values: "OrderedDict[str, Dict[str, Any]]" = OrderedDict() def _cleanup(self, now: Optional[float] = None) -> None: ts = now if now is not None else time.monotonic() while self._values: first_key = next(iter(self._values)) exp = float((self._values[first_key] or {}).get("expires_at", 0.0)) if exp > ts: break self._values.popitem(last=False) def get(self, key: str) -> Optional[ReplayEntry]: self._cleanup() hit = self._values.get(key) if not hit: return None # Touch key to preserve LRU behavior. self._values.move_to_end(key, last=True) entry = hit.get("entry") return entry if isinstance(entry, ReplayEntry) else None def set(self, key: str, entry: ReplayEntry) -> None: now = time.monotonic() self._cleanup(now) self._values[key] = { "expires_at": now + self._ttl_seconds, "entry": entry, } self._values.move_to_end(key, last=True) while len(self._values) > self._max_size: self._values.popitem(last=False) # Debug/test helpers def size(self) -> int: self._cleanup() return len(self._values) def delete(self, key: str) -> None: self._values.pop(key, None) def reset(self) -> None: self._values.clear() _STORE: Optional[IdempotencyStore] = None def get_idempotency_store() -> IdempotencyStore: global _STORE if _STORE is None: ttl = int( os.getenv( "SOFIIA_IDEMPOTENCY_TTL_S", os.getenv("CHAT_IDEMPOTENCY_TTL_SEC", "900"), ) ) max_size = int(os.getenv("SOFIIA_IDEMPOTENCY_MAX", "5000")) _STORE = InMemoryIdempotencyStore(ttl_seconds=ttl, max_size=max_size) return _STORE