orchestrator.py 5.4 KB

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