step_runner.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. from __future__ import annotations
  2. import json
  3. import logging
  4. import os
  5. import signal
  6. import subprocess
  7. import sys
  8. from dataclasses import dataclass
  9. from pathlib import Path
  10. from typing import Any
  11. from supply_infra.config import InfraSettings, get_infra_settings
  12. from supply_infra.pipeline.contracts import StepResult
  13. logger = logging.getLogger(__name__)
  14. _REPO_ROOT = Path(__file__).resolve().parents[2]
  15. _MAX_RESULT_BYTES = 100_000
  16. @dataclass(frozen=True)
  17. class StepExecutionRequest:
  18. run_id: str
  19. step_run_id: str
  20. step_key: str
  21. timeout_seconds: int
  22. def _log_directory(settings: InfraSettings, run_id: str) -> Path:
  23. base = Path(settings.pipeline_log_dir)
  24. if not base.is_absolute():
  25. base = _REPO_ROOT / base
  26. path = base / run_id
  27. path.mkdir(parents=True, exist_ok=True)
  28. return path
  29. def _read_result(path: Path) -> dict[str, Any]:
  30. if not path.is_file():
  31. return {"success": False, "error_code": "missing_result", "error": "Result file missing"}
  32. raw = path.read_bytes()
  33. if len(raw) > _MAX_RESULT_BYTES:
  34. return {
  35. "success": False,
  36. "error_code": "result_too_large",
  37. "error": f"Step result exceeds {_MAX_RESULT_BYTES} bytes",
  38. "result_bytes": len(raw),
  39. }
  40. try:
  41. payload = json.loads(raw.decode("utf-8"))
  42. except (UnicodeDecodeError, json.JSONDecodeError) as exc:
  43. return {
  44. "success": False,
  45. "error_code": "invalid_result_json",
  46. "error": str(exc),
  47. }
  48. if not isinstance(payload, dict):
  49. return {
  50. "success": False,
  51. "error_code": "invalid_result_type",
  52. "error": "Step result must be a JSON object",
  53. }
  54. return payload
  55. def run_step_subprocess(
  56. request: StepExecutionRequest,
  57. *,
  58. settings: InfraSettings | None = None,
  59. ) -> StepResult:
  60. active = settings or get_infra_settings()
  61. directory = _log_directory(active, request.run_id)
  62. stem = f"{request.step_key}-attempt-{request.step_run_id}"
  63. log_path = directory / f"{stem}.log"
  64. result_path = directory / f"{stem}.result.json"
  65. result_path.unlink(missing_ok=True)
  66. command = [
  67. sys.executable,
  68. "-m",
  69. "supply_infra.pipeline.step_cli",
  70. request.step_run_id,
  71. "--result-file",
  72. str(result_path),
  73. ]
  74. env = os.environ.copy()
  75. env["PROCESS_ROLE"] = "step"
  76. logger.info("Starting pipeline step: step_run_id=%s", request.step_run_id)
  77. timed_out = False
  78. exit_code: int | None = None
  79. with log_path.open("a", encoding="utf-8") as log_file:
  80. try:
  81. process = subprocess.Popen(
  82. command,
  83. cwd=str(_REPO_ROOT),
  84. env=env,
  85. stdout=log_file,
  86. stderr=subprocess.STDOUT,
  87. text=True,
  88. start_new_session=True,
  89. )
  90. except Exception as exc:
  91. logger.exception("Failed to launch pipeline step: %s", request.step_run_id)
  92. return StepResult(
  93. success=False,
  94. payload={},
  95. error_code="step_launch_failed",
  96. error_message=str(exc),
  97. log_uri=str(log_path),
  98. )
  99. try:
  100. exit_code = process.wait(timeout=request.timeout_seconds)
  101. except subprocess.TimeoutExpired:
  102. timed_out = True
  103. os.killpg(process.pid, signal.SIGTERM)
  104. try:
  105. exit_code = process.wait(timeout=min(active.pipeline_shutdown_grace_seconds, 30))
  106. except subprocess.TimeoutExpired:
  107. os.killpg(process.pid, signal.SIGKILL)
  108. exit_code = process.wait()
  109. if timed_out:
  110. return StepResult(
  111. success=False,
  112. payload={},
  113. error_code="step_timed_out",
  114. error_message=f"Step timed out after {request.timeout_seconds}s",
  115. exit_code=exit_code,
  116. log_uri=str(log_path),
  117. timed_out=True,
  118. )
  119. payload = _read_result(result_path)
  120. result_path.unlink(missing_ok=True)
  121. success = exit_code == 0 and payload.get("success") is not False
  122. return StepResult(
  123. success=success,
  124. payload=payload,
  125. error_code=(
  126. None
  127. if success
  128. else str(payload.get("error_code") or "step_failed")
  129. ),
  130. error_message=(
  131. None
  132. if success
  133. else str(payload.get("error") or f"Step exited with code {exit_code}")
  134. ),
  135. exit_code=exit_code,
  136. log_uri=str(log_path),
  137. )