feat: implement channel runtime connectors
This commit is contained in:
@ -0,0 +1,144 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user