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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1868 @@
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
+ import sys
13
+ from collections import deque
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timedelta, timezone
16
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterable
17
+
18
+ import grpc
19
+
20
+ from . import _pb
21
+ from ._proto.ringo.v1 import queue_pb2
22
+ from ._supervisor_policy import TaskTimeouts
23
+ from .concurrency import AdaptiveConcurrency, ResourceConcurrency, _Member
24
+ from .errors import (
25
+ IncompatibleVersionError,
26
+ LeaseLostError,
27
+ NotFoundError,
28
+ PermanentTaskError,
29
+ RetryTaskError,
30
+ RingoError,
31
+ UnavailableError,
32
+ error_from_detail,
33
+ map_rpc_error,
34
+ )
35
+ from .models import Task
36
+
37
+ if TYPE_CHECKING:
38
+ from ._supervisor_watchdog import _WorkerWatchdog
39
+
40
+
41
+ def _current_runtime() -> Any:
42
+ # A normal SDK install does not import the process-supervision runtime.
43
+ module = sys.modules.get("ringo_task_queue._supervisor_runtime")
44
+ return None if module is None else module.current_runtime()
45
+
46
+
47
+ _MAX_ERROR_BYTES = 256 * 1024
48
+
49
+ #: Fixed on_idle monitor cadence (design §10.6). These are deliberately not
50
+ #: exposed as Worker parameters: the poll interval and the continuous-idle
51
+ #: quiet period are protocol-level constants of the idle-observation feature.
52
+ _IDLE_POLL_SECONDS = 0.25
53
+ _IDLE_QUIET_SECONDS = 1.0
54
+ #: GetQueueActivity was introduced in protocol minor 1.
55
+ _IDLE_MIN_PROTOCOL_MINOR = 1
56
+ _ORDERED_REQUEST_IDS_MIN_PROTOCOL_MINOR = 6
57
+ _MAX_REQUEST_ID = 2**64 - 1
58
+
59
+
60
+ class _WatchdogFenced(Exception):
61
+ """A stopped deadline invocation abandoned during graceful drain."""
62
+
63
+
64
+ def _consume_task_exception(task: asyncio.Future[Any]) -> None:
65
+ if not task.cancelled():
66
+ task.exception()
67
+
68
+
69
+ def _safe_error(value: object) -> str:
70
+ """Return a valid UTF-8 error string no larger than 256 KiB."""
71
+ try:
72
+ text = str(value)
73
+ except Exception:
74
+ text = type(value).__name__
75
+ encoded = text.encode("utf-8", errors="replace")
76
+ if len(encoded) <= _MAX_ERROR_BYTES:
77
+ return encoded.decode("utf-8", errors="replace")
78
+ # Decode with replacement after truncating bytes; this is UTF-8 safe and
79
+ # never emits a broken surrogate or an over-sized protobuf string.
80
+ return encoded[:_MAX_ERROR_BYTES].decode("utf-8", errors="ignore")
81
+
82
+
83
+ class TaskContext:
84
+ """Context supplied to a worker handler.
85
+
86
+ ``cancelled`` becomes true when the local lease is lost, the stream drops,
87
+ or the worker is closed. ``report_progress`` is correlated on the Work
88
+ stream and never exposes the lease token to application code.
89
+ """
90
+
91
+ __slots__ = (
92
+ "_worker",
93
+ "_task",
94
+ "_attempt",
95
+ "_max_attempts",
96
+ "_worker_id",
97
+ "_assignment",
98
+ "_cancel_event",
99
+ )
100
+
101
+ def __init__(
102
+ self,
103
+ worker: "Worker",
104
+ task: Task,
105
+ attempt: int,
106
+ max_attempts: int,
107
+ worker_id: str,
108
+ assignment: "_Assignment",
109
+ ) -> None:
110
+ self._worker = worker
111
+ self._task = task
112
+ self._attempt = attempt
113
+ self._max_attempts = max_attempts
114
+ self._worker_id = worker_id
115
+ self._assignment = assignment
116
+ self._cancel_event = asyncio.Event()
117
+
118
+ @property
119
+ def task(self) -> Task:
120
+ return self._task
121
+
122
+ @property
123
+ def attempt(self) -> int:
124
+ return self._attempt
125
+
126
+ @property
127
+ def max_attempts(self) -> int:
128
+ return self._max_attempts
129
+
130
+ @property
131
+ def worker_id(self) -> str:
132
+ return self._worker_id
133
+
134
+ @property
135
+ def cancelled(self) -> bool:
136
+ return self._cancel_event.is_set()
137
+
138
+ @property
139
+ def is_cancelled(self) -> bool:
140
+ """Alias useful to code shared with SDKs using a verb-like name."""
141
+ return self.cancelled
142
+
143
+ async def wait_cancelled(self) -> None:
144
+ await self._cancel_event.wait()
145
+
146
+ def mark_progress(self) -> None:
147
+ """Mark local business progress without sending any RPC."""
148
+ watchdog = self._worker._watchdog
149
+ if watchdog is not None:
150
+ watchdog.mark_progress(self._assignment)
151
+
152
+ async def report_progress(
153
+ self, current: float, total: float, message: str = ""
154
+ ) -> None:
155
+ """Mark local progress, report it and await its correlated result."""
156
+ if self.cancelled:
157
+ raise LeaseLostError("task lease is no longer valid")
158
+ self.mark_progress()
159
+ await self._worker._report_progress(self._assignment, current, total, message)
160
+
161
+ def _cancel(self) -> None:
162
+ self._cancel_event.set()
163
+
164
+
165
+ @dataclass
166
+ class _Assignment:
167
+ task: Task
168
+ attempt: int
169
+ token: str
170
+ until: datetime
171
+ context: TaskContext | None = None
172
+ handler_task: asyncio.Task[Any] | None = None
173
+ renew_task: asyncio.Task[Any] | None = None
174
+ alive: bool = True
175
+ completion_started: bool = False
176
+ completion_success: bool = False
177
+ completion_congestion: bool = False
178
+ controller_released: bool = False
179
+ controller_release_task: asyncio.Task[None] | None = None
180
+ fixed_released: bool = False
181
+ started: bool = False
182
+ session_epoch: int = 0
183
+ session: asyncio.Event | None = None
184
+ renew_deadline: float | None = None
185
+ watchdog_expired: bool = False
186
+
187
+ @property
188
+ def identity(self) -> tuple[str, int, str]:
189
+ return (self.task.id, self.attempt, self.token)
190
+
191
+ @property
192
+ def ref(self) -> queue_pb2.LeaseRef:
193
+ return queue_pb2.LeaseRef(
194
+ task_id=self.task.id,
195
+ attempt=self.attempt,
196
+ lease_token=self.token,
197
+ )
198
+
199
+
200
+ @dataclass
201
+ class _WorkSource:
202
+ """One Work call owns its correlation and transport lifetime.
203
+
204
+ Reader/writer tasks keep this object even after a replacement becomes the
205
+ current source. No receipt, EOF or timeout from an old call can touch the
206
+ replacement's pending Futures, registration or transport.
207
+ """
208
+
209
+ epoch: int
210
+ call: Any
211
+ minor: int = 0
212
+ token: str = ""
213
+ ordered_request_ids: bool = False
214
+ done: asyncio.Event = field(default_factory=asyncio.Event)
215
+ registered: asyncio.Event = field(default_factory=asyncio.Event)
216
+ outbox: asyncio.Queue[queue_pb2.WorkRequest | None] = field(
217
+ default_factory=asyncio.Queue
218
+ )
219
+ pending: dict[int, asyncio.Future[queue_pb2.WorkResult]] = field(
220
+ default_factory=dict
221
+ )
222
+ next_request_id: int = 1
223
+ error: BaseException | None = None
224
+ reader: asyncio.Task[Any] | None = None
225
+ writer: asyncio.Task[Any] | None = None
226
+ publisher: asyncio.Task[None] | None = None
227
+ cleanup: asyncio.Task[None] | None = None
228
+ # Diagnostics only: accepted credit never participates in admission itself.
229
+ accepted_credit: int = 0
230
+ credit_generation: int = -1
231
+
232
+
233
+ class Worker:
234
+ """A credit-controlled asynchronous task worker.
235
+
236
+ ``run`` is intentionally a blocking coroutine: it remains active until
237
+ ``close`` is called. The client registers workers and drains them before
238
+ closing its channel. When ``on_idle`` is configured, ``run`` additionally
239
+ observes queue activity and executes the callback inline at most once.
240
+ """
241
+
242
+ def __init__(
243
+ self,
244
+ queue: Any,
245
+ handler: Callable[[Task, TaskContext], Awaitable[Any]],
246
+ *,
247
+ task_types: Iterable[str],
248
+ concurrency: int | ResourceConcurrency | AdaptiveConcurrency,
249
+ lease_duration: timedelta,
250
+ grace_period: timedelta,
251
+ on_idle: Callable[[], Any] | None = None,
252
+ weight: int = 1,
253
+ task_timeouts: TaskTimeouts | None = None,
254
+ ) -> None:
255
+ if isinstance(concurrency, bool) or not isinstance(
256
+ concurrency, (int, ResourceConcurrency, AdaptiveConcurrency)
257
+ ):
258
+ raise ValueError(
259
+ "concurrency must be an integer, ResourceConcurrency, or AdaptiveConcurrency"
260
+ )
261
+ if isinstance(concurrency, int) and concurrency < 1:
262
+ raise ValueError("initial fixed concurrency must be >= 1")
263
+ if isinstance(weight, bool) or not isinstance(weight, int) or weight < 1:
264
+ raise ValueError("weight must be a positive integer")
265
+ self._queue = queue
266
+ self._client = queue._client
267
+ self._handler = handler
268
+ self._task_types = list(task_types)
269
+ self._controller: ResourceConcurrency | AdaptiveConcurrency | None = (
270
+ concurrency if not isinstance(concurrency, int) else None
271
+ )
272
+ self._member: _Member | None = None
273
+ self._concurrency = (
274
+ concurrency
275
+ if isinstance(concurrency, int)
276
+ else max(1, concurrency.capacity)
277
+ )
278
+ self._weight = weight
279
+ self._lease_duration = lease_duration
280
+ self._grace_period = max(0.0, grace_period.total_seconds())
281
+ self._worker_id = self._new_worker_id()
282
+ self._on_idle = on_idle
283
+ # on_idle monitor state (design §10.6). ``_idle_candidate`` arms only
284
+ # after WorkerRegistered plus the initial credit of the *current*
285
+ # session; every reset condition disarms it or clears the zero timer.
286
+ self._idle_candidate = False
287
+ self._idle_zero_since: float | None = None
288
+ self._idle_fired = False
289
+ #: Session token captured at WorkerRegistered + initial credit. The
290
+ #: candidate is only observable while the connection still carries
291
+ #: this token: a unary-triggered renegotiation rotates the token
292
+ #: without touching the Work stream and must disarm the monitor.
293
+ self._idle_session_token: str | None = None
294
+ #: Set when the on_idle callback raised: run() must re-raise verbatim
295
+ #: instead of counting it as a Work-stream reconnect failure.
296
+ self._idle_error: BaseException | None = None
297
+
298
+ # close_started means no new stream may be opened, while force_cancel
299
+ # is raised only after the graceful drain window expires. Existing
300
+ # handlers and renewals remain fully live between those two points.
301
+ self._close_started = asyncio.Event()
302
+ self._force_cancel = asyncio.Event()
303
+ self._run_started = False
304
+ self._run_finished = asyncio.Event()
305
+ self._close_finished = asyncio.Event()
306
+ self._command_timeout = 30.0
307
+ self._close_lock = asyncio.Lock()
308
+ self._close_task: asyncio.Task[None] | None = None
309
+ self._source: _WorkSource | None = None
310
+ # Recovery history is distinct from a live source's error. A clean EOF
311
+ # remains clean, while exhausted recovery can report its earlier cause.
312
+ self._last_stream_error: BaseException | None = None
313
+ # A successor lease can arrive before the predecessor's NACK receipt
314
+ # is consumed. Only an identical complete LeaseRef is a duplicate.
315
+ self._inflight: dict[tuple[str, int, str], _Assignment] = {}
316
+ self._waiting_assignments: deque[_Assignment] = deque()
317
+ self._capacity_tasks: set[asyncio.Task[Any]] = set()
318
+ # Handler lifetime, not just lease-map membership, owns local capacity.
319
+ # Old-stream handlers that swallow cancellation remain here until their
320
+ # coroutine actually settles.
321
+ self._handler_tasks: set[asyncio.Task[Any]] = set()
322
+ self._fixed_running = 0
323
+ # Every capacity update shares one publisher mutex and a monotonically
324
+ # increasing session epoch. A late task from an old stream can never
325
+ # publish credit onto a replacement stream or after close.
326
+ self._publisher_lock = asyncio.Lock()
327
+ self._session_epoch = 0
328
+ self._publisher_closed = False
329
+ self._fixed_capacity_generation = 0
330
+ self._fixed_publication_task: asyncio.Task[None] | None = None
331
+ self._fixed_publication_source: tuple[int, asyncio.Event] | None = None
332
+ self._fixed_publication_waiters: list[tuple[int, asyncio.Future[None]]] = []
333
+ self._fixed_dynamic_target = False
334
+ if task_timeouts is not None and not isinstance(task_timeouts, TaskTimeouts):
335
+ raise ValueError("task_timeouts must be TaskTimeouts or None")
336
+ self._runtime = _current_runtime()
337
+ self._task_timeouts = (
338
+ self._runtime.task_timeouts
339
+ if task_timeouts is None and self._runtime is not None
340
+ else task_timeouts
341
+ )
342
+ self._watchdog: _WorkerWatchdog | None = None
343
+ self._watchdog_error: RuntimeError | None = None
344
+ if (
345
+ self._runtime is not None
346
+ and self._runtime.policy.no_start_timeout is not None
347
+ ) or (self._task_timeouts is not None and self._task_timeouts.enabled):
348
+ from ._supervisor_watchdog import _WorkerWatchdog
349
+
350
+ self._watchdog = _WorkerWatchdog(
351
+ self, self._task_timeouts or TaskTimeouts(), self._runtime
352
+ )
353
+ if self._controller is not None:
354
+ self._member = self._controller._attach(self, weight)
355
+
356
+ @staticmethod
357
+ def _new_worker_id() -> str:
358
+ # Importing here avoids coupling worker construction to client internals.
359
+ from .client import new_worker_id
360
+
361
+ return new_worker_id()
362
+
363
+ # Read-only private views keep diagnostics and existing benchmark probes
364
+ # useful without maintaining a second set of mutable transport state.
365
+ @property
366
+ def _session_done(self) -> asyncio.Event | None:
367
+ return self._source.done if self._source is not None else None
368
+
369
+ @property
370
+ def _call(self) -> Any:
371
+ return self._source.call if self._source is not None else None
372
+
373
+ @property
374
+ def _outbox(self) -> asyncio.Queue[queue_pb2.WorkRequest | None] | None:
375
+ return self._source.outbox if self._source is not None else None
376
+
377
+ @property
378
+ def _pending(self) -> dict[int, asyncio.Future[queue_pb2.WorkResult]]:
379
+ return self._source.pending if self._source is not None else {}
380
+
381
+ @property
382
+ def _registered(self) -> asyncio.Event | None:
383
+ return self._source.registered if self._source is not None else None
384
+
385
+ @property
386
+ def _reader_task(self) -> asyncio.Task[Any] | None:
387
+ return self._source.reader if self._source is not None else None
388
+
389
+ @property
390
+ def _writer_task(self) -> asyncio.Task[Any] | None:
391
+ return self._source.writer if self._source is not None else None
392
+
393
+ @property
394
+ def _stream_error(self) -> BaseException | None:
395
+ return (
396
+ self._source.error if self._source is not None else self._last_stream_error
397
+ )
398
+
399
+ @property
400
+ def worker_id(self) -> str:
401
+ return self._worker_id
402
+
403
+ @property
404
+ def concurrency(self) -> int:
405
+ """Current fixed target, or the controller's current capacity."""
406
+ if self._controller is not None:
407
+ return self._controller.capacity
408
+ return self._concurrency
409
+
410
+ @property
411
+ def weight(self) -> int:
412
+ return self._weight
413
+
414
+ async def set_concurrency(self, concurrency: int) -> None:
415
+ """Resize a fixed worker without cancelling running handlers."""
416
+ if self._controller is not None:
417
+ raise ValueError(
418
+ "a Worker attached to a shared controller cannot resize itself"
419
+ )
420
+ if isinstance(concurrency, bool) or not isinstance(concurrency, int):
421
+ raise ValueError("concurrency must be an integer")
422
+ if concurrency < 0 or concurrency > 2**31 - 1:
423
+ raise ValueError("concurrency must be between 0 and 2**31-1")
424
+ if self._run_started:
425
+ conn = self._client._require_conn()
426
+ if conn._session_minor < 2:
427
+ raise IncompatibleVersionError(
428
+ "runtime fixed concurrency requires protocol minor >= 2; "
429
+ f"negotiated minor is {conn._session_minor}"
430
+ )
431
+ self._concurrency = concurrency
432
+ self._fixed_dynamic_target = True
433
+ self._start_waiting_assignments()
434
+ if self._session_done is not None and not self._session_done.is_set():
435
+ await self._publish_fixed_capacity()
436
+
437
+ async def run(self) -> None:
438
+ """Run until :meth:`close` is called, reconnecting boundedly."""
439
+ if self._run_started:
440
+ raise RuntimeError("worker.run() may only be called once")
441
+ self._run_started = True
442
+ failures = 0
443
+ try:
444
+ if self._runtime is not None:
445
+ self._runtime.register_worker(self)
446
+ if self._watchdog is not None:
447
+ self._watchdog.start()
448
+ conn = self._client._require_conn()
449
+ if (
450
+ self._controller is not None or self._fixed_dynamic_target
451
+ ) and conn._session_minor < 2:
452
+ raise IncompatibleVersionError(
453
+ "dynamic concurrency requires protocol minor >= 2; "
454
+ f"negotiated minor is {conn._session_minor}"
455
+ )
456
+ if self._on_idle is not None:
457
+ # GetQueueActivity arrived in protocol minor 1; fail fast
458
+ # instead of silently running without idle observation.
459
+ conn = self._client._require_conn()
460
+ if conn._session_minor < _IDLE_MIN_PROTOCOL_MINOR:
461
+ raise IncompatibleVersionError(
462
+ "on_idle requires a server speaking protocol minor"
463
+ f" >= {_IDLE_MIN_PROTOCOL_MINOR}; negotiated minor"
464
+ f" is {conn._session_minor}"
465
+ )
466
+ while not self._close_started.is_set():
467
+ try:
468
+ await self._run_session_monitored()
469
+ except asyncio.CancelledError:
470
+ if not self._close_started.is_set():
471
+ raise
472
+ break
473
+ except Exception as exc:
474
+ if self._idle_error is not None:
475
+ # An on_idle callback failure is not a transport
476
+ # error: it must not be retried, reconnected, or
477
+ # converted into a task NACK.
478
+ raise
479
+ if isinstance(exc, IncompatibleVersionError):
480
+ # A reconnect may have renegotiated a protocol
481
+ # downgrade that makes on_idle unservable; surface
482
+ # it instead of burning the reconnect budget.
483
+ raise
484
+ self._last_stream_error = exc
485
+ failures += 1
486
+ if self._close_started.is_set():
487
+ break
488
+ if self._close_started.is_set():
489
+ break
490
+ # A stream loss invalidates local completion attempts, but does
491
+ # not send terminal commands for leases that may still exist.
492
+ await self._cancel_assignments()
493
+ conn = self._client._require_conn()
494
+ before_token = conn._session_token
495
+ await conn._recover_best_effort()
496
+ if conn._session_token == before_token:
497
+ failures += 1
498
+ else:
499
+ failures = 0
500
+ if failures >= 3:
501
+ raise self._stream_error or UnavailableError(
502
+ "bounded Work stream recovery exhausted"
503
+ )
504
+ if not self._close_started.is_set():
505
+ await asyncio.sleep(0)
506
+ if self._watchdog_error is not None:
507
+ raise self._watchdog_error
508
+ finally:
509
+ try:
510
+ await self._cancel_assignments()
511
+ finally:
512
+ try:
513
+ if self._watchdog is not None:
514
+ await self._watchdog.stop()
515
+ finally:
516
+ if self._runtime is not None:
517
+ self._runtime.unregister_worker(self)
518
+ self._run_finished.set()
519
+
520
+ async def close(self) -> None:
521
+ """Drain and stop once; caller cancellation cannot cancel cleanup."""
522
+ async with self._close_lock:
523
+ if self._close_task is None:
524
+ self._close_started.set()
525
+ # The controller must linearize detach and withdraw its probe
526
+ # before this gate closes publication; the close path uses the
527
+ # still-live stream to await that correlated WorkResult.
528
+ self._idle_candidate = False
529
+ self._idle_zero_since = None
530
+ self._idle_session_token = None
531
+ self._close_task = asyncio.create_task(
532
+ self._close_impl(), name=f"ringo-close-{self._worker_id}"
533
+ )
534
+ self._close_task.add_done_callback(_consume_task_exception)
535
+ close_task = self._close_task
536
+ await asyncio.shield(close_task)
537
+
538
+ async def _close_impl(self) -> None:
539
+ try:
540
+ if self._controller is not None and self._member is not None:
541
+ await self._controller._detach(self._member)
542
+ self._publisher_closed = True
543
+ # Drain keeps this Work session alive for handler ACK/renewal.
544
+ # Close is not a session replacement: changing its epoch here
545
+ # would fence valid origin-bound terminal commands during grace.
546
+ session = self._session_done
547
+ # Drain is best effort. A disconnected stream cannot safely report
548
+ # any terminal result, and leases are deliberately left to expire.
549
+ if session is not None and not session.is_set():
550
+ try:
551
+ await asyncio.wait_for(
552
+ self._send_command(
553
+ queue_pb2.WorkRequest(
554
+ drain=queue_pb2.WorkerDrain(begin=True),
555
+ ),
556
+ expected_epoch=self._session_epoch,
557
+ expected_session=session,
558
+ ),
559
+ timeout=max(
560
+ 0.1,
561
+ min(self._command_timeout, self._grace_period or 1.0),
562
+ ),
563
+ )
564
+ except Exception:
565
+ pass
566
+ if self._grace_period:
567
+ try:
568
+ await asyncio.wait_for(
569
+ self._wait_handlers(), timeout=self._grace_period
570
+ )
571
+ except asyncio.TimeoutError:
572
+ pass
573
+ finally:
574
+ # From this point handlers and renewals are cancelled; no terminal
575
+ # frame is generated by their cancellation paths.
576
+ self._force_cancel.set()
577
+ try:
578
+ await self._cancel_assignments()
579
+ await self._stop_session()
580
+ self._client._unregister_worker(self)
581
+ if self._run_started:
582
+ await self._run_finished.wait()
583
+ finally:
584
+ self._close_finished.set()
585
+
586
+ async def _wait_handlers(self) -> None:
587
+ tasks = list(self._handler_tasks)
588
+ if tasks:
589
+ await asyncio.gather(*tasks, return_exceptions=True)
590
+ # Done callbacks release permits asynchronously. Drain those releases
591
+ # too, but never include buffered leases that have not entered a handler.
592
+ if self._capacity_tasks:
593
+ await asyncio.gather(
594
+ *(asyncio.shield(task) for task in list(self._capacity_tasks)),
595
+ return_exceptions=True,
596
+ )
597
+
598
+ async def _cancel_assignments(self) -> None:
599
+ # Local assignments were reset, so the idle observation restarts too.
600
+ self._idle_candidate = False
601
+ self._idle_zero_since = None
602
+ self._idle_session_token = None
603
+ assignments = list(self._inflight.values())
604
+ self._inflight.clear()
605
+ self._waiting_assignments.clear()
606
+ renewals: list[asyncio.Task[Any]] = []
607
+ for assignment in assignments:
608
+ self._invalidate_assignment(assignment)
609
+ handler = assignment.handler_task
610
+ if handler is not None and not handler.done():
611
+ # Cancellation is advisory. Never let a handler that catches
612
+ # CancelledError block reconnect or forced close; its local
613
+ # capacity remains occupied until its coroutine really ends.
614
+ handler.cancel()
615
+ renewal = assignment.renew_task
616
+ if renewal is not None and not renewal.done():
617
+ renewal.cancel()
618
+ renewals.append(renewal)
619
+ if renewals:
620
+ await asyncio.gather(*renewals, return_exceptions=True)
621
+ for assignment in assignments:
622
+ await self._release_capacity_if_settled(assignment)
623
+
624
+ def _invalidate_assignment(self, assignment: _Assignment) -> None:
625
+ if not assignment.alive:
626
+ return
627
+ assignment.alive = False
628
+ if assignment.context is not None:
629
+ assignment.context._cancel()
630
+
631
+ async def _release_controller_permit(
632
+ self,
633
+ assignment: _Assignment,
634
+ *,
635
+ success: bool = False,
636
+ congestion: bool = False,
637
+ ) -> None:
638
+ controller, member = self._controller, self._member
639
+ if controller is None or member is None or assignment.controller_released:
640
+ return
641
+ if assignment.controller_release_task is None:
642
+
643
+ async def release() -> None:
644
+ await controller._release_running(
645
+ member, success=success, congestion=congestion
646
+ )
647
+ # The shielded task owns completion, even if every waiter was
648
+ # cancelled while the pool's resize lock held the decrement.
649
+ assignment.controller_released = True
650
+
651
+ task = asyncio.create_task(release())
652
+ assignment.controller_release_task = task
653
+ self._capacity_tasks.add(task)
654
+ task.add_done_callback(self._capacity_tasks.discard)
655
+ task.add_done_callback(_consume_task_exception)
656
+ # Grace timeout/caller cancellation must not consume the once flag
657
+ # while the pool's resize lock still blocks the actual decrement.
658
+ await asyncio.shield(assignment.controller_release_task)
659
+
660
+ async def _release_capacity_if_settled(self, assignment: _Assignment) -> None:
661
+ """Release capacity only as the application handler settles."""
662
+ if assignment.alive:
663
+ return
664
+ handler = assignment.handler_task
665
+ if handler is not None and not handler.done():
666
+ return
667
+ if self._controller is not None:
668
+ await self._release_controller_permit(
669
+ assignment,
670
+ success=assignment.completion_success,
671
+ congestion=assignment.completion_congestion,
672
+ )
673
+ return
674
+ if assignment.fixed_released:
675
+ return
676
+ assignment.fixed_released = True
677
+ if assignment.started:
678
+ self._fixed_running = max(0, self._fixed_running - 1)
679
+ self._start_waiting_assignments()
680
+ if not self._close_started.is_set():
681
+ await self._publish_fixed_capacity()
682
+
683
+ async def _run_session_monitored(self) -> None:
684
+ """Run one Work session, observing queue activity when on_idle is set.
685
+
686
+ Without ``on_idle`` this is exactly ``_run_session()``. With it, the
687
+ poll loop is executed inline by the ``run()`` coroutine itself (never
688
+ in a child task) so a callback exception — including ``SystemExit`` —
689
+ unwinds verbatim through ``await worker.run()``.
690
+ """
691
+ if self._on_idle is None:
692
+ await self._run_session()
693
+ return
694
+ session_task = asyncio.create_task(
695
+ self._run_session(), name="ringo-worker-session"
696
+ )
697
+ try:
698
+ while True:
699
+ done, _pending = await asyncio.wait(
700
+ (session_task,), timeout=_IDLE_POLL_SECONDS
701
+ )
702
+ if done:
703
+ # Propagate the session outcome (result or stream error).
704
+ return await session_task
705
+ if await self._idle_poll_once():
706
+ # Single-shot fired and returned normally: park on the
707
+ # session instead of waking every poll interval.
708
+ return await session_task
709
+ finally:
710
+ if not session_task.done():
711
+ session_task.cancel()
712
+ await asyncio.gather(session_task, return_exceptions=True)
713
+
714
+ async def _idle_poll_once(self) -> bool:
715
+ """One read-only, retry-safe activity observation tick.
716
+
717
+ Returns True exactly when the callback fired and returned normally.
718
+ """
719
+ callback = self._on_idle
720
+ if (
721
+ callback is None
722
+ or self._idle_fired
723
+ or not self._idle_candidate
724
+ or self._close_started.is_set()
725
+ or self._force_cancel.is_set()
726
+ ):
727
+ return False
728
+ # Local assignments make the queue non-idle for this worker.
729
+ if self._inflight or self._handler_tasks:
730
+ self._idle_zero_since = None
731
+ return False
732
+ session = self._session_done
733
+ if session is None or session.is_set():
734
+ return False
735
+ # The observation is bound to the token captured at registration: a
736
+ # unary-triggered renegotiation rotates the token without restarting
737
+ # the Work stream. End this session so the run loop re-registers the
738
+ # stream under the fresh token instead of observing a stale session.
739
+ conn = self._client._require_conn()
740
+ if conn._session_token != self._idle_session_token:
741
+ self._end_stale_idle_session(session)
742
+ return False
743
+ request = queue_pb2.GetQueueActivityRequest(
744
+ queue=self._queue.name, task_types=self._task_types
745
+ )
746
+ try:
747
+ response = await conn.call("GetQueueActivity", request, retry_safe=True)
748
+ except asyncio.CancelledError:
749
+ raise
750
+ except NotFoundError:
751
+ # A never-enqueued queue has no durable rows. This is a conclusive
752
+ # zero for idle observation only; public queries keep NOT_FOUND.
753
+ response = queue_pb2.GetQueueActivityResponse(active_task_count=0)
754
+ except Exception:
755
+ # A failed observation restarts the quiet window; a later poll
756
+ # may succeed again without tearing the session down.
757
+ self._idle_zero_since = None
758
+ return False
759
+ # The session or its token may have changed while the query was in
760
+ # flight; a stale response must not advance or complete the window.
761
+ if (
762
+ not self._idle_candidate
763
+ or self._session_done is not session
764
+ or session.is_set()
765
+ ):
766
+ self._idle_zero_since = None
767
+ return False
768
+ if self._client._require_conn()._session_token != self._idle_session_token:
769
+ self._end_stale_idle_session(session)
770
+ return False
771
+ if response.active_task_count > 0:
772
+ self._idle_zero_since = None
773
+ return False
774
+ now = asyncio.get_running_loop().time()
775
+ if self._idle_zero_since is None:
776
+ self._idle_zero_since = now
777
+ return False
778
+ if now - self._idle_zero_since < _IDLE_QUIET_SECONDS:
779
+ return False
780
+ # Final atomic gate: no await separates these checks from marking
781
+ # fired and invoking the callback, so an assignment landing in a
782
+ # query in-flight window cannot produce a false trigger.
783
+ if (
784
+ self._inflight
785
+ or self._handler_tasks
786
+ or self._close_started.is_set()
787
+ or self._force_cancel.is_set()
788
+ ):
789
+ self._idle_zero_since = None
790
+ return False
791
+ # Single-shot: mark before invoking so the callback can never
792
+ # re-enter the monitor, and so a normal return never fires twice.
793
+ self._idle_fired = True
794
+ try:
795
+ result = callback()
796
+ if inspect.isawaitable(result):
797
+ await result
798
+ except BaseException as exc:
799
+ # Tag callback failures so run() propagates them verbatim
800
+ # instead of feeding them to the reconnect loop.
801
+ self._idle_error = exc
802
+ raise
803
+ return True
804
+
805
+ def _end_stale_idle_session(self, session: asyncio.Event) -> None:
806
+ """End the current Work session after a token rotation.
807
+
808
+ Setting ``session_done`` is idempotent and is observed as a clean
809
+ stream end: ``_run_session`` returns without a stream error, the run
810
+ loop reconnects, and the next session re-registers (and re-arms the
811
+ idle candidate) under the fresh token. It is neither a callback
812
+ failure nor a reconnect-worthy stream error.
813
+ """
814
+ source = self._source
815
+ if source is None or source.done is not session:
816
+ return
817
+ self._idle_candidate = False
818
+ self._idle_zero_since = None
819
+ self._end_source(source)
820
+
821
+ def _end_source(
822
+ self, source: _WorkSource, error: BaseException | None = None
823
+ ) -> None:
824
+ """Synchronously end exactly one source before any cleanup wait.
825
+
826
+ Failing pending Futures here replaces per-command session-wait tasks.
827
+ A graceful Worker.close merely drains; it calls this only when the
828
+ transport actually ends or the grace period has expired.
829
+ """
830
+ if error is not None and source.error is None:
831
+ source.error = error
832
+ if self._source is source:
833
+ self._last_stream_error = error
834
+ source.done.set()
835
+ failure = source.error or UnavailableError("Work stream closed")
836
+ for future in source.pending.values():
837
+ if not future.done():
838
+ future.set_exception(failure)
839
+ source.pending.clear()
840
+ try:
841
+ source.call.cancel()
842
+ except Exception:
843
+ pass
844
+
845
+ async def _run_session(self) -> None:
846
+ conn = self._client._require_conn()
847
+ self._session_epoch += 1
848
+ # A new session invalidates any in-flight idle observation.
849
+ self._idle_candidate = False
850
+ self._idle_zero_since = None
851
+ self._idle_session_token = None
852
+ if self._on_idle is not None and conn._session_minor < _IDLE_MIN_PROTOCOL_MINOR:
853
+ # A reconnect may have renegotiated a downgrade to minor 0.
854
+ # Fail fast before opening the Work stream; run() surfaces this
855
+ # verbatim instead of counting it as a reconnect failure.
856
+ raise IncompatibleVersionError(
857
+ "on_idle requires a server speaking protocol minor"
858
+ f" >= {_IDLE_MIN_PROTOCOL_MINOR}; negotiated minor"
859
+ f" is {conn._session_minor}"
860
+ )
861
+ reg = queue_pb2.WorkerRegistration(
862
+ queue=self._queue.name,
863
+ worker_id=self._worker_id,
864
+ task_types=self._task_types,
865
+ max_concurrency=max(1, self._concurrency),
866
+ )
867
+ if self._lease_duration > timedelta(0):
868
+ reg.lease_duration.CopyFrom(_pb.td_to_dur(self._lease_duration))
869
+ async with conn._lock:
870
+ stub = conn._require_stub()
871
+ reg.context.CopyFrom(conn.request_context())
872
+ # Mode belongs to this registration snapshot, not to a later unary
873
+ # negotiation that may rotate the connection's token and minor.
874
+ minor = reg.context.protocol_minor
875
+ if (
876
+ self._controller is not None or self._fixed_dynamic_target
877
+ ) and minor < 2:
878
+ raise IncompatibleVersionError(
879
+ "dynamic concurrency requires protocol minor >= 2; "
880
+ f"negotiated minor is {minor}"
881
+ )
882
+ if self._on_idle is not None and minor < _IDLE_MIN_PROTOCOL_MINOR:
883
+ raise IncompatibleVersionError(
884
+ "on_idle requires a server speaking protocol minor"
885
+ f" >= {_IDLE_MIN_PROTOCOL_MINOR}; negotiated minor is {minor}"
886
+ )
887
+ ordered = minor >= _ORDERED_REQUEST_IDS_MIN_PROTOCOL_MINOR
888
+ if ordered:
889
+ reg.ordered_request_ids = True
890
+ call = stub.Work(metadata=conn._metadata())
891
+ source = _WorkSource(
892
+ self._session_epoch,
893
+ call,
894
+ minor=minor,
895
+ token=reg.context.session_token,
896
+ ordered_request_ids=ordered,
897
+ )
898
+ self._source = source
899
+ self._last_stream_error = None
900
+ source.writer = asyncio.create_task(
901
+ self._write_loop(source), name="ringo-worker-writer"
902
+ )
903
+ source.reader = asyncio.create_task(
904
+ self._read_loop(source), name="ringo-worker-reader"
905
+ )
906
+ try:
907
+ self._publish_frame(source, queue_pb2.WorkRequest(register=reg))
908
+ await self._wait_event_or_session(source.registered, source)
909
+ if self._force_cancel.is_set():
910
+ return
911
+ # Initial credit is sent only after WorkerRegistered was observed.
912
+ if self._controller is None:
913
+ await self._publish_fixed_capacity()
914
+ else:
915
+ self._controller._check_loop()
916
+ await self._controller._start_member(self._member)
917
+ if self._on_idle is not None and not self._close_started.is_set():
918
+ # Eligible only from the first/current WorkerRegistered plus
919
+ # a successful initial credit of this session. The candidate
920
+ # is bound to the token this stream was registered with: a
921
+ # later unary renegotiation that rotates the token disarms
922
+ # the monitor until the next session.
923
+ self._idle_session_token = source.token
924
+ self._idle_candidate = True
925
+ self._idle_zero_since = None
926
+ await source.done.wait()
927
+ if source.error is not None and not self._close_started.is_set():
928
+ raise source.error
929
+ finally:
930
+ await self._stop_session(source)
931
+ if self._source is source:
932
+ self._source = None
933
+
934
+ async def _wait_event_or_session(
935
+ self, event: asyncio.Event, source: _WorkSource
936
+ ) -> None:
937
+ # Registration is a cold-path Event race. Commands await only their
938
+ # private Future; every source-end path fails those Futures directly.
939
+ event_task = asyncio.create_task(event.wait())
940
+ done_task = asyncio.create_task(source.done.wait())
941
+ try:
942
+ done, _ = await asyncio.wait(
943
+ (event_task, done_task), return_when=asyncio.FIRST_COMPLETED
944
+ )
945
+ if done_task in done and not event.is_set():
946
+ raise source.error or UnavailableError("Work stream closed")
947
+ finally:
948
+ for task in (event_task, done_task):
949
+ if not task.done():
950
+ task.cancel()
951
+ await asyncio.gather(event_task, done_task, return_exceptions=True)
952
+
953
+ async def _stop_session(self, source: _WorkSource | None = None) -> None:
954
+ source = self._source if source is None else source
955
+ if source is None:
956
+ return
957
+ # Fail command Futures before any lock/join can block teardown. One
958
+ # shielded owner survives repeated caller cancellation and concurrent
959
+ # close/run cleanup, including cancellation before its first step.
960
+ self._end_source(source)
961
+ if source.cleanup is None:
962
+ task = asyncio.create_task(
963
+ self._stop_source(source),
964
+ name=f"ringo-stop-{self._worker_id}-{source.epoch}",
965
+ )
966
+ source.cleanup = task
967
+ self._capacity_tasks.add(task)
968
+ task.add_done_callback(self._capacity_tasks.discard)
969
+ task.add_done_callback(_consume_task_exception)
970
+ await asyncio.shield(source.cleanup)
971
+
972
+ async def _stop_source(self, source: _WorkSource) -> None:
973
+ source.outbox.put_nowait(None)
974
+ tasks = [
975
+ task
976
+ for task in (source.reader, source.writer, source.publisher)
977
+ if task is not None and task is not asyncio.current_task()
978
+ ]
979
+ for task in tasks:
980
+ task.cancel()
981
+ controller, member = self._controller, self._member
982
+ if controller is not None and member is not None and self._source is source:
983
+ # The run loop joins this cleanup before admitting its replacement.
984
+ # Only unused old-stream credit is reclaimed; running handlers keep
985
+ # their permits until their real coroutine settles.
986
+ await controller._stream_lost(member)
987
+ if tasks:
988
+ await asyncio.gather(*tasks, return_exceptions=True)
989
+
990
+ def _publish_frame(self, source: _WorkSource, frame: queue_pb2.WorkRequest) -> None:
991
+ """Synchronous FIFO insertion; callers own validation and correlation."""
992
+ source.outbox.put_nowait(frame)
993
+
994
+ async def _write_loop(self, source: _WorkSource) -> None:
995
+ try:
996
+ while not source.done.is_set():
997
+ frame = await source.outbox.get()
998
+ if frame is None or source.done.is_set():
999
+ try:
1000
+ done_writing = getattr(source.call, "done_writing", None)
1001
+ if done_writing is not None:
1002
+ await done_writing()
1003
+ except Exception:
1004
+ pass
1005
+ return
1006
+ await source.call.write(frame)
1007
+ except asyncio.CancelledError:
1008
+ raise
1009
+ except Exception as exc:
1010
+ self._end_source(source, self._map_transport(exc))
1011
+ finally:
1012
+ self._end_source(source)
1013
+
1014
+ async def _read_loop(self, source: _WorkSource) -> None:
1015
+ try:
1016
+ async for response in source.call:
1017
+ if source.done.is_set():
1018
+ return
1019
+ body = response.WhichOneof("body")
1020
+ if body == "registered":
1021
+ if self._source is not source:
1022
+ continue
1023
+ registered = response.registered
1024
+ if registered.ordered_request_ids != source.ordered_request_ids:
1025
+ raise IncompatibleVersionError(
1026
+ "Work registration did not confirm the requested ID mode"
1027
+ )
1028
+ if registered.HasField("lease_duration"):
1029
+ self._lease_duration = (
1030
+ _pb.dur_to_td(registered.lease_duration)
1031
+ or self._lease_duration
1032
+ )
1033
+ if (
1034
+ self._controller is None
1035
+ and not self._fixed_dynamic_target
1036
+ and registered.max_concurrency
1037
+ ):
1038
+ self._concurrency = registered.max_concurrency
1039
+ source.registered.set()
1040
+ elif body == "task":
1041
+ await self._on_assignment(response.task, source=source)
1042
+ elif body == "result":
1043
+ result = response.result
1044
+ future = source.pending.pop(result.request_id, None)
1045
+ if future is not None and not future.done():
1046
+ future.set_result(result)
1047
+ # Unknown/late results and heartbeats keep their existing
1048
+ # non-fatal behavior; they never correlate into another source.
1049
+ except asyncio.CancelledError:
1050
+ raise
1051
+ except Exception as exc:
1052
+ self._end_source(source, self._map_transport(exc))
1053
+ finally:
1054
+ self._end_source(source)
1055
+
1056
+ @staticmethod
1057
+ def _map_transport(exc: BaseException) -> BaseException:
1058
+ if isinstance(exc, RingoError):
1059
+ return exc
1060
+ if isinstance(exc, grpc.aio.AioRpcError):
1061
+ return map_rpc_error(exc)
1062
+ return UnavailableError(f"Work stream failed: {_safe_error(exc)}")
1063
+
1064
+ async def _on_assignment(
1065
+ self, leased: queue_pb2.LeasedTask, *, source: _WorkSource | None = None
1066
+ ) -> None:
1067
+ source = self._source if source is None else source
1068
+ if (
1069
+ source is None
1070
+ or self._source is not source
1071
+ or source.done.is_set()
1072
+ or self._force_cancel.is_set()
1073
+ or not leased.HasField("task")
1074
+ or not leased.HasField("lease")
1075
+ ):
1076
+ return
1077
+ task = Task.from_proto(leased.task)
1078
+ until = _pb.ts_to_dt(leased.lease.until)
1079
+ assignment = _Assignment(
1080
+ task=task,
1081
+ attempt=leased.lease.attempt,
1082
+ token=leased.lease.lease_token,
1083
+ until=until,
1084
+ renew_deadline=asyncio.get_running_loop().time()
1085
+ + (until - datetime.now(timezone.utc)).total_seconds(),
1086
+ )
1087
+ context = TaskContext(
1088
+ self,
1089
+ task,
1090
+ assignment.attempt,
1091
+ task.max_attempts,
1092
+ self._worker_id,
1093
+ assignment,
1094
+ )
1095
+ assignment.context = context
1096
+ # The predecessor may still be waiting for its successful NACK result
1097
+ # when a successor arrives. Do not confuse that valid new lease with
1098
+ # a duplicate, and never overwrite either lease's cleanup ownership.
1099
+ if assignment.identity in self._inflight:
1100
+ return
1101
+ assignment.session_epoch = source.epoch
1102
+ assignment.session = source.done
1103
+ if self._controller is not None and self._member is not None:
1104
+ # Consuming the reserved probe is the linearization point at which
1105
+ # an assignment becomes a running controller permit.
1106
+ if not await self._controller._probe_consumed(self._member):
1107
+ # A withdrawn probe must not become a new handler. The lease
1108
+ # remains server-owned and will naturally expire if this was a
1109
+ # transport race.
1110
+ return
1111
+ if self._source is not source or source.done.is_set():
1112
+ assignment.alive = False
1113
+ context._cancel()
1114
+ await self._release_controller_permit(assignment)
1115
+ return
1116
+ # A complete short task can arrive and ACK between two activity polls.
1117
+ # Acceptance itself restarts quiet time; old/duplicate/rejected frames
1118
+ # returned above must never reset the current source's observation.
1119
+ self._idle_zero_since = None
1120
+ self._inflight[assignment.identity] = assignment
1121
+ assignment.renew_task = asyncio.create_task(
1122
+ self._renew_loop(assignment),
1123
+ name=f"ringo-renew-{task.id}-{assignment.attempt}",
1124
+ )
1125
+ if self._controller is None:
1126
+ # Absolute credit updates can race assignments already in transit.
1127
+ # Retain and renew such leases, but gate actual handler entry locally.
1128
+ self._waiting_assignments.append(assignment)
1129
+ self._fixed_capacity_generation += 1
1130
+ self._start_waiting_assignments()
1131
+ else:
1132
+ self._start_handler(assignment)
1133
+
1134
+ def _start_waiting_assignments(self) -> None:
1135
+ if (
1136
+ self._controller is not None
1137
+ or self._close_started.is_set()
1138
+ or self._force_cancel.is_set()
1139
+ or self._session_done is None
1140
+ or self._session_done.is_set()
1141
+ ):
1142
+ return
1143
+ while self._waiting_assignments and self._fixed_running < self._concurrency:
1144
+ assignment = self._waiting_assignments.popleft()
1145
+ if not assignment.alive or assignment.session_epoch != self._session_epoch:
1146
+ continue
1147
+ expired = (
1148
+ assignment.renew_deadline <= asyncio.get_running_loop().time()
1149
+ if assignment.renew_deadline is not None
1150
+ else assignment.until <= datetime.now(timezone.utc)
1151
+ )
1152
+ if expired:
1153
+ self._fail_session(
1154
+ UnavailableError("queued lease reached its local deadline"),
1155
+ epoch=assignment.session_epoch,
1156
+ session=assignment.session,
1157
+ )
1158
+ return
1159
+ self._start_handler(assignment)
1160
+
1161
+ def _start_handler(self, assignment: _Assignment) -> None:
1162
+ assignment.started = True
1163
+ if self._controller is None:
1164
+ self._fixed_running += 1
1165
+ task = asyncio.create_task(
1166
+ self._handle_assignment(assignment),
1167
+ name=f"ringo-handler-{assignment.task.id}-{assignment.attempt}",
1168
+ )
1169
+ assignment.handler_task = task
1170
+ self._handler_tasks.add(task)
1171
+ task.add_done_callback(
1172
+ lambda completed: self._handler_done(assignment, completed)
1173
+ )
1174
+
1175
+ def _handler_done(self, assignment: _Assignment, task: asyncio.Task[Any]) -> None:
1176
+ _consume_task_exception(task)
1177
+ self._handler_tasks.discard(task)
1178
+ if task.cancelled() and assignment.alive:
1179
+ # Task.cancel before its first step never enters the coroutine's
1180
+ # cancellation handler. Stop that handlerless lease synchronously;
1181
+ # the existing release owner still accounts for actual Task.done.
1182
+ self._fail_session(
1183
+ UnavailableError("task handler cancelled before settlement"),
1184
+ epoch=assignment.session_epoch,
1185
+ session=assignment.session,
1186
+ )
1187
+ self._invalidate_assignment(assignment)
1188
+ if self._inflight.get(assignment.identity) is assignment:
1189
+ self._inflight.pop(assignment.identity)
1190
+ renewal = assignment.renew_task
1191
+ if renewal is not None and not renewal.done():
1192
+ renewal.cancel()
1193
+ self._schedule_capacity_release(assignment)
1194
+
1195
+ def _schedule_capacity_release(self, assignment: _Assignment) -> None:
1196
+ task = asyncio.create_task(self._release_finished_capacity(assignment))
1197
+ self._capacity_tasks.add(task)
1198
+ task.add_done_callback(self._capacity_tasks.discard)
1199
+ task.add_done_callback(_consume_task_exception)
1200
+
1201
+ async def _release_finished_capacity(self, assignment: _Assignment) -> None:
1202
+ epoch, session = self._session_epoch, self._session_done
1203
+ try:
1204
+ renewal = assignment.renew_task
1205
+ if not assignment.alive and renewal is not None and not renewal.done():
1206
+ # A done callback can cancel renewal before the handler ever
1207
+ # ran. Join that SDK task without releasing another assignment.
1208
+ await asyncio.gather(renewal, return_exceptions=True)
1209
+ await self._release_capacity_if_settled(assignment)
1210
+ except asyncio.CancelledError:
1211
+ raise
1212
+ except Exception as exc:
1213
+ # A delayed release from an old session must not tear down its
1214
+ # replacement. Publishers also fence their actual command epoch.
1215
+ self._fail_session(exc, epoch=epoch, session=session)
1216
+
1217
+ def _fail_session(
1218
+ self,
1219
+ error: BaseException,
1220
+ *,
1221
+ epoch: int | None = None,
1222
+ session: asyncio.Event | None = None,
1223
+ ) -> None:
1224
+ source = self._source
1225
+ if source is None:
1226
+ return
1227
+ if epoch is not None and (epoch != source.epoch or session is not source.done):
1228
+ return
1229
+ self._end_source(source, error)
1230
+
1231
+ async def _handle_assignment(self, assignment: _Assignment) -> None:
1232
+ try:
1233
+ context = assignment.context
1234
+ assert context is not None
1235
+ if not assignment.alive:
1236
+ return
1237
+ if self._force_cancel.is_set() or (
1238
+ assignment.session is not None and assignment.session.is_set()
1239
+ ):
1240
+ # Reader bursts can schedule a handler and observe EOF before
1241
+ # its first step. The immutable origin Event, not a replacement
1242
+ # source or close_started, gates entry into application code.
1243
+ await self._release_assignment(assignment, cancel_context=True)
1244
+ return
1245
+ # One disabled-path check; no monitor task, clock read or RPC for
1246
+ # ordinary workers. The enabled helper enters immediately before
1247
+ # the real handler call and exits before any terminal receipt.
1248
+ watchdog = self._watchdog
1249
+ if watchdog is None:
1250
+ result = self._handler(assignment.task, context)
1251
+ if inspect.isawaitable(result):
1252
+ await result
1253
+ else:
1254
+ await watchdog.invoke(assignment, context)
1255
+ if assignment.alive and not self._force_cancel.is_set():
1256
+ await self._finish(assignment, "ack")
1257
+ except _WatchdogFenced:
1258
+ # A deadline racing graceful close abandons only its own stopped
1259
+ # invocation; other handlers keep their live drain/ACK/renewal lane.
1260
+ await self._release_assignment(assignment, cancel_context=True)
1261
+ except asyncio.CancelledError:
1262
+ # Cancellation is not a NACK, but an application-cancelled handler
1263
+ # must not retain a live, perpetually renewed lease and capacity.
1264
+ if assignment.alive:
1265
+ self._fail_session(
1266
+ UnavailableError("task handler cancelled before settlement"),
1267
+ epoch=assignment.session_epoch,
1268
+ session=assignment.session,
1269
+ )
1270
+ await self._release_assignment(assignment, cancel_context=True)
1271
+ raise
1272
+ except RetryTaskError as exc:
1273
+ if assignment.alive and not self._force_cancel.is_set():
1274
+ await self._finish(assignment, "nack", exc)
1275
+ except PermanentTaskError as exc:
1276
+ if assignment.alive and not self._force_cancel.is_set():
1277
+ await self._finish(assignment, "reject", exc)
1278
+ except Exception as exc:
1279
+ if assignment.alive and not self._force_cancel.is_set():
1280
+ await self._finish(assignment, "nack", exc)
1281
+ # Capacity belongs to the complete handler task, including receipt
1282
+ # consumption; its done callback releases the slot exactly once.
1283
+
1284
+ def _watchdog_unresponsive(self, assignment: _Assignment, reason: str) -> None:
1285
+ """Fence before notifying: never NACK while old business still runs."""
1286
+ self._invalidate_assignment(assignment)
1287
+ renewal = assignment.renew_task
1288
+ if renewal is not None:
1289
+ renewal.cancel()
1290
+ # Do not recycle local capacity; the real handler's done callback owns
1291
+ # it even if run() returns while the application swallows cancellation.
1292
+ self._close_started.set()
1293
+ self._publisher_closed = True
1294
+ source = self._source
1295
+ if source is not None:
1296
+ # This is an intentional Worker-wide hard fence, not a stale RPC
1297
+ # result: even a replacement source must stop admitting new work.
1298
+ self._end_source(
1299
+ source, UnavailableError("unresponsive timed-out handler; Worker fenced")
1300
+ )
1301
+ detail = {
1302
+ "worker_id": self._worker_id,
1303
+ "queue": self._queue.name,
1304
+ "task_id": assignment.task.id,
1305
+ "attempt": assignment.attempt,
1306
+ "reason": reason,
1307
+ }
1308
+ if self._runtime is not None:
1309
+ self._runtime.request_restart("task cancellation grace exceeded", detail)
1310
+ else:
1311
+ self._watchdog_error = RuntimeError(
1312
+ "task cancellation grace exceeded: Worker stopped accepting tasks; "
1313
+ "a handler is still running; use process supervision for hard isolation"
1314
+ )
1315
+
1316
+ async def _finish(
1317
+ self,
1318
+ assignment: _Assignment,
1319
+ operation: str,
1320
+ error: BaseException | None = None,
1321
+ ) -> None:
1322
+ if not assignment.alive or assignment.completion_started:
1323
+ return
1324
+ assignment.completion_started = True
1325
+ request: queue_pb2.WorkRequest
1326
+ text = _safe_error(error) if error is not None else ""
1327
+ if operation == "ack":
1328
+ request = queue_pb2.WorkRequest(
1329
+ ack=queue_pb2.AckRequest(lease=assignment.ref)
1330
+ )
1331
+ elif operation == "nack":
1332
+ nack = queue_pb2.NackRequest(lease=assignment.ref, error=text)
1333
+ if isinstance(error, RetryTaskError) and error.delay is not None:
1334
+ if (
1335
+ not isinstance(error.delay, (int, float))
1336
+ or not math.isfinite(error.delay)
1337
+ or error.delay < 0
1338
+ ):
1339
+ # Treat an invalid override as an ordinary failure rather
1340
+ # than allowing a negative Duration onto the wire.
1341
+ nack.error = _safe_error(error)
1342
+ else:
1343
+ try:
1344
+ nack.retry_delay_override.CopyFrom(
1345
+ _pb.td_to_dur(timedelta(seconds=error.delay))
1346
+ )
1347
+ except Exception:
1348
+ # An unrepresentable override is treated as a normal
1349
+ # NACK; never strand the assignment on serialization.
1350
+ nack.error = _safe_error(error)
1351
+ request = queue_pb2.WorkRequest(nack=nack)
1352
+ else:
1353
+ request = queue_pb2.WorkRequest(
1354
+ reject=queue_pb2.RejectRequest(lease=assignment.ref, error=text)
1355
+ )
1356
+ try:
1357
+ # Task.cancel() can be pending after a handler returns/throws without
1358
+ # suspending. Queue.put() on the unbounded outbox is a fast path, so
1359
+ # deliver cancellation before any terminal frame reaches the writer.
1360
+ # Keep this inside our cancellation cleanup, including NACK/reject
1361
+ # callers in handler except blocks. A consumed cancellation remains
1362
+ # a valid normal result; its Task.cancelling() count is not a gate.
1363
+ await asyncio.sleep(0)
1364
+ if assignment.watchdog_expired and (
1365
+ not assignment.alive
1366
+ or self._close_started.is_set()
1367
+ or self._force_cancel.is_set()
1368
+ ):
1369
+ await self._release_assignment(assignment, cancel_context=True)
1370
+ return
1371
+ result = await self._send_command(
1372
+ request,
1373
+ expected_epoch=assignment.session_epoch,
1374
+ expected_session=assignment.session,
1375
+ )
1376
+ except asyncio.CancelledError:
1377
+ # This await also runs inside the handler's except blocks (NACK
1378
+ # and reject), where a sibling cancellation except cannot catch it.
1379
+ if assignment.alive:
1380
+ self._fail_session(
1381
+ UnavailableError("task settlement cancelled before receipt"),
1382
+ epoch=assignment.session_epoch,
1383
+ session=assignment.session,
1384
+ )
1385
+ await self._release_assignment(assignment, cancel_context=True)
1386
+ raise
1387
+ except Exception as exc:
1388
+ # The outcome may have committed even without a receipt. Never
1389
+ # replay it or keep renewing a lease with no live handler forever.
1390
+ self._fail_session(
1391
+ exc, epoch=assignment.session_epoch, session=assignment.session
1392
+ )
1393
+ await self._release_assignment(assignment, cancel_context=True)
1394
+ return
1395
+ detail = result.error if result.HasField("error") else None
1396
+ if detail is not None and detail.code == queue_pb2.ERROR_CODE_LEASE_LOST:
1397
+ await self._lease_lost(assignment)
1398
+ return
1399
+ if detail is not None and detail.code != queue_pb2.ERROR_CODE_UNSPECIFIED:
1400
+ self._fail_session(
1401
+ error_from_detail(detail),
1402
+ epoch=assignment.session_epoch,
1403
+ session=assignment.session,
1404
+ )
1405
+ await self._release_assignment(assignment, cancel_context=True)
1406
+ return
1407
+ congestion = isinstance(error, RetryTaskError) and error.congestion
1408
+ await self._release_assignment(
1409
+ assignment,
1410
+ success=operation == "ack",
1411
+ congestion=congestion,
1412
+ )
1413
+
1414
+ async def _release_assignment(
1415
+ self,
1416
+ assignment: _Assignment,
1417
+ *,
1418
+ cancel_context: bool = False,
1419
+ success: bool = False,
1420
+ congestion: bool = False,
1421
+ ) -> None:
1422
+ if not assignment.alive:
1423
+ return
1424
+ assignment.alive = False
1425
+ assignment.completion_success = success
1426
+ assignment.completion_congestion = congestion
1427
+ if self._inflight.get(assignment.identity) is assignment:
1428
+ self._inflight.pop(assignment.identity)
1429
+ if not assignment.started:
1430
+ self._waiting_assignments = deque(
1431
+ pending
1432
+ for pending in self._waiting_assignments
1433
+ if pending is not assignment
1434
+ )
1435
+ if cancel_context and assignment.context is not None:
1436
+ assignment.context._cancel()
1437
+ if (
1438
+ assignment.renew_task is not None
1439
+ and assignment.renew_task is not asyncio.current_task()
1440
+ ):
1441
+ assignment.renew_task.cancel()
1442
+ await asyncio.gather(assignment.renew_task, return_exceptions=True)
1443
+ await self._release_capacity_if_settled(assignment)
1444
+
1445
+ async def _lease_lost(self, assignment: _Assignment) -> None:
1446
+ if not assignment.alive:
1447
+ return
1448
+ await self._release_assignment(assignment, cancel_context=True)
1449
+ handler = assignment.handler_task
1450
+ if (
1451
+ handler is not None
1452
+ and handler is not asyncio.current_task()
1453
+ and not handler.done()
1454
+ ):
1455
+ # Do not await an application coroutine that may intentionally
1456
+ # swallow cancellation. Its capacity is released by its finally.
1457
+ handler.cancel()
1458
+ await self._release_capacity_if_settled(assignment)
1459
+
1460
+ async def _renew_loop(self, assignment: _Assignment) -> None:
1461
+ loop = asyncio.get_running_loop()
1462
+ if assignment.renew_deadline is None:
1463
+ assignment.renew_deadline = (
1464
+ loop.time()
1465
+ + (assignment.until - datetime.now(timezone.utc)).total_seconds()
1466
+ )
1467
+ while assignment.alive and not self._force_cancel.is_set():
1468
+ remaining = assignment.renew_deadline - loop.time()
1469
+ delay = max(0.01, remaining - self._lease_duration.total_seconds() / 3)
1470
+ try:
1471
+ await asyncio.wait_for(self._force_cancel.wait(), timeout=delay)
1472
+ return
1473
+ except asyncio.TimeoutError:
1474
+ pass
1475
+ if not assignment.alive or self._force_cancel.is_set():
1476
+ return
1477
+ request = queue_pb2.WorkRequest(
1478
+ extend_lease=queue_pb2.ExtendLeaseRequest(
1479
+ lease=assignment.ref,
1480
+ extension=_pb.td_to_dur(self._lease_duration),
1481
+ )
1482
+ )
1483
+ sent_at = loop.time()
1484
+ sent_wall = datetime.now(timezone.utc)
1485
+ try:
1486
+ result = await self._send_command(
1487
+ request,
1488
+ expected_epoch=assignment.session_epoch,
1489
+ expected_session=assignment.session,
1490
+ )
1491
+ except asyncio.CancelledError:
1492
+ raise
1493
+ except Exception as exc:
1494
+ self._fail_session(
1495
+ exc, epoch=assignment.session_epoch, session=assignment.session
1496
+ )
1497
+ await self._lease_lost(assignment)
1498
+ return
1499
+ detail = result.error if result.HasField("error") else None
1500
+ if detail is not None and detail.code == queue_pb2.ERROR_CODE_LEASE_LOST:
1501
+ await self._lease_lost(assignment)
1502
+ return
1503
+ if detail is not None and detail.code != queue_pb2.ERROR_CODE_UNSPECIFIED:
1504
+ self._fail_session(
1505
+ error_from_detail(detail),
1506
+ epoch=assignment.session_epoch,
1507
+ session=assignment.session,
1508
+ )
1509
+ await self._lease_lost(assignment)
1510
+ return
1511
+ if (
1512
+ not assignment.alive
1513
+ or self._force_cancel.is_set()
1514
+ or assignment.session_epoch != self._session_epoch
1515
+ or assignment.session is not self._session_done
1516
+ or assignment.session is None
1517
+ or assignment.session.is_set()
1518
+ ):
1519
+ return
1520
+ # WorkResult has no renewed deadline. Server processing cannot
1521
+ # precede publication, so send-time + extension is a conservative
1522
+ # lower bound, including queued writes/backpressure. Receipt time
1523
+ # must not grant an extra lease. Monotonic scheduling survives wall
1524
+ # clock changes; keep the private wall-clock field conservative too.
1525
+ assignment.renew_deadline = sent_at + self._lease_duration.total_seconds()
1526
+ assignment.until = sent_wall + self._lease_duration
1527
+
1528
+ async def _report_progress(
1529
+ self, assignment: _Assignment, current: float, total: float, message: str
1530
+ ) -> None:
1531
+ if not assignment.alive:
1532
+ raise LeaseLostError("task lease is no longer valid")
1533
+ request = queue_pb2.WorkRequest(
1534
+ progress=queue_pb2.ReportProgressRequest(
1535
+ lease=assignment.ref,
1536
+ current=current,
1537
+ total=total,
1538
+ message=_safe_error(message),
1539
+ )
1540
+ )
1541
+ result = await self._send_command(
1542
+ request,
1543
+ expected_epoch=assignment.session_epoch,
1544
+ expected_session=assignment.session,
1545
+ )
1546
+ detail = result.error if result.HasField("error") else None
1547
+ if detail is not None and detail.code == queue_pb2.ERROR_CODE_LEASE_LOST:
1548
+ await self._lease_lost(assignment)
1549
+ raise LeaseLostError(detail.message)
1550
+ if detail is not None and detail.code != queue_pb2.ERROR_CODE_UNSPECIFIED:
1551
+ raise error_from_detail(detail)
1552
+
1553
+ async def _publish_fixed_capacity(self) -> None:
1554
+ epoch, session = self._session_epoch, self._session_done
1555
+ if (
1556
+ self._publisher_closed
1557
+ or self._close_started.is_set()
1558
+ or session is None
1559
+ or session.is_set()
1560
+ ):
1561
+ return
1562
+ self._fixed_capacity_generation += 1
1563
+ generation = self._fixed_capacity_generation
1564
+ task = self._fixed_publication_task
1565
+ new_publisher = (
1566
+ task is None
1567
+ or task.done()
1568
+ or self._fixed_publication_source != (epoch, session)
1569
+ )
1570
+ if new_publisher:
1571
+ self._fixed_publication_waiters = []
1572
+ self._fixed_publication_source = (epoch, session)
1573
+ waiters = self._fixed_publication_waiters
1574
+ accepted: asyncio.Future[None] = asyncio.get_running_loop().create_future()
1575
+ accepted.add_done_callback(_consume_task_exception)
1576
+ waiters.append((generation, accepted))
1577
+ if new_publisher:
1578
+ task = asyncio.create_task(
1579
+ self._drain_fixed_capacity(epoch, session, waiters),
1580
+ name=f"ringo-credit-{self._worker_id}-{epoch}",
1581
+ )
1582
+ self._fixed_publication_task = task
1583
+ assert self._source is not None
1584
+ self._source.publisher = task
1585
+ self._capacity_tasks.add(task)
1586
+ task.add_done_callback(
1587
+ lambda completed: self._fixed_publication_done(completed, waiters)
1588
+ )
1589
+ # A resize waits for its generation's accepted snapshot, not for the
1590
+ # publisher to become idle: a continuously busy stream may never idle.
1591
+ # Caller/grace cancellation cannot abandon the shared publication.
1592
+ await asyncio.shield(accepted)
1593
+
1594
+ def _fixed_publication_done(
1595
+ self,
1596
+ task: asyncio.Task[None],
1597
+ waiters: list[tuple[int, asyncio.Future[None]]],
1598
+ ) -> None:
1599
+ self._capacity_tasks.discard(task)
1600
+ error = asyncio.CancelledError() if task.cancelled() else task.exception()
1601
+ # Task.cancel() before the coroutine's first step never enters its
1602
+ # finally. Complete this source's waiters even at that boundary.
1603
+ for _, future in waiters:
1604
+ if not future.done():
1605
+ if error is None:
1606
+ future.set_result(None)
1607
+ else:
1608
+ future.set_exception(error)
1609
+ waiters.clear()
1610
+
1611
+ async def _drain_fixed_capacity(
1612
+ self,
1613
+ epoch: int,
1614
+ session: asyncio.Event,
1615
+ waiters: list[tuple[int, asyncio.Future[None]]],
1616
+ ) -> None:
1617
+ error: BaseException | None = None
1618
+ try:
1619
+ async with self._publisher_lock:
1620
+ while True:
1621
+ for attempt in range(20):
1622
+ if (
1623
+ self._publisher_closed
1624
+ or self._close_started.is_set()
1625
+ or epoch != self._session_epoch
1626
+ or session is not self._session_done
1627
+ or session.is_set()
1628
+ ):
1629
+ return
1630
+ # Absolute credit is consumed by Assign frames. Coalesce
1631
+ # requests, never equal numeric values; an in-transit
1632
+ # assignment dirties the generation even if a completion
1633
+ # leaves occupancy unchanged. Retry re-samples all state.
1634
+ generation = self._fixed_capacity_generation
1635
+ available = max(
1636
+ 0,
1637
+ self._concurrency
1638
+ - self._fixed_running
1639
+ - len(self._waiting_assignments),
1640
+ )
1641
+ update = queue_pb2.CreditUpdate(available=available)
1642
+ if self._fixed_dynamic_target:
1643
+ update.target_concurrency = self._concurrency
1644
+ result = await self._send_command(
1645
+ queue_pb2.WorkRequest(credit=update),
1646
+ expected_epoch=epoch,
1647
+ expected_session=session,
1648
+ )
1649
+ if (
1650
+ not result.HasField("error")
1651
+ or result.error.code == queue_pb2.ERROR_CODE_UNSPECIFIED
1652
+ ):
1653
+ source = self._source
1654
+ if source is not None and source.done is session:
1655
+ source.accepted_credit = available
1656
+ source.credit_generation = generation
1657
+ break
1658
+ if (
1659
+ result.error.metadata.get("reason") != "credit_capacity"
1660
+ or attempt == 19
1661
+ ):
1662
+ raise error_from_detail(result.error)
1663
+ await asyncio.sleep(0.01)
1664
+ remaining = []
1665
+ for requested, future in waiters:
1666
+ if requested <= generation:
1667
+ if not future.done():
1668
+ future.set_result(None)
1669
+ else:
1670
+ remaining.append((requested, future))
1671
+ waiters[:] = remaining
1672
+ if generation == self._fixed_capacity_generation:
1673
+ return
1674
+ except BaseException as exc:
1675
+ error = exc
1676
+ if not isinstance(exc, asyncio.CancelledError):
1677
+ self._fail_session(exc, epoch=epoch, session=session)
1678
+ raise
1679
+ finally:
1680
+ # Session replacement/close releases only this publisher's waiters.
1681
+ # A late old failure never completes or cancels a replacement batch.
1682
+ for _, future in waiters:
1683
+ if not future.done():
1684
+ if error is None:
1685
+ future.set_result(None)
1686
+ else:
1687
+ future.set_exception(error)
1688
+ waiters.clear()
1689
+
1690
+ async def _publish_controller_initial(self, capacity: int) -> None:
1691
+ async with self._publisher_lock:
1692
+ if (
1693
+ self._publisher_closed
1694
+ or self._close_started.is_set()
1695
+ or self._session_done is None
1696
+ or self._session_done.is_set()
1697
+ ):
1698
+ raise UnavailableError("Work stream is not connected")
1699
+ ok = await self._publish_dynamic_locked(capacity, 0)
1700
+ if not ok:
1701
+ raise UnavailableError("initial dynamic credit was not accepted")
1702
+
1703
+ async def _publish_controller_probe(
1704
+ self, member: _Member, capacity: int, generation: int
1705
+ ) -> None:
1706
+ async with self._publisher_lock:
1707
+ if (
1708
+ self._publisher_closed
1709
+ or self._close_started.is_set()
1710
+ or self._member is not member
1711
+ or not member.probe
1712
+ or member.probe_generation != generation
1713
+ or self._session_done is None
1714
+ or self._session_done.is_set()
1715
+ ):
1716
+ return
1717
+ await self._publish_dynamic_locked(
1718
+ capacity,
1719
+ 1,
1720
+ probe_member=member,
1721
+ probe_generation=generation,
1722
+ )
1723
+
1724
+ async def _publish_controller_target(self, capacity: int) -> bool:
1725
+ """Publish target/zero credit for a live shared-controller stream."""
1726
+ async with self._publisher_lock:
1727
+ if self._publisher_closed:
1728
+ return True
1729
+ if self._session_done is None or self._session_done.is_set():
1730
+ return True
1731
+ return await self._publish_dynamic_locked(capacity, 0)
1732
+
1733
+ async def _withdraw_controller_probe(
1734
+ self,
1735
+ member: _Member,
1736
+ capacity: int,
1737
+ *,
1738
+ force_target: bool = False,
1739
+ ) -> bool:
1740
+ async with self._publisher_lock:
1741
+ if self._publisher_closed:
1742
+ return True
1743
+ if self._session_done is None or self._session_done.is_set():
1744
+ # The old stream is definitively gone; its credit no longer
1745
+ # exists server-side and the controller may reclaim the token.
1746
+ return True
1747
+ return await self._publish_dynamic_locked(
1748
+ capacity,
1749
+ 0,
1750
+ allow_closing=True,
1751
+ explicit_target=force_target,
1752
+ )
1753
+
1754
+ async def _publish_dynamic_locked(
1755
+ self,
1756
+ target: int,
1757
+ available: int,
1758
+ *,
1759
+ allow_closing: bool = False,
1760
+ explicit_target: bool = False,
1761
+ probe_member: _Member | None = None,
1762
+ probe_generation: int = 0,
1763
+ ) -> bool:
1764
+ controller = self._controller
1765
+ assert controller is not None
1766
+ epoch, session = self._session_epoch, self._session_done
1767
+ for attempt in range(20):
1768
+ if (
1769
+ self._publisher_closed
1770
+ or (self._close_started.is_set() and not allow_closing)
1771
+ or epoch != self._session_epoch
1772
+ or self._session_done is None
1773
+ or self._session_done.is_set()
1774
+ ):
1775
+ return self._session_done is None or self._session_done.is_set()
1776
+ if probe_member is not None and (
1777
+ not probe_member.probe
1778
+ or probe_member.probe_generation != probe_generation
1779
+ ):
1780
+ # The assignment consumed this probe while a previous
1781
+ # capacity update was in flight; never replay available=1.
1782
+ return True
1783
+ # target and available are one atomic protocol snapshot. A
1784
+ # credit_capacity rejection must be retried with a fresh target /
1785
+ # in-flight sample, never by replaying the old snapshot.
1786
+ current_target = controller.capacity if not explicit_target else target
1787
+ update = queue_pb2.CreditUpdate(
1788
+ available=max(0, available),
1789
+ target_concurrency=current_target,
1790
+ )
1791
+ result = await self._send_command(
1792
+ queue_pb2.WorkRequest(credit=update),
1793
+ expected_epoch=epoch,
1794
+ expected_session=session,
1795
+ )
1796
+ if (
1797
+ not result.HasField("error")
1798
+ or result.error.code == queue_pb2.ERROR_CODE_UNSPECIFIED
1799
+ ):
1800
+ source = self._source
1801
+ if source is not None and source.done is session:
1802
+ source.accepted_credit = available
1803
+ source.credit_generation = probe_generation
1804
+ return True
1805
+ if (
1806
+ result.error.metadata.get("reason") != "credit_capacity"
1807
+ or attempt == 19
1808
+ ):
1809
+ error = error_from_detail(result.error)
1810
+ self._fail_session(error, epoch=epoch, session=session)
1811
+ raise error
1812
+ await asyncio.sleep(0.01)
1813
+ return False
1814
+
1815
+ async def _send_command(
1816
+ self,
1817
+ request: queue_pb2.WorkRequest,
1818
+ *,
1819
+ expected_epoch: int | None = None,
1820
+ expected_session: asyncio.Event | None = None,
1821
+ ) -> queue_pb2.WorkResult:
1822
+ source = self._source
1823
+ if source is None or source.done.is_set():
1824
+ raise UnavailableError("Work stream is not connected")
1825
+ if expected_epoch is not None and (
1826
+ expected_epoch != source.epoch or expected_session is not source.done
1827
+ ):
1828
+ raise UnavailableError("command belongs to a replaced Work session")
1829
+ if source.next_request_id > _MAX_REQUEST_ID:
1830
+ error = UnavailableError(
1831
+ "Work request ID space exhausted; reconnect required"
1832
+ )
1833
+ self._end_source(source, error)
1834
+ raise error
1835
+ # This is the actual FIFO publication point, shared by every producer
1836
+ # including drain. No await separates source validation, numbering,
1837
+ # pending registration and insertion. Even an immediate receipt can
1838
+ # find its Future, and cancellation never rolls a consumed ID back.
1839
+ rid = source.next_request_id
1840
+ source.next_request_id += 1
1841
+ request.request_id = rid
1842
+ future: asyncio.Future[queue_pb2.WorkResult] = (
1843
+ asyncio.get_running_loop().create_future()
1844
+ )
1845
+ source.pending[rid] = future
1846
+ try:
1847
+ self._publish_frame(source, request)
1848
+ async with asyncio.timeout(self._command_timeout):
1849
+ return await future
1850
+ except asyncio.CancelledError:
1851
+ if future.done() and not future.cancelled():
1852
+ future.exception()
1853
+ raise
1854
+ except Exception as exc:
1855
+ # Source ownership, rather than the current Work call, fences a
1856
+ # timeout or publication failure. Unknown mutations are not replayed.
1857
+ self._end_source(source, exc)
1858
+ if future.done() and not future.cancelled():
1859
+ # Publication can fail before we ever await this Future. End
1860
+ # the source first, then consume its newly installed exception.
1861
+ future.exception()
1862
+ raise
1863
+ finally:
1864
+ if source.pending.get(rid) is future:
1865
+ source.pending.pop(rid)
1866
+
1867
+
1868
+ __all__ = ["TaskContext", "Worker"]