158 lines
5.2 KiB
Python
158 lines
5.2 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Callable, Mapping
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any, cast
|
|
|
|
from sqlalchemy import or_
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_audit.backend.db.models import AuditOutboxEvent
|
|
from govoplan_core.core.events import (
|
|
EventActorRef,
|
|
EventClassification,
|
|
EventObjectRef,
|
|
EventTenantRef,
|
|
PlatformEvent,
|
|
ensure_event_trace,
|
|
publish_platform_event,
|
|
)
|
|
|
|
EventDispatcher = Callable[[PlatformEvent], None]
|
|
|
|
|
|
class SqlAuditOutbox:
|
|
def enqueue(self, session: object, event: PlatformEvent) -> AuditOutboxEvent:
|
|
db = _session(session)
|
|
traced = ensure_event_trace(event)
|
|
item = AuditOutboxEvent(
|
|
event_id=traced.event_id,
|
|
event_type=traced.type,
|
|
module_id=traced.module_id,
|
|
correlation_id=traced.correlation_id,
|
|
causation_id=traced.causation_id,
|
|
classification=traced.classification,
|
|
payload=traced.to_dict(),
|
|
status="pending",
|
|
)
|
|
db.add(item)
|
|
db.flush()
|
|
return item
|
|
|
|
def dispatch_pending(
|
|
self,
|
|
session: object,
|
|
*,
|
|
dispatcher: EventDispatcher = publish_platform_event,
|
|
limit: int = 100,
|
|
) -> dict[str, int]:
|
|
db = _session(session)
|
|
now = datetime.now(timezone.utc)
|
|
rows = (
|
|
db.query(AuditOutboxEvent)
|
|
.filter(
|
|
AuditOutboxEvent.status.in_(("pending", "failed")),
|
|
or_(AuditOutboxEvent.next_attempt_at.is_(None), AuditOutboxEvent.next_attempt_at <= now),
|
|
)
|
|
.order_by(AuditOutboxEvent.created_at.asc(), AuditOutboxEvent.id.asc())
|
|
.limit(max(1, min(int(limit), 500)))
|
|
.all()
|
|
)
|
|
counts = {"selected": len(rows), "dispatched": 0, "failed": 0}
|
|
for row in rows:
|
|
try:
|
|
dispatcher(_event_from_payload(row.payload))
|
|
except Exception as exc: # noqa: BLE001 - dispatcher errors must be retained for retry/diagnostics.
|
|
row.status = "failed"
|
|
row.attempts += 1
|
|
row.last_error = str(exc)
|
|
row.next_attempt_at = now + _retry_delay(row.attempts)
|
|
counts["failed"] += 1
|
|
continue
|
|
row.status = "dispatched"
|
|
row.attempts += 1
|
|
row.dispatched_at = now
|
|
row.next_attempt_at = None
|
|
row.last_error = None
|
|
counts["dispatched"] += 1
|
|
db.flush()
|
|
return counts
|
|
|
|
|
|
def enqueue_platform_event(session: object, event: PlatformEvent) -> AuditOutboxEvent:
|
|
return SqlAuditOutbox().enqueue(session, event)
|
|
|
|
|
|
def dispatch_pending_platform_events(
|
|
session: object,
|
|
*,
|
|
dispatcher: EventDispatcher = publish_platform_event,
|
|
limit: int = 100,
|
|
) -> dict[str, int]:
|
|
return SqlAuditOutbox().dispatch_pending(session, dispatcher=dispatcher, limit=limit)
|
|
|
|
|
|
def _retry_delay(attempts: int) -> timedelta:
|
|
seconds = min(300, max(1, 2 ** max(0, attempts - 1)))
|
|
return timedelta(seconds=seconds)
|
|
|
|
|
|
def _event_from_payload(payload: Mapping[str, Any]) -> PlatformEvent:
|
|
return PlatformEvent(
|
|
type=str(payload["type"]),
|
|
module_id=str(payload["module_id"]),
|
|
payload=_mapping(payload.get("payload")),
|
|
occurred_at=_datetime(payload.get("occurred_at")),
|
|
event_id=str(payload["event_id"]),
|
|
correlation_id=_optional_str(payload.get("correlation_id")),
|
|
causation_id=_optional_str(payload.get("causation_id")),
|
|
actor=_actor_ref(payload.get("actor")),
|
|
tenant=_tenant_ref(payload.get("tenant")),
|
|
subject=_object_ref(payload.get("subject")),
|
|
resource=_object_ref(payload.get("resource")),
|
|
classification=cast(EventClassification, str(payload.get("classification") or "internal")),
|
|
)
|
|
|
|
|
|
def _session(session: object) -> Session:
|
|
if not isinstance(session, Session):
|
|
raise TypeError("Audit outbox requires a SQLAlchemy Session")
|
|
return session
|
|
|
|
|
|
def _mapping(value: object) -> dict[str, Any]:
|
|
return dict(value) if isinstance(value, Mapping) else {}
|
|
|
|
|
|
def _optional_str(value: object) -> str | None:
|
|
return str(value) if value is not None else None
|
|
|
|
|
|
def _datetime(value: object) -> datetime:
|
|
if isinstance(value, datetime):
|
|
return value
|
|
if isinstance(value, str):
|
|
return datetime.fromisoformat(value)
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _actor_ref(value: object) -> EventActorRef | None:
|
|
data = _mapping(value)
|
|
if not data:
|
|
return None
|
|
return EventActorRef(type=str(data["type"]), id=_optional_str(data.get("id")), label=_optional_str(data.get("label")))
|
|
|
|
|
|
def _tenant_ref(value: object) -> EventTenantRef | None:
|
|
data = _mapping(value)
|
|
if not data:
|
|
return None
|
|
return EventTenantRef(id=str(data["id"]), slug=_optional_str(data.get("slug")), label=_optional_str(data.get("label")))
|
|
|
|
|
|
def _object_ref(value: object) -> EventObjectRef | None:
|
|
data = _mapping(value)
|
|
if not data:
|
|
return None
|
|
return EventObjectRef(type=str(data["type"]), id=_optional_str(data.get("id")), label=_optional_str(data.get("label")))
|