health.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. from __future__ import annotations
  2. from datetime import datetime, timedelta
  3. from typing import Any
  4. from supply_infra.config import InfraSettings, get_infra_settings
  5. from supply_infra.db.repositories.pipeline_lock_repo import PipelineLockRepository
  6. from supply_infra.db.repositories.pipeline_run_repo import PipelineRunRepository
  7. from supply_infra.db.session import get_session
  8. from supply_infra.pipeline.dates import china_now
  9. SCHEDULER_HEARTBEAT_KEY = "pipeline:scheduler"
  10. RECONCILER_HEARTBEAT_KEY = "pipeline:reconciler"
  11. WORKER_HEARTBEAT_PREFIX = "pipeline:worker:"
  12. _ACTIVE_STATUSES = {
  13. "queued",
  14. "running",
  15. "cancelling",
  16. "blocked_previous_run",
  17. }
  18. def touch_scheduler_heartbeat(
  19. *,
  20. owner: str,
  21. settings: InfraSettings | None = None,
  22. ) -> None:
  23. active = settings or get_infra_settings()
  24. with get_session() as session:
  25. PipelineLockRepository(session).touch(
  26. lock_key=SCHEDULER_HEARTBEAT_KEY,
  27. owner=owner,
  28. lease_seconds=max(
  29. active.pipeline_lease_seconds,
  30. active.pipeline_heartbeat_seconds * 3,
  31. ),
  32. now=china_now(),
  33. )
  34. def touch_worker_heartbeat(
  35. *,
  36. owner: str,
  37. settings: InfraSettings | None = None,
  38. ) -> None:
  39. active = settings or get_infra_settings()
  40. with get_session() as session:
  41. PipelineLockRepository(session).touch(
  42. lock_key=f"{WORKER_HEARTBEAT_PREFIX}{owner}",
  43. owner=owner,
  44. lease_seconds=max(
  45. active.pipeline_lease_seconds,
  46. active.pipeline_heartbeat_seconds * 3,
  47. ),
  48. now=china_now(),
  49. )
  50. def touch_reconciler_heartbeat(
  51. *,
  52. owner: str,
  53. settings: InfraSettings | None = None,
  54. ) -> None:
  55. active = settings or get_infra_settings()
  56. with get_session() as session:
  57. PipelineLockRepository(session).touch(
  58. lock_key=RECONCILER_HEARTBEAT_KEY,
  59. owner=owner,
  60. lease_seconds=max(
  61. active.pipeline_lease_seconds,
  62. active.pipeline_reconcile_seconds * 3,
  63. ),
  64. now=china_now(),
  65. )
  66. def run_alert_level(
  67. *,
  68. status: str,
  69. started_at: datetime | None,
  70. created_at: datetime,
  71. deadline_at: datetime | None,
  72. now: datetime,
  73. settings: InfraSettings,
  74. ) -> str | None:
  75. if status not in _ACTIVE_STATUSES:
  76. return None
  77. if deadline_at is not None:
  78. remaining = deadline_at - now
  79. if remaining <= timedelta(minutes=settings.pipeline_final_warn_before_next_minutes):
  80. return "final_warning"
  81. if remaining <= timedelta(minutes=settings.pipeline_critical_before_next_minutes):
  82. return "critical"
  83. baseline = started_at or created_at
  84. if now - baseline >= timedelta(hours=settings.pipeline_warn_after_hours):
  85. return "warning"
  86. return None
  87. def runtime_components_ready(
  88. *,
  89. scheduler_enabled: bool,
  90. scheduler_running: bool,
  91. active_worker_count: int,
  92. expected_worker_count: int,
  93. reconciler_running: bool,
  94. ) -> bool:
  95. return bool(
  96. (not scheduler_enabled or scheduler_running)
  97. and active_worker_count >= expected_worker_count
  98. and reconciler_running
  99. )
  100. def pipeline_health_snapshot(
  101. settings: InfraSettings | None = None,
  102. ) -> dict[str, Any]:
  103. active = settings or get_infra_settings()
  104. now = china_now()
  105. with get_session() as session:
  106. lock_repo = PipelineLockRepository(session)
  107. scheduler_lock = lock_repo.get(SCHEDULER_HEARTBEAT_KEY)
  108. reconciler_lock = lock_repo.get(RECONCILER_HEARTBEAT_KEY)
  109. active_worker_count = lock_repo.count_active_with_prefix(
  110. WORKER_HEARTBEAT_PREFIX,
  111. now=now,
  112. )
  113. latest = PipelineRunRepository(session).list_recent(limit=1)
  114. latest_run = latest[0] if latest else None
  115. scheduler_heartbeat_at = (
  116. scheduler_lock.heartbeat_at if scheduler_lock is not None else None
  117. )
  118. scheduler_lease_until = (
  119. scheduler_lock.lease_until if scheduler_lock is not None else None
  120. )
  121. reconciler_lease_until = (
  122. reconciler_lock.lease_until if reconciler_lock is not None else None
  123. )
  124. latest_snapshot = (
  125. {
  126. "run_id": latest_run.run_id,
  127. "status": latest_run.status,
  128. "started_at": latest_run.started_at,
  129. "created_at": latest_run.created_at,
  130. "deadline_at": latest_run.deadline_at,
  131. }
  132. if latest_run is not None
  133. else None
  134. )
  135. scheduler_heartbeat = (
  136. scheduler_heartbeat_at.isoformat()
  137. if scheduler_heartbeat_at is not None
  138. else None
  139. )
  140. scheduler_running = bool(
  141. active.scheduler_enabled
  142. and scheduler_lease_until is not None
  143. and scheduler_lease_until > now
  144. )
  145. reconciler_running = bool(
  146. reconciler_lease_until is not None and reconciler_lease_until > now
  147. )
  148. workers_running = active_worker_count >= active.pipeline_worker_processes
  149. alert_level = None
  150. if latest_snapshot is not None:
  151. alert_level = run_alert_level(
  152. status=latest_snapshot["status"],
  153. started_at=latest_snapshot["started_at"],
  154. created_at=latest_snapshot["created_at"],
  155. deadline_at=latest_snapshot["deadline_at"],
  156. now=now,
  157. settings=active,
  158. )
  159. return {
  160. "scheduler_enabled": active.scheduler_enabled,
  161. "scheduler_running": scheduler_running,
  162. "scheduler_heartbeat_at": scheduler_heartbeat,
  163. "worker_expected_count": active.pipeline_worker_processes,
  164. "worker_active_count": active_worker_count,
  165. "workers_running": workers_running,
  166. "reconciler_running": reconciler_running,
  167. "runtime_components_ready": runtime_components_ready(
  168. scheduler_enabled=active.scheduler_enabled,
  169. scheduler_running=scheduler_running,
  170. active_worker_count=active_worker_count,
  171. expected_worker_count=active.pipeline_worker_processes,
  172. reconciler_running=reconciler_running,
  173. ),
  174. "latest_run_id": latest_snapshot["run_id"] if latest_snapshot else None,
  175. "latest_run_status": latest_snapshot["status"] if latest_snapshot else None,
  176. "latest_run_alert_level": alert_level,
  177. "checked_at": now.isoformat(),
  178. }