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