ringo-task-queue 0.1.0.dev0__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,785 @@
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 .errors import (
21
+ LeaseLostError,
22
+ PermanentTaskError,
23
+ RingoError,
24
+ RetryTaskError,
25
+ UnavailableError,
26
+ error_from_detail,
27
+ map_rpc_error,
28
+ )
29
+ from .models import Task
30
+
31
+
32
+ _MAX_ERROR_BYTES = 256 * 1024
33
+
34
+
35
+ def _consume_task_exception(task: asyncio.Task[Any]) -> None:
36
+ if not task.cancelled():
37
+ task.exception()
38
+
39
+
40
+ def _safe_error(value: object) -> str:
41
+ """Return a valid UTF-8 error string no larger than 256 KiB."""
42
+ try:
43
+ text = str(value)
44
+ except Exception:
45
+ text = type(value).__name__
46
+ encoded = text.encode("utf-8", errors="replace")
47
+ if len(encoded) <= _MAX_ERROR_BYTES:
48
+ return encoded.decode("utf-8", errors="replace")
49
+ # Decode with replacement after truncating bytes; this is UTF-8 safe and
50
+ # never emits a broken surrogate or an over-sized protobuf string.
51
+ return encoded[:_MAX_ERROR_BYTES].decode("utf-8", errors="ignore")
52
+
53
+
54
+ class TaskContext:
55
+ """Context supplied to a worker handler.
56
+
57
+ ``cancelled`` becomes true when the local lease is lost, the stream drops,
58
+ or the worker is closed. ``report_progress`` is correlated on the Work
59
+ stream and never exposes the lease token to application code.
60
+ """
61
+
62
+ __slots__ = (
63
+ "_worker",
64
+ "_task",
65
+ "_attempt",
66
+ "_max_attempts",
67
+ "_worker_id",
68
+ "_assignment",
69
+ "_cancel_event",
70
+ )
71
+
72
+ def __init__(
73
+ self,
74
+ worker: "Worker",
75
+ task: Task,
76
+ attempt: int,
77
+ max_attempts: int,
78
+ worker_id: str,
79
+ assignment: "_Assignment",
80
+ ) -> None:
81
+ self._worker = worker
82
+ self._task = task
83
+ self._attempt = attempt
84
+ self._max_attempts = max_attempts
85
+ self._worker_id = worker_id
86
+ self._assignment = assignment
87
+ self._cancel_event = asyncio.Event()
88
+
89
+ @property
90
+ def task(self) -> Task:
91
+ return self._task
92
+
93
+ @property
94
+ def attempt(self) -> int:
95
+ return self._attempt
96
+
97
+ @property
98
+ def max_attempts(self) -> int:
99
+ return self._max_attempts
100
+
101
+ @property
102
+ def worker_id(self) -> str:
103
+ return self._worker_id
104
+
105
+ @property
106
+ def cancelled(self) -> bool:
107
+ return self._cancel_event.is_set()
108
+
109
+ @property
110
+ def is_cancelled(self) -> bool:
111
+ """Alias useful to code shared with SDKs using a verb-like name."""
112
+ return self.cancelled
113
+
114
+ async def wait_cancelled(self) -> None:
115
+ await self._cancel_event.wait()
116
+
117
+ async def report_progress(
118
+ self, current: float, total: float, message: str = ""
119
+ ) -> None:
120
+ """Report progress for this attempt and await its correlated result."""
121
+ if self.cancelled:
122
+ raise LeaseLostError("task lease is no longer valid")
123
+ await self._worker._report_progress(self._assignment, current, total, message)
124
+
125
+ def _cancel(self) -> None:
126
+ self._cancel_event.set()
127
+
128
+
129
+ @dataclass
130
+ class _Assignment:
131
+ task: Task
132
+ attempt: int
133
+ token: str
134
+ until: datetime
135
+ context: TaskContext | None = None
136
+ handler_task: asyncio.Task[Any] | None = None
137
+ renew_task: asyncio.Task[Any] | None = None
138
+ alive: bool = True
139
+ completion_started: bool = False
140
+
141
+ @property
142
+ def ref(self) -> queue_pb2.LeaseRef:
143
+ return queue_pb2.LeaseRef(
144
+ task_id=self.task.id,
145
+ attempt=self.attempt,
146
+ lease_token=self.token,
147
+ )
148
+
149
+
150
+ class Worker:
151
+ """A credit-controlled asynchronous task worker.
152
+
153
+ ``run`` is intentionally a blocking coroutine: it remains active until
154
+ ``close`` is called. The client registers workers and drains them before
155
+ closing its channel.
156
+ """
157
+
158
+ def __init__(
159
+ self,
160
+ queue: Any,
161
+ handler: Callable[[Task, TaskContext], Awaitable[Any]],
162
+ *,
163
+ task_types: Iterable[str],
164
+ concurrency: int,
165
+ lease_duration: timedelta,
166
+ grace_period: timedelta,
167
+ ) -> None:
168
+ if concurrency < 1:
169
+ raise ValueError("concurrency must be >= 1")
170
+ self._queue = queue
171
+ self._client = queue._client
172
+ self._handler = handler
173
+ self._task_types = list(task_types)
174
+ self._concurrency = concurrency
175
+ self._lease_duration = lease_duration
176
+ self._grace_period = max(0.0, grace_period.total_seconds())
177
+ self._worker_id = self._new_worker_id()
178
+
179
+ # close_started means no new stream may be opened, while force_cancel
180
+ # is raised only after the graceful drain window expires. Existing
181
+ # handlers and renewals remain fully live between those two points.
182
+ self._close_started = asyncio.Event()
183
+ self._force_cancel = asyncio.Event()
184
+ self._run_started = False
185
+ self._run_finished = asyncio.Event()
186
+ self._close_finished = asyncio.Event()
187
+ self._command_timeout = 30.0
188
+ self._close_lock = asyncio.Lock()
189
+ self._close_task: asyncio.Task[None] | None = None
190
+ self._session_done: asyncio.Event | None = None
191
+ self._call: Any = None
192
+ self._outbox: asyncio.Queue[Any] | None = None
193
+ self._reader_task: asyncio.Task[Any] | None = None
194
+ self._writer_task: asyncio.Task[Any] | None = None
195
+ self._registered: asyncio.Event | None = None
196
+ self._stream_error: BaseException | None = None
197
+ self._next_request_id = 1
198
+ self._pending: dict[int, asyncio.Future[queue_pb2.WorkResult]] = {}
199
+ self._inflight: dict[str, _Assignment] = {}
200
+
201
+ @staticmethod
202
+ def _new_worker_id() -> str:
203
+ # Importing here avoids coupling worker construction to client internals.
204
+ from .client import new_worker_id
205
+
206
+ return new_worker_id()
207
+
208
+ @property
209
+ def worker_id(self) -> str:
210
+ return self._worker_id
211
+
212
+ @property
213
+ def concurrency(self) -> int:
214
+ return self._concurrency
215
+
216
+ async def run(self) -> None:
217
+ """Run until :meth:`close` is called, reconnecting boundedly."""
218
+ if self._run_started:
219
+ raise RuntimeError("worker.run() may only be called once")
220
+ self._run_started = True
221
+ failures = 0
222
+ try:
223
+ while not self._close_started.is_set():
224
+ try:
225
+ await self._run_session()
226
+ except asyncio.CancelledError:
227
+ if not self._close_started.is_set():
228
+ raise
229
+ break
230
+ except Exception as exc:
231
+ self._stream_error = exc
232
+ failures += 1
233
+ if self._close_started.is_set():
234
+ break
235
+ if self._close_started.is_set():
236
+ break
237
+ # A stream loss invalidates local completion attempts, but does
238
+ # not send terminal commands for leases that may still exist.
239
+ await self._cancel_assignments()
240
+ conn = self._client._require_conn()
241
+ before_token = conn._session_token
242
+ await conn._recover_best_effort()
243
+ if conn._session_token == before_token:
244
+ failures += 1
245
+ else:
246
+ failures = 0
247
+ if failures >= 3:
248
+ raise self._stream_error or UnavailableError(
249
+ "bounded Work stream recovery exhausted"
250
+ )
251
+ if not self._close_started.is_set():
252
+ await asyncio.sleep(0)
253
+ finally:
254
+ await self._cancel_assignments()
255
+ self._run_finished.set()
256
+
257
+ async def close(self) -> None:
258
+ """Drain and stop once; caller cancellation cannot cancel cleanup."""
259
+ async with self._close_lock:
260
+ if self._close_task is None:
261
+ self._close_started.set()
262
+ self._close_task = asyncio.create_task(
263
+ self._close_impl(), name=f"ringo-close-{self._worker_id}"
264
+ )
265
+ self._close_task.add_done_callback(_consume_task_exception)
266
+ close_task = self._close_task
267
+ await asyncio.shield(close_task)
268
+
269
+ async def _close_impl(self) -> None:
270
+ try:
271
+ session = self._session_done
272
+ # Drain is best effort. A disconnected stream cannot safely report
273
+ # any terminal result, and leases are deliberately left to expire.
274
+ if session is not None and not session.is_set():
275
+ try:
276
+ await asyncio.wait_for(
277
+ self._send_command(
278
+ queue_pb2.WorkRequest(
279
+ request_id=self._allocate_request_id(),
280
+ drain=queue_pb2.WorkerDrain(begin=True),
281
+ ),
282
+ wait=True,
283
+ ),
284
+ timeout=max(
285
+ 0.1,
286
+ min(self._command_timeout, self._grace_period or 1.0),
287
+ ),
288
+ )
289
+ except Exception:
290
+ pass
291
+ if self._grace_period:
292
+ try:
293
+ await asyncio.wait_for(
294
+ self._wait_handlers(), timeout=self._grace_period
295
+ )
296
+ except asyncio.TimeoutError:
297
+ pass
298
+ finally:
299
+ # From this point handlers and renewals are cancelled; no terminal
300
+ # frame is generated by their cancellation paths.
301
+ self._force_cancel.set()
302
+ try:
303
+ await self._cancel_assignments()
304
+ await self._stop_session()
305
+ self._client._unregister_worker(self)
306
+ if self._run_started:
307
+ await self._run_finished.wait()
308
+ finally:
309
+ self._close_finished.set()
310
+
311
+ async def _wait_handlers(self) -> None:
312
+ tasks = [a.handler_task for a in self._inflight.values() if a.handler_task]
313
+ if tasks:
314
+ await asyncio.gather(*tasks, return_exceptions=True)
315
+
316
+ async def _cancel_assignments(self) -> None:
317
+ assignments = list(self._inflight.values())
318
+ self._inflight.clear()
319
+ for assignment in assignments:
320
+ self._invalidate_assignment(assignment)
321
+ tasks: list[asyncio.Task[Any]] = []
322
+ for assignment in assignments:
323
+ if (
324
+ assignment.handler_task is not None
325
+ and not assignment.handler_task.done()
326
+ ):
327
+ assignment.handler_task.cancel()
328
+ tasks.append(assignment.handler_task)
329
+ if assignment.renew_task is not None and not assignment.renew_task.done():
330
+ assignment.renew_task.cancel()
331
+ tasks.append(assignment.renew_task)
332
+ if tasks:
333
+ await asyncio.gather(*tasks, return_exceptions=True)
334
+
335
+ def _invalidate_assignment(self, assignment: _Assignment) -> None:
336
+ if not assignment.alive:
337
+ return
338
+ assignment.alive = False
339
+ if assignment.context is not None:
340
+ assignment.context._cancel()
341
+
342
+ async def _run_session(self) -> None:
343
+ conn = self._client._require_conn()
344
+ reg = queue_pb2.WorkerRegistration(
345
+ queue=self._queue.name,
346
+ worker_id=self._worker_id,
347
+ task_types=self._task_types,
348
+ max_concurrency=self._concurrency,
349
+ )
350
+ if self._lease_duration > timedelta(0):
351
+ reg.lease_duration.CopyFrom(_pb.td_to_dur(self._lease_duration))
352
+ async with conn._lock:
353
+ stub = conn._require_stub()
354
+ reg.context.CopyFrom(conn.request_context())
355
+ call = stub.Work(metadata=conn._metadata())
356
+ self._call = call
357
+ self._session_done = asyncio.Event()
358
+ self._registered = asyncio.Event()
359
+ self._stream_error = None
360
+ self._outbox = asyncio.Queue()
361
+ self._pending.clear()
362
+ self._writer_task = asyncio.create_task(
363
+ self._write_loop(call), name="ringo-worker-writer"
364
+ )
365
+ self._reader_task = asyncio.create_task(
366
+ self._read_loop(call), name="ringo-worker-reader"
367
+ )
368
+ try:
369
+ await self._enqueue_frame(queue_pb2.WorkRequest(register=reg))
370
+ await self._wait_event_or_session(self._registered)
371
+ if self._force_cancel.is_set():
372
+ return
373
+ # Initial credit is sent only after WorkerRegistered was observed.
374
+ await self._send_credit(self._concurrency)
375
+ await self._wait_event_or_session(self._session_done)
376
+ if self._stream_error is not None and not self._close_started.is_set():
377
+ raise self._stream_error
378
+ finally:
379
+ await self._stop_session()
380
+ self._call = None
381
+ self._session_done = None
382
+ self._registered = None
383
+
384
+ async def _wait_event_or_session(self, event: asyncio.Event) -> None:
385
+ if self._session_done is None:
386
+ return
387
+ event_task = asyncio.create_task(event.wait())
388
+ done_task = asyncio.create_task(self._session_done.wait())
389
+ done, pending = await asyncio.wait(
390
+ (event_task, done_task), return_when=asyncio.FIRST_COMPLETED
391
+ )
392
+ for task in pending:
393
+ task.cancel()
394
+ await asyncio.gather(*pending, return_exceptions=True)
395
+ if done_task in done and not event.is_set():
396
+ if self._stream_error is not None:
397
+ raise self._stream_error
398
+ raise UnavailableError("Work stream closed")
399
+
400
+ async def _stop_session(self) -> None:
401
+ session = self._session_done
402
+ if session is not None:
403
+ session.set()
404
+ call, self._call = self._call, None
405
+ writer, self._writer_task = self._writer_task, None
406
+ reader, self._reader_task = self._reader_task, None
407
+ outbox, self._outbox = self._outbox, None
408
+ if outbox is not None and writer is not None and not writer.done():
409
+ await outbox.put(None)
410
+ if call is not None:
411
+ try:
412
+ call.cancel()
413
+ except Exception:
414
+ pass
415
+ tasks = [t for t in (reader, writer) if t is not None]
416
+ if tasks:
417
+ await asyncio.gather(*tasks, return_exceptions=True)
418
+ for future in self._pending.values():
419
+ if not future.done():
420
+ future.set_exception(UnavailableError("Work stream closed"))
421
+ self._pending.clear()
422
+
423
+ async def _enqueue_frame(self, frame: queue_pb2.WorkRequest) -> None:
424
+ outbox = self._outbox
425
+ if outbox is None:
426
+ raise UnavailableError("Work stream is not connected")
427
+ await outbox.put(frame)
428
+
429
+ async def _write_loop(self, call: Any) -> None:
430
+ try:
431
+ while True:
432
+ frame = await self._outbox.get() if self._outbox is not None else None
433
+ if frame is None:
434
+ try:
435
+ done_writing = getattr(call, "done_writing", None)
436
+ if done_writing is not None:
437
+ await done_writing()
438
+ except Exception:
439
+ pass
440
+ return
441
+ await call.write(frame)
442
+ except asyncio.CancelledError:
443
+ raise
444
+ except Exception as exc:
445
+ self._stream_error = self._map_transport(exc)
446
+ if self._session_done is not None:
447
+ self._session_done.set()
448
+
449
+ async def _read_loop(self, call: Any) -> None:
450
+ try:
451
+ async for response in call:
452
+ body = response.WhichOneof("body")
453
+ if body == "registered":
454
+ registered = response.registered
455
+ if registered.HasField("lease_duration"):
456
+ self._lease_duration = (
457
+ _pb.dur_to_td(registered.lease_duration)
458
+ or self._lease_duration
459
+ )
460
+ if registered.max_concurrency:
461
+ self._concurrency = registered.max_concurrency
462
+ if self._registered is not None:
463
+ self._registered.set()
464
+ elif body == "task":
465
+ await self._on_assignment(response.task)
466
+ elif body == "result":
467
+ result = response.result
468
+ future = self._pending.pop(result.request_id, None)
469
+ if future is not None and not future.done():
470
+ future.set_result(result)
471
+ # Heartbeats are unsolicited liveness frames and need no action.
472
+ except asyncio.CancelledError:
473
+ raise
474
+ except Exception as exc:
475
+ self._stream_error = self._map_transport(exc)
476
+ finally:
477
+ if self._session_done is not None:
478
+ self._session_done.set()
479
+
480
+ @staticmethod
481
+ def _map_transport(exc: BaseException) -> BaseException:
482
+ if isinstance(exc, RingoError):
483
+ return exc
484
+ if isinstance(exc, grpc.aio.AioRpcError):
485
+ return map_rpc_error(exc)
486
+ return UnavailableError(f"Work stream failed: {_safe_error(exc)}")
487
+
488
+ async def _on_assignment(self, leased: queue_pb2.LeasedTask) -> None:
489
+ if (
490
+ self._force_cancel.is_set()
491
+ or not leased.HasField("task")
492
+ or not leased.HasField("lease")
493
+ ):
494
+ return
495
+ task = Task.from_proto(leased.task)
496
+ until = _pb.ts_to_dt(leased.lease.until)
497
+ assignment = _Assignment(
498
+ task=task,
499
+ attempt=leased.lease.attempt,
500
+ token=leased.lease.lease_token,
501
+ until=until,
502
+ )
503
+ context = TaskContext(
504
+ self,
505
+ task,
506
+ assignment.attempt,
507
+ task.max_attempts,
508
+ self._worker_id,
509
+ assignment,
510
+ )
511
+ assignment.context = context
512
+ # Task IDs are unique among valid in-flight leases. Avoid replacing an
513
+ # existing assignment if a malformed peer sends a duplicate.
514
+ if task.id in self._inflight:
515
+ return
516
+ self._inflight[task.id] = assignment
517
+ assignment.renew_task = asyncio.create_task(
518
+ self._renew_loop(assignment), name=f"ringo-renew-{task.id}"
519
+ )
520
+ assignment.handler_task = asyncio.create_task(
521
+ self._handle_assignment(assignment), name=f"ringo-handler-{task.id}"
522
+ )
523
+ assignment.handler_task.add_done_callback(_consume_task_exception)
524
+
525
+ async def _handle_assignment(self, assignment: _Assignment) -> None:
526
+ try:
527
+ context = assignment.context
528
+ assert context is not None
529
+ result = self._handler(assignment.task, context)
530
+ if inspect.isawaitable(result):
531
+ await result
532
+ if assignment.alive and not self._force_cancel.is_set():
533
+ await self._finish(assignment, "ack")
534
+ except asyncio.CancelledError:
535
+ # Cancellation is intentionally not a task outcome. In particular,
536
+ # do not emit NACK while draining or after a lease loss.
537
+ raise
538
+ except RetryTaskError as exc:
539
+ if assignment.alive and not self._force_cancel.is_set():
540
+ await self._finish(assignment, "nack", exc)
541
+ except PermanentTaskError as exc:
542
+ if assignment.alive and not self._force_cancel.is_set():
543
+ await self._finish(assignment, "reject", exc)
544
+ except Exception as exc:
545
+ if assignment.alive and not self._force_cancel.is_set():
546
+ await self._finish(assignment, "nack", exc)
547
+
548
+ async def _finish(
549
+ self,
550
+ assignment: _Assignment,
551
+ operation: str,
552
+ error: BaseException | None = None,
553
+ ) -> None:
554
+ if not assignment.alive or assignment.completion_started:
555
+ return
556
+ assignment.completion_started = True
557
+ request: queue_pb2.WorkRequest
558
+ text = _safe_error(error) if error is not None else ""
559
+ if operation == "ack":
560
+ request = queue_pb2.WorkRequest(
561
+ ack=queue_pb2.AckRequest(lease=assignment.ref)
562
+ )
563
+ elif operation == "nack":
564
+ nack = queue_pb2.NackRequest(lease=assignment.ref, error=text)
565
+ if isinstance(error, RetryTaskError) and error.delay is not None:
566
+ if (
567
+ not isinstance(error.delay, (int, float))
568
+ or not math.isfinite(error.delay)
569
+ or error.delay < 0
570
+ ):
571
+ # Treat an invalid override as an ordinary failure rather
572
+ # than allowing a negative Duration onto the wire.
573
+ nack.error = _safe_error(error)
574
+ else:
575
+ try:
576
+ nack.retry_delay_override.CopyFrom(
577
+ _pb.td_to_dur(timedelta(seconds=error.delay))
578
+ )
579
+ except Exception:
580
+ # An unrepresentable override is treated as a normal
581
+ # NACK; never strand the assignment on serialization.
582
+ nack.error = _safe_error(error)
583
+ request = queue_pb2.WorkRequest(nack=nack)
584
+ else:
585
+ request = queue_pb2.WorkRequest(
586
+ reject=queue_pb2.RejectRequest(lease=assignment.ref, error=text)
587
+ )
588
+ try:
589
+ result = await self._send_command(request, wait=True)
590
+ except asyncio.CancelledError:
591
+ raise
592
+ except Exception:
593
+ return
594
+ if result is None:
595
+ return
596
+ detail = result.error if result.HasField("error") else None
597
+ if detail is not None and detail.code == queue_pb2.ERROR_CODE_LEASE_LOST:
598
+ await self._lease_lost(assignment)
599
+ return
600
+ if detail is not None and detail.code != queue_pb2.ERROR_CODE_UNSPECIFIED:
601
+ # A non-lease terminal failure is not terminal according to the
602
+ # protocol; leave the assignment fenced until the stream/server does.
603
+ assignment.completion_started = False
604
+ return
605
+ await self._release_assignment(assignment)
606
+ await self._send_credit(self._concurrency - len(self._inflight))
607
+
608
+ async def _release_assignment(
609
+ self, assignment: _Assignment, *, cancel_context: bool = False
610
+ ) -> None:
611
+ if not assignment.alive:
612
+ return
613
+ assignment.alive = False
614
+ self._inflight.pop(assignment.task.id, None)
615
+ if cancel_context and assignment.context is not None:
616
+ assignment.context._cancel()
617
+ if (
618
+ assignment.renew_task is not None
619
+ and assignment.renew_task is not asyncio.current_task()
620
+ ):
621
+ assignment.renew_task.cancel()
622
+ await asyncio.gather(assignment.renew_task, return_exceptions=True)
623
+
624
+ async def _lease_lost(self, assignment: _Assignment) -> None:
625
+ if not assignment.alive:
626
+ return
627
+ await self._release_assignment(assignment, cancel_context=True)
628
+ current = assignment.handler_task
629
+ if (
630
+ current is not None
631
+ and current is not asyncio.current_task()
632
+ and not current.done()
633
+ ):
634
+ current.cancel()
635
+ await asyncio.gather(current, return_exceptions=True)
636
+ if not self._close_started.is_set():
637
+ await self._send_credit(self._concurrency - len(self._inflight))
638
+
639
+ async def _renew_loop(self, assignment: _Assignment) -> None:
640
+ while assignment.alive and not self._force_cancel.is_set():
641
+ remaining = (assignment.until - datetime.now(timezone.utc)).total_seconds()
642
+ delay = max(0.01, remaining - self._lease_duration.total_seconds() / 3)
643
+ try:
644
+ await asyncio.wait_for(self._force_cancel.wait(), timeout=delay)
645
+ return
646
+ except asyncio.TimeoutError:
647
+ pass
648
+ if not assignment.alive or self._force_cancel.is_set():
649
+ return
650
+ request = queue_pb2.WorkRequest(
651
+ extend_lease=queue_pb2.ExtendLeaseRequest(
652
+ lease=assignment.ref,
653
+ extension=_pb.td_to_dur(self._lease_duration),
654
+ )
655
+ )
656
+ try:
657
+ result = await self._send_command(request, wait=True)
658
+ except asyncio.CancelledError:
659
+ raise
660
+ except Exception:
661
+ return
662
+ if result is None:
663
+ return
664
+ detail = result.error if result.HasField("error") else None
665
+ if detail is not None and detail.code == queue_pb2.ERROR_CODE_LEASE_LOST:
666
+ await self._lease_lost(assignment)
667
+ return
668
+ if detail is not None and detail.code != queue_pb2.ERROR_CODE_UNSPECIFIED:
669
+ return
670
+ # ExtendLeaseResponse is not carried in WorkResult. The protocol
671
+ # defines the new deadline as server_now + extension; this local
672
+ # estimate is the only safe deadline available to the SDK.
673
+ assignment.until = datetime.now(timezone.utc) + self._lease_duration
674
+
675
+ async def _report_progress(
676
+ self, assignment: _Assignment, current: float, total: float, message: str
677
+ ) -> None:
678
+ if not assignment.alive:
679
+ raise LeaseLostError("task lease is no longer valid")
680
+ request = queue_pb2.WorkRequest(
681
+ progress=queue_pb2.ReportProgressRequest(
682
+ lease=assignment.ref,
683
+ current=current,
684
+ total=total,
685
+ message=_safe_error(message),
686
+ )
687
+ )
688
+ result = await self._send_command(request, wait=True)
689
+ if result is None:
690
+ return
691
+ detail = result.error if result.HasField("error") else None
692
+ if detail is not None and detail.code == queue_pb2.ERROR_CODE_LEASE_LOST:
693
+ await self._lease_lost(assignment)
694
+ raise LeaseLostError(detail.message)
695
+ if detail is not None and detail.code != queue_pb2.ERROR_CODE_UNSPECIFIED:
696
+ raise error_from_detail(detail)
697
+
698
+ async def _send_credit(self, available: int) -> None:
699
+ if (
700
+ self._close_started.is_set()
701
+ or self._session_done is None
702
+ or self._session_done.is_set()
703
+ ):
704
+ return
705
+ available = max(0, available)
706
+ # The server can have an assignment queued in the transport before the
707
+ # single reader materializes it locally. A very fast handler may then
708
+ # briefly advertise too much capacity. The stable metadata reason lets
709
+ # clients retry that exact non-mutating absolute update without parsing
710
+ # human-readable error text.
711
+ for attempt in range(20):
712
+ request = queue_pb2.WorkRequest(
713
+ credit=queue_pb2.CreditUpdate(available=available)
714
+ )
715
+ result = await self._send_command(request, wait=True)
716
+ if (
717
+ result is None
718
+ or not result.HasField("error")
719
+ or result.error.code == queue_pb2.ERROR_CODE_UNSPECIFIED
720
+ ):
721
+ return
722
+ if (
723
+ result.error.metadata.get("reason") != "credit_capacity"
724
+ or attempt == 19
725
+ ):
726
+ raise error_from_detail(result.error)
727
+ await asyncio.sleep(0.01)
728
+
729
+ def _allocate_request_id(self) -> int:
730
+ rid = self._next_request_id
731
+ self._next_request_id += 1
732
+ if self._next_request_id == 0:
733
+ self._next_request_id = 1
734
+ return rid
735
+
736
+ async def _send_command(
737
+ self, request: queue_pb2.WorkRequest, *, wait: bool
738
+ ) -> queue_pb2.WorkResult | None:
739
+ if self._session_done is None or self._session_done.is_set():
740
+ raise UnavailableError("Work stream is not connected")
741
+ rid = request.request_id or self._allocate_request_id()
742
+ request.request_id = rid
743
+ loop = asyncio.get_running_loop()
744
+ future: asyncio.Future[queue_pb2.WorkResult] = loop.create_future()
745
+ self._pending[rid] = future
746
+ try:
747
+ await self._enqueue_frame(request)
748
+ if not wait:
749
+ return None
750
+ return await asyncio.wait_for(
751
+ self._wait_command_or_session(future), timeout=self._command_timeout
752
+ )
753
+ except asyncio.CancelledError:
754
+ self._pending.pop(rid, None)
755
+ if future.done() and not future.cancelled():
756
+ future.exception()
757
+ raise
758
+ except Exception:
759
+ self._pending.pop(rid, None)
760
+ if future.done() and not future.cancelled():
761
+ future.exception()
762
+ raise
763
+
764
+ async def _wait_command_or_session(
765
+ self, future: asyncio.Future[queue_pb2.WorkResult]
766
+ ) -> queue_pb2.WorkResult:
767
+ session = self._session_done
768
+ if session is None:
769
+ raise UnavailableError("Work stream is not connected")
770
+ session_wait = asyncio.create_task(session.wait())
771
+ try:
772
+ done, _ = await asyncio.wait(
773
+ (future, session_wait), return_when=asyncio.FIRST_COMPLETED
774
+ )
775
+ if future in done:
776
+ return future.result()
777
+ if future.done():
778
+ return future.result()
779
+ raise self._stream_error or UnavailableError("Work stream closed")
780
+ finally:
781
+ session_wait.cancel()
782
+ await asyncio.gather(session_wait, return_exceptions=True)
783
+
784
+
785
+ __all__ = ["TaskContext", "Worker"]