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.
@@ -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)