ringo-task-queue 0.1.0.dev8__py3-none-macosx_13_0_arm64.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,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()