118 lines
3.9 KiB
Python
118 lines
3.9 KiB
Python
"""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"]
|