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,592 @@
|
|
|
1
|
+
"""Typed configuration: models and their targets, queues, Redis/SQS endpoints, timeouts.
|
|
2
|
+
|
|
3
|
+
`QueueConfig` is the single object an app builds (directly or with `from_env`) and passes to
|
|
4
|
+
`LLMQueue`. Validation runs once at construction, so bad config fails at boot, not mid-request.
|
|
5
|
+
`create_redis` / `create_sync_redis` build the package's Redis/Valkey clients from it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import threading
|
|
13
|
+
import warnings
|
|
14
|
+
from collections.abc import Callable, Mapping
|
|
15
|
+
from enum import StrEnum
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
17
|
+
|
|
18
|
+
import orjson
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
|
|
20
|
+
|
|
21
|
+
from taskiq_llm_queue._logging import get_logger
|
|
22
|
+
from taskiq_llm_queue.errors import ConfigError
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
import redis
|
|
26
|
+
import redis.asyncio
|
|
27
|
+
|
|
28
|
+
logger = get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
# Built-in provider names; custom providers (QueueConfig.providers / entry points) add their own.
|
|
31
|
+
BUILTIN_PROVIDERS = frozenset({"bedrock", "openai"})
|
|
32
|
+
ProviderKind = str
|
|
33
|
+
ENV_PREFIX = "LLMQ_"
|
|
34
|
+
_ALIAS_RE = re.compile(r"^[a-z0-9][a-z0-9_.-]*$")
|
|
35
|
+
# Key of the BLAKE2b hash behind hashed ids and canary buckets. Public and fixed, so only good
|
|
36
|
+
# for local use: set your own `LLMQ_ID_HASH_SALT` in production (all pods must share it).
|
|
37
|
+
DEFAULT_ID_HASH_SALT = "taskiq-llm-queue/id-hash/v1"
|
|
38
|
+
_MAX_SALT_BYTES = 64 # BLAKE2b key limit
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Lane(StrEnum):
|
|
42
|
+
"""Request priority: live chat, background work, bulk ingestion (highest first)."""
|
|
43
|
+
|
|
44
|
+
RT = "rt"
|
|
45
|
+
BG = "bg"
|
|
46
|
+
BULK = "bulk"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Target(BaseModel):
|
|
50
|
+
"""One place a model can be served from: a region, a cross-region profile or a URL."""
|
|
51
|
+
|
|
52
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
53
|
+
|
|
54
|
+
region: str | None = None
|
|
55
|
+
profile_id: str | None = None
|
|
56
|
+
base_url: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ModelConfig(BaseModel):
|
|
60
|
+
"""One model alias: provider, provider model id, named targets and its queues.
|
|
61
|
+
|
|
62
|
+
`queues` maps `rt` / `default` / optional `bulk` to SQS queue names or URLs. The first entry
|
|
63
|
+
in `targets` is the primary target.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
67
|
+
|
|
68
|
+
provider: ProviderKind
|
|
69
|
+
model_id: str
|
|
70
|
+
targets: dict[str, Target] = Field(default_factory=lambda: {"primary": Target()})
|
|
71
|
+
queues: dict[Literal["rt", "default", "bulk"], str] = Field(default_factory=dict)
|
|
72
|
+
limiter_initial: int = Field(default=20, ge=1)
|
|
73
|
+
api_key_env: str | None = None
|
|
74
|
+
|
|
75
|
+
@field_validator("targets")
|
|
76
|
+
@classmethod
|
|
77
|
+
def _targets_not_empty(cls, value: dict[str, Target]) -> dict[str, Target]:
|
|
78
|
+
if not value:
|
|
79
|
+
raise ValueError("a model needs at least one target")
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class LimiterTuning(BaseModel):
|
|
84
|
+
"""Adaptive limiter tuning (`LLMQ_AC_*`), mapped onto `reliability.LimiterSettings`.
|
|
85
|
+
|
|
86
|
+
`initial` overrides a model's `limiter_initial` by alias (`LLMQ_AC_INITIAL`, JSON).
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
90
|
+
|
|
91
|
+
initial: dict[str, int] = Field(default_factory=dict)
|
|
92
|
+
min_limit: int = Field(default=2, ge=1)
|
|
93
|
+
max_limit: int = Field(default=2000, ge=1)
|
|
94
|
+
ramp_window: int = Field(default=50, ge=1)
|
|
95
|
+
increase_every: int = Field(default=20, ge=1)
|
|
96
|
+
decrease_factor: float = Field(default=0.7, gt=0, lt=1)
|
|
97
|
+
pause_secs: float = Field(default=2.0, ge=0)
|
|
98
|
+
bg_share: float = Field(default=0.8, gt=0, le=1)
|
|
99
|
+
utilisation: float = Field(default=0.9, gt=0, le=1)
|
|
100
|
+
ramp_cap: int | None = Field(default=None, ge=1)
|
|
101
|
+
# Renewed every pod heartbeat (10 s); a killed pod's slots return within it (S35b: 240 s
|
|
102
|
+
# left a killed worker's leases blocking most of the limit for 4 minutes).
|
|
103
|
+
lease_ttl_secs: float = Field(default=60.0, gt=0)
|
|
104
|
+
|
|
105
|
+
@model_validator(mode="after")
|
|
106
|
+
def _min_le_max(self) -> LimiterTuning:
|
|
107
|
+
if self.min_limit > self.max_limit:
|
|
108
|
+
raise ValueError("limiter.min_limit must be <= limiter.max_limit")
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class BreakerTuning(BaseModel):
|
|
113
|
+
"""Circuit breaker tuning (`LLMQ_CB_*`), mapped onto `reliability.BreakerSettings`."""
|
|
114
|
+
|
|
115
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
116
|
+
|
|
117
|
+
threshold: int = Field(default=5, ge=1)
|
|
118
|
+
window_secs: float = Field(default=30.0, gt=0)
|
|
119
|
+
cooldown_secs: float = Field(default=15.0, gt=0)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _lane_map(default: Mapping[Lane, Any]) -> Any:
|
|
123
|
+
"""Field factory for per-lane settings (a fresh dict per instance)."""
|
|
124
|
+
return Field(default_factory=lambda: dict(default))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class QueueConfig(BaseModel):
|
|
128
|
+
"""Everything `LLMQueue` needs; immutable after construction.
|
|
129
|
+
|
|
130
|
+
Timeouts are per lane; TTLs follow one rule (state keys outlive the longest lane
|
|
131
|
+
deadline plus worst-case execution; results only until the caller reads them).
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
135
|
+
|
|
136
|
+
key_prefix: str = "llmq:local"
|
|
137
|
+
# Redis/Valkey: `redis_url` (redis:// or rediss://) wins over host/port/ssl when set; its
|
|
138
|
+
# credentials win over `redis_username` / `redis_password`.
|
|
139
|
+
redis_url: SecretStr | None = None
|
|
140
|
+
redis_host: str = "localhost"
|
|
141
|
+
redis_port: int = 6379
|
|
142
|
+
redis_username: str | None = None
|
|
143
|
+
redis_password: SecretStr | None = None
|
|
144
|
+
redis_ssl: bool = False
|
|
145
|
+
redis_max_connections: int = Field(default=32, ge=1)
|
|
146
|
+
redis_socket_timeout_secs: float = Field(default=5.0, gt=0)
|
|
147
|
+
redis_socket_connect_timeout_secs: float = Field(default=2.0, gt=0)
|
|
148
|
+
redis_health_check_interval_secs: int = Field(default=30, ge=0)
|
|
149
|
+
redis_retry_attempts: int = Field(default=3, ge=0, le=10)
|
|
150
|
+
redis_client_name: str | None = "taskiq-llm-queue"
|
|
151
|
+
sqs_endpoint_url: str | None = None
|
|
152
|
+
bedrock_endpoint_url: str | None = None
|
|
153
|
+
# None lets the AWS chain decide (AWS_REGION, profile, IRSA / Pod Identity).
|
|
154
|
+
aws_region: str | None = None
|
|
155
|
+
models: dict[str, ModelConfig] = Field(default_factory=dict)
|
|
156
|
+
allowed_providers: frozenset[ProviderKind] = BUILTIN_PROVIDERS
|
|
157
|
+
denied_hosts: tuple[str, ...] = ()
|
|
158
|
+
providers: dict[str, str] = Field(default_factory=dict)
|
|
159
|
+
timeout_secs: dict[Lane, float] = Field(
|
|
160
|
+
default_factory=lambda: {Lane.RT: 90.0, Lane.BG: 300.0, Lane.BULK: 900.0}
|
|
161
|
+
)
|
|
162
|
+
state_ttl_secs: int = Field(default=1800, ge=60)
|
|
163
|
+
result_ttl_secs: int = Field(default=300, ge=10)
|
|
164
|
+
inline_max_bytes: int = Field(default=200_000, ge=1024, le=1_000_000)
|
|
165
|
+
hash_ids: bool = True
|
|
166
|
+
id_hash_salt: SecretStr = SecretStr(DEFAULT_ID_HASH_SALT)
|
|
167
|
+
strict_async: bool = False
|
|
168
|
+
# --- runtime tuning (all optional; defaults are the recommended production starts)
|
|
169
|
+
limiter: LimiterTuning = Field(default_factory=LimiterTuning)
|
|
170
|
+
breaker: BreakerTuning = Field(default_factory=BreakerTuning)
|
|
171
|
+
max_failures: int = Field(default=3, ge=1)
|
|
172
|
+
local_limit_cap: int = Field(default=10, ge=1)
|
|
173
|
+
cfg_reload_secs: float = Field(default=30.0, gt=0)
|
|
174
|
+
# Receiver `max_async_tasks` per model alias (`jobs` for the job broker); unset -> default.
|
|
175
|
+
worker_slots: dict[str, int] = Field(default_factory=dict)
|
|
176
|
+
drain_secs: float = Field(default=45.0, ge=0)
|
|
177
|
+
result_dispatch_ms: dict[Lane, int] = _lane_map({Lane.RT: 100, Lane.BG: 500, Lane.BULK: 2000})
|
|
178
|
+
embed_timeout_secs: dict[Lane, float] = _lane_map(
|
|
179
|
+
{Lane.RT: 10.0, Lane.BG: 60.0, Lane.BULK: 300.0}
|
|
180
|
+
)
|
|
181
|
+
# Startup default of the live fallback chains (`reliability.FallbackConfig` JSON shape).
|
|
182
|
+
fallback_default: dict[str, Any] = Field(default_factory=dict)
|
|
183
|
+
routing_enabled: bool = False # build a CanaryRouter on the live routing config
|
|
184
|
+
routing_default: dict[str, Any] = Field(default_factory=dict)
|
|
185
|
+
job_queues: dict[str, str] = Field(default_factory=dict)
|
|
186
|
+
retry_attempts: int = Field(default=3, ge=1, le=10)
|
|
187
|
+
retry_base_secs: float = Field(default=1.0, ge=0)
|
|
188
|
+
throttle_retry_attempts: int = Field(
|
|
189
|
+
default=6,
|
|
190
|
+
ge=1,
|
|
191
|
+
le=20,
|
|
192
|
+
description="Attempts per fallback step once the provider throttled it (instead of "
|
|
193
|
+
"`retry_attempts` when larger), each after a backoff that holds no limiter slot. "
|
|
194
|
+
"At S35b's 30 % random 429 rate a step fails 0.3^3 = 2.7 % with 3 tries, 0.07 % with 6.",
|
|
195
|
+
)
|
|
196
|
+
sqs_wait_time_secs: int = Field(default=20, ge=0, le=20)
|
|
197
|
+
sqs_failover_secs: float = Field(default=300.0, gt=0)
|
|
198
|
+
attempt_timeout_secs: float = Field(
|
|
199
|
+
default=180.0,
|
|
200
|
+
gt=0,
|
|
201
|
+
description="Longest one provider attempt may run (capped by the call's remaining "
|
|
202
|
+
"deadline); on expiry the worker tries the next fallback target. Unverified default: "
|
|
203
|
+
"long enough for an ~8k-token completion.",
|
|
204
|
+
)
|
|
205
|
+
provider_read_timeout_secs: float = Field(
|
|
206
|
+
default=300.0,
|
|
207
|
+
gt=0,
|
|
208
|
+
description="Socket read timeout of the built-in providers (botocore `read_timeout`, "
|
|
209
|
+
"httpx read for OpenAI-compatible servers).",
|
|
210
|
+
)
|
|
211
|
+
provider_connect_timeout_secs: float = Field(
|
|
212
|
+
default=10.0,
|
|
213
|
+
gt=0,
|
|
214
|
+
description="Connect timeout of the built-in providers (botocore `connect_timeout`, "
|
|
215
|
+
"httpx connect for OpenAI-compatible servers).",
|
|
216
|
+
)
|
|
217
|
+
sqs_send_timeout_secs: float = Field(
|
|
218
|
+
default=5.0,
|
|
219
|
+
gt=0,
|
|
220
|
+
description="Upper bound on one SQS send before the call fails over to direct; the "
|
|
221
|
+
"send also gets at most half the call's remaining budget. A send to an unreachable "
|
|
222
|
+
"SQS otherwise hangs on a dead pooled connection until the 60 s read timeout.",
|
|
223
|
+
)
|
|
224
|
+
valkey_wait_failover_secs: float = Field(
|
|
225
|
+
default=10.0,
|
|
226
|
+
gt=0,
|
|
227
|
+
description="How long result reads may fail before callers waiting on a queued call "
|
|
228
|
+
"run it direct under the local cap instead of waiting to their deadline. Longer than a "
|
|
229
|
+
"Valkey restart blip, so in-flight worker results are not run twice.",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
@field_validator("id_hash_salt")
|
|
233
|
+
@classmethod
|
|
234
|
+
def _salt_fits(cls, value: SecretStr) -> SecretStr:
|
|
235
|
+
size = len(value.get_secret_value().encode("utf-8"))
|
|
236
|
+
if not 1 <= size <= _MAX_SALT_BYTES:
|
|
237
|
+
raise ValueError(f"id_hash_salt must be 1-{_MAX_SALT_BYTES} bytes, got {size}")
|
|
238
|
+
return value
|
|
239
|
+
|
|
240
|
+
@field_validator("models")
|
|
241
|
+
@classmethod
|
|
242
|
+
def _aliases_valid(cls, value: dict[str, ModelConfig]) -> dict[str, ModelConfig]:
|
|
243
|
+
bad = [alias for alias in value if not _ALIAS_RE.match(alias)]
|
|
244
|
+
if bad:
|
|
245
|
+
raise ValueError(f"invalid model aliases (lowercase, no '@'): {bad}")
|
|
246
|
+
return value
|
|
247
|
+
|
|
248
|
+
@model_validator(mode="after")
|
|
249
|
+
def _providers_allowed(self) -> QueueConfig:
|
|
250
|
+
blocked = {
|
|
251
|
+
a: m.provider
|
|
252
|
+
for a, m in self.models.items()
|
|
253
|
+
if m.provider not in self.allowed_providers
|
|
254
|
+
}
|
|
255
|
+
if blocked:
|
|
256
|
+
raise ValueError(f"models use providers outside allowed_providers: {blocked}")
|
|
257
|
+
if self.fallback_default:
|
|
258
|
+
from taskiq_llm_queue.reliability.fallback import FallbackConfig
|
|
259
|
+
|
|
260
|
+
FallbackConfig.model_validate(self.fallback_default) # fail at boot, not per call
|
|
261
|
+
bad_slots = {k: v for k, v in self.worker_slots.items() if v < 1}
|
|
262
|
+
if bad_slots:
|
|
263
|
+
raise ValueError(f"worker_slots must be >= 1: {bad_slots}")
|
|
264
|
+
return self
|
|
265
|
+
|
|
266
|
+
def resolve(self, model: str) -> tuple[str, ModelConfig, str]:
|
|
267
|
+
"""Split `alias[@target]` and return (alias, model config, target name).
|
|
268
|
+
|
|
269
|
+
Raises `ConfigError` for an unknown alias or target, so typos surface on first use.
|
|
270
|
+
"""
|
|
271
|
+
alias, _, target = model.partition("@")
|
|
272
|
+
cfg = self.models.get(alias)
|
|
273
|
+
if cfg is None:
|
|
274
|
+
raise ConfigError(f"unknown model alias {alias!r}; known: {sorted(self.models)}")
|
|
275
|
+
target = target or next(iter(cfg.targets))
|
|
276
|
+
if target not in cfg.targets:
|
|
277
|
+
raise ConfigError(
|
|
278
|
+
f"unknown target {target!r} for {alias!r}; known: {sorted(cfg.targets)}"
|
|
279
|
+
)
|
|
280
|
+
return alias, cfg, target
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def id_hash_key(self) -> bytes:
|
|
284
|
+
"""`id_hash_salt` as the BLAKE2b key for hashed ids and canary buckets."""
|
|
285
|
+
return self.id_hash_salt.get_secret_value().encode("utf-8")
|
|
286
|
+
|
|
287
|
+
@classmethod
|
|
288
|
+
def from_env(cls, environ: Mapping[str, str] | None = None) -> QueueConfig:
|
|
289
|
+
"""Build a config from `LLMQ_*` variables; `LLMQ_MODELS` holds the models as JSON.
|
|
290
|
+
|
|
291
|
+
Every bad value is collected and reported in one `ConfigError`.
|
|
292
|
+
"""
|
|
293
|
+
env = os.environ if environ is None else environ
|
|
294
|
+
|
|
295
|
+
def get(name: str) -> str | None:
|
|
296
|
+
return env_value(env, name)
|
|
297
|
+
|
|
298
|
+
raw: dict[str, object] = {}
|
|
299
|
+
errors: list[str] = []
|
|
300
|
+
for name, key, conv in (
|
|
301
|
+
("KEY_PREFIX", "key_prefix", str),
|
|
302
|
+
("REDIS_URL", "redis_url", str),
|
|
303
|
+
("REDIS_HOST", "redis_host", str),
|
|
304
|
+
("REDIS_PORT", "redis_port", int),
|
|
305
|
+
("REDIS_USERNAME", "redis_username", str),
|
|
306
|
+
("REDIS_PASSWORD", "redis_password", str),
|
|
307
|
+
("REDIS_SSL", "redis_ssl", _to_bool),
|
|
308
|
+
("REDIS_MAX_CONNECTIONS", "redis_max_connections", int),
|
|
309
|
+
("REDIS_SOCKET_TIMEOUT_SECS", "redis_socket_timeout_secs", float),
|
|
310
|
+
("REDIS_SOCKET_CONNECT_TIMEOUT_SECS", "redis_socket_connect_timeout_secs", float),
|
|
311
|
+
("REDIS_HEALTH_CHECK_INTERVAL_SECS", "redis_health_check_interval_secs", int),
|
|
312
|
+
("REDIS_RETRY_ATTEMPTS", "redis_retry_attempts", int),
|
|
313
|
+
("REDIS_CLIENT_NAME", "redis_client_name", str),
|
|
314
|
+
("SQS_ENDPOINT_URL", "sqs_endpoint_url", str),
|
|
315
|
+
("BEDROCK_ENDPOINT_URL", "bedrock_endpoint_url", str),
|
|
316
|
+
("AWS_REGION", "aws_region", str),
|
|
317
|
+
("STATE_TTL_SECS", "state_ttl_secs", int),
|
|
318
|
+
("RESULT_TTL_SECS", "result_ttl_secs", int),
|
|
319
|
+
("INLINE_MAX_BYTES", "inline_max_bytes", int),
|
|
320
|
+
("HASH_IDS", "hash_ids", _to_bool),
|
|
321
|
+
("ID_HASH_SALT", "id_hash_salt", str),
|
|
322
|
+
("STRICT_ASYNC", "strict_async", _to_bool),
|
|
323
|
+
("ROUTING_ENABLED", "routing_enabled", _to_bool),
|
|
324
|
+
("MAX_FAILURES", "max_failures", int),
|
|
325
|
+
("LOCAL_LIMIT_CAP", "local_limit_cap", int),
|
|
326
|
+
("CFG_RELOAD_SECS", "cfg_reload_secs", float),
|
|
327
|
+
("CONSUMER_DRAIN_SECS", "drain_secs", float),
|
|
328
|
+
("RETRY_ATTEMPTS", "retry_attempts", int),
|
|
329
|
+
("RETRY_BASE_SECS", "retry_base_secs", float),
|
|
330
|
+
("THROTTLE_RETRY_ATTEMPTS", "throttle_retry_attempts", int),
|
|
331
|
+
("SQS_WAIT_TIME_SECS", "sqs_wait_time_secs", int),
|
|
332
|
+
("SQS_FAILOVER_SECS", "sqs_failover_secs", float),
|
|
333
|
+
("ATTEMPT_TIMEOUT_SECS", "attempt_timeout_secs", float),
|
|
334
|
+
("PROVIDER_READ_TIMEOUT_SECS", "provider_read_timeout_secs", float),
|
|
335
|
+
("PROVIDER_CONNECT_TIMEOUT_SECS", "provider_connect_timeout_secs", float),
|
|
336
|
+
("SQS_SEND_TIMEOUT_SECS", "sqs_send_timeout_secs", float),
|
|
337
|
+
("VALKEY_WAIT_FAILOVER_SECS", "valkey_wait_failover_secs", float),
|
|
338
|
+
("WORKER_SLOTS", "worker_slots", _int_map),
|
|
339
|
+
):
|
|
340
|
+
value = get(name)
|
|
341
|
+
if value is None:
|
|
342
|
+
continue
|
|
343
|
+
try:
|
|
344
|
+
raw[key] = conv(value)
|
|
345
|
+
except ValueError as exc:
|
|
346
|
+
errors.append(f"{ENV_PREFIX}{name}: {exc}")
|
|
347
|
+
for name, key in (
|
|
348
|
+
("MODELS", "models"),
|
|
349
|
+
("PROVIDERS", "providers"),
|
|
350
|
+
("FALLBACK_CHAINS", "fallback_default"),
|
|
351
|
+
("ROUTING_DEFAULT", "routing_default"),
|
|
352
|
+
("JOB_QUEUES", "job_queues"),
|
|
353
|
+
):
|
|
354
|
+
value = get(name)
|
|
355
|
+
if value:
|
|
356
|
+
try:
|
|
357
|
+
raw[key] = orjson.loads(value)
|
|
358
|
+
except orjson.JSONDecodeError as exc:
|
|
359
|
+
errors.append(f"{ENV_PREFIX}{name}: invalid JSON ({exc})")
|
|
360
|
+
_lanes_from_env(get, raw, errors)
|
|
361
|
+
_tuning_from_env(get, raw, errors)
|
|
362
|
+
if (allowed := get("ALLOWED_PROVIDERS")) is not None:
|
|
363
|
+
raw["allowed_providers"] = frozenset(p.strip() for p in allowed.split(",") if p.strip())
|
|
364
|
+
if (denied := get("DENIED_HOSTS")) is not None:
|
|
365
|
+
raw["denied_hosts"] = tuple(h.strip() for h in denied.split(",") if h.strip())
|
|
366
|
+
if errors:
|
|
367
|
+
raise ConfigError("; ".join(errors))
|
|
368
|
+
try:
|
|
369
|
+
return cls.model_validate(raw)
|
|
370
|
+
except ValueError as exc:
|
|
371
|
+
raise ConfigError(str(exc)) from exc
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
_Getter = Callable[[str], "str | None"]
|
|
375
|
+
|
|
376
|
+
# (env suffix, LimiterTuning field, converter) for `LLMQ_AC_*`; `CB_*` likewise for the breaker.
|
|
377
|
+
_LIMITER_ENV: tuple[tuple[str, str, Callable[[str], Any]], ...] = (
|
|
378
|
+
("AC_MIN", "min_limit", int),
|
|
379
|
+
("AC_MAX", "max_limit", int),
|
|
380
|
+
("AC_RAMP_WINDOW", "ramp_window", int),
|
|
381
|
+
("AC_INCREASE_EVERY", "increase_every", int),
|
|
382
|
+
("AC_DECREASE_FACTOR", "decrease_factor", float),
|
|
383
|
+
("AC_PAUSE_SECS", "pause_secs", float),
|
|
384
|
+
("AC_BG_SHARE", "bg_share", float),
|
|
385
|
+
("AC_RAMP_CAP", "ramp_cap", int),
|
|
386
|
+
("AC_LEASE_TTL_SECS", "lease_ttl_secs", float),
|
|
387
|
+
)
|
|
388
|
+
_BREAKER_ENV: tuple[tuple[str, str, Callable[[str], Any]], ...] = (
|
|
389
|
+
("CB_THRESHOLD", "threshold", int),
|
|
390
|
+
("CB_WINDOW_SECS", "window_secs", float),
|
|
391
|
+
("CB_COOLDOWN_SECS", "cooldown_secs", float),
|
|
392
|
+
)
|
|
393
|
+
# Deprecated `LLMQ_*` spellings (current name -> old name), read only when the new one is unset.
|
|
394
|
+
DEPRECATED_ENV: Mapping[str, str] = {
|
|
395
|
+
"CFG_RELOAD_SECS": "CFG_RELOAD_S",
|
|
396
|
+
"CONSUMER_DRAIN_SECS": "CONSUMER_DRAIN_S",
|
|
397
|
+
"RETRY_BASE_SECS": "RETRY_BASE_S",
|
|
398
|
+
"SQS_WAIT_TIME_SECS": "SQS_WAIT_TIME_S",
|
|
399
|
+
"SQS_FAILOVER_SECS": "SQS_FAILOVER_S",
|
|
400
|
+
"AC_PAUSE_SECS": "AC_PAUSE_S",
|
|
401
|
+
"AC_LEASE_TTL_SECS": "AC_LEASE_TTL_S",
|
|
402
|
+
"CB_WINDOW_SECS": "CB_WINDOW_S",
|
|
403
|
+
"CB_COOLDOWN_SECS": "CB_COOLDOWN_S",
|
|
404
|
+
**{f"TIMEOUT_{lane.value.upper()}_SECS": f"TIMEOUT_{lane.value.upper()}" for lane in Lane},
|
|
405
|
+
**{
|
|
406
|
+
f"TIMEOUT_EMBED_{lane.value.upper()}_SECS": f"TIMEOUT_EMBED_{lane.value.upper()}"
|
|
407
|
+
for lane in Lane
|
|
408
|
+
},
|
|
409
|
+
}
|
|
410
|
+
_warned_env: set[str] = set()
|
|
411
|
+
_warned_lock = threading.Lock()
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def env_value(env: Mapping[str, str], name: str) -> str | None:
|
|
415
|
+
"""`LLMQ_<name>`, else its deprecated spelling (warned once per process), else None."""
|
|
416
|
+
value = env.get(ENV_PREFIX + name)
|
|
417
|
+
old = DEPRECATED_ENV.get(name)
|
|
418
|
+
if value is not None or old is None:
|
|
419
|
+
return value
|
|
420
|
+
value = env.get(ENV_PREFIX + old)
|
|
421
|
+
if value is not None:
|
|
422
|
+
with _warned_lock:
|
|
423
|
+
first = old not in _warned_env
|
|
424
|
+
_warned_env.add(old)
|
|
425
|
+
if first:
|
|
426
|
+
message = f"{ENV_PREFIX}{old} is deprecated; use {ENV_PREFIX}{name}"
|
|
427
|
+
warnings.warn(message, DeprecationWarning, stacklevel=3)
|
|
428
|
+
logger.warning("deprecated_env_name", old=ENV_PREFIX + old, new=ENV_PREFIX + name)
|
|
429
|
+
return value
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _int_map(value: str) -> dict[str, int]:
|
|
433
|
+
"""Parse `a:1,b:2` (the `LLMQ_WORKER_SLOTS` / `LLMQ_RESULT_DISPATCH_MS` shape)."""
|
|
434
|
+
out: dict[str, int] = {}
|
|
435
|
+
for part in value.split(","):
|
|
436
|
+
if not part.strip():
|
|
437
|
+
continue
|
|
438
|
+
name, sep, number = part.partition(":")
|
|
439
|
+
if not sep or not name.strip():
|
|
440
|
+
raise ValueError(f"expected name:number pairs, got {part!r}")
|
|
441
|
+
out[name.strip()] = int(number)
|
|
442
|
+
return out
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _lanes_from_env(get: _Getter, raw: dict[str, object], errors: list[str]) -> None:
|
|
446
|
+
"""`LLMQ_TIMEOUT_<LANE>_SECS`, `LLMQ_TIMEOUT_EMBED_<LANE>_SECS`, `LLMQ_RESULT_DISPATCH_MS`."""
|
|
447
|
+
for prefix, key in (("TIMEOUT_", "timeout_secs"), ("TIMEOUT_EMBED_", "embed_timeout_secs")):
|
|
448
|
+
lanes: dict[Lane, float] = {}
|
|
449
|
+
for lane in Lane:
|
|
450
|
+
name = f"{prefix}{lane.value.upper()}_SECS"
|
|
451
|
+
value = get(name)
|
|
452
|
+
if value is None:
|
|
453
|
+
continue
|
|
454
|
+
try:
|
|
455
|
+
lanes[lane] = float(value)
|
|
456
|
+
except ValueError as exc:
|
|
457
|
+
errors.append(f"{ENV_PREFIX}{name}: {exc}")
|
|
458
|
+
if lanes:
|
|
459
|
+
default = QueueConfig.model_fields[key].get_default(call_default_factory=True)
|
|
460
|
+
raw[key] = {**default, **lanes}
|
|
461
|
+
if (value := get("RESULT_DISPATCH_MS")) is not None:
|
|
462
|
+
try:
|
|
463
|
+
raw["result_dispatch_ms"] = {Lane(k): v for k, v in _int_map(value).items()}
|
|
464
|
+
except ValueError as exc:
|
|
465
|
+
errors.append(f"{ENV_PREFIX}RESULT_DISPATCH_MS: {exc}")
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _tuning_from_env(get: _Getter, raw: dict[str, object], errors: list[str]) -> None:
|
|
469
|
+
"""Nested `limiter` (`LLMQ_AC_*`, `LLMQ_AC_INITIAL` JSON) and `breaker` (`LLMQ_CB_*`)."""
|
|
470
|
+
for field, table in (("limiter", _LIMITER_ENV), ("breaker", _BREAKER_ENV)):
|
|
471
|
+
values: dict[str, Any] = {}
|
|
472
|
+
for name, key, conv in table:
|
|
473
|
+
value = get(name)
|
|
474
|
+
if value is None:
|
|
475
|
+
continue
|
|
476
|
+
try:
|
|
477
|
+
values[key] = conv(value)
|
|
478
|
+
except ValueError as exc:
|
|
479
|
+
errors.append(f"{ENV_PREFIX}{name}: {exc}")
|
|
480
|
+
if field == "limiter" and (initial := get("AC_INITIAL")):
|
|
481
|
+
try:
|
|
482
|
+
values["initial"] = orjson.loads(initial)
|
|
483
|
+
except orjson.JSONDecodeError as exc:
|
|
484
|
+
errors.append(f"{ENV_PREFIX}AC_INITIAL: invalid JSON ({exc})")
|
|
485
|
+
if values:
|
|
486
|
+
raw[field] = values
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _to_bool(value: str) -> bool:
|
|
490
|
+
"""Parse the usual env-var spellings of a boolean; anything else is an error."""
|
|
491
|
+
lowered = value.strip().lower()
|
|
492
|
+
if lowered in {"1", "true", "yes", "on"}:
|
|
493
|
+
return True
|
|
494
|
+
if lowered in {"0", "false", "no", "off"}:
|
|
495
|
+
return False
|
|
496
|
+
raise ValueError(f"not a boolean: {value!r}")
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _redis_kwargs(config: QueueConfig, retry: Any, client_name: str | None) -> dict[str, Any]:
|
|
500
|
+
"""Pool settings shared by the async and sync clients (everything but the address)."""
|
|
501
|
+
from redis.exceptions import ConnectionError as RedisConnectionError
|
|
502
|
+
|
|
503
|
+
password = config.redis_password
|
|
504
|
+
return {
|
|
505
|
+
"username": config.redis_username,
|
|
506
|
+
"password": password.get_secret_value() if password is not None else None,
|
|
507
|
+
"max_connections": redis_pool_size(config),
|
|
508
|
+
"socket_timeout": config.redis_socket_timeout_secs,
|
|
509
|
+
"socket_connect_timeout": config.redis_socket_connect_timeout_secs,
|
|
510
|
+
"health_check_interval": config.redis_health_check_interval_secs,
|
|
511
|
+
"retry": retry,
|
|
512
|
+
"retry_on_error": [RedisConnectionError],
|
|
513
|
+
"client_name": client_name or config.redis_client_name,
|
|
514
|
+
"timeout": _POOL_WAIT_SECS,
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
_POOL_WAIT_SECS = 5.0 # BlockingConnectionPool: wait this long for a free connection
|
|
519
|
+
_POOL_SPARE = 16 # connections beyond the worker slots: heartbeat, dispatcher, callers
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def redis_pool_size(config: QueueConfig) -> int:
|
|
523
|
+
"""Connection cap of the package's Valkey pools: `redis_max_connections`, raised to fit.
|
|
524
|
+
|
|
525
|
+
Every worker slot may hold a connection at once (claim, acquire poll, result store), so
|
|
526
|
+
with queued models the cap is at least their worker slots plus `_POOL_SPARE`. Bug: 32
|
|
527
|
+
connections under 154 slots waited out `_POOL_WAIT_SECS`, read as "Valkey down" and leaked
|
|
528
|
+
a limiter lease.
|
|
529
|
+
"""
|
|
530
|
+
from taskiq_llm_queue.lifecycle import DEFAULT_JOB_SLOTS, DEFAULT_WORKER_SLOTS, JOBS_BROKER
|
|
531
|
+
|
|
532
|
+
brokers: dict[tuple[str, ...], int] = {} # aliases with the same queues share one broker
|
|
533
|
+
for alias, model in config.models.items():
|
|
534
|
+
if model.queues:
|
|
535
|
+
key = tuple(sorted(model.queues.values()))
|
|
536
|
+
brokers.setdefault(key, config.worker_slots.get(alias, DEFAULT_WORKER_SLOTS))
|
|
537
|
+
slots = sum(brokers.values())
|
|
538
|
+
if config.job_queues:
|
|
539
|
+
slots += config.worker_slots.get(JOBS_BROKER, DEFAULT_JOB_SLOTS)
|
|
540
|
+
return (
|
|
541
|
+
max(config.redis_max_connections, slots + _POOL_SPARE)
|
|
542
|
+
if slots
|
|
543
|
+
else config.redis_max_connections
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _backoff() -> Any:
|
|
548
|
+
from redis.backoff import ExponentialBackoff
|
|
549
|
+
|
|
550
|
+
return ExponentialBackoff(cap=1.0, base=0.1)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def create_redis(config: QueueConfig, *, client_name: str | None = None) -> redis.asyncio.Redis:
|
|
554
|
+
"""The package's own async client: bounded blocking pool, socket timeouts and retries.
|
|
555
|
+
|
|
556
|
+
The one factory for every async Valkey client the package builds (no `decode_responses`).
|
|
557
|
+
Connection errors are retried `redis_retry_attempts` times with exponential backoff.
|
|
558
|
+
"""
|
|
559
|
+
from redis.asyncio import BlockingConnectionPool, Redis
|
|
560
|
+
from redis.asyncio.connection import Connection, SSLConnection
|
|
561
|
+
from redis.asyncio.retry import Retry
|
|
562
|
+
|
|
563
|
+
kwargs = _redis_kwargs(config, Retry(_backoff(), config.redis_retry_attempts), client_name)
|
|
564
|
+
if config.redis_url is not None:
|
|
565
|
+
pool = BlockingConnectionPool.from_url(config.redis_url.get_secret_value(), **kwargs)
|
|
566
|
+
else:
|
|
567
|
+
pool = BlockingConnectionPool(
|
|
568
|
+
connection_class=SSLConnection if config.redis_ssl else Connection,
|
|
569
|
+
host=config.redis_host,
|
|
570
|
+
port=config.redis_port,
|
|
571
|
+
**kwargs,
|
|
572
|
+
)
|
|
573
|
+
return Redis(connection_pool=pool)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def create_sync_redis(config: QueueConfig, *, client_name: str | None = None) -> redis.Redis:
|
|
577
|
+
"""Blocking twin of `create_redis` (same settings) for sync callers and tools."""
|
|
578
|
+
from redis import BlockingConnectionPool, Redis
|
|
579
|
+
from redis.connection import Connection, SSLConnection
|
|
580
|
+
from redis.retry import Retry
|
|
581
|
+
|
|
582
|
+
kwargs = _redis_kwargs(config, Retry(_backoff(), config.redis_retry_attempts), client_name)
|
|
583
|
+
if config.redis_url is not None:
|
|
584
|
+
pool = BlockingConnectionPool.from_url(config.redis_url.get_secret_value(), **kwargs)
|
|
585
|
+
else:
|
|
586
|
+
pool = BlockingConnectionPool( # type: ignore[no-untyped-call] # redis stub gap
|
|
587
|
+
connection_class=SSLConnection if config.redis_ssl else Connection,
|
|
588
|
+
host=config.redis_host,
|
|
589
|
+
port=config.redis_port,
|
|
590
|
+
**kwargs,
|
|
591
|
+
)
|
|
592
|
+
return Redis(connection_pool=pool)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Per-request context (lane, session, user) carried through async tasks and threads.
|
|
2
|
+
|
|
3
|
+
Routers set it once with `request_context(...)`; queued adapters read it on every call to pick
|
|
4
|
+
the lane and the canary bucket. Raw threads must run under `contextvars.copy_context().run`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from contextvars import ContextVar
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
|
|
14
|
+
from taskiq_llm_queue.config import Lane
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class RequestContext:
|
|
19
|
+
"""What the current request is: its lane and the ids used for routing and tracing.
|
|
20
|
+
|
|
21
|
+
`defaulted` is True when no `request_context` was set, so callers can warn once about
|
|
22
|
+
calls that silently land in the background lane.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
lane: Lane = Lane.BG
|
|
26
|
+
session_id: str | None = None
|
|
27
|
+
user_id: str | None = None
|
|
28
|
+
defaulted: bool = True
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_CURRENT: ContextVar[RequestContext | None] = ContextVar("taskiq_llm_queue_context", default=None)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def current_context() -> RequestContext:
|
|
35
|
+
"""The active context, or the default (`bg` lane, `defaulted=True`) when none is set."""
|
|
36
|
+
return _CURRENT.get() or RequestContext()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@contextmanager
|
|
40
|
+
def request_context(
|
|
41
|
+
*,
|
|
42
|
+
lane: Lane | str | None = None,
|
|
43
|
+
session_id: str | None = None,
|
|
44
|
+
user_id: str | None = None,
|
|
45
|
+
) -> Iterator[RequestContext]:
|
|
46
|
+
"""Set the lane and ids for everything run inside the block; unset fields are inherited.
|
|
47
|
+
|
|
48
|
+
Nesting is allowed, so a background job inside a request can switch only the lane.
|
|
49
|
+
"""
|
|
50
|
+
base = current_context()
|
|
51
|
+
ctx = replace(
|
|
52
|
+
base,
|
|
53
|
+
lane=Lane(lane) if lane is not None else base.lane,
|
|
54
|
+
session_id=session_id if session_id is not None else base.session_id,
|
|
55
|
+
user_id=user_id if user_id is not None else base.user_id,
|
|
56
|
+
defaulted=False,
|
|
57
|
+
)
|
|
58
|
+
token = _CURRENT.set(ctx)
|
|
59
|
+
try:
|
|
60
|
+
yield ctx
|
|
61
|
+
finally:
|
|
62
|
+
_CURRENT.reset(token)
|