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