ringo-task-queue 0.1.0.dev4__py3-none-win_amd64.whl → 0.1.0.dev5__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.
@@ -1,1297 +1,1297 @@
1
- """Asyncio worker implementation for the credit-controlled Work stream.
2
-
3
- The worker deliberately owns one reader and one writer for every Work call.
4
- Handlers only receive :class:`TaskContext`; lease tokens stay in this module.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import asyncio
10
- import inspect
11
- import math
12
- from dataclasses import dataclass
13
- from datetime import datetime, timedelta, timezone
14
- from typing import Any, Awaitable, Callable, Iterable
15
-
16
- import grpc
17
-
18
- from . import _pb
19
- from ._proto.ringo.v1 import queue_pb2
20
- from .concurrency import AdaptiveConcurrency, ResourceConcurrency, _Member
21
- from .errors import (
22
- IncompatibleVersionError,
23
- LeaseLostError,
24
- PermanentTaskError,
25
- RetryTaskError,
26
- RingoError,
27
- UnavailableError,
28
- error_from_detail,
29
- map_rpc_error,
30
- )
31
- from .models import Task
32
-
33
- _MAX_ERROR_BYTES = 256 * 1024
34
-
35
- #: Fixed on_idle monitor cadence (design §10.6). These are deliberately not
36
- #: exposed as Worker parameters: the poll interval and the continuous-idle
37
- #: quiet period are protocol-level constants of the idle-observation feature.
38
- _IDLE_POLL_SECONDS = 0.25
39
- _IDLE_QUIET_SECONDS = 1.0
40
- #: GetQueueActivity was introduced in protocol minor 1.
41
- _IDLE_MIN_PROTOCOL_MINOR = 1
42
-
43
-
44
- def _consume_task_exception(task: asyncio.Task[Any]) -> None:
45
- if not task.cancelled():
46
- task.exception()
47
-
48
-
49
- def _safe_error(value: object) -> str:
50
- """Return a valid UTF-8 error string no larger than 256 KiB."""
51
- try:
52
- text = str(value)
53
- except Exception:
54
- text = type(value).__name__
55
- encoded = text.encode("utf-8", errors="replace")
56
- if len(encoded) <= _MAX_ERROR_BYTES:
57
- return encoded.decode("utf-8", errors="replace")
58
- # Decode with replacement after truncating bytes; this is UTF-8 safe and
59
- # never emits a broken surrogate or an over-sized protobuf string.
60
- return encoded[:_MAX_ERROR_BYTES].decode("utf-8", errors="ignore")
61
-
62
-
63
- class TaskContext:
64
- """Context supplied to a worker handler.
65
-
66
- ``cancelled`` becomes true when the local lease is lost, the stream drops,
67
- or the worker is closed. ``report_progress`` is correlated on the Work
68
- stream and never exposes the lease token to application code.
69
- """
70
-
71
- __slots__ = (
72
- "_worker",
73
- "_task",
74
- "_attempt",
75
- "_max_attempts",
76
- "_worker_id",
77
- "_assignment",
78
- "_cancel_event",
79
- )
80
-
81
- def __init__(
82
- self,
83
- worker: "Worker",
84
- task: Task,
85
- attempt: int,
86
- max_attempts: int,
87
- worker_id: str,
88
- assignment: "_Assignment",
89
- ) -> None:
90
- self._worker = worker
91
- self._task = task
92
- self._attempt = attempt
93
- self._max_attempts = max_attempts
94
- self._worker_id = worker_id
95
- self._assignment = assignment
96
- self._cancel_event = asyncio.Event()
97
-
98
- @property
99
- def task(self) -> Task:
100
- return self._task
101
-
102
- @property
103
- def attempt(self) -> int:
104
- return self._attempt
105
-
106
- @property
107
- def max_attempts(self) -> int:
108
- return self._max_attempts
109
-
110
- @property
111
- def worker_id(self) -> str:
112
- return self._worker_id
113
-
114
- @property
115
- def cancelled(self) -> bool:
116
- return self._cancel_event.is_set()
117
-
118
- @property
119
- def is_cancelled(self) -> bool:
120
- """Alias useful to code shared with SDKs using a verb-like name."""
121
- return self.cancelled
122
-
123
- async def wait_cancelled(self) -> None:
124
- await self._cancel_event.wait()
125
-
126
- async def report_progress(
127
- self, current: float, total: float, message: str = ""
128
- ) -> None:
129
- """Report progress for this attempt and await its correlated result."""
130
- if self.cancelled:
131
- raise LeaseLostError("task lease is no longer valid")
132
- await self._worker._report_progress(self._assignment, current, total, message)
133
-
134
- def _cancel(self) -> None:
135
- self._cancel_event.set()
136
-
137
-
138
- @dataclass
139
- class _Assignment:
140
- task: Task
141
- attempt: int
142
- token: str
143
- until: datetime
144
- context: TaskContext | None = None
145
- handler_task: asyncio.Task[Any] | None = None
146
- renew_task: asyncio.Task[Any] | None = None
147
- alive: bool = True
148
- completion_started: bool = False
149
- completion_success: bool = False
150
- completion_congestion: bool = False
151
- controller_released: bool = False
152
- fixed_released: bool = False
153
-
154
- @property
155
- def ref(self) -> queue_pb2.LeaseRef:
156
- return queue_pb2.LeaseRef(
157
- task_id=self.task.id,
158
- attempt=self.attempt,
159
- lease_token=self.token,
160
- )
161
-
162
-
163
- class Worker:
164
- """A credit-controlled asynchronous task worker.
165
-
166
- ``run`` is intentionally a blocking coroutine: it remains active until
167
- ``close`` is called. The client registers workers and drains them before
168
- closing its channel. When ``on_idle`` is configured, ``run`` additionally
169
- observes queue activity and executes the callback inline at most once.
170
- """
171
-
172
- def __init__(
173
- self,
174
- queue: Any,
175
- handler: Callable[[Task, TaskContext], Awaitable[Any]],
176
- *,
177
- task_types: Iterable[str],
178
- concurrency: int | ResourceConcurrency | AdaptiveConcurrency,
179
- lease_duration: timedelta,
180
- grace_period: timedelta,
181
- on_idle: Callable[[], Any] | None = None,
182
- weight: int = 1,
183
- ) -> None:
184
- if isinstance(concurrency, bool) or not isinstance(
185
- concurrency, (int, ResourceConcurrency, AdaptiveConcurrency)
186
- ):
187
- raise ValueError(
188
- "concurrency must be an integer, ResourceConcurrency, or AdaptiveConcurrency"
189
- )
190
- if isinstance(concurrency, int) and concurrency < 1:
191
- raise ValueError("initial fixed concurrency must be >= 1")
192
- if isinstance(weight, bool) or not isinstance(weight, int) or weight < 1:
193
- raise ValueError("weight must be a positive integer")
194
- self._queue = queue
195
- self._client = queue._client
196
- self._handler = handler
197
- self._task_types = list(task_types)
198
- self._controller: ResourceConcurrency | AdaptiveConcurrency | None = (
199
- concurrency if not isinstance(concurrency, int) else None
200
- )
201
- self._member: _Member | None = None
202
- self._concurrency = concurrency if isinstance(concurrency, int) else max(1, concurrency.capacity)
203
- self._configured_concurrency = self._concurrency
204
- self._weight = weight
205
- self._lease_duration = lease_duration
206
- self._grace_period = max(0.0, grace_period.total_seconds())
207
- self._worker_id = self._new_worker_id()
208
- self._on_idle = on_idle
209
- # on_idle monitor state (design §10.6). ``_idle_candidate`` arms only
210
- # after WorkerRegistered plus the initial credit of the *current*
211
- # session; every reset condition disarms it or clears the zero timer.
212
- self._idle_candidate = False
213
- self._idle_zero_since: float | None = None
214
- self._idle_fired = False
215
- #: Session token captured at WorkerRegistered + initial credit. The
216
- #: candidate is only observable while the connection still carries
217
- #: this token: a unary-triggered renegotiation rotates the token
218
- #: without touching the Work stream and must disarm the monitor.
219
- self._idle_session_token: str | None = None
220
- #: Set when the on_idle callback raised: run() must re-raise verbatim
221
- #: instead of counting it as a Work-stream reconnect failure.
222
- self._idle_error: BaseException | None = None
223
-
224
- # close_started means no new stream may be opened, while force_cancel
225
- # is raised only after the graceful drain window expires. Existing
226
- # handlers and renewals remain fully live between those two points.
227
- self._close_started = asyncio.Event()
228
- self._force_cancel = asyncio.Event()
229
- self._run_started = False
230
- self._run_finished = asyncio.Event()
231
- self._close_finished = asyncio.Event()
232
- self._command_timeout = 30.0
233
- self._close_lock = asyncio.Lock()
234
- self._close_task: asyncio.Task[None] | None = None
235
- self._session_done: asyncio.Event | None = None
236
- self._call: Any = None
237
- self._outbox: asyncio.Queue[Any] | None = None
238
- self._reader_task: asyncio.Task[Any] | None = None
239
- self._writer_task: asyncio.Task[Any] | None = None
240
- self._registered: asyncio.Event | None = None
241
- self._stream_error: BaseException | None = None
242
- self._next_request_id = 1
243
- self._pending: dict[int, asyncio.Future[queue_pb2.WorkResult]] = {}
244
- self._inflight: dict[str, _Assignment] = {}
245
- # Handler lifetime, not just lease-map membership, owns local capacity.
246
- # Old-stream handlers that swallow cancellation remain here until their
247
- # coroutine actually settles.
248
- self._handler_tasks: set[asyncio.Task[Any]] = set()
249
- self._fixed_running = 0
250
- # Every capacity update shares one publisher mutex and a monotonically
251
- # increasing session epoch. A late task from an old stream can never
252
- # publish credit onto a replacement stream or after close.
253
- self._publisher_lock = asyncio.Lock()
254
- self._session_epoch = 0
255
- self._publisher_closed = False
256
- self._probe: tuple[_Member, int] | None = None
257
- self._detached = False
258
- self._controller_registered = False
259
- self._fixed_dynamic_target = False
260
- if self._controller is not None:
261
- self._member = self._controller._attach(self, weight)
262
- self._controller_registered = True
263
-
264
- @staticmethod
265
- def _new_worker_id() -> str:
266
- # Importing here avoids coupling worker construction to client internals.
267
- from .client import new_worker_id
268
-
269
- return new_worker_id()
270
-
271
- @property
272
- def worker_id(self) -> str:
273
- return self._worker_id
274
-
275
- @property
276
- def concurrency(self) -> int:
277
- """Current fixed target, or the controller's current capacity."""
278
- if self._controller is not None:
279
- return self._controller.capacity
280
- return self._concurrency
281
-
282
- @property
283
- def weight(self) -> int:
284
- return self._weight
285
-
286
- async def set_concurrency(self, concurrency: int) -> None:
287
- """Resize a fixed worker without cancelling running handlers."""
288
- if self._controller is not None:
289
- raise ValueError("a Worker attached to a shared controller cannot resize itself")
290
- if isinstance(concurrency, bool) or not isinstance(concurrency, int):
291
- raise ValueError("concurrency must be an integer")
292
- if concurrency < 0 or concurrency > 2**31 - 1:
293
- raise ValueError("concurrency must be between 0 and 2**31-1")
294
- if self._run_started:
295
- conn = self._client._require_conn()
296
- if conn._session_minor < 2:
297
- raise IncompatibleVersionError(
298
- "runtime fixed concurrency requires protocol minor >= 2; "
299
- f"negotiated minor is {conn._session_minor}"
300
- )
301
- self._concurrency = concurrency
302
- self._fixed_dynamic_target = True
303
- if self._session_done is not None and not self._session_done.is_set():
304
- await self._publish_fixed_capacity()
305
-
306
- async def run(self) -> None:
307
- """Run until :meth:`close` is called, reconnecting boundedly."""
308
- if self._run_started:
309
- raise RuntimeError("worker.run() may only be called once")
310
- self._run_started = True
311
- failures = 0
312
- try:
313
- conn = self._client._require_conn()
314
- if (
315
- (self._controller is not None or self._fixed_dynamic_target)
316
- and conn._session_minor < 2
317
- ):
318
- raise IncompatibleVersionError(
319
- "dynamic concurrency requires protocol minor >= 2; "
320
- f"negotiated minor is {conn._session_minor}"
321
- )
322
- if self._on_idle is not None:
323
- # GetQueueActivity arrived in protocol minor 1; fail fast
324
- # instead of silently running without idle observation.
325
- conn = self._client._require_conn()
326
- if conn._session_minor < _IDLE_MIN_PROTOCOL_MINOR:
327
- raise IncompatibleVersionError(
328
- "on_idle requires a server speaking protocol minor"
329
- f" >= {_IDLE_MIN_PROTOCOL_MINOR}; negotiated minor"
330
- f" is {conn._session_minor}"
331
- )
332
- while not self._close_started.is_set():
333
- try:
334
- await self._run_session_monitored()
335
- except asyncio.CancelledError:
336
- if not self._close_started.is_set():
337
- raise
338
- break
339
- except Exception as exc:
340
- if self._idle_error is not None:
341
- # An on_idle callback failure is not a transport
342
- # error: it must not be retried, reconnected, or
343
- # converted into a task NACK.
344
- raise
345
- if isinstance(exc, IncompatibleVersionError):
346
- # A reconnect may have renegotiated a protocol
347
- # downgrade that makes on_idle unservable; surface
348
- # it instead of burning the reconnect budget.
349
- raise
350
- self._stream_error = exc
351
- failures += 1
352
- if self._close_started.is_set():
353
- break
354
- if self._close_started.is_set():
355
- break
356
- # A stream loss invalidates local completion attempts, but does
357
- # not send terminal commands for leases that may still exist.
358
- await self._cancel_assignments()
359
- conn = self._client._require_conn()
360
- before_token = conn._session_token
361
- await conn._recover_best_effort()
362
- if conn._session_token == before_token:
363
- failures += 1
364
- else:
365
- failures = 0
366
- if failures >= 3:
367
- raise self._stream_error or UnavailableError(
368
- "bounded Work stream recovery exhausted"
369
- )
370
- if not self._close_started.is_set():
371
- await asyncio.sleep(0)
372
- finally:
373
- await self._cancel_assignments()
374
- self._run_finished.set()
375
-
376
- async def close(self) -> None:
377
- """Drain and stop once; caller cancellation cannot cancel cleanup."""
378
- async with self._close_lock:
379
- if self._close_task is None:
380
- self._close_started.set()
381
- # The controller must linearize detach and withdraw its probe
382
- # before this gate closes publication; the close path uses the
383
- # still-live stream to await that correlated WorkResult.
384
- self._idle_candidate = False
385
- self._idle_zero_since = None
386
- self._idle_session_token = None
387
- self._close_task = asyncio.create_task(
388
- self._close_impl(), name=f"ringo-close-{self._worker_id}"
389
- )
390
- self._close_task.add_done_callback(_consume_task_exception)
391
- close_task = self._close_task
392
- await asyncio.shield(close_task)
393
-
394
- async def _close_impl(self) -> None:
395
- try:
396
- if self._controller is not None and self._member is not None:
397
- await self._controller._detach(self._member)
398
- self._detached = True
399
- self._publisher_closed = True
400
- self._session_epoch += 1
401
- session = self._session_done
402
- # Drain is best effort. A disconnected stream cannot safely report
403
- # any terminal result, and leases are deliberately left to expire.
404
- if session is not None and not session.is_set():
405
- try:
406
- await asyncio.wait_for(
407
- self._send_command(
408
- queue_pb2.WorkRequest(
409
- request_id=self._allocate_request_id(),
410
- drain=queue_pb2.WorkerDrain(begin=True),
411
- ),
412
- wait=True,
413
- ),
414
- timeout=max(
415
- 0.1,
416
- min(self._command_timeout, self._grace_period or 1.0),
417
- ),
418
- )
419
- except Exception:
420
- pass
421
- if self._grace_period:
422
- try:
423
- await asyncio.wait_for(
424
- self._wait_handlers(), timeout=self._grace_period
425
- )
426
- except asyncio.TimeoutError:
427
- pass
428
- finally:
429
- # From this point handlers and renewals are cancelled; no terminal
430
- # frame is generated by their cancellation paths.
431
- self._force_cancel.set()
432
- try:
433
- await self._cancel_assignments()
434
- await self._stop_session()
435
- self._client._unregister_worker(self)
436
- if self._run_started:
437
- await self._run_finished.wait()
438
- finally:
439
- self._close_finished.set()
440
-
441
- async def _wait_handlers(self) -> None:
442
- tasks = list(self._handler_tasks)
443
- if tasks:
444
- await asyncio.gather(*tasks, return_exceptions=True)
445
-
446
- async def _cancel_assignments(self) -> None:
447
- # Local assignments were reset, so the idle observation restarts too.
448
- self._idle_candidate = False
449
- self._idle_zero_since = None
450
- self._idle_session_token = None
451
- assignments = list(self._inflight.values())
452
- self._inflight.clear()
453
- renewals: list[asyncio.Task[Any]] = []
454
- for assignment in assignments:
455
- self._invalidate_assignment(assignment)
456
- handler = assignment.handler_task
457
- if handler is not None and not handler.done():
458
- # Cancellation is advisory. Never let a handler that catches
459
- # CancelledError block reconnect or forced close; its local
460
- # capacity remains occupied until its coroutine really ends.
461
- handler.cancel()
462
- renewal = assignment.renew_task
463
- if renewal is not None and not renewal.done():
464
- renewal.cancel()
465
- renewals.append(renewal)
466
- if renewals:
467
- await asyncio.gather(*renewals, return_exceptions=True)
468
- for assignment in assignments:
469
- await self._release_capacity_if_settled(assignment)
470
-
471
- def _invalidate_assignment(self, assignment: _Assignment) -> None:
472
- if not assignment.alive:
473
- return
474
- assignment.alive = False
475
- if assignment.context is not None:
476
- assignment.context._cancel()
477
-
478
- async def _release_controller_permit(
479
- self,
480
- assignment: _Assignment,
481
- *,
482
- success: bool = False,
483
- congestion: bool = False,
484
- ) -> None:
485
- if (
486
- self._controller is None
487
- or self._member is None
488
- or assignment.controller_released
489
- ):
490
- return
491
- assignment.controller_released = True
492
- await self._controller._release_running(
493
- self._member, success=success, congestion=congestion
494
- )
495
-
496
- async def _release_capacity_if_settled(
497
- self, assignment: _Assignment, *, handler_settling: bool = False
498
- ) -> None:
499
- """Release capacity only as the application handler settles."""
500
- if assignment.alive:
501
- return
502
- handler = assignment.handler_task
503
- current = asyncio.current_task()
504
- if handler is not None and not handler.done():
505
- if handler is not current or not handler_settling:
506
- return
507
- if self._controller is not None:
508
- await self._release_controller_permit(
509
- assignment,
510
- success=assignment.completion_success,
511
- congestion=assignment.completion_congestion,
512
- )
513
- return
514
- if assignment.fixed_released:
515
- return
516
- assignment.fixed_released = True
517
- self._fixed_running = max(0, self._fixed_running - 1)
518
- if not self._close_started.is_set():
519
- await self._publish_fixed_capacity()
520
-
521
- async def _run_session_monitored(self) -> None:
522
- """Run one Work session, observing queue activity when on_idle is set.
523
-
524
- Without ``on_idle`` this is exactly ``_run_session()``. With it, the
525
- poll loop is executed inline by the ``run()`` coroutine itself (never
526
- in a child task) so a callback exception — including ``SystemExit`` —
527
- unwinds verbatim through ``await worker.run()``.
528
- """
529
- if self._on_idle is None:
530
- await self._run_session()
531
- return
532
- session_task = asyncio.create_task(
533
- self._run_session(), name="ringo-worker-session"
534
- )
535
- try:
536
- while True:
537
- done, _pending = await asyncio.wait(
538
- (session_task,), timeout=_IDLE_POLL_SECONDS
539
- )
540
- if done:
541
- # Propagate the session outcome (result or stream error).
542
- return await session_task
543
- if await self._idle_poll_once():
544
- # Single-shot fired and returned normally: park on the
545
- # session instead of waking every poll interval.
546
- return await session_task
547
- finally:
548
- if not session_task.done():
549
- session_task.cancel()
550
- await asyncio.gather(session_task, return_exceptions=True)
551
-
552
- async def _idle_poll_once(self) -> bool:
553
- """One read-only, retry-safe activity observation tick.
554
-
555
- Returns True exactly when the callback fired and returned normally.
556
- """
557
- callback = self._on_idle
558
- if (
559
- callback is None
560
- or self._idle_fired
561
- or not self._idle_candidate
562
- or self._close_started.is_set()
563
- or self._force_cancel.is_set()
564
- ):
565
- return False
566
- # Local assignments make the queue non-idle for this worker.
567
- if self._inflight or self._handler_tasks:
568
- self._idle_zero_since = None
569
- return False
570
- session = self._session_done
571
- if session is None or session.is_set():
572
- return False
573
- # The observation is bound to the token captured at registration: a
574
- # unary-triggered renegotiation rotates the token without restarting
575
- # the Work stream. End this session so the run loop re-registers the
576
- # stream under the fresh token instead of observing a stale session.
577
- conn = self._client._require_conn()
578
- if conn._session_token != self._idle_session_token:
579
- self._end_stale_idle_session(session)
580
- return False
581
- request = queue_pb2.GetQueueActivityRequest(
582
- queue=self._queue.name, task_types=self._task_types
583
- )
584
- try:
585
- response = await conn.call(
586
- "GetQueueActivity", request, retry_safe=True
587
- )
588
- except asyncio.CancelledError:
589
- raise
590
- except Exception:
591
- # A failed observation restarts the quiet window; a later poll
592
- # may succeed again without tearing the session down.
593
- self._idle_zero_since = None
594
- return False
595
- # The session or its token may have changed while the query was in
596
- # flight; a stale response must not advance or complete the window.
597
- if (
598
- not self._idle_candidate
599
- or self._session_done is not session
600
- or session.is_set()
601
- ):
602
- self._idle_zero_since = None
603
- return False
604
- if self._client._require_conn()._session_token != self._idle_session_token:
605
- self._end_stale_idle_session(session)
606
- return False
607
- if response.active_task_count > 0:
608
- self._idle_zero_since = None
609
- return False
610
- now = asyncio.get_running_loop().time()
611
- if self._idle_zero_since is None:
612
- self._idle_zero_since = now
613
- return False
614
- if now - self._idle_zero_since < _IDLE_QUIET_SECONDS:
615
- return False
616
- # Final atomic gate: no await separates these checks from marking
617
- # fired and invoking the callback, so an assignment landing in a
618
- # query in-flight window cannot produce a false trigger.
619
- if (
620
- self._inflight
621
- or self._handler_tasks
622
- or self._close_started.is_set()
623
- or self._force_cancel.is_set()
624
- ):
625
- self._idle_zero_since = None
626
- return False
627
- # Single-shot: mark before invoking so the callback can never
628
- # re-enter the monitor, and so a normal return never fires twice.
629
- self._idle_fired = True
630
- try:
631
- result = callback()
632
- if inspect.isawaitable(result):
633
- await result
634
- except BaseException as exc:
635
- # Tag callback failures so run() propagates them verbatim
636
- # instead of feeding them to the reconnect loop.
637
- self._idle_error = exc
638
- raise
639
- return True
640
-
641
- def _end_stale_idle_session(self, session: asyncio.Event) -> None:
642
- """End the current Work session after a token rotation.
643
-
644
- Setting ``session_done`` is idempotent and is observed as a clean
645
- stream end: ``_run_session`` returns without a stream error, the run
646
- loop reconnects, and the next session re-registers (and re-arms the
647
- idle candidate) under the fresh token. It is neither a callback
648
- failure nor a reconnect-worthy stream error.
649
- """
650
- self._idle_candidate = False
651
- self._idle_zero_since = None
652
- session.set()
653
-
654
- async def _run_session(self) -> None:
655
- conn = self._client._require_conn()
656
- self._session_epoch += 1
657
- # A new session invalidates any in-flight idle observation.
658
- self._idle_candidate = False
659
- self._idle_zero_since = None
660
- self._idle_session_token = None
661
- if (
662
- self._on_idle is not None
663
- and conn._session_minor < _IDLE_MIN_PROTOCOL_MINOR
664
- ):
665
- # A reconnect may have renegotiated a downgrade to minor 0.
666
- # Fail fast before opening the Work stream; run() surfaces this
667
- # verbatim instead of counting it as a reconnect failure.
668
- raise IncompatibleVersionError(
669
- "on_idle requires a server speaking protocol minor"
670
- f" >= {_IDLE_MIN_PROTOCOL_MINOR}; negotiated minor"
671
- f" is {conn._session_minor}"
672
- )
673
- reg = queue_pb2.WorkerRegistration(
674
- queue=self._queue.name,
675
- worker_id=self._worker_id,
676
- task_types=self._task_types,
677
- max_concurrency=max(1, self._concurrency),
678
- )
679
- if self._lease_duration > timedelta(0):
680
- reg.lease_duration.CopyFrom(_pb.td_to_dur(self._lease_duration))
681
- async with conn._lock:
682
- stub = conn._require_stub()
683
- reg.context.CopyFrom(conn.request_context())
684
- call = stub.Work(metadata=conn._metadata())
685
- self._call = call
686
- self._session_done = asyncio.Event()
687
- self._registered = asyncio.Event()
688
- self._stream_error = None
689
- self._outbox = asyncio.Queue()
690
- self._pending.clear()
691
- self._writer_task = asyncio.create_task(
692
- self._write_loop(call), name="ringo-worker-writer"
693
- )
694
- self._reader_task = asyncio.create_task(
695
- self._read_loop(call), name="ringo-worker-reader"
696
- )
697
- try:
698
- await self._enqueue_frame(queue_pb2.WorkRequest(register=reg))
699
- await self._wait_event_or_session(self._registered)
700
- if self._force_cancel.is_set():
701
- return
702
- # Initial credit is sent only after WorkerRegistered was observed.
703
- if self._controller is None:
704
- await self._send_credit(self._concurrency)
705
- else:
706
- self._controller._check_loop()
707
- await self._controller._start_member(self._member)
708
- if self._on_idle is not None and not self._close_started.is_set():
709
- # Eligible only from the first/current WorkerRegistered plus
710
- # a successful initial credit of this session. The candidate
711
- # is bound to the token this stream was registered with: a
712
- # later unary renegotiation that rotates the token disarms
713
- # the monitor until the next session.
714
- self._idle_session_token = reg.context.session_token
715
- self._idle_candidate = True
716
- self._idle_zero_since = None
717
- await self._wait_event_or_session(self._session_done)
718
- if self._stream_error is not None and not self._close_started.is_set():
719
- raise self._stream_error
720
- finally:
721
- await self._stop_session()
722
- self._call = None
723
- self._session_done = None
724
- self._registered = None
725
-
726
- async def _wait_event_or_session(self, event: asyncio.Event) -> None:
727
- if self._session_done is None:
728
- return
729
- event_task = asyncio.create_task(event.wait())
730
- done_task = asyncio.create_task(self._session_done.wait())
731
- done, pending = await asyncio.wait(
732
- (event_task, done_task), return_when=asyncio.FIRST_COMPLETED
733
- )
734
- for task in pending:
735
- task.cancel()
736
- await asyncio.gather(*pending, return_exceptions=True)
737
- if done_task in done and not event.is_set():
738
- if self._stream_error is not None:
739
- raise self._stream_error
740
- raise UnavailableError("Work stream closed")
741
-
742
- async def _stop_session(self) -> None:
743
- session = self._session_done
744
- if session is not None:
745
- session.set()
746
- call, self._call = self._call, None
747
- writer, self._writer_task = self._writer_task, None
748
- reader, self._reader_task = self._reader_task, None
749
- outbox, self._outbox = self._outbox, None
750
- if outbox is not None and writer is not None and not writer.done():
751
- await outbox.put(None)
752
- if call is not None:
753
- try:
754
- call.cancel()
755
- except Exception:
756
- pass
757
- tasks = [t for t in (reader, writer) if t is not None]
758
- if tasks:
759
- await asyncio.gather(*tasks, return_exceptions=True)
760
- for future in self._pending.values():
761
- if not future.done():
762
- future.set_exception(UnavailableError("Work stream closed"))
763
- self._pending.clear()
764
- if self._controller is not None and self._member is not None:
765
- await self._controller._stream_lost(self._member)
766
-
767
- async def _enqueue_frame(self, frame: queue_pb2.WorkRequest) -> None:
768
- outbox = self._outbox
769
- if outbox is None:
770
- raise UnavailableError("Work stream is not connected")
771
- await outbox.put(frame)
772
-
773
- async def _write_loop(self, call: Any) -> None:
774
- try:
775
- while True:
776
- frame = await self._outbox.get() if self._outbox is not None else None
777
- if frame is None:
778
- try:
779
- done_writing = getattr(call, "done_writing", None)
780
- if done_writing is not None:
781
- await done_writing()
782
- except Exception:
783
- pass
784
- return
785
- await call.write(frame)
786
- except asyncio.CancelledError:
787
- raise
788
- except Exception as exc:
789
- self._stream_error = self._map_transport(exc)
790
- if self._session_done is not None:
791
- self._session_done.set()
792
-
793
- async def _read_loop(self, call: Any) -> None:
794
- try:
795
- async for response in call:
796
- body = response.WhichOneof("body")
797
- if body == "registered":
798
- registered = response.registered
799
- if registered.HasField("lease_duration"):
800
- self._lease_duration = (
801
- _pb.dur_to_td(registered.lease_duration)
802
- or self._lease_duration
803
- )
804
- if (
805
- self._controller is None
806
- and not self._fixed_dynamic_target
807
- and registered.max_concurrency
808
- ):
809
- self._concurrency = registered.max_concurrency
810
- if self._registered is not None:
811
- self._registered.set()
812
- elif body == "task":
813
- await self._on_assignment(response.task)
814
- elif body == "result":
815
- result = response.result
816
- future = self._pending.pop(result.request_id, None)
817
- if future is not None and not future.done():
818
- future.set_result(result)
819
- # Heartbeats are unsolicited liveness frames and need no action.
820
- except asyncio.CancelledError:
821
- raise
822
- except Exception as exc:
823
- self._stream_error = self._map_transport(exc)
824
- finally:
825
- if self._session_done is not None:
826
- self._session_done.set()
827
-
828
- @staticmethod
829
- def _map_transport(exc: BaseException) -> BaseException:
830
- if isinstance(exc, RingoError):
831
- return exc
832
- if isinstance(exc, grpc.aio.AioRpcError):
833
- return map_rpc_error(exc)
834
- return UnavailableError(f"Work stream failed: {_safe_error(exc)}")
835
-
836
- async def _on_assignment(self, leased: queue_pb2.LeasedTask) -> None:
837
- if (
838
- self._force_cancel.is_set()
839
- or not leased.HasField("task")
840
- or not leased.HasField("lease")
841
- ):
842
- return
843
- task = Task.from_proto(leased.task)
844
- until = _pb.ts_to_dt(leased.lease.until)
845
- assignment = _Assignment(
846
- task=task,
847
- attempt=leased.lease.attempt,
848
- token=leased.lease.lease_token,
849
- until=until,
850
- )
851
- context = TaskContext(
852
- self,
853
- task,
854
- assignment.attempt,
855
- task.max_attempts,
856
- self._worker_id,
857
- assignment,
858
- )
859
- assignment.context = context
860
- # Task IDs are unique among valid in-flight leases. Avoid replacing an
861
- # existing assignment if a malformed peer sends a duplicate.
862
- if task.id in self._inflight:
863
- return
864
- if self._controller is not None and self._member is not None:
865
- # Consuming the reserved probe is the linearization point at which
866
- # an assignment becomes a running controller permit.
867
- if not await self._controller._probe_consumed(self._member):
868
- # A withdrawn probe must not become a new handler. The lease
869
- # remains server-owned and will naturally expire if this was a
870
- # transport race.
871
- return
872
- self._inflight[task.id] = assignment
873
- if self._controller is None:
874
- self._fixed_running += 1
875
- assignment.renew_task = asyncio.create_task(
876
- self._renew_loop(assignment), name=f"ringo-renew-{task.id}"
877
- )
878
- assignment.handler_task = asyncio.create_task(
879
- self._handle_assignment(assignment), name=f"ringo-handler-{task.id}"
880
- )
881
- self._handler_tasks.add(assignment.handler_task)
882
- assignment.handler_task.add_done_callback(_consume_task_exception)
883
-
884
- async def _handle_assignment(self, assignment: _Assignment) -> None:
885
- try:
886
- context = assignment.context
887
- assert context is not None
888
- result = self._handler(assignment.task, context)
889
- if inspect.isawaitable(result):
890
- await result
891
- if assignment.alive and not self._force_cancel.is_set():
892
- await self._finish(assignment, "ack")
893
- except asyncio.CancelledError:
894
- # Cancellation is intentionally not a task outcome. In particular,
895
- # do not emit NACK while draining or after a lease loss.
896
- raise
897
- except RetryTaskError as exc:
898
- if assignment.alive and not self._force_cancel.is_set():
899
- await self._finish(assignment, "nack", exc)
900
- except PermanentTaskError as exc:
901
- if assignment.alive and not self._force_cancel.is_set():
902
- await self._finish(assignment, "reject", exc)
903
- except Exception as exc:
904
- if assignment.alive and not self._force_cancel.is_set():
905
- await self._finish(assignment, "nack", exc)
906
- finally:
907
- current = asyncio.current_task()
908
- if current is not None:
909
- self._handler_tasks.discard(current)
910
- await self._release_capacity_if_settled(
911
- assignment, handler_settling=True
912
- )
913
-
914
- async def _finish(
915
- self,
916
- assignment: _Assignment,
917
- operation: str,
918
- error: BaseException | None = None,
919
- ) -> None:
920
- if not assignment.alive or assignment.completion_started:
921
- return
922
- assignment.completion_started = True
923
- request: queue_pb2.WorkRequest
924
- text = _safe_error(error) if error is not None else ""
925
- if operation == "ack":
926
- request = queue_pb2.WorkRequest(
927
- ack=queue_pb2.AckRequest(lease=assignment.ref)
928
- )
929
- elif operation == "nack":
930
- nack = queue_pb2.NackRequest(lease=assignment.ref, error=text)
931
- if isinstance(error, RetryTaskError) and error.delay is not None:
932
- if (
933
- not isinstance(error.delay, (int, float))
934
- or not math.isfinite(error.delay)
935
- or error.delay < 0
936
- ):
937
- # Treat an invalid override as an ordinary failure rather
938
- # than allowing a negative Duration onto the wire.
939
- nack.error = _safe_error(error)
940
- else:
941
- try:
942
- nack.retry_delay_override.CopyFrom(
943
- _pb.td_to_dur(timedelta(seconds=error.delay))
944
- )
945
- except Exception:
946
- # An unrepresentable override is treated as a normal
947
- # NACK; never strand the assignment on serialization.
948
- nack.error = _safe_error(error)
949
- request = queue_pb2.WorkRequest(nack=nack)
950
- else:
951
- request = queue_pb2.WorkRequest(
952
- reject=queue_pb2.RejectRequest(lease=assignment.ref, error=text)
953
- )
954
- try:
955
- result = await self._send_command(request, wait=True)
956
- except asyncio.CancelledError:
957
- raise
958
- except Exception:
959
- return
960
- if result is None:
961
- return
962
- detail = result.error if result.HasField("error") else None
963
- if detail is not None and detail.code == queue_pb2.ERROR_CODE_LEASE_LOST:
964
- await self._lease_lost(assignment)
965
- return
966
- if detail is not None and detail.code != queue_pb2.ERROR_CODE_UNSPECIFIED:
967
- # A non-lease terminal failure is not terminal according to the
968
- # protocol; leave the assignment fenced until the stream/server does.
969
- assignment.completion_started = False
970
- return
971
- congestion = isinstance(error, RetryTaskError) and error.congestion
972
- await self._release_assignment(
973
- assignment,
974
- success=operation == "ack",
975
- congestion=congestion,
976
- )
977
-
978
- async def _release_assignment(
979
- self,
980
- assignment: _Assignment,
981
- *,
982
- cancel_context: bool = False,
983
- success: bool = False,
984
- congestion: bool = False,
985
- ) -> None:
986
- if not assignment.alive:
987
- return
988
- assignment.alive = False
989
- assignment.completion_success = success
990
- assignment.completion_congestion = congestion
991
- self._inflight.pop(assignment.task.id, None)
992
- if cancel_context and assignment.context is not None:
993
- assignment.context._cancel()
994
- if (
995
- assignment.renew_task is not None
996
- and assignment.renew_task is not asyncio.current_task()
997
- ):
998
- assignment.renew_task.cancel()
999
- await asyncio.gather(assignment.renew_task, return_exceptions=True)
1000
- await self._release_capacity_if_settled(assignment)
1001
-
1002
- async def _lease_lost(self, assignment: _Assignment) -> None:
1003
- if not assignment.alive:
1004
- return
1005
- await self._release_assignment(assignment, cancel_context=True)
1006
- handler = assignment.handler_task
1007
- if (
1008
- handler is not None
1009
- and handler is not asyncio.current_task()
1010
- and not handler.done()
1011
- ):
1012
- # Do not await an application coroutine that may intentionally
1013
- # swallow cancellation. Its capacity is released by its finally.
1014
- handler.cancel()
1015
- await self._release_capacity_if_settled(assignment)
1016
-
1017
- async def _renew_loop(self, assignment: _Assignment) -> None:
1018
- while assignment.alive and not self._force_cancel.is_set():
1019
- remaining = (assignment.until - datetime.now(timezone.utc)).total_seconds()
1020
- delay = max(0.01, remaining - self._lease_duration.total_seconds() / 3)
1021
- try:
1022
- await asyncio.wait_for(self._force_cancel.wait(), timeout=delay)
1023
- return
1024
- except asyncio.TimeoutError:
1025
- pass
1026
- if not assignment.alive or self._force_cancel.is_set():
1027
- return
1028
- request = queue_pb2.WorkRequest(
1029
- extend_lease=queue_pb2.ExtendLeaseRequest(
1030
- lease=assignment.ref,
1031
- extension=_pb.td_to_dur(self._lease_duration),
1032
- )
1033
- )
1034
- try:
1035
- result = await self._send_command(request, wait=True)
1036
- except asyncio.CancelledError:
1037
- raise
1038
- except Exception:
1039
- return
1040
- if result is None:
1041
- return
1042
- detail = result.error if result.HasField("error") else None
1043
- if detail is not None and detail.code == queue_pb2.ERROR_CODE_LEASE_LOST:
1044
- await self._lease_lost(assignment)
1045
- return
1046
- if detail is not None and detail.code != queue_pb2.ERROR_CODE_UNSPECIFIED:
1047
- return
1048
- # ExtendLeaseResponse is not carried in WorkResult. The protocol
1049
- # defines the new deadline as server_now + extension; this local
1050
- # estimate is the only safe deadline available to the SDK.
1051
- assignment.until = datetime.now(timezone.utc) + self._lease_duration
1052
-
1053
- async def _report_progress(
1054
- self, assignment: _Assignment, current: float, total: float, message: str
1055
- ) -> None:
1056
- if not assignment.alive:
1057
- raise LeaseLostError("task lease is no longer valid")
1058
- request = queue_pb2.WorkRequest(
1059
- progress=queue_pb2.ReportProgressRequest(
1060
- lease=assignment.ref,
1061
- current=current,
1062
- total=total,
1063
- message=_safe_error(message),
1064
- )
1065
- )
1066
- result = await self._send_command(request, wait=True)
1067
- if result is None:
1068
- return
1069
- detail = result.error if result.HasField("error") else None
1070
- if detail is not None and detail.code == queue_pb2.ERROR_CODE_LEASE_LOST:
1071
- await self._lease_lost(assignment)
1072
- raise LeaseLostError(detail.message)
1073
- if detail is not None and detail.code != queue_pb2.ERROR_CODE_UNSPECIFIED:
1074
- raise error_from_detail(detail)
1075
-
1076
- async def _send_credit(self, available: int) -> None:
1077
- # ``available`` is intentionally not trusted across retries. A task
1078
- # may arrive between a rejected snapshot and its retry, so every
1079
- # credit-capacity retry samples local in-flight state again.
1080
- await self._publish_fixed_capacity()
1081
-
1082
- async def _publish_fixed_capacity(self) -> None:
1083
- async with self._publisher_lock:
1084
- epoch = self._session_epoch
1085
- for attempt in range(20):
1086
- if (
1087
- self._publisher_closed
1088
- or self._close_started.is_set()
1089
- or epoch != self._session_epoch
1090
- or self._session_done is None
1091
- or self._session_done.is_set()
1092
- ):
1093
- return
1094
- available = max(0, self._concurrency - self._fixed_running)
1095
- update = queue_pb2.CreditUpdate(available=available)
1096
- if self._fixed_dynamic_target:
1097
- update.target_concurrency = self._concurrency
1098
- result = await self._send_command(
1099
- queue_pb2.WorkRequest(credit=update), wait=True
1100
- )
1101
- if (
1102
- result is None
1103
- or not result.HasField("error")
1104
- or result.error.code == queue_pb2.ERROR_CODE_UNSPECIFIED
1105
- ):
1106
- return
1107
- if (
1108
- result.error.metadata.get("reason") != "credit_capacity"
1109
- or attempt == 19
1110
- ):
1111
- raise error_from_detail(result.error)
1112
- await asyncio.sleep(0.01)
1113
-
1114
- async def _publish_controller_initial(self, capacity: int) -> None:
1115
- async with self._publisher_lock:
1116
- if (
1117
- self._publisher_closed
1118
- or self._close_started.is_set()
1119
- or self._session_done is None
1120
- or self._session_done.is_set()
1121
- ):
1122
- raise UnavailableError("Work stream is not connected")
1123
- ok = await self._publish_dynamic_locked(capacity, 0)
1124
- if not ok:
1125
- raise UnavailableError("initial dynamic credit was not accepted")
1126
-
1127
- async def _publish_controller_probe(
1128
- self, member: _Member, capacity: int, generation: int
1129
- ) -> None:
1130
- async with self._publisher_lock:
1131
- if (
1132
- self._publisher_closed
1133
- or self._close_started.is_set()
1134
- or self._member is not member
1135
- or not member.probe
1136
- or member.probe_generation != generation
1137
- or self._session_done is None
1138
- or self._session_done.is_set()
1139
- ):
1140
- return
1141
- await self._publish_dynamic_locked(
1142
- capacity,
1143
- 1,
1144
- probe_member=member,
1145
- probe_generation=generation,
1146
- )
1147
-
1148
- async def _publish_controller_target(self, capacity: int) -> bool:
1149
- """Publish target/zero credit for a live shared-controller stream."""
1150
- async with self._publisher_lock:
1151
- if self._publisher_closed:
1152
- return True
1153
- if self._session_done is None or self._session_done.is_set():
1154
- return True
1155
- return await self._publish_dynamic_locked(capacity, 0)
1156
-
1157
- async def _withdraw_controller_probe(
1158
- self,
1159
- member: _Member,
1160
- capacity: int,
1161
- *,
1162
- force_target: bool = False,
1163
- ) -> bool:
1164
- async with self._publisher_lock:
1165
- if self._publisher_closed:
1166
- return True
1167
- if self._session_done is None or self._session_done.is_set():
1168
- # The old stream is definitively gone; its credit no longer
1169
- # exists server-side and the controller may reclaim the token.
1170
- return True
1171
- return await self._publish_dynamic_locked(
1172
- capacity,
1173
- 0,
1174
- allow_closing=True,
1175
- explicit_target=force_target,
1176
- )
1177
-
1178
- async def _publish_dynamic_locked(
1179
- self,
1180
- target: int,
1181
- available: int,
1182
- *,
1183
- allow_closing: bool = False,
1184
- explicit_target: bool = False,
1185
- probe_member: _Member | None = None,
1186
- probe_generation: int = 0,
1187
- ) -> bool:
1188
- epoch = self._session_epoch
1189
- for attempt in range(20):
1190
- if (
1191
- self._publisher_closed
1192
- or (self._close_started.is_set() and not allow_closing)
1193
- or epoch != self._session_epoch
1194
- or self._session_done is None
1195
- or self._session_done.is_set()
1196
- ):
1197
- return self._session_done is None or self._session_done.is_set()
1198
- if probe_member is not None and (
1199
- not probe_member.probe
1200
- or probe_member.probe_generation != probe_generation
1201
- ):
1202
- # The assignment consumed this probe while a previous
1203
- # capacity update was in flight; never replay available=1.
1204
- return True
1205
- # target and available are one atomic protocol snapshot. A
1206
- # credit_capacity rejection must be retried with a fresh target /
1207
- # in-flight sample, never by replaying the old snapshot.
1208
- current_target = (
1209
- self._controller.capacity
1210
- if self._controller is not None and not explicit_target
1211
- else target
1212
- )
1213
- current_available = available
1214
- if self._controller is None:
1215
- # An assignment may have been sent by the server before the
1216
- # rejected credit result reached this reader. Re-sample the
1217
- # now-materialized local assignments instead of replaying the
1218
- # stale fixed-capacity snapshot.
1219
- current_available = max(0, current_target - self._fixed_running)
1220
- update = queue_pb2.CreditUpdate(
1221
- available=max(0, current_available),
1222
- target_concurrency=current_target,
1223
- )
1224
- result = await self._send_command(
1225
- queue_pb2.WorkRequest(credit=update), wait=True
1226
- )
1227
- if (
1228
- result is None
1229
- or not result.HasField("error")
1230
- or result.error.code == queue_pb2.ERROR_CODE_UNSPECIFIED
1231
- ):
1232
- return True
1233
- if (
1234
- result.error.metadata.get("reason") != "credit_capacity"
1235
- or attempt == 19
1236
- ):
1237
- raise error_from_detail(result.error)
1238
- await asyncio.sleep(0.01)
1239
- return False
1240
-
1241
- def _allocate_request_id(self) -> int:
1242
- rid = self._next_request_id
1243
- self._next_request_id += 1
1244
- if self._next_request_id == 0:
1245
- self._next_request_id = 1
1246
- return rid
1247
-
1248
- async def _send_command(
1249
- self, request: queue_pb2.WorkRequest, *, wait: bool
1250
- ) -> queue_pb2.WorkResult | None:
1251
- if self._session_done is None or self._session_done.is_set():
1252
- raise UnavailableError("Work stream is not connected")
1253
- rid = request.request_id or self._allocate_request_id()
1254
- request.request_id = rid
1255
- loop = asyncio.get_running_loop()
1256
- future: asyncio.Future[queue_pb2.WorkResult] = loop.create_future()
1257
- self._pending[rid] = future
1258
- try:
1259
- await self._enqueue_frame(request)
1260
- if not wait:
1261
- return None
1262
- return await asyncio.wait_for(
1263
- self._wait_command_or_session(future), timeout=self._command_timeout
1264
- )
1265
- except asyncio.CancelledError:
1266
- self._pending.pop(rid, None)
1267
- if future.done() and not future.cancelled():
1268
- future.exception()
1269
- raise
1270
- except Exception:
1271
- self._pending.pop(rid, None)
1272
- if future.done() and not future.cancelled():
1273
- future.exception()
1274
- raise
1275
-
1276
- async def _wait_command_or_session(
1277
- self, future: asyncio.Future[queue_pb2.WorkResult]
1278
- ) -> queue_pb2.WorkResult:
1279
- session = self._session_done
1280
- if session is None:
1281
- raise UnavailableError("Work stream is not connected")
1282
- session_wait = asyncio.create_task(session.wait())
1283
- try:
1284
- done, _ = await asyncio.wait(
1285
- (future, session_wait), return_when=asyncio.FIRST_COMPLETED
1286
- )
1287
- if future in done:
1288
- return future.result()
1289
- if future.done():
1290
- return future.result()
1291
- raise self._stream_error or UnavailableError("Work stream closed")
1292
- finally:
1293
- session_wait.cancel()
1294
- await asyncio.gather(session_wait, return_exceptions=True)
1295
-
1296
-
1297
- __all__ = ["TaskContext", "Worker"]
1
+ """Asyncio worker implementation for the credit-controlled Work stream.
2
+
3
+ The worker deliberately owns one reader and one writer for every Work call.
4
+ Handlers only receive :class:`TaskContext`; lease tokens stay in this module.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import inspect
11
+ import math
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timedelta, timezone
14
+ from typing import Any, Awaitable, Callable, Iterable
15
+
16
+ import grpc
17
+
18
+ from . import _pb
19
+ from ._proto.ringo.v1 import queue_pb2
20
+ from .concurrency import AdaptiveConcurrency, ResourceConcurrency, _Member
21
+ from .errors import (
22
+ IncompatibleVersionError,
23
+ LeaseLostError,
24
+ PermanentTaskError,
25
+ RetryTaskError,
26
+ RingoError,
27
+ UnavailableError,
28
+ error_from_detail,
29
+ map_rpc_error,
30
+ )
31
+ from .models import Task
32
+
33
+ _MAX_ERROR_BYTES = 256 * 1024
34
+
35
+ #: Fixed on_idle monitor cadence (design §10.6). These are deliberately not
36
+ #: exposed as Worker parameters: the poll interval and the continuous-idle
37
+ #: quiet period are protocol-level constants of the idle-observation feature.
38
+ _IDLE_POLL_SECONDS = 0.25
39
+ _IDLE_QUIET_SECONDS = 1.0
40
+ #: GetQueueActivity was introduced in protocol minor 1.
41
+ _IDLE_MIN_PROTOCOL_MINOR = 1
42
+
43
+
44
+ def _consume_task_exception(task: asyncio.Task[Any]) -> None:
45
+ if not task.cancelled():
46
+ task.exception()
47
+
48
+
49
+ def _safe_error(value: object) -> str:
50
+ """Return a valid UTF-8 error string no larger than 256 KiB."""
51
+ try:
52
+ text = str(value)
53
+ except Exception:
54
+ text = type(value).__name__
55
+ encoded = text.encode("utf-8", errors="replace")
56
+ if len(encoded) <= _MAX_ERROR_BYTES:
57
+ return encoded.decode("utf-8", errors="replace")
58
+ # Decode with replacement after truncating bytes; this is UTF-8 safe and
59
+ # never emits a broken surrogate or an over-sized protobuf string.
60
+ return encoded[:_MAX_ERROR_BYTES].decode("utf-8", errors="ignore")
61
+
62
+
63
+ class TaskContext:
64
+ """Context supplied to a worker handler.
65
+
66
+ ``cancelled`` becomes true when the local lease is lost, the stream drops,
67
+ or the worker is closed. ``report_progress`` is correlated on the Work
68
+ stream and never exposes the lease token to application code.
69
+ """
70
+
71
+ __slots__ = (
72
+ "_worker",
73
+ "_task",
74
+ "_attempt",
75
+ "_max_attempts",
76
+ "_worker_id",
77
+ "_assignment",
78
+ "_cancel_event",
79
+ )
80
+
81
+ def __init__(
82
+ self,
83
+ worker: "Worker",
84
+ task: Task,
85
+ attempt: int,
86
+ max_attempts: int,
87
+ worker_id: str,
88
+ assignment: "_Assignment",
89
+ ) -> None:
90
+ self._worker = worker
91
+ self._task = task
92
+ self._attempt = attempt
93
+ self._max_attempts = max_attempts
94
+ self._worker_id = worker_id
95
+ self._assignment = assignment
96
+ self._cancel_event = asyncio.Event()
97
+
98
+ @property
99
+ def task(self) -> Task:
100
+ return self._task
101
+
102
+ @property
103
+ def attempt(self) -> int:
104
+ return self._attempt
105
+
106
+ @property
107
+ def max_attempts(self) -> int:
108
+ return self._max_attempts
109
+
110
+ @property
111
+ def worker_id(self) -> str:
112
+ return self._worker_id
113
+
114
+ @property
115
+ def cancelled(self) -> bool:
116
+ return self._cancel_event.is_set()
117
+
118
+ @property
119
+ def is_cancelled(self) -> bool:
120
+ """Alias useful to code shared with SDKs using a verb-like name."""
121
+ return self.cancelled
122
+
123
+ async def wait_cancelled(self) -> None:
124
+ await self._cancel_event.wait()
125
+
126
+ async def report_progress(
127
+ self, current: float, total: float, message: str = ""
128
+ ) -> None:
129
+ """Report progress for this attempt and await its correlated result."""
130
+ if self.cancelled:
131
+ raise LeaseLostError("task lease is no longer valid")
132
+ await self._worker._report_progress(self._assignment, current, total, message)
133
+
134
+ def _cancel(self) -> None:
135
+ self._cancel_event.set()
136
+
137
+
138
+ @dataclass
139
+ class _Assignment:
140
+ task: Task
141
+ attempt: int
142
+ token: str
143
+ until: datetime
144
+ context: TaskContext | None = None
145
+ handler_task: asyncio.Task[Any] | None = None
146
+ renew_task: asyncio.Task[Any] | None = None
147
+ alive: bool = True
148
+ completion_started: bool = False
149
+ completion_success: bool = False
150
+ completion_congestion: bool = False
151
+ controller_released: bool = False
152
+ fixed_released: bool = False
153
+
154
+ @property
155
+ def ref(self) -> queue_pb2.LeaseRef:
156
+ return queue_pb2.LeaseRef(
157
+ task_id=self.task.id,
158
+ attempt=self.attempt,
159
+ lease_token=self.token,
160
+ )
161
+
162
+
163
+ class Worker:
164
+ """A credit-controlled asynchronous task worker.
165
+
166
+ ``run`` is intentionally a blocking coroutine: it remains active until
167
+ ``close`` is called. The client registers workers and drains them before
168
+ closing its channel. When ``on_idle`` is configured, ``run`` additionally
169
+ observes queue activity and executes the callback inline at most once.
170
+ """
171
+
172
+ def __init__(
173
+ self,
174
+ queue: Any,
175
+ handler: Callable[[Task, TaskContext], Awaitable[Any]],
176
+ *,
177
+ task_types: Iterable[str],
178
+ concurrency: int | ResourceConcurrency | AdaptiveConcurrency,
179
+ lease_duration: timedelta,
180
+ grace_period: timedelta,
181
+ on_idle: Callable[[], Any] | None = None,
182
+ weight: int = 1,
183
+ ) -> None:
184
+ if isinstance(concurrency, bool) or not isinstance(
185
+ concurrency, (int, ResourceConcurrency, AdaptiveConcurrency)
186
+ ):
187
+ raise ValueError(
188
+ "concurrency must be an integer, ResourceConcurrency, or AdaptiveConcurrency"
189
+ )
190
+ if isinstance(concurrency, int) and concurrency < 1:
191
+ raise ValueError("initial fixed concurrency must be >= 1")
192
+ if isinstance(weight, bool) or not isinstance(weight, int) or weight < 1:
193
+ raise ValueError("weight must be a positive integer")
194
+ self._queue = queue
195
+ self._client = queue._client
196
+ self._handler = handler
197
+ self._task_types = list(task_types)
198
+ self._controller: ResourceConcurrency | AdaptiveConcurrency | None = (
199
+ concurrency if not isinstance(concurrency, int) else None
200
+ )
201
+ self._member: _Member | None = None
202
+ self._concurrency = concurrency if isinstance(concurrency, int) else max(1, concurrency.capacity)
203
+ self._configured_concurrency = self._concurrency
204
+ self._weight = weight
205
+ self._lease_duration = lease_duration
206
+ self._grace_period = max(0.0, grace_period.total_seconds())
207
+ self._worker_id = self._new_worker_id()
208
+ self._on_idle = on_idle
209
+ # on_idle monitor state (design §10.6). ``_idle_candidate`` arms only
210
+ # after WorkerRegistered plus the initial credit of the *current*
211
+ # session; every reset condition disarms it or clears the zero timer.
212
+ self._idle_candidate = False
213
+ self._idle_zero_since: float | None = None
214
+ self._idle_fired = False
215
+ #: Session token captured at WorkerRegistered + initial credit. The
216
+ #: candidate is only observable while the connection still carries
217
+ #: this token: a unary-triggered renegotiation rotates the token
218
+ #: without touching the Work stream and must disarm the monitor.
219
+ self._idle_session_token: str | None = None
220
+ #: Set when the on_idle callback raised: run() must re-raise verbatim
221
+ #: instead of counting it as a Work-stream reconnect failure.
222
+ self._idle_error: BaseException | None = None
223
+
224
+ # close_started means no new stream may be opened, while force_cancel
225
+ # is raised only after the graceful drain window expires. Existing
226
+ # handlers and renewals remain fully live between those two points.
227
+ self._close_started = asyncio.Event()
228
+ self._force_cancel = asyncio.Event()
229
+ self._run_started = False
230
+ self._run_finished = asyncio.Event()
231
+ self._close_finished = asyncio.Event()
232
+ self._command_timeout = 30.0
233
+ self._close_lock = asyncio.Lock()
234
+ self._close_task: asyncio.Task[None] | None = None
235
+ self._session_done: asyncio.Event | None = None
236
+ self._call: Any = None
237
+ self._outbox: asyncio.Queue[Any] | None = None
238
+ self._reader_task: asyncio.Task[Any] | None = None
239
+ self._writer_task: asyncio.Task[Any] | None = None
240
+ self._registered: asyncio.Event | None = None
241
+ self._stream_error: BaseException | None = None
242
+ self._next_request_id = 1
243
+ self._pending: dict[int, asyncio.Future[queue_pb2.WorkResult]] = {}
244
+ self._inflight: dict[str, _Assignment] = {}
245
+ # Handler lifetime, not just lease-map membership, owns local capacity.
246
+ # Old-stream handlers that swallow cancellation remain here until their
247
+ # coroutine actually settles.
248
+ self._handler_tasks: set[asyncio.Task[Any]] = set()
249
+ self._fixed_running = 0
250
+ # Every capacity update shares one publisher mutex and a monotonically
251
+ # increasing session epoch. A late task from an old stream can never
252
+ # publish credit onto a replacement stream or after close.
253
+ self._publisher_lock = asyncio.Lock()
254
+ self._session_epoch = 0
255
+ self._publisher_closed = False
256
+ self._probe: tuple[_Member, int] | None = None
257
+ self._detached = False
258
+ self._controller_registered = False
259
+ self._fixed_dynamic_target = False
260
+ if self._controller is not None:
261
+ self._member = self._controller._attach(self, weight)
262
+ self._controller_registered = True
263
+
264
+ @staticmethod
265
+ def _new_worker_id() -> str:
266
+ # Importing here avoids coupling worker construction to client internals.
267
+ from .client import new_worker_id
268
+
269
+ return new_worker_id()
270
+
271
+ @property
272
+ def worker_id(self) -> str:
273
+ return self._worker_id
274
+
275
+ @property
276
+ def concurrency(self) -> int:
277
+ """Current fixed target, or the controller's current capacity."""
278
+ if self._controller is not None:
279
+ return self._controller.capacity
280
+ return self._concurrency
281
+
282
+ @property
283
+ def weight(self) -> int:
284
+ return self._weight
285
+
286
+ async def set_concurrency(self, concurrency: int) -> None:
287
+ """Resize a fixed worker without cancelling running handlers."""
288
+ if self._controller is not None:
289
+ raise ValueError("a Worker attached to a shared controller cannot resize itself")
290
+ if isinstance(concurrency, bool) or not isinstance(concurrency, int):
291
+ raise ValueError("concurrency must be an integer")
292
+ if concurrency < 0 or concurrency > 2**31 - 1:
293
+ raise ValueError("concurrency must be between 0 and 2**31-1")
294
+ if self._run_started:
295
+ conn = self._client._require_conn()
296
+ if conn._session_minor < 2:
297
+ raise IncompatibleVersionError(
298
+ "runtime fixed concurrency requires protocol minor >= 2; "
299
+ f"negotiated minor is {conn._session_minor}"
300
+ )
301
+ self._concurrency = concurrency
302
+ self._fixed_dynamic_target = True
303
+ if self._session_done is not None and not self._session_done.is_set():
304
+ await self._publish_fixed_capacity()
305
+
306
+ async def run(self) -> None:
307
+ """Run until :meth:`close` is called, reconnecting boundedly."""
308
+ if self._run_started:
309
+ raise RuntimeError("worker.run() may only be called once")
310
+ self._run_started = True
311
+ failures = 0
312
+ try:
313
+ conn = self._client._require_conn()
314
+ if (
315
+ (self._controller is not None or self._fixed_dynamic_target)
316
+ and conn._session_minor < 2
317
+ ):
318
+ raise IncompatibleVersionError(
319
+ "dynamic concurrency requires protocol minor >= 2; "
320
+ f"negotiated minor is {conn._session_minor}"
321
+ )
322
+ if self._on_idle is not None:
323
+ # GetQueueActivity arrived in protocol minor 1; fail fast
324
+ # instead of silently running without idle observation.
325
+ conn = self._client._require_conn()
326
+ if conn._session_minor < _IDLE_MIN_PROTOCOL_MINOR:
327
+ raise IncompatibleVersionError(
328
+ "on_idle requires a server speaking protocol minor"
329
+ f" >= {_IDLE_MIN_PROTOCOL_MINOR}; negotiated minor"
330
+ f" is {conn._session_minor}"
331
+ )
332
+ while not self._close_started.is_set():
333
+ try:
334
+ await self._run_session_monitored()
335
+ except asyncio.CancelledError:
336
+ if not self._close_started.is_set():
337
+ raise
338
+ break
339
+ except Exception as exc:
340
+ if self._idle_error is not None:
341
+ # An on_idle callback failure is not a transport
342
+ # error: it must not be retried, reconnected, or
343
+ # converted into a task NACK.
344
+ raise
345
+ if isinstance(exc, IncompatibleVersionError):
346
+ # A reconnect may have renegotiated a protocol
347
+ # downgrade that makes on_idle unservable; surface
348
+ # it instead of burning the reconnect budget.
349
+ raise
350
+ self._stream_error = exc
351
+ failures += 1
352
+ if self._close_started.is_set():
353
+ break
354
+ if self._close_started.is_set():
355
+ break
356
+ # A stream loss invalidates local completion attempts, but does
357
+ # not send terminal commands for leases that may still exist.
358
+ await self._cancel_assignments()
359
+ conn = self._client._require_conn()
360
+ before_token = conn._session_token
361
+ await conn._recover_best_effort()
362
+ if conn._session_token == before_token:
363
+ failures += 1
364
+ else:
365
+ failures = 0
366
+ if failures >= 3:
367
+ raise self._stream_error or UnavailableError(
368
+ "bounded Work stream recovery exhausted"
369
+ )
370
+ if not self._close_started.is_set():
371
+ await asyncio.sleep(0)
372
+ finally:
373
+ await self._cancel_assignments()
374
+ self._run_finished.set()
375
+
376
+ async def close(self) -> None:
377
+ """Drain and stop once; caller cancellation cannot cancel cleanup."""
378
+ async with self._close_lock:
379
+ if self._close_task is None:
380
+ self._close_started.set()
381
+ # The controller must linearize detach and withdraw its probe
382
+ # before this gate closes publication; the close path uses the
383
+ # still-live stream to await that correlated WorkResult.
384
+ self._idle_candidate = False
385
+ self._idle_zero_since = None
386
+ self._idle_session_token = None
387
+ self._close_task = asyncio.create_task(
388
+ self._close_impl(), name=f"ringo-close-{self._worker_id}"
389
+ )
390
+ self._close_task.add_done_callback(_consume_task_exception)
391
+ close_task = self._close_task
392
+ await asyncio.shield(close_task)
393
+
394
+ async def _close_impl(self) -> None:
395
+ try:
396
+ if self._controller is not None and self._member is not None:
397
+ await self._controller._detach(self._member)
398
+ self._detached = True
399
+ self._publisher_closed = True
400
+ self._session_epoch += 1
401
+ session = self._session_done
402
+ # Drain is best effort. A disconnected stream cannot safely report
403
+ # any terminal result, and leases are deliberately left to expire.
404
+ if session is not None and not session.is_set():
405
+ try:
406
+ await asyncio.wait_for(
407
+ self._send_command(
408
+ queue_pb2.WorkRequest(
409
+ request_id=self._allocate_request_id(),
410
+ drain=queue_pb2.WorkerDrain(begin=True),
411
+ ),
412
+ wait=True,
413
+ ),
414
+ timeout=max(
415
+ 0.1,
416
+ min(self._command_timeout, self._grace_period or 1.0),
417
+ ),
418
+ )
419
+ except Exception:
420
+ pass
421
+ if self._grace_period:
422
+ try:
423
+ await asyncio.wait_for(
424
+ self._wait_handlers(), timeout=self._grace_period
425
+ )
426
+ except asyncio.TimeoutError:
427
+ pass
428
+ finally:
429
+ # From this point handlers and renewals are cancelled; no terminal
430
+ # frame is generated by their cancellation paths.
431
+ self._force_cancel.set()
432
+ try:
433
+ await self._cancel_assignments()
434
+ await self._stop_session()
435
+ self._client._unregister_worker(self)
436
+ if self._run_started:
437
+ await self._run_finished.wait()
438
+ finally:
439
+ self._close_finished.set()
440
+
441
+ async def _wait_handlers(self) -> None:
442
+ tasks = list(self._handler_tasks)
443
+ if tasks:
444
+ await asyncio.gather(*tasks, return_exceptions=True)
445
+
446
+ async def _cancel_assignments(self) -> None:
447
+ # Local assignments were reset, so the idle observation restarts too.
448
+ self._idle_candidate = False
449
+ self._idle_zero_since = None
450
+ self._idle_session_token = None
451
+ assignments = list(self._inflight.values())
452
+ self._inflight.clear()
453
+ renewals: list[asyncio.Task[Any]] = []
454
+ for assignment in assignments:
455
+ self._invalidate_assignment(assignment)
456
+ handler = assignment.handler_task
457
+ if handler is not None and not handler.done():
458
+ # Cancellation is advisory. Never let a handler that catches
459
+ # CancelledError block reconnect or forced close; its local
460
+ # capacity remains occupied until its coroutine really ends.
461
+ handler.cancel()
462
+ renewal = assignment.renew_task
463
+ if renewal is not None and not renewal.done():
464
+ renewal.cancel()
465
+ renewals.append(renewal)
466
+ if renewals:
467
+ await asyncio.gather(*renewals, return_exceptions=True)
468
+ for assignment in assignments:
469
+ await self._release_capacity_if_settled(assignment)
470
+
471
+ def _invalidate_assignment(self, assignment: _Assignment) -> None:
472
+ if not assignment.alive:
473
+ return
474
+ assignment.alive = False
475
+ if assignment.context is not None:
476
+ assignment.context._cancel()
477
+
478
+ async def _release_controller_permit(
479
+ self,
480
+ assignment: _Assignment,
481
+ *,
482
+ success: bool = False,
483
+ congestion: bool = False,
484
+ ) -> None:
485
+ if (
486
+ self._controller is None
487
+ or self._member is None
488
+ or assignment.controller_released
489
+ ):
490
+ return
491
+ assignment.controller_released = True
492
+ await self._controller._release_running(
493
+ self._member, success=success, congestion=congestion
494
+ )
495
+
496
+ async def _release_capacity_if_settled(
497
+ self, assignment: _Assignment, *, handler_settling: bool = False
498
+ ) -> None:
499
+ """Release capacity only as the application handler settles."""
500
+ if assignment.alive:
501
+ return
502
+ handler = assignment.handler_task
503
+ current = asyncio.current_task()
504
+ if handler is not None and not handler.done():
505
+ if handler is not current or not handler_settling:
506
+ return
507
+ if self._controller is not None:
508
+ await self._release_controller_permit(
509
+ assignment,
510
+ success=assignment.completion_success,
511
+ congestion=assignment.completion_congestion,
512
+ )
513
+ return
514
+ if assignment.fixed_released:
515
+ return
516
+ assignment.fixed_released = True
517
+ self._fixed_running = max(0, self._fixed_running - 1)
518
+ if not self._close_started.is_set():
519
+ await self._publish_fixed_capacity()
520
+
521
+ async def _run_session_monitored(self) -> None:
522
+ """Run one Work session, observing queue activity when on_idle is set.
523
+
524
+ Without ``on_idle`` this is exactly ``_run_session()``. With it, the
525
+ poll loop is executed inline by the ``run()`` coroutine itself (never
526
+ in a child task) so a callback exception — including ``SystemExit`` —
527
+ unwinds verbatim through ``await worker.run()``.
528
+ """
529
+ if self._on_idle is None:
530
+ await self._run_session()
531
+ return
532
+ session_task = asyncio.create_task(
533
+ self._run_session(), name="ringo-worker-session"
534
+ )
535
+ try:
536
+ while True:
537
+ done, _pending = await asyncio.wait(
538
+ (session_task,), timeout=_IDLE_POLL_SECONDS
539
+ )
540
+ if done:
541
+ # Propagate the session outcome (result or stream error).
542
+ return await session_task
543
+ if await self._idle_poll_once():
544
+ # Single-shot fired and returned normally: park on the
545
+ # session instead of waking every poll interval.
546
+ return await session_task
547
+ finally:
548
+ if not session_task.done():
549
+ session_task.cancel()
550
+ await asyncio.gather(session_task, return_exceptions=True)
551
+
552
+ async def _idle_poll_once(self) -> bool:
553
+ """One read-only, retry-safe activity observation tick.
554
+
555
+ Returns True exactly when the callback fired and returned normally.
556
+ """
557
+ callback = self._on_idle
558
+ if (
559
+ callback is None
560
+ or self._idle_fired
561
+ or not self._idle_candidate
562
+ or self._close_started.is_set()
563
+ or self._force_cancel.is_set()
564
+ ):
565
+ return False
566
+ # Local assignments make the queue non-idle for this worker.
567
+ if self._inflight or self._handler_tasks:
568
+ self._idle_zero_since = None
569
+ return False
570
+ session = self._session_done
571
+ if session is None or session.is_set():
572
+ return False
573
+ # The observation is bound to the token captured at registration: a
574
+ # unary-triggered renegotiation rotates the token without restarting
575
+ # the Work stream. End this session so the run loop re-registers the
576
+ # stream under the fresh token instead of observing a stale session.
577
+ conn = self._client._require_conn()
578
+ if conn._session_token != self._idle_session_token:
579
+ self._end_stale_idle_session(session)
580
+ return False
581
+ request = queue_pb2.GetQueueActivityRequest(
582
+ queue=self._queue.name, task_types=self._task_types
583
+ )
584
+ try:
585
+ response = await conn.call(
586
+ "GetQueueActivity", request, retry_safe=True
587
+ )
588
+ except asyncio.CancelledError:
589
+ raise
590
+ except Exception:
591
+ # A failed observation restarts the quiet window; a later poll
592
+ # may succeed again without tearing the session down.
593
+ self._idle_zero_since = None
594
+ return False
595
+ # The session or its token may have changed while the query was in
596
+ # flight; a stale response must not advance or complete the window.
597
+ if (
598
+ not self._idle_candidate
599
+ or self._session_done is not session
600
+ or session.is_set()
601
+ ):
602
+ self._idle_zero_since = None
603
+ return False
604
+ if self._client._require_conn()._session_token != self._idle_session_token:
605
+ self._end_stale_idle_session(session)
606
+ return False
607
+ if response.active_task_count > 0:
608
+ self._idle_zero_since = None
609
+ return False
610
+ now = asyncio.get_running_loop().time()
611
+ if self._idle_zero_since is None:
612
+ self._idle_zero_since = now
613
+ return False
614
+ if now - self._idle_zero_since < _IDLE_QUIET_SECONDS:
615
+ return False
616
+ # Final atomic gate: no await separates these checks from marking
617
+ # fired and invoking the callback, so an assignment landing in a
618
+ # query in-flight window cannot produce a false trigger.
619
+ if (
620
+ self._inflight
621
+ or self._handler_tasks
622
+ or self._close_started.is_set()
623
+ or self._force_cancel.is_set()
624
+ ):
625
+ self._idle_zero_since = None
626
+ return False
627
+ # Single-shot: mark before invoking so the callback can never
628
+ # re-enter the monitor, and so a normal return never fires twice.
629
+ self._idle_fired = True
630
+ try:
631
+ result = callback()
632
+ if inspect.isawaitable(result):
633
+ await result
634
+ except BaseException as exc:
635
+ # Tag callback failures so run() propagates them verbatim
636
+ # instead of feeding them to the reconnect loop.
637
+ self._idle_error = exc
638
+ raise
639
+ return True
640
+
641
+ def _end_stale_idle_session(self, session: asyncio.Event) -> None:
642
+ """End the current Work session after a token rotation.
643
+
644
+ Setting ``session_done`` is idempotent and is observed as a clean
645
+ stream end: ``_run_session`` returns without a stream error, the run
646
+ loop reconnects, and the next session re-registers (and re-arms the
647
+ idle candidate) under the fresh token. It is neither a callback
648
+ failure nor a reconnect-worthy stream error.
649
+ """
650
+ self._idle_candidate = False
651
+ self._idle_zero_since = None
652
+ session.set()
653
+
654
+ async def _run_session(self) -> None:
655
+ conn = self._client._require_conn()
656
+ self._session_epoch += 1
657
+ # A new session invalidates any in-flight idle observation.
658
+ self._idle_candidate = False
659
+ self._idle_zero_since = None
660
+ self._idle_session_token = None
661
+ if (
662
+ self._on_idle is not None
663
+ and conn._session_minor < _IDLE_MIN_PROTOCOL_MINOR
664
+ ):
665
+ # A reconnect may have renegotiated a downgrade to minor 0.
666
+ # Fail fast before opening the Work stream; run() surfaces this
667
+ # verbatim instead of counting it as a reconnect failure.
668
+ raise IncompatibleVersionError(
669
+ "on_idle requires a server speaking protocol minor"
670
+ f" >= {_IDLE_MIN_PROTOCOL_MINOR}; negotiated minor"
671
+ f" is {conn._session_minor}"
672
+ )
673
+ reg = queue_pb2.WorkerRegistration(
674
+ queue=self._queue.name,
675
+ worker_id=self._worker_id,
676
+ task_types=self._task_types,
677
+ max_concurrency=max(1, self._concurrency),
678
+ )
679
+ if self._lease_duration > timedelta(0):
680
+ reg.lease_duration.CopyFrom(_pb.td_to_dur(self._lease_duration))
681
+ async with conn._lock:
682
+ stub = conn._require_stub()
683
+ reg.context.CopyFrom(conn.request_context())
684
+ call = stub.Work(metadata=conn._metadata())
685
+ self._call = call
686
+ self._session_done = asyncio.Event()
687
+ self._registered = asyncio.Event()
688
+ self._stream_error = None
689
+ self._outbox = asyncio.Queue()
690
+ self._pending.clear()
691
+ self._writer_task = asyncio.create_task(
692
+ self._write_loop(call), name="ringo-worker-writer"
693
+ )
694
+ self._reader_task = asyncio.create_task(
695
+ self._read_loop(call), name="ringo-worker-reader"
696
+ )
697
+ try:
698
+ await self._enqueue_frame(queue_pb2.WorkRequest(register=reg))
699
+ await self._wait_event_or_session(self._registered)
700
+ if self._force_cancel.is_set():
701
+ return
702
+ # Initial credit is sent only after WorkerRegistered was observed.
703
+ if self._controller is None:
704
+ await self._send_credit(self._concurrency)
705
+ else:
706
+ self._controller._check_loop()
707
+ await self._controller._start_member(self._member)
708
+ if self._on_idle is not None and not self._close_started.is_set():
709
+ # Eligible only from the first/current WorkerRegistered plus
710
+ # a successful initial credit of this session. The candidate
711
+ # is bound to the token this stream was registered with: a
712
+ # later unary renegotiation that rotates the token disarms
713
+ # the monitor until the next session.
714
+ self._idle_session_token = reg.context.session_token
715
+ self._idle_candidate = True
716
+ self._idle_zero_since = None
717
+ await self._wait_event_or_session(self._session_done)
718
+ if self._stream_error is not None and not self._close_started.is_set():
719
+ raise self._stream_error
720
+ finally:
721
+ await self._stop_session()
722
+ self._call = None
723
+ self._session_done = None
724
+ self._registered = None
725
+
726
+ async def _wait_event_or_session(self, event: asyncio.Event) -> None:
727
+ if self._session_done is None:
728
+ return
729
+ event_task = asyncio.create_task(event.wait())
730
+ done_task = asyncio.create_task(self._session_done.wait())
731
+ done, pending = await asyncio.wait(
732
+ (event_task, done_task), return_when=asyncio.FIRST_COMPLETED
733
+ )
734
+ for task in pending:
735
+ task.cancel()
736
+ await asyncio.gather(*pending, return_exceptions=True)
737
+ if done_task in done and not event.is_set():
738
+ if self._stream_error is not None:
739
+ raise self._stream_error
740
+ raise UnavailableError("Work stream closed")
741
+
742
+ async def _stop_session(self) -> None:
743
+ session = self._session_done
744
+ if session is not None:
745
+ session.set()
746
+ call, self._call = self._call, None
747
+ writer, self._writer_task = self._writer_task, None
748
+ reader, self._reader_task = self._reader_task, None
749
+ outbox, self._outbox = self._outbox, None
750
+ if outbox is not None and writer is not None and not writer.done():
751
+ await outbox.put(None)
752
+ if call is not None:
753
+ try:
754
+ call.cancel()
755
+ except Exception:
756
+ pass
757
+ tasks = [t for t in (reader, writer) if t is not None]
758
+ if tasks:
759
+ await asyncio.gather(*tasks, return_exceptions=True)
760
+ for future in self._pending.values():
761
+ if not future.done():
762
+ future.set_exception(UnavailableError("Work stream closed"))
763
+ self._pending.clear()
764
+ if self._controller is not None and self._member is not None:
765
+ await self._controller._stream_lost(self._member)
766
+
767
+ async def _enqueue_frame(self, frame: queue_pb2.WorkRequest) -> None:
768
+ outbox = self._outbox
769
+ if outbox is None:
770
+ raise UnavailableError("Work stream is not connected")
771
+ await outbox.put(frame)
772
+
773
+ async def _write_loop(self, call: Any) -> None:
774
+ try:
775
+ while True:
776
+ frame = await self._outbox.get() if self._outbox is not None else None
777
+ if frame is None:
778
+ try:
779
+ done_writing = getattr(call, "done_writing", None)
780
+ if done_writing is not None:
781
+ await done_writing()
782
+ except Exception:
783
+ pass
784
+ return
785
+ await call.write(frame)
786
+ except asyncio.CancelledError:
787
+ raise
788
+ except Exception as exc:
789
+ self._stream_error = self._map_transport(exc)
790
+ if self._session_done is not None:
791
+ self._session_done.set()
792
+
793
+ async def _read_loop(self, call: Any) -> None:
794
+ try:
795
+ async for response in call:
796
+ body = response.WhichOneof("body")
797
+ if body == "registered":
798
+ registered = response.registered
799
+ if registered.HasField("lease_duration"):
800
+ self._lease_duration = (
801
+ _pb.dur_to_td(registered.lease_duration)
802
+ or self._lease_duration
803
+ )
804
+ if (
805
+ self._controller is None
806
+ and not self._fixed_dynamic_target
807
+ and registered.max_concurrency
808
+ ):
809
+ self._concurrency = registered.max_concurrency
810
+ if self._registered is not None:
811
+ self._registered.set()
812
+ elif body == "task":
813
+ await self._on_assignment(response.task)
814
+ elif body == "result":
815
+ result = response.result
816
+ future = self._pending.pop(result.request_id, None)
817
+ if future is not None and not future.done():
818
+ future.set_result(result)
819
+ # Heartbeats are unsolicited liveness frames and need no action.
820
+ except asyncio.CancelledError:
821
+ raise
822
+ except Exception as exc:
823
+ self._stream_error = self._map_transport(exc)
824
+ finally:
825
+ if self._session_done is not None:
826
+ self._session_done.set()
827
+
828
+ @staticmethod
829
+ def _map_transport(exc: BaseException) -> BaseException:
830
+ if isinstance(exc, RingoError):
831
+ return exc
832
+ if isinstance(exc, grpc.aio.AioRpcError):
833
+ return map_rpc_error(exc)
834
+ return UnavailableError(f"Work stream failed: {_safe_error(exc)}")
835
+
836
+ async def _on_assignment(self, leased: queue_pb2.LeasedTask) -> None:
837
+ if (
838
+ self._force_cancel.is_set()
839
+ or not leased.HasField("task")
840
+ or not leased.HasField("lease")
841
+ ):
842
+ return
843
+ task = Task.from_proto(leased.task)
844
+ until = _pb.ts_to_dt(leased.lease.until)
845
+ assignment = _Assignment(
846
+ task=task,
847
+ attempt=leased.lease.attempt,
848
+ token=leased.lease.lease_token,
849
+ until=until,
850
+ )
851
+ context = TaskContext(
852
+ self,
853
+ task,
854
+ assignment.attempt,
855
+ task.max_attempts,
856
+ self._worker_id,
857
+ assignment,
858
+ )
859
+ assignment.context = context
860
+ # Task IDs are unique among valid in-flight leases. Avoid replacing an
861
+ # existing assignment if a malformed peer sends a duplicate.
862
+ if task.id in self._inflight:
863
+ return
864
+ if self._controller is not None and self._member is not None:
865
+ # Consuming the reserved probe is the linearization point at which
866
+ # an assignment becomes a running controller permit.
867
+ if not await self._controller._probe_consumed(self._member):
868
+ # A withdrawn probe must not become a new handler. The lease
869
+ # remains server-owned and will naturally expire if this was a
870
+ # transport race.
871
+ return
872
+ self._inflight[task.id] = assignment
873
+ if self._controller is None:
874
+ self._fixed_running += 1
875
+ assignment.renew_task = asyncio.create_task(
876
+ self._renew_loop(assignment), name=f"ringo-renew-{task.id}"
877
+ )
878
+ assignment.handler_task = asyncio.create_task(
879
+ self._handle_assignment(assignment), name=f"ringo-handler-{task.id}"
880
+ )
881
+ self._handler_tasks.add(assignment.handler_task)
882
+ assignment.handler_task.add_done_callback(_consume_task_exception)
883
+
884
+ async def _handle_assignment(self, assignment: _Assignment) -> None:
885
+ try:
886
+ context = assignment.context
887
+ assert context is not None
888
+ result = self._handler(assignment.task, context)
889
+ if inspect.isawaitable(result):
890
+ await result
891
+ if assignment.alive and not self._force_cancel.is_set():
892
+ await self._finish(assignment, "ack")
893
+ except asyncio.CancelledError:
894
+ # Cancellation is intentionally not a task outcome. In particular,
895
+ # do not emit NACK while draining or after a lease loss.
896
+ raise
897
+ except RetryTaskError as exc:
898
+ if assignment.alive and not self._force_cancel.is_set():
899
+ await self._finish(assignment, "nack", exc)
900
+ except PermanentTaskError as exc:
901
+ if assignment.alive and not self._force_cancel.is_set():
902
+ await self._finish(assignment, "reject", exc)
903
+ except Exception as exc:
904
+ if assignment.alive and not self._force_cancel.is_set():
905
+ await self._finish(assignment, "nack", exc)
906
+ finally:
907
+ current = asyncio.current_task()
908
+ if current is not None:
909
+ self._handler_tasks.discard(current)
910
+ await self._release_capacity_if_settled(
911
+ assignment, handler_settling=True
912
+ )
913
+
914
+ async def _finish(
915
+ self,
916
+ assignment: _Assignment,
917
+ operation: str,
918
+ error: BaseException | None = None,
919
+ ) -> None:
920
+ if not assignment.alive or assignment.completion_started:
921
+ return
922
+ assignment.completion_started = True
923
+ request: queue_pb2.WorkRequest
924
+ text = _safe_error(error) if error is not None else ""
925
+ if operation == "ack":
926
+ request = queue_pb2.WorkRequest(
927
+ ack=queue_pb2.AckRequest(lease=assignment.ref)
928
+ )
929
+ elif operation == "nack":
930
+ nack = queue_pb2.NackRequest(lease=assignment.ref, error=text)
931
+ if isinstance(error, RetryTaskError) and error.delay is not None:
932
+ if (
933
+ not isinstance(error.delay, (int, float))
934
+ or not math.isfinite(error.delay)
935
+ or error.delay < 0
936
+ ):
937
+ # Treat an invalid override as an ordinary failure rather
938
+ # than allowing a negative Duration onto the wire.
939
+ nack.error = _safe_error(error)
940
+ else:
941
+ try:
942
+ nack.retry_delay_override.CopyFrom(
943
+ _pb.td_to_dur(timedelta(seconds=error.delay))
944
+ )
945
+ except Exception:
946
+ # An unrepresentable override is treated as a normal
947
+ # NACK; never strand the assignment on serialization.
948
+ nack.error = _safe_error(error)
949
+ request = queue_pb2.WorkRequest(nack=nack)
950
+ else:
951
+ request = queue_pb2.WorkRequest(
952
+ reject=queue_pb2.RejectRequest(lease=assignment.ref, error=text)
953
+ )
954
+ try:
955
+ result = await self._send_command(request, wait=True)
956
+ except asyncio.CancelledError:
957
+ raise
958
+ except Exception:
959
+ return
960
+ if result is None:
961
+ return
962
+ detail = result.error if result.HasField("error") else None
963
+ if detail is not None and detail.code == queue_pb2.ERROR_CODE_LEASE_LOST:
964
+ await self._lease_lost(assignment)
965
+ return
966
+ if detail is not None and detail.code != queue_pb2.ERROR_CODE_UNSPECIFIED:
967
+ # A non-lease terminal failure is not terminal according to the
968
+ # protocol; leave the assignment fenced until the stream/server does.
969
+ assignment.completion_started = False
970
+ return
971
+ congestion = isinstance(error, RetryTaskError) and error.congestion
972
+ await self._release_assignment(
973
+ assignment,
974
+ success=operation == "ack",
975
+ congestion=congestion,
976
+ )
977
+
978
+ async def _release_assignment(
979
+ self,
980
+ assignment: _Assignment,
981
+ *,
982
+ cancel_context: bool = False,
983
+ success: bool = False,
984
+ congestion: bool = False,
985
+ ) -> None:
986
+ if not assignment.alive:
987
+ return
988
+ assignment.alive = False
989
+ assignment.completion_success = success
990
+ assignment.completion_congestion = congestion
991
+ self._inflight.pop(assignment.task.id, None)
992
+ if cancel_context and assignment.context is not None:
993
+ assignment.context._cancel()
994
+ if (
995
+ assignment.renew_task is not None
996
+ and assignment.renew_task is not asyncio.current_task()
997
+ ):
998
+ assignment.renew_task.cancel()
999
+ await asyncio.gather(assignment.renew_task, return_exceptions=True)
1000
+ await self._release_capacity_if_settled(assignment)
1001
+
1002
+ async def _lease_lost(self, assignment: _Assignment) -> None:
1003
+ if not assignment.alive:
1004
+ return
1005
+ await self._release_assignment(assignment, cancel_context=True)
1006
+ handler = assignment.handler_task
1007
+ if (
1008
+ handler is not None
1009
+ and handler is not asyncio.current_task()
1010
+ and not handler.done()
1011
+ ):
1012
+ # Do not await an application coroutine that may intentionally
1013
+ # swallow cancellation. Its capacity is released by its finally.
1014
+ handler.cancel()
1015
+ await self._release_capacity_if_settled(assignment)
1016
+
1017
+ async def _renew_loop(self, assignment: _Assignment) -> None:
1018
+ while assignment.alive and not self._force_cancel.is_set():
1019
+ remaining = (assignment.until - datetime.now(timezone.utc)).total_seconds()
1020
+ delay = max(0.01, remaining - self._lease_duration.total_seconds() / 3)
1021
+ try:
1022
+ await asyncio.wait_for(self._force_cancel.wait(), timeout=delay)
1023
+ return
1024
+ except asyncio.TimeoutError:
1025
+ pass
1026
+ if not assignment.alive or self._force_cancel.is_set():
1027
+ return
1028
+ request = queue_pb2.WorkRequest(
1029
+ extend_lease=queue_pb2.ExtendLeaseRequest(
1030
+ lease=assignment.ref,
1031
+ extension=_pb.td_to_dur(self._lease_duration),
1032
+ )
1033
+ )
1034
+ try:
1035
+ result = await self._send_command(request, wait=True)
1036
+ except asyncio.CancelledError:
1037
+ raise
1038
+ except Exception:
1039
+ return
1040
+ if result is None:
1041
+ return
1042
+ detail = result.error if result.HasField("error") else None
1043
+ if detail is not None and detail.code == queue_pb2.ERROR_CODE_LEASE_LOST:
1044
+ await self._lease_lost(assignment)
1045
+ return
1046
+ if detail is not None and detail.code != queue_pb2.ERROR_CODE_UNSPECIFIED:
1047
+ return
1048
+ # ExtendLeaseResponse is not carried in WorkResult. The protocol
1049
+ # defines the new deadline as server_now + extension; this local
1050
+ # estimate is the only safe deadline available to the SDK.
1051
+ assignment.until = datetime.now(timezone.utc) + self._lease_duration
1052
+
1053
+ async def _report_progress(
1054
+ self, assignment: _Assignment, current: float, total: float, message: str
1055
+ ) -> None:
1056
+ if not assignment.alive:
1057
+ raise LeaseLostError("task lease is no longer valid")
1058
+ request = queue_pb2.WorkRequest(
1059
+ progress=queue_pb2.ReportProgressRequest(
1060
+ lease=assignment.ref,
1061
+ current=current,
1062
+ total=total,
1063
+ message=_safe_error(message),
1064
+ )
1065
+ )
1066
+ result = await self._send_command(request, wait=True)
1067
+ if result is None:
1068
+ return
1069
+ detail = result.error if result.HasField("error") else None
1070
+ if detail is not None and detail.code == queue_pb2.ERROR_CODE_LEASE_LOST:
1071
+ await self._lease_lost(assignment)
1072
+ raise LeaseLostError(detail.message)
1073
+ if detail is not None and detail.code != queue_pb2.ERROR_CODE_UNSPECIFIED:
1074
+ raise error_from_detail(detail)
1075
+
1076
+ async def _send_credit(self, available: int) -> None:
1077
+ # ``available`` is intentionally not trusted across retries. A task
1078
+ # may arrive between a rejected snapshot and its retry, so every
1079
+ # credit-capacity retry samples local in-flight state again.
1080
+ await self._publish_fixed_capacity()
1081
+
1082
+ async def _publish_fixed_capacity(self) -> None:
1083
+ async with self._publisher_lock:
1084
+ epoch = self._session_epoch
1085
+ for attempt in range(20):
1086
+ if (
1087
+ self._publisher_closed
1088
+ or self._close_started.is_set()
1089
+ or epoch != self._session_epoch
1090
+ or self._session_done is None
1091
+ or self._session_done.is_set()
1092
+ ):
1093
+ return
1094
+ available = max(0, self._concurrency - self._fixed_running)
1095
+ update = queue_pb2.CreditUpdate(available=available)
1096
+ if self._fixed_dynamic_target:
1097
+ update.target_concurrency = self._concurrency
1098
+ result = await self._send_command(
1099
+ queue_pb2.WorkRequest(credit=update), wait=True
1100
+ )
1101
+ if (
1102
+ result is None
1103
+ or not result.HasField("error")
1104
+ or result.error.code == queue_pb2.ERROR_CODE_UNSPECIFIED
1105
+ ):
1106
+ return
1107
+ if (
1108
+ result.error.metadata.get("reason") != "credit_capacity"
1109
+ or attempt == 19
1110
+ ):
1111
+ raise error_from_detail(result.error)
1112
+ await asyncio.sleep(0.01)
1113
+
1114
+ async def _publish_controller_initial(self, capacity: int) -> None:
1115
+ async with self._publisher_lock:
1116
+ if (
1117
+ self._publisher_closed
1118
+ or self._close_started.is_set()
1119
+ or self._session_done is None
1120
+ or self._session_done.is_set()
1121
+ ):
1122
+ raise UnavailableError("Work stream is not connected")
1123
+ ok = await self._publish_dynamic_locked(capacity, 0)
1124
+ if not ok:
1125
+ raise UnavailableError("initial dynamic credit was not accepted")
1126
+
1127
+ async def _publish_controller_probe(
1128
+ self, member: _Member, capacity: int, generation: int
1129
+ ) -> None:
1130
+ async with self._publisher_lock:
1131
+ if (
1132
+ self._publisher_closed
1133
+ or self._close_started.is_set()
1134
+ or self._member is not member
1135
+ or not member.probe
1136
+ or member.probe_generation != generation
1137
+ or self._session_done is None
1138
+ or self._session_done.is_set()
1139
+ ):
1140
+ return
1141
+ await self._publish_dynamic_locked(
1142
+ capacity,
1143
+ 1,
1144
+ probe_member=member,
1145
+ probe_generation=generation,
1146
+ )
1147
+
1148
+ async def _publish_controller_target(self, capacity: int) -> bool:
1149
+ """Publish target/zero credit for a live shared-controller stream."""
1150
+ async with self._publisher_lock:
1151
+ if self._publisher_closed:
1152
+ return True
1153
+ if self._session_done is None or self._session_done.is_set():
1154
+ return True
1155
+ return await self._publish_dynamic_locked(capacity, 0)
1156
+
1157
+ async def _withdraw_controller_probe(
1158
+ self,
1159
+ member: _Member,
1160
+ capacity: int,
1161
+ *,
1162
+ force_target: bool = False,
1163
+ ) -> bool:
1164
+ async with self._publisher_lock:
1165
+ if self._publisher_closed:
1166
+ return True
1167
+ if self._session_done is None or self._session_done.is_set():
1168
+ # The old stream is definitively gone; its credit no longer
1169
+ # exists server-side and the controller may reclaim the token.
1170
+ return True
1171
+ return await self._publish_dynamic_locked(
1172
+ capacity,
1173
+ 0,
1174
+ allow_closing=True,
1175
+ explicit_target=force_target,
1176
+ )
1177
+
1178
+ async def _publish_dynamic_locked(
1179
+ self,
1180
+ target: int,
1181
+ available: int,
1182
+ *,
1183
+ allow_closing: bool = False,
1184
+ explicit_target: bool = False,
1185
+ probe_member: _Member | None = None,
1186
+ probe_generation: int = 0,
1187
+ ) -> bool:
1188
+ epoch = self._session_epoch
1189
+ for attempt in range(20):
1190
+ if (
1191
+ self._publisher_closed
1192
+ or (self._close_started.is_set() and not allow_closing)
1193
+ or epoch != self._session_epoch
1194
+ or self._session_done is None
1195
+ or self._session_done.is_set()
1196
+ ):
1197
+ return self._session_done is None or self._session_done.is_set()
1198
+ if probe_member is not None and (
1199
+ not probe_member.probe
1200
+ or probe_member.probe_generation != probe_generation
1201
+ ):
1202
+ # The assignment consumed this probe while a previous
1203
+ # capacity update was in flight; never replay available=1.
1204
+ return True
1205
+ # target and available are one atomic protocol snapshot. A
1206
+ # credit_capacity rejection must be retried with a fresh target /
1207
+ # in-flight sample, never by replaying the old snapshot.
1208
+ current_target = (
1209
+ self._controller.capacity
1210
+ if self._controller is not None and not explicit_target
1211
+ else target
1212
+ )
1213
+ current_available = available
1214
+ if self._controller is None:
1215
+ # An assignment may have been sent by the server before the
1216
+ # rejected credit result reached this reader. Re-sample the
1217
+ # now-materialized local assignments instead of replaying the
1218
+ # stale fixed-capacity snapshot.
1219
+ current_available = max(0, current_target - self._fixed_running)
1220
+ update = queue_pb2.CreditUpdate(
1221
+ available=max(0, current_available),
1222
+ target_concurrency=current_target,
1223
+ )
1224
+ result = await self._send_command(
1225
+ queue_pb2.WorkRequest(credit=update), wait=True
1226
+ )
1227
+ if (
1228
+ result is None
1229
+ or not result.HasField("error")
1230
+ or result.error.code == queue_pb2.ERROR_CODE_UNSPECIFIED
1231
+ ):
1232
+ return True
1233
+ if (
1234
+ result.error.metadata.get("reason") != "credit_capacity"
1235
+ or attempt == 19
1236
+ ):
1237
+ raise error_from_detail(result.error)
1238
+ await asyncio.sleep(0.01)
1239
+ return False
1240
+
1241
+ def _allocate_request_id(self) -> int:
1242
+ rid = self._next_request_id
1243
+ self._next_request_id += 1
1244
+ if self._next_request_id == 0:
1245
+ self._next_request_id = 1
1246
+ return rid
1247
+
1248
+ async def _send_command(
1249
+ self, request: queue_pb2.WorkRequest, *, wait: bool
1250
+ ) -> queue_pb2.WorkResult | None:
1251
+ if self._session_done is None or self._session_done.is_set():
1252
+ raise UnavailableError("Work stream is not connected")
1253
+ rid = request.request_id or self._allocate_request_id()
1254
+ request.request_id = rid
1255
+ loop = asyncio.get_running_loop()
1256
+ future: asyncio.Future[queue_pb2.WorkResult] = loop.create_future()
1257
+ self._pending[rid] = future
1258
+ try:
1259
+ await self._enqueue_frame(request)
1260
+ if not wait:
1261
+ return None
1262
+ return await asyncio.wait_for(
1263
+ self._wait_command_or_session(future), timeout=self._command_timeout
1264
+ )
1265
+ except asyncio.CancelledError:
1266
+ self._pending.pop(rid, None)
1267
+ if future.done() and not future.cancelled():
1268
+ future.exception()
1269
+ raise
1270
+ except Exception:
1271
+ self._pending.pop(rid, None)
1272
+ if future.done() and not future.cancelled():
1273
+ future.exception()
1274
+ raise
1275
+
1276
+ async def _wait_command_or_session(
1277
+ self, future: asyncio.Future[queue_pb2.WorkResult]
1278
+ ) -> queue_pb2.WorkResult:
1279
+ session = self._session_done
1280
+ if session is None:
1281
+ raise UnavailableError("Work stream is not connected")
1282
+ session_wait = asyncio.create_task(session.wait())
1283
+ try:
1284
+ done, _ = await asyncio.wait(
1285
+ (future, session_wait), return_when=asyncio.FIRST_COMPLETED
1286
+ )
1287
+ if future in done:
1288
+ return future.result()
1289
+ if future.done():
1290
+ return future.result()
1291
+ raise self._stream_error or UnavailableError("Work stream closed")
1292
+ finally:
1293
+ session_wait.cancel()
1294
+ await asyncio.gather(session_wait, return_exceptions=True)
1295
+
1296
+
1297
+ __all__ = ["TaskContext", "Worker"]