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