refactor(sofiia-console): extract idempotency store abstraction
Move idempotency TTL/LRU logic into a dedicated store module with a swap-ready interface and wire chat send flow to use store get/set semantics without changing API behavior. Made-with: Cursor
This commit is contained in:
89
services/sofiia-console/app/idempotency.py
Normal file
89
services/sofiia-console/app/idempotency.py
Normal file
@@ -0,0 +1,89 @@
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user