taskiq-llm-queue 0.1.0a1__py3-none-any.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.
- taskiq_llm_queue/__init__.py +138 -0
- taskiq_llm_queue/_logging.py +40 -0
- taskiq_llm_queue/_version.py +5 -0
- taskiq_llm_queue/broker/__init__.py +86 -0
- taskiq_llm_queue/broker/acker.py +239 -0
- taskiq_llm_queue/broker/batching.py +188 -0
- taskiq_llm_queue/broker/broker.py +937 -0
- taskiq_llm_queue/broker/delivery.py +50 -0
- taskiq_llm_queue/broker/errors.py +178 -0
- taskiq_llm_queue/broker/events.py +51 -0
- taskiq_llm_queue/broker/filters.py +32 -0
- taskiq_llm_queue/broker/heartbeat.py +157 -0
- taskiq_llm_queue/broker/queue.py +141 -0
- taskiq_llm_queue/broker/s3_offload.py +346 -0
- taskiq_llm_queue/broker/scheduler.py +113 -0
- taskiq_llm_queue/broker/serializer.py +32 -0
- taskiq_llm_queue/cli.py +488 -0
- taskiq_llm_queue/client.py +1409 -0
- taskiq_llm_queue/config.py +592 -0
- taskiq_llm_queue/context.py +62 -0
- taskiq_llm_queue/core.py +832 -0
- taskiq_llm_queue/envelope.py +136 -0
- taskiq_llm_queue/errors.py +71 -0
- taskiq_llm_queue/failover.py +286 -0
- taskiq_llm_queue/http_transport.py +220 -0
- taskiq_llm_queue/keys.py +87 -0
- taskiq_llm_queue/langchain/__init__.py +64 -0
- taskiq_llm_queue/langchain/_client.py +142 -0
- taskiq_llm_queue/langchain/bedrock.py +291 -0
- taskiq_llm_queue/langchain/openai.py +103 -0
- taskiq_llm_queue/lifecycle.py +512 -0
- taskiq_llm_queue/metrics/__init__.py +45 -0
- taskiq_llm_queue/metrics/emf.py +100 -0
- taskiq_llm_queue/metrics/prometheus.py +66 -0
- taskiq_llm_queue/metrics_names.py +19 -0
- taskiq_llm_queue/protocols.py +140 -0
- taskiq_llm_queue/providers/__init__.py +62 -0
- taskiq_llm_queue/providers/bedrock.py +188 -0
- taskiq_llm_queue/providers/classify.py +251 -0
- taskiq_llm_queue/providers/embeddings.py +87 -0
- taskiq_llm_queue/providers/hosts.py +34 -0
- taskiq_llm_queue/providers/openai_compat.py +142 -0
- taskiq_llm_queue/providers/registry.py +173 -0
- taskiq_llm_queue/py.typed +0 -0
- taskiq_llm_queue/reliability/__init__.py +59 -0
- taskiq_llm_queue/reliability/_lua.py +461 -0
- taskiq_llm_queue/reliability/breaker.py +86 -0
- taskiq_llm_queue/reliability/claims.py +226 -0
- taskiq_llm_queue/reliability/config_store.py +148 -0
- taskiq_llm_queue/reliability/fallback.py +111 -0
- taskiq_llm_queue/reliability/limiter.py +482 -0
- taskiq_llm_queue/reliability/local_limiter.py +153 -0
- taskiq_llm_queue/results/__init__.py +25 -0
- taskiq_llm_queue/results/backend.py +226 -0
- taskiq_llm_queue/results/dispatcher.py +366 -0
- taskiq_llm_queue/results/s3_backend.py +105 -0
- taskiq_llm_queue/routing.py +173 -0
- taskiq_llm_queue/routing_hooks.py +90 -0
- taskiq_llm_queue/tasks.py +453 -0
- taskiq_llm_queue-0.1.0a1.dist-info/METADATA +296 -0
- taskiq_llm_queue-0.1.0a1.dist-info/RECORD +65 -0
- taskiq_llm_queue-0.1.0a1.dist-info/WHEEL +4 -0
- taskiq_llm_queue-0.1.0a1.dist-info/entry_points.txt +2 -0
- taskiq_llm_queue-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- taskiq_llm_queue-0.1.0a1.dist-info/licenses/NOTICE +15 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""The taskiq tasks every worker registers: `taskiq_llm_queue.call`, `.embed` and `.job`.
|
|
2
|
+
|
|
3
|
+
Names are stable for all of 0.x (STABILITY.md). LLM tasks run `core.execute_job`, which stores
|
|
4
|
+
the reply itself; the task returns None and `WorkerResultBackend` skips taskiq's own write, so
|
|
5
|
+
a result is written exactly once (DS8). Jobs run app-registered functions from an allow-list.
|
|
6
|
+
No exception leaves a task body: taskiq would ack (delete) the message, so every infrastructure
|
|
7
|
+
failure releases the message for redelivery instead.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import contextlib
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
from redis.exceptions import RedisError
|
|
20
|
+
from taskiq import AsyncResultBackend, Context, TaskiqDepends, TaskiqResult
|
|
21
|
+
|
|
22
|
+
from taskiq_llm_queue._logging import get_logger
|
|
23
|
+
from taskiq_llm_queue.broker.broker import (
|
|
24
|
+
SQS_MESSAGE_ID_LABEL,
|
|
25
|
+
SQS_RECEIPT_HANDLE_LABEL,
|
|
26
|
+
SQS_RECEIVE_COUNT_LABEL,
|
|
27
|
+
)
|
|
28
|
+
from taskiq_llm_queue.core import InfraError, execute_job
|
|
29
|
+
from taskiq_llm_queue.envelope import (
|
|
30
|
+
TASK_CALL,
|
|
31
|
+
TASK_EMBED,
|
|
32
|
+
TASK_JOB,
|
|
33
|
+
LLMReply,
|
|
34
|
+
parse_envelope,
|
|
35
|
+
)
|
|
36
|
+
from taskiq_llm_queue.errors import QueueUnavailable, UnsupportedEnvelopeError
|
|
37
|
+
from taskiq_llm_queue.protocols import ClaimDecision, ClaimState
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from taskiq_llm_queue.broker.broker import HybridSQSBroker
|
|
41
|
+
from taskiq_llm_queue.broker.delivery import Delivery
|
|
42
|
+
from taskiq_llm_queue.core import CoreDeps
|
|
43
|
+
from taskiq_llm_queue.results.backend import LLMQResultBackend
|
|
44
|
+
from taskiq_llm_queue.routing_hooks import FailoverPolicy
|
|
45
|
+
|
|
46
|
+
logger = get_logger(__name__)
|
|
47
|
+
|
|
48
|
+
JOB_VERSION = 1
|
|
49
|
+
JobFunction = Callable[[dict[str, Any]], Awaitable[Any]]
|
|
50
|
+
_UNSUPPORTED_RELEASE_SECS = 30
|
|
51
|
+
# DS5: an exhausted job is released quickly until `maxReceiveCount` moves it to the DLQ.
|
|
52
|
+
_DLQ_RELEASE_SECS = 1
|
|
53
|
+
_CONTEXT: Any = TaskiqDepends() # taskiq injects the message Context into the task functions
|
|
54
|
+
|
|
55
|
+
# `_Running.stop_reason` values set by the broker heartbeat and the pod heartbeat.
|
|
56
|
+
LOST = "lost" # SQS receipt handle invalid (DS6): the message is no longer ours
|
|
57
|
+
CLAIM_LOST = "claim_lost" # another pod owns the claim now: never delete the message
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class WorkerResultBackend(AsyncResultBackend[Any]):
|
|
61
|
+
"""Result backend given to the brokers: skips taskiq's write for tasks that stored their own.
|
|
62
|
+
|
|
63
|
+
LLM tasks return None after `execute_job` stored the reply; a task that raised unexpectedly
|
|
64
|
+
gets an `ok=False` reply so its waiter is released. Cancellations write nothing.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, inner: LLMQResultBackend) -> None:
|
|
68
|
+
"""Wrap the shared JSON backend (its user count tracks every broker)."""
|
|
69
|
+
super().__init__()
|
|
70
|
+
self.inner = inner
|
|
71
|
+
|
|
72
|
+
async def startup(self) -> None:
|
|
73
|
+
"""Register one user of the shared backend."""
|
|
74
|
+
await self.inner.startup()
|
|
75
|
+
|
|
76
|
+
async def shutdown(self) -> None:
|
|
77
|
+
"""Drop one user of the shared backend."""
|
|
78
|
+
await self.inner.shutdown()
|
|
79
|
+
|
|
80
|
+
async def set_result(self, task_id: str, result: TaskiqResult[Any]) -> None:
|
|
81
|
+
"""Store only unexpected task errors, never over a reply the task already stored."""
|
|
82
|
+
error = result.error
|
|
83
|
+
if not result.is_err or isinstance(error, asyncio.CancelledError):
|
|
84
|
+
return
|
|
85
|
+
with contextlib.suppress(Exception):
|
|
86
|
+
if await self.inner.is_result_ready(task_id):
|
|
87
|
+
return
|
|
88
|
+
reply = LLMReply(
|
|
89
|
+
request_id=task_id,
|
|
90
|
+
ok=False,
|
|
91
|
+
error={"type": "task_error", "message": type(error).__name__, "retryable": True},
|
|
92
|
+
)
|
|
93
|
+
await self.inner.put(reply, ttl_secs=self.inner.result_ttl_secs)
|
|
94
|
+
|
|
95
|
+
async def is_result_ready(self, task_id: str) -> bool:
|
|
96
|
+
"""Delegate to the shared backend."""
|
|
97
|
+
return await self.inner.is_result_ready(task_id)
|
|
98
|
+
|
|
99
|
+
async def get_result(self, task_id: str, with_logs: bool = False) -> TaskiqResult[Any]:
|
|
100
|
+
"""Delegate to the shared backend."""
|
|
101
|
+
return await self.inner.get_result(task_id, with_logs)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(slots=True, eq=False)
|
|
105
|
+
class _Running:
|
|
106
|
+
"""One claimed task this pod runs: its asyncio task, broker and SQS receipt handle."""
|
|
107
|
+
|
|
108
|
+
request_id: str
|
|
109
|
+
task: asyncio.Task[Any]
|
|
110
|
+
broker: HybridSQSBroker
|
|
111
|
+
receipt_handle: str | None
|
|
112
|
+
message_id: str | None
|
|
113
|
+
stop_reason: str | None = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(slots=True)
|
|
117
|
+
class WorkerRuntime:
|
|
118
|
+
"""Per-`LLMQueue` worker state shared by every registered task.
|
|
119
|
+
|
|
120
|
+
Tracks claimed, running deliveries (one entry each, so two deliveries of one request never
|
|
121
|
+
clobber each other) for drain, the pod heartbeat and the broker's `on_lost` (DS6).
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
deps: CoreDeps
|
|
125
|
+
jobs: dict[str, JobFunction] = field(default_factory=dict)
|
|
126
|
+
running: dict[tuple[str, int], _Running] = field(default_factory=dict)
|
|
127
|
+
defer_secs: int = 20
|
|
128
|
+
failover: FailoverPolicy | None = None
|
|
129
|
+
|
|
130
|
+
def register(
|
|
131
|
+
self, request_id: str, broker: HybridSQSBroker, labels: Mapping[str, Any]
|
|
132
|
+
) -> _Running:
|
|
133
|
+
"""Record the current asyncio task as running `request_id` (call only after RUN)."""
|
|
134
|
+
task = asyncio.current_task()
|
|
135
|
+
assert task is not None
|
|
136
|
+
entry = _Running(
|
|
137
|
+
request_id,
|
|
138
|
+
task,
|
|
139
|
+
broker,
|
|
140
|
+
_label(labels, SQS_RECEIPT_HANDLE_LABEL),
|
|
141
|
+
_label(labels, SQS_MESSAGE_ID_LABEL),
|
|
142
|
+
)
|
|
143
|
+
self.running[(request_id, id(entry))] = entry
|
|
144
|
+
return entry
|
|
145
|
+
|
|
146
|
+
def unregister(self, entry: _Running) -> None:
|
|
147
|
+
"""Drop exactly this delivery's entry."""
|
|
148
|
+
self.running.pop((entry.request_id, id(entry)), None)
|
|
149
|
+
|
|
150
|
+
def running_ids(self) -> list[str]:
|
|
151
|
+
"""Distinct request ids of running tasks (for the claim heartbeat)."""
|
|
152
|
+
return list(dict.fromkeys(entry.request_id for entry in self.running.values()))
|
|
153
|
+
|
|
154
|
+
def on_lost(self, delivery: Delivery) -> None:
|
|
155
|
+
"""Broker heartbeat lost the message (DS6): cancel its task; it writes no result."""
|
|
156
|
+
for entry in list(self.running.values()):
|
|
157
|
+
if entry.message_id == delivery.message_id and not entry.task.done():
|
|
158
|
+
entry.stop_reason = LOST
|
|
159
|
+
entry.task.cancel()
|
|
160
|
+
|
|
161
|
+
def stop_running(self, reason: str, request_ids: Sequence[str] | None = None) -> list[Any]:
|
|
162
|
+
"""Cancel running tasks (all, or the given ids); returns the tasks to await."""
|
|
163
|
+
chosen = [
|
|
164
|
+
entry
|
|
165
|
+
for entry in list(self.running.values())
|
|
166
|
+
if request_ids is None or entry.request_id in request_ids
|
|
167
|
+
]
|
|
168
|
+
for entry in chosen:
|
|
169
|
+
if not entry.task.done():
|
|
170
|
+
entry.stop_reason = entry.stop_reason or reason
|
|
171
|
+
entry.task.cancel()
|
|
172
|
+
return [entry.task for entry in chosen]
|
|
173
|
+
|
|
174
|
+
async def run_llm(self, raw: Any, labels: Mapping[str, Any], broker: HybridSQSBroker) -> None:
|
|
175
|
+
"""Body of the `call` / `embed` tasks: parse, execute, release instead of losing work."""
|
|
176
|
+
try:
|
|
177
|
+
envelope = parse_envelope(raw)
|
|
178
|
+
except UnsupportedEnvelopeError:
|
|
179
|
+
await _release(broker, labels, _UNSUPPORTED_RELEASE_SECS)
|
|
180
|
+
return
|
|
181
|
+
except ValueError as exc:
|
|
182
|
+
logger.error("poison_task_dropped", error_type=type(exc).__name__)
|
|
183
|
+
await self._reply_poison(raw)
|
|
184
|
+
return
|
|
185
|
+
rid = envelope.request_id
|
|
186
|
+
owned: list[_Running] = []
|
|
187
|
+
|
|
188
|
+
def on_run() -> None:
|
|
189
|
+
owned.append(self.register(rid, broker, labels))
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
result = await execute_job(
|
|
193
|
+
envelope, self.deps, on_run=on_run, deliveries=_receive_count(labels)
|
|
194
|
+
)
|
|
195
|
+
if result.decision is ClaimDecision.DEFER:
|
|
196
|
+
await _release(broker, labels, self.defer_secs) # DS2: never delete
|
|
197
|
+
elif result.requeue_secs is not None: # DS4 no capacity; S48 model not configured
|
|
198
|
+
await _release(broker, labels, result.requeue_secs)
|
|
199
|
+
except InfraError as exc:
|
|
200
|
+
if owned: # S50: a claim-loss cancel here skipped the release
|
|
201
|
+
self.unregister(owned[0])
|
|
202
|
+
await self._infra_release(rid, exc, broker, labels, owned=bool(owned))
|
|
203
|
+
except QueueUnavailable: # DS8: reply cached on this pod, claim left running
|
|
204
|
+
logger.warning(
|
|
205
|
+
"result_not_stored", request_id=rid, model=envelope.model, lane=envelope.lane.value
|
|
206
|
+
)
|
|
207
|
+
await _release(broker, labels, self.defer_secs)
|
|
208
|
+
except asyncio.CancelledError:
|
|
209
|
+
entry = owned[0] if owned else None
|
|
210
|
+
await asyncio.shield(self._after_cancel(rid, entry, broker, labels))
|
|
211
|
+
if _claim_loss_only(entry):
|
|
212
|
+
return
|
|
213
|
+
raise
|
|
214
|
+
except Exception as exc: # a bug: release (maxReceiveCount bounds it) rather than ack
|
|
215
|
+
logger.exception(
|
|
216
|
+
"task_failed_unexpectedly", request_id=rid, error_type=type(exc).__name__
|
|
217
|
+
)
|
|
218
|
+
await _release(broker, labels, self.defer_secs)
|
|
219
|
+
finally:
|
|
220
|
+
if owned:
|
|
221
|
+
self.unregister(owned[0])
|
|
222
|
+
|
|
223
|
+
async def _infra_release(
|
|
224
|
+
self,
|
|
225
|
+
request_id: str,
|
|
226
|
+
exc: BaseException,
|
|
227
|
+
broker: HybridSQSBroker,
|
|
228
|
+
labels: Mapping[str, Any],
|
|
229
|
+
*,
|
|
230
|
+
owned: bool,
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Valkey failed: report it, release the message, then hand our claim back if held.
|
|
233
|
+
|
|
234
|
+
The message goes first (S50): the claim release can hang reconnecting to a dead Valkey,
|
|
235
|
+
and a task cancelled there before releasing was acked by taskiq, losing the call.
|
|
236
|
+
"""
|
|
237
|
+
cause = exc.__cause__ or exc
|
|
238
|
+
self._report_redis(cause)
|
|
239
|
+
logger.warning(
|
|
240
|
+
"valkey_error_task_released",
|
|
241
|
+
request_id=request_id,
|
|
242
|
+
error_type=type(cause).__name__,
|
|
243
|
+
attempted=getattr(exc, "attempted", False),
|
|
244
|
+
)
|
|
245
|
+
await _release(broker, labels, self.defer_secs)
|
|
246
|
+
claims = self.deps.claims
|
|
247
|
+
if owned and claims is not None:
|
|
248
|
+
with contextlib.suppress(Exception):
|
|
249
|
+
await claims.release(request_id, worker=self.deps.worker)
|
|
250
|
+
|
|
251
|
+
def _report_redis(self, exc: BaseException) -> None:
|
|
252
|
+
if self.failover is not None and isinstance(exc, RedisError):
|
|
253
|
+
try:
|
|
254
|
+
self.failover.report_redis_failure(exc)
|
|
255
|
+
except Exception: # the policy must never break the worker
|
|
256
|
+
logger.exception("failover_report_failed")
|
|
257
|
+
|
|
258
|
+
async def _after_cancel(
|
|
259
|
+
self,
|
|
260
|
+
request_id: str,
|
|
261
|
+
entry: _Running | None,
|
|
262
|
+
broker: HybridSQSBroker,
|
|
263
|
+
labels: Mapping[str, Any],
|
|
264
|
+
) -> None:
|
|
265
|
+
"""Finish a cancelled delivery without deleting a message another pod may still need."""
|
|
266
|
+
claims = self.deps.claims
|
|
267
|
+
reason = entry.stop_reason if entry is not None else None
|
|
268
|
+
if entry is None: # cancelled before the claim said RUN: the claim may not be ours
|
|
269
|
+
await _release(broker, labels, self.defer_secs)
|
|
270
|
+
elif reason == LOST: # DS6: SQS gave the message away; free our claim for the new owner
|
|
271
|
+
if claims is not None:
|
|
272
|
+
with contextlib.suppress(Exception):
|
|
273
|
+
await claims.release(request_id, worker=self.deps.worker)
|
|
274
|
+
elif reason == CLAIM_LOST: # another pod runs it now: keep the message, never ack it
|
|
275
|
+
logger.warning("claim_lost_task_stopped", request_id=request_id)
|
|
276
|
+
await _release(broker, labels, self.defer_secs)
|
|
277
|
+
else: # drain leftover (§7.4): claim `released`, message visible now
|
|
278
|
+
await self._hand_back(request_id, broker, labels)
|
|
279
|
+
|
|
280
|
+
async def _hand_back(
|
|
281
|
+
self, request_id: str, broker: HybridSQSBroker, labels: Mapping[str, Any]
|
|
282
|
+
) -> None:
|
|
283
|
+
"""Drain leftover: claim `released` and message visible now, so another pod starts it."""
|
|
284
|
+
claims = self.deps.claims
|
|
285
|
+
with contextlib.suppress(Exception):
|
|
286
|
+
if claims is not None:
|
|
287
|
+
await claims.release(request_id, worker=self.deps.worker)
|
|
288
|
+
await _release(broker, labels, 0)
|
|
289
|
+
|
|
290
|
+
async def _reply_poison(self, raw: Any) -> None:
|
|
291
|
+
"""Release a waiter on a malformed envelope that still names its request id."""
|
|
292
|
+
rid = raw.get("request_id") if isinstance(raw, dict) else None
|
|
293
|
+
if isinstance(rid, str) and self.deps.results is not None:
|
|
294
|
+
reply = LLMReply(
|
|
295
|
+
request_id=rid,
|
|
296
|
+
ok=False,
|
|
297
|
+
error={"type": "invalid_envelope", "message": "malformed", "retryable": False},
|
|
298
|
+
)
|
|
299
|
+
with contextlib.suppress(Exception):
|
|
300
|
+
await self.deps.results.put(reply, ttl_secs=self.deps.config.result_ttl_secs)
|
|
301
|
+
|
|
302
|
+
async def run_job(self, raw: Any, labels: Mapping[str, Any], broker: HybridSQSBroker) -> None:
|
|
303
|
+
"""Body of the `job` task: run an allow-listed app job once (claimed) by name.
|
|
304
|
+
|
|
305
|
+
A failing job is released with backoff; after `max_failures` it keeps being released
|
|
306
|
+
until SQS `maxReceiveCount` moves it to the DLQ (DS5). Valkey errors release it too.
|
|
307
|
+
"""
|
|
308
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("request_id"), str):
|
|
309
|
+
logger.error("poison_job_dropped")
|
|
310
|
+
return
|
|
311
|
+
job_id: str = raw["request_id"]
|
|
312
|
+
name = str(raw.get("job"))
|
|
313
|
+
function = self.jobs.get(name)
|
|
314
|
+
if function is None:
|
|
315
|
+
await _release(broker, labels, _UNSUPPORTED_RELEASE_SECS)
|
|
316
|
+
return
|
|
317
|
+
claims = self.deps.claims
|
|
318
|
+
deadline = float(raw.get("deadline_at") or time.time() + 86400)
|
|
319
|
+
try:
|
|
320
|
+
if claims is not None:
|
|
321
|
+
decision = await claims.claim(job_id, deadline_at=deadline, worker=self.deps.worker)
|
|
322
|
+
if decision is ClaimDecision.DEFER:
|
|
323
|
+
await _release(broker, labels, self.defer_secs)
|
|
324
|
+
return
|
|
325
|
+
if decision is not ClaimDecision.RUN:
|
|
326
|
+
return
|
|
327
|
+
if await self._failures_exhausted(job_id):
|
|
328
|
+
await self._to_dlq(job_id, name, broker, labels)
|
|
329
|
+
return
|
|
330
|
+
except RedisError as exc:
|
|
331
|
+
await self._infra_release(job_id, exc, broker, labels, owned=False)
|
|
332
|
+
return
|
|
333
|
+
entry = self.register(job_id, broker, labels)
|
|
334
|
+
try:
|
|
335
|
+
await function(dict(raw.get("payload") or {}))
|
|
336
|
+
except asyncio.CancelledError:
|
|
337
|
+
await asyncio.shield(self._after_cancel(job_id, entry, broker, labels))
|
|
338
|
+
if _claim_loss_only(entry):
|
|
339
|
+
return
|
|
340
|
+
raise
|
|
341
|
+
except Exception:
|
|
342
|
+
logger.exception("job_failed", job=name, request_id=job_id)
|
|
343
|
+
await self._job_failed(job_id, name, broker, labels)
|
|
344
|
+
return
|
|
345
|
+
finally:
|
|
346
|
+
self.unregister(entry)
|
|
347
|
+
if claims is not None: # the job ran; a failed CAS is logged, never re-runs it here
|
|
348
|
+
try:
|
|
349
|
+
await claims.complete(job_id, ClaimState.DONE, worker=self.deps.worker)
|
|
350
|
+
except Exception as exc:
|
|
351
|
+
logger.warning(
|
|
352
|
+
"claim_complete_failed", request_id=job_id, error_type=type(exc).__name__
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
async def _job_failed(
|
|
356
|
+
self, job_id: str, name: str, broker: HybridSQSBroker, labels: Mapping[str, Any]
|
|
357
|
+
) -> None:
|
|
358
|
+
"""Count a real failure (DS5) and release the message with backoff, or toward the DLQ."""
|
|
359
|
+
claims = self.deps.claims
|
|
360
|
+
if claims is None:
|
|
361
|
+
await _release(broker, labels, _UNSUPPORTED_RELEASE_SECS)
|
|
362
|
+
return
|
|
363
|
+
try:
|
|
364
|
+
failures = await claims.record_failure(job_id)
|
|
365
|
+
except RedisError as exc:
|
|
366
|
+
await self._infra_release(job_id, exc, broker, labels, owned=True)
|
|
367
|
+
return
|
|
368
|
+
if failures.exhausted:
|
|
369
|
+
await self._to_dlq(job_id, name, broker, labels)
|
|
370
|
+
return
|
|
371
|
+
with contextlib.suppress(Exception):
|
|
372
|
+
await claims.release(job_id, worker=self.deps.worker)
|
|
373
|
+
await _release(broker, labels, min(900, 30 * 2**failures.count))
|
|
374
|
+
|
|
375
|
+
async def _failures_exhausted(self, job_id: str) -> bool:
|
|
376
|
+
"""True when the claim record already counts `max_failures` real failures (DS5)."""
|
|
377
|
+
redis = self.deps.redis
|
|
378
|
+
if redis is None:
|
|
379
|
+
return False
|
|
380
|
+
raw = await redis.hget(self.deps.keys.job(job_id), "failures")
|
|
381
|
+
return raw is not None and int(raw) >= self.deps.config.max_failures
|
|
382
|
+
|
|
383
|
+
async def _to_dlq(
|
|
384
|
+
self, job_id: str, name: str, broker: HybridSQSBroker, labels: Mapping[str, Any]
|
|
385
|
+
) -> None:
|
|
386
|
+
"""DS5: never ack an exhausted job; release it until `maxReceiveCount` dead-letters it."""
|
|
387
|
+
logger.error("job_failures_exhausted", job=name, request_id=job_id)
|
|
388
|
+
claims = self.deps.claims
|
|
389
|
+
if claims is not None:
|
|
390
|
+
with contextlib.suppress(Exception):
|
|
391
|
+
await claims.release(job_id, worker=self.deps.worker)
|
|
392
|
+
await _release(broker, labels, _DLQ_RELEASE_SECS)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _label(labels: Mapping[str, Any], name: str) -> str | None:
|
|
396
|
+
value = labels.get(name)
|
|
397
|
+
return None if value is None else str(value)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _claim_loss_only(entry: _Running | None) -> bool:
|
|
401
|
+
"""True when the only cancel was our claim-loss stop: end quietly, not with a traceback.
|
|
402
|
+
|
|
403
|
+
The message is already released (a later taskiq ack is a no-op) and nothing is stored.
|
|
404
|
+
Shutdown or any other pending cancel keeps the count above 0, so it is re-raised.
|
|
405
|
+
"""
|
|
406
|
+
if entry is None or entry.stop_reason != CLAIM_LOST:
|
|
407
|
+
return False
|
|
408
|
+
task = asyncio.current_task()
|
|
409
|
+
return task is not None and task.uncancel() == 0
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _receive_count(labels: Mapping[str, Any]) -> int:
|
|
413
|
+
"""SQS `ApproximateReceiveCount` of this delivery (1 when absent or unparsable)."""
|
|
414
|
+
try:
|
|
415
|
+
return max(1, int(labels.get(SQS_RECEIVE_COUNT_LABEL) or 1))
|
|
416
|
+
except (TypeError, ValueError):
|
|
417
|
+
return 1
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
async def _release(broker: HybridSQSBroker, labels: Mapping[str, Any], secs: int) -> None:
|
|
421
|
+
"""Make the current message visible again after `secs` instead of acking it."""
|
|
422
|
+
handle = _label(labels, SQS_RECEIPT_HANDLE_LABEL)
|
|
423
|
+
if handle is not None:
|
|
424
|
+
await broker.release(handle, visibility_secs=secs, reason="task")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def job_filter(runtime: WorkerRuntime) -> Callable[[Any], bool]:
|
|
428
|
+
"""Broker message filter: job messages whose name this pod doesn't know are released."""
|
|
429
|
+
|
|
430
|
+
def accept(message: Any) -> bool:
|
|
431
|
+
if message.task_name != TASK_JOB:
|
|
432
|
+
return True
|
|
433
|
+
args = [*message.args, *message.kwargs.values()]
|
|
434
|
+
return any(isinstance(a, dict) and a.get("job") in runtime.jobs for a in args)
|
|
435
|
+
|
|
436
|
+
return accept
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def register_tasks(broker: HybridSQSBroker, runtime: WorkerRuntime) -> None:
|
|
440
|
+
"""Register the three stable task names on `broker`, bound to `runtime`."""
|
|
441
|
+
|
|
442
|
+
async def call(envelope: dict[str, Any], context: Context = _CONTEXT) -> None:
|
|
443
|
+
await runtime.run_llm(envelope, context.message.labels, broker)
|
|
444
|
+
|
|
445
|
+
async def embed(envelope: dict[str, Any], context: Context = _CONTEXT) -> None:
|
|
446
|
+
await runtime.run_llm(envelope, context.message.labels, broker)
|
|
447
|
+
|
|
448
|
+
async def job(payload: dict[str, Any], context: Context = _CONTEXT) -> None:
|
|
449
|
+
await runtime.run_job(payload, context.message.labels, broker)
|
|
450
|
+
|
|
451
|
+
broker.register_task(call, task_name=TASK_CALL)
|
|
452
|
+
broker.register_task(embed, task_name=TASK_EMBED)
|
|
453
|
+
broker.register_task(job, task_name=TASK_JOB)
|