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