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,1409 @@
|
|
|
1
|
+
"""`LLMQueue`: the public client. Sends calls through SQS (or runs them `direct`) and works them.
|
|
2
|
+
|
|
3
|
+
One instance per process. `start(mode)` connects Valkey and SQS, starts the result dispatcher
|
|
4
|
+
(senders) and the in-process taskiq receivers (workers); `stop()` drains in the order of §7.4.
|
|
5
|
+
Calls build an envelope from `request_context`, route it, and await the reply (§6.1).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import base64
|
|
12
|
+
import contextlib
|
|
13
|
+
import math
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from collections.abc import Awaitable, Callable, Coroutine, Iterator, Mapping, Sequence
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import TYPE_CHECKING, Any, Literal, TypeVar
|
|
20
|
+
|
|
21
|
+
import orjson
|
|
22
|
+
from redis.asyncio import Redis
|
|
23
|
+
from redis.exceptions import RedisError
|
|
24
|
+
from taskiq.exceptions import SendTaskError
|
|
25
|
+
|
|
26
|
+
from taskiq_llm_queue import metrics_names as mn
|
|
27
|
+
from taskiq_llm_queue._logging import get_logger
|
|
28
|
+
from taskiq_llm_queue.broker.errors import ErrorFamily, SQSSendError
|
|
29
|
+
from taskiq_llm_queue.broker.events import BrokerHooks
|
|
30
|
+
from taskiq_llm_queue.config import Lane, create_redis, create_sync_redis
|
|
31
|
+
from taskiq_llm_queue.context import current_context
|
|
32
|
+
from taskiq_llm_queue.core import CoreDeps, InfraError, JobResult, execute_job
|
|
33
|
+
from taskiq_llm_queue.envelope import (
|
|
34
|
+
TASK_CALL,
|
|
35
|
+
TASK_EMBED,
|
|
36
|
+
TASK_JOB,
|
|
37
|
+
CallMeta,
|
|
38
|
+
Envelope,
|
|
39
|
+
LLMReply,
|
|
40
|
+
task_labels,
|
|
41
|
+
)
|
|
42
|
+
from taskiq_llm_queue.errors import (
|
|
43
|
+
ConfigError,
|
|
44
|
+
LLMQueueCancelled,
|
|
45
|
+
LLMQueueError,
|
|
46
|
+
LLMQueueTimeout,
|
|
47
|
+
ProviderError,
|
|
48
|
+
QueueUnavailable,
|
|
49
|
+
)
|
|
50
|
+
from taskiq_llm_queue.failover import FailoverSettings, FailoverState
|
|
51
|
+
from taskiq_llm_queue.keys import KeySpace
|
|
52
|
+
from taskiq_llm_queue.lifecycle import (
|
|
53
|
+
BrokerSet,
|
|
54
|
+
ClaimGroup,
|
|
55
|
+
MetricsHooks,
|
|
56
|
+
PodHeartbeat,
|
|
57
|
+
PodRegistry,
|
|
58
|
+
Workers,
|
|
59
|
+
breaker_settings,
|
|
60
|
+
build_brokers,
|
|
61
|
+
claim_settings,
|
|
62
|
+
limiter_settings,
|
|
63
|
+
queue_for_lane,
|
|
64
|
+
queue_name,
|
|
65
|
+
)
|
|
66
|
+
from taskiq_llm_queue.metrics import MetricsSink, NullMetrics
|
|
67
|
+
from taskiq_llm_queue.protocols import ClaimDecision, ClaimState
|
|
68
|
+
from taskiq_llm_queue.providers.embeddings import (
|
|
69
|
+
batches,
|
|
70
|
+
embedding_body,
|
|
71
|
+
max_batch_size,
|
|
72
|
+
parse_embeddings,
|
|
73
|
+
)
|
|
74
|
+
from taskiq_llm_queue.providers.registry import ProviderRegistry
|
|
75
|
+
from taskiq_llm_queue.reliability.breaker import CircuitBreaker
|
|
76
|
+
from taskiq_llm_queue.reliability.claims import RedisClaimStore
|
|
77
|
+
from taskiq_llm_queue.reliability.config_store import LiveConfigStore, fallback_store
|
|
78
|
+
from taskiq_llm_queue.reliability.fallback import FallbackConfig
|
|
79
|
+
from taskiq_llm_queue.reliability.limiter import AdaptiveLimiter, SyncAdaptiveLimiter
|
|
80
|
+
from taskiq_llm_queue.reliability.local_limiter import LocalLimiter
|
|
81
|
+
from taskiq_llm_queue.results.backend import LLMQResultBackend
|
|
82
|
+
from taskiq_llm_queue.results.dispatcher import ResultDispatcher, ResultsUnavailable
|
|
83
|
+
from taskiq_llm_queue.routing import CanaryRouter, RoutingConfig, routing_store
|
|
84
|
+
from taskiq_llm_queue.routing_hooks import CallPath, QueueHooks, safe_hook
|
|
85
|
+
from taskiq_llm_queue.tasks import JOB_VERSION, JobFunction, WorkerResultBackend, WorkerRuntime
|
|
86
|
+
|
|
87
|
+
if TYPE_CHECKING:
|
|
88
|
+
import redis
|
|
89
|
+
from aiobotocore.session import AioSession
|
|
90
|
+
|
|
91
|
+
from taskiq_llm_queue.config import QueueConfig
|
|
92
|
+
from taskiq_llm_queue.envelope import FallbackPolicy, Op
|
|
93
|
+
from taskiq_llm_queue.protocols import Lease, Limiter
|
|
94
|
+
from taskiq_llm_queue.providers.registry import ProviderFactory
|
|
95
|
+
from taskiq_llm_queue.routing_hooks import FailoverPolicy, PathRouter
|
|
96
|
+
|
|
97
|
+
logger = get_logger(__name__)
|
|
98
|
+
|
|
99
|
+
Mode = Literal["send", "work", "both"]
|
|
100
|
+
# HTTP status the OpenAI transport reports for an error reply, by `ErrorKind` value.
|
|
101
|
+
_STATUS_BY_KIND: Mapping[str, int] = {"throttle": 429, "retryable": 503, "fatal": 400}
|
|
102
|
+
_T = TypeVar("_T") # PEP 695 needs 3.12; the package supports 3.11
|
|
103
|
+
# Share of the remaining call budget an SQS send may use, so a failed send leaves the rest
|
|
104
|
+
# for the `direct` failover run (capped further by `sqs_send_timeout_secs`).
|
|
105
|
+
_SEND_BUDGET_SHARE = 0.5
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True, slots=True)
|
|
109
|
+
class ConverseResult:
|
|
110
|
+
"""`converse` result: the exact Bedrock Converse dict and how the call was served."""
|
|
111
|
+
|
|
112
|
+
response: dict[str, Any]
|
|
113
|
+
meta: CallMeta
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True, slots=True)
|
|
117
|
+
class ChatResult:
|
|
118
|
+
"""`chat` result: the OpenAI-compatible chat completion body and how it was served."""
|
|
119
|
+
|
|
120
|
+
response: dict[str, Any]
|
|
121
|
+
meta: CallMeta
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True, slots=True)
|
|
125
|
+
class InvokeResult:
|
|
126
|
+
"""`invoke_model` result: the raw model body, its content type and how it was served.
|
|
127
|
+
|
|
128
|
+
`response` gives the InvokeModel-style `{"body": bytes, "contentType": str}`; `json()`
|
|
129
|
+
parses a JSON body.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
body: bytes
|
|
133
|
+
content_type: str
|
|
134
|
+
meta: CallMeta
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def response(self) -> dict[str, Any]:
|
|
138
|
+
"""`{"body": bytes, "contentType": str}`, as boto3's InvokeModel reply has them."""
|
|
139
|
+
return {"body": self.body, "contentType": self.content_type}
|
|
140
|
+
|
|
141
|
+
def json(self) -> Any:
|
|
142
|
+
"""The body parsed as JSON (embedding and most text models reply in JSON)."""
|
|
143
|
+
return orjson.loads(self.body)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True, slots=True)
|
|
147
|
+
class EmbedResult:
|
|
148
|
+
"""`embed` result: one vector per input text, in input order, and how they were served.
|
|
149
|
+
|
|
150
|
+
It also behaves as the list of vectors (`len`, iteration, indexing). With several provider
|
|
151
|
+
batches, `meta` merges them: first batch's model/target, summed attempts, slowest waits.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
vectors: list[list[float]]
|
|
155
|
+
meta: CallMeta
|
|
156
|
+
|
|
157
|
+
def __len__(self) -> int:
|
|
158
|
+
return len(self.vectors)
|
|
159
|
+
|
|
160
|
+
def __iter__(self) -> Iterator[list[float]]:
|
|
161
|
+
return iter(self.vectors)
|
|
162
|
+
|
|
163
|
+
def __getitem__(self, index: int) -> list[float]:
|
|
164
|
+
return self.vectors[index]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(slots=True)
|
|
168
|
+
class _Started:
|
|
169
|
+
"""Everything `start()` creates; dropped as a whole by `stop()`."""
|
|
170
|
+
|
|
171
|
+
mode: Mode
|
|
172
|
+
redis: Redis
|
|
173
|
+
owns_redis: bool
|
|
174
|
+
results: LLMQResultBackend
|
|
175
|
+
limiter: AdaptiveLimiter
|
|
176
|
+
claims: RedisClaimStore
|
|
177
|
+
breaker: CircuitBreaker
|
|
178
|
+
fallback: LiveConfigStore[FallbackConfig]
|
|
179
|
+
dispatcher: ResultDispatcher
|
|
180
|
+
brokers: BrokerSet
|
|
181
|
+
runtime: WorkerRuntime
|
|
182
|
+
workers: Workers | None
|
|
183
|
+
pods: PodRegistry | None = None
|
|
184
|
+
tasks: list[asyncio.Task[Any]] = field(default_factory=list)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class LLMQueue:
|
|
188
|
+
"""LLM request client with its own durable queue; also the in-process worker (README).
|
|
189
|
+
|
|
190
|
+
`router` picks `sqs` or `direct` per call; without one, `routing_enabled` builds a
|
|
191
|
+
`CanaryRouter` on the live routing config, else `sqs` when the model has queues. `failover`
|
|
192
|
+
defaults to a `FailoverState` from the config. `hooks` defaults to `MetricsHooks(metrics)`
|
|
193
|
+
(if it is also a `BrokerHooks`, the brokers report to it too). `provider_factories`,
|
|
194
|
+
`redis` and `aws_session` are injection points for tests and custom wiring.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
config: QueueConfig,
|
|
200
|
+
*,
|
|
201
|
+
pod_id: str | None = None,
|
|
202
|
+
router: PathRouter | None = None,
|
|
203
|
+
failover: FailoverPolicy | None = None,
|
|
204
|
+
hooks: QueueHooks | None = None,
|
|
205
|
+
metrics: MetricsSink | None = None,
|
|
206
|
+
provider_factories: Mapping[str, ProviderFactory] | None = None,
|
|
207
|
+
redis: Redis | None = None,
|
|
208
|
+
aws_session: AioSession | None = None,
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Validate providers (boot-time `ConfigError`); no I/O until `start()`."""
|
|
211
|
+
self.config = config
|
|
212
|
+
self.pod_id = pod_id or f"pod-{uuid.uuid4().hex[:8]}"
|
|
213
|
+
self.metrics: MetricsSink = metrics or NullMetrics()
|
|
214
|
+
self.router = router
|
|
215
|
+
self._own_router: CanaryRouter | None = None
|
|
216
|
+
self.failover: FailoverPolicy = failover or FailoverState(
|
|
217
|
+
FailoverSettings(
|
|
218
|
+
sqs_failover_secs=config.sqs_failover_secs,
|
|
219
|
+
local_cap_fallback=config.local_limit_cap,
|
|
220
|
+
),
|
|
221
|
+
probe=self._probe_redis,
|
|
222
|
+
metrics=self.metrics,
|
|
223
|
+
)
|
|
224
|
+
self.hooks = hooks or MetricsHooks(self.metrics)
|
|
225
|
+
self.keys = KeySpace(config.key_prefix)
|
|
226
|
+
self._factories = dict(provider_factories or {})
|
|
227
|
+
self.registry = ProviderRegistry(config, factories=self._factories)
|
|
228
|
+
self._injected_redis = redis
|
|
229
|
+
self._injected_session = aws_session
|
|
230
|
+
self.aws_session: AioSession | None = aws_session
|
|
231
|
+
self._state: _Started | None = None
|
|
232
|
+
self._direct_tasks: dict[str, asyncio.Task[Any]] = {}
|
|
233
|
+
self._embed_groups: dict[str, list[str]] = {} # embed request id -> batch request ids
|
|
234
|
+
# DS17 claims held as `direct:<pod>` (request id -> holders) and every in-flight lease
|
|
235
|
+
# of this pod (worker, direct and sync paths): both kept alive by the pod heartbeat.
|
|
236
|
+
self._direct_claims: dict[str, int] = {}
|
|
237
|
+
self._leases: set[Lease] = set()
|
|
238
|
+
# Cancel flags that could not be set while Valkey was down; set once a probe succeeds.
|
|
239
|
+
self._pending_cancels: set[str] = set()
|
|
240
|
+
self._sent_at = -math.inf # monotonic time the last SQS send completed (any queue)
|
|
241
|
+
self._local_limiter = _LocalLimiters(self.failover, config)
|
|
242
|
+
self._jobs: dict[str, JobFunction] = {}
|
|
243
|
+
self._sync: _SyncRunner | None = None
|
|
244
|
+
self._sync_lock = threading.Lock()
|
|
245
|
+
self.sync_fallback_count = 0
|
|
246
|
+
|
|
247
|
+
# ------------------------------------------------------------------ lifecycle
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def started(self) -> bool:
|
|
251
|
+
"""True between `start()` and `stop()`."""
|
|
252
|
+
return self._state is not None
|
|
253
|
+
|
|
254
|
+
async def __aenter__(self) -> LLMQueue:
|
|
255
|
+
await self.start()
|
|
256
|
+
return self
|
|
257
|
+
|
|
258
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
259
|
+
await self.stop()
|
|
260
|
+
|
|
261
|
+
async def _probe_redis(self) -> object:
|
|
262
|
+
"""Valkey health probe for the default `FailoverState`."""
|
|
263
|
+
state = self._state
|
|
264
|
+
if state is None:
|
|
265
|
+
raise QueueUnavailable("not started")
|
|
266
|
+
pong = await state.redis.ping()
|
|
267
|
+
pending, self._pending_cancels = self._pending_cancels, set()
|
|
268
|
+
for request_id in pending: # queued copies of calls served direct must not run again
|
|
269
|
+
try:
|
|
270
|
+
await state.claims.cancel(request_id)
|
|
271
|
+
except Exception:
|
|
272
|
+
self._pending_cancels.add(request_id)
|
|
273
|
+
return pong
|
|
274
|
+
|
|
275
|
+
def _redis_ok(self) -> None:
|
|
276
|
+
"""Tell the failover policy Valkey answered (ends Valkey-down mode early)."""
|
|
277
|
+
report = getattr(self.failover, "report_redis_ok", None)
|
|
278
|
+
if callable(report):
|
|
279
|
+
safe_hook(report)
|
|
280
|
+
|
|
281
|
+
async def _watch_limits(self, limiter: AdaptiveLimiter) -> None:
|
|
282
|
+
"""Feed learned limits to the failover local cap and the `llmq_limit` gauge."""
|
|
283
|
+
note = getattr(self.failover, "note_limit", None)
|
|
284
|
+
while True:
|
|
285
|
+
await asyncio.sleep(self.config.cfg_reload_secs)
|
|
286
|
+
for (model, target), limit in list(limiter.last_limits.items()):
|
|
287
|
+
if callable(note):
|
|
288
|
+
safe_hook(note, model, target, limit)
|
|
289
|
+
labels = {"model": model, "target": target}
|
|
290
|
+
safe_hook(self.metrics.gauge, mn.LIMIT, float(limit), labels)
|
|
291
|
+
|
|
292
|
+
async def start(self, mode: Mode = "both") -> None:
|
|
293
|
+
"""Connect and start: `send` (dispatcher), `work` (receivers) or `both`; idempotent."""
|
|
294
|
+
if self._state is not None:
|
|
295
|
+
return
|
|
296
|
+
if mode not in ("send", "work", "both"):
|
|
297
|
+
raise ConfigError(f"mode must be send, work or both, got {mode!r}")
|
|
298
|
+
config = self.config
|
|
299
|
+
owns = self._injected_redis is None
|
|
300
|
+
client = self._injected_redis or create_redis(config, client_name=self._redis_name())
|
|
301
|
+
# One AWS session (credential chain, IRSA refresh) for every provider of this pod.
|
|
302
|
+
self.aws_session = self._injected_session or _new_aws_session()
|
|
303
|
+
self.registry.aws_session = self.aws_session
|
|
304
|
+
results = LLMQResultBackend.from_config(config, redis=client)
|
|
305
|
+
limiter = AdaptiveLimiter(client, self.keys, limiter_settings(config))
|
|
306
|
+
claims = RedisClaimStore(client, self.keys, claim_settings(config))
|
|
307
|
+
breaker = CircuitBreaker(client, self.keys, breaker_settings(config))
|
|
308
|
+
default_chains = FallbackConfig.model_validate(config.fallback_default)
|
|
309
|
+
fallback = fallback_store(client, self.keys, default_chains)
|
|
310
|
+
dispatcher = ResultDispatcher(
|
|
311
|
+
results,
|
|
312
|
+
redis=client,
|
|
313
|
+
channel=self.keys.done_channel(self.pod_id),
|
|
314
|
+
intervals_ms=config.result_dispatch_ms,
|
|
315
|
+
# A send-only pod's only steady Valkey traffic: its errors must open Valkey-down
|
|
316
|
+
# mode, or calls keep going to SQS that no worker can claim (S21).
|
|
317
|
+
on_error=self._report_redis,
|
|
318
|
+
on_recovered=self._redis_ok,
|
|
319
|
+
abandon_after_secs=config.valkey_wait_failover_secs,
|
|
320
|
+
)
|
|
321
|
+
deps = CoreDeps(
|
|
322
|
+
config=config,
|
|
323
|
+
keys=self.keys,
|
|
324
|
+
providers=self.registry.for_model,
|
|
325
|
+
limiter=limiter,
|
|
326
|
+
claims=claims,
|
|
327
|
+
breaker=breaker,
|
|
328
|
+
results=results,
|
|
329
|
+
redis=client,
|
|
330
|
+
fallback=lambda: fallback.current,
|
|
331
|
+
worker=self.pod_id,
|
|
332
|
+
hooks=self.hooks,
|
|
333
|
+
leases=self._leases,
|
|
334
|
+
)
|
|
335
|
+
runtime = WorkerRuntime(
|
|
336
|
+
deps,
|
|
337
|
+
jobs=self._jobs,
|
|
338
|
+
defer_secs=round(2 * claims.settings.heartbeat_interval_secs),
|
|
339
|
+
failover=self.failover,
|
|
340
|
+
)
|
|
341
|
+
broker_hooks = self.hooks if isinstance(self.hooks, BrokerHooks) else None
|
|
342
|
+
brokers = build_brokers(
|
|
343
|
+
config,
|
|
344
|
+
runtime,
|
|
345
|
+
limiter,
|
|
346
|
+
WorkerResultBackend(results),
|
|
347
|
+
failover=self.failover,
|
|
348
|
+
hooks=broker_hooks,
|
|
349
|
+
session=self.aws_session,
|
|
350
|
+
)
|
|
351
|
+
started: list[Any] = []
|
|
352
|
+
try:
|
|
353
|
+
for broker in brokers.all():
|
|
354
|
+
await broker.startup()
|
|
355
|
+
started.append(broker)
|
|
356
|
+
await fallback.load()
|
|
357
|
+
except BaseException:
|
|
358
|
+
for broker in started:
|
|
359
|
+
with contextlib.suppress(Exception):
|
|
360
|
+
await broker.shutdown()
|
|
361
|
+
if owns:
|
|
362
|
+
await client.aclose()
|
|
363
|
+
raise
|
|
364
|
+
workers = Workers(brokers, runtime) if mode in ("work", "both") else None
|
|
365
|
+
interval = claims.settings.heartbeat_interval_secs
|
|
366
|
+
pods = PodRegistry(client, self.keys.pods, self.pod_id, ttl_secs=3 * interval)
|
|
367
|
+
state = _Started(
|
|
368
|
+
mode,
|
|
369
|
+
client,
|
|
370
|
+
owns,
|
|
371
|
+
results,
|
|
372
|
+
limiter,
|
|
373
|
+
claims,
|
|
374
|
+
breaker,
|
|
375
|
+
fallback,
|
|
376
|
+
dispatcher,
|
|
377
|
+
brokers,
|
|
378
|
+
runtime,
|
|
379
|
+
workers,
|
|
380
|
+
pods,
|
|
381
|
+
)
|
|
382
|
+
heartbeat = self._pod_heartbeat(state, interval)
|
|
383
|
+
state.tasks.append(
|
|
384
|
+
asyncio.create_task(fallback.run_reload(config.cfg_reload_secs), name="llmq-fallback")
|
|
385
|
+
)
|
|
386
|
+
state.tasks.append(asyncio.create_task(heartbeat.run(), name="llmq-pod-heartbeat"))
|
|
387
|
+
state.tasks.append(asyncio.create_task(self._watch_limits(limiter), name="llmq-limits"))
|
|
388
|
+
if self.router is None and config.routing_enabled:
|
|
389
|
+
default = RoutingConfig.model_validate(config.routing_default)
|
|
390
|
+
self._own_router = CanaryRouter(
|
|
391
|
+
routing_store(client, self.keys, default),
|
|
392
|
+
metrics=self.metrics,
|
|
393
|
+
reload_secs=config.cfg_reload_secs,
|
|
394
|
+
salt=config.id_hash_key,
|
|
395
|
+
)
|
|
396
|
+
await self._own_router.reload()
|
|
397
|
+
self._own_router.start()
|
|
398
|
+
if mode in ("send", "both"):
|
|
399
|
+
await dispatcher.start()
|
|
400
|
+
if workers is not None:
|
|
401
|
+
workers.start()
|
|
402
|
+
self._state = state
|
|
403
|
+
_set_default_queue(self)
|
|
404
|
+
|
|
405
|
+
def _pod_heartbeat(self, state: _Started, interval: float) -> PodHeartbeat:
|
|
406
|
+
"""One heartbeat for every mode: worker claims, DS17 direct claims, all leases (DS11)."""
|
|
407
|
+
runtime = state.runtime
|
|
408
|
+
|
|
409
|
+
def worker_lost(lost: list[str]) -> None:
|
|
410
|
+
runtime.stop_running("claim_lost", lost)
|
|
411
|
+
|
|
412
|
+
def direct_lost(lost: list[str]) -> None:
|
|
413
|
+
logger.warning("direct_claims_lost", count=len(lost))
|
|
414
|
+
|
|
415
|
+
set_live = getattr(self.failover, "set_live_pods", None)
|
|
416
|
+
return PodHeartbeat(
|
|
417
|
+
claims=state.claims,
|
|
418
|
+
limiter=state.limiter,
|
|
419
|
+
leases=self._leases,
|
|
420
|
+
groups=[
|
|
421
|
+
ClaimGroup(self.pod_id, runtime.running_ids, worker_lost),
|
|
422
|
+
ClaimGroup(f"direct:{self.pod_id}", lambda: list(self._direct_claims), direct_lost),
|
|
423
|
+
],
|
|
424
|
+
interval_secs=interval,
|
|
425
|
+
pods=state.pods,
|
|
426
|
+
on_live_pods=set_live if callable(set_live) else None,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
async def stop(self, drain_secs: float | None = None) -> None:
|
|
430
|
+
"""Ordered shutdown (§7.4): pause → drain running tasks → hand back leftovers → close.
|
|
431
|
+
|
|
432
|
+
`drain_secs` defaults to `config.drain_secs`. Waiting callers on this pod are failed
|
|
433
|
+
with `QueueUnavailable`. Also closes the sync runner (even if `start()` never ran, e.g.
|
|
434
|
+
when only `*_sync` calls were made). Idempotent.
|
|
435
|
+
"""
|
|
436
|
+
state, self._state = self._state, None
|
|
437
|
+
if state is None:
|
|
438
|
+
await asyncio.to_thread(self.close_sync)
|
|
439
|
+
return
|
|
440
|
+
_set_default_queue(None, only=self)
|
|
441
|
+
drain = self.config.drain_secs if drain_secs is None else drain_secs
|
|
442
|
+
if state.workers is not None:
|
|
443
|
+
await state.workers.drain(drain)
|
|
444
|
+
else:
|
|
445
|
+
await asyncio.gather(*(b.pause_receiving() for b in state.brokers.all()))
|
|
446
|
+
router, self._own_router = self._own_router, None
|
|
447
|
+
if router is not None:
|
|
448
|
+
await router.aclose()
|
|
449
|
+
close_failover = getattr(self.failover, "aclose", None)
|
|
450
|
+
if callable(close_failover):
|
|
451
|
+
with contextlib.suppress(Exception):
|
|
452
|
+
await close_failover()
|
|
453
|
+
for task in state.tasks:
|
|
454
|
+
task.cancel()
|
|
455
|
+
await asyncio.gather(*state.tasks, return_exceptions=True)
|
|
456
|
+
await state.dispatcher.stop()
|
|
457
|
+
for broker in state.brokers.all():
|
|
458
|
+
try:
|
|
459
|
+
await broker.shutdown()
|
|
460
|
+
except Exception as exc:
|
|
461
|
+
logger.warning("broker_shutdown_failed", error_type=type(exc).__name__)
|
|
462
|
+
await self.registry.aclose()
|
|
463
|
+
if state.pods is not None:
|
|
464
|
+
with contextlib.suppress(Exception):
|
|
465
|
+
await state.pods.leave()
|
|
466
|
+
if state.owns_redis:
|
|
467
|
+
with contextlib.suppress(Exception):
|
|
468
|
+
await state.redis.aclose()
|
|
469
|
+
await state.redis.connection_pool.disconnect()
|
|
470
|
+
await asyncio.to_thread(self.close_sync)
|
|
471
|
+
|
|
472
|
+
def close_sync(self) -> None:
|
|
473
|
+
"""Close the sync runner (thread, loop, providers, blocking Valkey client); idempotent.
|
|
474
|
+
|
|
475
|
+
For sync-only apps that never call `start()`/`stop()`; a later sync call starts a new one.
|
|
476
|
+
"""
|
|
477
|
+
with self._sync_lock:
|
|
478
|
+
runner, self._sync = self._sync, None
|
|
479
|
+
if runner is not None:
|
|
480
|
+
runner.close()
|
|
481
|
+
|
|
482
|
+
async def pause_receiving(self) -> None:
|
|
483
|
+
"""For `/drain`: stop taking new messages and release unstarted ones (§7.4 step 1)."""
|
|
484
|
+
if self._state is not None:
|
|
485
|
+
await asyncio.gather(*(b.pause_receiving() for b in self._state.brokers.all()))
|
|
486
|
+
|
|
487
|
+
def resume_receiving(self) -> None:
|
|
488
|
+
"""Undo `pause_receiving`."""
|
|
489
|
+
if self._state is not None:
|
|
490
|
+
for broker in self._state.brokers.all():
|
|
491
|
+
broker.resume_receiving()
|
|
492
|
+
|
|
493
|
+
# ------------------------------------------------------------------ client API
|
|
494
|
+
|
|
495
|
+
async def converse(
|
|
496
|
+
self,
|
|
497
|
+
model: str,
|
|
498
|
+
request: dict[str, Any],
|
|
499
|
+
*,
|
|
500
|
+
lane: Lane | str | None = None,
|
|
501
|
+
fallback: FallbackPolicy | None = None,
|
|
502
|
+
timeout_secs: float | None = None,
|
|
503
|
+
deadline_at: float | None = None,
|
|
504
|
+
request_id: str | None = None,
|
|
505
|
+
) -> ConverseResult:
|
|
506
|
+
"""Bedrock Converse through the queue; `response` is the exact Converse dict.
|
|
507
|
+
|
|
508
|
+
`timeout_secs` (relative) defaults to the lane timeout; `deadline_at` (epoch seconds)
|
|
509
|
+
caps it. Raises `LLMQueueTimeout`, `LLMQueueCancelled` or `ProviderError`.
|
|
510
|
+
"""
|
|
511
|
+
reply = await self._call(
|
|
512
|
+
"converse", model, request, lane, fallback, timeout_secs, deadline_at, request_id
|
|
513
|
+
)
|
|
514
|
+
return ConverseResult(reply.response or {}, reply.meta)
|
|
515
|
+
|
|
516
|
+
async def invoke_model(
|
|
517
|
+
self,
|
|
518
|
+
model: str,
|
|
519
|
+
body: Mapping[str, Any] | list[Any] | str | bytes,
|
|
520
|
+
*,
|
|
521
|
+
content_type: str = "application/json",
|
|
522
|
+
accept: str = "application/json",
|
|
523
|
+
lane: Lane | str | None = None,
|
|
524
|
+
fallback: FallbackPolicy | None = None,
|
|
525
|
+
timeout_secs: float | None = None,
|
|
526
|
+
deadline_at: float | None = None,
|
|
527
|
+
request_id: str | None = None,
|
|
528
|
+
) -> InvokeResult:
|
|
529
|
+
"""Bedrock InvokeModel with the model-native `body` (JSON value, or UTF-8 text/bytes).
|
|
530
|
+
|
|
531
|
+
Returns the raw reply body (`InvokeResult.body`, `.json()`) and how it was served.
|
|
532
|
+
"""
|
|
533
|
+
params = {"body": _invoke_body(body), "contentType": content_type, "accept": accept}
|
|
534
|
+
reply = await self._call(
|
|
535
|
+
"invoke_model", model, params, lane, fallback, timeout_secs, deadline_at, request_id
|
|
536
|
+
)
|
|
537
|
+
return _invoke_result(reply)
|
|
538
|
+
|
|
539
|
+
async def chat(
|
|
540
|
+
self,
|
|
541
|
+
model: str,
|
|
542
|
+
request: dict[str, Any],
|
|
543
|
+
*,
|
|
544
|
+
lane: Lane | str | None = None,
|
|
545
|
+
fallback: FallbackPolicy | None = None,
|
|
546
|
+
timeout_secs: float | None = None,
|
|
547
|
+
deadline_at: float | None = None,
|
|
548
|
+
request_id: str | None = None,
|
|
549
|
+
) -> ChatResult:
|
|
550
|
+
"""OpenAI-compatible chat completion; `response` is the completion body as a dict."""
|
|
551
|
+
reply = await self._call(
|
|
552
|
+
"chat", model, request, lane, fallback, timeout_secs, deadline_at, request_id
|
|
553
|
+
)
|
|
554
|
+
return ChatResult(reply.response or {}, reply.meta)
|
|
555
|
+
|
|
556
|
+
async def embed(
|
|
557
|
+
self,
|
|
558
|
+
model: str,
|
|
559
|
+
texts: list[str],
|
|
560
|
+
*,
|
|
561
|
+
lane: Lane | str | None = None,
|
|
562
|
+
input_type: str | None = None,
|
|
563
|
+
timeout_secs: float | None = None,
|
|
564
|
+
deadline_at: float | None = None,
|
|
565
|
+
request_id: str | None = None,
|
|
566
|
+
) -> EmbedResult:
|
|
567
|
+
"""Embed `texts` as one task per provider batch (Titan 1, Cohere 96, OpenAI 2048).
|
|
568
|
+
|
|
569
|
+
Embeddings never switch model; vectors come back in input order. With several batches
|
|
570
|
+
each task gets `<request_id>-<n>`; `cancel(request_id)` cancels all of them.
|
|
571
|
+
"""
|
|
572
|
+
if not texts:
|
|
573
|
+
return EmbedResult([], CallMeta())
|
|
574
|
+
plan = self._embed_plan(model, texts, input_type)
|
|
575
|
+
rid = request_id or _new_request_id()
|
|
576
|
+
ids = _batch_ids(rid, len(plan))
|
|
577
|
+
parent = rid if len(plan) > 1 else None
|
|
578
|
+
self._embed_groups[rid] = ids
|
|
579
|
+
try:
|
|
580
|
+
replies = await asyncio.gather(
|
|
581
|
+
*(
|
|
582
|
+
self._call(
|
|
583
|
+
op,
|
|
584
|
+
model,
|
|
585
|
+
params,
|
|
586
|
+
lane,
|
|
587
|
+
"same_model",
|
|
588
|
+
timeout_secs,
|
|
589
|
+
deadline_at,
|
|
590
|
+
batch_id,
|
|
591
|
+
embed=True,
|
|
592
|
+
parent_request_id=parent,
|
|
593
|
+
)
|
|
594
|
+
for (op, params), batch_id in zip(plan, ids, strict=True)
|
|
595
|
+
)
|
|
596
|
+
)
|
|
597
|
+
finally:
|
|
598
|
+
self._embed_groups.pop(rid, None)
|
|
599
|
+
return _embed_result(plan, replies)
|
|
600
|
+
|
|
601
|
+
async def cancel(self, request_id: str) -> None:
|
|
602
|
+
"""Cancel a call, or every batch of an `embed`; its caller gets `LLMQueueCancelled`.
|
|
603
|
+
|
|
604
|
+
Sets the shared cancel flag (DS14) and stops a local `direct` run.
|
|
605
|
+
"""
|
|
606
|
+
for rid in self._embed_groups.get(request_id) or [request_id]:
|
|
607
|
+
task = self._direct_tasks.get(rid)
|
|
608
|
+
if task is not None and not task.done():
|
|
609
|
+
task.cancel()
|
|
610
|
+
if self._state is not None:
|
|
611
|
+
await self._state.claims.cancel(rid)
|
|
612
|
+
|
|
613
|
+
# ------------------------------------------------------------------ jobs
|
|
614
|
+
|
|
615
|
+
def register_job(self, name: str, function: JobFunction) -> None:
|
|
616
|
+
"""Allow-list an app job for the `taskiq_llm_queue.job` task on this pod."""
|
|
617
|
+
self._jobs[name] = function
|
|
618
|
+
|
|
619
|
+
async def send_job(
|
|
620
|
+
self,
|
|
621
|
+
name: str,
|
|
622
|
+
payload: dict[str, Any],
|
|
623
|
+
*,
|
|
624
|
+
job_id: str | None = None,
|
|
625
|
+
timeout_secs: float | None = None,
|
|
626
|
+
deadline_at: float | None = None,
|
|
627
|
+
) -> str:
|
|
628
|
+
"""Send one job to `config.job_queues[name]`; returns its job id (not awaited).
|
|
629
|
+
|
|
630
|
+
The job must finish within `timeout_secs` (default `state_ttl_secs`), capped by
|
|
631
|
+
`deadline_at` (epoch seconds).
|
|
632
|
+
"""
|
|
633
|
+
state = self._require_started()
|
|
634
|
+
queue = self.config.job_queues.get(name)
|
|
635
|
+
if queue is None or state.brokers.jobs is None:
|
|
636
|
+
raise ConfigError(f"no job queue configured for {name!r}")
|
|
637
|
+
job_id = job_id or f"job-{uuid.uuid4().hex}"
|
|
638
|
+
now = time.time()
|
|
639
|
+
deadline = now + (timeout_secs if timeout_secs is not None else self.config.state_ttl_secs)
|
|
640
|
+
if deadline_at is not None:
|
|
641
|
+
deadline = min(deadline, deadline_at)
|
|
642
|
+
body: dict[str, Any] = {
|
|
643
|
+
"v": JOB_VERSION,
|
|
644
|
+
"request_id": job_id,
|
|
645
|
+
"job": name,
|
|
646
|
+
"payload": payload,
|
|
647
|
+
"deadline_at": max(deadline, now),
|
|
648
|
+
}
|
|
649
|
+
task = state.brokers.jobs.find_task(TASK_JOB)
|
|
650
|
+
assert task is not None
|
|
651
|
+
await task.kicker().with_task_id(job_id).with_labels(queue_name=queue_name(queue)).kiq(body)
|
|
652
|
+
return job_id
|
|
653
|
+
|
|
654
|
+
# ------------------------------------------------------------------ sync
|
|
655
|
+
|
|
656
|
+
def converse_sync(
|
|
657
|
+
self,
|
|
658
|
+
model: str,
|
|
659
|
+
request: dict[str, Any],
|
|
660
|
+
*,
|
|
661
|
+
lane: Lane | str | None = None,
|
|
662
|
+
fallback: FallbackPolicy | None = None,
|
|
663
|
+
timeout_secs: float | None = None,
|
|
664
|
+
deadline_at: float | None = None,
|
|
665
|
+
request_id: str | None = None,
|
|
666
|
+
) -> ConverseResult:
|
|
667
|
+
"""Blocking `converse`: runs `direct` under the shared limiter (sync fallback)."""
|
|
668
|
+
reply = self._run_sync(
|
|
669
|
+
model, "converse", request, lane, fallback, timeout_secs, deadline_at, request_id
|
|
670
|
+
)
|
|
671
|
+
return ConverseResult(reply.response or {}, reply.meta)
|
|
672
|
+
|
|
673
|
+
def chat_sync(
|
|
674
|
+
self,
|
|
675
|
+
model: str,
|
|
676
|
+
request: dict[str, Any],
|
|
677
|
+
*,
|
|
678
|
+
lane: Lane | str | None = None,
|
|
679
|
+
fallback: FallbackPolicy | None = None,
|
|
680
|
+
timeout_secs: float | None = None,
|
|
681
|
+
deadline_at: float | None = None,
|
|
682
|
+
request_id: str | None = None,
|
|
683
|
+
) -> ChatResult:
|
|
684
|
+
"""Blocking `chat`: runs `direct` under the shared limiter (sync fallback)."""
|
|
685
|
+
reply = self._run_sync(
|
|
686
|
+
model, "chat", request, lane, fallback, timeout_secs, deadline_at, request_id
|
|
687
|
+
)
|
|
688
|
+
return ChatResult(reply.response or {}, reply.meta)
|
|
689
|
+
|
|
690
|
+
def invoke_model_sync(
|
|
691
|
+
self,
|
|
692
|
+
model: str,
|
|
693
|
+
body: Mapping[str, Any] | list[Any] | str | bytes,
|
|
694
|
+
*,
|
|
695
|
+
content_type: str = "application/json",
|
|
696
|
+
accept: str = "application/json",
|
|
697
|
+
lane: Lane | str | None = None,
|
|
698
|
+
fallback: FallbackPolicy | None = None,
|
|
699
|
+
timeout_secs: float | None = None,
|
|
700
|
+
deadline_at: float | None = None,
|
|
701
|
+
request_id: str | None = None,
|
|
702
|
+
) -> InvokeResult:
|
|
703
|
+
"""Blocking `invoke_model`: runs `direct` under the shared limiter (sync fallback)."""
|
|
704
|
+
params = {"body": _invoke_body(body), "contentType": content_type, "accept": accept}
|
|
705
|
+
reply = self._run_sync(
|
|
706
|
+
model, "invoke_model", params, lane, fallback, timeout_secs, deadline_at, request_id
|
|
707
|
+
)
|
|
708
|
+
return _invoke_result(reply)
|
|
709
|
+
|
|
710
|
+
def embed_sync(
|
|
711
|
+
self,
|
|
712
|
+
model: str,
|
|
713
|
+
texts: list[str],
|
|
714
|
+
*,
|
|
715
|
+
lane: Lane | str | None = None,
|
|
716
|
+
input_type: str | None = None,
|
|
717
|
+
timeout_secs: float | None = None,
|
|
718
|
+
deadline_at: float | None = None,
|
|
719
|
+
request_id: str | None = None,
|
|
720
|
+
) -> EmbedResult:
|
|
721
|
+
"""Blocking `embed`: one `direct` call per provider batch under the shared limiter."""
|
|
722
|
+
if not texts:
|
|
723
|
+
return EmbedResult([], CallMeta())
|
|
724
|
+
plan = self._embed_plan(model, texts, input_type)
|
|
725
|
+
ids = _batch_ids(request_id or _new_request_id(), len(plan))
|
|
726
|
+
replies = [
|
|
727
|
+
self._run_sync(
|
|
728
|
+
model, op, params, lane, "same_model", timeout_secs, deadline_at, rid, embed=True
|
|
729
|
+
)
|
|
730
|
+
for (op, params), rid in zip(plan, ids, strict=True)
|
|
731
|
+
]
|
|
732
|
+
return _embed_result(plan, replies)
|
|
733
|
+
|
|
734
|
+
# ------------------------------------------------------------------ internals
|
|
735
|
+
|
|
736
|
+
def _require_started(self) -> _Started:
|
|
737
|
+
if self._state is None:
|
|
738
|
+
raise QueueUnavailable("LLMQueue is not started; call start() first")
|
|
739
|
+
return self._state
|
|
740
|
+
|
|
741
|
+
def _embed_plan(
|
|
742
|
+
self, model: str, texts: list[str], input_type: str | None
|
|
743
|
+
) -> list[tuple[Op, dict[str, Any]]]:
|
|
744
|
+
"""(op, params) per provider batch for `texts`."""
|
|
745
|
+
_, cfg, _ = self.config.resolve(model)
|
|
746
|
+
size = max_batch_size(cfg.provider, cfg.model_id)
|
|
747
|
+
plan: list[tuple[Op, dict[str, Any]]] = []
|
|
748
|
+
for batch in batches(texts, size):
|
|
749
|
+
if cfg.provider == "bedrock":
|
|
750
|
+
options = (
|
|
751
|
+
{"input_type": input_type}
|
|
752
|
+
if input_type and "cohere" in cfg.model_id.lower()
|
|
753
|
+
else {}
|
|
754
|
+
)
|
|
755
|
+
plan.append(
|
|
756
|
+
("invoke_model", {"body": embedding_body(cfg.model_id, batch, **options)})
|
|
757
|
+
)
|
|
758
|
+
else:
|
|
759
|
+
plan.append(("embeddings", {"input": batch}))
|
|
760
|
+
return plan
|
|
761
|
+
|
|
762
|
+
def _envelope(
|
|
763
|
+
self,
|
|
764
|
+
op: Op,
|
|
765
|
+
model: str,
|
|
766
|
+
params: dict[str, Any],
|
|
767
|
+
lane: Lane | str | None,
|
|
768
|
+
fallback: FallbackPolicy | None,
|
|
769
|
+
timeout_secs: float | None,
|
|
770
|
+
deadline_at: float | None,
|
|
771
|
+
request_id: str | None,
|
|
772
|
+
*,
|
|
773
|
+
embed: bool = False,
|
|
774
|
+
parent_request_id: str | None = None,
|
|
775
|
+
) -> Envelope:
|
|
776
|
+
"""Build the envelope from `request_context` (lane, session, user) and lane timeouts."""
|
|
777
|
+
self.config.resolve(model) # unknown alias / target → ConfigError before anything runs
|
|
778
|
+
ctx = current_context()
|
|
779
|
+
call_lane = Lane(lane) if lane is not None else ctx.lane
|
|
780
|
+
waits = self.config.embed_timeout_secs if embed else self.config.timeout_secs
|
|
781
|
+
now = time.time()
|
|
782
|
+
deadline = now + (timeout_secs if timeout_secs is not None else waits[call_lane])
|
|
783
|
+
if deadline_at is not None:
|
|
784
|
+
deadline = min(deadline, deadline_at)
|
|
785
|
+
return Envelope(
|
|
786
|
+
request_id=request_id or _new_request_id(),
|
|
787
|
+
parent_request_id=parent_request_id,
|
|
788
|
+
session_id=ctx.session_id,
|
|
789
|
+
user_id=ctx.user_id,
|
|
790
|
+
reply_to=self.pod_id,
|
|
791
|
+
lane=call_lane,
|
|
792
|
+
op=op,
|
|
793
|
+
fallback=fallback or "same_model",
|
|
794
|
+
model=model,
|
|
795
|
+
params=params,
|
|
796
|
+
enqueued_at=now,
|
|
797
|
+
deadline_at=max(deadline, now),
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
def _route(self, alias: str, envelope: Envelope) -> CallPath:
|
|
801
|
+
"""`direct` while failed over or Valkey is down, else the router, else sqs-if-queued."""
|
|
802
|
+
state = self._state
|
|
803
|
+
if state is None or alias not in state.brokers.by_alias:
|
|
804
|
+
return "direct"
|
|
805
|
+
failover = self.failover
|
|
806
|
+
if failover.redis_down() or failover.path_override(alias):
|
|
807
|
+
return "direct"
|
|
808
|
+
router = self.router or self._own_router
|
|
809
|
+
if router is not None:
|
|
810
|
+
return router.route(alias, envelope.lane, envelope.session_id, envelope.user_id)
|
|
811
|
+
return "sqs"
|
|
812
|
+
|
|
813
|
+
async def _call(
|
|
814
|
+
self,
|
|
815
|
+
op: Op,
|
|
816
|
+
model: str,
|
|
817
|
+
params: dict[str, Any],
|
|
818
|
+
lane: Lane | str | None,
|
|
819
|
+
fallback: FallbackPolicy | None,
|
|
820
|
+
timeout_secs: float | None,
|
|
821
|
+
deadline_at: float | None,
|
|
822
|
+
request_id: str | None,
|
|
823
|
+
*,
|
|
824
|
+
embed: bool = False,
|
|
825
|
+
parent_request_id: str | None = None,
|
|
826
|
+
) -> LLMReply:
|
|
827
|
+
"""Build, route and run one call; raises the mapped error for an `ok=False` reply."""
|
|
828
|
+
envelope = self._envelope(
|
|
829
|
+
op,
|
|
830
|
+
model,
|
|
831
|
+
params,
|
|
832
|
+
lane,
|
|
833
|
+
fallback,
|
|
834
|
+
timeout_secs,
|
|
835
|
+
deadline_at,
|
|
836
|
+
request_id,
|
|
837
|
+
embed=embed,
|
|
838
|
+
parent_request_id=parent_request_id,
|
|
839
|
+
)
|
|
840
|
+
alias = model.partition("@")[0]
|
|
841
|
+
path = self._route(alias, envelope)
|
|
842
|
+
t0 = time.monotonic()
|
|
843
|
+
ok = False
|
|
844
|
+
try:
|
|
845
|
+
if path == "sqs":
|
|
846
|
+
reply = await self._via_sqs(alias, envelope, embed=embed)
|
|
847
|
+
else:
|
|
848
|
+
reply = await self._direct(envelope)
|
|
849
|
+
result = _unwrap(reply)
|
|
850
|
+
ok = True
|
|
851
|
+
return result
|
|
852
|
+
finally:
|
|
853
|
+
secs = time.monotonic() - t0
|
|
854
|
+
safe_hook(self.hooks.on_call, alias, envelope.lane.value, path, ok, secs)
|
|
855
|
+
|
|
856
|
+
async def _via_sqs(self, alias: str, envelope: Envelope, *, embed: bool) -> LLMReply:
|
|
857
|
+
"""Kiq to the model's lane queue and await the reply (§6.1); failover to `direct`."""
|
|
858
|
+
state = self._require_started()
|
|
859
|
+
broker = state.brokers.by_alias[alias]
|
|
860
|
+
queue = queue_for_lane(self.config.models[alias], envelope.lane)
|
|
861
|
+
assert queue is not None
|
|
862
|
+
sent = envelope
|
|
863
|
+
try:
|
|
864
|
+
sent = await self._maybe_by_reference(state, envelope)
|
|
865
|
+
except RedisError as exc:
|
|
866
|
+
self._report_redis(exc)
|
|
867
|
+
return await self._direct(envelope)
|
|
868
|
+
labels = {
|
|
869
|
+
**task_labels(sent, hash_ids=self.config.hash_ids, salt=self.config.id_hash_key),
|
|
870
|
+
"queue_name": queue_name(queue),
|
|
871
|
+
"expiry": repr(sent.deadline_at),
|
|
872
|
+
}
|
|
873
|
+
task = broker.find_task(TASK_EMBED if embed else TASK_CALL)
|
|
874
|
+
assert task is not None
|
|
875
|
+
send = asyncio.ensure_future(
|
|
876
|
+
task.kicker()
|
|
877
|
+
.with_task_id(sent.request_id)
|
|
878
|
+
.with_labels(**labels)
|
|
879
|
+
.kiq(sent.model_dump(mode="json"))
|
|
880
|
+
)
|
|
881
|
+
try:
|
|
882
|
+
await self._bounded_send(alias, send, envelope.deadline_at)
|
|
883
|
+
except asyncio.CancelledError: # a batched send may still go out: flag it (DS14)
|
|
884
|
+
send.cancel()
|
|
885
|
+
await asyncio.shield(self._flag_cancel(state, envelope.request_id))
|
|
886
|
+
raise
|
|
887
|
+
except (
|
|
888
|
+
SendTaskError,
|
|
889
|
+
SQSSendError,
|
|
890
|
+
QueueUnavailable,
|
|
891
|
+
OSError, # includes TimeoutError from the bounded send (ambiguous: DS17 claim)
|
|
892
|
+
) as exc: # taskiq wraps kick errors
|
|
893
|
+
rejected = _deterministic_send_error(exc)
|
|
894
|
+
if rejected is not None: # the same message would fail again: no failover
|
|
895
|
+
logger.warning(
|
|
896
|
+
"sqs_send_rejected",
|
|
897
|
+
request_id=envelope.request_id,
|
|
898
|
+
model=alias,
|
|
899
|
+
error_type=type(rejected).__name__,
|
|
900
|
+
)
|
|
901
|
+
if isinstance(rejected, LLMQueueError):
|
|
902
|
+
raise rejected from exc
|
|
903
|
+
raise LLMQueueError(f"send rejected: {type(rejected).__name__}") from exc
|
|
904
|
+
logger.warning(
|
|
905
|
+
"sqs_send_failed",
|
|
906
|
+
request_id=envelope.request_id,
|
|
907
|
+
model=alias,
|
|
908
|
+
lane=envelope.lane.value,
|
|
909
|
+
error_type=type(exc).__name__,
|
|
910
|
+
)
|
|
911
|
+
self.failover.report_sqs_failure(alias, exc)
|
|
912
|
+
return await self._direct(envelope, claim=True)
|
|
913
|
+
return await self._wait(state, envelope)
|
|
914
|
+
|
|
915
|
+
async def _bounded_send(
|
|
916
|
+
self, alias: str, send: asyncio.Future[Any], deadline_at: float
|
|
917
|
+
) -> None:
|
|
918
|
+
"""Await one SQS send; time out only when SQS sends stopped completing.
|
|
919
|
+
|
|
920
|
+
An unreachable SQS can hang a send on a dead pooled connection until botocore's 60 s
|
|
921
|
+
read timeout (S20), so a send gets `sqs_send_timeout_secs` and at most half the call
|
|
922
|
+
budget. But a send queued behind a burst is slow, not failed (S35b: 10K sends timed out
|
|
923
|
+
at 5 s and moved the sender to direct): while another send (any queue) completed
|
|
924
|
+
within the timeout, it keeps waiting up to the budget share. Raises `TimeoutError`.
|
|
925
|
+
"""
|
|
926
|
+
limit = self.config.sqs_send_timeout_secs
|
|
927
|
+
give_up = time.monotonic() + max(0.0, _SEND_BUDGET_SHARE * (deadline_at - time.time()))
|
|
928
|
+
while True:
|
|
929
|
+
wait = max(0.0, min(limit, give_up - time.monotonic()))
|
|
930
|
+
done, _ = await asyncio.wait({send}, timeout=wait)
|
|
931
|
+
if done:
|
|
932
|
+
send.result() # raises the send's own error
|
|
933
|
+
self._sent_at = time.monotonic()
|
|
934
|
+
return
|
|
935
|
+
now = time.monotonic()
|
|
936
|
+
if now >= give_up or now - self._sent_at >= limit:
|
|
937
|
+
send.cancel()
|
|
938
|
+
raise TimeoutError(f"SQS send for {alias} timed out")
|
|
939
|
+
|
|
940
|
+
async def _wait(self, state: _Started, envelope: Envelope) -> LLMReply:
|
|
941
|
+
"""Await the dispatcher; on timeout or caller cancellation set the cancel flag."""
|
|
942
|
+
remaining = max(0.0, envelope.deadline_at - time.time())
|
|
943
|
+
try:
|
|
944
|
+
reply = await state.dispatcher.wait(envelope.request_id, remaining, envelope.lane)
|
|
945
|
+
except ResultsUnavailable:
|
|
946
|
+
# Valkey gone for `valkey_wait_failover_secs`: no worker can claim the queued copy
|
|
947
|
+
# and no result can arrive, so serve the call here under the local cap (S21).
|
|
948
|
+
logger.warning("valkey_down_wait_direct", request_id=envelope.request_id)
|
|
949
|
+
await self._flag_cancel(state, envelope.request_id)
|
|
950
|
+
return await self._direct(envelope)
|
|
951
|
+
except LLMQueueTimeout:
|
|
952
|
+
await self._flag_cancel(state, envelope.request_id)
|
|
953
|
+
raise
|
|
954
|
+
except asyncio.CancelledError:
|
|
955
|
+
await asyncio.shield(self._flag_cancel(state, envelope.request_id))
|
|
956
|
+
raise
|
|
957
|
+
self._redis_ok() # the reply came through Valkey
|
|
958
|
+
return reply
|
|
959
|
+
|
|
960
|
+
async def _flag_cancel(self, state: _Started, request_id: str) -> None:
|
|
961
|
+
"""Set the DS14 cancel flag; while Valkey is down, queue it for the recovery probe."""
|
|
962
|
+
if self.failover.redis_down(): # a Valkey call now would only add connect timeouts
|
|
963
|
+
self._pending_cancels.add(request_id)
|
|
964
|
+
return
|
|
965
|
+
with contextlib.suppress(Exception):
|
|
966
|
+
await state.claims.cancel(request_id)
|
|
967
|
+
|
|
968
|
+
async def _maybe_by_reference(self, state: _Started, envelope: Envelope) -> Envelope:
|
|
969
|
+
"""Store params over `inline_max_bytes` in Valkey and send `params_ref` instead (D48)."""
|
|
970
|
+
size = len(envelope.model_dump_json().encode())
|
|
971
|
+
if size <= self.config.inline_max_bytes:
|
|
972
|
+
return envelope
|
|
973
|
+
key = self.keys.params(envelope.request_id)
|
|
974
|
+
await state.redis.set(key, orjson.dumps(envelope.params), ex=self.config.state_ttl_secs)
|
|
975
|
+
return envelope.model_copy(update={"params": None, "params_ref": key})
|
|
976
|
+
|
|
977
|
+
def _report_redis(self, exc: BaseException) -> None:
|
|
978
|
+
self.failover.report_redis_failure(exc)
|
|
979
|
+
|
|
980
|
+
def _direct_deps(self) -> CoreDeps:
|
|
981
|
+
"""Deps for an in-process call: shared limiter + breaker, or the local cap (DS18)."""
|
|
982
|
+
state = self._require_started()
|
|
983
|
+
if self.failover.redis_down():
|
|
984
|
+
return CoreDeps(
|
|
985
|
+
config=self.config,
|
|
986
|
+
keys=self.keys,
|
|
987
|
+
providers=self.registry.for_model,
|
|
988
|
+
limiter=self._local_limiter,
|
|
989
|
+
worker=f"direct:{self.pod_id}",
|
|
990
|
+
hooks=self.hooks,
|
|
991
|
+
)
|
|
992
|
+
return CoreDeps(
|
|
993
|
+
config=self.config,
|
|
994
|
+
keys=self.keys,
|
|
995
|
+
providers=self.registry.for_model,
|
|
996
|
+
limiter=state.limiter,
|
|
997
|
+
claims=state.claims,
|
|
998
|
+
breaker=state.breaker,
|
|
999
|
+
redis=state.redis,
|
|
1000
|
+
fallback=lambda: state.fallback.current,
|
|
1001
|
+
worker=f"direct:{self.pod_id}",
|
|
1002
|
+
hooks=self.hooks,
|
|
1003
|
+
leases=self._leases,
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
async def _direct(self, envelope: Envelope, *, claim: bool = False) -> LLMReply:
|
|
1007
|
+
"""Run in-process; with `claim` (DS17) a queued copy that already runs is awaited.
|
|
1008
|
+
|
|
1009
|
+
The DS17 claim is heartbeated by the pod heartbeat while the call runs. If Valkey fails
|
|
1010
|
+
before any provider attempt the call runs again under the local cap; after one started,
|
|
1011
|
+
the caller gets `QueueUnavailable` (never a second provider call).
|
|
1012
|
+
"""
|
|
1013
|
+
deps = self._direct_deps()
|
|
1014
|
+
rid = envelope.request_id
|
|
1015
|
+
held = claim and deps.claims is not None
|
|
1016
|
+
task = asyncio.current_task()
|
|
1017
|
+
if task is not None:
|
|
1018
|
+
self._direct_tasks[rid] = task
|
|
1019
|
+
if held:
|
|
1020
|
+
self._direct_claims[rid] = self._direct_claims.get(rid, 0) + 1
|
|
1021
|
+
try:
|
|
1022
|
+
try:
|
|
1023
|
+
result = await execute_job(envelope, deps, claim=held, store=False)
|
|
1024
|
+
except InfraError as exc:
|
|
1025
|
+
self._report_redis(exc.__cause__ or exc)
|
|
1026
|
+
if exc.attempted:
|
|
1027
|
+
raise QueueUnavailable(f"Valkey failed during call {rid}") from exc
|
|
1028
|
+
result = await self._direct_again(envelope)
|
|
1029
|
+
if held:
|
|
1030
|
+
await self._finish_direct_claim(rid, result.reply)
|
|
1031
|
+
finally:
|
|
1032
|
+
self._direct_tasks.pop(rid, None)
|
|
1033
|
+
if held:
|
|
1034
|
+
self._drop_direct_claim(rid)
|
|
1035
|
+
if result.reply is not None:
|
|
1036
|
+
return result.reply
|
|
1037
|
+
if result.decision is ClaimDecision.ACK_CANCELLED:
|
|
1038
|
+
raise LLMQueueCancelled(f"call {envelope.request_id} was cancelled")
|
|
1039
|
+
state = self._require_started()
|
|
1040
|
+
return await self._wait(state, envelope) # the queued copy runs elsewhere
|
|
1041
|
+
|
|
1042
|
+
async def _direct_again(self, envelope: Envelope) -> JobResult:
|
|
1043
|
+
"""Second, unclaimed run after Valkey failed before any attempt (local cap, DS18)."""
|
|
1044
|
+
try:
|
|
1045
|
+
return await execute_job(envelope, self._direct_deps(), claim=False, store=False)
|
|
1046
|
+
except InfraError as exc:
|
|
1047
|
+
self._report_redis(exc.__cause__ or exc)
|
|
1048
|
+
raise QueueUnavailable(f"Valkey failed during call {envelope.request_id}") from exc
|
|
1049
|
+
|
|
1050
|
+
async def _finish_direct_claim(self, request_id: str, reply: LLMReply | None) -> None:
|
|
1051
|
+
"""Best effort: mark a DS17 claim terminal after the local re-run answered the caller."""
|
|
1052
|
+
state = self._state
|
|
1053
|
+
if state is None or reply is None:
|
|
1054
|
+
return
|
|
1055
|
+
final = ClaimState.CANCELLED if reply.cancelled else ClaimState.DONE
|
|
1056
|
+
try:
|
|
1057
|
+
await state.claims.complete(request_id, final, worker=f"direct:{self.pod_id}")
|
|
1058
|
+
except Exception as exc:
|
|
1059
|
+
logger.warning(
|
|
1060
|
+
"claim_complete_failed", request_id=request_id, error_type=type(exc).__name__
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
def _drop_direct_claim(self, request_id: str) -> None:
|
|
1064
|
+
left = self._direct_claims.get(request_id, 0) - 1
|
|
1065
|
+
if left > 0:
|
|
1066
|
+
self._direct_claims[request_id] = left
|
|
1067
|
+
else:
|
|
1068
|
+
self._direct_claims.pop(request_id, None)
|
|
1069
|
+
|
|
1070
|
+
def _run_sync(
|
|
1071
|
+
self,
|
|
1072
|
+
model: str,
|
|
1073
|
+
op: Op,
|
|
1074
|
+
params: dict[str, Any],
|
|
1075
|
+
lane: Lane | str | None,
|
|
1076
|
+
fallback: FallbackPolicy | None,
|
|
1077
|
+
timeout_secs: float | None,
|
|
1078
|
+
deadline_at: float | None,
|
|
1079
|
+
request_id: str | None,
|
|
1080
|
+
*,
|
|
1081
|
+
embed: bool = False,
|
|
1082
|
+
) -> LLMReply:
|
|
1083
|
+
"""Run one call `direct` on the private sync loop; counts `llmq_sync_fallback_total`."""
|
|
1084
|
+
if self.config.strict_async:
|
|
1085
|
+
raise LLMQueueError(f"sync {op} call refused: strict_async is set")
|
|
1086
|
+
envelope = self._envelope(
|
|
1087
|
+
op, model, params, lane, fallback, timeout_secs, deadline_at, request_id, embed=embed
|
|
1088
|
+
)
|
|
1089
|
+
self.sync_fallback_count += 1
|
|
1090
|
+
safe_hook(self.hooks.on_sync_fallback, model.partition("@")[0])
|
|
1091
|
+
with self._sync_lock:
|
|
1092
|
+
if self._sync is None:
|
|
1093
|
+
self._sync = _SyncRunner(self)
|
|
1094
|
+
runner = self._sync
|
|
1095
|
+
reply = runner.run(runner.execute(envelope), envelope.deadline_at - time.time() + 5)
|
|
1096
|
+
return _unwrap(reply)
|
|
1097
|
+
|
|
1098
|
+
def _redis_name(self) -> str | None:
|
|
1099
|
+
"""`CLIENT SETNAME` value: the configured name plus this pod's id."""
|
|
1100
|
+
base = self.config.redis_client_name
|
|
1101
|
+
return f"{base}:{self.pod_id}" if base else None
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
class _LocalLimiters:
|
|
1105
|
+
"""`Limiter` for Valkey-down mode (DS18): the failover policy's per-target local caps.
|
|
1106
|
+
|
|
1107
|
+
Uses `FailoverState.local_limiter(model, target)` when the policy has it, else one
|
|
1108
|
+
`LocalLimiter` capped at `local_limit_cap`. A lease goes back to the limiter that issued it,
|
|
1109
|
+
even after the policy rebuilt its limiters.
|
|
1110
|
+
"""
|
|
1111
|
+
|
|
1112
|
+
def __init__(self, failover: FailoverPolicy, config: QueueConfig) -> None:
|
|
1113
|
+
self._failover = failover
|
|
1114
|
+
self._fallback = LocalLimiter(
|
|
1115
|
+
config.local_limit_cap,
|
|
1116
|
+
bg_share=config.limiter.bg_share,
|
|
1117
|
+
pause_secs=config.limiter.pause_secs,
|
|
1118
|
+
)
|
|
1119
|
+
self._issued: dict[str, LocalLimiter] = {}
|
|
1120
|
+
|
|
1121
|
+
def _for(self, model: str, target: str) -> LocalLimiter:
|
|
1122
|
+
factory = getattr(self._failover, "local_limiter", None)
|
|
1123
|
+
limiter = factory(model, target) if callable(factory) else None
|
|
1124
|
+
return limiter if isinstance(limiter, LocalLimiter) else self._fallback
|
|
1125
|
+
|
|
1126
|
+
async def acquire(
|
|
1127
|
+
self,
|
|
1128
|
+
model: str,
|
|
1129
|
+
target: str,
|
|
1130
|
+
lane: Lane,
|
|
1131
|
+
timeout_secs: float,
|
|
1132
|
+
*,
|
|
1133
|
+
should_abort: Callable[[], Awaitable[bool]] | None = None,
|
|
1134
|
+
) -> Lease | None:
|
|
1135
|
+
limiter = self._for(model, target)
|
|
1136
|
+
lease = await limiter.acquire(model, target, lane, timeout_secs, should_abort=should_abort)
|
|
1137
|
+
if lease is not None:
|
|
1138
|
+
self._issued[lease.token] = limiter
|
|
1139
|
+
return lease
|
|
1140
|
+
|
|
1141
|
+
async def release(self, lease: Lease, *, throttled: bool) -> None:
|
|
1142
|
+
limiter = self._issued.pop(lease.token, None) or self._for(lease.model, lease.target)
|
|
1143
|
+
await limiter.release(lease, throttled=throttled)
|
|
1144
|
+
|
|
1145
|
+
def gate(self, model: str, target: str) -> Any:
|
|
1146
|
+
return self._for(model, target).gate(model, target)
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
class _SyncLimiter:
|
|
1150
|
+
"""`Limiter` over `SyncAdaptiveLimiter`, run in threads so the private loop never blocks."""
|
|
1151
|
+
|
|
1152
|
+
def __init__(self, inner: SyncAdaptiveLimiter) -> None:
|
|
1153
|
+
self._inner = inner
|
|
1154
|
+
self._orphans: set[asyncio.Future[None]] = set()
|
|
1155
|
+
|
|
1156
|
+
async def acquire(
|
|
1157
|
+
self,
|
|
1158
|
+
model: str,
|
|
1159
|
+
target: str,
|
|
1160
|
+
lane: Lane,
|
|
1161
|
+
timeout_secs: float,
|
|
1162
|
+
*,
|
|
1163
|
+
should_abort: Callable[[], Awaitable[bool]] | None = None,
|
|
1164
|
+
) -> Lease | None:
|
|
1165
|
+
"""Take a slot in a thread; a lease granted after the caller gave up is released."""
|
|
1166
|
+
abort = _sync_abort(should_abort, asyncio.get_running_loop())
|
|
1167
|
+
future = asyncio.ensure_future(
|
|
1168
|
+
asyncio.to_thread(
|
|
1169
|
+
self._inner.acquire, model, target, lane, timeout_secs, should_abort=abort
|
|
1170
|
+
)
|
|
1171
|
+
)
|
|
1172
|
+
try:
|
|
1173
|
+
return await asyncio.shield(future)
|
|
1174
|
+
except asyncio.CancelledError: # sync timeout: the thread may still win a slot
|
|
1175
|
+
future.add_done_callback(self._release_orphan)
|
|
1176
|
+
raise
|
|
1177
|
+
|
|
1178
|
+
def _release_orphan(self, future: asyncio.Future[Lease | None]) -> None:
|
|
1179
|
+
if future.cancelled() or future.exception() is not None:
|
|
1180
|
+
return
|
|
1181
|
+
lease = future.result()
|
|
1182
|
+
if lease is not None:
|
|
1183
|
+
self._orphans.add(asyncio.ensure_future(self.release(lease, throttled=False)))
|
|
1184
|
+
for done in [f for f in self._orphans if f.done()]:
|
|
1185
|
+
self._orphans.discard(done)
|
|
1186
|
+
|
|
1187
|
+
async def release(self, lease: Lease, *, throttled: bool) -> None:
|
|
1188
|
+
await asyncio.to_thread(self._inner.release, lease, throttled=throttled)
|
|
1189
|
+
|
|
1190
|
+
def gate(self, model: str, target: str) -> Any:
|
|
1191
|
+
raise NotImplementedError("sync limiter has no receive gate")
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
def _sync_abort(
|
|
1195
|
+
should_abort: Callable[[], Awaitable[bool]] | None, loop: asyncio.AbstractEventLoop
|
|
1196
|
+
) -> Callable[[], bool] | None:
|
|
1197
|
+
"""Run an async `should_abort` on `loop` from the limiter's thread (False on any error)."""
|
|
1198
|
+
if should_abort is None:
|
|
1199
|
+
return None
|
|
1200
|
+
|
|
1201
|
+
async def call() -> bool:
|
|
1202
|
+
return await should_abort()
|
|
1203
|
+
|
|
1204
|
+
def check() -> bool:
|
|
1205
|
+
try:
|
|
1206
|
+
return asyncio.run_coroutine_threadsafe(call(), loop).result(5)
|
|
1207
|
+
except Exception:
|
|
1208
|
+
return False
|
|
1209
|
+
|
|
1210
|
+
return check
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
class _SyncRunner:
|
|
1214
|
+
"""A daemon thread with its own event loop and providers for sync callers (R22).
|
|
1215
|
+
|
|
1216
|
+
Providers bind their HTTP clients to a loop, so sync calls get their own registry; the
|
|
1217
|
+
limiter is the shared Valkey one through a blocking client (or the local cap, DS18).
|
|
1218
|
+
"""
|
|
1219
|
+
|
|
1220
|
+
def __init__(self, owner: LLMQueue) -> None:
|
|
1221
|
+
self._owner = owner
|
|
1222
|
+
self._loop = asyncio.new_event_loop()
|
|
1223
|
+
self._thread = threading.Thread(
|
|
1224
|
+
target=self._loop.run_forever, name="llmq-sync", daemon=True
|
|
1225
|
+
)
|
|
1226
|
+
self._thread.start()
|
|
1227
|
+
self._registry = ProviderRegistry(owner.config, factories=owner._factories)
|
|
1228
|
+
self._redis: redis.Redis | None = None
|
|
1229
|
+
|
|
1230
|
+
def run(self, coro: Coroutine[Any, Any, _T], timeout_secs: float) -> _T:
|
|
1231
|
+
"""Run `coro` on the private loop and block for its result."""
|
|
1232
|
+
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
|
1233
|
+
try:
|
|
1234
|
+
return future.result(max(1.0, timeout_secs))
|
|
1235
|
+
except TimeoutError as exc:
|
|
1236
|
+
future.cancel()
|
|
1237
|
+
raise LLMQueueTimeout("sync call timed out") from exc
|
|
1238
|
+
|
|
1239
|
+
def _limiter(self) -> Limiter:
|
|
1240
|
+
owner = self._owner
|
|
1241
|
+
if owner.failover.redis_down():
|
|
1242
|
+
return owner._local_limiter
|
|
1243
|
+
if self._redis is None:
|
|
1244
|
+
self._redis = create_sync_redis(owner.config, client_name=owner._redis_name())
|
|
1245
|
+
return _SyncLimiter(
|
|
1246
|
+
SyncAdaptiveLimiter(self._redis, owner.keys, limiter_settings(owner.config))
|
|
1247
|
+
)
|
|
1248
|
+
|
|
1249
|
+
async def execute(self, envelope: Envelope) -> LLMReply:
|
|
1250
|
+
owner = self._owner
|
|
1251
|
+
deps = CoreDeps(
|
|
1252
|
+
config=owner.config,
|
|
1253
|
+
keys=owner.keys,
|
|
1254
|
+
providers=self._registry.for_model,
|
|
1255
|
+
limiter=self._limiter(),
|
|
1256
|
+
worker=f"sync:{owner.pod_id}",
|
|
1257
|
+
hooks=owner.hooks,
|
|
1258
|
+
leases=owner._leases, # renewed by the pod heartbeat (DS11)
|
|
1259
|
+
)
|
|
1260
|
+
try:
|
|
1261
|
+
result = await execute_job(envelope, deps, claim=False, store=False)
|
|
1262
|
+
except InfraError as exc:
|
|
1263
|
+
owner._report_redis(exc.__cause__ or exc)
|
|
1264
|
+
if exc.attempted: # never a second provider call
|
|
1265
|
+
raise QueueUnavailable(f"Valkey failed during call {envelope.request_id}") from exc
|
|
1266
|
+
deps.limiter = owner._local_limiter
|
|
1267
|
+
result = await execute_job(envelope, deps, claim=False, store=False)
|
|
1268
|
+
assert result.reply is not None
|
|
1269
|
+
return result.reply
|
|
1270
|
+
|
|
1271
|
+
def close(self) -> None:
|
|
1272
|
+
"""Close providers, stop the loop and the thread."""
|
|
1273
|
+
with contextlib.suppress(Exception):
|
|
1274
|
+
asyncio.run_coroutine_threadsafe(self._registry.aclose(), self._loop).result(10)
|
|
1275
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
1276
|
+
self._thread.join(timeout=10)
|
|
1277
|
+
if self._redis is not None:
|
|
1278
|
+
self._redis.close()
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def decode_invoke(response: Mapping[str, Any]) -> dict[str, Any]:
|
|
1282
|
+
"""`{"body_b64", "contentType"}` from a worker → `{"body": bytes, "contentType": str}`."""
|
|
1283
|
+
return {
|
|
1284
|
+
"body": base64.b64decode(response.get("body_b64", "")),
|
|
1285
|
+
"contentType": str(response.get("contentType", "")),
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def _new_request_id() -> str:
|
|
1290
|
+
return f"llm-{uuid.uuid4().hex}"
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
def _new_aws_session() -> AioSession:
|
|
1294
|
+
"""A fresh aiobotocore session (credential chain resolved once, shared by its clients)."""
|
|
1295
|
+
from aiobotocore.session import get_session
|
|
1296
|
+
|
|
1297
|
+
return get_session()
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def _batch_ids(request_id: str, count: int) -> list[str]:
|
|
1301
|
+
"""Request id per embed batch: the id itself for one batch, else `<id>-<n>`."""
|
|
1302
|
+
return [request_id] if count == 1 else [f"{request_id}-{i}" for i in range(count)]
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def _invoke_body(body: Mapping[str, Any] | list[Any] | str | bytes) -> Any:
|
|
1306
|
+
"""Envelope-safe InvokeModel body: JSON values as-is, bytes as UTF-8 text."""
|
|
1307
|
+
if isinstance(body, bytes):
|
|
1308
|
+
try:
|
|
1309
|
+
return body.decode("utf-8")
|
|
1310
|
+
except UnicodeDecodeError as exc:
|
|
1311
|
+
raise ValueError("invoke_model body bytes must be UTF-8 text") from exc
|
|
1312
|
+
if isinstance(body, Mapping):
|
|
1313
|
+
return dict(body)
|
|
1314
|
+
return body
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def _invoke_result(reply: LLMReply) -> InvokeResult:
|
|
1318
|
+
raw = decode_invoke(reply.response or {})
|
|
1319
|
+
return InvokeResult(raw["body"], raw["contentType"], reply.meta)
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def _merge_meta(metas: Sequence[CallMeta]) -> CallMeta:
|
|
1323
|
+
"""One `CallMeta` for several batches: first model/target, summed attempts, slowest waits."""
|
|
1324
|
+
if not metas:
|
|
1325
|
+
return CallMeta()
|
|
1326
|
+
first = metas[0]
|
|
1327
|
+
return CallMeta(
|
|
1328
|
+
served_model=first.served_model,
|
|
1329
|
+
served_target=first.served_target,
|
|
1330
|
+
fallback_used=any(m.fallback_used for m in metas),
|
|
1331
|
+
queue_wait_ms=max(m.queue_wait_ms for m in metas),
|
|
1332
|
+
provider_ms=max(m.provider_ms for m in metas),
|
|
1333
|
+
attempts=sum(m.attempts for m in metas),
|
|
1334
|
+
worker=first.worker,
|
|
1335
|
+
)
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
def _embed_result(plan: Sequence[tuple[Op, Any]], replies: Sequence[LLMReply]) -> EmbedResult:
|
|
1339
|
+
vectors = [v for (op, _), reply in zip(plan, replies, strict=True) for v in _vectors(op, reply)]
|
|
1340
|
+
return EmbedResult(vectors, _merge_meta([r.meta for r in replies]))
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def _vectors(op: Op, reply: LLMReply) -> list[list[float]]:
|
|
1344
|
+
"""Vectors from one embed reply: Bedrock InvokeModel body or OpenAI embeddings `data`."""
|
|
1345
|
+
response = reply.response or {}
|
|
1346
|
+
if op == "invoke_model":
|
|
1347
|
+
return parse_embeddings(orjson.loads(decode_invoke(response)["body"]))
|
|
1348
|
+
data = sorted(response.get("data", []), key=lambda item: item.get("index", 0))
|
|
1349
|
+
return [list(item["embedding"]) for item in data]
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def _deterministic_send_error(exc: BaseException) -> BaseException | None:
|
|
1353
|
+
"""The cause of a send that would fail again (bad label, too large, config), else None.
|
|
1354
|
+
|
|
1355
|
+
Only infrastructure failures (SQS down, throttled, timeouts) open the SQS failover window.
|
|
1356
|
+
"""
|
|
1357
|
+
seen: BaseException | None = exc
|
|
1358
|
+
for _ in range(5):
|
|
1359
|
+
if seen is None:
|
|
1360
|
+
return None
|
|
1361
|
+
if isinstance(seen, SQSSendError):
|
|
1362
|
+
return seen if seen.family is ErrorFamily.SIZE_LIMIT else None
|
|
1363
|
+
if isinstance(seen, ValueError | TypeError | ConfigError):
|
|
1364
|
+
return seen
|
|
1365
|
+
seen = seen.__cause__
|
|
1366
|
+
return None
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
def _unwrap(reply: LLMReply) -> LLMReply:
|
|
1370
|
+
"""Return an ok reply; map an error reply to `LLMQueueTimeout` / `ProviderError`."""
|
|
1371
|
+
if reply.ok:
|
|
1372
|
+
return reply
|
|
1373
|
+
error = reply.error or {}
|
|
1374
|
+
code = str(error.get("type", "error"))
|
|
1375
|
+
message = str(error.get("message", code))
|
|
1376
|
+
if reply.cancelled or code == "cancelled":
|
|
1377
|
+
raise LLMQueueCancelled(f"call {reply.request_id} was cancelled")
|
|
1378
|
+
if code == "deadline_exceeded":
|
|
1379
|
+
raise LLMQueueTimeout(f"call {reply.request_id} ran past its deadline")
|
|
1380
|
+
retry_after = error.get("retry_after_secs")
|
|
1381
|
+
exc = ProviderError(
|
|
1382
|
+
_error_text(code, message),
|
|
1383
|
+
retryable=bool(error.get("retryable", False)),
|
|
1384
|
+
retry_after_secs=float(retry_after) if retry_after is not None else None,
|
|
1385
|
+
)
|
|
1386
|
+
exc.status_code = _STATUS_BY_KIND.get(str(error.get("kind", "")), 502) # type: ignore[attr-defined] # optional attribute read by http_transport
|
|
1387
|
+
raise exc
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def _error_text(code: str, message: str) -> str:
|
|
1391
|
+
"""`code: message` without repeating the code (`ThrottlingException: ThrottlingException`)."""
|
|
1392
|
+
parts: list[str] = []
|
|
1393
|
+
for part in [code, *message.split(": ")]:
|
|
1394
|
+
if part and part not in parts:
|
|
1395
|
+
parts.append(part)
|
|
1396
|
+
return ": ".join(parts) or "error"
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
def _set_default_queue(llmq: LLMQueue | None, *, only: LLMQueue | None = None) -> None:
|
|
1400
|
+
"""Register (or clear) the adapters' default queue; clearing only if it is still `only`."""
|
|
1401
|
+
from taskiq_llm_queue.langchain._client import get_default_queue, set_default_queue
|
|
1402
|
+
|
|
1403
|
+
if only is not None:
|
|
1404
|
+
try:
|
|
1405
|
+
if get_default_queue() is not only:
|
|
1406
|
+
return
|
|
1407
|
+
except LLMQueueError:
|
|
1408
|
+
return
|
|
1409
|
+
set_default_queue(llmq)
|