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,50 @@
1
+ """One received SQS message and its local lifecycle: buffered, started, then acked or released.
2
+
3
+ The broker, the heartbeat and `release()` all act on the same `Delivery`, so its `done`
4
+ flag makes ack, release and heartbeat stop idempotent and race-free within one event loop.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import math
11
+ from collections.abc import Awaitable, Callable
12
+ from dataclasses import dataclass, field
13
+ from typing import TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ from taskiq import TaskiqMessage
17
+
18
+ from taskiq_llm_queue.broker.queue import SQSQueue
19
+
20
+
21
+ @dataclass(slots=True, eq=False)
22
+ class Delivery:
23
+ """A received message: where it came from, its receipt handle and its local state.
24
+
25
+ `lost` is set when a heartbeat finds the receipt handle invalid (another consumer may
26
+ now own the message), so the running task can stop without writing a result (DS6).
27
+ `outcome` is "acked" or "released" once `done`; `after_ack` callbacks run only after
28
+ SQS confirmed the delete. `expires_at` is the `expiry` attribute (epoch seconds).
29
+ `message` is the screened, validated taskiq message until it is handed to the Receiver.
30
+ """
31
+
32
+ queue: SQSQueue
33
+ queue_url: str
34
+ message_id: str
35
+ receipt_handle: str
36
+ body: str
37
+ receive_count: int
38
+ received_at: float
39
+ visibility_secs: int
40
+ sent_at: float | None = None
41
+ started: bool = False
42
+ done: bool = False
43
+ extensions: int = 0
44
+ next_heartbeat_at: float = math.inf
45
+ lost: asyncio.Event = field(default_factory=asyncio.Event)
46
+ task_id: str | None = None
47
+ expires_at: float | None = None
48
+ outcome: str | None = None
49
+ after_ack: list[Callable[[], Awaitable[None]]] = field(default_factory=list)
50
+ message: TaskiqMessage | None = None
@@ -0,0 +1,178 @@
1
+ """Broker exceptions and SQS error-family classification.
2
+
3
+ ElasticMQ, moto and AWS spell the same failure differently (an oversized batch is
4
+ `InvalidAttributeValue`, `InvalidParameterValue` or `BatchRequestTooLong`), so every broker
5
+ decision uses `ErrorFamily`, never the raw code. The raw code is kept for logs only.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import random
11
+ import re
12
+ from collections.abc import Callable
13
+ from enum import StrEnum
14
+
15
+ from taskiq_llm_queue.errors import ConfigError, LLMQueueError, QueueUnavailable
16
+
17
+
18
+ class BrokerConfigError(ConfigError):
19
+ """Invalid broker or queue settings (limits, priorities, names), raised at construction."""
20
+
21
+
22
+ class InvalidLabelError(LLMQueueError, ValueError):
23
+ """A task label the broker reads (`delay`, `group_id`, `expiry`...) is out of range.
24
+
25
+ Raised by `kick`, i.e. by the caller's `.kiq()`, so a bad label never reaches SQS.
26
+ """
27
+
28
+
29
+ class MessageTooLargeError(LLMQueueError, ValueError):
30
+ """One serialized message is over the SQS 1 MiB limit; offload the payload instead."""
31
+
32
+
33
+ class SQSSendError(QueueUnavailable):
34
+ """SQS refused or failed a send; `family` says whether retrying can help."""
35
+
36
+ def __init__(self, message: str, *, family: ErrorFamily, code: str = "") -> None:
37
+ """Keep the error family for decisions and the provider's code for logs."""
38
+ super().__init__(message)
39
+ self.family = family
40
+ self.code = code
41
+
42
+
43
+ class ErrorFamily(StrEnum):
44
+ """What kind of SQS failure this is, independent of which fake or region spelled it.
45
+
46
+ `TRANSIENT` (5xx, connection errors) and `THROTTLED` are both retried with backoff.
47
+ """
48
+
49
+ SIZE_LIMIT = "size_limit"
50
+ INVALID_HANDLE = "invalid_handle"
51
+ THROTTLED = "throttled"
52
+ TRANSIENT = "transient"
53
+ NOT_FOUND = "not_found"
54
+ OTHER = "other"
55
+
56
+ @property
57
+ def retryable(self) -> bool:
58
+ """True for families where the same call may succeed after a pause."""
59
+ return self in (ErrorFamily.THROTTLED, ErrorFamily.TRANSIENT)
60
+
61
+
62
+ _SIZE_CODES = frozenset(
63
+ {"batchrequesttoolong", "messagetoolong", "requestentitytoolarge", "requesttoolarge"}
64
+ )
65
+ # Generic "bad parameter" codes that mean "too big" only when the message says so.
66
+ _GENERIC_CODES = frozenset({"invalidattributevalue", "invalidparametervalue"})
67
+ _SIZE_TEXT = re.compile(
68
+ r"too ?long|shorter than|too large|exceed\w* .*(size|bytes|limit)|maximum .*size", re.I
69
+ )
70
+ _HANDLE_CODES = frozenset(
71
+ {"receipthandleisinvalid", "invalidreceipthandle", "messagenotinflight", "invalididformat"}
72
+ )
73
+ _HANDLE_TEXT = re.compile(r"receipt ?handle|not ?in ?flight", re.I)
74
+ _THROTTLE_CODES = frozenset(
75
+ {
76
+ "requestthrottled",
77
+ "throttlingexception",
78
+ "throttling",
79
+ "throttled",
80
+ "slowdown",
81
+ "overlimit",
82
+ "toomanyrequestsexception",
83
+ "requestlimitexceeded",
84
+ "kmsthrottlingexception",
85
+ }
86
+ )
87
+ _TRANSIENT_CODES = frozenset(
88
+ {
89
+ "internalerror",
90
+ "internalfailure",
91
+ "serviceunavailable",
92
+ "serviceunavailableexception",
93
+ "requesttimeout",
94
+ "requesttimeoutexception",
95
+ }
96
+ )
97
+ _NOT_FOUND_CODES = frozenset(
98
+ {
99
+ "queuedoesnotexist",
100
+ "nonexistentqueue",
101
+ "resourcenotfoundexception",
102
+ "queuedeletedrecently",
103
+ }
104
+ )
105
+
106
+
107
+ def classify_code(code: str, message: str = "", status: int | None = None) -> ErrorFamily:
108
+ """Map one SQS error code (and its message / HTTP status) to an `ErrorFamily`.
109
+
110
+ Accepts both legacy (`AWS.SimpleQueueService.NonExistentQueue`) and JSON-protocol
111
+ spellings; used for whole-call errors and for `Failed[]` batch entries alike.
112
+ """
113
+ key = code.rsplit(".", 1)[-1].lower()
114
+ if key in _SIZE_CODES or (key in _GENERIC_CODES and _SIZE_TEXT.search(message)):
115
+ return ErrorFamily.SIZE_LIMIT
116
+ if key in _HANDLE_CODES or (key in _GENERIC_CODES and _HANDLE_TEXT.search(message)):
117
+ return ErrorFamily.INVALID_HANDLE
118
+ if key in _THROTTLE_CODES or status == 429:
119
+ return ErrorFamily.THROTTLED
120
+ if key in _NOT_FOUND_CODES:
121
+ return ErrorFamily.NOT_FOUND
122
+ if key in _TRANSIENT_CODES or (status is not None and status >= 500):
123
+ return ErrorFamily.TRANSIENT
124
+ return ErrorFamily.OTHER
125
+
126
+
127
+ def error_code(exc: BaseException) -> str:
128
+ """The provider's error code of a botocore `ClientError`, or the exception class name."""
129
+ response = getattr(exc, "response", None)
130
+ if isinstance(response, dict):
131
+ return str(response.get("Error", {}).get("Code", "")) or type(exc).__name__
132
+ return type(exc).__name__
133
+
134
+
135
+ def classify_error(exc: BaseException) -> ErrorFamily:
136
+ """Classify any exception raised by an SQS call into an `ErrorFamily`.
137
+
138
+ `ClientError` is read through its response; connection and read-timeout errors are
139
+ `TRANSIENT`; anything else is `OTHER`.
140
+ """
141
+ response = getattr(exc, "response", None)
142
+ if isinstance(response, dict):
143
+ error = response.get("Error", {})
144
+ status = response.get("ResponseMetadata", {}).get("HTTPStatusCode")
145
+ return classify_code(
146
+ str(error.get("Code", "")),
147
+ str(error.get("Message", "")),
148
+ status if isinstance(status, int) else None,
149
+ )
150
+ # Imported here so this module stays importable (and testable) without botocore loaded.
151
+ from botocore.exceptions import (
152
+ ConnectionClosedError,
153
+ EndpointConnectionError,
154
+ ReadTimeoutError,
155
+ )
156
+
157
+ if isinstance(
158
+ exc,
159
+ EndpointConnectionError | ConnectionClosedError | ReadTimeoutError | ConnectionError,
160
+ ):
161
+ return ErrorFamily.TRANSIENT
162
+ if isinstance(exc, TimeoutError):
163
+ return ErrorFamily.TRANSIENT
164
+ return ErrorFamily.OTHER
165
+
166
+
167
+ def backoff_delay(
168
+ attempt: int,
169
+ *,
170
+ base_secs: float,
171
+ cap_secs: float,
172
+ rand: Callable[[float, float], float] = random.uniform,
173
+ ) -> float:
174
+ """Full-jitter exponential backoff: uniform(0, min(cap, base * 2**attempt)).
175
+
176
+ Each poller and flusher keeps its own attempt counter, so pods spread their retries.
177
+ """
178
+ return rand(0.0, min(cap_secs, base_secs * (2 ** min(attempt, 30))))
@@ -0,0 +1,51 @@
1
+ """Broker event hooks (A7): counts and timings for metrics, without a metrics dependency.
2
+
3
+ Subclass `BrokerHooks` and override what you need; the broker calls every hook through
4
+ `safe_call`, so a failing hook is logged and never breaks receiving, acking or sending.
5
+ Queue names are the `SQSQueue.name` routing keys, safe to use as a `queue` metric label.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+
13
+ from taskiq_llm_queue._logging import get_logger
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ class BrokerHooks:
19
+ """No-op callbacks for broker events; override the ones a metrics layer cares about."""
20
+
21
+ def on_receive(self, queue: str, count: int) -> None:
22
+ """`count` messages arrived from one `ReceiveMessage` call (0 for an empty poll)."""
23
+
24
+ def on_queue_wait(self, queue: str, wait_secs: float) -> None:
25
+ """One message waited `wait_secs` in SQS (now - `SentTimestamp`)."""
26
+
27
+ def on_ack(self, queue: str, count: int) -> None:
28
+ """`count` messages were deleted (acks after the task, and expired-message deletes)."""
29
+
30
+ def on_heartbeat(self, queue: str, extended: int, lost: int) -> None:
31
+ """A heartbeat batch extended `extended` messages; `lost` had invalid handles."""
32
+
33
+ def on_release(self, queue: str, count: int, reason: str) -> None:
34
+ """Messages made visible again; reason is shutdown, drain, unknown_task, manual..."""
35
+
36
+ def on_expired(self, queue: str, count: int) -> None:
37
+ """Messages past their `expiry` label were deleted without running (P11)."""
38
+
39
+ def on_error(self, queue: str, operation: str, family: str) -> None:
40
+ """An SQS call failed; `family` is an `ErrorFamily` value."""
41
+
42
+ def on_send(self, queue: str, count: int) -> None:
43
+ """`count` messages were sent (one call, batched or not)."""
44
+
45
+
46
+ def safe_call(hook: Callable[..., Any], *args: Any) -> None:
47
+ """Run one hook, logging (not raising) anything it throws."""
48
+ try:
49
+ hook(*args)
50
+ except Exception:
51
+ logger.exception("broker_hook_failed", hook=getattr(hook, "__name__", repr(hook)))
@@ -0,0 +1,32 @@
1
+ """Pre-receive checks that decide whether a message may reach taskiq's Receiver (DS9).
2
+
3
+ taskiq's Receiver acks (deletes) messages whose task it does not know, which would lose
4
+ work during a rolling deploy. The broker runs these checks first and releases rejected
5
+ messages with a short visibility instead, so a pod that knows the task picks them up.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from taskiq_llm_queue.envelope import ENVELOPE_VERSION
14
+
15
+ if TYPE_CHECKING:
16
+ from taskiq.message import TaskiqMessage
17
+
18
+ MessageFilter = Callable[["TaskiqMessage"], bool]
19
+
20
+
21
+ def envelope_supported(message: TaskiqMessage) -> bool:
22
+ """False when any argument is an envelope (`v` + `request_id`) newer than this worker.
23
+
24
+ Older-but-supported and malformed envelopes pass; the task itself rejects malformed ones.
25
+ """
26
+ values: list[Any] = [*message.args, *message.kwargs.values()]
27
+ for value in values:
28
+ if isinstance(value, dict) and "request_id" in value:
29
+ version = value.get("v")
30
+ if isinstance(version, int) and version > ENVELOPE_VERSION:
31
+ return False
32
+ return True
@@ -0,0 +1,157 @@
1
+ """Keeps received messages invisible while they wait or run, with batched extensions (DS6).
2
+
3
+ One loop serves every tracked delivery: each is extended by its queue's visibility timeout
4
+ every `interval` (≤ visibility / 3), so two missed beats still leave margin. Tracking stops
5
+ on ack or release, after `max_extensions`, once the message's `expiry` (deadline) plus a grace
6
+ has passed, or when SQS says the receipt handle is invalid; the last case sets
7
+ `Delivery.lost` and calls `on_lost` so the task can be cancelled.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import contextlib
14
+ import functools
15
+ import math
16
+ import time
17
+ from collections.abc import Callable
18
+ from typing import TYPE_CHECKING
19
+
20
+ from taskiq_llm_queue._logging import get_logger
21
+ from taskiq_llm_queue.broker.acker import VisibilityResult
22
+ from taskiq_llm_queue.broker.events import BrokerHooks, safe_call
23
+
24
+ if TYPE_CHECKING:
25
+ from taskiq_llm_queue.broker.acker import Acker
26
+ from taskiq_llm_queue.broker.delivery import Delivery
27
+ from taskiq_llm_queue.broker.queue import Heartbeat
28
+
29
+ logger = get_logger(__name__)
30
+
31
+
32
+ class HeartbeatManager:
33
+ """Schedules visibility extensions for tracked deliveries through the `Acker`."""
34
+
35
+ def __init__(
36
+ self,
37
+ acker: Acker,
38
+ config: Heartbeat,
39
+ *,
40
+ hooks: BrokerHooks,
41
+ on_lost: Callable[[Delivery], None] | None = None,
42
+ wall_clock: Callable[[], float] = time.time,
43
+ ) -> None:
44
+ """Nothing runs until `start()`; `track` before `start` is allowed.
45
+
46
+ `wall_clock` (epoch seconds) is compared with `Delivery.expires_at`; injectable for tests.
47
+ """
48
+ self._acker = acker
49
+ self._wall_clock = wall_clock
50
+ self._config = config
51
+ self._hooks = hooks
52
+ self._on_lost = on_lost
53
+ self._tracked: dict[int, Delivery] = {}
54
+ self._wake = asyncio.Event()
55
+ self._task: asyncio.Task[None] | None = None
56
+
57
+ @property
58
+ def tracked(self) -> int:
59
+ """Deliveries currently being extended."""
60
+ return len(self._tracked)
61
+
62
+ def track(self, delivery: Delivery) -> None:
63
+ """Start extending a delivery; the first beat is one interval after it was received."""
64
+ interval = self._config.interval_for(delivery.visibility_secs)
65
+ delivery.next_heartbeat_at = delivery.received_at + interval
66
+ self._tracked[id(delivery)] = delivery
67
+ self._wake.set()
68
+
69
+ def untrack(self, delivery: Delivery) -> None:
70
+ """Stop extending a delivery (ack, release, or extensions exhausted)."""
71
+ self._tracked.pop(id(delivery), None)
72
+ delivery.next_heartbeat_at = math.inf
73
+
74
+ def start(self) -> None:
75
+ """Start the background loop; idempotent."""
76
+ if self._task is None or self._task.done():
77
+ self._task = asyncio.create_task(self._run(), name="llmq-heartbeat")
78
+
79
+ async def stop(self) -> None:
80
+ """Stop the loop; tracked deliveries are left to their visibility timeout."""
81
+ if self._task is not None:
82
+ self._task.cancel()
83
+ with contextlib.suppress(asyncio.CancelledError):
84
+ await self._task
85
+ self._task = None
86
+
87
+ def due(self, now: float) -> list[Delivery]:
88
+ """Deliveries whose next beat is at or before `now` (oldest first)."""
89
+ ready = [d for d in self._tracked.values() if d.next_heartbeat_at <= now]
90
+ return sorted(ready, key=lambda d: d.next_heartbeat_at)
91
+
92
+ def beat(self, now: float) -> int:
93
+ """Submit one extension for every due delivery; returns how many were submitted."""
94
+ submitted = 0
95
+ for delivery in self.due(now):
96
+ if delivery.done:
97
+ self.untrack(delivery)
98
+ continue
99
+ limit = self._config.max_extensions
100
+ if limit is not None and delivery.extensions >= limit:
101
+ logger.warning(
102
+ "sqs_heartbeat_max_extensions",
103
+ queue=delivery.queue.name,
104
+ max_extensions=limit,
105
+ )
106
+ self.untrack(delivery)
107
+ continue
108
+ if (
109
+ delivery.expires_at is not None
110
+ and self._wall_clock() > delivery.expires_at + self._config.deadline_grace_secs
111
+ ):
112
+ # DS6: a delivery stuck past its deadline must not stay invisible forever; once
113
+ # it reappears the broker deletes it as expired (P11).
114
+ logger.warning(
115
+ "sqs_heartbeat_deadline_passed",
116
+ queue=delivery.queue.name,
117
+ message_id=delivery.message_id,
118
+ extensions=delivery.extensions,
119
+ )
120
+ self.untrack(delivery)
121
+ continue
122
+ delivery.extensions += 1
123
+ delivery.next_heartbeat_at = now + self._config.interval_for(delivery.visibility_secs)
124
+ future = self._acker.change_visibility(
125
+ delivery.queue.name,
126
+ delivery.queue_url,
127
+ delivery.receipt_handle,
128
+ delivery.visibility_secs,
129
+ )
130
+ future.add_done_callback(functools.partial(self._settle, delivery))
131
+ submitted += 1
132
+ return submitted
133
+
134
+ def _settle(self, delivery: Delivery, future: asyncio.Future[VisibilityResult]) -> None:
135
+ if future.cancelled() or future.exception() is not None:
136
+ return
137
+ result = future.result()
138
+ if result is VisibilityResult.OK:
139
+ safe_call(self._hooks.on_heartbeat, delivery.queue.name, 1, 0)
140
+ return
141
+ if result is VisibilityResult.LOST and not delivery.done:
142
+ safe_call(self._hooks.on_heartbeat, delivery.queue.name, 0, 1)
143
+ self.untrack(delivery)
144
+ delivery.lost.set()
145
+ if self._on_lost is not None:
146
+ safe_call(self._on_lost, delivery)
147
+
148
+ async def _run(self) -> None:
149
+ loop = asyncio.get_running_loop()
150
+ while True:
151
+ now = loop.time()
152
+ self.beat(now)
153
+ nxt = min((d.next_heartbeat_at for d in self._tracked.values()), default=math.inf)
154
+ self._wake.clear()
155
+ timeout = None if math.isinf(nxt) else max(0.0, nxt - loop.time())
156
+ with contextlib.suppress(TimeoutError):
157
+ await asyncio.wait_for(self._wake.wait(), timeout)
@@ -0,0 +1,141 @@
1
+ """Queue, send-batching and heartbeat settings for `HybridSQSBroker`, validated on creation.
2
+
3
+ Every SQS hard limit (10 messages per call, 20 s long poll, 900 s delay, 1 MiB payload,
4
+ 128-character FIFO ids) is checked here, so a bad value fails at boot instead of on the
5
+ first send or receive (P3, P16).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass, field
12
+ from typing import TYPE_CHECKING
13
+
14
+ from taskiq_llm_queue.broker.errors import BrokerConfigError
15
+
16
+ if TYPE_CHECKING:
17
+ from taskiq_llm_queue.protocols import ReceiveGate
18
+
19
+ MAX_BATCH_ENTRIES = 10
20
+ MAX_WAIT_TIME_SECS = 20
21
+ MAX_DELAY_SECS = 900
22
+ MAX_VISIBILITY_SECS = 43_200
23
+ MAX_MESSAGE_BYTES = 1_048_576
24
+ MAX_BATCH_BYTES = 1_048_576
25
+ # DS15: flush a send batch before 900 KB so attribute overhead never crosses the 1 MiB cap.
26
+ DEFAULT_BATCH_BYTES = 900_000
27
+ MAX_FIFO_ID_CHARS = 128
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class Batching:
32
+ """Send batching for one queue (P8): up to `size` messages or `max_bytes` per call.
33
+
34
+ A partial batch is sent after `timeout_secs`; `kick` awaits the send so callers still
35
+ see failures.
36
+ """
37
+
38
+ size: int = MAX_BATCH_ENTRIES
39
+ timeout_secs: float = 0.05
40
+ max_bytes: int = DEFAULT_BATCH_BYTES
41
+
42
+ def __post_init__(self) -> None:
43
+ """Reject values SQS would refuse."""
44
+ errors = []
45
+ if not 1 <= self.size <= MAX_BATCH_ENTRIES:
46
+ errors.append(f"batching.size must be 1..{MAX_BATCH_ENTRIES}, got {self.size}")
47
+ if not 0 <= self.timeout_secs <= 10:
48
+ errors.append(f"batching.timeout_secs must be 0..10, got {self.timeout_secs}")
49
+ if not 1024 <= self.max_bytes <= MAX_BATCH_BYTES:
50
+ errors.append(f"batching.max_bytes must be 1024..{MAX_BATCH_BYTES}")
51
+ if errors:
52
+ raise BrokerConfigError("; ".join(errors))
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class Heartbeat:
57
+ """Visibility extension for received messages (P13, DS6), sent in batches.
58
+
59
+ `interval_secs` defaults to (and is capped at) a third of the queue's visibility timeout;
60
+ `max_extensions=None` extends until the message is acked or released, but never past the
61
+ message's `expiry` (its `deadline_at`) plus `deadline_grace_secs` (DS6).
62
+ """
63
+
64
+ interval_secs: float | None = None
65
+ max_extensions: int | None = None
66
+ flush_secs: float = 0.5
67
+ deadline_grace_secs: float = 60.0
68
+
69
+ def __post_init__(self) -> None:
70
+ """Reject non-positive intervals and negative extension counts."""
71
+ if self.interval_secs is not None and self.interval_secs <= 0:
72
+ raise BrokerConfigError("heartbeat.interval_secs must be > 0")
73
+ if self.max_extensions is not None and self.max_extensions < 0:
74
+ raise BrokerConfigError("heartbeat.max_extensions must be >= 0")
75
+ if not 0 < self.flush_secs <= 1:
76
+ raise BrokerConfigError("heartbeat.flush_secs must be in (0, 1] (DS6)")
77
+ if self.deadline_grace_secs < 0:
78
+ raise BrokerConfigError("heartbeat.deadline_grace_secs must be >= 0")
79
+
80
+ def interval_for(self, visibility_secs: int) -> float:
81
+ """Seconds between extensions for a queue with this visibility timeout (≤ visibility/3)."""
82
+ limit = max(visibility_secs / 3, 0.1)
83
+ return limit if self.interval_secs is None else min(self.interval_secs, limit)
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class SQSQueue:
88
+ """One SQS queue the broker sends to and receives from.
89
+
90
+ `name` is the routing key (`queue_name` label) and the SQS queue name unless `url` is
91
+ given. `priority` 0 is highest. `visibility_timeout_secs=None` reads the queue's own
92
+ setting at startup. `gate` pauses receiving while closed (A4).
93
+ """
94
+
95
+ name: str
96
+ priority: int = 0
97
+ url: str | None = None
98
+ wait_time_secs: int = MAX_WAIT_TIME_SECS
99
+ max_messages: int = MAX_BATCH_ENTRIES
100
+ visibility_timeout_secs: int | None = None
101
+ is_fifo: bool | None = None
102
+ is_fair: bool = False
103
+ default_delay_secs: int = 0
104
+ batching: Batching | None = None
105
+ is_declare: bool = False
106
+ attributes: Mapping[str, str] = field(default_factory=dict)
107
+ gate: ReceiveGate | None = None
108
+
109
+ def __post_init__(self) -> None:
110
+ """Validate every limit at once and derive `is_fifo` from the `.fifo` suffix."""
111
+ sqs_name = (self.url or self.name).rstrip("/").rsplit("/", 1)[-1]
112
+ if self.is_fifo is None:
113
+ object.__setattr__(self, "is_fifo", sqs_name.endswith(".fifo"))
114
+ errors = []
115
+ if not self.name:
116
+ errors.append("name is empty")
117
+ if self.priority < 0:
118
+ errors.append(f"priority must be >= 0, got {self.priority}")
119
+ if not 0 <= self.wait_time_secs <= MAX_WAIT_TIME_SECS:
120
+ errors.append(f"wait_time_secs must be 0..{MAX_WAIT_TIME_SECS}")
121
+ if not 1 <= self.max_messages <= MAX_BATCH_ENTRIES:
122
+ errors.append(f"max_messages must be 1..{MAX_BATCH_ENTRIES}")
123
+ if self.visibility_timeout_secs is not None and not (
124
+ 0 <= self.visibility_timeout_secs <= MAX_VISIBILITY_SECS
125
+ ):
126
+ errors.append(f"visibility_timeout_secs must be 0..{MAX_VISIBILITY_SECS}")
127
+ if not 0 <= self.default_delay_secs <= MAX_DELAY_SECS:
128
+ errors.append(f"default_delay_secs must be 0..{MAX_DELAY_SECS}")
129
+ if self.is_fifo and not sqs_name.endswith(".fifo"):
130
+ errors.append("a FIFO queue name must end with '.fifo'")
131
+ if self.is_fifo and self.is_fair:
132
+ errors.append("is_fair applies to standard queues only")
133
+ if self.is_fifo and self.default_delay_secs:
134
+ errors.append("per-message delay is not supported on FIFO queues (DS19)")
135
+ if errors:
136
+ raise BrokerConfigError(f"queue {self.name!r}: " + "; ".join(errors))
137
+
138
+ @property
139
+ def fifo(self) -> bool:
140
+ """`is_fifo` as a plain bool (resolved in `__post_init__`)."""
141
+ return bool(self.is_fifo)