Files
microdao-daarion/services/sofiia-console/app/idempotency.py
Apple 0c626943d6 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
2026-03-02 08:11:13 -08:00

90 lines
2.5 KiB
Python

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