deerflow-code/offline-backend-20260512/backend/packages/harness/deerflow/workflows/runtime/sink.py
2026-09-07 18:24:55 +08:00

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"]