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,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)
|