ringo-task-queue 0.1.0.dev7__py3-none-win_amd64.whl → 0.1.0.dev9__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.
@@ -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)