"""Bridge event dedupe store for external connector retries.""" from __future__ import annotations import json from dataclasses import asdict, dataclass from datetime import datetime, timezone from pathlib import Path from threading import Lock from typing import Any def _iso_now() -> str: return datetime.now(timezone.utc).isoformat() def _parse_iso(value: str) -> datetime: return datetime.fromisoformat(value.replace("Z", "+00:00")) @dataclass(slots=True) class ConnectorMessageDedupeRecord: dedupe_key: str connection_id: str event_id: str status: str first_seen_at: str updated_at: str delivery_attempts: int message_id: str | None = None last_error: str | None = None def to_dict(self) -> dict[str, Any]: return asdict(self) @classmethod def from_dict(cls, data: dict[str, Any]) -> "ConnectorMessageDedupeRecord": return cls( dedupe_key=str(data.get("dedupe_key") or ""), connection_id=str(data.get("connection_id") or ""), event_id=str(data.get("event_id") or ""), status=str(data.get("status") or "processing"), first_seen_at=str(data.get("first_seen_at") or _iso_now()), updated_at=str(data.get("updated_at") or _iso_now()), delivery_attempts=int(data.get("delivery_attempts") or 0), message_id=str(data["message_id"]) if data.get("message_id") is not None else None, last_error=str(data["last_error"]) if data.get("last_error") is not None else None, ) @dataclass(slots=True) class DedupeBeginResult: should_process: bool dedupe_key: str status: str http_status: int retry_after_seconds: int | None record: ConnectorMessageDedupeRecord class MessageDedupeStore: def __init__(self, path: Path, *, processing_ttl_seconds: int = 60) -> None: self.path = Path(path) self.processing_ttl_seconds = int(processing_ttl_seconds) self._lock = Lock() def begin(self, *, connection_id: str, event_id: str, delivery_attempt: int) -> DedupeBeginResult: dedupe_key = f"{connection_id}:{event_id}" now = _iso_now() with self._lock: data = self._load() raw = data["records"].get(dedupe_key) if isinstance(raw, dict): record = ConnectorMessageDedupeRecord.from_dict(raw) if record.status == "completed": return DedupeBeginResult(False, dedupe_key, record.status, 200, None, record) if record.status == "processing" and not self._is_stale(record, now): return DedupeBeginResult(False, dedupe_key, record.status, 409, 5, record) record.status = "processing" record.updated_at = now record.delivery_attempts = max(record.delivery_attempts + 1, int(delivery_attempt)) record.last_error = None else: record = ConnectorMessageDedupeRecord( dedupe_key=dedupe_key, connection_id=connection_id, event_id=event_id, status="processing", first_seen_at=now, updated_at=now, delivery_attempts=max(1, int(delivery_attempt)), ) data["records"][dedupe_key] = record.to_dict() self._save(data) return DedupeBeginResult(True, dedupe_key, record.status, 200, None, record) def complete(self, dedupe_key: str, *, message_id: str | None) -> ConnectorMessageDedupeRecord: return self._mark(dedupe_key, status="completed", message_id=message_id, error=None) def fail(self, dedupe_key: str, *, error: str) -> ConnectorMessageDedupeRecord: return self._mark(dedupe_key, status="failed", message_id=None, error=error) def _mark( self, dedupe_key: str, *, status: str, message_id: str | None, error: str | None, ) -> ConnectorMessageDedupeRecord: with self._lock: data = self._load() raw = data["records"].get(dedupe_key) if not isinstance(raw, dict): raise KeyError(dedupe_key) record = ConnectorMessageDedupeRecord.from_dict(raw) record.status = status record.updated_at = _iso_now() record.message_id = message_id or record.message_id record.last_error = error data["records"][dedupe_key] = record.to_dict() self._save(data) return record def _is_stale(self, record: ConnectorMessageDedupeRecord, now: str) -> bool: age = (_parse_iso(now) - _parse_iso(record.updated_at)).total_seconds() return age >= self.processing_ttl_seconds def _load(self) -> dict[str, Any]: if not self.path.exists(): return {"records": {}} try: data = json.loads(self.path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return {"records": {}} if not isinstance(data, dict) or not isinstance(data.get("records"), dict): return {"records": {}} return data def _save(self, data: dict[str, Any]) -> None: self.path.parent.mkdir(parents=True, exist_ok=True) tmp_path = self.path.with_name(f"{self.path.name}.tmp") tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") tmp_path.replace(self.path)