workflow_engine.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. import logging
  2. import os
  3. import time
  4. from typing import Any
  5. from app.config import settings
  6. from app.service.engine.execution_context import ExecutionContext
  7. from app.service.engine.parameter_resolver import ParameterResolver
  8. from app.service.engine.workflow_result import WorkflowResult
  9. from app.service.spec.step import Step
  10. from app.service.spec.workflow import Workflow
  11. from app.service.task.task_executor import TaskExecutor
  12. from app.service.task.task_param import TaskParam
  13. from app.service.task.task_result import TaskResult
  14. from app.util.task_util import create_task_workspace, cleanup_workspace
  15. logger = logging.getLogger(__name__)
  16. class WorkflowEngine:
  17. _BASE64_DECODE_TYPE = "base64_decode"
  18. _BASE64_MISSING_FILES_ERROR = "No files provided for base64 decoding"
  19. _MAX_PREVIOUS_STEP_RERUNS = 3
  20. def __init__(self, task_executor: TaskExecutor):
  21. self._task_executor = task_executor
  22. self._resolver = ParameterResolver()
  23. def execute(
  24. self, task_id: str, workflow: Workflow, inputs: dict[str, Any]
  25. ) -> WorkflowResult:
  26. total_steps = len(workflow.order)
  27. logger.info("========== Workflow start ========== version=%s, total_steps=%d",
  28. workflow.version, total_steps)
  29. wf_t0 = time.time()
  30. workspace: str | None = None
  31. ctx = ExecutionContext(inputs)
  32. try:
  33. workspace = create_task_workspace(task_id, workflow.template_path)
  34. logger.info("Workspace created: %s", workspace)
  35. except Exception as e:
  36. logger.error("Failed to create workspace: %s", e)
  37. return WorkflowResult.failure(f"failed: {e}")
  38. try:
  39. for idx, step_id in enumerate(workflow.order):
  40. step = workflow.steps.get(step_id)
  41. if step is None:
  42. logger.warning("Step '%s' not found in steps definition, skipping", step_id)
  43. continue
  44. step_no = idx + 1
  45. logger.info("------ Step %d/%d [%s] type=%s start ------",
  46. step_no, total_steps, step.id, step.type)
  47. logger.info("[%s] raw_inputs=%s", step.id, step.inputs)
  48. step_t0 = time.time()
  49. try:
  50. step_dir = os.path.join(workspace, step.id)
  51. os.makedirs(step_dir, exist_ok=True)
  52. result = self._execute_step_with_retry(task_id, workspace, step, ctx)
  53. step_elapsed = time.time() - step_t0
  54. if result.is_failure():
  55. recovered_result = self._recover_base64_by_rerunning_previous_step(
  56. task_id=task_id,
  57. workspace=workspace,
  58. workflow=workflow,
  59. current_step_index=idx,
  60. current_step=step,
  61. ctx=ctx,
  62. failed_result=result,
  63. )
  64. if recovered_result is not None:
  65. result = recovered_result
  66. step_elapsed = time.time() - step_t0
  67. if result.is_failure():
  68. logger.error("[%s] FAILED in %.2fs after retries. Error: %s",
  69. step_id, step_elapsed, result.error_message)
  70. return WorkflowResult.failure(
  71. f"Step {step_id} failed: {result.error_message}"
  72. )
  73. except Exception as e:
  74. step_elapsed = time.time() - step_t0
  75. logger.error("[%s] EXCEPTION in %.2fs: %s", step_id, step_elapsed, e, exc_info=True)
  76. return WorkflowResult.failure(f"Step {step_id} failed: {e}")
  77. logger.info("[%s] SUCCESS in %.2fs outputs=%s",
  78. step.id, step_elapsed, _shorten(result.outputs))
  79. ctx.put_step_result(step_id, result.outputs)
  80. logger.info("All steps completed, resolving final outputs ...")
  81. outputs: dict[str, Any] = {}
  82. if workflow.outputs:
  83. for key, value in workflow.outputs.items():
  84. outputs[key] = self._resolver.resolve(value, ctx)
  85. logger.info("Final output_keys=%s", list(outputs.keys()))
  86. total_elapsed = time.time() - wf_t0
  87. logger.info("========== Workflow end ========== total_elapsed=%.2fs, success=True",
  88. total_elapsed)
  89. return WorkflowResult.ok(outputs)
  90. finally:
  91. if workspace:
  92. cleanup_workspace(workspace, task_id)
  93. def _execute_step_with_retry(
  94. self, task_id: str, workspace: str, step: Step, ctx: ExecutionContext
  95. ) -> TaskResult:
  96. max_retries = (
  97. settings.step_retry_count if step.retry_count is None else step.retry_count
  98. )
  99. last_result: TaskResult | None = None
  100. for attempt in range(max_retries + 1):
  101. step_inputs = self._resolve_inputs(step.inputs, ctx)
  102. task = self._task_executor.resolve(step.type)
  103. param = TaskParam(
  104. workspace=workspace,
  105. step_id=step.id,
  106. type=step.type,
  107. resources=step.resources,
  108. inputs=step_inputs,
  109. )
  110. if attempt > 0:
  111. logger.info("[%s] Retry attempt %d/%d",
  112. step.id, attempt, max_retries)
  113. logger.info("[%s] Executing task type=%s, resolved_input_keys=%s",
  114. step.id, step.type, list(step_inputs.keys()))
  115. attempt_t0 = time.time()
  116. last_result = task.execute(param)
  117. attempt_elapsed = time.time() - attempt_t0
  118. if last_result.is_success():
  119. logger.info("[%s] Task returned SUCCESS in %.2fs",
  120. step.id, attempt_elapsed)
  121. return last_result
  122. if attempt < max_retries:
  123. logger.warning(
  124. "[%s] Task returned FAILURE in %.2fs (attempt %d/%d), will retry. Error: %s",
  125. step.id, attempt_elapsed,
  126. attempt + 1, max_retries + 1, last_result.error_message,
  127. )
  128. time.sleep(1)
  129. else:
  130. logger.error(
  131. "[%s] Task returned FAILURE in %.2fs (final attempt %d/%d). Error: %s",
  132. step.id, attempt_elapsed,
  133. attempt + 1, max_retries + 1, last_result.error_message,
  134. )
  135. return last_result # type: ignore[return-value]
  136. def _resolve_inputs(
  137. self, inputs: dict[str, Any], ctx: ExecutionContext
  138. ) -> dict[str, Any]:
  139. return {k: self._resolver.resolve(v, ctx) for k, v in inputs.items()} if inputs else {}
  140. def _recover_base64_by_rerunning_previous_step(
  141. self,
  142. task_id: str,
  143. workspace: str,
  144. workflow: Workflow,
  145. current_step_index: int,
  146. current_step: Step,
  147. ctx: ExecutionContext,
  148. failed_result: TaskResult,
  149. ) -> TaskResult | None:
  150. if current_step.type != self._BASE64_DECODE_TYPE:
  151. return None
  152. if self._BASE64_MISSING_FILES_ERROR not in failed_result.error_message:
  153. return None
  154. if current_step_index <= 0:
  155. logger.error(
  156. "[%s] Missing files in base64_decode but no previous step exists",
  157. current_step.id,
  158. )
  159. return None
  160. previous_step_id = workflow.order[current_step_index - 1]
  161. previous_step = workflow.steps.get(previous_step_id)
  162. if previous_step is None:
  163. logger.error(
  164. "[%s] Missing files in base64_decode but previous step '%s' not found",
  165. current_step.id,
  166. previous_step_id,
  167. )
  168. return None
  169. current_result = failed_result
  170. for recover_attempt in range(1, self._MAX_PREVIOUS_STEP_RERUNS + 1):
  171. logger.warning(
  172. "[%s] Trigger fallback: rerun previous step [%s] (attempt %d/%d) "
  173. "because base64_decode has no files",
  174. current_step.id,
  175. previous_step.id,
  176. recover_attempt,
  177. self._MAX_PREVIOUS_STEP_RERUNS,
  178. )
  179. previous_result = self._execute_step_with_retry(task_id, workspace, previous_step, ctx)
  180. if previous_result.is_failure():
  181. logger.warning(
  182. "[%s] Previous step rerun failed on attempt %d/%d: %s",
  183. previous_step.id,
  184. recover_attempt,
  185. self._MAX_PREVIOUS_STEP_RERUNS,
  186. previous_result.error_message,
  187. )
  188. continue
  189. ctx.put_step_result(previous_step.id, previous_result.outputs)
  190. logger.info(
  191. "[%s] Previous step rerun succeeded on attempt %d/%d, rerunning current step",
  192. previous_step.id,
  193. recover_attempt,
  194. self._MAX_PREVIOUS_STEP_RERUNS,
  195. )
  196. current_result = self._execute_step_with_retry(task_id, workspace, current_step, ctx)
  197. if current_result.is_success():
  198. logger.info(
  199. "[%s] Recovery succeeded after rerunning previous step [%s]",
  200. current_step.id,
  201. previous_step.id,
  202. )
  203. return current_result
  204. if self._BASE64_MISSING_FILES_ERROR not in current_result.error_message:
  205. logger.error(
  206. "[%s] Current step rerun failed with non-recoverable error: %s",
  207. current_step.id,
  208. current_result.error_message,
  209. )
  210. return current_result
  211. logger.error(
  212. "[%s] Recovery failed after %d attempts of rerunning previous step [%s]",
  213. current_step.id,
  214. self._MAX_PREVIOUS_STEP_RERUNS,
  215. previous_step.id,
  216. )
  217. return current_result
  218. def _shorten(d: dict | None, max_len: int = 120) -> str:
  219. if d is None:
  220. return "null"
  221. s = str(d)
  222. return s if len(s) <= max_len else s[:max_len] + "..."