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.
- ringo_task_queue/_proto/ringo/v1/queue_pb2.py +26 -22
- ringo_task_queue/_proto/ringo/v1/queue_pb2_grpc.py +45 -0
- 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 +2 -2
- ringo_task_queue/bin/manifest.json +7 -7
- ringo_task_queue/bin/ringo-task-queue-windows-amd64.exe +0 -0
- ringo_task_queue/client.py +135 -72
- ringo_task_queue/shared.py +60 -8
- ringo_task_queue/supervisor.py +575 -0
- ringo_task_queue/worker.py +609 -272
- {ringo_task_queue-0.1.0.dev7.dist-info → ringo_task_queue-0.1.0.dev9.dist-info}/METADATA +106 -9
- ringo_task_queue-0.1.0.dev9.dist-info/RECORD +27 -0
- ringo_task_queue-0.1.0.dev7.dist-info/RECORD +0 -20
- {ringo_task_queue-0.1.0.dev7.dist-info → ringo_task_queue-0.1.0.dev9.dist-info}/WHEEL +0 -0
- {ringo_task_queue-0.1.0.dev7.dist-info → ringo_task_queue-0.1.0.dev9.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""Child-side lifecycle bridge, instantiated only by the supervisor bootstrap."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import weakref
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from ._supervisor_ipc import HEALTH_INTERVAL, Channel
|
|
16
|
+
from ._supervisor_policy import SupervisorPolicy, TaskTimeouts
|
|
17
|
+
from .errors import RingoError
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from .worker import Worker
|
|
21
|
+
|
|
22
|
+
_LOG = logging.getLogger(__name__)
|
|
23
|
+
_HEALTH_INTERVAL = HEALTH_INTERVAL
|
|
24
|
+
_MAX_HEALTH_ITEMS = 128
|
|
25
|
+
_CURRENT: ChildRuntime | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def current_runtime() -> ChildRuntime | None:
|
|
29
|
+
return _CURRENT
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ChildRuntime:
|
|
33
|
+
"""An isolated generation's private control and health channel.
|
|
34
|
+
|
|
35
|
+
The channel thread does not claim that business/event loops are healthy.
|
|
36
|
+
Loop ticks are generated *on those loops* and remain stale if they block.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self, channel: Channel, policy: SupervisorPolicy, task_timeouts: TaskTimeouts
|
|
41
|
+
) -> None:
|
|
42
|
+
self.channel = channel
|
|
43
|
+
self.policy = policy
|
|
44
|
+
self.task_timeouts = task_timeouts
|
|
45
|
+
self._lock = threading.Lock()
|
|
46
|
+
self._loops: dict[asyncio.AbstractEventLoop, dict[str, Any]] = {}
|
|
47
|
+
self._workers: weakref.WeakSet[Worker] = weakref.WeakSet()
|
|
48
|
+
self._worker_snapshots: dict[str, dict[str, Any]] = {}
|
|
49
|
+
# Strongly retain a single-use Worker's identity until its ordered stop
|
|
50
|
+
# frame is sent; a pending tombstone must never outlive/reuse that id.
|
|
51
|
+
self._pending_worker_stops: dict[str, Worker] = {}
|
|
52
|
+
self._stopping = False
|
|
53
|
+
self._closed = threading.Event()
|
|
54
|
+
self._restart_requested = False
|
|
55
|
+
self._orphan_timer: threading.Timer | None = None
|
|
56
|
+
self._orphaned = False
|
|
57
|
+
|
|
58
|
+
def start(self) -> None:
|
|
59
|
+
global _CURRENT
|
|
60
|
+
if _CURRENT is not None:
|
|
61
|
+
raise RuntimeError("nested process supervision is not supported")
|
|
62
|
+
_CURRENT = self
|
|
63
|
+
threading.Thread(
|
|
64
|
+
target=self._control, name="ringo-supervisor-control", daemon=True
|
|
65
|
+
).start()
|
|
66
|
+
threading.Thread(
|
|
67
|
+
target=self._report, name="ringo-supervisor-health", daemon=True
|
|
68
|
+
).start()
|
|
69
|
+
|
|
70
|
+
def _loop_state(self) -> dict[str, Any]:
|
|
71
|
+
loop = asyncio.get_running_loop()
|
|
72
|
+
with self._lock:
|
|
73
|
+
state = self._loops.get(loop)
|
|
74
|
+
if state is None:
|
|
75
|
+
state = {
|
|
76
|
+
"clients": weakref.WeakSet(),
|
|
77
|
+
"stop": asyncio.Event(),
|
|
78
|
+
"last_tick": time.monotonic(),
|
|
79
|
+
"closed": False,
|
|
80
|
+
"pulse": None,
|
|
81
|
+
"drain": None,
|
|
82
|
+
}
|
|
83
|
+
self._loops[loop] = state
|
|
84
|
+
state["pulse"] = loop.call_soon(self._pulse, loop, state)
|
|
85
|
+
stopping = self._stopping
|
|
86
|
+
if stopping:
|
|
87
|
+
state["stop"].set()
|
|
88
|
+
self._schedule_drain(loop, state)
|
|
89
|
+
return state
|
|
90
|
+
|
|
91
|
+
def _pulse(self, loop: asyncio.AbstractEventLoop, state: dict[str, Any]) -> None:
|
|
92
|
+
# A timer, not a permanent Task: a sync entrypoint may legitimately own
|
|
93
|
+
# run_until_complete()/loop.close() without SDK-created pending Tasks.
|
|
94
|
+
with self._lock:
|
|
95
|
+
if self._closed.is_set():
|
|
96
|
+
state["closed"] = True
|
|
97
|
+
return
|
|
98
|
+
state["last_tick"] = time.monotonic()
|
|
99
|
+
state["pulse"] = loop.call_later(_HEALTH_INTERVAL, self._pulse, loop, state)
|
|
100
|
+
|
|
101
|
+
def register_client(self, client: Any) -> None:
|
|
102
|
+
state = self._loop_state()
|
|
103
|
+
with self._lock:
|
|
104
|
+
if self._stopping:
|
|
105
|
+
raise RingoError(
|
|
106
|
+
"supervised generation is stopping; client start is fenced"
|
|
107
|
+
)
|
|
108
|
+
state["clients"].add(client)
|
|
109
|
+
|
|
110
|
+
def register_worker(self, worker: Worker) -> None:
|
|
111
|
+
self._loop_state()
|
|
112
|
+
with self._lock:
|
|
113
|
+
if self._stopping:
|
|
114
|
+
raise RingoError(
|
|
115
|
+
"supervised generation is stopping; Worker admission is fenced"
|
|
116
|
+
)
|
|
117
|
+
self._workers.add(worker)
|
|
118
|
+
# Per-Worker configuration, never per-task IPC. Announce before its
|
|
119
|
+
# Work stream can admit a handler, including explicit unlimited types.
|
|
120
|
+
timeouts = worker._task_timeouts or TaskTimeouts()
|
|
121
|
+
scope = worker._task_types
|
|
122
|
+
budgets = (
|
|
123
|
+
[timeouts.for_type(kind) for kind in scope]
|
|
124
|
+
if scope
|
|
125
|
+
else [timeouts.default, *timeouts.by_type.values()]
|
|
126
|
+
)
|
|
127
|
+
bounded = bool(budgets) and all(value is not None for value in budgets)
|
|
128
|
+
fallback = None
|
|
129
|
+
if bounded:
|
|
130
|
+
fallback = (
|
|
131
|
+
max(value.total_seconds() for value in budgets if value is not None)
|
|
132
|
+
+ timeouts.cancel_grace.total_seconds()
|
|
133
|
+
+ 3 * _HEALTH_INTERVAL
|
|
134
|
+
)
|
|
135
|
+
try:
|
|
136
|
+
self.channel.send(
|
|
137
|
+
{
|
|
138
|
+
"kind": "worker_budget",
|
|
139
|
+
"worker_id": str(id(worker)),
|
|
140
|
+
"loop_id": str(id(asyncio.get_running_loop())),
|
|
141
|
+
"fallback_timeout": fallback,
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
except OSError:
|
|
145
|
+
self._parent_lost()
|
|
146
|
+
raise RingoError(
|
|
147
|
+
"supervisor parent control channel is unavailable"
|
|
148
|
+
) from None
|
|
149
|
+
|
|
150
|
+
def update_worker(self, worker_id: str, snapshot: dict[str, Any]) -> None:
|
|
151
|
+
with self._lock:
|
|
152
|
+
# A bounded snapshot is diagnostic only, never task data authority.
|
|
153
|
+
if (
|
|
154
|
+
worker_id in self._worker_snapshots
|
|
155
|
+
or len(self._worker_snapshots) < _MAX_HEALTH_ITEMS
|
|
156
|
+
):
|
|
157
|
+
self._worker_snapshots[worker_id] = dict(snapshot)
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _worker_busy(worker: Worker) -> bool:
|
|
161
|
+
watchdog = worker._watchdog
|
|
162
|
+
return bool(watchdog is not None and watchdog.executions) or any(
|
|
163
|
+
not task.done() for task in tuple(worker._handler_tasks)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def unregister_worker(self, worker: Worker) -> None:
|
|
167
|
+
# Public worker_id is an audit label, not a process-unique identity.
|
|
168
|
+
worker_id = str(id(worker))
|
|
169
|
+
with self._lock:
|
|
170
|
+
self._worker_snapshots.pop(worker_id, None)
|
|
171
|
+
still_running = any(
|
|
172
|
+
candidate is worker and self._worker_busy(candidate)
|
|
173
|
+
for candidate in self._workers
|
|
174
|
+
)
|
|
175
|
+
if still_running:
|
|
176
|
+
with self._lock:
|
|
177
|
+
self._pending_worker_stops[worker_id] = worker
|
|
178
|
+
else:
|
|
179
|
+
with self._lock:
|
|
180
|
+
self._workers.discard(worker)
|
|
181
|
+
try:
|
|
182
|
+
self.channel.send({"kind": "worker_stopped", "worker_id": worker_id})
|
|
183
|
+
except OSError:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
def request_restart(
|
|
187
|
+
self, reason: str, detail: dict[str, Any] | None = None
|
|
188
|
+
) -> None:
|
|
189
|
+
with self._lock:
|
|
190
|
+
if self._stopping or self._restart_requested:
|
|
191
|
+
return
|
|
192
|
+
self._restart_requested = True
|
|
193
|
+
try:
|
|
194
|
+
self.channel.send(
|
|
195
|
+
{"kind": "restart", "reason": reason[:128], "detail": detail or {}}
|
|
196
|
+
)
|
|
197
|
+
except (OSError, ValueError):
|
|
198
|
+
self._parent_lost()
|
|
199
|
+
|
|
200
|
+
def _report(self) -> None:
|
|
201
|
+
while not self._closed.wait(_HEALTH_INTERVAL):
|
|
202
|
+
with self._lock:
|
|
203
|
+
for loop in tuple(self._loops):
|
|
204
|
+
if loop.is_closed():
|
|
205
|
+
state = self._loops.pop(loop)
|
|
206
|
+
state["closed"] = True
|
|
207
|
+
if state.get("pulse") is not None:
|
|
208
|
+
state["pulse"].cancel()
|
|
209
|
+
loops = [
|
|
210
|
+
{"id": str(id(loop)), "last_tick": state["last_tick"]}
|
|
211
|
+
for loop, state in self._loops.items()
|
|
212
|
+
if not state["closed"] and loop.is_running()
|
|
213
|
+
][:_MAX_HEALTH_ITEMS]
|
|
214
|
+
workers = list(self._worker_snapshots.values())
|
|
215
|
+
business_workers = list(self._workers)
|
|
216
|
+
stopping = self._stopping
|
|
217
|
+
executions = []
|
|
218
|
+
for worker in business_workers:
|
|
219
|
+
watchdog = worker._watchdog
|
|
220
|
+
if watchdog is None:
|
|
221
|
+
continue
|
|
222
|
+
try:
|
|
223
|
+
external = watchdog.external_snapshot()
|
|
224
|
+
except (AttributeError, RuntimeError, TypeError, ValueError):
|
|
225
|
+
external = {"unknown": True}
|
|
226
|
+
if external is not None:
|
|
227
|
+
executions.append({**external, "sampled_at": time.monotonic()})
|
|
228
|
+
# Prioritize actual earliest deadlines, bounding the health frame
|
|
229
|
+
# independently of the number of Workers (not a per-task channel).
|
|
230
|
+
executions.sort(key=lambda item: item.get("deadline", float("inf")))
|
|
231
|
+
executions = executions[:_MAX_HEALTH_ITEMS]
|
|
232
|
+
with self._lock:
|
|
233
|
+
pending_stops = self._pending_worker_stops.copy()
|
|
234
|
+
finished = {
|
|
235
|
+
worker_id
|
|
236
|
+
for worker_id, retiring in pending_stops.items()
|
|
237
|
+
if not self._worker_busy(retiring)
|
|
238
|
+
}
|
|
239
|
+
with self._lock:
|
|
240
|
+
for worker_id in finished:
|
|
241
|
+
self._pending_worker_stops.pop(worker_id, None)
|
|
242
|
+
self._workers.discard(pending_stops[worker_id])
|
|
243
|
+
try:
|
|
244
|
+
for worker_id in finished:
|
|
245
|
+
self.channel.send(
|
|
246
|
+
{"kind": "worker_stopped", "worker_id": worker_id}
|
|
247
|
+
)
|
|
248
|
+
self.channel.send(
|
|
249
|
+
{
|
|
250
|
+
"kind": "health",
|
|
251
|
+
"loops": loops,
|
|
252
|
+
"workers": workers,
|
|
253
|
+
"executions": executions,
|
|
254
|
+
"stopping": stopping,
|
|
255
|
+
}
|
|
256
|
+
)
|
|
257
|
+
except (OSError, ValueError):
|
|
258
|
+
self._parent_lost()
|
|
259
|
+
return
|
|
260
|
+
# Do not let a WeakSet snapshot retain retired Workers forever or
|
|
261
|
+
# release a pending identity before its stop has reached the wire.
|
|
262
|
+
if business_workers:
|
|
263
|
+
del worker, watchdog
|
|
264
|
+
del business_workers, pending_stops
|
|
265
|
+
|
|
266
|
+
def _control(self) -> None:
|
|
267
|
+
try:
|
|
268
|
+
while not self._closed.is_set():
|
|
269
|
+
message = self.channel.receive()
|
|
270
|
+
if message is None:
|
|
271
|
+
self._parent_lost()
|
|
272
|
+
return
|
|
273
|
+
if message.get("kind") == "stop":
|
|
274
|
+
self._request_stop()
|
|
275
|
+
except (OSError, ValueError):
|
|
276
|
+
if not self._closed.is_set():
|
|
277
|
+
self._parent_lost()
|
|
278
|
+
|
|
279
|
+
def _request_stop(self) -> None:
|
|
280
|
+
with self._lock:
|
|
281
|
+
self._stopping = True
|
|
282
|
+
states = list(self._loops.items())
|
|
283
|
+
for loop, state in states:
|
|
284
|
+
if loop.is_closed():
|
|
285
|
+
continue
|
|
286
|
+
try:
|
|
287
|
+
loop.call_soon_threadsafe(state["stop"].set)
|
|
288
|
+
loop.call_soon_threadsafe(self._schedule_drain, loop, state)
|
|
289
|
+
except RuntimeError:
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
def _schedule_drain(
|
|
293
|
+
self, loop: asyncio.AbstractEventLoop, state: dict[str, Any]
|
|
294
|
+
) -> None:
|
|
295
|
+
if state["drain"] is None and not loop.is_closed():
|
|
296
|
+
state["drain"] = loop.create_task(
|
|
297
|
+
self._drain_loop(state), name="ringo-supervisor-drain"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
async def _drain_loop(self, state: dict[str, Any]) -> None:
|
|
301
|
+
with self._lock:
|
|
302
|
+
clients = list(state["clients"])
|
|
303
|
+
# Start all Worker close fences in one event-loop turn before waiting.
|
|
304
|
+
# Client.close otherwise drains its Workers sequentially.
|
|
305
|
+
workers = {worker for client in clients for worker in list(client._workers)}
|
|
306
|
+
if workers:
|
|
307
|
+
await asyncio.gather(
|
|
308
|
+
*(worker.close() for worker in workers), return_exceptions=True
|
|
309
|
+
)
|
|
310
|
+
if clients:
|
|
311
|
+
await asyncio.gather(
|
|
312
|
+
*(client.close() for client in clients), return_exceptions=True
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def _parent_lost(self) -> None:
|
|
316
|
+
with self._lock:
|
|
317
|
+
if self._closed.is_set() or self._orphan_timer is not None:
|
|
318
|
+
return
|
|
319
|
+
self._orphaned = True
|
|
320
|
+
timer = threading.Timer(
|
|
321
|
+
max(0.05, self.policy.shutdown_grace.total_seconds()), self._orphan_exit
|
|
322
|
+
)
|
|
323
|
+
timer.daemon = True
|
|
324
|
+
self._orphan_timer = timer
|
|
325
|
+
self._request_stop()
|
|
326
|
+
timer.start()
|
|
327
|
+
|
|
328
|
+
@staticmethod
|
|
329
|
+
def _orphan_exit() -> None:
|
|
330
|
+
# POSIX children are bootstrapped in a dedicated session. Shared-local
|
|
331
|
+
# daemons start in another session and are deliberately not signalled.
|
|
332
|
+
if os.name != "nt":
|
|
333
|
+
try:
|
|
334
|
+
os.killpg(os.getpgrp(), signal.SIGKILL) # type: ignore[attr-defined]
|
|
335
|
+
except OSError:
|
|
336
|
+
pass
|
|
337
|
+
os._exit(1)
|
|
338
|
+
|
|
339
|
+
async def run_awaitable(self, awaitable: Any) -> Any:
|
|
340
|
+
state = self._loop_state()
|
|
341
|
+
|
|
342
|
+
async def application():
|
|
343
|
+
# SystemExit escaping an asyncio Task has special loop-abort
|
|
344
|
+
# behavior. Normalize it inside the application boundary so an
|
|
345
|
+
# intentional exit(0) cannot race a spurious crash notification.
|
|
346
|
+
try:
|
|
347
|
+
return await awaitable
|
|
348
|
+
except SystemExit as exc:
|
|
349
|
+
return (
|
|
350
|
+
exc.code
|
|
351
|
+
if isinstance(exc.code, int)
|
|
352
|
+
else (0 if exc.code is None else 1)
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
main_task = asyncio.create_task(application())
|
|
356
|
+
stopped = asyncio.create_task(state["stop"].wait())
|
|
357
|
+
try:
|
|
358
|
+
done, _ = await asyncio.wait(
|
|
359
|
+
(main_task, stopped), return_when=asyncio.FIRST_COMPLETED
|
|
360
|
+
)
|
|
361
|
+
if stopped in done or self._stopping:
|
|
362
|
+
# Even if main completed in the same turn, honor the already
|
|
363
|
+
# started generation-wide drain before asyncio.run shuts down.
|
|
364
|
+
self._schedule_drain(asyncio.get_running_loop(), state)
|
|
365
|
+
await state["drain"]
|
|
366
|
+
if not main_task.done():
|
|
367
|
+
main_task.cancel()
|
|
368
|
+
await asyncio.gather(main_task, return_exceptions=True)
|
|
369
|
+
result = None
|
|
370
|
+
elif main_task.cancelled():
|
|
371
|
+
result = None
|
|
372
|
+
else:
|
|
373
|
+
result = await main_task
|
|
374
|
+
else:
|
|
375
|
+
result = await main_task
|
|
376
|
+
# Bound asyncio.run's shutdown too: leftover application tasks can
|
|
377
|
+
# swallow cancellation after the entrypoint has already returned.
|
|
378
|
+
code = (
|
|
379
|
+
result
|
|
380
|
+
if isinstance(result, int) and not isinstance(result, bool)
|
|
381
|
+
else 0
|
|
382
|
+
)
|
|
383
|
+
self.channel.send({"kind": "entrypoint_done", "exit_code": code})
|
|
384
|
+
return result
|
|
385
|
+
except BaseException:
|
|
386
|
+
try:
|
|
387
|
+
self.channel.send({"kind": "entrypoint_failed"})
|
|
388
|
+
except OSError:
|
|
389
|
+
pass
|
|
390
|
+
raise
|
|
391
|
+
finally:
|
|
392
|
+
stopped.cancel()
|
|
393
|
+
await asyncio.gather(stopped, return_exceptions=True)
|
|
394
|
+
|
|
395
|
+
def invoke(self, function: Any) -> int:
|
|
396
|
+
if inspect.iscoroutinefunction(function):
|
|
397
|
+
# Construct the coroutine once; no recursive launcher or pickling.
|
|
398
|
+
result = asyncio.run(self.run_awaitable(function()))
|
|
399
|
+
else:
|
|
400
|
+
# A synchronous entrypoint may itself call asyncio.run(). SDK
|
|
401
|
+
# client registration attaches lifecycle hooks to that actual loop.
|
|
402
|
+
result = function()
|
|
403
|
+
if inspect.isawaitable(result):
|
|
404
|
+
result = asyncio.run(self.run_awaitable(result))
|
|
405
|
+
code = result if isinstance(result, int) and not isinstance(result, bool) else 0
|
|
406
|
+
self.channel.send({"kind": "entrypoint_done", "exit_code": code})
|
|
407
|
+
return code
|
|
408
|
+
|
|
409
|
+
def close(self) -> None:
|
|
410
|
+
global _CURRENT
|
|
411
|
+
self._closed.set()
|
|
412
|
+
if self._orphan_timer is not None:
|
|
413
|
+
self._orphan_timer.cancel()
|
|
414
|
+
if _CURRENT is self:
|
|
415
|
+
_CURRENT = None
|
|
416
|
+
if self._orphaned:
|
|
417
|
+
# A cooperative main can finish *before* the orphan timer fires.
|
|
418
|
+
# Do not let its exit cancel the only remaining POSIX tree owner.
|
|
419
|
+
self._orphan_exit()
|
|
420
|
+
self.channel.close()
|