ringo-task-queue 0.1.0.dev8__py3-none-macosx_13_0_arm64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1261 @@
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
+ import sys
9
+ from datetime import timedelta
10
+ from itertools import islice
11
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, Iterable, Literal
12
+
13
+ import grpc
14
+ from grpc_health.v1 import health_pb2, health_pb2_grpc
15
+
16
+ from . import _pb
17
+ from ._proto.ringo.v1 import queue_pb2, queue_pb2_grpc
18
+ from ._supervisor_policy import TaskTimeouts
19
+ from ._version import PROTOCOL_MAJOR, PROTOCOL_MINOR, SDK_VERSION
20
+ from .concurrency import AdaptiveConcurrency, ResourceConcurrency
21
+ from .errors import (
22
+ IncompatibleVersionError,
23
+ InvalidArgumentError,
24
+ RingoError,
25
+ UnavailableError,
26
+ map_rpc_error,
27
+ )
28
+ from .models import (
29
+ AttemptPage,
30
+ BatchResult,
31
+ EnqueueResult,
32
+ PostgresStorage,
33
+ QueueStats,
34
+ SQLiteStorage,
35
+ Task,
36
+ TaskSpec,
37
+ )
38
+
39
+ if TYPE_CHECKING: # pragma: no cover
40
+ from .daemon import LocalDaemon
41
+ from .shared import SharedLocalDaemon
42
+ from .worker import Worker
43
+
44
+ SDK_NAME = "ringo-python"
45
+
46
+ DEFAULT_RPC_TIMEOUT = 30.0
47
+ _DEFAULT_MAX_MESSAGE_BYTES = 16 * 1024 * 1024
48
+ _MAX_ENQUEUE_BATCH = 1000
49
+ _RECONNECT_ATTEMPTS = 3
50
+ _BACKOFF_SECONDS = (0.2, 0.6, 1.5)
51
+ #: GetQueueStats is additive protocol 1.4; older servers reject the RPC.
52
+ _STATS_MIN_PROTOCOL_MINOR = 4
53
+ #: Maximum number of task-type filters accepted by ``TaskQueue.stats``.
54
+ _STATS_MAX_TASK_TYPES = 100
55
+ _ATTEMPT_HISTORY_MIN_PROTOCOL_MINOR = 5
56
+ _ATTEMPT_PAGE_DEFAULT_LIMIT = 32
57
+ _ATTEMPT_PAGE_MAX_LIMIT = 100
58
+ _MAX_ATTEMPT_CURSOR_BYTES = 1024
59
+
60
+ #: Supported values for the remote ``compression`` option: ``"none"``
61
+ #: (the default) or ``"gzip"``. ``None`` means "not specified": the channel
62
+ #: default applies and a legacy raw ``channel_options`` compression entry
63
+ #: keeps its meaning. The runtime validation in
64
+ #: :func:`_normalize_compression` still rejects any other value for dynamic
65
+ #: callers.
66
+ CompressionOption = Literal["none", "gzip"]
67
+ _COMPRESSION_CHOICES: tuple[str, ...] = ("none", "gzip")
68
+ #: gRPC channel option that controls the same thing as ``compression``. An
69
+ #: explicit ``compression`` (even ``"none"``) combined with this raw option is
70
+ #: an ambiguity error, mirroring the Node SDK.
71
+ _COMPRESSION_CHANNEL_OPTION = "grpc.default_compression_algorithm"
72
+
73
+
74
+ def _normalize_compression(value: CompressionOption | None) -> grpc.Compression | None:
75
+ """Map the public ``compression`` option onto ``grpc.Compression``.
76
+
77
+ ``None`` (unspecified) maps to ``None`` so the channel default applies.
78
+ Anything else outside ``_COMPRESSION_CHOICES`` is a clear
79
+ ``InvalidArgumentError`` raised before any network activity.
80
+ """
81
+ if value is None:
82
+ return None
83
+ if not isinstance(value, str) or value not in _COMPRESSION_CHOICES:
84
+ raise InvalidArgumentError(
85
+ f"unsupported compression {value!r}; expected one of"
86
+ f" {', '.join(_COMPRESSION_CHOICES)}"
87
+ )
88
+ if value == "gzip":
89
+ return grpc.Compression.Gzip
90
+ return grpc.Compression.NoCompression
91
+
92
+
93
+ def _check_compression_channel_option_conflict(
94
+ channel_options: Iterable[tuple[str, Any]] | None,
95
+ compression: grpc.Compression | None,
96
+ ) -> None:
97
+ """Reject an explicit ``compression`` alongside the raw gRPC option.
98
+
99
+ When ``compression`` is unspecified (``None``), raw ``channel_options``
100
+ keep their legacy meaning for advanced users.
101
+ """
102
+ if compression is None:
103
+ return
104
+ for key, _ in channel_options or ():
105
+ if key == _COMPRESSION_CHANNEL_OPTION:
106
+ raise InvalidArgumentError(
107
+ f"compression and channel_options[{_COMPRESSION_CHANNEL_OPTION!r}]"
108
+ " must not both be set"
109
+ )
110
+
111
+
112
+ def _consume_task_exception(task: asyncio.Task[Any]) -> None:
113
+ if not task.cancelled():
114
+ task.exception()
115
+
116
+
117
+ def _parse_endpoint(endpoint: str) -> tuple[str, bool]:
118
+ """Return ``(authority, use_tls)`` for an endpoint string."""
119
+ if not endpoint:
120
+ raise InvalidArgumentError("endpoint is required")
121
+ use_tls = False
122
+ rest = endpoint
123
+ for scheme, tls in (
124
+ ("grpc://", False),
125
+ ("http://", False),
126
+ ("grpcs://", True),
127
+ ("https://", True),
128
+ ):
129
+ if rest.startswith(scheme):
130
+ rest = rest[len(scheme) :]
131
+ use_tls = tls
132
+ break
133
+ rest = rest.rstrip("/")
134
+ if ":" not in rest:
135
+ rest = f"{rest}:7233"
136
+ return rest, use_tls
137
+
138
+
139
+ #: Bounded automatic restarts of a crashed local daemon (design §31).
140
+ _MAX_DAEMON_RESTARTS = 3
141
+
142
+
143
+ class _Connection:
144
+ """Owns the grpc.aio channel, negotiation, and retried unary calls."""
145
+
146
+ def __init__(
147
+ self,
148
+ endpoint: str,
149
+ token: str | None = None,
150
+ *,
151
+ local_token: str | None = None,
152
+ local_generation: str | None = None,
153
+ channel_options: Iterable[tuple[str, Any]] | None = None,
154
+ compression: grpc.Compression | None = None,
155
+ ) -> None:
156
+ self._endpoint, self._tls = _parse_endpoint(endpoint)
157
+ self._token = token
158
+ self._local_token = local_token
159
+ self._local_generation = local_generation
160
+ self._attach_call: Any = None
161
+ self._attach_reader_task: asyncio.Task[Any] | None = None
162
+ self._shared_disconnect: Callable[[], Awaitable[None]] | None = None
163
+ self._channel_options = list(channel_options or ())
164
+ configured = {key for key, _ in self._channel_options}
165
+ for key in ("grpc.max_receive_message_length", "grpc.max_send_message_length"):
166
+ if key not in configured:
167
+ self._channel_options.append((key, _DEFAULT_MAX_MESSAGE_BYTES))
168
+ if local_token is not None:
169
+ configured = {key for key, _ in self._channel_options}
170
+ for key, value in (
171
+ ("grpc.keepalive_time_ms", 15000),
172
+ ("grpc.keepalive_timeout_ms", 10000),
173
+ ("grpc.keepalive_permit_without_calls", 1),
174
+ ):
175
+ if key not in configured:
176
+ self._channel_options.append((key, value))
177
+ _check_compression_channel_option_conflict(self._channel_options, compression)
178
+ # Channel-level default compression; survives every reconnect because
179
+ # _open_channel() re-applies it to each new aio channel. ``None`` means
180
+ # unspecified, so legacy raw channel options decide the encoding.
181
+ self._compression = compression
182
+ self._channel: grpc.aio.Channel | None = None
183
+ self._stub: queue_pb2_grpc.QueueServiceStub | None = None
184
+ self._session_minor = PROTOCOL_MINOR
185
+ self._session_token = ""
186
+ self._lock = asyncio.Lock()
187
+ self._closed = False
188
+ #: Optional client-provided reconnect hook (daemon restart + channel
189
+ #: rebuild). Invoked under ``_lock`` instead of plain ``negotiate``.
190
+ self.on_reconnect: Callable[[], Awaitable[None]] | None = None
191
+
192
+ # -- lifecycle ---------------------------------------------------------
193
+
194
+ def _open_channel(self) -> None:
195
+ if self._closed:
196
+ raise RingoError("connection is closed")
197
+ if self._tls:
198
+ credentials = grpc.ssl_channel_credentials()
199
+ self._channel = grpc.aio.secure_channel(
200
+ self._endpoint,
201
+ credentials,
202
+ options=self._channel_options,
203
+ compression=self._compression,
204
+ )
205
+ else:
206
+ self._channel = grpc.aio.insecure_channel(
207
+ self._endpoint,
208
+ options=self._channel_options,
209
+ compression=self._compression,
210
+ )
211
+ self._stub = queue_pb2_grpc.QueueServiceStub(self._channel)
212
+
213
+ async def connect(self, *, check_health: bool = False) -> None:
214
+ self._open_channel()
215
+ if check_health:
216
+ await self._await_serving()
217
+ if self._local_token is not None:
218
+ await self.attach_local()
219
+ await self.negotiate()
220
+
221
+ async def attach_local(self) -> None:
222
+ """Register this SDK process as a shared-daemon member."""
223
+ if self._local_token is None or self._local_generation is None:
224
+ return
225
+ stub = self._require_stub()
226
+ try:
227
+ request = queue_pb2.AttachLocalRequest(generation=self._local_generation)
228
+ call = stub.AttachLocal(request, metadata=self._metadata())
229
+ response = await call.read()
230
+ if response is grpc.aio.EOF or response is None:
231
+ raise UnavailableError(
232
+ "shared daemon closed AttachLocal during handshake"
233
+ )
234
+ if self._closed:
235
+ call.cancel()
236
+ raise RingoError("connection is closed")
237
+ if response.generation != self._local_generation:
238
+ call.cancel()
239
+ raise UnavailableError(
240
+ "shared daemon generation changed during AttachLocal"
241
+ )
242
+ except grpc.aio.AioRpcError as exc:
243
+ raise map_rpc_error(exc) from exc
244
+ self._attach_call = call
245
+ self._attach_reader_task = asyncio.create_task(
246
+ self._consume_attach(call), name="ringo-shared-attach"
247
+ )
248
+
249
+ async def _consume_attach(self, call: Any) -> None:
250
+ try:
251
+ while True:
252
+ response = await call.read()
253
+ if response is grpc.aio.EOF:
254
+ break
255
+ if response is None:
256
+ break
257
+ if response.generation != self._local_generation:
258
+ break
259
+ except asyncio.CancelledError:
260
+ return
261
+ except grpc.aio.AioRpcError:
262
+ pass
263
+ finally:
264
+ if call is self._attach_call:
265
+ self._attach_call = None
266
+ if not self._closed and self._shared_disconnect is not None:
267
+ asyncio.ensure_future(self._shared_disconnect())
268
+
269
+ def set_shared_session(
270
+ self, token: str, generation: str, on_disconnect: Callable[[], Awaitable[None]]
271
+ ) -> None:
272
+ self._local_token = token
273
+ self._local_generation = generation
274
+ self._shared_disconnect = on_disconnect
275
+
276
+ async def _close_attach(self) -> None:
277
+ task, self._attach_reader_task = self._attach_reader_task, None
278
+ call, self._attach_call = self._attach_call, None
279
+ if call is not None:
280
+ try:
281
+ call.cancel()
282
+ except Exception:
283
+ pass
284
+ if task is not None and task is not asyncio.current_task():
285
+ task.cancel()
286
+ await asyncio.gather(task, return_exceptions=True)
287
+
288
+ async def _await_serving(self, timeout: float = 5.0) -> None:
289
+ """Wait until the health service reports SERVING (local daemon
290
+ readiness is announced on stdout before the gRPC health state may be
291
+ observable, so poll briefly instead of assuming)."""
292
+ assert self._channel is not None
293
+ health_stub = health_pb2_grpc.HealthStub(self._channel)
294
+ deadline = asyncio.get_running_loop().time() + timeout
295
+ last: Exception | None = None
296
+ while True:
297
+ if self._closed:
298
+ raise RingoError("connection is closed")
299
+ try:
300
+ response = await health_stub.Check(
301
+ health_pb2.HealthCheckRequest(),
302
+ timeout=2.0,
303
+ metadata=self._metadata(),
304
+ )
305
+ if response.status == health_pb2.HealthCheckResponse.SERVING:
306
+ return
307
+ last = RingoError("daemon health status is not SERVING")
308
+ except grpc.aio.AioRpcError as exc:
309
+ last = exc
310
+ if asyncio.get_running_loop().time() >= deadline:
311
+ raise RingoError(f"daemon did not report SERVING health: {last}")
312
+ await asyncio.sleep(0.05)
313
+
314
+ async def reset(self, endpoint: str) -> None:
315
+ """Point the connection at a new endpoint (local daemon restart)."""
316
+ if self._closed:
317
+ raise RingoError("connection is closed")
318
+ await self._close_attach()
319
+ self._endpoint, self._tls = _parse_endpoint(endpoint)
320
+ channel, self._channel = self._channel, None
321
+ self._stub = None
322
+ if channel is not None:
323
+ await channel.close()
324
+ self._open_channel()
325
+
326
+ async def negotiate(self) -> None:
327
+ """Perform the major=1/minor=0 version handshake (ADR 0002)."""
328
+ stub = self._require_stub()
329
+ request = queue_pb2.NegotiateRequest(
330
+ sdk_name=SDK_NAME,
331
+ sdk_version=SDK_VERSION,
332
+ protocol_major=PROTOCOL_MAJOR,
333
+ protocol_minor=PROTOCOL_MINOR,
334
+ )
335
+ try:
336
+ response = await stub.Negotiate(
337
+ request, timeout=DEFAULT_RPC_TIMEOUT, metadata=self._metadata()
338
+ )
339
+ except grpc.aio.AioRpcError as exc:
340
+ raise map_rpc_error(exc) from exc
341
+ if self._closed:
342
+ raise RingoError("connection is closed")
343
+ if response.protocol_major != PROTOCOL_MAJOR:
344
+ raise IncompatibleVersionError(
345
+ f"server speaks protocol major {response.protocol_major},"
346
+ f" SDK requires major {PROTOCOL_MAJOR}; upgrade one side"
347
+ )
348
+ self._session_minor = min(response.protocol_minor, PROTOCOL_MINOR)
349
+ self._session_token = response.session_token
350
+
351
+ async def close(self) -> None:
352
+ self._closed = True
353
+ await self._close_attach()
354
+ channel, self._channel = self._channel, None
355
+ self._stub = None
356
+ if channel is not None:
357
+ await channel.close()
358
+
359
+ # -- helpers -----------------------------------------------------------
360
+
361
+ def _require_stub(self) -> queue_pb2_grpc.QueueServiceStub:
362
+ if self._closed or self._stub is None:
363
+ raise RingoError("connection is closed")
364
+ return self._stub
365
+
366
+ def _metadata(self) -> list[tuple[str, str]]:
367
+ metadata: list[tuple[str, str]] = []
368
+ if self._token:
369
+ metadata.append(("authorization", f"Bearer {self._token}"))
370
+ if self._local_token:
371
+ metadata.append(("x-ringo-local-token", self._local_token))
372
+ return metadata
373
+
374
+ def request_context(self) -> queue_pb2.RequestContext:
375
+ return queue_pb2.RequestContext(
376
+ protocol_major=PROTOCOL_MAJOR,
377
+ protocol_minor=self._session_minor,
378
+ session_token=self._session_token,
379
+ )
380
+
381
+ @property
382
+ def endpoint(self) -> str:
383
+ return self._endpoint
384
+
385
+ @property
386
+ def protocol_minor(self) -> int:
387
+ """Protocol minor negotiated for the current session."""
388
+ return self._session_minor
389
+
390
+ async def call(
391
+ self,
392
+ name: str,
393
+ request: Any,
394
+ *,
395
+ timeout: float = DEFAULT_RPC_TIMEOUT,
396
+ retry_safe: bool = False,
397
+ ) -> Any:
398
+ """Issue a single unary RPC.
399
+
400
+ Submission is never silently replayed: an ``UNAVAILABLE`` may mean the
401
+ server committed the operation (a lost ACK response, a claimed lease)
402
+ so the original error is raised after best-effort recovery (daemon
403
+ restart + fresh negotiation) prepares the connection for *future*
404
+ calls. Only genuinely read-only RPCs (task, history, and stats reads)
405
+ pass ``retry_safe=True`` and may be re-sent, each time with a freshly
406
+ negotiated ``RequestContext``.
407
+ """
408
+ rpc, sent_token = await self._begin_unary(name, request, timeout=timeout)
409
+ cause: BaseException | None = None
410
+ err: RingoError | None = None
411
+ try:
412
+ return await rpc
413
+ except grpc.aio.AioRpcError as exc:
414
+ cause = exc
415
+ err = map_rpc_error(exc)
416
+ except asyncio.CancelledError as exc:
417
+ # grpc.aio raises CancelledError when a reconnect closes its old
418
+ # channel. Preserve genuine caller cancellation, but surface this
419
+ # transport race as UNAVAILABLE.
420
+ task = asyncio.current_task()
421
+ if task is not None and task.cancelling():
422
+ raise
423
+ cause = exc
424
+ err = UnavailableError("RPC interrupted by connection recovery")
425
+
426
+ assert err is not None and cause is not None
427
+ if self._closed:
428
+ raise err from cause
429
+ if isinstance(err, IncompatibleVersionError):
430
+ # Session rejection happens in authorization before the operation
431
+ # can execute. It is therefore safe to negotiate a fresh short-lived
432
+ # token and retry once, including ordinary token expiry with no
433
+ # concurrent daemon restart.
434
+ await self._recover_best_effort()
435
+ async with self._lock:
436
+ rotated = self._session_token != sent_token
437
+ if rotated:
438
+ rpc, _ = await self._begin_unary(name, request, timeout=timeout)
439
+ try:
440
+ return await rpc
441
+ except grpc.aio.AioRpcError as retry_exc:
442
+ raise map_rpc_error(retry_exc) from retry_exc
443
+ raise err from cause
444
+ if not isinstance(err, UnavailableError):
445
+ raise err from cause
446
+
447
+ # Recovery prepares the connection for future calls. Mutations are not
448
+ # replayed because an unavailable response may hide a committed write.
449
+ await self._recover_best_effort()
450
+ if not retry_safe:
451
+ raise err from cause
452
+ rpc, _ = await self._begin_unary(name, request, timeout=timeout)
453
+ try:
454
+ return await rpc
455
+ except grpc.aio.AioRpcError as retry_exc:
456
+ raise map_rpc_error(retry_exc) from retry_exc
457
+
458
+ async def _begin_unary(
459
+ self,
460
+ name: str,
461
+ request: Any,
462
+ *,
463
+ timeout: float,
464
+ ) -> tuple[Any, str]:
465
+ """Snapshot a stub and fresh session context outside reconnect races."""
466
+ async with self._lock:
467
+ # Check every send, including a read retry after renegotiation.
468
+ if (
469
+ name == "GetTaskAttempts"
470
+ and self._session_minor < _ATTEMPT_HISTORY_MIN_PROTOCOL_MINOR
471
+ ):
472
+ raise IncompatibleVersionError(
473
+ "attempt history requires protocol minor >= 5 (GetTaskAttempts);"
474
+ f" negotiated minor is {self._session_minor}"
475
+ )
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())
482
+ stub = self._require_stub()
483
+ token = self._session_token
484
+ rpc = getattr(stub, name)(
485
+ request, timeout=timeout, metadata=self._metadata()
486
+ )
487
+ return rpc, token
488
+
489
+ async def _recover_best_effort(self) -> None:
490
+ """Bounded reconnect so later calls can succeed; failures here are
491
+ swallowed because the original call error is what the caller sees."""
492
+ for attempt in range(_RECONNECT_ATTEMPTS):
493
+ try:
494
+ await asyncio.sleep(
495
+ _BACKOFF_SECONDS[min(attempt, len(_BACKOFF_SECONDS) - 1)]
496
+ )
497
+ async with self._lock:
498
+ if self._closed:
499
+ return
500
+ if self.on_reconnect is not None:
501
+ await self.on_reconnect()
502
+ else:
503
+ await self.negotiate()
504
+ return
505
+ except Exception:
506
+ continue
507
+
508
+
509
+ def new_worker_id() -> str:
510
+ return f"py-{os.getpid()}-{secrets.token_hex(4)}"
511
+
512
+
513
+ # ---------------------------------------------------------------------------
514
+ # Public client
515
+
516
+
517
+ class RingoClient:
518
+ """A connected (or connectable) Ringo client.
519
+
520
+ Use as an async context manager, or call :meth:`start` / :meth:`close`
521
+ explicitly::
522
+
523
+ async with Ringo.connect("127.0.0.1:7233") as client:
524
+ queue = client.queue("crawl")
525
+ """
526
+
527
+ def __init__(
528
+ self,
529
+ *,
530
+ endpoint: str | None = None,
531
+ token: str | None = None,
532
+ daemon: "LocalDaemon | None" = None,
533
+ shared_daemon: "SharedLocalDaemon | None" = None,
534
+ channel_options: Iterable[tuple[str, Any]] | None = None,
535
+ compression: CompressionOption | None = None,
536
+ ) -> None:
537
+ self._endpoint = endpoint
538
+ self._token = token
539
+ self._channel_options = channel_options
540
+ # The value is always validated, but ``compression`` only applies to
541
+ # remote connections; local mode stays on the (uncompressed) default.
542
+ normalized = _normalize_compression(compression)
543
+ if daemon is None and shared_daemon is None:
544
+ _check_compression_channel_option_conflict(
545
+ self._channel_options, normalized
546
+ )
547
+ self._compression = normalized
548
+ self._compression_label: CompressionOption = (
549
+ compression if compression is not None else "none"
550
+ )
551
+ else:
552
+ # Local mode never enables compression; an explicit value is
553
+ # validated above and then ignored (source compatibility).
554
+ self._compression = None
555
+ self._compression_label = "none"
556
+ self._daemon = daemon
557
+ self._shared_daemon = shared_daemon
558
+ if daemon is not None and shared_daemon is not None:
559
+ raise InvalidArgumentError(
560
+ "daemon and shared_daemon are mutually exclusive"
561
+ )
562
+ # ``_conn`` is built in start(): a local client learns the daemon's
563
+ # random loopback port only after the daemon announces readiness.
564
+ self._conn: _Connection | None = None
565
+ self._queues: dict[str, TaskQueue] = {}
566
+ self._workers: set[Worker] = set()
567
+ self._started = False
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.
572
+ self._start_lock = asyncio.Lock()
573
+ self._close_lock = asyncio.Lock()
574
+ self._close_task: asyncio.Task[None] | None = None
575
+ self._daemon_restart_count = 0
576
+
577
+ # -- lifecycle ---------------------------------------------------------
578
+
579
+ def _check_open(self) -> None:
580
+ if self._closed:
581
+ raise RingoError("client is closed")
582
+
583
+ async def start(self) -> "RingoClient":
584
+ async with self._start_lock:
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)
592
+ if not self._started:
593
+ try:
594
+ for attempt in range(2 if self._shared_daemon is not None else 1):
595
+ if self._daemon is not None:
596
+ self._endpoint = await self._daemon.start()
597
+ elif self._shared_daemon is not None:
598
+ self._endpoint = await self._shared_daemon.start()
599
+ self._check_open()
600
+ conn = _Connection(
601
+ self._endpoint or "",
602
+ self._token,
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
+ ),
613
+ channel_options=self._channel_options,
614
+ compression=self._compression,
615
+ )
616
+ self._conn = conn
617
+ conn.on_reconnect = self._reconnect
618
+ if self._shared_daemon is not None:
619
+ conn.set_shared_session(
620
+ self._shared_daemon.token or "",
621
+ self._shared_daemon.generation or "",
622
+ self._shared_stream_lost,
623
+ )
624
+ try:
625
+ await conn.connect(
626
+ check_health=self._daemon is not None
627
+ or self._shared_daemon is not None
628
+ )
629
+ self._check_open()
630
+ break
631
+ except Exception:
632
+ await conn.close()
633
+ self._conn = None
634
+ if (
635
+ self._closed
636
+ or self._shared_daemon is None
637
+ or attempt > 0
638
+ ):
639
+ raise
640
+ # Stale PID/port, wrong token or generation is never
641
+ # accepted as identity. Re-elect once, boundedly.
642
+ self._shared_daemon.invalidate_current()
643
+ except BaseException:
644
+ # Fast cleanup: never leak a half-started channel or local daemon.
645
+ failed_conn = self._conn
646
+ self._conn = None
647
+ if failed_conn is not None:
648
+ await failed_conn.close()
649
+ await self._stop_daemon()
650
+ raise
651
+ self._started = True
652
+ return self
653
+
654
+ def _require_conn(self) -> _Connection:
655
+ conn = self._conn
656
+ if conn is None:
657
+ raise RingoError(
658
+ "client is not started; use 'async with' or await client.start()"
659
+ )
660
+ return conn
661
+
662
+ async def _reconnect(self) -> None:
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()
680
+ await conn.reset(endpoint)
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()
704
+
705
+ async def _shared_stream_lost(self) -> None:
706
+ """Proactively reconnect an idle shared client after AttachLocal EOF."""
707
+ if self._closed or not self._started or self._shared_daemon is None:
708
+ return
709
+ conn = self._conn
710
+ if conn is None or conn._closed:
711
+ return
712
+ # A stream can report EOF more than once while a replacement is being
713
+ # opened. Serialize recovery through the connection's normal lock.
714
+ for attempt in range(_MAX_DAEMON_RESTARTS + 1):
715
+ if self._closed:
716
+ return
717
+ try:
718
+ await asyncio.sleep(
719
+ _BACKOFF_SECONDS[min(attempt, len(_BACKOFF_SECONDS) - 1)]
720
+ )
721
+ async with conn._lock:
722
+ await self._reconnect()
723
+ return
724
+ except Exception:
725
+ continue
726
+
727
+ async def __aenter__(self) -> "RingoClient":
728
+ return await self.start()
729
+
730
+ async def close(self) -> None:
731
+ """Drain and stop once; caller cancellation cannot cancel cleanup."""
732
+ async with self._close_lock:
733
+ if self._close_task is None:
734
+ self._closed = True
735
+ self._close_task = asyncio.create_task(
736
+ self._close_impl(), name="ringo-client-close"
737
+ )
738
+ self._close_task.add_done_callback(_consume_task_exception)
739
+ close_task = self._close_task
740
+ await asyncio.shield(close_task)
741
+
742
+ async def _close_impl(self) -> None:
743
+ """Attempt every cleanup step and aggregate any failures."""
744
+ errors: list[Exception] = []
745
+ # 1. Drain workers (stop taking tasks, wait grace, cancel remainder).
746
+ for worker in list(self._workers):
747
+ try:
748
+ await worker.close()
749
+ except Exception as exc: # keep cleaning up the rest
750
+ errors.append(exc)
751
+ self._workers.clear()
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
756
+ if conn is not None:
757
+ try:
758
+ await conn.close()
759
+ except Exception as exc:
760
+ errors.append(exc)
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
776
+ if errors:
777
+ raise ExceptionGroup("errors while closing Ringo client", errors)
778
+
779
+ async def _stop_daemon(self) -> None:
780
+ daemon, self._daemon = self._daemon, None
781
+ if daemon is not None:
782
+ await daemon.stop()
783
+
784
+ async def __aexit__(self, exc_type, exc, tb) -> None:
785
+ try:
786
+ await self.close()
787
+ except ExceptionGroup as cleanup_errors:
788
+ # A business exception takes precedence; cleanup failures must
789
+ # not mask it, but should not vanish either.
790
+ if exc is None:
791
+ raise
792
+ for error in cleanup_errors.exceptions:
793
+ exc.__cause__ = exc.__cause__ or error
794
+
795
+ @property
796
+ def is_local(self) -> bool:
797
+ return self._daemon is not None or self._shared_daemon is not None
798
+
799
+ @property
800
+ def compression(self) -> CompressionOption:
801
+ """The effective channel compression: ``"none"`` (default) or ``"gzip"``."""
802
+ return self._compression_label
803
+
804
+ # -- queues ------------------------------------------------------------
805
+
806
+ def queue(self, name: str) -> "TaskQueue":
807
+ if not name:
808
+ raise InvalidArgumentError("queue name is required")
809
+ if self._closed:
810
+ raise RingoError("client is closed")
811
+ existing = self._queues.get(name)
812
+ if existing is None:
813
+ existing = TaskQueue(self, name)
814
+ self._queues[name] = existing
815
+ return existing
816
+
817
+ def _register_worker(self, worker: "Worker") -> None:
818
+ self._workers.add(worker)
819
+
820
+ def _unregister_worker(self, worker: "Worker") -> None:
821
+ self._workers.discard(worker)
822
+
823
+
824
+ class Ringo:
825
+ """Factory for local (embedded daemon) and remote clients.
826
+
827
+ Both factories are synchronous and return a client whose
828
+ ``async with``/``await start()`` performs the actual connect and
829
+ negotiation, so ``async with Ringo.local(...)`` works directly.
830
+ """
831
+
832
+ @staticmethod
833
+ def local(
834
+ data_dir: str | os.PathLike[str],
835
+ storage: str | SQLiteStorage | PostgresStorage = "sqlite",
836
+ daemon_path: str | os.PathLike[str] | None = None,
837
+ *,
838
+ shared: bool = False,
839
+ startup_timeout: float = 15.0,
840
+ stop_timeout: float = 10.0,
841
+ ) -> RingoClient:
842
+ """Start a private daemon, or opt into a shared SQLite daemon."""
843
+ if not isinstance(shared, bool):
844
+ raise InvalidArgumentError("shared must be a boolean")
845
+ if storage == "sqlite" or isinstance(storage, SQLiteStorage):
846
+ storage_name = "sqlite"
847
+ postgres_dsn = None
848
+ elif isinstance(storage, PostgresStorage):
849
+ storage_name = "postgres"
850
+ postgres_dsn = storage.dsn
851
+ else:
852
+ raise InvalidArgumentError(f"unsupported local storage {storage!r}")
853
+ if shared and storage_name != "sqlite":
854
+ raise InvalidArgumentError("shared local mode supports SQLite only")
855
+ # Imported here so remote-only users never pay for subprocess imports.
856
+ if shared:
857
+ from .shared import SharedLocalDaemon
858
+
859
+ shared_daemon = SharedLocalDaemon(
860
+ data_dir, daemon_path=daemon_path, startup_timeout=startup_timeout
861
+ )
862
+ return RingoClient(shared_daemon=shared_daemon)
863
+ from .daemon import LocalDaemon
864
+
865
+ daemon = LocalDaemon(
866
+ data_dir,
867
+ storage=storage_name,
868
+ postgres_dsn=postgres_dsn,
869
+ daemon_path=daemon_path,
870
+ startup_timeout=startup_timeout,
871
+ stop_timeout=stop_timeout,
872
+ )
873
+ return RingoClient(daemon=daemon)
874
+
875
+ @staticmethod
876
+ def connect(
877
+ endpoint: str,
878
+ token: str | None = None,
879
+ *,
880
+ channel_options: Iterable[tuple[str, Any]] | None = None,
881
+ compression: CompressionOption | None = None,
882
+ ) -> RingoClient:
883
+ """Create a client for a remote (or already-running) endpoint.
884
+
885
+ ``compression`` is an optional whole-channel setting: ``"none"``
886
+ (the default) or ``"gzip"``. ``None`` means "not specified", leaving
887
+ the gRPC channel default (and any raw ``channel_options`` compression
888
+ entry) in charge. An explicit value combined with the raw channel
889
+ option ``grpc.default_compression_algorithm`` raises
890
+ :class:`InvalidArgumentError`; any other value does so as well,
891
+ immediately and before any network activity.
892
+ """
893
+ return RingoClient(
894
+ endpoint=endpoint,
895
+ token=token,
896
+ channel_options=channel_options,
897
+ compression=compression,
898
+ )
899
+
900
+
901
+ # ---------------------------------------------------------------------------
902
+ # TaskQueue
903
+
904
+
905
+ Handler = Callable[[Task, Any], Coroutine[Any, Any, None]]
906
+
907
+
908
+ class TaskQueue:
909
+ """A client bound to one logical queue (``queue + uniqueKey`` dedupe)."""
910
+
911
+ def __init__(self, client: RingoClient, name: str) -> None:
912
+ self._client = client
913
+ self.name = name
914
+
915
+ # -- enqueue ------------------------------------------------------------
916
+
917
+ async def enqueue(
918
+ self,
919
+ spec: TaskSpec | None = None,
920
+ /,
921
+ **kwargs: Any,
922
+ ) -> EnqueueResult:
923
+ """Enqueue one task; duplicates return ``created=False`` (no raise)."""
924
+ if spec is None:
925
+ spec = TaskSpec(**kwargs)
926
+ request = queue_pb2.EnqueueRequest(queue=self.name, task=spec.to_proto())
927
+ response = await self._client._require_conn().call("Enqueue", request)
928
+ return EnqueueResult.from_proto(response.result)
929
+
930
+ async def enqueue_many(self, specs: Iterable[TaskSpec]) -> BatchResult:
931
+ """Enqueue any number of tasks, auto-batching under the server limit.
932
+
933
+ The server enforces a hard limit of ``_MAX_ENQUEUE_BATCH`` (1000)
934
+ tasks per gRPC call. This method transparently splits larger
935
+ iterables into sequential sub-batches, preserving creation order,
936
+ and aggregates the per-item results into a single :class:`BatchResult`.
937
+
938
+ Fail-fast: if any sub-batch fails, the exception propagates and no
939
+ partial result is returned.
940
+ """
941
+ specs = list(specs)
942
+ all_results: list[EnqueueResult] = []
943
+ total_created = 0
944
+ total_duplicates = 0
945
+
946
+ for start in range(0, len(specs), _MAX_ENQUEUE_BATCH):
947
+ chunk = specs[start : start + _MAX_ENQUEUE_BATCH]
948
+ request = queue_pb2.EnqueueManyRequest(
949
+ queue=self.name, tasks=[s.to_proto() for s in chunk]
950
+ )
951
+ response = await self._client._require_conn().call("EnqueueMany", request)
952
+ all_results.extend(EnqueueResult.from_proto(r) for r in response.results)
953
+ total_created += response.created_count
954
+ total_duplicates += response.duplicate_count
955
+
956
+ return BatchResult(
957
+ results=all_results,
958
+ created_count=total_created,
959
+ duplicate_count=total_duplicates,
960
+ )
961
+
962
+ # -- queries ------------------------------------------------------------
963
+
964
+ async def get_by_id(self, task_id: str) -> Task:
965
+ if not task_id:
966
+ raise InvalidArgumentError("task_id is required")
967
+ request = queue_pb2.GetTaskRequest(queue=self.name, task_id=task_id)
968
+ response = await self._client._require_conn().call(
969
+ "GetTask", request, retry_safe=True
970
+ )
971
+ return Task.from_proto(response.task)
972
+
973
+ async def get_by_unique_key(self, unique_key: str) -> Task:
974
+ if not unique_key:
975
+ raise InvalidArgumentError("unique_key is required")
976
+ request = queue_pb2.GetTaskRequest(queue=self.name, unique_key=unique_key)
977
+ response = await self._client._require_conn().call(
978
+ "GetTask", request, retry_safe=True
979
+ )
980
+ return Task.from_proto(response.task)
981
+
982
+ async def get_attempts(
983
+ self,
984
+ unique_key: str,
985
+ *,
986
+ limit: int = _ATTEMPT_PAGE_DEFAULT_LIMIT,
987
+ cursor: str | None = None,
988
+ ) -> AttemptPage:
989
+ """Read oldest-first history by business key, not the bounded Task preview.
990
+
991
+ Cursors retain the first page's history high-water mark. Pages can be
992
+ shorter than ``limit`` due to the byte budget; follow ``next_cursor``
993
+ until None. Restart without a cursor to see later appended attempts.
994
+ """
995
+ return await self._get_attempts("unique_key", unique_key, limit, cursor)
996
+
997
+ async def get_attempts_by_id(
998
+ self,
999
+ task_id: str,
1000
+ *,
1001
+ limit: int = _ATTEMPT_PAGE_DEFAULT_LIMIT,
1002
+ cursor: str | None = None,
1003
+ ) -> AttemptPage:
1004
+ """Read a bounded durable history page by explicit server task ID."""
1005
+ return await self._get_attempts("task_id", task_id, limit, cursor)
1006
+
1007
+ async def _get_attempts(
1008
+ self, selector: str, key: str, limit: int, cursor: str | None
1009
+ ) -> AttemptPage:
1010
+ if not isinstance(key, str) or not key:
1011
+ raise InvalidArgumentError(f"{selector} is required")
1012
+ if (
1013
+ isinstance(limit, bool)
1014
+ or not isinstance(limit, int)
1015
+ or not 1 <= limit <= _ATTEMPT_PAGE_MAX_LIMIT
1016
+ ):
1017
+ raise InvalidArgumentError("limit must be an integer between 1 and 100")
1018
+ if cursor is not None:
1019
+ if not isinstance(cursor, str) or not cursor:
1020
+ raise InvalidArgumentError("cursor must be a nonempty string or None")
1021
+ try:
1022
+ cursor_bytes = len(cursor.encode("utf-8"))
1023
+ except UnicodeEncodeError as exc:
1024
+ raise InvalidArgumentError("cursor must be valid UTF-8") from exc
1025
+ if cursor_bytes > _MAX_ATTEMPT_CURSOR_BYTES:
1026
+ raise InvalidArgumentError("cursor must be at most 1024 UTF-8 bytes")
1027
+ conn = self._client._require_conn()
1028
+ if conn.protocol_minor < _ATTEMPT_HISTORY_MIN_PROTOCOL_MINOR:
1029
+ raise IncompatibleVersionError(
1030
+ "attempt history requires protocol minor >= 5 (GetTaskAttempts);"
1031
+ f" negotiated minor is {conn.protocol_minor}"
1032
+ )
1033
+ request = queue_pb2.GetTaskAttemptsRequest(
1034
+ queue=self.name, limit=limit, cursor=cursor or ""
1035
+ )
1036
+ setattr(request, selector, key)
1037
+ response = await conn.call("GetTaskAttempts", request, retry_safe=True)
1038
+ return AttemptPage.from_proto(response)
1039
+
1040
+ async def stats(self, task_types: Iterable[str] | None = None) -> QueueStats:
1041
+ """Read a point-in-time per-status count snapshot for this queue.
1042
+
1043
+ ``task_types`` of ``None`` or empty counts every task type; otherwise
1044
+ only tasks matching any of the given types are included. All six
1045
+ canonical statuses are always present (zero included); ``active`` on
1046
+ the result is ``SCHEDULED + READY + LEASED``. This is a per-queue,
1047
+ per-instance observation: the SDK never fabricates a cross-instance
1048
+ aggregate or a time-window summary.
1049
+
1050
+ The read-only ``GetQueueStats`` RPC is additive protocol 1.4: when the
1051
+ negotiated session minor is lower, this raises
1052
+ :class:`IncompatibleVersionError` before any network activity.
1053
+ ``NOT_FOUND`` (unknown queue) and ``UNAVAILABLE`` propagate unchanged.
1054
+ More than 100 ``task_types`` is an :class:`InvalidArgumentError` raised
1055
+ before any RPC; at most 101 elements are consumed from the iterable.
1056
+ """
1057
+ selected: list[str] | None = None
1058
+ if task_types is not None:
1059
+ # islice caps consumption so an unbounded iterable stops at 101.
1060
+ selected = list(islice(task_types, _STATS_MAX_TASK_TYPES + 1))
1061
+ if len(selected) > _STATS_MAX_TASK_TYPES:
1062
+ raise InvalidArgumentError(
1063
+ f"task_types must contain at most {_STATS_MAX_TASK_TYPES}"
1064
+ f" values; got more than {_STATS_MAX_TASK_TYPES}"
1065
+ )
1066
+ conn = self._client._require_conn()
1067
+ if conn.protocol_minor < _STATS_MIN_PROTOCOL_MINOR:
1068
+ raise IncompatibleVersionError(
1069
+ f"queue stats requires protocol minor >= {_STATS_MIN_PROTOCOL_MINOR}"
1070
+ f" (GetQueueStats); negotiated minor is {conn.protocol_minor}"
1071
+ )
1072
+ request = queue_pb2.GetQueueStatsRequest(
1073
+ queue=self.name, task_types=selected or []
1074
+ )
1075
+ response = await conn.call("GetQueueStats", request, retry_safe=True)
1076
+ return QueueStats.from_proto(response)
1077
+
1078
+ async def retry(self, unique_key: str) -> Task:
1079
+ """Reactivate a SUCCEEDED/DEAD task by its business unique key.
1080
+
1081
+ This always resolves through the proto ``unique_key`` field; use
1082
+ :meth:`retry_by_id` for the task-ID form. No string is ever guessed.
1083
+ """
1084
+ if not unique_key:
1085
+ raise InvalidArgumentError("unique_key is required")
1086
+ request = queue_pb2.RetryTaskRequest(queue=self.name)
1087
+ request.unique_key = unique_key
1088
+ response = await self._client._require_conn().call("RetryTask", request)
1089
+ return Task.from_proto(response.task)
1090
+
1091
+ async def retry_by_id(self, task_id: str) -> Task:
1092
+ """Reactivate a SUCCEEDED/DEAD task by its server task ID."""
1093
+ if not task_id:
1094
+ raise InvalidArgumentError("task_id is required")
1095
+ request = queue_pb2.RetryTaskRequest(queue=self.name)
1096
+ request.task_id = task_id
1097
+ response = await self._client._require_conn().call("RetryTask", request)
1098
+ return Task.from_proto(response.task)
1099
+
1100
+ # -- manual claim ---------------------------------------------------------
1101
+
1102
+ async def claim(
1103
+ self,
1104
+ limit: int = 1,
1105
+ *,
1106
+ task_types: Iterable[str] | None = None,
1107
+ wait_timeout: timedelta | None = None,
1108
+ lease_duration: timedelta | None = None,
1109
+ worker_id: str | None = None,
1110
+ ) -> list["LeasedTask"]:
1111
+ """Manually claim tasks (advanced API; not the default worker path)."""
1112
+ if limit < 1 or limit > _MAX_ENQUEUE_BATCH:
1113
+ raise InvalidArgumentError(
1114
+ f"limit must be between 1 and {_MAX_ENQUEUE_BATCH}"
1115
+ )
1116
+ if wait_timeout is not None and wait_timeout < timedelta(0):
1117
+ raise InvalidArgumentError("wait_timeout must not be negative")
1118
+ if lease_duration is not None and lease_duration < timedelta(0):
1119
+ raise InvalidArgumentError("lease_duration must not be negative")
1120
+ request = queue_pb2.ClaimRequest(
1121
+ queue=self.name,
1122
+ worker_id=worker_id or new_worker_id(),
1123
+ task_types=list(task_types or []),
1124
+ limit=limit,
1125
+ )
1126
+ if wait_timeout is not None:
1127
+ request.wait_timeout.CopyFrom(_pb.td_to_dur(wait_timeout))
1128
+ if lease_duration is not None:
1129
+ request.lease_duration.CopyFrom(_pb.td_to_dur(lease_duration))
1130
+ response = await self._client._require_conn().call("Claim", request)
1131
+ return [LeasedTask(self, t) for t in response.tasks]
1132
+
1133
+ # -- worker ---------------------------------------------------------------
1134
+
1135
+ def worker(
1136
+ self,
1137
+ handler: Handler,
1138
+ *,
1139
+ task_types: Iterable[str] | None = None,
1140
+ concurrency: int | ResourceConcurrency | AdaptiveConcurrency = 1,
1141
+ weight: int = 1,
1142
+ lease_duration: timedelta = timedelta(seconds=60),
1143
+ grace_period: timedelta = timedelta(seconds=30),
1144
+ on_idle: Callable[[], Any] | None = None,
1145
+ task_timeouts: TaskTimeouts | None = None,
1146
+ ) -> "Worker":
1147
+ """Create a credit-controlled worker over the frozen Work stream.
1148
+
1149
+ ``on_idle`` is an optional zero-argument callable (sync or awaitable)
1150
+ for bounded batch runs: once the worker has registered and sent its
1151
+ initial credit, the SDK polls queue activity every 250 ms via the
1152
+ read-only ``GetQueueActivity`` RPC scoped to this worker's
1153
+ ``task_types``; after the activity count has been continuously zero
1154
+ for at least 1 s the callback fires exactly once, inline on the
1155
+ ``run()`` coroutine. A callback that returns normally leaves the
1156
+ worker running (and never fires again); any exception it raises —
1157
+ including ``SystemExit`` — propagates verbatim out of
1158
+ ``await worker.run()`` without becoming a task NACK. Workers without
1159
+ ``on_idle`` never send activity RPCs and stay compatible with
1160
+ protocol minor 0 servers; ``on_idle`` requires minor >= 1 and raises
1161
+ :class:`IncompatibleVersionError` from ``run()`` otherwise. Never
1162
+ call ``worker.close()`` from inside the callback (it would wait on
1163
+ the very coroutine executing it).
1164
+ """
1165
+ from .worker import Worker
1166
+
1167
+ if not callable(handler):
1168
+ raise InvalidArgumentError("handler must be callable")
1169
+ if on_idle is not None and not callable(on_idle):
1170
+ raise InvalidArgumentError("on_idle must be callable")
1171
+ if isinstance(concurrency, bool) or not isinstance(
1172
+ concurrency, (int, ResourceConcurrency, AdaptiveConcurrency)
1173
+ ):
1174
+ raise InvalidArgumentError(
1175
+ "concurrency must be an integer, ResourceConcurrency, or AdaptiveConcurrency"
1176
+ )
1177
+ if isinstance(concurrency, int) and (
1178
+ concurrency < 1 or concurrency > 2**31 - 1
1179
+ ):
1180
+ raise InvalidArgumentError(
1181
+ "initial fixed concurrency must be between 1 and 2**31-1"
1182
+ )
1183
+ if isinstance(weight, bool) or not isinstance(weight, int) or weight < 1:
1184
+ raise InvalidArgumentError("weight must be a positive integer")
1185
+ if lease_duration < timedelta(0):
1186
+ raise InvalidArgumentError("lease_duration must not be negative")
1187
+ if grace_period < timedelta(0):
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")
1191
+ worker = Worker(
1192
+ self,
1193
+ handler,
1194
+ task_types=list(task_types or []),
1195
+ concurrency=concurrency,
1196
+ lease_duration=lease_duration,
1197
+ grace_period=grace_period,
1198
+ on_idle=on_idle,
1199
+ weight=weight,
1200
+ task_timeouts=task_timeouts,
1201
+ )
1202
+ self._client._register_worker(worker)
1203
+ return worker
1204
+
1205
+
1206
+ # ---------------------------------------------------------------------------
1207
+ # Manual lease
1208
+
1209
+
1210
+ class LeasedTask:
1211
+ """A task claimed via :meth:`TaskQueue.claim` with its current lease."""
1212
+
1213
+ def __init__(self, queue: TaskQueue, msg: queue_pb2.LeasedTask) -> None:
1214
+ self._queue = queue
1215
+ self.task = Task.from_proto(msg.task)
1216
+ self.attempt: int = msg.lease.attempt
1217
+ self.worker_id: str = msg.lease.worker_id
1218
+ self._token: str = msg.lease.lease_token
1219
+ self._lease_until = _pb.ts_to_dt(msg.lease.until)
1220
+
1221
+ @property
1222
+ def lease_until(self):
1223
+ return self._lease_until
1224
+
1225
+ def _ref(self) -> queue_pb2.LeaseRef:
1226
+ return queue_pb2.LeaseRef(
1227
+ task_id=self.task.id,
1228
+ attempt=self.attempt,
1229
+ lease_token=self._token,
1230
+ )
1231
+
1232
+ async def _terminal(self, name: str, request: Any) -> None:
1233
+ request.lease.CopyFrom(self._ref())
1234
+ conn = self._queue._client._require_conn()
1235
+ await conn.call(name, request)
1236
+
1237
+ async def ack(self) -> None:
1238
+ """Acknowledge success for the current attempt/lease token."""
1239
+ await self._terminal("Ack", queue_pb2.AckRequest())
1240
+
1241
+ async def nack(self, error: str = "", *, delay: timedelta | None = None) -> None:
1242
+ """Negative-acknowledge; the task is retried per policy."""
1243
+ request = queue_pb2.NackRequest(error=error)
1244
+ if delay is not None:
1245
+ request.retry_delay_override.CopyFrom(_pb.td_to_dur(delay))
1246
+ await self._terminal("Nack", request)
1247
+
1248
+ async def reject(self, error: str = "") -> None:
1249
+ """Permanently fail the task (DEAD) for the current attempt."""
1250
+ await self._terminal("Reject", queue_pb2.RejectRequest(error=error))
1251
+
1252
+ async def extend_lease(self, extension: timedelta) -> None:
1253
+ """Extend the lease to ``server_now + extension`` (absolute reset)."""
1254
+ if extension <= timedelta(0):
1255
+ raise InvalidArgumentError("extension must be positive")
1256
+ request = queue_pb2.ExtendLeaseRequest()
1257
+ request.extension.CopyFrom(_pb.td_to_dur(extension))
1258
+ request.lease.CopyFrom(self._ref())
1259
+ conn = self._queue._client._require_conn()
1260
+ response = await conn.call("ExtendLease", request)
1261
+ self._lease_until = _pb.ts_to_dt(response.lease.until)