ringo-task-queue 0.1.0.dev8__py3-none-macosx_13_0_x86_64.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,91 @@
1
+ """Private subprocess bootstrap. Use the public ``supervise`` function."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import inspect
7
+ import os
8
+ import socket
9
+ import sys
10
+ import traceback
11
+
12
+ from ._supervisor_ipc import Channel
13
+ from ._supervisor_policy import SupervisorPolicy, TaskTimeouts
14
+ from ._supervisor_runtime import ChildRuntime
15
+
16
+
17
+ def _load(entrypoint: str):
18
+ module_name, attribute = entrypoint.split(":", 1)
19
+ result = importlib.import_module(module_name)
20
+ for name in attribute.split("."):
21
+ result = getattr(result, name)
22
+ if not callable(result):
23
+ raise TypeError("supervisor entrypoint must be callable")
24
+ inspect.signature(result).bind()
25
+ return result
26
+
27
+
28
+ def main() -> int:
29
+ if len(sys.argv) != 2:
30
+ print("This is a private supervisor bootstrap.", file=sys.stderr)
31
+ return 78
32
+ # Never pass the local channel capability on a command line or leave it in
33
+ # the environment inherited by browsers/application subprocesses.
34
+ port = int(os.environ.pop("RINGO_SUPERVISOR_PORT"))
35
+ token = os.environ.pop("RINGO_SUPERVISOR_TOKEN")
36
+ generation = os.environ.pop("RINGO_SUPERVISOR_GENERATION")
37
+ connection = socket.create_connection(("127.0.0.1", port), timeout=30)
38
+ connection.settimeout(None)
39
+ channel = Channel(connection)
40
+ channel.send({"kind": "hello", "token": token, "generation": generation})
41
+ config = channel.receive(timeout=30)
42
+ if (
43
+ config is None
44
+ or config.get("kind") != "configure"
45
+ or config.get("generation") != generation
46
+ ):
47
+ channel.close()
48
+ return 78
49
+ # The parent has assigned the Job before allowing *any application import*.
50
+ # Only shared-local's explicit launch path is allowed to break away.
51
+ if config.get("windows_job"):
52
+ os.environ["RINGO_SUPERVISOR_JOB"] = "1"
53
+ else:
54
+ os.environ.pop("RINGO_SUPERVISOR_JOB", None)
55
+ runtime = ChildRuntime(
56
+ channel,
57
+ SupervisorPolicy._from_wire(config["policy"]),
58
+ TaskTimeouts._from_wire(config["task_timeouts"]),
59
+ )
60
+ runtime.start()
61
+ ready = False
62
+ try:
63
+ try:
64
+ function = _load(sys.argv[1])
65
+ except Exception: # noqa: BLE001 - Arbitrary application imports fail closed.
66
+ traceback.print_exc()
67
+ channel.send({"kind": "fatal", "reason": "invalid_entrypoint"})
68
+ return 78
69
+ channel.send({"kind": "ready"})
70
+ ready = True
71
+ return runtime.invoke(function)
72
+ except SystemExit as exc:
73
+ if not ready:
74
+ channel.send({"kind": "fatal", "reason": "entrypoint_import_exited"})
75
+ return 78
76
+ code = exc.code if isinstance(exc.code, int) else (0 if exc.code is None else 1)
77
+ channel.send({"kind": "entrypoint_done", "exit_code": code})
78
+ return code
79
+ except BaseException: # noqa: BLE001 - Final subprocess/application boundary.
80
+ traceback.print_exc()
81
+ try:
82
+ channel.send({"kind": "entrypoint_failed"})
83
+ except OSError:
84
+ pass
85
+ return 1
86
+ finally:
87
+ runtime.close()
88
+
89
+
90
+ if __name__ == "__main__":
91
+ raise SystemExit(main())
@@ -0,0 +1,81 @@
1
+ """Private, bounded same-host supervision channel (not a queue protocol)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import select
7
+ import socket
8
+ import threading
9
+ import time
10
+ from typing import Any
11
+
12
+ _MAX_FRAME = 256 * 1024
13
+ HEALTH_INTERVAL = 1.0
14
+ _READ_POLL = 0.2
15
+
16
+
17
+ class Channel:
18
+ def __init__(self, connection: socket.socket) -> None:
19
+ self.socket = connection
20
+ self._buffer = bytearray()
21
+ self._write_lock = threading.Lock()
22
+ self._closed = threading.Event()
23
+
24
+ def send(self, message: dict[str, Any]) -> None:
25
+ data = (
26
+ json.dumps(message, separators=(",", ":"), allow_nan=False).encode("utf-8")
27
+ + b"\n"
28
+ )
29
+ if len(data) > _MAX_FRAME:
30
+ raise ValueError("supervisor IPC frame is too large")
31
+ with self._write_lock:
32
+ if self._closed.is_set():
33
+ raise OSError("supervisor IPC channel is closed")
34
+ self.socket.sendall(data)
35
+
36
+ def receive(self, *, timeout: float | None = None) -> dict[str, Any] | None:
37
+ # Exactly one reader owns this buffer. Avoid socket.makefile(): closing
38
+ # its BufferedReader from another thread can wait forever for readline's
39
+ # lock on Windows, even after socket.shutdown(). A raw socket has no
40
+ # hidden file reference/lock, and polling makes local close bounded.
41
+ deadline = None if timeout is None else time.monotonic() + timeout
42
+ while not self._closed.is_set():
43
+ newline = self._buffer.find(b"\n")
44
+ if newline >= 0:
45
+ if newline + 1 > _MAX_FRAME:
46
+ raise ValueError("invalid supervisor IPC frame length")
47
+ data = bytes(self._buffer[:newline])
48
+ del self._buffer[: newline + 1]
49
+ value = json.loads(data)
50
+ if not isinstance(value, dict):
51
+ # The value is malformed wire data, not a Python argument.
52
+ raise ValueError("supervisor IPC frame must be an object")
53
+ return value
54
+ if len(self._buffer) >= _MAX_FRAME:
55
+ raise ValueError("invalid supervisor IPC frame length")
56
+ wait = _READ_POLL
57
+ if deadline is not None:
58
+ remaining = deadline - time.monotonic()
59
+ if remaining <= 0:
60
+ raise TimeoutError("supervisor IPC receive timed out")
61
+ wait = min(wait, remaining)
62
+ readable, _, _ = select.select([self.socket], [], [], wait)
63
+ if not readable:
64
+ continue
65
+ data = self.socket.recv(min(8192, _MAX_FRAME - len(self._buffer)))
66
+ if not data:
67
+ if self._buffer:
68
+ raise ValueError("incomplete supervisor IPC frame")
69
+ return None
70
+ self._buffer.extend(data)
71
+ return None
72
+
73
+ def close(self) -> None:
74
+ if self._closed.is_set():
75
+ return
76
+ self._closed.set()
77
+ try:
78
+ self.socket.shutdown(socket.SHUT_RDWR)
79
+ except OSError:
80
+ pass
81
+ self.socket.close()
@@ -0,0 +1,182 @@
1
+ """Validated, immutable configuration for opt-in process supervision."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # Declarative policy validation consistently raises ValueError for bad inputs.
6
+ # ruff: noqa: TRY004
7
+ import math
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass, field
10
+ from datetime import timedelta
11
+ from types import MappingProxyType
12
+ from typing import Any
13
+
14
+
15
+ def _seconds(value: timedelta | None, name: str, *, zero: bool = False) -> float | None:
16
+ if value is None:
17
+ return None
18
+ if not isinstance(value, timedelta):
19
+ raise ValueError(f"{name} must be a timedelta or None")
20
+ seconds = value.total_seconds()
21
+ if not math.isfinite(seconds) or seconds < 0 or (seconds == 0 and not zero):
22
+ raise ValueError(f"{name} must be {'nonnegative' if zero else 'positive'}")
23
+ return seconds
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class TaskTimeouts:
28
+ """Business execution budgets, excluding queueing and terminal receipts.
29
+
30
+ ``by_type`` overrides only ``default``; an explicit ``None`` disables that
31
+ type's execution budget. A Worker-provided object replaces the inherited
32
+ object in full. Progress monitoring is opt-in and arms only after a real
33
+ application progress mark; lease renewal is never business progress.
34
+ """
35
+
36
+ default: timedelta | None = None
37
+ by_type: Mapping[str, timedelta | None] = field(default_factory=dict)
38
+ progress_timeout: timedelta | None = None
39
+ cancel_grace: timedelta = timedelta(seconds=5)
40
+
41
+ def __post_init__(self) -> None:
42
+ _seconds(self.default, "default")
43
+ _seconds(self.progress_timeout, "progress_timeout")
44
+ _seconds(self.cancel_grace, "cancel_grace", zero=True)
45
+ if self.cancel_grace is None:
46
+ raise ValueError("cancel_grace must be a timedelta")
47
+ if not isinstance(self.by_type, Mapping):
48
+ raise ValueError("by_type must be a mapping")
49
+ values = dict(self.by_type)
50
+ for key, value in values.items():
51
+ if not isinstance(key, str) or not key:
52
+ raise ValueError("by_type keys must be nonempty task-type strings")
53
+ _seconds(value, f"by_type[{key!r}]")
54
+ object.__setattr__(self, "by_type", MappingProxyType(values))
55
+
56
+ def for_type(self, task_type: str) -> timedelta | None:
57
+ return self.by_type.get(task_type, self.default)
58
+
59
+ @property
60
+ def enabled(self) -> bool:
61
+ return (
62
+ self.default is not None
63
+ or any(value is not None for value in self.by_type.values())
64
+ or self.progress_timeout is not None
65
+ )
66
+
67
+ def _to_wire(self) -> dict:
68
+ return {
69
+ "default": _seconds(self.default, "default"),
70
+ "by_type": {
71
+ key: _seconds(value, key) for key, value in self.by_type.items()
72
+ },
73
+ "progress_timeout": _seconds(self.progress_timeout, "progress_timeout"),
74
+ "cancel_grace": _seconds(self.cancel_grace, "cancel_grace", zero=True),
75
+ }
76
+
77
+ @classmethod
78
+ def _from_wire(cls, value: dict) -> TaskTimeouts:
79
+ def duration(seconds):
80
+ return None if seconds is None else timedelta(seconds=seconds)
81
+
82
+ return cls(
83
+ default=duration(value["default"]),
84
+ by_type={
85
+ key: duration(seconds) for key, seconds in value["by_type"].items()
86
+ },
87
+ progress_timeout=duration(value["progress_timeout"]),
88
+ cancel_grace=timedelta(seconds=value["cancel_grace"]),
89
+ )
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class SupervisorPolicy:
94
+ """Process policy; destructive resource/time thresholds are opt-in.
95
+
96
+ RSS is a sampled, approximate sum for the owned process tree, not a kernel
97
+ hard allocation limit. No-start detection is heuristic under competing
98
+ consumers, so it only warns unless ``restart_on_stall`` is explicit.
99
+ Planned recycling neither consumes nor clears the fault-restart budget.
100
+ """
101
+
102
+ recycle_after: timedelta | None = None
103
+ soft_rss_bytes: int | None = None
104
+ max_rss_bytes: int | None = None
105
+ rss_grace: timedelta = timedelta(seconds=30)
106
+ shutdown_grace: timedelta = timedelta(seconds=30)
107
+ startup_timeout: timedelta = timedelta(seconds=30)
108
+ heartbeat_timeout: timedelta | None = None
109
+ no_start_timeout: timedelta | None = None
110
+ restart_on_stall: bool = False
111
+ max_restarts: int = 5
112
+ restart_window: timedelta = timedelta(minutes=10)
113
+
114
+ def __post_init__(self) -> None:
115
+ for name in ("recycle_after", "heartbeat_timeout", "no_start_timeout"):
116
+ _seconds(getattr(self, name), name)
117
+ for name in (
118
+ "rss_grace",
119
+ "shutdown_grace",
120
+ "startup_timeout",
121
+ "restart_window",
122
+ ):
123
+ value = getattr(self, name)
124
+ if value is None:
125
+ raise ValueError(f"{name} must be a timedelta")
126
+ _seconds(value, name, zero=name in ("rss_grace", "shutdown_grace"))
127
+ for name in ("soft_rss_bytes", "max_rss_bytes"):
128
+ value = getattr(self, name)
129
+ if value is not None and (
130
+ isinstance(value, bool) or not isinstance(value, int) or value <= 0
131
+ ):
132
+ raise ValueError(f"{name} must be a positive integer or None")
133
+ if (
134
+ self.soft_rss_bytes is not None
135
+ and self.max_rss_bytes is not None
136
+ and self.soft_rss_bytes >= self.max_rss_bytes
137
+ ):
138
+ raise ValueError("soft_rss_bytes must be smaller than max_rss_bytes")
139
+ if not isinstance(self.restart_on_stall, bool):
140
+ raise ValueError("restart_on_stall must be a boolean")
141
+ if self.restart_on_stall and self.no_start_timeout is None:
142
+ raise ValueError("restart_on_stall requires no_start_timeout")
143
+ if (
144
+ isinstance(self.max_restarts, bool)
145
+ or not isinstance(self.max_restarts, int)
146
+ or self.max_restarts < 0
147
+ ):
148
+ raise ValueError("max_restarts must be a nonnegative integer")
149
+
150
+ def _to_wire(self) -> dict:
151
+ return {
152
+ "recycle_after": _seconds(self.recycle_after, "recycle_after"),
153
+ "soft_rss_bytes": self.soft_rss_bytes,
154
+ "max_rss_bytes": self.max_rss_bytes,
155
+ "rss_grace": self.rss_grace.total_seconds(),
156
+ "shutdown_grace": self.shutdown_grace.total_seconds(),
157
+ "startup_timeout": self.startup_timeout.total_seconds(),
158
+ "heartbeat_timeout": _seconds(self.heartbeat_timeout, "heartbeat_timeout"),
159
+ "no_start_timeout": _seconds(self.no_start_timeout, "no_start_timeout"),
160
+ "restart_on_stall": self.restart_on_stall,
161
+ "max_restarts": self.max_restarts,
162
+ "restart_window": self.restart_window.total_seconds(),
163
+ }
164
+
165
+ @classmethod
166
+ def _from_wire(cls, value: dict) -> SupervisorPolicy:
167
+ duration_names = {
168
+ "recycle_after",
169
+ "rss_grace",
170
+ "shutdown_grace",
171
+ "startup_timeout",
172
+ "heartbeat_timeout",
173
+ "no_start_timeout",
174
+ "restart_window",
175
+ }
176
+ arguments: dict[str, Any] = {
177
+ key: (None if item is None else timedelta(seconds=item))
178
+ if key in duration_names
179
+ else item
180
+ for key, item in value.items()
181
+ }
182
+ return cls(**arguments)