ringo-task-queue 0.1.0.dev0__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 +68 -0
- ringo_task_queue/_pb.py +154 -0
- ringo_task_queue/_proto/ringo/v1/queue_pb2.py +133 -0
- ringo_task_queue/_proto/ringo/v1/queue_pb2_grpc.py +574 -0
- ringo_task_queue/_version.py +13 -0
- ringo_task_queue/bin/manifest.json +20 -0
- ringo_task_queue/bin/ringo-task-queue-windows-amd64.exe +0 -0
- ringo_task_queue/binary.py +253 -0
- ringo_task_queue/client.py +863 -0
- ringo_task_queue/daemon.py +507 -0
- ringo_task_queue/errors.py +152 -0
- ringo_task_queue/models.py +364 -0
- ringo_task_queue/py.typed +0 -0
- ringo_task_queue/worker.py +785 -0
- ringo_task_queue-0.1.0.dev0.dist-info/METADATA +445 -0
- ringo_task_queue-0.1.0.dev0.dist-info/RECORD +18 -0
- ringo_task_queue-0.1.0.dev0.dist-info/WHEEL +4 -0
- ringo_task_queue-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
"""Connection management and the user-facing client/queue APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, Iterable, Literal
|
|
10
|
+
|
|
11
|
+
import grpc
|
|
12
|
+
from grpc_health.v1 import health_pb2, health_pb2_grpc
|
|
13
|
+
|
|
14
|
+
from . import _pb
|
|
15
|
+
from ._proto.ringo.v1 import queue_pb2, queue_pb2_grpc
|
|
16
|
+
from ._version import PROTOCOL_MAJOR, PROTOCOL_MINOR, SDK_VERSION
|
|
17
|
+
from .errors import (
|
|
18
|
+
IncompatibleVersionError,
|
|
19
|
+
InvalidArgumentError,
|
|
20
|
+
RingoError,
|
|
21
|
+
UnavailableError,
|
|
22
|
+
map_rpc_error,
|
|
23
|
+
)
|
|
24
|
+
from .models import (
|
|
25
|
+
BatchResult,
|
|
26
|
+
EnqueueResult,
|
|
27
|
+
PostgresStorage,
|
|
28
|
+
SQLiteStorage,
|
|
29
|
+
Task,
|
|
30
|
+
TaskSpec,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
34
|
+
from .daemon import LocalDaemon
|
|
35
|
+
from .worker import Worker
|
|
36
|
+
|
|
37
|
+
SDK_NAME = "ringo-python"
|
|
38
|
+
|
|
39
|
+
DEFAULT_RPC_TIMEOUT = 30.0
|
|
40
|
+
_MAX_ENQUEUE_BATCH = 1000
|
|
41
|
+
_RECONNECT_ATTEMPTS = 3
|
|
42
|
+
_BACKOFF_SECONDS = (0.2, 0.6, 1.5)
|
|
43
|
+
|
|
44
|
+
#: Supported values for the remote ``compression`` option: ``"none"``
|
|
45
|
+
#: (the default) or ``"gzip"``. ``None`` means "not specified": the channel
|
|
46
|
+
#: default applies and a legacy raw ``channel_options`` compression entry
|
|
47
|
+
#: keeps its meaning. The runtime validation in
|
|
48
|
+
#: :func:`_normalize_compression` still rejects any other value for dynamic
|
|
49
|
+
#: callers.
|
|
50
|
+
CompressionOption = Literal["none", "gzip"]
|
|
51
|
+
_COMPRESSION_CHOICES: tuple[str, ...] = ("none", "gzip")
|
|
52
|
+
#: gRPC channel option that controls the same thing as ``compression``. An
|
|
53
|
+
#: explicit ``compression`` (even ``"none"``) combined with this raw option is
|
|
54
|
+
#: an ambiguity error, mirroring the Node SDK.
|
|
55
|
+
_COMPRESSION_CHANNEL_OPTION = "grpc.default_compression_algorithm"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _normalize_compression(value: CompressionOption | None) -> grpc.Compression | None:
|
|
59
|
+
"""Map the public ``compression`` option onto ``grpc.Compression``.
|
|
60
|
+
|
|
61
|
+
``None`` (unspecified) maps to ``None`` so the channel default applies.
|
|
62
|
+
Anything else outside ``_COMPRESSION_CHOICES`` is a clear
|
|
63
|
+
``InvalidArgumentError`` raised before any network activity.
|
|
64
|
+
"""
|
|
65
|
+
if value is None:
|
|
66
|
+
return None
|
|
67
|
+
if not isinstance(value, str) or value not in _COMPRESSION_CHOICES:
|
|
68
|
+
raise InvalidArgumentError(
|
|
69
|
+
f"unsupported compression {value!r}; expected one of"
|
|
70
|
+
f" {', '.join(_COMPRESSION_CHOICES)}"
|
|
71
|
+
)
|
|
72
|
+
if value == "gzip":
|
|
73
|
+
return grpc.Compression.Gzip
|
|
74
|
+
return grpc.Compression.NoCompression
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _check_compression_channel_option_conflict(
|
|
78
|
+
channel_options: Iterable[tuple[str, Any]] | None,
|
|
79
|
+
compression: grpc.Compression | None,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Reject an explicit ``compression`` alongside the raw gRPC option.
|
|
82
|
+
|
|
83
|
+
When ``compression`` is unspecified (``None``), raw ``channel_options``
|
|
84
|
+
keep their legacy meaning for advanced users.
|
|
85
|
+
"""
|
|
86
|
+
if compression is None:
|
|
87
|
+
return
|
|
88
|
+
for key, _ in channel_options or ():
|
|
89
|
+
if key == _COMPRESSION_CHANNEL_OPTION:
|
|
90
|
+
raise InvalidArgumentError(
|
|
91
|
+
f"compression and channel_options[{_COMPRESSION_CHANNEL_OPTION!r}]"
|
|
92
|
+
" must not both be set"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _consume_task_exception(task: asyncio.Task[Any]) -> None:
|
|
97
|
+
if not task.cancelled():
|
|
98
|
+
task.exception()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _parse_endpoint(endpoint: str) -> tuple[str, bool]:
|
|
102
|
+
"""Return ``(authority, use_tls)`` for an endpoint string."""
|
|
103
|
+
if not endpoint:
|
|
104
|
+
raise InvalidArgumentError("endpoint is required")
|
|
105
|
+
use_tls = False
|
|
106
|
+
rest = endpoint
|
|
107
|
+
for scheme, tls in (
|
|
108
|
+
("grpc://", False),
|
|
109
|
+
("http://", False),
|
|
110
|
+
("grpcs://", True),
|
|
111
|
+
("https://", True),
|
|
112
|
+
):
|
|
113
|
+
if rest.startswith(scheme):
|
|
114
|
+
rest = rest[len(scheme) :]
|
|
115
|
+
use_tls = tls
|
|
116
|
+
break
|
|
117
|
+
rest = rest.rstrip("/")
|
|
118
|
+
if ":" not in rest:
|
|
119
|
+
rest = f"{rest}:7233"
|
|
120
|
+
return rest, use_tls
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
#: Bounded automatic restarts of a crashed local daemon (design §31).
|
|
124
|
+
_MAX_DAEMON_RESTARTS = 3
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class _Connection:
|
|
128
|
+
"""Owns the grpc.aio channel, negotiation, and retried unary calls."""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
endpoint: str,
|
|
133
|
+
token: str | None = None,
|
|
134
|
+
*,
|
|
135
|
+
channel_options: Iterable[tuple[str, Any]] | None = None,
|
|
136
|
+
compression: grpc.Compression | None = None,
|
|
137
|
+
) -> None:
|
|
138
|
+
self._endpoint, self._tls = _parse_endpoint(endpoint)
|
|
139
|
+
self._token = token
|
|
140
|
+
self._channel_options = list(channel_options or ())
|
|
141
|
+
_check_compression_channel_option_conflict(
|
|
142
|
+
self._channel_options, compression
|
|
143
|
+
)
|
|
144
|
+
# Channel-level default compression; survives every reconnect because
|
|
145
|
+
# _open_channel() re-applies it to each new aio channel. ``None`` means
|
|
146
|
+
# unspecified, so legacy raw channel options decide the encoding.
|
|
147
|
+
self._compression = compression
|
|
148
|
+
self._channel: grpc.aio.Channel | None = None
|
|
149
|
+
self._stub: queue_pb2_grpc.QueueServiceStub | None = None
|
|
150
|
+
self._session_minor = PROTOCOL_MINOR
|
|
151
|
+
self._session_token = ""
|
|
152
|
+
self._lock = asyncio.Lock()
|
|
153
|
+
self._closed = False
|
|
154
|
+
#: Optional client-provided reconnect hook (daemon restart + channel
|
|
155
|
+
#: rebuild). Invoked under ``_lock`` instead of plain ``negotiate``.
|
|
156
|
+
self.on_reconnect: Callable[[], Awaitable[None]] | None = None
|
|
157
|
+
|
|
158
|
+
# -- lifecycle ---------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def _open_channel(self) -> None:
|
|
161
|
+
if self._tls:
|
|
162
|
+
credentials = grpc.ssl_channel_credentials()
|
|
163
|
+
self._channel = grpc.aio.secure_channel(
|
|
164
|
+
self._endpoint,
|
|
165
|
+
credentials,
|
|
166
|
+
options=self._channel_options,
|
|
167
|
+
compression=self._compression,
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
self._channel = grpc.aio.insecure_channel(
|
|
171
|
+
self._endpoint,
|
|
172
|
+
options=self._channel_options,
|
|
173
|
+
compression=self._compression,
|
|
174
|
+
)
|
|
175
|
+
self._stub = queue_pb2_grpc.QueueServiceStub(self._channel)
|
|
176
|
+
|
|
177
|
+
async def connect(self, *, check_health: bool = False) -> None:
|
|
178
|
+
self._open_channel()
|
|
179
|
+
if check_health:
|
|
180
|
+
await self._await_serving()
|
|
181
|
+
await self.negotiate()
|
|
182
|
+
|
|
183
|
+
async def _await_serving(self, timeout: float = 5.0) -> None:
|
|
184
|
+
"""Wait until the health service reports SERVING (local daemon
|
|
185
|
+
readiness is announced on stdout before the gRPC health state may be
|
|
186
|
+
observable, so poll briefly instead of assuming)."""
|
|
187
|
+
assert self._channel is not None
|
|
188
|
+
health_stub = health_pb2_grpc.HealthStub(self._channel)
|
|
189
|
+
deadline = asyncio.get_running_loop().time() + timeout
|
|
190
|
+
last: Exception | None = None
|
|
191
|
+
while True:
|
|
192
|
+
try:
|
|
193
|
+
response = await health_stub.Check(
|
|
194
|
+
health_pb2.HealthCheckRequest(),
|
|
195
|
+
timeout=2.0,
|
|
196
|
+
metadata=self._metadata(),
|
|
197
|
+
)
|
|
198
|
+
if response.status == health_pb2.HealthCheckResponse.SERVING:
|
|
199
|
+
return
|
|
200
|
+
last = RingoError("daemon health status is not SERVING")
|
|
201
|
+
except grpc.aio.AioRpcError as exc:
|
|
202
|
+
last = exc
|
|
203
|
+
if asyncio.get_running_loop().time() >= deadline:
|
|
204
|
+
raise RingoError(f"daemon did not report SERVING health: {last}")
|
|
205
|
+
await asyncio.sleep(0.05)
|
|
206
|
+
|
|
207
|
+
async def reset(self, endpoint: str) -> None:
|
|
208
|
+
"""Point the connection at a new endpoint (local daemon restart)."""
|
|
209
|
+
self._endpoint, self._tls = _parse_endpoint(endpoint)
|
|
210
|
+
channel, self._channel = self._channel, None
|
|
211
|
+
self._stub = None
|
|
212
|
+
if channel is not None:
|
|
213
|
+
await channel.close()
|
|
214
|
+
self._open_channel()
|
|
215
|
+
|
|
216
|
+
async def negotiate(self) -> None:
|
|
217
|
+
"""Perform the major=1/minor=0 version handshake (ADR 0002)."""
|
|
218
|
+
stub = self._require_stub()
|
|
219
|
+
request = queue_pb2.NegotiateRequest(
|
|
220
|
+
sdk_name=SDK_NAME,
|
|
221
|
+
sdk_version=SDK_VERSION,
|
|
222
|
+
protocol_major=PROTOCOL_MAJOR,
|
|
223
|
+
protocol_minor=PROTOCOL_MINOR,
|
|
224
|
+
)
|
|
225
|
+
try:
|
|
226
|
+
response = await stub.Negotiate(
|
|
227
|
+
request, timeout=DEFAULT_RPC_TIMEOUT, metadata=self._metadata()
|
|
228
|
+
)
|
|
229
|
+
except grpc.aio.AioRpcError as exc:
|
|
230
|
+
raise map_rpc_error(exc) from exc
|
|
231
|
+
if response.protocol_major != PROTOCOL_MAJOR:
|
|
232
|
+
raise IncompatibleVersionError(
|
|
233
|
+
f"server speaks protocol major {response.protocol_major},"
|
|
234
|
+
f" SDK requires major {PROTOCOL_MAJOR}; upgrade one side"
|
|
235
|
+
)
|
|
236
|
+
self._session_minor = min(response.protocol_minor, PROTOCOL_MINOR)
|
|
237
|
+
self._session_token = response.session_token
|
|
238
|
+
|
|
239
|
+
async def close(self) -> None:
|
|
240
|
+
self._closed = True
|
|
241
|
+
channel, self._channel = self._channel, None
|
|
242
|
+
self._stub = None
|
|
243
|
+
if channel is not None:
|
|
244
|
+
await channel.close()
|
|
245
|
+
|
|
246
|
+
# -- helpers -----------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
def _require_stub(self) -> queue_pb2_grpc.QueueServiceStub:
|
|
249
|
+
if self._stub is None:
|
|
250
|
+
raise RingoError("connection is closed")
|
|
251
|
+
return self._stub
|
|
252
|
+
|
|
253
|
+
def _metadata(self) -> list[tuple[str, str]]:
|
|
254
|
+
if self._token:
|
|
255
|
+
return [("authorization", f"Bearer {self._token}")]
|
|
256
|
+
return []
|
|
257
|
+
|
|
258
|
+
def request_context(self) -> queue_pb2.RequestContext:
|
|
259
|
+
return queue_pb2.RequestContext(
|
|
260
|
+
protocol_major=PROTOCOL_MAJOR,
|
|
261
|
+
protocol_minor=self._session_minor,
|
|
262
|
+
session_token=self._session_token,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def endpoint(self) -> str:
|
|
267
|
+
return self._endpoint
|
|
268
|
+
|
|
269
|
+
async def call(
|
|
270
|
+
self,
|
|
271
|
+
name: str,
|
|
272
|
+
request: Any,
|
|
273
|
+
*,
|
|
274
|
+
timeout: float = DEFAULT_RPC_TIMEOUT,
|
|
275
|
+
retry_safe: bool = False,
|
|
276
|
+
context_attr: str | None = "context",
|
|
277
|
+
) -> Any:
|
|
278
|
+
"""Issue a single unary RPC.
|
|
279
|
+
|
|
280
|
+
Submission is never silently replayed: an ``UNAVAILABLE`` may mean the
|
|
281
|
+
server committed the operation (a lost ACK response, a claimed lease)
|
|
282
|
+
so the original error is raised after best-effort recovery (daemon
|
|
283
|
+
restart + fresh negotiation) prepares the connection for *future*
|
|
284
|
+
calls. Only genuinely read-only RPCs (``GetTask``) pass
|
|
285
|
+
``retry_safe=True`` and may be re-sent, each time with a freshly
|
|
286
|
+
negotiated ``RequestContext``.
|
|
287
|
+
"""
|
|
288
|
+
rpc, sent_token = await self._begin_unary(
|
|
289
|
+
name, request, timeout=timeout, context_attr=context_attr
|
|
290
|
+
)
|
|
291
|
+
cause: BaseException | None = None
|
|
292
|
+
err: RingoError | None = None
|
|
293
|
+
try:
|
|
294
|
+
return await rpc
|
|
295
|
+
except grpc.aio.AioRpcError as exc:
|
|
296
|
+
cause = exc
|
|
297
|
+
err = map_rpc_error(exc)
|
|
298
|
+
except asyncio.CancelledError as exc:
|
|
299
|
+
# grpc.aio raises CancelledError when a reconnect closes its old
|
|
300
|
+
# channel. Preserve genuine caller cancellation, but surface this
|
|
301
|
+
# transport race as UNAVAILABLE.
|
|
302
|
+
task = asyncio.current_task()
|
|
303
|
+
if task is not None and task.cancelling():
|
|
304
|
+
raise
|
|
305
|
+
cause = exc
|
|
306
|
+
err = UnavailableError("RPC interrupted by connection recovery")
|
|
307
|
+
|
|
308
|
+
assert err is not None and cause is not None
|
|
309
|
+
if self._closed:
|
|
310
|
+
raise err from cause
|
|
311
|
+
if isinstance(err, IncompatibleVersionError):
|
|
312
|
+
# Session rejection happens in authorization before the operation
|
|
313
|
+
# can execute. It is therefore safe to negotiate a fresh short-lived
|
|
314
|
+
# token and retry once, including ordinary token expiry with no
|
|
315
|
+
# concurrent daemon restart.
|
|
316
|
+
await self._recover_best_effort()
|
|
317
|
+
async with self._lock:
|
|
318
|
+
rotated = self._session_token != sent_token
|
|
319
|
+
if rotated:
|
|
320
|
+
rpc, _ = await self._begin_unary(
|
|
321
|
+
name, request, timeout=timeout, context_attr=context_attr
|
|
322
|
+
)
|
|
323
|
+
try:
|
|
324
|
+
return await rpc
|
|
325
|
+
except grpc.aio.AioRpcError as retry_exc:
|
|
326
|
+
raise map_rpc_error(retry_exc) from retry_exc
|
|
327
|
+
raise err from cause
|
|
328
|
+
if not isinstance(err, UnavailableError):
|
|
329
|
+
raise err from cause
|
|
330
|
+
|
|
331
|
+
# Recovery prepares the connection for future calls. Mutations are not
|
|
332
|
+
# replayed because an unavailable response may hide a committed write.
|
|
333
|
+
await self._recover_best_effort()
|
|
334
|
+
if not retry_safe:
|
|
335
|
+
raise err from cause
|
|
336
|
+
rpc, _ = await self._begin_unary(
|
|
337
|
+
name, request, timeout=timeout, context_attr=context_attr
|
|
338
|
+
)
|
|
339
|
+
try:
|
|
340
|
+
return await rpc
|
|
341
|
+
except grpc.aio.AioRpcError as retry_exc:
|
|
342
|
+
raise map_rpc_error(retry_exc) from retry_exc
|
|
343
|
+
|
|
344
|
+
async def _begin_unary(
|
|
345
|
+
self,
|
|
346
|
+
name: str,
|
|
347
|
+
request: Any,
|
|
348
|
+
*,
|
|
349
|
+
timeout: float,
|
|
350
|
+
context_attr: str | None,
|
|
351
|
+
) -> tuple[Any, str]:
|
|
352
|
+
"""Snapshot a stub and fresh session context outside reconnect races."""
|
|
353
|
+
async with self._lock:
|
|
354
|
+
if context_attr is not None:
|
|
355
|
+
getattr(request, context_attr).CopyFrom(self.request_context())
|
|
356
|
+
stub = self._require_stub()
|
|
357
|
+
token = self._session_token
|
|
358
|
+
rpc = getattr(stub, name)(
|
|
359
|
+
request, timeout=timeout, metadata=self._metadata()
|
|
360
|
+
)
|
|
361
|
+
return rpc, token
|
|
362
|
+
|
|
363
|
+
async def _recover_best_effort(self) -> None:
|
|
364
|
+
"""Bounded reconnect so later calls can succeed; failures here are
|
|
365
|
+
swallowed because the original call error is what the caller sees."""
|
|
366
|
+
for attempt in range(_RECONNECT_ATTEMPTS):
|
|
367
|
+
try:
|
|
368
|
+
await asyncio.sleep(
|
|
369
|
+
_BACKOFF_SECONDS[min(attempt, len(_BACKOFF_SECONDS) - 1)]
|
|
370
|
+
)
|
|
371
|
+
async with self._lock:
|
|
372
|
+
if self._closed:
|
|
373
|
+
return
|
|
374
|
+
if self.on_reconnect is not None:
|
|
375
|
+
await self.on_reconnect()
|
|
376
|
+
else:
|
|
377
|
+
await self.negotiate()
|
|
378
|
+
return
|
|
379
|
+
except Exception:
|
|
380
|
+
continue
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def new_worker_id() -> str:
|
|
384
|
+
return f"py-{os.getpid()}-{secrets.token_hex(4)}"
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ---------------------------------------------------------------------------
|
|
388
|
+
# Public client
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class RingoClient:
|
|
392
|
+
"""A connected (or connectable) Ringo client.
|
|
393
|
+
|
|
394
|
+
Use as an async context manager, or call :meth:`start` / :meth:`close`
|
|
395
|
+
explicitly::
|
|
396
|
+
|
|
397
|
+
async with Ringo.connect("127.0.0.1:7233") as client:
|
|
398
|
+
queue = client.queue("crawl")
|
|
399
|
+
"""
|
|
400
|
+
|
|
401
|
+
def __init__(
|
|
402
|
+
self,
|
|
403
|
+
*,
|
|
404
|
+
endpoint: str | None = None,
|
|
405
|
+
token: str | None = None,
|
|
406
|
+
daemon: "LocalDaemon | None" = None,
|
|
407
|
+
channel_options: Iterable[tuple[str, Any]] | None = None,
|
|
408
|
+
compression: CompressionOption | None = None,
|
|
409
|
+
) -> None:
|
|
410
|
+
self._endpoint = endpoint
|
|
411
|
+
self._token = token
|
|
412
|
+
self._channel_options = channel_options
|
|
413
|
+
# The value is always validated, but ``compression`` only applies to
|
|
414
|
+
# remote connections; local mode stays on the (uncompressed) default.
|
|
415
|
+
normalized = _normalize_compression(compression)
|
|
416
|
+
if daemon is None:
|
|
417
|
+
_check_compression_channel_option_conflict(
|
|
418
|
+
self._channel_options, normalized
|
|
419
|
+
)
|
|
420
|
+
self._compression = normalized
|
|
421
|
+
self._compression_label: CompressionOption = (
|
|
422
|
+
compression if compression is not None else "none"
|
|
423
|
+
)
|
|
424
|
+
else:
|
|
425
|
+
# Local mode never enables compression; an explicit value is
|
|
426
|
+
# validated above and then ignored (source compatibility).
|
|
427
|
+
self._compression = None
|
|
428
|
+
self._compression_label = "none"
|
|
429
|
+
self._daemon = daemon
|
|
430
|
+
# ``_conn`` is built in start(): a local client learns the daemon's
|
|
431
|
+
# random loopback port only after the daemon announces readiness.
|
|
432
|
+
self._conn: _Connection | None = None
|
|
433
|
+
self._queues: dict[str, TaskQueue] = {}
|
|
434
|
+
self._workers: set[Worker] = set()
|
|
435
|
+
self._started = False
|
|
436
|
+
self._closed = False
|
|
437
|
+
self._start_lock = asyncio.Lock()
|
|
438
|
+
self._close_lock = asyncio.Lock()
|
|
439
|
+
self._close_task: asyncio.Task[None] | None = None
|
|
440
|
+
self._daemon_restart_count = 0
|
|
441
|
+
|
|
442
|
+
# -- lifecycle ---------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
async def start(self) -> "RingoClient":
|
|
445
|
+
async with self._start_lock:
|
|
446
|
+
if self._closed:
|
|
447
|
+
raise RingoError("client is closed")
|
|
448
|
+
if not self._started:
|
|
449
|
+
try:
|
|
450
|
+
if self._daemon is not None:
|
|
451
|
+
self._endpoint = await self._daemon.start()
|
|
452
|
+
self._conn = _Connection(
|
|
453
|
+
self._endpoint or "",
|
|
454
|
+
self._token,
|
|
455
|
+
channel_options=self._channel_options,
|
|
456
|
+
compression=self._compression,
|
|
457
|
+
)
|
|
458
|
+
self._conn.on_reconnect = self._reconnect
|
|
459
|
+
await self._conn.connect(check_health=self._daemon is not None)
|
|
460
|
+
except BaseException:
|
|
461
|
+
# Fast cleanup: never leak a half-started local daemon.
|
|
462
|
+
await self._stop_daemon()
|
|
463
|
+
raise
|
|
464
|
+
self._started = True
|
|
465
|
+
return self
|
|
466
|
+
|
|
467
|
+
def _require_conn(self) -> _Connection:
|
|
468
|
+
conn = self._conn
|
|
469
|
+
if conn is None:
|
|
470
|
+
raise RingoError(
|
|
471
|
+
"client is not started; use 'async with' or await client.start()"
|
|
472
|
+
)
|
|
473
|
+
return conn
|
|
474
|
+
|
|
475
|
+
async def _reconnect(self) -> None:
|
|
476
|
+
"""Reconnect hook: restart a dead local daemon (bounded, ADR 0003 /
|
|
477
|
+
design §31: at most 3 restarts with backoff), then renegotiate."""
|
|
478
|
+
conn = self._require_conn()
|
|
479
|
+
daemon = self._daemon
|
|
480
|
+
if daemon is not None and daemon.is_dead:
|
|
481
|
+
if self._daemon_restart_count >= _MAX_DAEMON_RESTARTS:
|
|
482
|
+
raise UnavailableError(
|
|
483
|
+
f"local daemon restart budget ({_MAX_DAEMON_RESTARTS}) exhausted"
|
|
484
|
+
)
|
|
485
|
+
self._daemon_restart_count += 1
|
|
486
|
+
delay = 0.3 * (2 ** (self._daemon_restart_count - 1))
|
|
487
|
+
await asyncio.sleep(delay)
|
|
488
|
+
endpoint = await daemon.start()
|
|
489
|
+
await conn.reset(endpoint)
|
|
490
|
+
await conn.negotiate()
|
|
491
|
+
|
|
492
|
+
async def __aenter__(self) -> "RingoClient":
|
|
493
|
+
return await self.start()
|
|
494
|
+
|
|
495
|
+
async def close(self) -> None:
|
|
496
|
+
"""Drain and stop once; caller cancellation cannot cancel cleanup."""
|
|
497
|
+
async with self._close_lock:
|
|
498
|
+
if self._close_task is None:
|
|
499
|
+
self._closed = True
|
|
500
|
+
self._close_task = asyncio.create_task(
|
|
501
|
+
self._close_impl(), name="ringo-client-close"
|
|
502
|
+
)
|
|
503
|
+
self._close_task.add_done_callback(_consume_task_exception)
|
|
504
|
+
close_task = self._close_task
|
|
505
|
+
await asyncio.shield(close_task)
|
|
506
|
+
|
|
507
|
+
async def _close_impl(self) -> None:
|
|
508
|
+
"""Attempt every cleanup step and aggregate any failures."""
|
|
509
|
+
errors: list[Exception] = []
|
|
510
|
+
# 1. Drain workers (stop taking tasks, wait grace, cancel remainder).
|
|
511
|
+
for worker in list(self._workers):
|
|
512
|
+
try:
|
|
513
|
+
await worker.close()
|
|
514
|
+
except Exception as exc: # keep cleaning up the rest
|
|
515
|
+
errors.append(exc)
|
|
516
|
+
self._workers.clear()
|
|
517
|
+
# 2. Close the gRPC channel.
|
|
518
|
+
conn, self._conn = self._conn, None
|
|
519
|
+
if conn is not None:
|
|
520
|
+
try:
|
|
521
|
+
await conn.close()
|
|
522
|
+
except Exception as exc:
|
|
523
|
+
errors.append(exc)
|
|
524
|
+
# 3. Shut the local daemon down via the lifecycle pipe, then wait.
|
|
525
|
+
try:
|
|
526
|
+
await self._stop_daemon()
|
|
527
|
+
except Exception as exc:
|
|
528
|
+
errors.append(exc)
|
|
529
|
+
if errors:
|
|
530
|
+
raise ExceptionGroup("errors while closing Ringo client", errors)
|
|
531
|
+
|
|
532
|
+
async def _stop_daemon(self) -> None:
|
|
533
|
+
daemon, self._daemon = self._daemon, None
|
|
534
|
+
if daemon is not None:
|
|
535
|
+
await daemon.stop()
|
|
536
|
+
|
|
537
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
538
|
+
try:
|
|
539
|
+
await self.close()
|
|
540
|
+
except ExceptionGroup as cleanup_errors:
|
|
541
|
+
# A business exception takes precedence; cleanup failures must
|
|
542
|
+
# not mask it, but should not vanish either.
|
|
543
|
+
if exc is None:
|
|
544
|
+
raise
|
|
545
|
+
for error in cleanup_errors.exceptions:
|
|
546
|
+
exc.__cause__ = exc.__cause__ or error
|
|
547
|
+
|
|
548
|
+
@property
|
|
549
|
+
def is_local(self) -> bool:
|
|
550
|
+
return self._daemon is not None
|
|
551
|
+
|
|
552
|
+
@property
|
|
553
|
+
def compression(self) -> CompressionOption:
|
|
554
|
+
"""The effective channel compression: ``"none"`` (default) or ``"gzip"``."""
|
|
555
|
+
return self._compression_label
|
|
556
|
+
|
|
557
|
+
# -- queues ------------------------------------------------------------
|
|
558
|
+
|
|
559
|
+
def queue(self, name: str) -> "TaskQueue":
|
|
560
|
+
if not name:
|
|
561
|
+
raise InvalidArgumentError("queue name is required")
|
|
562
|
+
if self._closed:
|
|
563
|
+
raise RingoError("client is closed")
|
|
564
|
+
existing = self._queues.get(name)
|
|
565
|
+
if existing is None:
|
|
566
|
+
existing = TaskQueue(self, name)
|
|
567
|
+
self._queues[name] = existing
|
|
568
|
+
return existing
|
|
569
|
+
|
|
570
|
+
def _register_worker(self, worker: "Worker") -> None:
|
|
571
|
+
self._workers.add(worker)
|
|
572
|
+
|
|
573
|
+
def _unregister_worker(self, worker: "Worker") -> None:
|
|
574
|
+
self._workers.discard(worker)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
class Ringo:
|
|
578
|
+
"""Factory for local (embedded daemon) and remote clients.
|
|
579
|
+
|
|
580
|
+
Both factories are synchronous and return a client whose
|
|
581
|
+
``async with``/``await start()`` performs the actual connect and
|
|
582
|
+
negotiation, so ``async with Ringo.local(...)`` works directly.
|
|
583
|
+
"""
|
|
584
|
+
|
|
585
|
+
@staticmethod
|
|
586
|
+
def local(
|
|
587
|
+
data_dir: str | os.PathLike[str],
|
|
588
|
+
storage: str | SQLiteStorage | PostgresStorage = "sqlite",
|
|
589
|
+
daemon_path: str | os.PathLike[str] | None = None,
|
|
590
|
+
*,
|
|
591
|
+
startup_timeout: float = 15.0,
|
|
592
|
+
stop_timeout: float = 10.0,
|
|
593
|
+
) -> RingoClient:
|
|
594
|
+
"""Start a private daemon over SQLite or user-managed PostgreSQL."""
|
|
595
|
+
if storage == "sqlite" or isinstance(storage, SQLiteStorage):
|
|
596
|
+
storage_name = "sqlite"
|
|
597
|
+
postgres_dsn = None
|
|
598
|
+
elif isinstance(storage, PostgresStorage):
|
|
599
|
+
storage_name = "postgres"
|
|
600
|
+
postgres_dsn = storage.dsn
|
|
601
|
+
else:
|
|
602
|
+
raise InvalidArgumentError(f"unsupported local storage {storage!r}")
|
|
603
|
+
# Imported here so remote-only users never pay for subprocess imports.
|
|
604
|
+
from .daemon import LocalDaemon
|
|
605
|
+
|
|
606
|
+
daemon = LocalDaemon(
|
|
607
|
+
data_dir,
|
|
608
|
+
storage=storage_name,
|
|
609
|
+
postgres_dsn=postgres_dsn,
|
|
610
|
+
daemon_path=daemon_path,
|
|
611
|
+
startup_timeout=startup_timeout,
|
|
612
|
+
stop_timeout=stop_timeout,
|
|
613
|
+
)
|
|
614
|
+
return RingoClient(daemon=daemon)
|
|
615
|
+
|
|
616
|
+
@staticmethod
|
|
617
|
+
def connect(
|
|
618
|
+
endpoint: str,
|
|
619
|
+
token: str | None = None,
|
|
620
|
+
*,
|
|
621
|
+
channel_options: Iterable[tuple[str, Any]] | None = None,
|
|
622
|
+
compression: CompressionOption | None = None,
|
|
623
|
+
) -> RingoClient:
|
|
624
|
+
"""Create a client for a remote (or already-running) endpoint.
|
|
625
|
+
|
|
626
|
+
``compression`` is an optional whole-channel setting: ``"none"``
|
|
627
|
+
(the default) or ``"gzip"``. ``None`` means "not specified", leaving
|
|
628
|
+
the gRPC channel default (and any raw ``channel_options`` compression
|
|
629
|
+
entry) in charge. An explicit value combined with the raw channel
|
|
630
|
+
option ``grpc.default_compression_algorithm`` raises
|
|
631
|
+
:class:`InvalidArgumentError`; any other value does so as well,
|
|
632
|
+
immediately and before any network activity.
|
|
633
|
+
"""
|
|
634
|
+
return RingoClient(
|
|
635
|
+
endpoint=endpoint,
|
|
636
|
+
token=token,
|
|
637
|
+
channel_options=channel_options,
|
|
638
|
+
compression=compression,
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# ---------------------------------------------------------------------------
|
|
643
|
+
# TaskQueue
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
Handler = Callable[[Task, Any], Coroutine[Any, Any, None]]
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
class TaskQueue:
|
|
650
|
+
"""A client bound to one logical queue (``queue + uniqueKey`` dedupe)."""
|
|
651
|
+
|
|
652
|
+
def __init__(self, client: RingoClient, name: str) -> None:
|
|
653
|
+
self._client = client
|
|
654
|
+
self.name = name
|
|
655
|
+
|
|
656
|
+
# -- enqueue ------------------------------------------------------------
|
|
657
|
+
|
|
658
|
+
async def enqueue(
|
|
659
|
+
self,
|
|
660
|
+
spec: TaskSpec | None = None,
|
|
661
|
+
/,
|
|
662
|
+
**kwargs: Any,
|
|
663
|
+
) -> EnqueueResult:
|
|
664
|
+
"""Enqueue one task; duplicates return ``created=False`` (no raise)."""
|
|
665
|
+
if spec is None:
|
|
666
|
+
spec = TaskSpec(**kwargs)
|
|
667
|
+
request = queue_pb2.EnqueueRequest(queue=self.name, task=spec.to_proto())
|
|
668
|
+
request.context.CopyFrom(self._client._require_conn().request_context())
|
|
669
|
+
response = await self._client._require_conn().call("Enqueue", request)
|
|
670
|
+
return EnqueueResult.from_proto(response.result)
|
|
671
|
+
|
|
672
|
+
async def enqueue_many(self, specs: Iterable[TaskSpec]) -> BatchResult:
|
|
673
|
+
"""Enqueue a batch (max 1000), reporting every item individually."""
|
|
674
|
+
specs = list(specs)
|
|
675
|
+
if not specs:
|
|
676
|
+
raise InvalidArgumentError("enqueue_many requires at least one TaskSpec")
|
|
677
|
+
if len(specs) > _MAX_ENQUEUE_BATCH:
|
|
678
|
+
raise InvalidArgumentError(
|
|
679
|
+
f"enqueue_many accepts at most {_MAX_ENQUEUE_BATCH} tasks"
|
|
680
|
+
)
|
|
681
|
+
request = queue_pb2.EnqueueManyRequest(
|
|
682
|
+
queue=self.name, tasks=[s.to_proto() for s in specs]
|
|
683
|
+
)
|
|
684
|
+
request.context.CopyFrom(self._client._require_conn().request_context())
|
|
685
|
+
response = await self._client._require_conn().call("EnqueueMany", request)
|
|
686
|
+
return BatchResult(
|
|
687
|
+
results=[EnqueueResult.from_proto(r) for r in response.results],
|
|
688
|
+
created_count=response.created_count,
|
|
689
|
+
duplicate_count=response.duplicate_count,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
# -- queries ------------------------------------------------------------
|
|
693
|
+
|
|
694
|
+
async def get_by_id(self, task_id: str) -> Task:
|
|
695
|
+
if not task_id:
|
|
696
|
+
raise InvalidArgumentError("task_id is required")
|
|
697
|
+
request = queue_pb2.GetTaskRequest(queue=self.name, task_id=task_id)
|
|
698
|
+
request.context.CopyFrom(self._client._require_conn().request_context())
|
|
699
|
+
response = await self._client._require_conn().call(
|
|
700
|
+
"GetTask", request, retry_safe=True
|
|
701
|
+
)
|
|
702
|
+
return Task.from_proto(response.task)
|
|
703
|
+
|
|
704
|
+
async def get_by_unique_key(self, unique_key: str) -> Task:
|
|
705
|
+
if not unique_key:
|
|
706
|
+
raise InvalidArgumentError("unique_key is required")
|
|
707
|
+
request = queue_pb2.GetTaskRequest(queue=self.name, unique_key=unique_key)
|
|
708
|
+
request.context.CopyFrom(self._client._require_conn().request_context())
|
|
709
|
+
response = await self._client._require_conn().call(
|
|
710
|
+
"GetTask", request, retry_safe=True
|
|
711
|
+
)
|
|
712
|
+
return Task.from_proto(response.task)
|
|
713
|
+
|
|
714
|
+
async def retry(self, unique_key: str) -> Task:
|
|
715
|
+
"""Reactivate a SUCCEEDED/DEAD task by its business unique key.
|
|
716
|
+
|
|
717
|
+
This always resolves through the proto ``unique_key`` field; use
|
|
718
|
+
:meth:`retry_by_id` for the task-ID form. No string is ever guessed.
|
|
719
|
+
"""
|
|
720
|
+
if not unique_key:
|
|
721
|
+
raise InvalidArgumentError("unique_key is required")
|
|
722
|
+
request = queue_pb2.RetryTaskRequest(queue=self.name)
|
|
723
|
+
request.unique_key = unique_key
|
|
724
|
+
request.context.CopyFrom(self._client._require_conn().request_context())
|
|
725
|
+
response = await self._client._require_conn().call("RetryTask", request)
|
|
726
|
+
return Task.from_proto(response.task)
|
|
727
|
+
|
|
728
|
+
async def retry_by_id(self, task_id: str) -> Task:
|
|
729
|
+
"""Reactivate a SUCCEEDED/DEAD task by its server task ID."""
|
|
730
|
+
if not task_id:
|
|
731
|
+
raise InvalidArgumentError("task_id is required")
|
|
732
|
+
request = queue_pb2.RetryTaskRequest(queue=self.name)
|
|
733
|
+
request.task_id = task_id
|
|
734
|
+
request.context.CopyFrom(self._client._require_conn().request_context())
|
|
735
|
+
response = await self._client._require_conn().call("RetryTask", request)
|
|
736
|
+
return Task.from_proto(response.task)
|
|
737
|
+
|
|
738
|
+
# -- manual claim ---------------------------------------------------------
|
|
739
|
+
|
|
740
|
+
async def claim(
|
|
741
|
+
self,
|
|
742
|
+
limit: int = 1,
|
|
743
|
+
*,
|
|
744
|
+
task_types: Iterable[str] | None = None,
|
|
745
|
+
wait_timeout: timedelta | None = None,
|
|
746
|
+
lease_duration: timedelta | None = None,
|
|
747
|
+
worker_id: str | None = None,
|
|
748
|
+
) -> list["LeasedTask"]:
|
|
749
|
+
"""Manually claim tasks (advanced API; not the default worker path)."""
|
|
750
|
+
if limit < 1 or limit > _MAX_ENQUEUE_BATCH:
|
|
751
|
+
raise InvalidArgumentError(
|
|
752
|
+
f"limit must be between 1 and {_MAX_ENQUEUE_BATCH}"
|
|
753
|
+
)
|
|
754
|
+
if wait_timeout is not None and wait_timeout < timedelta(0):
|
|
755
|
+
raise InvalidArgumentError("wait_timeout must not be negative")
|
|
756
|
+
if lease_duration is not None and lease_duration < timedelta(0):
|
|
757
|
+
raise InvalidArgumentError("lease_duration must not be negative")
|
|
758
|
+
request = queue_pb2.ClaimRequest(
|
|
759
|
+
queue=self.name,
|
|
760
|
+
worker_id=worker_id or new_worker_id(),
|
|
761
|
+
task_types=list(task_types or []),
|
|
762
|
+
limit=limit,
|
|
763
|
+
)
|
|
764
|
+
if wait_timeout is not None:
|
|
765
|
+
request.wait_timeout.CopyFrom(_pb.td_to_dur(wait_timeout))
|
|
766
|
+
if lease_duration is not None:
|
|
767
|
+
request.lease_duration.CopyFrom(_pb.td_to_dur(lease_duration))
|
|
768
|
+
request.context.CopyFrom(self._client._require_conn().request_context())
|
|
769
|
+
response = await self._client._require_conn().call("Claim", request)
|
|
770
|
+
return [LeasedTask(self, t) for t in response.tasks]
|
|
771
|
+
|
|
772
|
+
# -- worker ---------------------------------------------------------------
|
|
773
|
+
|
|
774
|
+
def worker(
|
|
775
|
+
self,
|
|
776
|
+
handler: Handler,
|
|
777
|
+
*,
|
|
778
|
+
task_types: Iterable[str] | None = None,
|
|
779
|
+
concurrency: int = 1,
|
|
780
|
+
lease_duration: timedelta = timedelta(seconds=60),
|
|
781
|
+
grace_period: timedelta = timedelta(seconds=30),
|
|
782
|
+
) -> "Worker":
|
|
783
|
+
"""Create a credit-controlled worker over the frozen Work stream."""
|
|
784
|
+
from .worker import Worker
|
|
785
|
+
|
|
786
|
+
if not callable(handler):
|
|
787
|
+
raise InvalidArgumentError("handler must be callable")
|
|
788
|
+
if concurrency < 1 or concurrency > 2**32 - 1:
|
|
789
|
+
raise InvalidArgumentError("concurrency must fit in a non-zero uint32")
|
|
790
|
+
if lease_duration < timedelta(0):
|
|
791
|
+
raise InvalidArgumentError("lease_duration must not be negative")
|
|
792
|
+
if grace_period < timedelta(0):
|
|
793
|
+
raise InvalidArgumentError("grace_period must not be negative")
|
|
794
|
+
worker = Worker(
|
|
795
|
+
self,
|
|
796
|
+
handler,
|
|
797
|
+
task_types=list(task_types or []),
|
|
798
|
+
concurrency=concurrency,
|
|
799
|
+
lease_duration=lease_duration,
|
|
800
|
+
grace_period=grace_period,
|
|
801
|
+
)
|
|
802
|
+
self._client._register_worker(worker)
|
|
803
|
+
return worker
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
# ---------------------------------------------------------------------------
|
|
807
|
+
# Manual lease
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
class LeasedTask:
|
|
811
|
+
"""A task claimed via :meth:`TaskQueue.claim` with its current lease."""
|
|
812
|
+
|
|
813
|
+
def __init__(self, queue: TaskQueue, msg: queue_pb2.LeasedTask) -> None:
|
|
814
|
+
self._queue = queue
|
|
815
|
+
self.task = Task.from_proto(msg.task)
|
|
816
|
+
self.attempt: int = msg.lease.attempt
|
|
817
|
+
self.worker_id: str = msg.lease.worker_id
|
|
818
|
+
self._token: str = msg.lease.lease_token
|
|
819
|
+
self._lease_until = _pb.ts_to_dt(msg.lease.until)
|
|
820
|
+
|
|
821
|
+
@property
|
|
822
|
+
def lease_until(self):
|
|
823
|
+
return self._lease_until
|
|
824
|
+
|
|
825
|
+
def _ref(self) -> queue_pb2.LeaseRef:
|
|
826
|
+
return queue_pb2.LeaseRef(
|
|
827
|
+
task_id=self.task.id,
|
|
828
|
+
attempt=self.attempt,
|
|
829
|
+
lease_token=self._token,
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
async def _terminal(self, name: str, request: Any) -> None:
|
|
833
|
+
request.lease.CopyFrom(self._ref())
|
|
834
|
+
conn = self._queue._client._require_conn()
|
|
835
|
+
request.context.CopyFrom(conn.request_context())
|
|
836
|
+
await conn.call(name, request)
|
|
837
|
+
|
|
838
|
+
async def ack(self) -> None:
|
|
839
|
+
"""Acknowledge success for the current attempt/lease token."""
|
|
840
|
+
await self._terminal("Ack", queue_pb2.AckRequest())
|
|
841
|
+
|
|
842
|
+
async def nack(self, error: str = "", *, delay: timedelta | None = None) -> None:
|
|
843
|
+
"""Negative-acknowledge; the task is retried per policy."""
|
|
844
|
+
request = queue_pb2.NackRequest(error=error)
|
|
845
|
+
if delay is not None:
|
|
846
|
+
request.retry_delay_override.CopyFrom(_pb.td_to_dur(delay))
|
|
847
|
+
await self._terminal("Nack", request)
|
|
848
|
+
|
|
849
|
+
async def reject(self, error: str = "") -> None:
|
|
850
|
+
"""Permanently fail the task (DEAD) for the current attempt."""
|
|
851
|
+
await self._terminal("Reject", queue_pb2.RejectRequest(error=error))
|
|
852
|
+
|
|
853
|
+
async def extend_lease(self, extension: timedelta) -> None:
|
|
854
|
+
"""Extend the lease to ``server_now + extension`` (absolute reset)."""
|
|
855
|
+
if extension <= timedelta(0):
|
|
856
|
+
raise InvalidArgumentError("extension must be positive")
|
|
857
|
+
request = queue_pb2.ExtendLeaseRequest()
|
|
858
|
+
request.extension.CopyFrom(_pb.td_to_dur(extension))
|
|
859
|
+
request.lease.CopyFrom(self._ref())
|
|
860
|
+
conn = self._queue._client._require_conn()
|
|
861
|
+
request.context.CopyFrom(conn.request_context())
|
|
862
|
+
response = await conn.call("ExtendLease", request)
|
|
863
|
+
self._lease_until = _pb.ts_to_dt(response.lease.until)
|