Repository navigation
Expand file tree
/
Copy pathjob_process_runner.py
More file actions
122 lines (105 loc) · 3.92 KB
/
Copy pathjob_process_runner.py
File metadata and controls
122 lines (105 loc) · 3.92 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
"""Killable subprocess boundary for durable job handlers."""
from __future__ import annotations
import asyncio
import json
import os
import signal
import sys
from typing import Any, Callable
class JobProcessError(RuntimeError):
"""A job child exited without completing successfully."""
class JobProcessTimeout(JobProcessError):
"""A job child exceeded its wall-clock deadline and was terminated."""
async def _terminate_process_tree(
process: asyncio.subprocess.Process, grace_seconds: float = 5.0
) -> None:
"""Terminate the child's process group, escalating to SIGKILL if needed."""
if process.returncode is not None:
return
try:
os.killpg(process.pid, signal.SIGTERM)
except (ProcessLookupError, PermissionError):
try:
process.terminate()
except ProcessLookupError:
return
try:
await asyncio.wait_for(process.wait(), timeout=grace_seconds)
return
except asyncio.TimeoutError:
pass
try:
os.killpg(process.pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError):
try:
process.kill()
except ProcessLookupError:
return
await process.wait()
async def run_job_subprocess(
job_id: int,
job_type: str,
payload: dict[str, Any],
*,
timeout: float,
should_continue: Callable[[], bool] | None = None,
poll_interval: float = 1.0,
) -> None:
"""Run one registered handler in an independently killable Python process."""
if should_continue and not await asyncio.to_thread(should_continue):
raise JobProcessError(f"job {job_id} ({job_type}) claim cancelled or lost")
process = await asyncio.create_subprocess_exec(
sys.executable,
"-m",
"apps.api.job_process",
str(job_id),
job_type,
stdin=asyncio.subprocess.PIPE,
# Keep normal logs attached to the worker/container. Payloads (which may
# contain secrets) travel over stdin and never appear in process args.
stdout=None,
stderr=None,
start_new_session=True,
)
encoded_payload = json.dumps(payload, separators=(",", ":")).encode("utf-8")
communication = asyncio.create_task(process.communicate(encoded_payload))
async def monitor_claim() -> None:
while True:
if not await asyncio.to_thread(should_continue):
raise JobProcessError(f"job {job_id} ({job_type}) claim cancelled or lost")
await asyncio.sleep(poll_interval)
monitor = asyncio.create_task(monitor_claim()) if should_continue else None
try:
waiting = {communication}
if monitor:
waiting.add(monitor)
done, _ = await asyncio.wait(waiting, timeout=timeout, return_when=asyncio.FIRST_COMPLETED)
if not done:
raise asyncio.TimeoutError
if monitor and monitor in done:
await monitor
await communication
except asyncio.TimeoutError as exc:
await _terminate_process_tree(process)
await asyncio.gather(communication, return_exceptions=True)
raise JobProcessTimeout(
f"job {job_id} ({job_type}) exceeded {timeout:g}s"
) from exc
except asyncio.CancelledError:
# Worker shutdown/redeploy must not orphan a child that can keep making
# external writes after its lease is reclaimed by another replica.
await _terminate_process_tree(process)
await asyncio.gather(communication, return_exceptions=True)
raise
except Exception:
await _terminate_process_tree(process)
await asyncio.gather(communication, return_exceptions=True)
raise
finally:
if monitor:
monitor.cancel()
await asyncio.gather(monitor, return_exceptions=True)
if process.returncode != 0:
raise JobProcessError(
f"job {job_id} ({job_type}) child exited with code {process.returncode}"
)