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,188 @@
|
|
|
1
|
+
"""Micro-batching for SQS batch APIs: send, delete (ack) and change-visibility (heartbeat).
|
|
2
|
+
|
|
3
|
+
Items are grouped until `max_items` or `max_bytes` is reached or the oldest item has waited
|
|
4
|
+
`max_delay_secs`, then handed to one flush call. Batches of one batcher are flushed one at a
|
|
5
|
+
time and in order, which keeps FIFO group order for sends (P8, A6, DS7, DS15).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
from collections import deque
|
|
13
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Generic, TypeVar
|
|
16
|
+
|
|
17
|
+
from taskiq_llm_queue._logging import get_logger
|
|
18
|
+
|
|
19
|
+
logger = get_logger(__name__)
|
|
20
|
+
|
|
21
|
+
T = TypeVar("T")
|
|
22
|
+
R = TypeVar("R")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class _Pending(Generic[T, R]):
|
|
27
|
+
item: T
|
|
28
|
+
size: int
|
|
29
|
+
future: asyncio.Future[R]
|
|
30
|
+
queued_at: float
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MicroBatcher(Generic[T, R]):
|
|
34
|
+
"""Collects items and flushes them in bounded batches through one async callable.
|
|
35
|
+
|
|
36
|
+
`flush` receives the items of one batch and returns one result per item, in order; an
|
|
37
|
+
item whose result is a `BaseException` fails its future with it. Results are always
|
|
38
|
+
retrieved, so fire-and-forget callers (acks) never log "exception never retrieved".
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
flush: Callable[[Sequence[T]], Awaitable[Sequence[R | BaseException]]],
|
|
44
|
+
*,
|
|
45
|
+
max_items: int = 10,
|
|
46
|
+
max_bytes: int | None = None,
|
|
47
|
+
size_of: Callable[[T], int] | None = None,
|
|
48
|
+
max_delay_secs: float = 0.5,
|
|
49
|
+
name: str = "batcher",
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Configure limits; the background flush task starts on the first `submit`."""
|
|
52
|
+
self._flush = flush
|
|
53
|
+
self._max_items = max_items
|
|
54
|
+
self._max_bytes = max_bytes
|
|
55
|
+
self._size_of = size_of
|
|
56
|
+
self._max_delay_secs = max_delay_secs
|
|
57
|
+
self._name = name
|
|
58
|
+
self._pending: deque[_Pending[T, R]] = deque()
|
|
59
|
+
self._pending_bytes = 0
|
|
60
|
+
self._wake = asyncio.Event()
|
|
61
|
+
self._task: asyncio.Task[None] | None = None
|
|
62
|
+
self._closing = False
|
|
63
|
+
self._force = False
|
|
64
|
+
self._in_flight = 0
|
|
65
|
+
self._idle = asyncio.Event()
|
|
66
|
+
self._idle.set()
|
|
67
|
+
|
|
68
|
+
def __len__(self) -> int:
|
|
69
|
+
return len(self._pending) + self._in_flight
|
|
70
|
+
|
|
71
|
+
def submit(self, item: T) -> asyncio.Future[R]:
|
|
72
|
+
"""Queue one item and return a future for its result; never blocks."""
|
|
73
|
+
if self._closing:
|
|
74
|
+
raise RuntimeError(f"{self._name} is closed")
|
|
75
|
+
loop = asyncio.get_running_loop()
|
|
76
|
+
future: asyncio.Future[R] = loop.create_future()
|
|
77
|
+
future.add_done_callback(_retrieve)
|
|
78
|
+
size = self._size_of(item) if self._size_of else 0
|
|
79
|
+
self._pending.append(_Pending(item, size, future, loop.time()))
|
|
80
|
+
self._pending_bytes += size
|
|
81
|
+
self._idle.clear()
|
|
82
|
+
if self._task is None or self._task.done():
|
|
83
|
+
self._task = asyncio.create_task(self._run(), name=f"llmq-{self._name}")
|
|
84
|
+
self._wake.set()
|
|
85
|
+
return future
|
|
86
|
+
|
|
87
|
+
async def flush_now(self) -> None:
|
|
88
|
+
"""Send everything queued so far and wait until those batches are done."""
|
|
89
|
+
if self._idle.is_set():
|
|
90
|
+
return
|
|
91
|
+
self._force = True
|
|
92
|
+
self._wake.set()
|
|
93
|
+
try:
|
|
94
|
+
await self._idle.wait()
|
|
95
|
+
finally:
|
|
96
|
+
self._force = False
|
|
97
|
+
|
|
98
|
+
async def close(self) -> None:
|
|
99
|
+
"""Flush what is queued, then stop the background task; idempotent."""
|
|
100
|
+
self._closing = True
|
|
101
|
+
await self.flush_now()
|
|
102
|
+
if self._task is not None:
|
|
103
|
+
self._task.cancel()
|
|
104
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
105
|
+
await self._task
|
|
106
|
+
self._task = None
|
|
107
|
+
|
|
108
|
+
def _full(self) -> bool:
|
|
109
|
+
return len(self._pending) >= self._max_items or (
|
|
110
|
+
self._max_bytes is not None and self._pending_bytes >= self._max_bytes
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def _take(self) -> list[_Pending[T, R]]:
|
|
114
|
+
batch: list[_Pending[T, R]] = []
|
|
115
|
+
total = 0
|
|
116
|
+
while self._pending and len(batch) < self._max_items:
|
|
117
|
+
nxt = self._pending[0]
|
|
118
|
+
if batch and self._max_bytes is not None and total + nxt.size > self._max_bytes:
|
|
119
|
+
break
|
|
120
|
+
batch.append(self._pending.popleft())
|
|
121
|
+
total += nxt.size
|
|
122
|
+
self._pending_bytes -= nxt.size
|
|
123
|
+
return batch
|
|
124
|
+
|
|
125
|
+
async def _run(self) -> None:
|
|
126
|
+
loop = asyncio.get_running_loop()
|
|
127
|
+
while True:
|
|
128
|
+
if not self._pending:
|
|
129
|
+
self._wake.clear()
|
|
130
|
+
await self._wake.wait()
|
|
131
|
+
continue
|
|
132
|
+
deadline = self._pending[0].queued_at + self._max_delay_secs
|
|
133
|
+
while not (self._full() or self._force or self._closing):
|
|
134
|
+
remaining = deadline - loop.time()
|
|
135
|
+
if remaining <= 0:
|
|
136
|
+
break
|
|
137
|
+
self._wake.clear()
|
|
138
|
+
with contextlib.suppress(TimeoutError):
|
|
139
|
+
await asyncio.wait_for(self._wake.wait(), remaining)
|
|
140
|
+
batch = self._take()
|
|
141
|
+
if batch:
|
|
142
|
+
await self._send(batch)
|
|
143
|
+
if not self._pending:
|
|
144
|
+
self._idle.set()
|
|
145
|
+
|
|
146
|
+
async def _send(self, batch: list[_Pending[T, R]]) -> None:
|
|
147
|
+
self._in_flight += len(batch)
|
|
148
|
+
try:
|
|
149
|
+
results: Sequence[R | BaseException] = await self._flush([p.item for p in batch])
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
logger.warning(
|
|
152
|
+
"batch_flush_failed", batcher=self._name, size=len(batch), error=str(exc)
|
|
153
|
+
)
|
|
154
|
+
results = [exc] * len(batch)
|
|
155
|
+
finally:
|
|
156
|
+
self._in_flight -= len(batch)
|
|
157
|
+
if len(results) != len(batch):
|
|
158
|
+
mismatch = RuntimeError(f"{self._name}: flush returned {len(results)} results")
|
|
159
|
+
results = [mismatch] * len(batch)
|
|
160
|
+
for pending, result in zip(batch, results, strict=True):
|
|
161
|
+
if pending.future.done():
|
|
162
|
+
continue
|
|
163
|
+
if isinstance(result, BaseException):
|
|
164
|
+
pending.future.set_exception(result)
|
|
165
|
+
else:
|
|
166
|
+
pending.future.set_result(result)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _retrieve(future: asyncio.Future[Any]) -> None:
|
|
170
|
+
if not future.cancelled():
|
|
171
|
+
future.exception()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def message_bytes(body: str, attributes: Mapping[str, Mapping[str, Any]] | None = None) -> int:
|
|
175
|
+
"""Size SQS counts for one message: UTF-8 body plus attribute names, types and values.
|
|
176
|
+
|
|
177
|
+
Used to keep `SendMessageBatch` under the 1 MiB request cap (DS15).
|
|
178
|
+
"""
|
|
179
|
+
size = len(body.encode("utf-8"))
|
|
180
|
+
for name, value in (attributes or {}).items():
|
|
181
|
+
size += len(name.encode("utf-8")) + len(str(value.get("DataType", "")).encode("utf-8"))
|
|
182
|
+
string_value = value.get("StringValue")
|
|
183
|
+
if string_value is not None:
|
|
184
|
+
size += len(str(string_value).encode("utf-8"))
|
|
185
|
+
binary_value = value.get("BinaryValue")
|
|
186
|
+
if isinstance(binary_value, bytes | bytearray):
|
|
187
|
+
size += len(binary_value)
|
|
188
|
+
return size
|