ringo-task-queue 0.1.0.dev6__py3-none-win_amd64.whl → 0.1.0.dev8__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ringo_task_queue/__init__.py +75 -71
- ringo_task_queue/_pb.py +154 -154
- ringo_task_queue/_proto/ringo/v1/queue_pb2.py +97 -85
- ringo_task_queue/_proto/ringo/v1/queue_pb2_grpc.py +138 -5
- ringo_task_queue/_supervisor_child.py +91 -0
- ringo_task_queue/_supervisor_ipc.py +81 -0
- ringo_task_queue/_supervisor_policy.py +182 -0
- ringo_task_queue/_supervisor_process.py +562 -0
- ringo_task_queue/_supervisor_runtime.py +420 -0
- ringo_task_queue/_supervisor_watchdog.py +429 -0
- ringo_task_queue/_version.py +17 -17
- ringo_task_queue/bin/manifest.json +7 -7
- ringo_task_queue/bin/ringo-task-queue-windows-amd64.exe +0 -0
- ringo_task_queue/binary.py +253 -253
- ringo_task_queue/client.py +1261 -1071
- ringo_task_queue/concurrency.py +480 -480
- ringo_task_queue/daemon.py +507 -507
- ringo_task_queue/errors.py +159 -159
- ringo_task_queue/models.py +449 -364
- ringo_task_queue/shared.py +359 -305
- ringo_task_queue/supervisor.py +575 -0
- ringo_task_queue/worker.py +1868 -1297
- {ringo_task_queue-0.1.0.dev6.dist-info → ringo_task_queue-0.1.0.dev8.dist-info}/METADATA +120 -13
- ringo_task_queue-0.1.0.dev8.dist-info/RECORD +27 -0
- {ringo_task_queue-0.1.0.dev6.dist-info → ringo_task_queue-0.1.0.dev8.dist-info}/licenses/LICENSE +21 -21
- ringo_task_queue-0.1.0.dev6.dist-info/RECORD +0 -20
- {ringo_task_queue-0.1.0.dev6.dist-info → ringo_task_queue-0.1.0.dev8.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
"""Opt-in, lightweight supervision of an importable Python application.
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from ringo_task_queue.supervisor import supervise, SupervisorPolicy, TaskTimeouts
|
|
5
|
+
|
|
6
|
+
if __name__ == "__main__":
|
|
7
|
+
raise SystemExit(supervise(
|
|
8
|
+
"myapp:main",
|
|
9
|
+
policy=SupervisorPolicy(recycle_after=timedelta(hours=12)),
|
|
10
|
+
task_timeouts=TaskTimeouts(default=timedelta(minutes=45)),
|
|
11
|
+
))
|
|
12
|
+
|
|
13
|
+
The entrypoint is a zero-argument sync or async callable. This blocking launcher
|
|
14
|
+
belongs outside the application's event loop. Normal completion is not a crash.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import logging
|
|
21
|
+
import math
|
|
22
|
+
import os
|
|
23
|
+
import random
|
|
24
|
+
import re
|
|
25
|
+
import secrets
|
|
26
|
+
import signal
|
|
27
|
+
import socket
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
32
|
+
from collections import deque
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from ._supervisor_ipc import HEALTH_INTERVAL, Channel
|
|
37
|
+
from ._supervisor_policy import SupervisorPolicy, TaskTimeouts
|
|
38
|
+
|
|
39
|
+
__all__ = ["SupervisorPolicy", "TaskTimeouts", "supervise"]
|
|
40
|
+
_LOG = logging.getLogger(__name__)
|
|
41
|
+
_ENTRYPOINT = re.compile(
|
|
42
|
+
r"^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*:[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*$"
|
|
43
|
+
)
|
|
44
|
+
_MONITOR_INTERVAL = 0.1
|
|
45
|
+
_MEMORY_INTERVAL = 1.0
|
|
46
|
+
_CLEANUP_TIMEOUT = 5.0
|
|
47
|
+
_WINDOWS_CREATE_SUSPENDED = 0x00000004
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _UserStop(BaseException):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _CleanupFailed(RuntimeError):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _Reader:
|
|
59
|
+
def __init__(self, channel: Channel) -> None:
|
|
60
|
+
self.channel = channel
|
|
61
|
+
self.ready = False
|
|
62
|
+
self.ready_at = time.monotonic()
|
|
63
|
+
self.worker_budgets: dict[str, float | None] = {}
|
|
64
|
+
self.worker_loop_ids: dict[str, str] = {}
|
|
65
|
+
self.budget_changed_at = time.monotonic()
|
|
66
|
+
self.health_serial = 0
|
|
67
|
+
self.entrypoint_done = False
|
|
68
|
+
self.entrypoint_failed = False
|
|
69
|
+
self.entrypoint_exit = 0
|
|
70
|
+
self.restart: dict[str, Any] | None = None
|
|
71
|
+
self.fatal = False
|
|
72
|
+
self.closed = False
|
|
73
|
+
self.last_health = time.monotonic()
|
|
74
|
+
self.health: dict[str, Any] = {}
|
|
75
|
+
self.thread = threading.Thread(
|
|
76
|
+
target=self._run, name="ringo-supervisor-reader", daemon=True
|
|
77
|
+
)
|
|
78
|
+
self.thread.start()
|
|
79
|
+
|
|
80
|
+
def _run(self) -> None:
|
|
81
|
+
try:
|
|
82
|
+
while True:
|
|
83
|
+
message = self.channel.receive()
|
|
84
|
+
if message is None:
|
|
85
|
+
return
|
|
86
|
+
kind = message.get("kind")
|
|
87
|
+
if kind == "ready":
|
|
88
|
+
self.ready_at = time.monotonic()
|
|
89
|
+
self.ready = True
|
|
90
|
+
elif kind == "health":
|
|
91
|
+
self.health_serial += 1
|
|
92
|
+
message["_serial"] = self.health_serial
|
|
93
|
+
self.last_health = time.monotonic()
|
|
94
|
+
self.health = message
|
|
95
|
+
elif kind == "worker_budget":
|
|
96
|
+
worker_id = str(message["worker_id"])
|
|
97
|
+
self.worker_budgets[worker_id] = message.get("fallback_timeout")
|
|
98
|
+
self.worker_loop_ids[worker_id] = str(message.get("loop_id", ""))
|
|
99
|
+
self.budget_changed_at = time.monotonic()
|
|
100
|
+
elif kind == "worker_stopped":
|
|
101
|
+
worker_id = str(message["worker_id"])
|
|
102
|
+
self.worker_budgets.pop(worker_id, None)
|
|
103
|
+
self.worker_loop_ids.pop(worker_id, None)
|
|
104
|
+
self.budget_changed_at = time.monotonic()
|
|
105
|
+
elif kind == "restart" and self.restart is None:
|
|
106
|
+
self.restart = message
|
|
107
|
+
elif kind == "entrypoint_done":
|
|
108
|
+
self.entrypoint_exit = message.get("exit_code", 0)
|
|
109
|
+
self.entrypoint_done = True
|
|
110
|
+
elif kind == "entrypoint_failed":
|
|
111
|
+
self.entrypoint_failed = True
|
|
112
|
+
elif kind == "fatal":
|
|
113
|
+
self.fatal = True
|
|
114
|
+
except (OSError, ValueError):
|
|
115
|
+
pass
|
|
116
|
+
finally:
|
|
117
|
+
self.closed = True
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class _Generation:
|
|
122
|
+
process: subprocess.Popen
|
|
123
|
+
tree: Any
|
|
124
|
+
channel: Channel
|
|
125
|
+
reader: _Reader
|
|
126
|
+
started: float
|
|
127
|
+
max_rss_bytes: int | None = None
|
|
128
|
+
cleanup_fault: str | None = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _spawn(
|
|
132
|
+
entrypoint: str,
|
|
133
|
+
policy: SupervisorPolicy,
|
|
134
|
+
task_timeouts: TaskTimeouts,
|
|
135
|
+
stop: threading.Event | None = None,
|
|
136
|
+
) -> _Generation:
|
|
137
|
+
from ._supervisor_process import ProcessTree
|
|
138
|
+
|
|
139
|
+
token = secrets.token_hex(32)
|
|
140
|
+
generation = secrets.token_hex(16)
|
|
141
|
+
environment = dict(os.environ)
|
|
142
|
+
# Nested/ancestor markers must not establish this generation's ownership.
|
|
143
|
+
environment.pop("RINGO_SUPERVISOR_JOB", None)
|
|
144
|
+
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
145
|
+
listener.bind(("127.0.0.1", 0))
|
|
146
|
+
listener.listen(4)
|
|
147
|
+
listener.settimeout(0.1)
|
|
148
|
+
environment.update(
|
|
149
|
+
{
|
|
150
|
+
"RINGO_SUPERVISOR_PORT": str(listener.getsockname()[1]),
|
|
151
|
+
"RINGO_SUPERVISOR_TOKEN": token,
|
|
152
|
+
"RINGO_SUPERVISOR_GENERATION": generation,
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
options: dict[str, Any] = {
|
|
156
|
+
"env": environment,
|
|
157
|
+
"stdin": subprocess.DEVNULL,
|
|
158
|
+
"close_fds": True,
|
|
159
|
+
}
|
|
160
|
+
if os.name == "nt":
|
|
161
|
+
options["creationflags"] = (
|
|
162
|
+
subprocess.CREATE_NEW_PROCESS_GROUP | _WINDOWS_CREATE_SUSPENDED
|
|
163
|
+
)
|
|
164
|
+
else:
|
|
165
|
+
options["start_new_session"] = True
|
|
166
|
+
process = None
|
|
167
|
+
tree = None
|
|
168
|
+
channel = None
|
|
169
|
+
started = time.monotonic()
|
|
170
|
+
try:
|
|
171
|
+
process = subprocess.Popen(
|
|
172
|
+
[sys.executable, "-m", "ringo_task_queue._supervisor_child", entrypoint],
|
|
173
|
+
**options,
|
|
174
|
+
)
|
|
175
|
+
# Windows must contain even a venv redirector *before it can spawn the
|
|
176
|
+
# real interpreter*. The later authenticated GO also gates app imports.
|
|
177
|
+
tree = ProcessTree(process.pid)
|
|
178
|
+
if stop is not None and stop.is_set():
|
|
179
|
+
raise _UserStop()
|
|
180
|
+
if os.name == "nt":
|
|
181
|
+
tree.resume_root()
|
|
182
|
+
deadline = started + policy.startup_timeout.total_seconds()
|
|
183
|
+
while time.monotonic() < deadline:
|
|
184
|
+
if stop is not None and stop.is_set():
|
|
185
|
+
raise _UserStop()
|
|
186
|
+
if process.poll() is not None:
|
|
187
|
+
raise RuntimeError(
|
|
188
|
+
"supervised child exited before control-channel startup"
|
|
189
|
+
)
|
|
190
|
+
try:
|
|
191
|
+
connection, _ = listener.accept()
|
|
192
|
+
except TimeoutError:
|
|
193
|
+
continue
|
|
194
|
+
connection.settimeout(min(1.0, max(0.01, deadline - time.monotonic())))
|
|
195
|
+
candidate = Channel(connection)
|
|
196
|
+
try:
|
|
197
|
+
hello = candidate.receive(
|
|
198
|
+
timeout=min(1.0, max(0.01, deadline - time.monotonic()))
|
|
199
|
+
)
|
|
200
|
+
valid = (
|
|
201
|
+
hello is not None
|
|
202
|
+
and hello.get("kind") == "hello"
|
|
203
|
+
and isinstance(hello.get("token"), str)
|
|
204
|
+
and secrets.compare_digest(hello["token"], token)
|
|
205
|
+
and hello.get("generation") == generation
|
|
206
|
+
)
|
|
207
|
+
except (OSError, ValueError):
|
|
208
|
+
valid = False
|
|
209
|
+
if not valid:
|
|
210
|
+
candidate.close()
|
|
211
|
+
continue
|
|
212
|
+
connection.settimeout(None)
|
|
213
|
+
channel = candidate
|
|
214
|
+
channel.send(
|
|
215
|
+
{
|
|
216
|
+
"kind": "configure",
|
|
217
|
+
"generation": generation,
|
|
218
|
+
"windows_job": os.name == "nt",
|
|
219
|
+
"policy": policy._to_wire(),
|
|
220
|
+
"task_timeouts": task_timeouts._to_wire(),
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
return _Generation(
|
|
224
|
+
process,
|
|
225
|
+
tree,
|
|
226
|
+
channel,
|
|
227
|
+
_Reader(channel),
|
|
228
|
+
started,
|
|
229
|
+
max_rss_bytes=policy.max_rss_bytes,
|
|
230
|
+
)
|
|
231
|
+
raise TimeoutError("supervised child control-channel startup timed out")
|
|
232
|
+
except BaseException:
|
|
233
|
+
if tree is not None:
|
|
234
|
+
try:
|
|
235
|
+
tree.kill()
|
|
236
|
+
if not tree.wait_empty(_CLEANUP_TIMEOUT):
|
|
237
|
+
raise _CleanupFailed(
|
|
238
|
+
"supervisor startup cleanup did not empty the owned process tree"
|
|
239
|
+
)
|
|
240
|
+
except Exception as cleanup_error:
|
|
241
|
+
raise _CleanupFailed(
|
|
242
|
+
"supervisor startup resource cleanup failed; refusing replacement"
|
|
243
|
+
) from cleanup_error
|
|
244
|
+
finally:
|
|
245
|
+
tree.close()
|
|
246
|
+
elif process is not None and process.poll() is None:
|
|
247
|
+
process.kill()
|
|
248
|
+
if process is not None:
|
|
249
|
+
process.wait(timeout=_CLEANUP_TIMEOUT)
|
|
250
|
+
if channel is not None:
|
|
251
|
+
channel.close()
|
|
252
|
+
raise
|
|
253
|
+
finally:
|
|
254
|
+
listener.close()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _external_overdue(
|
|
258
|
+
health: dict[str, Any], now: float
|
|
259
|
+
) -> set[tuple[str, float, float]]:
|
|
260
|
+
overdue = set()
|
|
261
|
+
for execution in health.get("executions", []):
|
|
262
|
+
if execution.get("unknown"):
|
|
263
|
+
continue
|
|
264
|
+
identity = execution.get("execution_id")
|
|
265
|
+
values = [
|
|
266
|
+
execution.get(name) for name in ("sampled_at", "deadline", "cancel_grace")
|
|
267
|
+
]
|
|
268
|
+
if not isinstance(identity, str) or not all(
|
|
269
|
+
isinstance(value, (int, float))
|
|
270
|
+
and not isinstance(value, bool)
|
|
271
|
+
and math.isfinite(value)
|
|
272
|
+
for value in values
|
|
273
|
+
):
|
|
274
|
+
continue
|
|
275
|
+
sampled, deadline, grace = values
|
|
276
|
+
if (
|
|
277
|
+
grace >= 0
|
|
278
|
+
and 0 <= now - sampled <= 2 * HEALTH_INTERVAL
|
|
279
|
+
and sampled >= deadline + grace
|
|
280
|
+
):
|
|
281
|
+
overdue.add((identity, float(deadline), float(grace)))
|
|
282
|
+
return overdue
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _monitor(
|
|
286
|
+
generation: _Generation,
|
|
287
|
+
policy: SupervisorPolicy,
|
|
288
|
+
stop: threading.Event | None = None,
|
|
289
|
+
) -> str:
|
|
290
|
+
reader = generation.reader
|
|
291
|
+
soft_since: float | None = None
|
|
292
|
+
last_sample = float("-inf")
|
|
293
|
+
last_health_serial = 0
|
|
294
|
+
previous_health: dict[str, Any] = {}
|
|
295
|
+
while True:
|
|
296
|
+
if stop is not None and stop.is_set():
|
|
297
|
+
return "interrupted"
|
|
298
|
+
now = time.monotonic()
|
|
299
|
+
if reader.fatal:
|
|
300
|
+
return "invalid_entrypoint"
|
|
301
|
+
if reader.restart is not None:
|
|
302
|
+
reason = str(reader.restart.get("reason", "requested_restart"))[:128]
|
|
303
|
+
_LOG.warning("Supervised child requested recovery: %r", reason)
|
|
304
|
+
return "requested_restart"
|
|
305
|
+
code = generation.process.poll()
|
|
306
|
+
if code is not None:
|
|
307
|
+
# Drain already-buffered readiness/fatal frames before classifying
|
|
308
|
+
# a very short application; process exit can beat the reader thread.
|
|
309
|
+
reader.thread.join(timeout=0.2)
|
|
310
|
+
if reader.fatal:
|
|
311
|
+
return "invalid_entrypoint"
|
|
312
|
+
return "normal_exit" if code == 0 and reader.ready else "crash"
|
|
313
|
+
if (
|
|
314
|
+
not reader.ready
|
|
315
|
+
and now - generation.started >= policy.startup_timeout.total_seconds()
|
|
316
|
+
):
|
|
317
|
+
return "startup_timeout"
|
|
318
|
+
if reader.entrypoint_failed:
|
|
319
|
+
return "crash"
|
|
320
|
+
if reader.entrypoint_done:
|
|
321
|
+
return "normal_exit" if reader.entrypoint_exit == 0 else "crash"
|
|
322
|
+
if reader.closed:
|
|
323
|
+
return "channel_lost"
|
|
324
|
+
health = reader.health
|
|
325
|
+
serial = health.get("_serial", 0)
|
|
326
|
+
if serial != last_health_serial:
|
|
327
|
+
# Missing a frame might hide progress recovery or an unknown read.
|
|
328
|
+
# Revalidate the preceding sample's freshness now, and match the
|
|
329
|
+
# effective deadline/grace as well as the execution identity.
|
|
330
|
+
if serial == last_health_serial + 1 and (
|
|
331
|
+
_external_overdue(health, now) & _external_overdue(previous_health, now)
|
|
332
|
+
):
|
|
333
|
+
return "business_deadline_unresponsive"
|
|
334
|
+
previous_health = health
|
|
335
|
+
last_health_serial = serial
|
|
336
|
+
# If native code monopolizes the GIL, even the reporting thread cannot
|
|
337
|
+
# snapshot actual entries. Only universally bounded active Workers
|
|
338
|
+
# permit this conservative maximum-budget liveness fallback.
|
|
339
|
+
budgets = tuple(reader.worker_budgets.copy().values())
|
|
340
|
+
if reader.ready and budgets and all(value is not None for value in budgets):
|
|
341
|
+
fallback = max(value for value in budgets if value is not None)
|
|
342
|
+
baseline = max(
|
|
343
|
+
reader.last_health, reader.ready_at, reader.budget_changed_at
|
|
344
|
+
)
|
|
345
|
+
if now - baseline >= fallback:
|
|
346
|
+
return "business_observation_timeout"
|
|
347
|
+
active_loop_ids = {
|
|
348
|
+
reader.worker_loop_ids.get(worker_id)
|
|
349
|
+
for worker_id in reader.worker_budgets.copy()
|
|
350
|
+
}
|
|
351
|
+
for loop in health.get("loops", []):
|
|
352
|
+
if (
|
|
353
|
+
loop.get("id") in active_loop_ids
|
|
354
|
+
and now - max(loop["last_tick"], reader.budget_changed_at)
|
|
355
|
+
>= fallback
|
|
356
|
+
):
|
|
357
|
+
return "business_observation_timeout"
|
|
358
|
+
if now - last_sample >= _MEMORY_INTERVAL:
|
|
359
|
+
# Membership sampling also retains identities for cleanup on POSIX.
|
|
360
|
+
rss = generation.tree.memory_bytes()
|
|
361
|
+
last_sample = now
|
|
362
|
+
if policy.max_rss_bytes is not None and rss >= policy.max_rss_bytes:
|
|
363
|
+
_LOG.warning(
|
|
364
|
+
"Owned process RSS reached hard recovery threshold: %d bytes", rss
|
|
365
|
+
)
|
|
366
|
+
return "hard_memory"
|
|
367
|
+
if policy.soft_rss_bytes is not None and rss >= policy.soft_rss_bytes:
|
|
368
|
+
if soft_since is None:
|
|
369
|
+
soft_since = now
|
|
370
|
+
if now - soft_since >= policy.rss_grace.total_seconds():
|
|
371
|
+
_LOG.warning(
|
|
372
|
+
"Owned process RSS persistently exceeded soft threshold: %d bytes",
|
|
373
|
+
rss,
|
|
374
|
+
)
|
|
375
|
+
return "soft_memory"
|
|
376
|
+
else:
|
|
377
|
+
soft_since = None
|
|
378
|
+
if reader.ready and policy.heartbeat_timeout is not None:
|
|
379
|
+
timeout = policy.heartbeat_timeout.total_seconds()
|
|
380
|
+
if now - reader.last_health >= timeout:
|
|
381
|
+
return "health_channel_timeout"
|
|
382
|
+
for loop in reader.health.get("loops", []):
|
|
383
|
+
last_tick = loop.get("last_tick")
|
|
384
|
+
if isinstance(last_tick, (int, float)) and now - last_tick >= timeout:
|
|
385
|
+
return "event_loop_timeout"
|
|
386
|
+
if (
|
|
387
|
+
reader.ready
|
|
388
|
+
and policy.recycle_after is not None
|
|
389
|
+
and now - generation.started >= policy.recycle_after.total_seconds()
|
|
390
|
+
):
|
|
391
|
+
return "planned_recycle"
|
|
392
|
+
time.sleep(_MONITOR_INTERVAL)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _cleanup(generation: _Generation, grace: float) -> bool:
|
|
396
|
+
cleaned = False
|
|
397
|
+
try:
|
|
398
|
+
try:
|
|
399
|
+
generation.channel.send({"kind": "stop"})
|
|
400
|
+
except OSError:
|
|
401
|
+
pass
|
|
402
|
+
deadline = time.monotonic() + grace
|
|
403
|
+
last_memory = float("-inf")
|
|
404
|
+
max_rss = getattr(generation, "max_rss_bytes", None)
|
|
405
|
+
while time.monotonic() < deadline:
|
|
406
|
+
if generation.tree.wait_empty(0):
|
|
407
|
+
break
|
|
408
|
+
now = time.monotonic()
|
|
409
|
+
if max_rss is not None and now - last_memory >= _MEMORY_INTERVAL:
|
|
410
|
+
# A long healthy-task drain must not disable the hard resource
|
|
411
|
+
# guard after a soft threshold or planned recycle started it.
|
|
412
|
+
try:
|
|
413
|
+
rss = generation.tree.memory_bytes()
|
|
414
|
+
except Exception: # noqa: BLE001 - Unknown RSS is not a zero reading.
|
|
415
|
+
generation.cleanup_fault = "resource_observation_error"
|
|
416
|
+
_LOG.exception(
|
|
417
|
+
"RSS observation failed during drain; forcing owned cleanup"
|
|
418
|
+
)
|
|
419
|
+
break
|
|
420
|
+
last_memory = now
|
|
421
|
+
if rss >= max_rss:
|
|
422
|
+
generation.cleanup_fault = "hard_memory"
|
|
423
|
+
_LOG.warning(
|
|
424
|
+
"Hard RSS threshold reached during drain: %d bytes", rss
|
|
425
|
+
)
|
|
426
|
+
break
|
|
427
|
+
time.sleep(min(0.05, max(0.0, deadline - time.monotonic())))
|
|
428
|
+
if not generation.tree.wait_empty(0):
|
|
429
|
+
generation.tree.kill()
|
|
430
|
+
if not generation.tree.wait_empty(_CLEANUP_TIMEOUT):
|
|
431
|
+
_LOG.error(
|
|
432
|
+
"Owned resources did not exit; refusing to launch another generation"
|
|
433
|
+
)
|
|
434
|
+
else:
|
|
435
|
+
generation.process.wait(timeout=_CLEANUP_TIMEOUT)
|
|
436
|
+
cleaned = True
|
|
437
|
+
except Exception: # noqa: BLE001 - Unknown native failures must block replacement.
|
|
438
|
+
_LOG.exception("Resource cleanup failed; refusing to launch another generation")
|
|
439
|
+
finally:
|
|
440
|
+
for close in (
|
|
441
|
+
generation.channel.close,
|
|
442
|
+
lambda: generation.reader.thread.join(timeout=1),
|
|
443
|
+
generation.tree.close,
|
|
444
|
+
):
|
|
445
|
+
try:
|
|
446
|
+
close()
|
|
447
|
+
except Exception: # noqa: BLE001 - Finish every independent owned cleanup.
|
|
448
|
+
cleaned = False
|
|
449
|
+
_LOG.exception("Resource owner close failed; refusing replacement")
|
|
450
|
+
return cleaned
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def supervise(
|
|
454
|
+
entrypoint: str,
|
|
455
|
+
*,
|
|
456
|
+
policy: SupervisorPolicy | None = None,
|
|
457
|
+
task_timeouts: TaskTimeouts | None = None,
|
|
458
|
+
) -> int:
|
|
459
|
+
"""Run a zero-argument ``module:function`` in supervised fresh processes.
|
|
460
|
+
|
|
461
|
+
Normal return/exit(0) ends supervision. Explicit recycling is independent
|
|
462
|
+
of exit status. Faults have a bounded, cross-generation restart window;
|
|
463
|
+
user interruption stops rather than restarting. Returns a process exit
|
|
464
|
+
status (0 success, 130 interruption, 74 cleanup/ownership failure,
|
|
465
|
+
75 exhausted recovery budget, 78 invalid entrypoint/configuration).
|
|
466
|
+
|
|
467
|
+
Import this module explicitly. On POSIX, install
|
|
468
|
+
``ringo-task-queue[supervisor]`` for identity-aware process sampling.
|
|
469
|
+
"""
|
|
470
|
+
if (
|
|
471
|
+
not isinstance(entrypoint, str)
|
|
472
|
+
or not _ENTRYPOINT.fullmatch(entrypoint)
|
|
473
|
+
or entrypoint.split(":", 1)[0] == "__main__"
|
|
474
|
+
):
|
|
475
|
+
raise ValueError(
|
|
476
|
+
"entrypoint must be an importable 'module:function', not __main__ or a closure"
|
|
477
|
+
)
|
|
478
|
+
if threading.current_thread() is not threading.main_thread():
|
|
479
|
+
raise RuntimeError("supervise must run in the launcher's main thread")
|
|
480
|
+
try:
|
|
481
|
+
asyncio.get_running_loop()
|
|
482
|
+
except RuntimeError:
|
|
483
|
+
pass
|
|
484
|
+
else:
|
|
485
|
+
raise RuntimeError(
|
|
486
|
+
"supervise is blocking; call it outside the application event loop"
|
|
487
|
+
)
|
|
488
|
+
runtime_module = sys.modules.get("ringo_task_queue._supervisor_runtime")
|
|
489
|
+
if runtime_module is not None and runtime_module.current_runtime() is not None:
|
|
490
|
+
raise RuntimeError("nested process supervision is not supported")
|
|
491
|
+
policy = policy if policy is not None else SupervisorPolicy()
|
|
492
|
+
task_timeouts = task_timeouts if task_timeouts is not None else TaskTimeouts()
|
|
493
|
+
if not isinstance(policy, SupervisorPolicy) or not isinstance(
|
|
494
|
+
task_timeouts, TaskTimeouts
|
|
495
|
+
):
|
|
496
|
+
raise ValueError( # noqa: TRY004 - Consistent declarative policy validation.
|
|
497
|
+
"policy/task_timeouts must use their respective configuration types"
|
|
498
|
+
)
|
|
499
|
+
faults: deque[float] = deque()
|
|
500
|
+
old_term = signal.getsignal(signal.SIGTERM)
|
|
501
|
+
old_int = signal.getsignal(signal.SIGINT)
|
|
502
|
+
stop = threading.Event()
|
|
503
|
+
|
|
504
|
+
def interrupted(_signal, _frame):
|
|
505
|
+
# Repeated signals must not unwind kill/wait or leak a resource owner.
|
|
506
|
+
stop.set()
|
|
507
|
+
|
|
508
|
+
signal.signal(signal.SIGTERM, interrupted)
|
|
509
|
+
signal.signal(signal.SIGINT, interrupted)
|
|
510
|
+
generation: _Generation | None = None
|
|
511
|
+
try:
|
|
512
|
+
while True:
|
|
513
|
+
try:
|
|
514
|
+
if stop.is_set():
|
|
515
|
+
return 130
|
|
516
|
+
generation = _spawn(entrypoint, policy, task_timeouts, stop)
|
|
517
|
+
reason = _monitor(generation, policy, stop)
|
|
518
|
+
except _CleanupFailed:
|
|
519
|
+
_LOG.exception(
|
|
520
|
+
"Startup cleanup failed; no replacement will be launched"
|
|
521
|
+
)
|
|
522
|
+
return 74
|
|
523
|
+
except (KeyboardInterrupt, _UserStop):
|
|
524
|
+
if generation is not None:
|
|
525
|
+
_cleanup(generation, policy.shutdown_grace.total_seconds())
|
|
526
|
+
generation = None
|
|
527
|
+
return 130
|
|
528
|
+
except Exception: # noqa: BLE001 - Isolate arbitrary generation failures.
|
|
529
|
+
_LOG.exception("Supervised generation failed")
|
|
530
|
+
reason = "generation_error"
|
|
531
|
+
if generation is not None:
|
|
532
|
+
grace = policy.shutdown_grace.total_seconds()
|
|
533
|
+
if reason in ("hard_memory", "crash", "invalid_entrypoint"):
|
|
534
|
+
grace = min(grace, 1.0)
|
|
535
|
+
cleaned = _cleanup(generation, grace)
|
|
536
|
+
cleanup_fault = getattr(generation, "cleanup_fault", None)
|
|
537
|
+
if reason == "planned_recycle" and cleanup_fault is not None:
|
|
538
|
+
# A hard resource fault discovered while draining is not
|
|
539
|
+
# an unlimited series of free planned replacements.
|
|
540
|
+
reason = cleanup_fault
|
|
541
|
+
generation = None
|
|
542
|
+
if not cleaned:
|
|
543
|
+
return 74
|
|
544
|
+
if reason == "interrupted" or stop.is_set():
|
|
545
|
+
return 130
|
|
546
|
+
if reason == "normal_exit":
|
|
547
|
+
return 0
|
|
548
|
+
if reason == "invalid_entrypoint":
|
|
549
|
+
return 78
|
|
550
|
+
if reason != "planned_recycle":
|
|
551
|
+
now = time.monotonic()
|
|
552
|
+
while (
|
|
553
|
+
faults and now - faults[0] >= policy.restart_window.total_seconds()
|
|
554
|
+
):
|
|
555
|
+
faults.popleft()
|
|
556
|
+
if len(faults) >= policy.max_restarts:
|
|
557
|
+
_LOG.error(
|
|
558
|
+
"Supervisor recovery budget exhausted; last reason=%s", reason
|
|
559
|
+
)
|
|
560
|
+
return 75
|
|
561
|
+
faults.append(now)
|
|
562
|
+
delay = min(30.0, 0.3 * 2 ** min(len(faults) - 1, 7))
|
|
563
|
+
try:
|
|
564
|
+
if stop.wait(delay * random.uniform(0.8, 1.2)):
|
|
565
|
+
return 130
|
|
566
|
+
except (KeyboardInterrupt, _UserStop):
|
|
567
|
+
return 130
|
|
568
|
+
_LOG.info("Starting replacement generation; reason=%s", reason)
|
|
569
|
+
finally:
|
|
570
|
+
try:
|
|
571
|
+
if generation is not None:
|
|
572
|
+
_cleanup(generation, 0)
|
|
573
|
+
finally:
|
|
574
|
+
signal.signal(signal.SIGTERM, old_term)
|
|
575
|
+
signal.signal(signal.SIGINT, old_int)
|