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,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)
|