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,937 @@
|
|
|
1
|
+
"""`HybridSQSBroker`: a taskiq broker on Amazon SQS with priority, capacity and batching.
|
|
2
|
+
|
|
3
|
+
Feature parity with taskiq-aio-sqs and taskiq-sqs (multi-queue routing, FIFO / fair
|
|
4
|
+
queues, delay, expiry, batched send, heartbeat, metadata, declare) plus strict priority
|
|
5
|
+
across queues, capacity-aware real long polls, receive gates, batched ack / heartbeat and
|
|
6
|
+
release on shutdown (see docs/DESIGN.md). Both upstream projects are MIT; this is our own code.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextlib
|
|
13
|
+
import functools
|
|
14
|
+
import math
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterable, Mapping, Sequence
|
|
17
|
+
from typing import TYPE_CHECKING, Any, TypedDict
|
|
18
|
+
|
|
19
|
+
from aiobotocore.config import AioConfig
|
|
20
|
+
from aiobotocore.session import AioSession, get_session
|
|
21
|
+
from taskiq import AckableMessage, AsyncBroker, BrokerMessage, TaskiqMessage
|
|
22
|
+
from taskiq.formatters.proxy_formatter import ProxyFormatter
|
|
23
|
+
|
|
24
|
+
from taskiq_llm_queue._logging import get_logger
|
|
25
|
+
from taskiq_llm_queue.broker.acker import Acker, VisibilityResult
|
|
26
|
+
from taskiq_llm_queue.broker.batching import MicroBatcher, message_bytes
|
|
27
|
+
from taskiq_llm_queue.broker.delivery import Delivery
|
|
28
|
+
from taskiq_llm_queue.broker.errors import (
|
|
29
|
+
BrokerConfigError,
|
|
30
|
+
ErrorFamily,
|
|
31
|
+
InvalidLabelError,
|
|
32
|
+
MessageTooLargeError,
|
|
33
|
+
SQSSendError,
|
|
34
|
+
backoff_delay,
|
|
35
|
+
classify_code,
|
|
36
|
+
classify_error,
|
|
37
|
+
error_code,
|
|
38
|
+
)
|
|
39
|
+
from taskiq_llm_queue.broker.events import BrokerHooks, safe_call
|
|
40
|
+
from taskiq_llm_queue.broker.filters import MessageFilter, envelope_supported
|
|
41
|
+
from taskiq_llm_queue.broker.heartbeat import HeartbeatManager
|
|
42
|
+
from taskiq_llm_queue.broker.queue import (
|
|
43
|
+
MAX_DELAY_SECS,
|
|
44
|
+
MAX_FIFO_ID_CHARS,
|
|
45
|
+
MAX_MESSAGE_BYTES,
|
|
46
|
+
MAX_VISIBILITY_SECS,
|
|
47
|
+
Heartbeat,
|
|
48
|
+
SQSQueue,
|
|
49
|
+
)
|
|
50
|
+
from taskiq_llm_queue.broker.scheduler import SlotScheduler
|
|
51
|
+
from taskiq_llm_queue.errors import QueueUnavailable
|
|
52
|
+
|
|
53
|
+
if TYPE_CHECKING:
|
|
54
|
+
from types_aiobotocore_sqs.client import SQSClient
|
|
55
|
+
from types_aiobotocore_sqs.type_defs import MessageTypeDef
|
|
56
|
+
|
|
57
|
+
logger = get_logger(__name__)
|
|
58
|
+
|
|
59
|
+
QUEUE_LABEL = "queue_name"
|
|
60
|
+
DELAY_LABEL = "delay"
|
|
61
|
+
GROUP_ID_LABEL = "group_id"
|
|
62
|
+
DEDUPLICATION_ID_LABEL = "deduplication_id"
|
|
63
|
+
EXPIRY_LABEL = "expiry"
|
|
64
|
+
SKIP_BATCHING_LABEL = "skip_batching"
|
|
65
|
+
EXPIRY_ATTRIBUTE = "expiry"
|
|
66
|
+
SQS_MESSAGE_ID_LABEL = "sqs_message_id"
|
|
67
|
+
SQS_RECEIPT_HANDLE_LABEL = "sqs_receipt_handle"
|
|
68
|
+
SQS_QUEUE_LABEL = "sqs_queue_name"
|
|
69
|
+
SQS_RECEIVE_COUNT_LABEL = "sqs_approximate_receive_count"
|
|
70
|
+
DEFAULT_HEARTBEAT = Heartbeat()
|
|
71
|
+
|
|
72
|
+
ReceiveBudget = Callable[[SQSQueue], int | Awaitable[int]]
|
|
73
|
+
_SendItem = tuple[dict[str, Any], int]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _ClientKwargs(TypedDict):
|
|
77
|
+
endpoint_url: str | None
|
|
78
|
+
region_name: str | None
|
|
79
|
+
aws_access_key_id: str | None
|
|
80
|
+
aws_secret_access_key: str | None
|
|
81
|
+
aws_session_token: str | None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class HybridSQSBroker(AsyncBroker):
|
|
85
|
+
"""taskiq broker over one or more SQS queues with strict priority (0 = highest).
|
|
86
|
+
|
|
87
|
+
Capacity is `max_inflight` delivered-not-acked messages across all queues; pollers
|
|
88
|
+
receive only into free slots (`MaxNumberOfMessages = min(10, free)`) with real long
|
|
89
|
+
polls. `receive_budget(queue)` can cap a receive further (e.g. limiter headroom, DS4).
|
|
90
|
+
Unknown tasks and newer envelopes are released, not acked (DS9).
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
queues: Sequence[SQSQueue],
|
|
96
|
+
*,
|
|
97
|
+
default_queue: str | None = None,
|
|
98
|
+
endpoint_url: str | None = None,
|
|
99
|
+
region_name: str | None = None,
|
|
100
|
+
aws_access_key_id: str | None = None,
|
|
101
|
+
aws_secret_access_key: str | None = None,
|
|
102
|
+
aws_session_token: str | None = None,
|
|
103
|
+
client_config: AioConfig | None = None,
|
|
104
|
+
session: AioSession | None = None,
|
|
105
|
+
max_inflight: int = 10,
|
|
106
|
+
reserve_for_priority0: int = 0,
|
|
107
|
+
min_slots_for_lower: int = 0,
|
|
108
|
+
receive_budget: ReceiveBudget | None = None,
|
|
109
|
+
heartbeat: Heartbeat | None = DEFAULT_HEARTBEAT,
|
|
110
|
+
ack_flush_secs: float = 0.5,
|
|
111
|
+
use_task_id_for_deduplication: bool = False,
|
|
112
|
+
expose_message_metadata: bool = False,
|
|
113
|
+
skip_batch_tasks: Iterable[str] = (),
|
|
114
|
+
unknown_release_secs: int = 30,
|
|
115
|
+
message_filter: MessageFilter | None = None,
|
|
116
|
+
hooks: BrokerHooks | None = None,
|
|
117
|
+
on_lost: Callable[[Delivery], None] | None = None,
|
|
118
|
+
release_running_on_shutdown: bool = True,
|
|
119
|
+
stop_grace_secs: float | None = None,
|
|
120
|
+
backoff_base_secs: float = 0.5,
|
|
121
|
+
backoff_cap_secs: float = 20.0,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Validate everything up front; no client or task is created until `startup()`."""
|
|
124
|
+
super().__init__()
|
|
125
|
+
errors = []
|
|
126
|
+
if not queues:
|
|
127
|
+
errors.append("at least one SQSQueue is required")
|
|
128
|
+
names = [q.name for q in queues]
|
|
129
|
+
if len(set(names)) != len(names):
|
|
130
|
+
errors.append(f"queue names must be unique: {names}")
|
|
131
|
+
if default_queue is not None and default_queue not in names:
|
|
132
|
+
errors.append(f"default_queue {default_queue!r} is not one of {names}")
|
|
133
|
+
if not 0 <= unknown_release_secs <= MAX_VISIBILITY_SECS:
|
|
134
|
+
errors.append(f"unknown_release_secs must be 0..{MAX_VISIBILITY_SECS}")
|
|
135
|
+
if not 0 < ack_flush_secs <= 1:
|
|
136
|
+
errors.append("ack_flush_secs must be in (0, 1] (DS7)")
|
|
137
|
+
if stop_grace_secs is not None and stop_grace_secs < 0:
|
|
138
|
+
errors.append("stop_grace_secs must be >= 0")
|
|
139
|
+
if errors:
|
|
140
|
+
raise BrokerConfigError("; ".join(errors))
|
|
141
|
+
self._queues = {q.name: q for q in queues}
|
|
142
|
+
self._default_queue = default_queue or names[0]
|
|
143
|
+
self._client_kwargs: _ClientKwargs = {
|
|
144
|
+
"endpoint_url": endpoint_url,
|
|
145
|
+
"region_name": region_name,
|
|
146
|
+
"aws_access_key_id": aws_access_key_id,
|
|
147
|
+
"aws_secret_access_key": aws_secret_access_key,
|
|
148
|
+
"aws_session_token": aws_session_token,
|
|
149
|
+
}
|
|
150
|
+
self._client_config = client_config
|
|
151
|
+
self._session = session
|
|
152
|
+
self._scheduler = SlotScheduler(
|
|
153
|
+
max_inflight,
|
|
154
|
+
reserve_for_priority0=reserve_for_priority0,
|
|
155
|
+
min_slots_for_lower=min_slots_for_lower,
|
|
156
|
+
)
|
|
157
|
+
self._receive_budget = receive_budget
|
|
158
|
+
self._heartbeat_config = heartbeat
|
|
159
|
+
self._ack_flush_secs = ack_flush_secs
|
|
160
|
+
self._use_task_id_for_dedup = use_task_id_for_deduplication
|
|
161
|
+
self._expose_metadata = expose_message_metadata
|
|
162
|
+
self._skip_batch_tasks = frozenset(skip_batch_tasks)
|
|
163
|
+
self._unknown_release_secs = unknown_release_secs
|
|
164
|
+
self._message_filter = message_filter
|
|
165
|
+
self.hooks = hooks or BrokerHooks()
|
|
166
|
+
self._on_lost = on_lost
|
|
167
|
+
self._release_running_on_shutdown = release_running_on_shutdown
|
|
168
|
+
self.formatter = DeliveryFormatter(self)
|
|
169
|
+
# A long poll cancelled client-side can still complete server-side and take a message
|
|
170
|
+
# we release right after (seen on ElasticMQ), so by default polls finish naturally.
|
|
171
|
+
self._stop_grace_secs = (
|
|
172
|
+
max((q.wait_time_secs for q in queues), default=0) + 2.0
|
|
173
|
+
if stop_grace_secs is None
|
|
174
|
+
else stop_grace_secs
|
|
175
|
+
)
|
|
176
|
+
self._backoff_base_secs = backoff_base_secs
|
|
177
|
+
self._backoff_cap_secs = backoff_cap_secs
|
|
178
|
+
|
|
179
|
+
self._stack: contextlib.AsyncExitStack | None = None
|
|
180
|
+
self._client: SQSClient | None = None
|
|
181
|
+
self._urls: dict[str, str] = {}
|
|
182
|
+
self._visibility: dict[str, int] = {}
|
|
183
|
+
self._acker: Acker | None = None
|
|
184
|
+
self._heartbeat: HeartbeatManager | None = None
|
|
185
|
+
self._senders: dict[str, MicroBatcher[_SendItem, str]] = {}
|
|
186
|
+
self._active: dict[str, Delivery] = {}
|
|
187
|
+
self._by_task: dict[str, Delivery] = {}
|
|
188
|
+
self._after_ack_tasks: set[asyncio.Task[None]] = set()
|
|
189
|
+
self._buffer: asyncio.Queue[Delivery] | None = None
|
|
190
|
+
self._pollers: dict[str, asyncio.Task[None]] = {}
|
|
191
|
+
self._in_receive: set[str] = set()
|
|
192
|
+
self._receiving = asyncio.Event()
|
|
193
|
+
self._receiving.set()
|
|
194
|
+
self._stopping = False
|
|
195
|
+
|
|
196
|
+
# ------------------------------------------------------------------ lifecycle
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def queues(self) -> dict[str, SQSQueue]:
|
|
200
|
+
"""Configured queues by routing name."""
|
|
201
|
+
return dict(self._queues)
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def scheduler(self) -> SlotScheduler:
|
|
205
|
+
"""Slot accounting (read `held`, `budget()`; `resize()` changes capacity live)."""
|
|
206
|
+
return self._scheduler
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def is_started(self) -> bool:
|
|
210
|
+
"""True between `startup()` and `shutdown()`."""
|
|
211
|
+
return self._client is not None
|
|
212
|
+
|
|
213
|
+
async def startup(self) -> None:
|
|
214
|
+
"""Create the SQS client, resolve (or declare) queues, start batchers; idempotent."""
|
|
215
|
+
if self._client is not None:
|
|
216
|
+
return
|
|
217
|
+
await super().startup()
|
|
218
|
+
stack = contextlib.AsyncExitStack()
|
|
219
|
+
# One retry layer, not two (DS16): botocore makes at most one quick retry (standard
|
|
220
|
+
# mode, no client-side rate limiter that would also delay heartbeats), and the Acker /
|
|
221
|
+
# poller backoff, which counts errors in metrics, owns throttle and 5xx retries.
|
|
222
|
+
# Adaptive x 5 under the Acker's 3 meant ~15 calls per ack while visibility lapsed.
|
|
223
|
+
base = AioConfig(
|
|
224
|
+
retries={"mode": "standard", "total_max_attempts": 2},
|
|
225
|
+
max_pool_connections=4 * len(self._queues) + 10,
|
|
226
|
+
read_timeout=60,
|
|
227
|
+
)
|
|
228
|
+
config = base.merge(self._client_config) if self._client_config else base
|
|
229
|
+
session = self._session or get_session()
|
|
230
|
+
try:
|
|
231
|
+
client: SQSClient = await stack.enter_async_context(
|
|
232
|
+
session.create_client("sqs", config=config, **self._client_kwargs)
|
|
233
|
+
)
|
|
234
|
+
for queue in self._queues.values():
|
|
235
|
+
url = await self._resolve_url(client, queue)
|
|
236
|
+
self._urls[queue.name] = url
|
|
237
|
+
self._visibility[queue.name] = await self._resolve_visibility(client, queue, url)
|
|
238
|
+
except BaseException:
|
|
239
|
+
await stack.aclose()
|
|
240
|
+
raise
|
|
241
|
+
self._stack = stack
|
|
242
|
+
self._client = client
|
|
243
|
+
self._stopping = False
|
|
244
|
+
self._acker = Acker(
|
|
245
|
+
client,
|
|
246
|
+
hooks=self.hooks,
|
|
247
|
+
ack_flush_secs=self._ack_flush_secs,
|
|
248
|
+
visibility_flush_secs=(
|
|
249
|
+
self._heartbeat_config.flush_secs if self._heartbeat_config else 0.5
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
if self._heartbeat_config is not None:
|
|
253
|
+
self._heartbeat = HeartbeatManager(
|
|
254
|
+
self._acker, self._heartbeat_config, hooks=self.hooks, on_lost=self._on_lost
|
|
255
|
+
)
|
|
256
|
+
for queue in self._queues.values():
|
|
257
|
+
if queue.batching is not None:
|
|
258
|
+
self._senders[queue.name] = MicroBatcher[_SendItem, str](
|
|
259
|
+
functools.partial(self._send_batch, queue, self._urls[queue.name]),
|
|
260
|
+
max_items=queue.batching.size,
|
|
261
|
+
max_bytes=queue.batching.max_bytes,
|
|
262
|
+
size_of=lambda item: item[1],
|
|
263
|
+
max_delay_secs=queue.batching.timeout_secs,
|
|
264
|
+
name=f"send:{queue.name}",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
async def shutdown(self) -> None:
|
|
268
|
+
"""Stop pollers, release unstarted (and leftover running) messages, flush, close (A5)."""
|
|
269
|
+
if self._client is None:
|
|
270
|
+
await super().shutdown()
|
|
271
|
+
return
|
|
272
|
+
self._stopping = True
|
|
273
|
+
self._receiving.set()
|
|
274
|
+
await self._stop_pollers()
|
|
275
|
+
await self._release_where(lambda d: not d.started, "shutdown")
|
|
276
|
+
if self._release_running_on_shutdown:
|
|
277
|
+
await self._release_where(lambda d: True, "shutdown_running")
|
|
278
|
+
if self._heartbeat is not None:
|
|
279
|
+
await self._heartbeat.stop()
|
|
280
|
+
await asyncio.gather(*(s.close() for s in self._senders.values()))
|
|
281
|
+
if self._acker is not None:
|
|
282
|
+
await self._acker.close()
|
|
283
|
+
if self._after_ack_tasks:
|
|
284
|
+
await asyncio.gather(*self._after_ack_tasks, return_exceptions=True)
|
|
285
|
+
if self._stack is not None:
|
|
286
|
+
await self._stack.aclose()
|
|
287
|
+
self._senders.clear()
|
|
288
|
+
self._client = None
|
|
289
|
+
self._stack = None
|
|
290
|
+
self._acker = None
|
|
291
|
+
self._heartbeat = None
|
|
292
|
+
await super().shutdown()
|
|
293
|
+
|
|
294
|
+
async def pause_receiving(self, *, release_buffered: bool = True) -> None:
|
|
295
|
+
"""Stop taking new messages (for a /drain endpoint); running tasks continue.
|
|
296
|
+
|
|
297
|
+
Polls already in flight finish; whatever they return, and any received-not-started
|
|
298
|
+
message when `release_buffered`, is made visible again at once.
|
|
299
|
+
"""
|
|
300
|
+
self._receiving.clear()
|
|
301
|
+
if release_buffered:
|
|
302
|
+
await self._release_where(lambda d: not d.started, "drain")
|
|
303
|
+
|
|
304
|
+
def resume_receiving(self) -> None:
|
|
305
|
+
"""Undo `pause_receiving`."""
|
|
306
|
+
self._receiving.set()
|
|
307
|
+
|
|
308
|
+
def delivery(self, message_id: str) -> Delivery | None:
|
|
309
|
+
"""The active delivery for an SQS message id (e.g. to await `lost`), if any."""
|
|
310
|
+
return next((d for d in self._active.values() if d.message_id == message_id), None)
|
|
311
|
+
|
|
312
|
+
def delivery_for_task(self, task_id: str) -> Delivery | None:
|
|
313
|
+
"""The active (not yet acked or released) delivery carrying taskiq task `task_id`."""
|
|
314
|
+
return self._by_task.get(task_id)
|
|
315
|
+
|
|
316
|
+
async def discard(self, target: Delivery | str, *, reason: str = "poison") -> bool:
|
|
317
|
+
"""Ack (delete) a received message without running it, e.g. one that can never run.
|
|
318
|
+
|
|
319
|
+
`target` is a `Delivery`, an SQS message id or a receipt handle. Returns False if it
|
|
320
|
+
was already acked or released.
|
|
321
|
+
"""
|
|
322
|
+
delivery = target if isinstance(target, Delivery) else self._find(target)
|
|
323
|
+
if delivery is None or delivery.done:
|
|
324
|
+
return False
|
|
325
|
+
logger.warning(
|
|
326
|
+
"sqs_message_discarded",
|
|
327
|
+
queue=delivery.queue.name,
|
|
328
|
+
message_id=delivery.message_id,
|
|
329
|
+
reason=reason,
|
|
330
|
+
)
|
|
331
|
+
await self._ack(delivery)
|
|
332
|
+
return True
|
|
333
|
+
|
|
334
|
+
async def release(
|
|
335
|
+
self, target: Delivery | str, *, visibility_secs: int = 0, reason: str = "manual"
|
|
336
|
+
) -> bool:
|
|
337
|
+
"""Hand a received message back to SQS now (visibility 0 by default), without acking.
|
|
338
|
+
|
|
339
|
+
`target` is a `Delivery`, an SQS message id or a receipt handle. A later taskiq ack of
|
|
340
|
+
the same message is a no-op. Returns False if it was already acked or released.
|
|
341
|
+
"""
|
|
342
|
+
if not 0 <= visibility_secs <= MAX_VISIBILITY_SECS:
|
|
343
|
+
raise BrokerConfigError(f"visibility_secs must be 0..{MAX_VISIBILITY_SECS}")
|
|
344
|
+
delivery = target if isinstance(target, Delivery) else self._find(target)
|
|
345
|
+
if delivery is None or not self._finish(delivery, "released"):
|
|
346
|
+
return False
|
|
347
|
+
client, acker = self._require_client(), self._require_acker()
|
|
348
|
+
try:
|
|
349
|
+
await acker.call(
|
|
350
|
+
delivery.queue.name,
|
|
351
|
+
"release",
|
|
352
|
+
lambda: client.change_message_visibility(
|
|
353
|
+
QueueUrl=delivery.queue_url,
|
|
354
|
+
ReceiptHandle=delivery.receipt_handle,
|
|
355
|
+
VisibilityTimeout=visibility_secs,
|
|
356
|
+
),
|
|
357
|
+
raise_final=True,
|
|
358
|
+
)
|
|
359
|
+
except Exception as exc:
|
|
360
|
+
if classify_error(exc) is not ErrorFamily.INVALID_HANDLE:
|
|
361
|
+
logger.warning("sqs_release_failed", queue=delivery.queue.name, error=str(exc))
|
|
362
|
+
return False
|
|
363
|
+
safe_call(self.hooks.on_release, delivery.queue.name, 1, reason)
|
|
364
|
+
return True
|
|
365
|
+
|
|
366
|
+
# ------------------------------------------------------------------ receive side
|
|
367
|
+
|
|
368
|
+
async def listen(self) -> AsyncGenerator[bytes | AckableMessage, None]:
|
|
369
|
+
"""Run one poller per queue and yield messages as taskiq `AckableMessage`s.
|
|
370
|
+
|
|
371
|
+
Closing the generator (Receiver stop) stops the pollers and releases every message
|
|
372
|
+
that was received but not yet handed to the Receiver.
|
|
373
|
+
"""
|
|
374
|
+
self._require_client()
|
|
375
|
+
self._buffer = asyncio.Queue()
|
|
376
|
+
self._receiving.set()
|
|
377
|
+
self._pollers = {
|
|
378
|
+
name: asyncio.create_task(self._poll(q), name=f"llmq-poll-{name}")
|
|
379
|
+
for name, q in self._queues.items()
|
|
380
|
+
}
|
|
381
|
+
if self._heartbeat is not None:
|
|
382
|
+
self._heartbeat.start()
|
|
383
|
+
try:
|
|
384
|
+
while True:
|
|
385
|
+
delivery = await self._buffer.get()
|
|
386
|
+
if delivery.done:
|
|
387
|
+
continue
|
|
388
|
+
delivery.started = True
|
|
389
|
+
data = _Body(delivery.body.encode("utf-8"))
|
|
390
|
+
data.parsed, delivery.message = delivery.message, None
|
|
391
|
+
yield AckableMessage(data=data, ack=functools.partial(self._ack, delivery))
|
|
392
|
+
finally:
|
|
393
|
+
with contextlib.suppress(Exception):
|
|
394
|
+
await self._stop_pollers()
|
|
395
|
+
if self._client is not None:
|
|
396
|
+
await self._release_where(lambda d: not d.started, "stopped")
|
|
397
|
+
|
|
398
|
+
async def _poll(self, queue: SQSQueue) -> None:
|
|
399
|
+
"""Receive loop for one queue; survives any error except cancellation (logs, backs off)."""
|
|
400
|
+
url = self._urls[queue.name]
|
|
401
|
+
state = _PollState()
|
|
402
|
+
while not self._stopping:
|
|
403
|
+
try:
|
|
404
|
+
await self._poll_once(queue, url, state)
|
|
405
|
+
except asyncio.CancelledError:
|
|
406
|
+
raise
|
|
407
|
+
except Exception as exc: # a dead poller would silently stop this queue
|
|
408
|
+
safe_call(self.hooks.on_error, queue.name, "poll", classify_error(exc).value)
|
|
409
|
+
delay = backoff_delay(
|
|
410
|
+
state.attempt,
|
|
411
|
+
base_secs=self._backoff_base_secs,
|
|
412
|
+
cap_secs=self._backoff_cap_secs,
|
|
413
|
+
)
|
|
414
|
+
logger.error(
|
|
415
|
+
"sqs_poller_error",
|
|
416
|
+
queue=queue.name,
|
|
417
|
+
error_type=type(exc).__name__,
|
|
418
|
+
attempt=state.attempt,
|
|
419
|
+
retry_in_secs=round(delay, 2),
|
|
420
|
+
exc_info=exc,
|
|
421
|
+
)
|
|
422
|
+
state.attempt += 1
|
|
423
|
+
await asyncio.sleep(delay)
|
|
424
|
+
|
|
425
|
+
async def _poll_once(self, queue: SQSQueue, url: str, state: _PollState) -> None:
|
|
426
|
+
"""One iteration: gate, budget, slots, long poll, accept."""
|
|
427
|
+
if not self._receiving.is_set():
|
|
428
|
+
await self._receiving.wait()
|
|
429
|
+
return
|
|
430
|
+
if queue.gate is not None and not queue.gate.is_open():
|
|
431
|
+
await queue.gate.wait_open()
|
|
432
|
+
return
|
|
433
|
+
# After an empty poll, ask for one message: an idle long poll then holds one slot,
|
|
434
|
+
# not ten, and still returns on the first arrival (A2).
|
|
435
|
+
want = 1 if state.idle else queue.max_messages
|
|
436
|
+
if self._receive_budget is not None:
|
|
437
|
+
headroom = self._receive_budget(queue)
|
|
438
|
+
if not isinstance(headroom, int):
|
|
439
|
+
headroom = await headroom
|
|
440
|
+
# DS4: messages received but not yet handed to a task will take limiter capacity
|
|
441
|
+
# soon, though the limiter does not count them yet.
|
|
442
|
+
headroom -= self._buffer.qsize() if self._buffer is not None else 0
|
|
443
|
+
if headroom <= 0:
|
|
444
|
+
await asyncio.sleep(0.25)
|
|
445
|
+
return
|
|
446
|
+
want = min(want, headroom)
|
|
447
|
+
reserved = await self._scheduler.acquire(queue.priority, want)
|
|
448
|
+
gate_closed = queue.gate is not None and not queue.gate.is_open()
|
|
449
|
+
if self._stopping or not self._receiving.is_set() or gate_closed:
|
|
450
|
+
self._scheduler.release(queue.priority, reserved)
|
|
451
|
+
return
|
|
452
|
+
self._in_receive.add(queue.name)
|
|
453
|
+
try:
|
|
454
|
+
resp = await self._require_client().receive_message(
|
|
455
|
+
QueueUrl=url,
|
|
456
|
+
MaxNumberOfMessages=reserved,
|
|
457
|
+
WaitTimeSeconds=queue.wait_time_secs,
|
|
458
|
+
MessageSystemAttributeNames=["ApproximateReceiveCount", "SentTimestamp"],
|
|
459
|
+
MessageAttributeNames=["All"],
|
|
460
|
+
)
|
|
461
|
+
except asyncio.CancelledError:
|
|
462
|
+
self._scheduler.release(queue.priority, reserved)
|
|
463
|
+
raise
|
|
464
|
+
except Exception as exc:
|
|
465
|
+
self._scheduler.release(queue.priority, reserved)
|
|
466
|
+
family = classify_error(exc)
|
|
467
|
+
safe_call(self.hooks.on_error, queue.name, "receive", family.value)
|
|
468
|
+
delay = backoff_delay(
|
|
469
|
+
state.attempt, base_secs=self._backoff_base_secs, cap_secs=self._backoff_cap_secs
|
|
470
|
+
)
|
|
471
|
+
logger.warning(
|
|
472
|
+
"sqs_receive_failed",
|
|
473
|
+
queue=queue.name,
|
|
474
|
+
error_family=family.value,
|
|
475
|
+
error_code=error_code(exc),
|
|
476
|
+
attempt=state.attempt,
|
|
477
|
+
retry_in_secs=round(delay, 2),
|
|
478
|
+
)
|
|
479
|
+
state.attempt += 1
|
|
480
|
+
await asyncio.sleep(delay)
|
|
481
|
+
return
|
|
482
|
+
finally:
|
|
483
|
+
self._in_receive.discard(queue.name)
|
|
484
|
+
state.attempt = 0
|
|
485
|
+
messages = resp.get("Messages", [])
|
|
486
|
+
state.idle = not messages
|
|
487
|
+
self._scheduler.release(queue.priority, reserved - len(messages))
|
|
488
|
+
safe_call(self.hooks.on_receive, queue.name, len(messages))
|
|
489
|
+
for message in messages:
|
|
490
|
+
try:
|
|
491
|
+
self._accept(queue, url, message)
|
|
492
|
+
except Exception as exc:
|
|
493
|
+
self._reject(queue, url, message, exc)
|
|
494
|
+
|
|
495
|
+
def _reject(self, queue: SQSQueue, url: str, message: MessageTypeDef, exc: Exception) -> None:
|
|
496
|
+
"""`_accept` failed: free the message's slot and make it visible again, never leak it."""
|
|
497
|
+
handle = message.get("ReceiptHandle", "")
|
|
498
|
+
logger.error(
|
|
499
|
+
"sqs_accept_failed",
|
|
500
|
+
queue=queue.name,
|
|
501
|
+
message_id=message.get("MessageId", ""),
|
|
502
|
+
error_type=type(exc).__name__,
|
|
503
|
+
exc_info=exc,
|
|
504
|
+
)
|
|
505
|
+
delivery = self._active.get(handle)
|
|
506
|
+
if delivery is not None:
|
|
507
|
+
if not self._finish(delivery, "released"):
|
|
508
|
+
return
|
|
509
|
+
else:
|
|
510
|
+
self._scheduler.release(queue.priority, 1)
|
|
511
|
+
if handle and self._acker is not None:
|
|
512
|
+
self._acker.change_visibility(queue.name, url, handle, 0)
|
|
513
|
+
|
|
514
|
+
def _accept(self, queue: SQSQueue, url: str, message: MessageTypeDef) -> None:
|
|
515
|
+
"""Turn one received message into a buffered delivery, or expire / release it."""
|
|
516
|
+
attributes: Mapping[Any, str] = message.get("Attributes", {})
|
|
517
|
+
sent_ms = attributes.get("SentTimestamp")
|
|
518
|
+
sent_at = int(sent_ms) / 1000 if sent_ms else None
|
|
519
|
+
delivery = Delivery(
|
|
520
|
+
queue=queue,
|
|
521
|
+
queue_url=url,
|
|
522
|
+
message_id=message.get("MessageId", ""),
|
|
523
|
+
receipt_handle=message.get("ReceiptHandle", ""),
|
|
524
|
+
body=message.get("Body", ""),
|
|
525
|
+
receive_count=int(attributes.get("ApproximateReceiveCount", "1")),
|
|
526
|
+
received_at=asyncio.get_running_loop().time(),
|
|
527
|
+
visibility_secs=self._visibility[queue.name],
|
|
528
|
+
sent_at=sent_at,
|
|
529
|
+
)
|
|
530
|
+
self._active[delivery.receipt_handle] = delivery
|
|
531
|
+
if sent_at is not None:
|
|
532
|
+
safe_call(self.hooks.on_queue_wait, queue.name, max(0.0, time.time() - sent_at))
|
|
533
|
+
expiry_attr = message.get("MessageAttributes", {}).get(EXPIRY_ATTRIBUTE)
|
|
534
|
+
expiry = expiry_attr.get("StringValue") if expiry_attr is not None else None
|
|
535
|
+
delivery.expires_at = _epoch(expiry) if expiry is not None else None
|
|
536
|
+
if delivery.expires_at is not None and delivery.expires_at < time.time():
|
|
537
|
+
self._finish(delivery, "acked")
|
|
538
|
+
self._require_acker().delete(queue.name, url, delivery.receipt_handle)
|
|
539
|
+
safe_call(self.hooks.on_expired, queue.name, 1)
|
|
540
|
+
return
|
|
541
|
+
reason, body = self._screen(delivery)
|
|
542
|
+
if reason is not None:
|
|
543
|
+
logger.warning(
|
|
544
|
+
"sqs_message_released",
|
|
545
|
+
message_id=delivery.message_id,
|
|
546
|
+
queue=queue.name,
|
|
547
|
+
release_secs=self._unknown_release_secs,
|
|
548
|
+
reason=reason,
|
|
549
|
+
)
|
|
550
|
+
self._finish(delivery, "released")
|
|
551
|
+
self._require_acker().change_visibility(
|
|
552
|
+
queue.name, url, delivery.receipt_handle, self._unknown_release_secs
|
|
553
|
+
)
|
|
554
|
+
safe_call(self.hooks.on_release, queue.name, 1, reason)
|
|
555
|
+
return
|
|
556
|
+
delivery.body = body
|
|
557
|
+
if self._stopping or not self._receiving.is_set() or self._buffer is None:
|
|
558
|
+
self._finish(delivery, "released")
|
|
559
|
+
self._require_acker().change_visibility(queue.name, url, delivery.receipt_handle, 0)
|
|
560
|
+
safe_call(self.hooks.on_release, queue.name, 1, "drain")
|
|
561
|
+
return
|
|
562
|
+
if delivery.task_id is not None:
|
|
563
|
+
self._by_task[delivery.task_id] = delivery
|
|
564
|
+
if self._heartbeat is not None:
|
|
565
|
+
self._heartbeat.track(delivery)
|
|
566
|
+
self._buffer.put_nowait(delivery)
|
|
567
|
+
|
|
568
|
+
def _screen(self, delivery: Delivery) -> tuple[str | None, str]:
|
|
569
|
+
"""DS9 pre-filter: (reject reason or None, body to yield, with metadata if enabled).
|
|
570
|
+
|
|
571
|
+
With our `DeliveryFormatter` the body is parsed once and the validated message rides
|
|
572
|
+
to the Receiver on the yielded bytes; a replaced formatter gets the old re-dump path.
|
|
573
|
+
"""
|
|
574
|
+
if not isinstance(self.formatter, DeliveryFormatter):
|
|
575
|
+
return self._screen_redump(delivery)
|
|
576
|
+
try:
|
|
577
|
+
raw = self.serializer.loadb(delivery.body.encode("utf-8"))
|
|
578
|
+
task_name, task_id = raw["task_name"], raw["task_id"]
|
|
579
|
+
except Exception:
|
|
580
|
+
return "unparseable", delivery.body
|
|
581
|
+
if isinstance(task_id, str):
|
|
582
|
+
delivery.task_id = task_id
|
|
583
|
+
if not isinstance(task_name, str) or self.find_task(task_name) is None:
|
|
584
|
+
return f"unknown_task:{task_name}", delivery.body
|
|
585
|
+
try: # once per message; taskiq's Receiver would otherwise do the same again
|
|
586
|
+
parsed = TaskiqMessage.model_validate(raw)
|
|
587
|
+
except Exception:
|
|
588
|
+
return "unparseable", delivery.body
|
|
589
|
+
if not envelope_supported(parsed):
|
|
590
|
+
return "unsupported_envelope", delivery.body
|
|
591
|
+
if self._message_filter is not None and not self._message_filter(parsed):
|
|
592
|
+
return "filtered", delivery.body
|
|
593
|
+
if self._expose_metadata:
|
|
594
|
+
parsed.labels.update(self._metadata_labels(delivery))
|
|
595
|
+
delivery.message = parsed
|
|
596
|
+
return None, delivery.body
|
|
597
|
+
|
|
598
|
+
def _metadata_labels(self, delivery: Delivery) -> dict[str, str]:
|
|
599
|
+
"""P12 labels: SQS message id, receipt handle, queue name and receive count."""
|
|
600
|
+
return {
|
|
601
|
+
SQS_MESSAGE_ID_LABEL: delivery.message_id,
|
|
602
|
+
SQS_RECEIPT_HANDLE_LABEL: delivery.receipt_handle,
|
|
603
|
+
SQS_QUEUE_LABEL: delivery.queue.name,
|
|
604
|
+
SQS_RECEIVE_COUNT_LABEL: str(delivery.receive_count),
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
def _screen_redump(self, delivery: Delivery) -> tuple[str | None, str]:
|
|
608
|
+
"""`_screen` for a user formatter: parse with it, re-serialize to add metadata labels."""
|
|
609
|
+
try:
|
|
610
|
+
parsed = self.formatter.loads(delivery.body.encode("utf-8"))
|
|
611
|
+
except Exception:
|
|
612
|
+
return "unparseable", delivery.body
|
|
613
|
+
delivery.task_id = parsed.task_id
|
|
614
|
+
if self.find_task(parsed.task_name) is None:
|
|
615
|
+
return f"unknown_task:{parsed.task_name}", delivery.body
|
|
616
|
+
if not envelope_supported(parsed):
|
|
617
|
+
return "unsupported_envelope", delivery.body
|
|
618
|
+
if self._message_filter is not None and not self._message_filter(parsed):
|
|
619
|
+
return "filtered", delivery.body
|
|
620
|
+
if not self._expose_metadata:
|
|
621
|
+
return None, delivery.body
|
|
622
|
+
parsed.labels.update(self._metadata_labels(delivery))
|
|
623
|
+
return None, self.formatter.dumps(parsed).message.decode("utf-8")
|
|
624
|
+
|
|
625
|
+
async def _ack(self, delivery: Delivery) -> None:
|
|
626
|
+
"""Taskiq ack: free the slot, stop the heartbeat, queue a batched delete."""
|
|
627
|
+
if self._finish(delivery, "acked") and self._acker is not None:
|
|
628
|
+
future = self._acker.delete(
|
|
629
|
+
delivery.queue.name, delivery.queue_url, delivery.receipt_handle
|
|
630
|
+
)
|
|
631
|
+
if delivery.after_ack:
|
|
632
|
+
future.add_done_callback(functools.partial(self._run_after_ack, delivery))
|
|
633
|
+
|
|
634
|
+
def _run_after_ack(self, delivery: Delivery, future: asyncio.Future[bool]) -> None:
|
|
635
|
+
"""Run `after_ack` callbacks once SQS confirmed the delete (and the handle wasn't lost)."""
|
|
636
|
+
callbacks, delivery.after_ack = delivery.after_ack, []
|
|
637
|
+
deleted = not future.cancelled() and future.exception() is None and future.result()
|
|
638
|
+
if not deleted or delivery.lost.is_set():
|
|
639
|
+
return
|
|
640
|
+
for callback in callbacks:
|
|
641
|
+
task = asyncio.ensure_future(_run_callback(callback, delivery.queue.name))
|
|
642
|
+
self._after_ack_tasks.add(task)
|
|
643
|
+
task.add_done_callback(self._after_ack_tasks.discard)
|
|
644
|
+
|
|
645
|
+
def _finish(self, delivery: Delivery, outcome: str = "released") -> bool:
|
|
646
|
+
"""Mark a delivery done exactly once and free its slot; False if already done."""
|
|
647
|
+
if delivery.done:
|
|
648
|
+
return False
|
|
649
|
+
delivery.done = True
|
|
650
|
+
delivery.outcome = outcome
|
|
651
|
+
self._active.pop(delivery.receipt_handle, None)
|
|
652
|
+
if delivery.task_id is not None and self._by_task.get(delivery.task_id) is delivery:
|
|
653
|
+
del self._by_task[delivery.task_id]
|
|
654
|
+
if self._heartbeat is not None:
|
|
655
|
+
self._heartbeat.untrack(delivery)
|
|
656
|
+
self._scheduler.release(delivery.queue.priority, 1)
|
|
657
|
+
return True
|
|
658
|
+
|
|
659
|
+
def _find(self, key: str) -> Delivery | None:
|
|
660
|
+
return self._active.get(key) or self.delivery(key)
|
|
661
|
+
|
|
662
|
+
async def _release_where(self, predicate: Callable[[Delivery], bool], reason: str) -> None:
|
|
663
|
+
"""Make matching active deliveries visible now, in batches, and wait for SQS."""
|
|
664
|
+
acker = self._acker
|
|
665
|
+
if acker is None:
|
|
666
|
+
return
|
|
667
|
+
chosen = [d for d in list(self._active.values()) if predicate(d) and self._finish(d)]
|
|
668
|
+
futures = [
|
|
669
|
+
acker.change_visibility(d.queue.name, d.queue_url, d.receipt_handle, 0) for d in chosen
|
|
670
|
+
]
|
|
671
|
+
await acker.flush()
|
|
672
|
+
counts: dict[str, int] = {}
|
|
673
|
+
for delivery, future in zip(chosen, futures, strict=True):
|
|
674
|
+
if (
|
|
675
|
+
future.done()
|
|
676
|
+
and not future.cancelled()
|
|
677
|
+
and future.exception() is None
|
|
678
|
+
and future.result() is VisibilityResult.OK
|
|
679
|
+
):
|
|
680
|
+
counts[delivery.queue.name] = counts.get(delivery.queue.name, 0) + 1
|
|
681
|
+
for name, count in counts.items():
|
|
682
|
+
safe_call(self.hooks.on_release, name, count, reason)
|
|
683
|
+
|
|
684
|
+
async def _stop_pollers(self) -> None:
|
|
685
|
+
"""Cancel idle pollers at once; let in-flight long polls finish within the grace."""
|
|
686
|
+
pollers = self._pollers
|
|
687
|
+
self._pollers = {}
|
|
688
|
+
if not pollers:
|
|
689
|
+
return
|
|
690
|
+
stopping, self._stopping = self._stopping, True
|
|
691
|
+
for name, task in pollers.items():
|
|
692
|
+
if name not in self._in_receive:
|
|
693
|
+
task.cancel()
|
|
694
|
+
_, pending = await asyncio.wait(pollers.values(), timeout=self._stop_grace_secs)
|
|
695
|
+
for task in pending:
|
|
696
|
+
task.cancel()
|
|
697
|
+
await asyncio.gather(*pollers.values(), return_exceptions=True)
|
|
698
|
+
self._stopping = stopping
|
|
699
|
+
|
|
700
|
+
# ------------------------------------------------------------------ send side
|
|
701
|
+
|
|
702
|
+
async def kick(self, message: BrokerMessage) -> None:
|
|
703
|
+
"""Send one task to the queue named by its `queue_name` label (or the default).
|
|
704
|
+
|
|
705
|
+
Batched queues await the batch flush, so send failures still reach the caller.
|
|
706
|
+
"""
|
|
707
|
+
queue = self._route(message)
|
|
708
|
+
url = self._urls.get(queue.name)
|
|
709
|
+
if url is None or self._client is None:
|
|
710
|
+
raise QueueUnavailable("broker is not started; call startup() first")
|
|
711
|
+
entry = self._entry(message, queue)
|
|
712
|
+
size = message_bytes(entry["MessageBody"], entry.get("MessageAttributes"))
|
|
713
|
+
if size > MAX_MESSAGE_BYTES:
|
|
714
|
+
raise MessageTooLargeError(
|
|
715
|
+
f"message for {message.task_name} is {size} bytes (SQS limit {MAX_MESSAGE_BYTES})"
|
|
716
|
+
)
|
|
717
|
+
sender = self._senders.get(queue.name)
|
|
718
|
+
skip = message.labels.get(SKIP_BATCHING_LABEL)
|
|
719
|
+
if (
|
|
720
|
+
sender is not None
|
|
721
|
+
and not _truthy(skip)
|
|
722
|
+
and message.task_name not in self._skip_batch_tasks
|
|
723
|
+
):
|
|
724
|
+
await sender.submit((entry, size))
|
|
725
|
+
return
|
|
726
|
+
await self._send_one(queue, url, entry)
|
|
727
|
+
|
|
728
|
+
def _route(self, message: BrokerMessage) -> SQSQueue:
|
|
729
|
+
name = str(message.labels.get(QUEUE_LABEL) or self._default_queue)
|
|
730
|
+
queue = self._queues.get(name)
|
|
731
|
+
if queue is None:
|
|
732
|
+
raise InvalidLabelError(f"queue_name {name!r} is not one of {sorted(self._queues)}")
|
|
733
|
+
return queue
|
|
734
|
+
|
|
735
|
+
def _entry(self, message: BrokerMessage, queue: SQSQueue) -> dict[str, Any]:
|
|
736
|
+
"""Build the SendMessage fields for one task from its labels (P5, P6, P7, P11)."""
|
|
737
|
+
labels = message.labels
|
|
738
|
+
try:
|
|
739
|
+
body = message.message.decode("utf-8")
|
|
740
|
+
except UnicodeDecodeError:
|
|
741
|
+
raise BrokerConfigError("SQS bodies are text; use a UTF-8 serializer") from None
|
|
742
|
+
entry: dict[str, Any] = {"MessageBody": body}
|
|
743
|
+
delay = _int_label(labels.get(DELAY_LABEL, queue.default_delay_secs), DELAY_LABEL)
|
|
744
|
+
if not 0 <= delay <= MAX_DELAY_SECS:
|
|
745
|
+
raise InvalidLabelError(f"delay must be 0..{MAX_DELAY_SECS} seconds, got {delay}")
|
|
746
|
+
if delay and queue.fifo:
|
|
747
|
+
raise InvalidLabelError(f"delay is not supported on FIFO queue {queue.name!r}")
|
|
748
|
+
if delay:
|
|
749
|
+
entry["DelaySeconds"] = delay
|
|
750
|
+
if queue.fifo or queue.is_fair:
|
|
751
|
+
group = str(labels.get(GROUP_ID_LABEL) or message.task_name)
|
|
752
|
+
entry["MessageGroupId"] = _fifo_id(group, GROUP_ID_LABEL)
|
|
753
|
+
if queue.fifo:
|
|
754
|
+
dedup = labels.get(DEDUPLICATION_ID_LABEL)
|
|
755
|
+
if dedup is None and self._use_task_id_for_dedup:
|
|
756
|
+
dedup = message.task_id
|
|
757
|
+
if dedup is not None:
|
|
758
|
+
entry["MessageDeduplicationId"] = _fifo_id(str(dedup), DEDUPLICATION_ID_LABEL)
|
|
759
|
+
expiry = labels.get(EXPIRY_LABEL)
|
|
760
|
+
if expiry is not None:
|
|
761
|
+
try:
|
|
762
|
+
expiry_at = float(expiry)
|
|
763
|
+
except (TypeError, ValueError):
|
|
764
|
+
raise InvalidLabelError(f"expiry must be epoch seconds, got {expiry!r}") from None
|
|
765
|
+
if not math.isfinite(expiry_at) or expiry_at <= 0:
|
|
766
|
+
raise InvalidLabelError(f"expiry must be positive epoch seconds, got {expiry!r}")
|
|
767
|
+
entry["MessageAttributes"] = {
|
|
768
|
+
EXPIRY_ATTRIBUTE: {"DataType": "Number", "StringValue": repr(expiry_at)}
|
|
769
|
+
}
|
|
770
|
+
return entry
|
|
771
|
+
|
|
772
|
+
async def _send_one(self, queue: SQSQueue, url: str, entry: dict[str, Any]) -> str:
|
|
773
|
+
client, acker = self._require_client(), self._require_acker()
|
|
774
|
+
try:
|
|
775
|
+
resp = await acker.call(
|
|
776
|
+
queue.name,
|
|
777
|
+
"send",
|
|
778
|
+
lambda: client.send_message(QueueUrl=url, **entry),
|
|
779
|
+
raise_final=True,
|
|
780
|
+
)
|
|
781
|
+
except Exception as exc:
|
|
782
|
+
raise SQSSendError(
|
|
783
|
+
f"send to {queue.name} failed: {exc}",
|
|
784
|
+
family=classify_error(exc),
|
|
785
|
+
code=error_code(exc),
|
|
786
|
+
) from exc
|
|
787
|
+
safe_call(self.hooks.on_send, queue.name, 1)
|
|
788
|
+
return str(resp.get("MessageId", ""))
|
|
789
|
+
|
|
790
|
+
async def _send_batch(
|
|
791
|
+
self, queue: SQSQueue, url: str, items: Sequence[_SendItem]
|
|
792
|
+
) -> list[str | BaseException]:
|
|
793
|
+
"""One `SendMessageBatch`; halves and retries if SQS still says it's too large."""
|
|
794
|
+
client = self._require_client()
|
|
795
|
+
entries: list[Any] = [{**entry, "Id": str(i)} for i, (entry, _) in enumerate(items)]
|
|
796
|
+
try:
|
|
797
|
+
resp = await self._require_acker().call(
|
|
798
|
+
queue.name,
|
|
799
|
+
"send_batch",
|
|
800
|
+
lambda: client.send_message_batch(QueueUrl=url, Entries=entries),
|
|
801
|
+
raise_final=True,
|
|
802
|
+
)
|
|
803
|
+
except Exception as exc:
|
|
804
|
+
family = classify_error(exc)
|
|
805
|
+
if family is ErrorFamily.SIZE_LIMIT and len(items) > 1:
|
|
806
|
+
half = len(items) // 2
|
|
807
|
+
first = await self._send_batch(queue, url, items[:half])
|
|
808
|
+
return first + await self._send_batch(queue, url, items[half:])
|
|
809
|
+
error = SQSSendError(
|
|
810
|
+
f"batch send to {queue.name} failed: {exc}", family=family, code=error_code(exc)
|
|
811
|
+
)
|
|
812
|
+
return [error] * len(items)
|
|
813
|
+
results: list[str | BaseException] = [""] * len(items)
|
|
814
|
+
for ok in resp.get("Successful", []):
|
|
815
|
+
results[int(ok["Id"])] = ok.get("MessageId", "")
|
|
816
|
+
sent = len(resp.get("Successful", []))
|
|
817
|
+
for failed in resp.get("Failed", []):
|
|
818
|
+
index = int(failed["Id"])
|
|
819
|
+
family = classify_code(failed.get("Code", ""), failed.get("Message", ""))
|
|
820
|
+
if not failed.get("SenderFault", False) or family.retryable:
|
|
821
|
+
try:
|
|
822
|
+
results[index] = await self._send_one(queue, url, items[index][0])
|
|
823
|
+
continue
|
|
824
|
+
except SQSSendError as exc:
|
|
825
|
+
results[index] = exc
|
|
826
|
+
continue
|
|
827
|
+
results[index] = SQSSendError(
|
|
828
|
+
f"send to {queue.name} failed: {failed.get('Message', '')}",
|
|
829
|
+
family=family,
|
|
830
|
+
code=failed.get("Code", ""),
|
|
831
|
+
)
|
|
832
|
+
if sent:
|
|
833
|
+
safe_call(self.hooks.on_send, queue.name, sent)
|
|
834
|
+
return results
|
|
835
|
+
|
|
836
|
+
# ------------------------------------------------------------------ helpers
|
|
837
|
+
|
|
838
|
+
async def _resolve_url(self, client: SQSClient, queue: SQSQueue) -> str:
|
|
839
|
+
if queue.url:
|
|
840
|
+
return queue.url
|
|
841
|
+
if queue.is_declare:
|
|
842
|
+
attributes: dict[Any, str] = dict(queue.attributes)
|
|
843
|
+
if queue.fifo:
|
|
844
|
+
attributes.setdefault("FifoQueue", "true")
|
|
845
|
+
if queue.visibility_timeout_secs is not None:
|
|
846
|
+
attributes.setdefault("VisibilityTimeout", str(queue.visibility_timeout_secs))
|
|
847
|
+
created = await client.create_queue(QueueName=queue.name, Attributes=attributes)
|
|
848
|
+
return created["QueueUrl"]
|
|
849
|
+
try:
|
|
850
|
+
return (await client.get_queue_url(QueueName=queue.name))["QueueUrl"]
|
|
851
|
+
except Exception as exc:
|
|
852
|
+
if classify_error(exc) is ErrorFamily.NOT_FOUND:
|
|
853
|
+
raise BrokerConfigError(
|
|
854
|
+
f"SQS queue {queue.name!r} does not exist (create it or set is_declare=True)"
|
|
855
|
+
) from exc
|
|
856
|
+
raise
|
|
857
|
+
|
|
858
|
+
async def _resolve_visibility(self, client: SQSClient, queue: SQSQueue, url: str) -> int:
|
|
859
|
+
if queue.visibility_timeout_secs is not None:
|
|
860
|
+
return queue.visibility_timeout_secs
|
|
861
|
+
resp = await client.get_queue_attributes(QueueUrl=url, AttributeNames=["VisibilityTimeout"])
|
|
862
|
+
return int(resp.get("Attributes", {}).get("VisibilityTimeout", "30"))
|
|
863
|
+
|
|
864
|
+
def _require_client(self) -> SQSClient:
|
|
865
|
+
if self._client is None:
|
|
866
|
+
raise QueueUnavailable("broker is not started; call startup() first")
|
|
867
|
+
return self._client
|
|
868
|
+
|
|
869
|
+
def _require_acker(self) -> Acker:
|
|
870
|
+
if self._acker is None:
|
|
871
|
+
raise QueueUnavailable("broker is not started; call startup() first")
|
|
872
|
+
return self._acker
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
class _Body(bytes):
|
|
876
|
+
"""Message bytes yielded to the Receiver, carrying the message `_screen` already parsed."""
|
|
877
|
+
|
|
878
|
+
parsed: TaskiqMessage | None = None
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
class DeliveryFormatter(ProxyFormatter):
|
|
882
|
+
"""taskiq's `ProxyFormatter` (broker serializer) that skips parsing a screened message.
|
|
883
|
+
|
|
884
|
+
The broker's `_screen` parses and validates each received body once; `loads` returns that
|
|
885
|
+
message (with P12 metadata labels) instead of decoding the same bytes again.
|
|
886
|
+
"""
|
|
887
|
+
|
|
888
|
+
def loads(self, message: bytes) -> TaskiqMessage:
|
|
889
|
+
"""The message screened with these bytes, else a normal serializer parse."""
|
|
890
|
+
parsed = message.parsed if isinstance(message, _Body) else None
|
|
891
|
+
return parsed if parsed is not None else super().loads(message)
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
class _PollState:
|
|
895
|
+
"""Per-poller retry counter and idle flag, kept across `_poll_once` iterations."""
|
|
896
|
+
|
|
897
|
+
__slots__ = ("attempt", "idle")
|
|
898
|
+
|
|
899
|
+
def __init__(self) -> None:
|
|
900
|
+
"""Start with no failures and a full-size first receive."""
|
|
901
|
+
self.attempt = 0
|
|
902
|
+
self.idle = False
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
async def _run_callback(callback: Callable[[], Awaitable[None]], queue: str) -> None:
|
|
906
|
+
"""Run one after-ack callback, logging (not raising) what it throws."""
|
|
907
|
+
try:
|
|
908
|
+
await callback()
|
|
909
|
+
except Exception as exc:
|
|
910
|
+
logger.warning("sqs_after_ack_failed", queue=queue, error_type=type(exc).__name__)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _epoch(raw: str) -> float | None:
|
|
914
|
+
"""Parse an `expiry` attribute; None when it is not a finite number."""
|
|
915
|
+
try:
|
|
916
|
+
value = float(raw)
|
|
917
|
+
except ValueError:
|
|
918
|
+
return None
|
|
919
|
+
return value if math.isfinite(value) else None
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _truthy(value: object) -> bool:
|
|
923
|
+
return str(value).strip().lower() in {"1", "true", "yes", "on"} if value is not None else False
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _int_label(value: object, name: str) -> int:
|
|
927
|
+
"""Parse an integer label; taskiq sends label values as strings."""
|
|
928
|
+
try:
|
|
929
|
+
return round(float(str(value)))
|
|
930
|
+
except (TypeError, ValueError):
|
|
931
|
+
raise InvalidLabelError(f"{name} must be a number, got {value!r}") from None
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _fifo_id(value: str, name: str) -> str:
|
|
935
|
+
if not 1 <= len(value) <= MAX_FIFO_ID_CHARS:
|
|
936
|
+
raise InvalidLabelError(f"{name} must be 1..{MAX_FIFO_ID_CHARS} characters")
|
|
937
|
+
return value
|