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.
Files changed (65) hide show
  1. taskiq_llm_queue/__init__.py +138 -0
  2. taskiq_llm_queue/_logging.py +40 -0
  3. taskiq_llm_queue/_version.py +5 -0
  4. taskiq_llm_queue/broker/__init__.py +86 -0
  5. taskiq_llm_queue/broker/acker.py +239 -0
  6. taskiq_llm_queue/broker/batching.py +188 -0
  7. taskiq_llm_queue/broker/broker.py +937 -0
  8. taskiq_llm_queue/broker/delivery.py +50 -0
  9. taskiq_llm_queue/broker/errors.py +178 -0
  10. taskiq_llm_queue/broker/events.py +51 -0
  11. taskiq_llm_queue/broker/filters.py +32 -0
  12. taskiq_llm_queue/broker/heartbeat.py +157 -0
  13. taskiq_llm_queue/broker/queue.py +141 -0
  14. taskiq_llm_queue/broker/s3_offload.py +346 -0
  15. taskiq_llm_queue/broker/scheduler.py +113 -0
  16. taskiq_llm_queue/broker/serializer.py +32 -0
  17. taskiq_llm_queue/cli.py +488 -0
  18. taskiq_llm_queue/client.py +1409 -0
  19. taskiq_llm_queue/config.py +592 -0
  20. taskiq_llm_queue/context.py +62 -0
  21. taskiq_llm_queue/core.py +832 -0
  22. taskiq_llm_queue/envelope.py +136 -0
  23. taskiq_llm_queue/errors.py +71 -0
  24. taskiq_llm_queue/failover.py +286 -0
  25. taskiq_llm_queue/http_transport.py +220 -0
  26. taskiq_llm_queue/keys.py +87 -0
  27. taskiq_llm_queue/langchain/__init__.py +64 -0
  28. taskiq_llm_queue/langchain/_client.py +142 -0
  29. taskiq_llm_queue/langchain/bedrock.py +291 -0
  30. taskiq_llm_queue/langchain/openai.py +103 -0
  31. taskiq_llm_queue/lifecycle.py +512 -0
  32. taskiq_llm_queue/metrics/__init__.py +45 -0
  33. taskiq_llm_queue/metrics/emf.py +100 -0
  34. taskiq_llm_queue/metrics/prometheus.py +66 -0
  35. taskiq_llm_queue/metrics_names.py +19 -0
  36. taskiq_llm_queue/protocols.py +140 -0
  37. taskiq_llm_queue/providers/__init__.py +62 -0
  38. taskiq_llm_queue/providers/bedrock.py +188 -0
  39. taskiq_llm_queue/providers/classify.py +251 -0
  40. taskiq_llm_queue/providers/embeddings.py +87 -0
  41. taskiq_llm_queue/providers/hosts.py +34 -0
  42. taskiq_llm_queue/providers/openai_compat.py +142 -0
  43. taskiq_llm_queue/providers/registry.py +173 -0
  44. taskiq_llm_queue/py.typed +0 -0
  45. taskiq_llm_queue/reliability/__init__.py +59 -0
  46. taskiq_llm_queue/reliability/_lua.py +461 -0
  47. taskiq_llm_queue/reliability/breaker.py +86 -0
  48. taskiq_llm_queue/reliability/claims.py +226 -0
  49. taskiq_llm_queue/reliability/config_store.py +148 -0
  50. taskiq_llm_queue/reliability/fallback.py +111 -0
  51. taskiq_llm_queue/reliability/limiter.py +482 -0
  52. taskiq_llm_queue/reliability/local_limiter.py +153 -0
  53. taskiq_llm_queue/results/__init__.py +25 -0
  54. taskiq_llm_queue/results/backend.py +226 -0
  55. taskiq_llm_queue/results/dispatcher.py +366 -0
  56. taskiq_llm_queue/results/s3_backend.py +105 -0
  57. taskiq_llm_queue/routing.py +173 -0
  58. taskiq_llm_queue/routing_hooks.py +90 -0
  59. taskiq_llm_queue/tasks.py +453 -0
  60. taskiq_llm_queue-0.1.0a1.dist-info/METADATA +296 -0
  61. taskiq_llm_queue-0.1.0a1.dist-info/RECORD +65 -0
  62. taskiq_llm_queue-0.1.0a1.dist-info/WHEEL +4 -0
  63. taskiq_llm_queue-0.1.0a1.dist-info/entry_points.txt +2 -0
  64. taskiq_llm_queue-0.1.0a1.dist-info/licenses/LICENSE +21 -0
  65. 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