reconciler.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. from __future__ import annotations
  2. import logging
  3. import os
  4. import signal
  5. import socket
  6. import threading
  7. from datetime import timedelta
  8. from zoneinfo import ZoneInfo
  9. from supply_infra.config import InfraSettings, get_infra_settings
  10. from supply_infra.db.repositories.pipeline_run_repo import PipelineRunRepository
  11. from supply_infra.db.repositories.pipeline_step_run_repo import PipelineStepRunRepository
  12. from supply_infra.db.session import dispose_engine, get_session
  13. from supply_infra.pipeline.dates import CHINA_TIMEZONE, china_now
  14. from supply_infra.pipeline.enums import RunStatus, StepStatus
  15. from supply_infra.pipeline.health import (
  16. run_alert_level,
  17. touch_reconciler_heartbeat,
  18. )
  19. from supply_infra.pipeline.run_service import PIPELINE_KEY, submit_pipeline_run
  20. logger = logging.getLogger(__name__)
  21. def reconcile_once() -> dict[str, int]:
  22. settings = get_infra_settings()
  23. now = china_now()
  24. stats = {
  25. "retries_promoted": 0,
  26. "expired_steps": 0,
  27. "deadline_exceeded": 0,
  28. "previous_run_unblocked": 0,
  29. "alerts_emitted": 0,
  30. "missed_runs_submitted": 0,
  31. }
  32. with get_session() as session:
  33. run_repo = PipelineRunRepository(session)
  34. step_repo = PipelineStepRunRepository(session)
  35. stats["retries_promoted"] = step_repo.promote_due_retries(now)
  36. for step in step_repo.list_expired_running(now):
  37. run = run_repo.get(step.run_id, for_update=True)
  38. if run is not None and run.status in {
  39. RunStatus.CANCELLING.value,
  40. RunStatus.CANCELLED.value,
  41. }:
  42. step.status = StepStatus.CANCELLED.value
  43. step.finished_at = now
  44. step.lease_owner = None
  45. step.lease_until = None
  46. step.error_code = "cancelled"
  47. step.error_message = "Cancellation completed after worker lease expired"
  48. if run.status == RunStatus.CANCELLING.value:
  49. run_repo.finish(
  50. run,
  51. status=RunStatus.CANCELLED.value,
  52. now=now,
  53. )
  54. stats["expired_steps"] += 1
  55. continue
  56. if run is not None and run.status == RunStatus.DEADLINE_EXCEEDED.value:
  57. step.status = StepStatus.TIMED_OUT.value
  58. step.finished_at = now
  59. step.lease_owner = None
  60. step.lease_until = None
  61. step.error_code = "deadline_exceeded"
  62. step.error_message = "Worker lease expired after pipeline deadline"
  63. stats["expired_steps"] += 1
  64. continue
  65. step.status = StepStatus.TIMED_OUT.value
  66. step.finished_at = now
  67. step.lease_owner = None
  68. step.lease_until = None
  69. step.error_code = "lease_expired"
  70. step.error_message = "Worker heartbeat lease expired"
  71. if step.retryable and step.attempt < step.max_attempts:
  72. step_repo.create_retry_attempt(step, status=StepStatus.READY.value)
  73. if run is not None:
  74. run.status = RunStatus.QUEUED.value
  75. run.current_step = step.step_key
  76. run.lease_owner = None
  77. run.lease_until = None
  78. else:
  79. step_repo.block_unfinished_downstream(
  80. run_id=step.run_id,
  81. after_order=step.step_order,
  82. reason="Worker heartbeat lease expired",
  83. )
  84. if run is not None:
  85. run_repo.finish(
  86. run,
  87. status=RunStatus.FAILED.value,
  88. now=now,
  89. error_code="lease_expired",
  90. error_message=f"Lease expired at step {step.step_key}",
  91. )
  92. stats["expired_steps"] += 1
  93. for run in run_repo.list_past_deadline(now):
  94. for step in step_repo.list_for_run(run.run_id):
  95. if step.status == StepStatus.RUNNING.value:
  96. step.error_code = "deadline_exceeded"
  97. step.error_message = (
  98. "Pipeline exceeded next scheduled start; waiting for "
  99. "the worker or lease expiry"
  100. )
  101. continue
  102. if step.status in {
  103. StepStatus.PENDING.value,
  104. StepStatus.READY.value,
  105. StepStatus.RETRY_WAIT.value,
  106. StepStatus.BLOCKED.value,
  107. }:
  108. step.status = StepStatus.BLOCKED.value
  109. step.error_code = "deadline_exceeded"
  110. step.error_message = "Pipeline exceeded next scheduled start"
  111. run_repo.finish(
  112. run,
  113. status=RunStatus.DEADLINE_EXCEEDED.value,
  114. now=now,
  115. error_code="deadline_exceeded",
  116. error_message="Pipeline exceeded next scheduled start",
  117. )
  118. stats["deadline_exceeded"] += 1
  119. for run in run_repo.list_blocked_previous():
  120. blocked_by = (run.summary_json or {}).get("blocked_by_run_id")
  121. previous = run_repo.get(str(blocked_by)) if blocked_by else None
  122. if previous is not None and previous.status in {
  123. RunStatus.QUEUED.value,
  124. RunStatus.RUNNING.value,
  125. RunStatus.CANCELLING.value,
  126. RunStatus.BLOCKED_PREVIOUS_RUN.value,
  127. }:
  128. continue
  129. if previous is not None and step_repo.has_running_for_run(previous.run_id):
  130. continue
  131. latest = step_repo.latest_attempts(run.run_id)
  132. first = min(latest.values(), key=lambda item: item.step_order, default=None)
  133. if first is None:
  134. continue
  135. first.status = StepStatus.READY.value
  136. first.error_code = None
  137. first.error_message = None
  138. for step in latest.values():
  139. if step.step_run_id != first.step_run_id:
  140. step.status = StepStatus.PENDING.value
  141. step.error_code = None
  142. step.error_message = None
  143. run.status = RunStatus.QUEUED.value
  144. run.summary_json = None
  145. stats["previous_run_unblocked"] += 1
  146. for run in run_repo.list_nonterminal():
  147. alert_level = run_alert_level(
  148. status=run.status,
  149. started_at=run.started_at,
  150. created_at=run.created_at,
  151. deadline_at=run.deadline_at,
  152. now=now,
  153. settings=settings,
  154. )
  155. if alert_level is not None:
  156. logger.warning(
  157. "pipeline_alert level=%s run_id=%s biz_dt=%s status=%s deadline=%s",
  158. alert_level,
  159. run.run_id,
  160. run.biz_dt,
  161. run.status,
  162. run.deadline_at.isoformat() if run.deadline_at else None,
  163. )
  164. stats["alerts_emitted"] += 1
  165. local_now = now.replace(tzinfo=CHINA_TIMEZONE).astimezone(
  166. ZoneInfo(settings.scheduler_timezone)
  167. )
  168. missed_cutoff = local_now.replace(
  169. hour=settings.scheduler_cron_hour,
  170. minute=settings.scheduler_cron_minute,
  171. second=0,
  172. microsecond=0,
  173. ) + timedelta(seconds=settings.pipeline_missed_run_grace_seconds)
  174. if settings.scheduler_enabled and local_now >= missed_cutoff:
  175. biz_dt = local_now.strftime("%Y%m%d")
  176. with get_session() as session:
  177. existing = PipelineRunRepository(session).get_for_business_day(
  178. pipeline_key=PIPELINE_KEY,
  179. biz_dt=biz_dt,
  180. )
  181. if existing is None:
  182. submission = submit_pipeline_run(
  183. biz_dt=biz_dt,
  184. trigger_type="reconcile",
  185. trigger_source="missed_run_reconciler",
  186. trigger_reason="Scheduled batch was absent after the grace window",
  187. settings=settings,
  188. )
  189. if submission.created:
  190. logger.error(
  191. "pipeline_alert level=critical type=missed_run_recovered "
  192. "run_id=%s biz_dt=%s",
  193. submission.run_id,
  194. biz_dt,
  195. )
  196. stats["missed_runs_submitted"] = 1
  197. return stats
  198. class PipelineReconciler:
  199. def __init__(self, settings: InfraSettings | None = None) -> None:
  200. self.settings = settings or get_infra_settings()
  201. self.owner = f"{socket.gethostname()}:{os.getpid()}"
  202. self._stop = threading.Event()
  203. def stop(self, *_args: object) -> None:
  204. self._stop.set()
  205. def run_forever(self) -> None:
  206. signal.signal(signal.SIGTERM, self.stop)
  207. signal.signal(signal.SIGINT, self.stop)
  208. logger.info("Pipeline reconciler started")
  209. try:
  210. while not self._stop.is_set():
  211. try:
  212. result = reconcile_once()
  213. touch_reconciler_heartbeat(
  214. owner=self.owner,
  215. settings=self.settings,
  216. )
  217. logger.info("Pipeline reconcile result: %s", result)
  218. except Exception:
  219. logger.exception("Pipeline reconcile failed")
  220. self._stop.wait(self.settings.pipeline_reconcile_seconds)
  221. finally:
  222. dispose_engine()
  223. logger.info("Pipeline reconciler stopped")