"""Persist-then-publish event sink for workflow runs.""" from __future__ import annotations import logging from collections.abc import Awaitable, Callable from datetime import UTC, datetime from typing import Any from deerflow.persistence.workflow_events.base import WorkflowEventStore from deerflow.workflows.events import WorkflowEventEnvelope, WorkflowEventType logger = logging.getLogger(__name__) PublishFn = Callable[[dict[str, Any]], Awaitable[None]] class PersistingWorkflowEventSink: """Assigns ``seq`` in the database first, then fans out to live listeners. A persistence failure never aborts the run, but the event is **dropped rather than published**: SSE clients tail by cursor and rebuild from the durable log, so a live-only frame with a made-up seq would render once and then vanish on reconnect (a "ghost event"). Callers can see the drop via ``degraded_count`` / the ``eventPersistenceDegraded`` marker on the returned envelope; the stream endpoint synthesises a closing frame from the run row when a terminal event could not be logged. """ def __init__( self, store: WorkflowEventStore, *, run_id: str, workflow_id: str, version_id: str, publish: PublishFn | None = None, ) -> None: self._store = store self._run_id = run_id self._workflow_id = workflow_id self._version_id = version_id self._publish = publish self.degraded_count = 0 async def emit( self, event_type: WorkflowEventType, *, data: dict[str, Any] | None = None, node_id: str | None = None, node_run_id: str | None = None, ) -> WorkflowEventEnvelope: payload = data or {} seq = 0 degraded = False try: row = await self._store.append( run_id=self._run_id, workflow_id=self._workflow_id, version_id=self._version_id, event_type=event_type, payload=payload, node_id=node_id, node_run_id=node_run_id, ) seq = int(row.get("seq") or 0) except Exception as exc: # noqa: BLE001 - the run must survive a log outage logger.warning( "workflow event not persisted (dropped, not published): run=%s event=%s node=%s error=%s:%s", self._run_id, event_type, node_id, type(exc).__name__, exc, ) self.degraded_count += 1 degraded = True envelope = WorkflowEventEnvelope( runId=self._run_id, workflowId=self._workflow_id, versionId=self._version_id, seq=seq, event=event_type, nodeId=node_id, nodeRunId=node_run_id, timestamp=datetime.now(UTC), data={**payload, "eventPersistenceDegraded": True} if degraded else payload, ) if degraded: return envelope if self._publish is not None: try: await self._publish(envelope.to_sse_dict()) except Exception: # noqa: BLE001 - live fan-out is best effort logger.debug("workflow live publish failed", exc_info=True) return envelope def envelope_from_row(row: dict[str, Any]) -> dict[str, Any]: """Turn a stored event row into the SSE wire object (replay path).""" return { "schemaVersion": "1.0", "runId": row.get("run_id"), "workflowId": row.get("workflow_id"), "versionId": row.get("version_id"), "seq": int(row.get("seq") or 0), "event": row.get("event_type"), "nodeId": row.get("node_id"), "nodeRunId": row.get("node_run_id"), "timestamp": row.get("created_at"), "data": row.get("payload") or {}, } __all__ = ["PersistingWorkflowEventSink", "envelope_from_row"]