orchestrator.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. from __future__ import annotations
  2. from datetime import timedelta
  3. from sqlalchemy.orm import Session
  4. from supply_infra.db.repositories.pipeline_run_repo import PipelineRunRepository
  5. from supply_infra.db.repositories.pipeline_step_run_repo import PipelineStepRunRepository
  6. from supply_infra.pipeline.contracts import StepResult
  7. from supply_infra.pipeline.dates import china_now
  8. from supply_infra.pipeline.enums import RunStatus, StepStatus
  9. from supply_infra.pipeline.gates import evaluate_step_gate
  10. _TRANSIENT_ERRORS = {
  11. "category_match_failed_items",
  12. "step_timed_out",
  13. "step_launch_failed",
  14. "db_pool_exhausted",
  15. }
  16. def complete_step(
  17. session: Session,
  18. *,
  19. step_run_id: str,
  20. owner: str,
  21. result: StepResult,
  22. ) -> str:
  23. now = china_now()
  24. step_repo = PipelineStepRunRepository(session)
  25. run_repo = PipelineRunRepository(session)
  26. step = step_repo.get(step_run_id, for_update=True)
  27. if step is None:
  28. raise ValueError(f"step_run_id not found: {step_run_id}")
  29. if step.status != StepStatus.RUNNING.value or step.lease_owner != owner:
  30. raise RuntimeError(f"Step lease lost: {step_run_id}")
  31. run = run_repo.get(step.run_id, for_update=True)
  32. if run is None:
  33. raise ValueError(f"pipeline run not found: {step.run_id}")
  34. if run.status in {
  35. RunStatus.CANCELLING.value,
  36. RunStatus.CANCELLED.value,
  37. }:
  38. step_repo.finish(
  39. step,
  40. status=StepStatus.CANCELLED.value,
  41. now=now,
  42. exit_code=result.exit_code,
  43. result=result.payload,
  44. error_code="cancelled",
  45. error_message="Step result discarded after pipeline cancellation",
  46. log_uri=result.log_uri,
  47. )
  48. if run.status == RunStatus.CANCELLING.value:
  49. run_repo.finish(run, status=RunStatus.CANCELLED.value, now=now)
  50. return RunStatus.CANCELLED.value
  51. if run.status == RunStatus.DEADLINE_EXCEEDED.value:
  52. step_repo.finish(
  53. step,
  54. status=StepStatus.TIMED_OUT.value,
  55. now=now,
  56. exit_code=result.exit_code,
  57. result=result.payload,
  58. error_code="deadline_exceeded",
  59. error_message="Step result discarded after pipeline deadline",
  60. log_uri=result.log_uri,
  61. )
  62. return RunStatus.DEADLINE_EXCEEDED.value
  63. gate = evaluate_step_gate(step.step_key, result.payload) if result.success else None
  64. passed = bool(result.success and gate and gate.passed)
  65. error_code = result.error_code
  66. error_message = result.error_message
  67. if result.success and gate is not None and not gate.passed:
  68. error_code = gate.error_code
  69. error_message = gate.error_message
  70. if passed:
  71. step_repo.finish(
  72. step,
  73. status=StepStatus.SUCCEEDED.value,
  74. now=now,
  75. exit_code=result.exit_code,
  76. result=result.payload,
  77. error_code=None,
  78. error_message=None,
  79. log_uri=result.log_uri,
  80. )
  81. latest = step_repo.latest_attempts(run.run_id)
  82. incomplete = [
  83. item
  84. for item in sorted(latest.values(), key=lambda value: value.step_order)
  85. if item.status != StepStatus.SUCCEEDED.value
  86. ]
  87. if not incomplete:
  88. run_repo.finish(
  89. run,
  90. status=RunStatus.SUCCEEDED.value,
  91. now=now,
  92. summary={"steps": len(latest), "dry_run": run.dry_run},
  93. )
  94. return RunStatus.SUCCEEDED.value
  95. next_step = incomplete[0]
  96. if next_step.status == StepStatus.PENDING.value:
  97. next_step.status = StepStatus.READY.value
  98. next_step.error_code = None
  99. next_step.error_message = None
  100. run.status = RunStatus.QUEUED.value
  101. run.current_step = next_step.step_key
  102. run.heartbeat_at = now
  103. run.lease_owner = None
  104. run.lease_until = None
  105. session.flush()
  106. return RunStatus.QUEUED.value
  107. failed_status = (
  108. StepStatus.TIMED_OUT.value if result.timed_out else StepStatus.FAILED.value
  109. )
  110. step_repo.finish(
  111. step,
  112. status=failed_status,
  113. now=now,
  114. exit_code=result.exit_code,
  115. result=result.payload,
  116. error_code=error_code or "step_failed",
  117. error_message=error_message,
  118. log_uri=result.log_uri,
  119. )
  120. should_retry = (
  121. step.retryable
  122. and step.attempt < step.max_attempts
  123. and (error_code or "") in _TRANSIENT_ERRORS
  124. )
  125. if should_retry:
  126. retry_at = now + timedelta(seconds=min(300, 30 * (2 ** (step.attempt - 1))))
  127. step_repo.create_retry_attempt(
  128. step,
  129. status=StepStatus.RETRY_WAIT.value,
  130. next_retry_at=retry_at,
  131. )
  132. run.status = RunStatus.QUEUED.value
  133. run.current_step = step.step_key
  134. run.heartbeat_at = now
  135. run.lease_owner = None
  136. run.lease_until = None
  137. session.flush()
  138. return StepStatus.RETRY_WAIT.value
  139. reason = error_message or error_code or "critical step failed"
  140. step_repo.block_unfinished_downstream(
  141. run_id=run.run_id,
  142. after_order=step.step_order,
  143. reason=reason,
  144. )
  145. run_repo.finish(
  146. run,
  147. status=RunStatus.FAILED.value,
  148. now=now,
  149. error_code=error_code or "critical_step_failed",
  150. error_message=reason,
  151. summary={"failed_step": step.step_key, "attempt": step.attempt},
  152. )
  153. return RunStatus.FAILED.value