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
ringo_task_queue/client.py
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import os
|
|
7
7
|
import secrets
|
|
8
|
+
import sys
|
|
8
9
|
from datetime import timedelta
|
|
9
10
|
from itertools import islice
|
|
10
11
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, Iterable, Literal
|
|
@@ -14,6 +15,7 @@ from grpc_health.v1 import health_pb2, health_pb2_grpc
|
|
|
14
15
|
|
|
15
16
|
from . import _pb
|
|
16
17
|
from ._proto.ringo.v1 import queue_pb2, queue_pb2_grpc
|
|
18
|
+
from ._supervisor_policy import TaskTimeouts
|
|
17
19
|
from ._version import PROTOCOL_MAJOR, PROTOCOL_MINOR, SDK_VERSION
|
|
18
20
|
from .concurrency import AdaptiveConcurrency, ResourceConcurrency
|
|
19
21
|
from .errors import (
|
|
@@ -172,9 +174,7 @@ class _Connection:
|
|
|
172
174
|
):
|
|
173
175
|
if key not in configured:
|
|
174
176
|
self._channel_options.append((key, value))
|
|
175
|
-
_check_compression_channel_option_conflict(
|
|
176
|
-
self._channel_options, compression
|
|
177
|
-
)
|
|
177
|
+
_check_compression_channel_option_conflict(self._channel_options, compression)
|
|
178
178
|
# Channel-level default compression; survives every reconnect because
|
|
179
179
|
# _open_channel() re-applies it to each new aio channel. ``None`` means
|
|
180
180
|
# unspecified, so legacy raw channel options decide the encoding.
|
|
@@ -192,6 +192,8 @@ class _Connection:
|
|
|
192
192
|
# -- lifecycle ---------------------------------------------------------
|
|
193
193
|
|
|
194
194
|
def _open_channel(self) -> None:
|
|
195
|
+
if self._closed:
|
|
196
|
+
raise RingoError("connection is closed")
|
|
195
197
|
if self._tls:
|
|
196
198
|
credentials = grpc.ssl_channel_credentials()
|
|
197
199
|
self._channel = grpc.aio.secure_channel(
|
|
@@ -226,10 +228,17 @@ class _Connection:
|
|
|
226
228
|
call = stub.AttachLocal(request, metadata=self._metadata())
|
|
227
229
|
response = await call.read()
|
|
228
230
|
if response is grpc.aio.EOF or response is None:
|
|
229
|
-
raise UnavailableError(
|
|
231
|
+
raise UnavailableError(
|
|
232
|
+
"shared daemon closed AttachLocal during handshake"
|
|
233
|
+
)
|
|
234
|
+
if self._closed:
|
|
235
|
+
call.cancel()
|
|
236
|
+
raise RingoError("connection is closed")
|
|
230
237
|
if response.generation != self._local_generation:
|
|
231
238
|
call.cancel()
|
|
232
|
-
raise UnavailableError(
|
|
239
|
+
raise UnavailableError(
|
|
240
|
+
"shared daemon generation changed during AttachLocal"
|
|
241
|
+
)
|
|
233
242
|
except grpc.aio.AioRpcError as exc:
|
|
234
243
|
raise map_rpc_error(exc) from exc
|
|
235
244
|
self._attach_call = call
|
|
@@ -285,6 +294,8 @@ class _Connection:
|
|
|
285
294
|
deadline = asyncio.get_running_loop().time() + timeout
|
|
286
295
|
last: Exception | None = None
|
|
287
296
|
while True:
|
|
297
|
+
if self._closed:
|
|
298
|
+
raise RingoError("connection is closed")
|
|
288
299
|
try:
|
|
289
300
|
response = await health_stub.Check(
|
|
290
301
|
health_pb2.HealthCheckRequest(),
|
|
@@ -302,6 +313,8 @@ class _Connection:
|
|
|
302
313
|
|
|
303
314
|
async def reset(self, endpoint: str) -> None:
|
|
304
315
|
"""Point the connection at a new endpoint (local daemon restart)."""
|
|
316
|
+
if self._closed:
|
|
317
|
+
raise RingoError("connection is closed")
|
|
305
318
|
await self._close_attach()
|
|
306
319
|
self._endpoint, self._tls = _parse_endpoint(endpoint)
|
|
307
320
|
channel, self._channel = self._channel, None
|
|
@@ -325,6 +338,8 @@ class _Connection:
|
|
|
325
338
|
)
|
|
326
339
|
except grpc.aio.AioRpcError as exc:
|
|
327
340
|
raise map_rpc_error(exc) from exc
|
|
341
|
+
if self._closed:
|
|
342
|
+
raise RingoError("connection is closed")
|
|
328
343
|
if response.protocol_major != PROTOCOL_MAJOR:
|
|
329
344
|
raise IncompatibleVersionError(
|
|
330
345
|
f"server speaks protocol major {response.protocol_major},"
|
|
@@ -344,7 +359,7 @@ class _Connection:
|
|
|
344
359
|
# -- helpers -----------------------------------------------------------
|
|
345
360
|
|
|
346
361
|
def _require_stub(self) -> queue_pb2_grpc.QueueServiceStub:
|
|
347
|
-
if self._stub is None:
|
|
362
|
+
if self._closed or self._stub is None:
|
|
348
363
|
raise RingoError("connection is closed")
|
|
349
364
|
return self._stub
|
|
350
365
|
|
|
@@ -379,7 +394,6 @@ class _Connection:
|
|
|
379
394
|
*,
|
|
380
395
|
timeout: float = DEFAULT_RPC_TIMEOUT,
|
|
381
396
|
retry_safe: bool = False,
|
|
382
|
-
context_attr: str | None = "context",
|
|
383
397
|
) -> Any:
|
|
384
398
|
"""Issue a single unary RPC.
|
|
385
399
|
|
|
@@ -391,9 +405,7 @@ class _Connection:
|
|
|
391
405
|
pass ``retry_safe=True`` and may be re-sent, each time with a freshly
|
|
392
406
|
negotiated ``RequestContext``.
|
|
393
407
|
"""
|
|
394
|
-
rpc, sent_token = await self._begin_unary(
|
|
395
|
-
name, request, timeout=timeout, context_attr=context_attr
|
|
396
|
-
)
|
|
408
|
+
rpc, sent_token = await self._begin_unary(name, request, timeout=timeout)
|
|
397
409
|
cause: BaseException | None = None
|
|
398
410
|
err: RingoError | None = None
|
|
399
411
|
try:
|
|
@@ -423,9 +435,7 @@ class _Connection:
|
|
|
423
435
|
async with self._lock:
|
|
424
436
|
rotated = self._session_token != sent_token
|
|
425
437
|
if rotated:
|
|
426
|
-
rpc, _ = await self._begin_unary(
|
|
427
|
-
name, request, timeout=timeout, context_attr=context_attr
|
|
428
|
-
)
|
|
438
|
+
rpc, _ = await self._begin_unary(name, request, timeout=timeout)
|
|
429
439
|
try:
|
|
430
440
|
return await rpc
|
|
431
441
|
except grpc.aio.AioRpcError as retry_exc:
|
|
@@ -439,9 +449,7 @@ class _Connection:
|
|
|
439
449
|
await self._recover_best_effort()
|
|
440
450
|
if not retry_safe:
|
|
441
451
|
raise err from cause
|
|
442
|
-
rpc, _ = await self._begin_unary(
|
|
443
|
-
name, request, timeout=timeout, context_attr=context_attr
|
|
444
|
-
)
|
|
452
|
+
rpc, _ = await self._begin_unary(name, request, timeout=timeout)
|
|
445
453
|
try:
|
|
446
454
|
return await rpc
|
|
447
455
|
except grpc.aio.AioRpcError as retry_exc:
|
|
@@ -453,7 +461,6 @@ class _Connection:
|
|
|
453
461
|
request: Any,
|
|
454
462
|
*,
|
|
455
463
|
timeout: float,
|
|
456
|
-
context_attr: str | None,
|
|
457
464
|
) -> tuple[Any, str]:
|
|
458
465
|
"""Snapshot a stub and fresh session context outside reconnect races."""
|
|
459
466
|
async with self._lock:
|
|
@@ -466,8 +473,12 @@ class _Connection:
|
|
|
466
473
|
"attempt history requires protocol minor >= 5 (GetTaskAttempts);"
|
|
467
474
|
f" negotiated minor is {self._session_minor}"
|
|
468
475
|
)
|
|
469
|
-
if
|
|
470
|
-
|
|
476
|
+
if name == "ProbeQueue" and self._session_minor < 7:
|
|
477
|
+
raise IncompatibleVersionError(
|
|
478
|
+
"queue eligibility probe requires protocol minor >= 7 (ProbeQueue);"
|
|
479
|
+
f" negotiated minor is {self._session_minor}"
|
|
480
|
+
)
|
|
481
|
+
request.context.CopyFrom(self.request_context())
|
|
471
482
|
stub = self._require_stub()
|
|
472
483
|
token = self._session_token
|
|
473
484
|
rpc = getattr(stub, name)(
|
|
@@ -545,7 +556,9 @@ class RingoClient:
|
|
|
545
556
|
self._daemon = daemon
|
|
546
557
|
self._shared_daemon = shared_daemon
|
|
547
558
|
if daemon is not None and shared_daemon is not None:
|
|
548
|
-
raise InvalidArgumentError(
|
|
559
|
+
raise InvalidArgumentError(
|
|
560
|
+
"daemon and shared_daemon are mutually exclusive"
|
|
561
|
+
)
|
|
549
562
|
# ``_conn`` is built in start(): a local client learns the daemon's
|
|
550
563
|
# random loopback port only after the daemon announces readiness.
|
|
551
564
|
self._conn: _Connection | None = None
|
|
@@ -553,6 +566,9 @@ class RingoClient:
|
|
|
553
566
|
self._workers: set[Worker] = set()
|
|
554
567
|
self._started = False
|
|
555
568
|
self._closed = False
|
|
569
|
+
# Start, reconnect and final resource cleanup share one lifecycle lane.
|
|
570
|
+
# Reconnect enters from the connection's session lock; cleanup must not
|
|
571
|
+
# acquire that lock while holding this one.
|
|
556
572
|
self._start_lock = asyncio.Lock()
|
|
557
573
|
self._close_lock = asyncio.Lock()
|
|
558
574
|
self._close_task: asyncio.Task[None] | None = None
|
|
@@ -560,10 +576,19 @@ class RingoClient:
|
|
|
560
576
|
|
|
561
577
|
# -- lifecycle ---------------------------------------------------------
|
|
562
578
|
|
|
579
|
+
def _check_open(self) -> None:
|
|
580
|
+
if self._closed:
|
|
581
|
+
raise RingoError("client is closed")
|
|
582
|
+
|
|
563
583
|
async def start(self) -> "RingoClient":
|
|
564
584
|
async with self._start_lock:
|
|
565
|
-
|
|
566
|
-
|
|
585
|
+
self._check_open()
|
|
586
|
+
# Register before allocating resources: a runtime STOP fence rejects
|
|
587
|
+
# late starts, and an in-flight start is visible to unified drain.
|
|
588
|
+
module = sys.modules.get("ringo_task_queue._supervisor_runtime")
|
|
589
|
+
runtime = None if module is None else module.current_runtime()
|
|
590
|
+
if runtime is not None:
|
|
591
|
+
runtime.register_client(self)
|
|
567
592
|
if not self._started:
|
|
568
593
|
try:
|
|
569
594
|
for attempt in range(2 if self._shared_daemon is not None else 1):
|
|
@@ -571,11 +596,20 @@ class RingoClient:
|
|
|
571
596
|
self._endpoint = await self._daemon.start()
|
|
572
597
|
elif self._shared_daemon is not None:
|
|
573
598
|
self._endpoint = await self._shared_daemon.start()
|
|
599
|
+
self._check_open()
|
|
574
600
|
conn = _Connection(
|
|
575
601
|
self._endpoint or "",
|
|
576
602
|
self._token,
|
|
577
|
-
local_token=(
|
|
578
|
-
|
|
603
|
+
local_token=(
|
|
604
|
+
self._shared_daemon.token
|
|
605
|
+
if self._shared_daemon
|
|
606
|
+
else None
|
|
607
|
+
),
|
|
608
|
+
local_generation=(
|
|
609
|
+
self._shared_daemon.generation
|
|
610
|
+
if self._shared_daemon
|
|
611
|
+
else None
|
|
612
|
+
),
|
|
579
613
|
channel_options=self._channel_options,
|
|
580
614
|
compression=self._compression,
|
|
581
615
|
)
|
|
@@ -589,13 +623,19 @@ class RingoClient:
|
|
|
589
623
|
)
|
|
590
624
|
try:
|
|
591
625
|
await conn.connect(
|
|
592
|
-
check_health=self._daemon is not None
|
|
626
|
+
check_health=self._daemon is not None
|
|
627
|
+
or self._shared_daemon is not None
|
|
593
628
|
)
|
|
629
|
+
self._check_open()
|
|
594
630
|
break
|
|
595
631
|
except Exception:
|
|
596
632
|
await conn.close()
|
|
597
633
|
self._conn = None
|
|
598
|
-
if
|
|
634
|
+
if (
|
|
635
|
+
self._closed
|
|
636
|
+
or self._shared_daemon is None
|
|
637
|
+
or attempt > 0
|
|
638
|
+
):
|
|
599
639
|
raise
|
|
600
640
|
# Stale PID/port, wrong token or generation is never
|
|
601
641
|
# accepted as identity. Re-elect once, boundedly.
|
|
@@ -620,34 +660,47 @@ class RingoClient:
|
|
|
620
660
|
return conn
|
|
621
661
|
|
|
622
662
|
async def _reconnect(self) -> None:
|
|
623
|
-
"""
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
663
|
+
"""Rotate resources in the lifecycle lane; close wins at every boundary."""
|
|
664
|
+
async with self._start_lock:
|
|
665
|
+
self._check_open()
|
|
666
|
+
conn = self._require_conn()
|
|
667
|
+
daemon = self._daemon
|
|
668
|
+
shared = self._shared_daemon
|
|
669
|
+
if daemon is not None and daemon.is_dead:
|
|
670
|
+
if self._daemon_restart_count >= _MAX_DAEMON_RESTARTS:
|
|
671
|
+
raise UnavailableError(
|
|
672
|
+
f"local daemon restart budget ({_MAX_DAEMON_RESTARTS}) exhausted"
|
|
673
|
+
)
|
|
674
|
+
self._daemon_restart_count += 1
|
|
675
|
+
delay = 0.3 * (2 ** (self._daemon_restart_count - 1))
|
|
676
|
+
await asyncio.sleep(delay)
|
|
677
|
+
self._check_open()
|
|
678
|
+
endpoint = await daemon.start()
|
|
679
|
+
self._check_open()
|
|
640
680
|
await conn.reset(endpoint)
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
681
|
+
self._check_open()
|
|
682
|
+
elif shared is not None:
|
|
683
|
+
try:
|
|
684
|
+
endpoint = await shared.refresh()
|
|
685
|
+
self._check_open()
|
|
686
|
+
await conn.reset(endpoint)
|
|
687
|
+
self._check_open()
|
|
688
|
+
conn.set_shared_session(
|
|
689
|
+
shared.token or "",
|
|
690
|
+
shared.generation or "",
|
|
691
|
+
self._shared_stream_lost,
|
|
692
|
+
)
|
|
693
|
+
await conn.attach_local()
|
|
694
|
+
self._check_open()
|
|
695
|
+
await conn.negotiate()
|
|
696
|
+
self._check_open()
|
|
697
|
+
except Exception:
|
|
698
|
+
if not self._closed:
|
|
699
|
+
shared.invalidate_current()
|
|
700
|
+
raise
|
|
701
|
+
return
|
|
702
|
+
await conn.negotiate()
|
|
703
|
+
self._check_open()
|
|
651
704
|
|
|
652
705
|
async def _shared_stream_lost(self) -> None:
|
|
653
706
|
"""Proactively reconnect an idle shared client after AttachLocal EOF."""
|
|
@@ -662,7 +715,9 @@ class RingoClient:
|
|
|
662
715
|
if self._closed:
|
|
663
716
|
return
|
|
664
717
|
try:
|
|
665
|
-
await asyncio.sleep(
|
|
718
|
+
await asyncio.sleep(
|
|
719
|
+
_BACKOFF_SECONDS[min(attempt, len(_BACKOFF_SECONDS) - 1)]
|
|
720
|
+
)
|
|
666
721
|
async with conn._lock:
|
|
667
722
|
await self._reconnect()
|
|
668
723
|
return
|
|
@@ -694,18 +749,30 @@ class RingoClient:
|
|
|
694
749
|
except Exception as exc: # keep cleaning up the rest
|
|
695
750
|
errors.append(exc)
|
|
696
751
|
self._workers.clear()
|
|
697
|
-
# 2.
|
|
698
|
-
|
|
752
|
+
# 2. Fence the current channel first: this interrupts a pending handshake
|
|
753
|
+
# without acquiring the session lock (reconnect holds it). Work remains
|
|
754
|
+
# usable through the worker grace period above.
|
|
755
|
+
conn = self._conn
|
|
699
756
|
if conn is not None:
|
|
700
757
|
try:
|
|
701
758
|
await conn.close()
|
|
702
759
|
except Exception as exc:
|
|
703
760
|
errors.append(exc)
|
|
704
|
-
# 3.
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
761
|
+
# 3. An in-progress daemon.start cannot be abandoned. Wait for its
|
|
762
|
+
# bounded completion, then reconcile resources under the lifecycle lane.
|
|
763
|
+
# Closed checks prevent it from publishing a new channel in the meantime.
|
|
764
|
+
async with self._start_lock:
|
|
765
|
+
conn, self._conn = self._conn, None
|
|
766
|
+
if conn is not None:
|
|
767
|
+
try:
|
|
768
|
+
await conn.close()
|
|
769
|
+
except Exception as exc:
|
|
770
|
+
errors.append(exc)
|
|
771
|
+
try:
|
|
772
|
+
await self._stop_daemon()
|
|
773
|
+
except Exception as exc:
|
|
774
|
+
errors.append(exc)
|
|
775
|
+
self._started = False
|
|
709
776
|
if errors:
|
|
710
777
|
raise ExceptionGroup("errors while closing Ringo client", errors)
|
|
711
778
|
|
|
@@ -857,7 +924,6 @@ class TaskQueue:
|
|
|
857
924
|
if spec is None:
|
|
858
925
|
spec = TaskSpec(**kwargs)
|
|
859
926
|
request = queue_pb2.EnqueueRequest(queue=self.name, task=spec.to_proto())
|
|
860
|
-
request.context.CopyFrom(self._client._require_conn().request_context())
|
|
861
927
|
response = await self._client._require_conn().call("Enqueue", request)
|
|
862
928
|
return EnqueueResult.from_proto(response.result)
|
|
863
929
|
|
|
@@ -882,7 +948,6 @@ class TaskQueue:
|
|
|
882
948
|
request = queue_pb2.EnqueueManyRequest(
|
|
883
949
|
queue=self.name, tasks=[s.to_proto() for s in chunk]
|
|
884
950
|
)
|
|
885
|
-
request.context.CopyFrom(self._client._require_conn().request_context())
|
|
886
951
|
response = await self._client._require_conn().call("EnqueueMany", request)
|
|
887
952
|
all_results.extend(EnqueueResult.from_proto(r) for r in response.results)
|
|
888
953
|
total_created += response.created_count
|
|
@@ -900,7 +965,6 @@ class TaskQueue:
|
|
|
900
965
|
if not task_id:
|
|
901
966
|
raise InvalidArgumentError("task_id is required")
|
|
902
967
|
request = queue_pb2.GetTaskRequest(queue=self.name, task_id=task_id)
|
|
903
|
-
request.context.CopyFrom(self._client._require_conn().request_context())
|
|
904
968
|
response = await self._client._require_conn().call(
|
|
905
969
|
"GetTask", request, retry_safe=True
|
|
906
970
|
)
|
|
@@ -910,7 +974,6 @@ class TaskQueue:
|
|
|
910
974
|
if not unique_key:
|
|
911
975
|
raise InvalidArgumentError("unique_key is required")
|
|
912
976
|
request = queue_pb2.GetTaskRequest(queue=self.name, unique_key=unique_key)
|
|
913
|
-
request.context.CopyFrom(self._client._require_conn().request_context())
|
|
914
977
|
response = await self._client._require_conn().call(
|
|
915
978
|
"GetTask", request, retry_safe=True
|
|
916
979
|
)
|
|
@@ -1009,7 +1072,6 @@ class TaskQueue:
|
|
|
1009
1072
|
request = queue_pb2.GetQueueStatsRequest(
|
|
1010
1073
|
queue=self.name, task_types=selected or []
|
|
1011
1074
|
)
|
|
1012
|
-
request.context.CopyFrom(conn.request_context())
|
|
1013
1075
|
response = await conn.call("GetQueueStats", request, retry_safe=True)
|
|
1014
1076
|
return QueueStats.from_proto(response)
|
|
1015
1077
|
|
|
@@ -1023,7 +1085,6 @@ class TaskQueue:
|
|
|
1023
1085
|
raise InvalidArgumentError("unique_key is required")
|
|
1024
1086
|
request = queue_pb2.RetryTaskRequest(queue=self.name)
|
|
1025
1087
|
request.unique_key = unique_key
|
|
1026
|
-
request.context.CopyFrom(self._client._require_conn().request_context())
|
|
1027
1088
|
response = await self._client._require_conn().call("RetryTask", request)
|
|
1028
1089
|
return Task.from_proto(response.task)
|
|
1029
1090
|
|
|
@@ -1033,7 +1094,6 @@ class TaskQueue:
|
|
|
1033
1094
|
raise InvalidArgumentError("task_id is required")
|
|
1034
1095
|
request = queue_pb2.RetryTaskRequest(queue=self.name)
|
|
1035
1096
|
request.task_id = task_id
|
|
1036
|
-
request.context.CopyFrom(self._client._require_conn().request_context())
|
|
1037
1097
|
response = await self._client._require_conn().call("RetryTask", request)
|
|
1038
1098
|
return Task.from_proto(response.task)
|
|
1039
1099
|
|
|
@@ -1067,7 +1127,6 @@ class TaskQueue:
|
|
|
1067
1127
|
request.wait_timeout.CopyFrom(_pb.td_to_dur(wait_timeout))
|
|
1068
1128
|
if lease_duration is not None:
|
|
1069
1129
|
request.lease_duration.CopyFrom(_pb.td_to_dur(lease_duration))
|
|
1070
|
-
request.context.CopyFrom(self._client._require_conn().request_context())
|
|
1071
1130
|
response = await self._client._require_conn().call("Claim", request)
|
|
1072
1131
|
return [LeasedTask(self, t) for t in response.tasks]
|
|
1073
1132
|
|
|
@@ -1083,6 +1142,7 @@ class TaskQueue:
|
|
|
1083
1142
|
lease_duration: timedelta = timedelta(seconds=60),
|
|
1084
1143
|
grace_period: timedelta = timedelta(seconds=30),
|
|
1085
1144
|
on_idle: Callable[[], Any] | None = None,
|
|
1145
|
+
task_timeouts: TaskTimeouts | None = None,
|
|
1086
1146
|
) -> "Worker":
|
|
1087
1147
|
"""Create a credit-controlled worker over the frozen Work stream.
|
|
1088
1148
|
|
|
@@ -1114,7 +1174,9 @@ class TaskQueue:
|
|
|
1114
1174
|
raise InvalidArgumentError(
|
|
1115
1175
|
"concurrency must be an integer, ResourceConcurrency, or AdaptiveConcurrency"
|
|
1116
1176
|
)
|
|
1117
|
-
if isinstance(concurrency, int) and (
|
|
1177
|
+
if isinstance(concurrency, int) and (
|
|
1178
|
+
concurrency < 1 or concurrency > 2**31 - 1
|
|
1179
|
+
):
|
|
1118
1180
|
raise InvalidArgumentError(
|
|
1119
1181
|
"initial fixed concurrency must be between 1 and 2**31-1"
|
|
1120
1182
|
)
|
|
@@ -1124,6 +1186,8 @@ class TaskQueue:
|
|
|
1124
1186
|
raise InvalidArgumentError("lease_duration must not be negative")
|
|
1125
1187
|
if grace_period < timedelta(0):
|
|
1126
1188
|
raise InvalidArgumentError("grace_period must not be negative")
|
|
1189
|
+
if task_timeouts is not None and not isinstance(task_timeouts, TaskTimeouts):
|
|
1190
|
+
raise InvalidArgumentError("task_timeouts must be TaskTimeouts or None")
|
|
1127
1191
|
worker = Worker(
|
|
1128
1192
|
self,
|
|
1129
1193
|
handler,
|
|
@@ -1133,6 +1197,7 @@ class TaskQueue:
|
|
|
1133
1197
|
grace_period=grace_period,
|
|
1134
1198
|
on_idle=on_idle,
|
|
1135
1199
|
weight=weight,
|
|
1200
|
+
task_timeouts=task_timeouts,
|
|
1136
1201
|
)
|
|
1137
1202
|
self._client._register_worker(worker)
|
|
1138
1203
|
return worker
|
|
@@ -1167,7 +1232,6 @@ class LeasedTask:
|
|
|
1167
1232
|
async def _terminal(self, name: str, request: Any) -> None:
|
|
1168
1233
|
request.lease.CopyFrom(self._ref())
|
|
1169
1234
|
conn = self._queue._client._require_conn()
|
|
1170
|
-
request.context.CopyFrom(conn.request_context())
|
|
1171
1235
|
await conn.call(name, request)
|
|
1172
1236
|
|
|
1173
1237
|
async def ack(self) -> None:
|
|
@@ -1193,6 +1257,5 @@ class LeasedTask:
|
|
|
1193
1257
|
request.extension.CopyFrom(_pb.td_to_dur(extension))
|
|
1194
1258
|
request.lease.CopyFrom(self._ref())
|
|
1195
1259
|
conn = self._queue._client._require_conn()
|
|
1196
|
-
request.context.CopyFrom(conn.request_context())
|
|
1197
1260
|
response = await conn.call("ExtendLease", request)
|
|
1198
1261
|
self._lease_until = _pb.ts_to_dt(response.lease.until)
|
ringo_task_queue/shared.py
CHANGED
|
@@ -8,6 +8,7 @@ import os
|
|
|
8
8
|
import re
|
|
9
9
|
import stat
|
|
10
10
|
import subprocess
|
|
11
|
+
import threading
|
|
11
12
|
import time
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
from typing import Any, cast
|
|
@@ -131,6 +132,46 @@ def validate_shared_state(value: object) -> dict[str, object] | None:
|
|
|
131
132
|
return value
|
|
132
133
|
|
|
133
134
|
|
|
135
|
+
class _DetachedProcess:
|
|
136
|
+
"""Observe a shared child without giving an event loop kill ownership.
|
|
137
|
+
|
|
138
|
+
asyncio subprocess transports kill running children when finalized. A
|
|
139
|
+
shared daemon must instead outlive its launcher's loop, object and process.
|
|
140
|
+
The daemon reaper retains Popen until natural exit, reaping POSIX zombies
|
|
141
|
+
without joining the interpreter or its default asyncio executor.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def __init__(self, process: subprocess.Popen[bytes]) -> None:
|
|
145
|
+
self._process = process
|
|
146
|
+
threading.Thread(
|
|
147
|
+
target=process.wait,
|
|
148
|
+
name=f"ringo-shared-reaper-{process.pid}",
|
|
149
|
+
daemon=True,
|
|
150
|
+
).start()
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def spawn(cls, command: list[str], **options: Any) -> _DetachedProcess:
|
|
154
|
+
return cls(subprocess.Popen(command, **options))
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def pid(self) -> int:
|
|
158
|
+
return self._process.pid
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def returncode(self) -> int | None:
|
|
162
|
+
return self._process.poll()
|
|
163
|
+
|
|
164
|
+
def kill(self) -> None:
|
|
165
|
+
# Explicit test/recovery callers still target this exact Popen child.
|
|
166
|
+
# Neither object finalization nor SharedLocalDaemon.stop calls this.
|
|
167
|
+
self._process.kill()
|
|
168
|
+
|
|
169
|
+
async def wait(self) -> int:
|
|
170
|
+
while (code := self._process.poll()) is None:
|
|
171
|
+
await asyncio.sleep(0.05)
|
|
172
|
+
return code
|
|
173
|
+
|
|
174
|
+
|
|
134
175
|
class SharedLocalDaemon:
|
|
135
176
|
"""Discover or start a shared-local daemon without owning its process.
|
|
136
177
|
|
|
@@ -149,7 +190,7 @@ class SharedLocalDaemon:
|
|
|
149
190
|
self.data_dir = Path(data_dir).absolute()
|
|
150
191
|
self._daemon_path = daemon_path
|
|
151
192
|
self._startup_timeout = startup_timeout
|
|
152
|
-
self._launcher:
|
|
193
|
+
self._launcher: _DetachedProcess | None = None
|
|
153
194
|
self.endpoint: str | None = None
|
|
154
195
|
self.token: str | None = None
|
|
155
196
|
self.generation: str | None = None
|
|
@@ -240,18 +281,29 @@ class SharedLocalDaemon:
|
|
|
240
281
|
# DEVNULL is intentional: no lifecycle stdin and no inherited pipe
|
|
241
282
|
# can keep either process alive or block after this SDK exits.
|
|
242
283
|
detach: dict[str, Any] = (
|
|
243
|
-
{"creationflags":
|
|
284
|
+
{"creationflags": (
|
|
285
|
+
subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
|
|
286
|
+
| (subprocess.CREATE_BREAKAWAY_FROM_JOB # type: ignore[attr-defined]
|
|
287
|
+
if os.environ.get("RINGO_SUPERVISOR_JOB") == "1" else 0)
|
|
288
|
+
)}
|
|
244
289
|
if os.name == "nt" else {"start_new_session": True}
|
|
245
290
|
)
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
291
|
+
# No asyncio transport: its finalizer would kill a still-running
|
|
292
|
+
# shared daemon when this client/loop is collected or exits.
|
|
293
|
+
self._launcher = _DetachedProcess.spawn(
|
|
294
|
+
command,
|
|
295
|
+
stdin=subprocess.DEVNULL,
|
|
296
|
+
stdout=subprocess.DEVNULL,
|
|
297
|
+
stderr=subprocess.DEVNULL,
|
|
298
|
+
close_fds=True,
|
|
251
299
|
**detach,
|
|
252
300
|
)
|
|
253
301
|
except OSError as exc:
|
|
254
|
-
|
|
302
|
+
required = (
|
|
303
|
+
" (supervisor Job breakaway required; no inherited-Job fallback)"
|
|
304
|
+
if os.name == "nt" and os.environ.get("RINGO_SUPERVISOR_JOB") == "1" else ""
|
|
305
|
+
)
|
|
306
|
+
raise RingoError(f"failed to spawn shared daemon {binary}{required}: {exc}") from exc
|
|
255
307
|
|
|
256
308
|
async def _endpoint_reachable(self, endpoint: str) -> bool:
|
|
257
309
|
try:
|