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,346 @@
|
|
|
1
|
+
"""Large task payloads through S3: SQS carries a small reference, S3 carries the args (P9).
|
|
2
|
+
|
|
3
|
+
`S3OffloadMiddleware` uploads a task's args/kwargs as JSON when the serialized message is over
|
|
4
|
+
a threshold and restores them on the worker, so the broker's byte-aware send batching (DS15)
|
|
5
|
+
only ever sees small messages. Adapted from taskiq-sqs `S3OffloadMiddleware` (MIT, see NOTICE).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
import orjson
|
|
14
|
+
from aiobotocore.config import AioConfig
|
|
15
|
+
from aiobotocore.session import AioSession, get_session
|
|
16
|
+
from botocore.exceptions import ClientError
|
|
17
|
+
from taskiq import TaskiqMiddleware
|
|
18
|
+
|
|
19
|
+
from taskiq_llm_queue._logging import get_logger
|
|
20
|
+
from taskiq_llm_queue.errors import LLMQueueError
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from taskiq import TaskiqMessage, TaskiqResult
|
|
24
|
+
from types_aiobotocore_s3 import S3Client
|
|
25
|
+
|
|
26
|
+
from taskiq_llm_queue.broker.broker import HybridSQSBroker
|
|
27
|
+
|
|
28
|
+
logger = get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
OFFLOAD_LABEL = "s3_offload_key"
|
|
31
|
+
DEFAULT_THRESHOLD_BYTES = 200_000 # DS15: 200 KB inline limit on the serialized taskiq message
|
|
32
|
+
_MISSING_CODES = frozenset({"NoSuchKey", "404", "NotFound"})
|
|
33
|
+
_BUCKET_EXISTS_CODES = frozenset({"BucketAlreadyOwnedByYou", "BucketAlreadyExists"})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class S3OffloadError(LLMQueueError):
|
|
37
|
+
"""S3 offload failed: bucket missing, payload unreadable or not JSON-serializable."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OffloadedPayloadMissingError(S3OffloadError):
|
|
41
|
+
"""The S3 object a message refers to is gone (deleted, expired or never written).
|
|
42
|
+
|
|
43
|
+
Raised from `pre_execute` so the task never runs with empty arguments. On a
|
|
44
|
+
`HybridSQSBroker` the message is acked (discarded as poison) first, since taskiq's
|
|
45
|
+
Receiver would otherwise leave the delivery unfinished; elsewhere it is not acked.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_missing(exc: ClientError) -> bool:
|
|
50
|
+
"""True when a botocore error means "no such object/bucket" (spellings differ per call)."""
|
|
51
|
+
return str(exc.response.get("Error", {}).get("Code", "")) in _MISSING_CODES
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class S3Connection:
|
|
55
|
+
"""Owns one aiobotocore S3 client for a bucket, shared by `startup` users and refcounted.
|
|
56
|
+
|
|
57
|
+
Used by `S3OffloadMiddleware` and `S3ResultBackend`. An injected `client` is never closed;
|
|
58
|
+
an owned one is created in the first `startup` and closed when the last user shuts down.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
bucket: str,
|
|
64
|
+
*,
|
|
65
|
+
client: S3Client | None = None,
|
|
66
|
+
session: AioSession | None = None,
|
|
67
|
+
endpoint_url: str | None = None,
|
|
68
|
+
region_name: str | None = None,
|
|
69
|
+
aws_access_key_id: str | None = None,
|
|
70
|
+
aws_secret_access_key: str | None = None,
|
|
71
|
+
aws_session_token: str | None = None,
|
|
72
|
+
client_config: AioConfig | None = None,
|
|
73
|
+
declare: bool = False,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Keep settings only (no I/O); `declare` creates the bucket in `startup` if absent."""
|
|
76
|
+
if not bucket:
|
|
77
|
+
raise ValueError("bucket must be a non-empty name")
|
|
78
|
+
self.bucket = bucket
|
|
79
|
+
self.declare = declare
|
|
80
|
+
self._client = client
|
|
81
|
+
self._owns_client = client is None
|
|
82
|
+
self._session = session
|
|
83
|
+
self._client_config = client_config
|
|
84
|
+
self._client_kwargs: dict[str, Any] = {
|
|
85
|
+
"endpoint_url": endpoint_url,
|
|
86
|
+
"region_name": region_name,
|
|
87
|
+
"aws_access_key_id": aws_access_key_id,
|
|
88
|
+
"aws_secret_access_key": aws_secret_access_key,
|
|
89
|
+
"aws_session_token": aws_session_token,
|
|
90
|
+
}
|
|
91
|
+
self._stack: contextlib.AsyncExitStack | None = None
|
|
92
|
+
self._users = 0
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def client(self) -> S3Client:
|
|
96
|
+
"""The client in use; raises `S3OffloadError` before `startup`."""
|
|
97
|
+
if self._client is None:
|
|
98
|
+
raise S3OffloadError("S3 client is not started; call startup() first")
|
|
99
|
+
return self._client
|
|
100
|
+
|
|
101
|
+
async def startup(self) -> None:
|
|
102
|
+
"""Register one user; the first creates the client and checks (or creates) the bucket."""
|
|
103
|
+
self._users += 1
|
|
104
|
+
if self._users > 1:
|
|
105
|
+
return
|
|
106
|
+
try:
|
|
107
|
+
if self._client is None:
|
|
108
|
+
stack = contextlib.AsyncExitStack()
|
|
109
|
+
session = self._session or get_session()
|
|
110
|
+
try:
|
|
111
|
+
self._client = await stack.enter_async_context(
|
|
112
|
+
session.create_client(
|
|
113
|
+
"s3", config=self._client_config, **self._client_kwargs
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
except BaseException:
|
|
117
|
+
await stack.aclose()
|
|
118
|
+
raise
|
|
119
|
+
self._stack = stack
|
|
120
|
+
await self._ensure_bucket()
|
|
121
|
+
except BaseException:
|
|
122
|
+
self._users -= 1
|
|
123
|
+
await self.aclose()
|
|
124
|
+
raise
|
|
125
|
+
|
|
126
|
+
async def shutdown(self) -> None:
|
|
127
|
+
"""Drop one user; close the owned client once none is left. Safe to call repeatedly."""
|
|
128
|
+
self._users = max(0, self._users - 1)
|
|
129
|
+
if self._users == 0:
|
|
130
|
+
await self.aclose()
|
|
131
|
+
|
|
132
|
+
async def aclose(self) -> None:
|
|
133
|
+
"""Close the owned client now, whoever still uses it; idempotent."""
|
|
134
|
+
if self._owns_client and self._stack is not None:
|
|
135
|
+
stack, self._stack, self._client = self._stack, None, None
|
|
136
|
+
await stack.aclose()
|
|
137
|
+
|
|
138
|
+
async def _ensure_bucket(self) -> None:
|
|
139
|
+
try:
|
|
140
|
+
await self.client.head_bucket(Bucket=self.bucket)
|
|
141
|
+
return
|
|
142
|
+
except ClientError as exc:
|
|
143
|
+
if not is_missing(exc):
|
|
144
|
+
raise
|
|
145
|
+
if not self.declare:
|
|
146
|
+
raise S3OffloadError(f"S3 bucket {self.bucket!r} does not exist") from None
|
|
147
|
+
region = self.client.meta.region_name
|
|
148
|
+
kwargs: dict[str, Any] = {"Bucket": self.bucket}
|
|
149
|
+
if region and region != "us-east-1":
|
|
150
|
+
kwargs["CreateBucketConfiguration"] = {"LocationConstraint": region}
|
|
151
|
+
try:
|
|
152
|
+
await self.client.create_bucket(**kwargs)
|
|
153
|
+
except ClientError as exc:
|
|
154
|
+
if str(exc.response.get("Error", {}).get("Code", "")) not in _BUCKET_EXISTS_CODES:
|
|
155
|
+
raise
|
|
156
|
+
|
|
157
|
+
async def get_bytes(self, key: str) -> bytes | None:
|
|
158
|
+
"""Read one object, or None when it does not exist."""
|
|
159
|
+
try:
|
|
160
|
+
response = await self.client.get_object(Bucket=self.bucket, Key=key)
|
|
161
|
+
except ClientError as exc:
|
|
162
|
+
if is_missing(exc):
|
|
163
|
+
return None
|
|
164
|
+
raise
|
|
165
|
+
async with response["Body"] as stream:
|
|
166
|
+
return await stream.read()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def object_key(prefix: str, task_id: str) -> str:
|
|
170
|
+
"""`<prefix>/<task_id>.json`, or `<task_id>.json` for an empty prefix."""
|
|
171
|
+
if not task_id or "/" in task_id or task_id in {".", ".."}:
|
|
172
|
+
raise ValueError(f"task_id {task_id!r} cannot be used as an S3 key segment")
|
|
173
|
+
prefix = prefix.strip("/")
|
|
174
|
+
return f"{prefix}/{task_id}.json" if prefix else f"{task_id}.json"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class S3OffloadMiddleware(TaskiqMiddleware):
|
|
178
|
+
"""Moves args/kwargs of oversized messages to `s3://bucket/<base_path>/<task_id>.json`.
|
|
179
|
+
|
|
180
|
+
`pre_send` (client) offloads when the formatted message exceeds `threshold_bytes` and puts
|
|
181
|
+
the object key in the `s3_offload_key` label; `pre_execute` (worker) restores them.
|
|
182
|
+
After a successful run the object is deleted when `delete_after_execute`; on a
|
|
183
|
+
`HybridSQSBroker` only once SQS confirmed the message's ack, never when the task released
|
|
184
|
+
it (defer, drain), so a redelivery can still read it. A failed run keeps the object; add
|
|
185
|
+
an S3 lifecycle rule on the prefix to expire leftovers. Payloads are JSON only.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def __init__(
|
|
189
|
+
self,
|
|
190
|
+
bucket: str,
|
|
191
|
+
*,
|
|
192
|
+
threshold_bytes: int = DEFAULT_THRESHOLD_BYTES,
|
|
193
|
+
base_path: str = "taskiq-offload",
|
|
194
|
+
delete_after_execute: bool = True,
|
|
195
|
+
declare: bool = False,
|
|
196
|
+
client: S3Client | None = None,
|
|
197
|
+
session: AioSession | None = None,
|
|
198
|
+
endpoint_url: str | None = None,
|
|
199
|
+
region_name: str | None = None,
|
|
200
|
+
aws_access_key_id: str | None = None,
|
|
201
|
+
aws_secret_access_key: str | None = None,
|
|
202
|
+
aws_session_token: str | None = None,
|
|
203
|
+
client_config: AioConfig | None = None,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Keep settings only; the S3 client is created in `startup` (no I/O here).
|
|
206
|
+
|
|
207
|
+
`threshold_bytes` is compared with the UTF-8 size of the whole formatted message
|
|
208
|
+
(broker formatter when attached, else JSON of args/kwargs).
|
|
209
|
+
"""
|
|
210
|
+
super().__init__()
|
|
211
|
+
if threshold_bytes < 1:
|
|
212
|
+
raise ValueError("threshold_bytes must be >= 1")
|
|
213
|
+
self.threshold_bytes = threshold_bytes
|
|
214
|
+
self.base_path = base_path
|
|
215
|
+
self.delete_after_execute = delete_after_execute
|
|
216
|
+
self.s3 = S3Connection(
|
|
217
|
+
bucket,
|
|
218
|
+
client=client,
|
|
219
|
+
session=session,
|
|
220
|
+
endpoint_url=endpoint_url,
|
|
221
|
+
region_name=region_name,
|
|
222
|
+
aws_access_key_id=aws_access_key_id,
|
|
223
|
+
aws_secret_access_key=aws_secret_access_key,
|
|
224
|
+
aws_session_token=aws_session_token,
|
|
225
|
+
client_config=client_config,
|
|
226
|
+
declare=declare,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
async def startup(self) -> None:
|
|
230
|
+
"""Create the S3 client and check (or declare) the bucket; runs in broker startup."""
|
|
231
|
+
await self.s3.startup()
|
|
232
|
+
|
|
233
|
+
async def shutdown(self) -> None:
|
|
234
|
+
"""Close the owned S3 client; runs in broker shutdown."""
|
|
235
|
+
await self.s3.shutdown()
|
|
236
|
+
|
|
237
|
+
def message_size(self, message: TaskiqMessage) -> int:
|
|
238
|
+
"""Bytes the message would take on the wire, as the threshold check sees it."""
|
|
239
|
+
broker = getattr(self, "broker", None)
|
|
240
|
+
if broker is not None:
|
|
241
|
+
return len(broker.formatter.dumps(message).message)
|
|
242
|
+
return len(encode_payload(message.args, message.kwargs))
|
|
243
|
+
|
|
244
|
+
async def pre_send(self, message: TaskiqMessage) -> TaskiqMessage:
|
|
245
|
+
"""Upload args/kwargs and send a reference when the message is over the threshold."""
|
|
246
|
+
message.labels.pop(OFFLOAD_LABEL, None) # a re-kicked message must not reuse an old key
|
|
247
|
+
if self.message_size(message) <= self.threshold_bytes:
|
|
248
|
+
return message
|
|
249
|
+
payload = encode_payload(message.args, message.kwargs)
|
|
250
|
+
key = object_key(self.base_path, message.task_id)
|
|
251
|
+
await self.s3.client.put_object(
|
|
252
|
+
Bucket=self.s3.bucket, Key=key, Body=payload, ContentType="application/json"
|
|
253
|
+
)
|
|
254
|
+
logger.debug("offload_stored", task_id=message.task_id, size_bytes=len(payload))
|
|
255
|
+
message.args = []
|
|
256
|
+
message.kwargs = {}
|
|
257
|
+
message.labels[OFFLOAD_LABEL] = key
|
|
258
|
+
return message
|
|
259
|
+
|
|
260
|
+
def _sqs_broker(self) -> HybridSQSBroker | None:
|
|
261
|
+
from taskiq_llm_queue.broker.broker import HybridSQSBroker
|
|
262
|
+
|
|
263
|
+
broker = getattr(self, "broker", None)
|
|
264
|
+
return broker if isinstance(broker, HybridSQSBroker) else None
|
|
265
|
+
|
|
266
|
+
async def pre_execute(self, message: TaskiqMessage) -> TaskiqMessage:
|
|
267
|
+
"""Restore offloaded args/kwargs; raises `OffloadedPayloadMissingError` if gone.
|
|
268
|
+
|
|
269
|
+
taskiq calls this outside its ack path, so on a `HybridSQSBroker` the delivery is
|
|
270
|
+
finished before raising: acked when the object is gone, released when S3 failed.
|
|
271
|
+
"""
|
|
272
|
+
key = message.labels.get(OFFLOAD_LABEL)
|
|
273
|
+
if not key:
|
|
274
|
+
return message
|
|
275
|
+
sqs = self._sqs_broker()
|
|
276
|
+
try:
|
|
277
|
+
body = await self.s3.get_bytes(str(key))
|
|
278
|
+
if body is None:
|
|
279
|
+
raise OffloadedPayloadMissingError(
|
|
280
|
+
f"offloaded payload of task {message.task_id} not found at "
|
|
281
|
+
f"s3://{self.s3.bucket}/{key}"
|
|
282
|
+
)
|
|
283
|
+
message.args, message.kwargs = decode_payload(body)
|
|
284
|
+
except Exception as exc:
|
|
285
|
+
delivery = sqs.delivery_for_task(message.task_id) if sqs is not None else None
|
|
286
|
+
if sqs is not None and delivery is not None:
|
|
287
|
+
logger.error(
|
|
288
|
+
"offload_restore_failed",
|
|
289
|
+
task_id=message.task_id,
|
|
290
|
+
key=key,
|
|
291
|
+
error_type=type(exc).__name__,
|
|
292
|
+
)
|
|
293
|
+
if isinstance(exc, S3OffloadError): # gone or corrupt
|
|
294
|
+
await sqs.discard(delivery, reason="offload_unreadable") # never runnable
|
|
295
|
+
else:
|
|
296
|
+
await sqs.release(delivery, visibility_secs=30, reason="offload_read_failed")
|
|
297
|
+
raise
|
|
298
|
+
return message
|
|
299
|
+
|
|
300
|
+
async def post_execute(self, message: TaskiqMessage, result: TaskiqResult[Any]) -> None:
|
|
301
|
+
"""Delete the offloaded object after a successful run when `delete_after_execute`.
|
|
302
|
+
|
|
303
|
+
On a `HybridSQSBroker` the delete waits for the message's ack and is skipped when the
|
|
304
|
+
task released it instead, since the redelivery still needs the payload.
|
|
305
|
+
"""
|
|
306
|
+
key = message.labels.get(OFFLOAD_LABEL)
|
|
307
|
+
if not key or not self.delete_after_execute or result.is_err:
|
|
308
|
+
return
|
|
309
|
+
sqs = self._sqs_broker()
|
|
310
|
+
if sqs is None:
|
|
311
|
+
await self._delete(str(key))
|
|
312
|
+
return
|
|
313
|
+
delivery = sqs.delivery_for_task(message.task_id)
|
|
314
|
+
if delivery is None: # released (defer, drain, unknown job) or already finished
|
|
315
|
+
logger.debug("offload_kept", task_id=message.task_id, key=key)
|
|
316
|
+
return
|
|
317
|
+
delivery.after_ack.append(lambda: self._delete(str(key)))
|
|
318
|
+
|
|
319
|
+
async def _delete(self, key: str) -> None:
|
|
320
|
+
try:
|
|
321
|
+
await self.s3.client.delete_object(Bucket=self.s3.bucket, Key=key)
|
|
322
|
+
except ClientError as exc: # the result is already computed; a lifecycle rule cleans up
|
|
323
|
+
logger.warning("offload_delete_failed", key=key, error=str(exc))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def encode_payload(args: list[Any], kwargs: dict[str, Any]) -> bytes:
|
|
327
|
+
"""JSON bytes of `{"args", "kwargs"}`; raises `S3OffloadError` for non-JSON values."""
|
|
328
|
+
try:
|
|
329
|
+
return orjson.dumps({"args": args, "kwargs": kwargs})
|
|
330
|
+
except (TypeError, ValueError) as exc:
|
|
331
|
+
raise S3OffloadError(f"task arguments are not JSON-serializable: {exc}") from exc
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def decode_payload(body: bytes) -> tuple[list[Any], dict[str, Any]]:
|
|
335
|
+
"""Inverse of `encode_payload`; raises `S3OffloadError` for anything else."""
|
|
336
|
+
try:
|
|
337
|
+
data = orjson.loads(body)
|
|
338
|
+
except ValueError as exc:
|
|
339
|
+
raise S3OffloadError("offloaded payload is not valid JSON") from exc
|
|
340
|
+
if (
|
|
341
|
+
not isinstance(data, dict)
|
|
342
|
+
or not isinstance(data.get("args"), list)
|
|
343
|
+
or not isinstance(data.get("kwargs"), dict)
|
|
344
|
+
):
|
|
345
|
+
raise S3OffloadError("offloaded payload is not an {args, kwargs} object")
|
|
346
|
+
return data["args"], data["kwargs"]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Capacity and priority accounting shared by the broker's per-queue pollers (A1, A2, DS4).
|
|
2
|
+
|
|
3
|
+
A slot is held from the moment a poller reserves it for a `ReceiveMessage` call until the
|
|
4
|
+
delivered message is acked or released; unused reservations return as soon as the receive
|
|
5
|
+
comes back. Pollers therefore never receive more than the free slots, even when several
|
|
6
|
+
long polls run at once.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from collections import Counter
|
|
13
|
+
|
|
14
|
+
from taskiq_llm_queue.broker.errors import BrokerConfigError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SlotScheduler:
|
|
18
|
+
"""Strict-priority slot accounting for `max_inflight` delivered-not-acked messages.
|
|
19
|
+
|
|
20
|
+
`reserve_for_priority0` slots are usable only by priority 0; `min_slots_for_lower` slots
|
|
21
|
+
are always kept for priorities > 0 (priority 0 can never take them). A lower priority
|
|
22
|
+
also yields freed slots to any higher priority that is waiting for capacity, except
|
|
23
|
+
inside its guaranteed share.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
max_inflight: int,
|
|
29
|
+
*,
|
|
30
|
+
reserve_for_priority0: int = 0,
|
|
31
|
+
min_slots_for_lower: int = 0,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Validate the split; the three numbers must fit inside `max_inflight`."""
|
|
34
|
+
if max_inflight < 1:
|
|
35
|
+
raise BrokerConfigError(f"max_inflight must be >= 1, got {max_inflight}")
|
|
36
|
+
if reserve_for_priority0 < 0 or min_slots_for_lower < 0:
|
|
37
|
+
raise BrokerConfigError("reserve_for_priority0 and min_slots_for_lower must be >= 0")
|
|
38
|
+
if reserve_for_priority0 + min_slots_for_lower > max_inflight:
|
|
39
|
+
raise BrokerConfigError(
|
|
40
|
+
"reserve_for_priority0 + min_slots_for_lower must not exceed max_inflight"
|
|
41
|
+
)
|
|
42
|
+
self.max_inflight = max_inflight
|
|
43
|
+
self.reserve_for_priority0 = reserve_for_priority0
|
|
44
|
+
self.min_slots_for_lower = min_slots_for_lower
|
|
45
|
+
self._held: Counter[int] = Counter()
|
|
46
|
+
self._waiting: Counter[int] = Counter()
|
|
47
|
+
self._changed: asyncio.Event | None = None
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def held(self) -> int:
|
|
51
|
+
"""Slots in use: reserved for a pending receive or delivered and not yet acked."""
|
|
52
|
+
return sum(self._held.values())
|
|
53
|
+
|
|
54
|
+
def held_by(self, priority: int) -> int:
|
|
55
|
+
"""Slots held by one priority level."""
|
|
56
|
+
return self._held[priority]
|
|
57
|
+
|
|
58
|
+
def budget(self, priority: int) -> int:
|
|
59
|
+
"""How many more messages a poller of this priority may receive right now."""
|
|
60
|
+
free = self.max_inflight - self.held
|
|
61
|
+
lower_held = sum(n for p, n in self._held.items() if p > 0)
|
|
62
|
+
if priority == 0:
|
|
63
|
+
owed_to_lower = max(0, self.min_slots_for_lower - lower_held)
|
|
64
|
+
return max(0, free - owed_to_lower)
|
|
65
|
+
lower_cap = max(self.max_inflight - self.reserve_for_priority0, self.min_slots_for_lower)
|
|
66
|
+
allowed = min(free, lower_cap - lower_held)
|
|
67
|
+
if any(n for p, n in self._waiting.items() if p < priority):
|
|
68
|
+
allowed = min(allowed, self.min_slots_for_lower - lower_held)
|
|
69
|
+
return max(0, allowed)
|
|
70
|
+
|
|
71
|
+
async def acquire(self, priority: int, want: int) -> int:
|
|
72
|
+
"""Wait until this priority has budget, then reserve and return up to `want` slots."""
|
|
73
|
+
if want < 1:
|
|
74
|
+
return 0
|
|
75
|
+
self._waiting[priority] += 1
|
|
76
|
+
try:
|
|
77
|
+
while (budget := self.budget(priority)) <= 0:
|
|
78
|
+
await self._event().wait()
|
|
79
|
+
finally:
|
|
80
|
+
self._waiting[priority] -= 1
|
|
81
|
+
self._notify()
|
|
82
|
+
taken = min(want, budget)
|
|
83
|
+
self._held[priority] += taken
|
|
84
|
+
return taken
|
|
85
|
+
|
|
86
|
+
def release(self, priority: int, count: int = 1) -> None:
|
|
87
|
+
"""Return slots (unused reservation, ack, release or expired delete)."""
|
|
88
|
+
if count <= 0:
|
|
89
|
+
return
|
|
90
|
+
self._held[priority] = max(0, self._held[priority] - count)
|
|
91
|
+
self._notify()
|
|
92
|
+
|
|
93
|
+
def resize(self, max_inflight: int) -> None:
|
|
94
|
+
"""Change capacity at runtime, e.g. from a drain or a measured pod limit."""
|
|
95
|
+
if max_inflight < self.reserve_for_priority0 + self.min_slots_for_lower or max_inflight < 1:
|
|
96
|
+
raise BrokerConfigError("max_inflight too small for the reserved slots")
|
|
97
|
+
self.max_inflight = max_inflight
|
|
98
|
+
self._notify()
|
|
99
|
+
|
|
100
|
+
async def wait_changed(self) -> None:
|
|
101
|
+
"""Return at the next capacity change (a slot freed, a waiter left, a resize)."""
|
|
102
|
+
await self._event().wait()
|
|
103
|
+
|
|
104
|
+
def _event(self) -> asyncio.Event:
|
|
105
|
+
if self._changed is None:
|
|
106
|
+
self._changed = asyncio.Event()
|
|
107
|
+
return self._changed
|
|
108
|
+
|
|
109
|
+
def _notify(self) -> None:
|
|
110
|
+
# Waiters hold the old event; setting it wakes them, the next check uses a fresh one.
|
|
111
|
+
if self._changed is not None:
|
|
112
|
+
self._changed.set()
|
|
113
|
+
self._changed = None
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""SQS message serializer: taskiq's `ORJSONSerializer` with a stdlib fallback for odd values.
|
|
2
|
+
|
|
3
|
+
taskiq's default `JSONSerializer` escapes non-ASCII (`ensure_ascii`); measured on a 4 KB Hindi /
|
|
4
|
+
Chinese prompt it was ~45x slower to dump and the body 1.5-3x larger than orjson's UTF-8. Both
|
|
5
|
+
write plain JSON, so old and new pods read each other's messages during a rolling deploy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import orjson
|
|
14
|
+
from taskiq.serializers import ORJSONSerializer
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WireSerializer(ORJSONSerializer):
|
|
18
|
+
"""orjson for SQS bodies; values orjson refuses (ints over 64 bits) fall back to stdlib.
|
|
19
|
+
|
|
20
|
+
Output is UTF-8 JSON either way, readable by taskiq's stdlib `JSONSerializer` too.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def dumpb(self, value: Any) -> bytes:
|
|
24
|
+
"""Serialize with orjson, or stdlib `json` (no ASCII escaping) when orjson refuses."""
|
|
25
|
+
try:
|
|
26
|
+
return orjson.dumps(value, default=self.default, option=self.option)
|
|
27
|
+
except TypeError: # orjson.JSONEncodeError: e.g. an int beyond 64 bits
|
|
28
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()
|
|
29
|
+
|
|
30
|
+
def loadb(self, value: bytes) -> Any:
|
|
31
|
+
"""Parse JSON bytes (a `bytes` subclass is copied first; orjson wants exact types)."""
|
|
32
|
+
return orjson.loads(value if type(value) is bytes else bytes(value))
|