dify/api/services/workflow_node_execution_trace_service.py
Blackoutta 7dc3126ff4
fix: preserve and display workflow retry details (#38854)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-15 05:58:37 +00:00

218 lines
8.2 KiB
Python

"""Expand persisted node executions into public terminal and retry trace records."""
from __future__ import annotations
import logging
from collections.abc import Mapping, Sequence
from enum import Enum
from typing import Any
from pydantic import BaseModel, ConfigDict, ValidationError
from core.app.workflow.retry_history import RETRY_HISTORY_PROCESS_DATA_KEY, WorkflowNodeRetryAttempt
from libs.helper import to_timestamp
from models.workflow import WorkflowNodeExecutionModel
from repositories.api_workflow_node_execution_repository import DifyAPIWorkflowNodeExecutionRepository
from services.variable_truncator import VariableTruncator
logger = logging.getLogger(__name__)
class WorkflowNodeExecutionTrace(BaseModel):
"""Read model preserving node-execution identity for terminal and virtual retry traces."""
id: str
tenant_id: str
app_id: str
workflow_id: str
triggered_from: str
workflow_run_id: str | None = None
index: int | None = None
predecessor_node_id: str | None = None
node_execution_id: str | None = None
node_id: str | None = None
node_type: str | None = None
title: str | None = None
inputs: Mapping[str, Any] | None = None
process_data: Mapping[str, Any] | None = None
outputs: Mapping[str, Any] | None = None
status: str | None = None
error: str | None = None
elapsed_time: float | None = None
execution_metadata: Mapping[str, Any] | None = None
extras: Any = None
created_at: int | None = None
created_by_role: str | None = None
created_by: str
created_by_account: Any = None
created_by_end_user: Any = None
finished_at: int | None = None
inputs_truncated: bool = False
outputs_truncated: bool = False
process_data_truncated: bool = False
retry_index: int | None = None
model_config = ConfigDict(arbitrary_types_allowed=True)
def assemble_workflow_node_execution_traces(
executions: Sequence[WorkflowNodeExecutionModel],
repository: DifyAPIWorkflowNodeExecutionRepository,
) -> list[WorkflowNodeExecutionTrace]:
"""Expand valid persisted retry attempts before each terminal execution."""
traces: list[WorkflowNodeExecutionTrace] = []
for execution in executions:
traces.extend(_expand_execution(execution, repository))
return traces
def _expand_execution(
execution: WorkflowNodeExecutionModel,
repository: DifyAPIWorkflowNodeExecutionRepository,
) -> list[WorkflowNodeExecutionTrace]:
full_process_data = _load_full_process_data(execution, repository)
retry_attempts = _parse_retry_attempts(execution, full_process_data)
terminal_metadata = execution.execution_metadata_dict
traces = [_retry_trace(execution, attempt, terminal_metadata) for attempt in retry_attempts]
traces.append(_terminal_trace(execution))
return traces
def _load_full_process_data(
execution: WorkflowNodeExecutionModel,
repository: DifyAPIWorkflowNodeExecutionRepository,
) -> Mapping[str, Any] | None:
try:
return repository.load_full_process_data(execution)
except Exception:
logger.warning(
"Failed to load full Process Data for workflow run %s node execution %s; using inline preview",
execution.workflow_run_id,
execution.id,
exc_info=True,
)
return execution.process_data_dict
def _parse_retry_attempts(
execution: WorkflowNodeExecutionModel,
process_data: Mapping[str, Any] | None,
) -> list[WorkflowNodeRetryAttempt]:
raw_history = (process_data or {}).get(RETRY_HISTORY_PROCESS_DATA_KEY)
if not isinstance(raw_history, list):
return []
attempts: list[WorkflowNodeRetryAttempt] = []
seen_indices: set[int] = set()
for raw_attempt in raw_history:
try:
attempt = WorkflowNodeRetryAttempt.model_validate(raw_attempt)
except ValidationError:
logger.warning(
"Skipping malformed retry history for workflow run %s node execution %s",
execution.workflow_run_id,
execution.id,
exc_info=True,
)
continue
if attempt.retry_index in seen_indices:
logger.warning(
"Skipping duplicate retry index %s for workflow run %s node execution %s",
attempt.retry_index,
execution.workflow_run_id,
execution.id,
)
continue
seen_indices.add(attempt.retry_index)
attempts.append(attempt)
return sorted(attempts, key=lambda attempt: attempt.retry_index)
def _retry_trace(
execution: WorkflowNodeExecutionModel,
attempt: WorkflowNodeRetryAttempt,
terminal_metadata: Mapping[str, Any],
) -> WorkflowNodeExecutionTrace:
truncator = VariableTruncator.default()
inputs, inputs_truncated = truncator.truncate_variable_mapping(attempt.inputs)
process_data, process_data_truncated = truncator.truncate_variable_mapping(attempt.process_data)
outputs, outputs_truncated = truncator.truncate_variable_mapping(attempt.outputs)
execution_metadata = {**terminal_metadata, **attempt.execution_metadata}
return WorkflowNodeExecutionTrace(
id=f"{execution.id}:retry:{attempt.retry_index}",
tenant_id=execution.tenant_id,
app_id=execution.app_id,
workflow_id=execution.workflow_id,
triggered_from=_enum_value(execution.triggered_from) or "",
workflow_run_id=execution.workflow_run_id,
index=execution.index,
predecessor_node_id=execution.predecessor_node_id,
node_execution_id=execution.node_execution_id,
node_id=execution.node_id,
node_type=execution.node_type,
title=execution.title,
inputs=inputs,
process_data=process_data,
outputs=outputs,
status="retry",
error=attempt.error,
elapsed_time=attempt.elapsed_time,
execution_metadata=execution_metadata,
extras=execution.extras,
created_at=attempt.created_at,
created_by_role=_enum_value(execution.created_by_role),
created_by=execution.created_by,
created_by_account=execution.created_by_account,
created_by_end_user=execution.created_by_end_user,
finished_at=attempt.finished_at,
inputs_truncated=inputs_truncated,
outputs_truncated=outputs_truncated,
process_data_truncated=process_data_truncated,
retry_index=attempt.retry_index,
)
def _terminal_trace(execution: WorkflowNodeExecutionModel) -> WorkflowNodeExecutionTrace:
process_data = execution.process_data_dict
if process_data is not None and RETRY_HISTORY_PROCESS_DATA_KEY in process_data:
process_data = dict(process_data)
process_data.pop(RETRY_HISTORY_PROCESS_DATA_KEY, None)
return WorkflowNodeExecutionTrace(
id=execution.id,
tenant_id=execution.tenant_id,
app_id=execution.app_id,
workflow_id=execution.workflow_id,
triggered_from=_enum_value(execution.triggered_from) or "",
workflow_run_id=execution.workflow_run_id,
index=execution.index,
predecessor_node_id=execution.predecessor_node_id,
node_execution_id=execution.node_execution_id,
node_id=execution.node_id,
node_type=execution.node_type,
title=execution.title,
inputs=execution.inputs_dict,
process_data=process_data,
outputs=execution.outputs_dict,
status=_enum_value(execution.status),
error=execution.error,
elapsed_time=execution.elapsed_time,
execution_metadata=execution.execution_metadata_dict,
extras=execution.extras,
created_at=to_timestamp(execution.created_at),
created_by_role=_enum_value(execution.created_by_role),
created_by=execution.created_by,
created_by_account=execution.created_by_account,
created_by_end_user=execution.created_by_end_user,
finished_at=to_timestamp(execution.finished_at),
inputs_truncated=execution.inputs_truncated,
outputs_truncated=execution.outputs_truncated,
process_data_truncated=execution.process_data_truncated,
)
def _enum_value(value: Enum | str | None) -> str | None:
if value is None or isinstance(value, str):
return value
return str(value.value)