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,482 @@
|
|
|
1
|
+
"""Cluster-wide adaptive concurrency limiter per (model, target), learned from throttles.
|
|
2
|
+
|
|
3
|
+
State lives in Valkey (`KeySpace.limiter` hash + `KeySpace.leases` ZSET); every decision is one
|
|
4
|
+
Lua call using Valkey `TIME` (DS10-DS12). `AdaptiveLimiter` serves asyncio workers,
|
|
5
|
+
`SyncAdaptiveLimiter` serves sync callers on the `direct` path with the same scripts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
from redis.exceptions import RedisError
|
|
19
|
+
|
|
20
|
+
from taskiq_llm_queue._logging import get_logger
|
|
21
|
+
from taskiq_llm_queue.config import Lane
|
|
22
|
+
from taskiq_llm_queue.keys import KeySpace
|
|
23
|
+
from taskiq_llm_queue.protocols import Lease
|
|
24
|
+
from taskiq_llm_queue.reliability import _lua
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
import redis
|
|
28
|
+
import redis.asyncio
|
|
29
|
+
|
|
30
|
+
from taskiq_llm_queue.config import QueueConfig
|
|
31
|
+
|
|
32
|
+
logger = get_logger(__name__)
|
|
33
|
+
|
|
34
|
+
_MAX_ORPHANS = 4096 # past this, leases left by failed calls just expire (lease_ttl_secs)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class LimiterSettings:
|
|
39
|
+
"""Tuning of the adaptive limiter (`LLMQ_AC_*`); defaults are recommended production starts.
|
|
40
|
+
|
|
41
|
+
`initial` maps a model alias to its start limit (a stored learned limit always wins).
|
|
42
|
+
DS12: growth needs `inflight >= utilisation x allowed` and stops at 2x the peak inflight,
|
|
43
|
+
which decays with `peak_halflife_secs`; `ramp_cap` is an extra hard cap on ramp doubling.
|
|
44
|
+
Cuts need clustered throttles (`loss_tolerance` of a `window_secs` window, see `_lua`).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
initial: Mapping[str, int] | None = None
|
|
48
|
+
default_initial: int = 20
|
|
49
|
+
min_limit: int = 2
|
|
50
|
+
max_limit: int = 2000
|
|
51
|
+
ramp_window: int = 50
|
|
52
|
+
increase_every: int = 20
|
|
53
|
+
decrease_factor: float = 0.7
|
|
54
|
+
pause_secs: float = 2.0
|
|
55
|
+
bg_share: float = 0.8
|
|
56
|
+
utilisation: float = 0.9
|
|
57
|
+
ramp_cap: int | None = None
|
|
58
|
+
lease_ttl_secs: float = 60.0 # 6 pod heartbeats; a killed pod's slots return in 1 min
|
|
59
|
+
poll_secs: float = 0.05
|
|
60
|
+
max_poll_secs: float = 0.25
|
|
61
|
+
peak_halflife_secs: float = 300.0
|
|
62
|
+
# S35: a lone 429 among many successes is noise, not a capacity signal.
|
|
63
|
+
loss_tolerance: float = 0.05
|
|
64
|
+
min_throttles: int = 2
|
|
65
|
+
pause_ratio: float = 0.5
|
|
66
|
+
window_secs: float = 1.0
|
|
67
|
+
# S07: hold the limit near `settle_ratio x` the last cut point and probe above it slowly.
|
|
68
|
+
settle_ratio: float = 0.9
|
|
69
|
+
probe_secs: float = 5.0
|
|
70
|
+
# S35b: throttles that do not fall after a cut are not ours; hold at the start value.
|
|
71
|
+
hold_ratio: float = 0.6
|
|
72
|
+
|
|
73
|
+
def __post_init__(self) -> None:
|
|
74
|
+
"""Reject impossible settings at construction (boot time), never on a call path."""
|
|
75
|
+
if not 1 <= self.min_limit <= self.max_limit:
|
|
76
|
+
raise ValueError("need 1 <= min_limit <= max_limit")
|
|
77
|
+
if not 0 < self.decrease_factor < 1 or not 0 < self.bg_share <= 1:
|
|
78
|
+
raise ValueError("decrease_factor must be in (0, 1) and bg_share in (0, 1]")
|
|
79
|
+
if self.ramp_window < 1 or self.increase_every < 1 or self.lease_ttl_secs <= 0:
|
|
80
|
+
raise ValueError("ramp_window, increase_every and lease_ttl_secs must be positive")
|
|
81
|
+
if not 0 < self.utilisation <= 1:
|
|
82
|
+
raise ValueError("utilisation must be in (0, 1]")
|
|
83
|
+
if self.poll_secs <= 0 or self.max_poll_secs < self.poll_secs:
|
|
84
|
+
raise ValueError("need 0 < poll_secs <= max_poll_secs")
|
|
85
|
+
if not 0 <= self.loss_tolerance <= 1 or not 0 <= self.pause_ratio <= 1:
|
|
86
|
+
raise ValueError("loss_tolerance and pause_ratio must be in [0, 1]")
|
|
87
|
+
if self.min_throttles < 1 or self.window_secs <= 0 or self.probe_secs < 0:
|
|
88
|
+
raise ValueError("need min_throttles >= 1, window_secs > 0 and probe_secs >= 0")
|
|
89
|
+
if not 0 < self.settle_ratio <= 1:
|
|
90
|
+
raise ValueError("settle_ratio must be in (0, 1]")
|
|
91
|
+
if self.hold_ratio < 0:
|
|
92
|
+
raise ValueError("hold_ratio must be >= 0")
|
|
93
|
+
|
|
94
|
+
def retry_delay(self, attempt: int, paused_secs: float, rng: random.Random) -> float:
|
|
95
|
+
"""Sleep before the next acquire try: the pause remaining, else jittered backoff.
|
|
96
|
+
|
|
97
|
+
Backoff doubles from `poll_secs` to `max_poll_secs`; jitter spreads waiting pods.
|
|
98
|
+
"""
|
|
99
|
+
if paused_secs > 0:
|
|
100
|
+
return paused_secs + rng.uniform(0, self.poll_secs)
|
|
101
|
+
base = min(self.max_poll_secs, self.poll_secs * 2 ** min(attempt, 16))
|
|
102
|
+
return rng.uniform(base / 2, base)
|
|
103
|
+
|
|
104
|
+
def start_for(self, model: str) -> int:
|
|
105
|
+
"""Start limit for a model alias before anything was learned."""
|
|
106
|
+
return (self.initial or {}).get(model, self.default_initial)
|
|
107
|
+
|
|
108
|
+
def share_for(self, lane: Lane) -> float:
|
|
109
|
+
"""Fraction of the limit a lane may use: rt the full limit, bg/bulk `bg_share`."""
|
|
110
|
+
return 1.0 if lane is Lane.RT else self.bg_share
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_config(cls, config: QueueConfig, **overrides: Any) -> LimiterSettings:
|
|
114
|
+
"""Settings whose start limits come from each model's `limiter_initial`."""
|
|
115
|
+
initial = {alias: m.limiter_initial for alias, m in config.models.items()}
|
|
116
|
+
return cls(initial=initial, **overrides)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True, slots=True)
|
|
120
|
+
class LimiterState:
|
|
121
|
+
"""Snapshot of one model target: learned limit, live leases and remaining pause."""
|
|
122
|
+
|
|
123
|
+
limit: int | None
|
|
124
|
+
inflight: int
|
|
125
|
+
paused_secs: float
|
|
126
|
+
|
|
127
|
+
def headroom(self, share: float = 1.0) -> int:
|
|
128
|
+
"""Free slots for a lane share; 0 while paused, unknown limits count as one slot."""
|
|
129
|
+
if self.paused_secs > 0:
|
|
130
|
+
return 0
|
|
131
|
+
if self.limit is None:
|
|
132
|
+
return 1
|
|
133
|
+
return max(0, max(1, int(self.limit * share)) - self.inflight)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class PauseGate:
|
|
137
|
+
"""`ReceiveGate` that is closed while its model target is paused after a throttle.
|
|
138
|
+
|
|
139
|
+
`is_open` reads a local deadline updated by every limiter reply; `wait_open` re-reads Valkey
|
|
140
|
+
so a pause set by another pod is seen. If Valkey is unreachable the gate stays closed (DS18).
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
refresh: Callable[[], Awaitable[None]],
|
|
146
|
+
*,
|
|
147
|
+
clock: Callable[[], float],
|
|
148
|
+
sleep: Callable[[float], Awaitable[None]],
|
|
149
|
+
retry_secs: float = 1.0,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Keep the refresh callback and the injected monotonic clock and sleep."""
|
|
152
|
+
self._refresh = refresh
|
|
153
|
+
self._clock = clock
|
|
154
|
+
self._sleep = sleep
|
|
155
|
+
self._retry_secs = retry_secs
|
|
156
|
+
self._closed_until = 0.0
|
|
157
|
+
|
|
158
|
+
def close_for(self, secs: float) -> None:
|
|
159
|
+
"""Close the gate for `secs` from now (0 opens it)."""
|
|
160
|
+
self._closed_until = self._clock() + max(0.0, secs)
|
|
161
|
+
|
|
162
|
+
def is_open(self) -> bool:
|
|
163
|
+
"""True when the broker may receive now."""
|
|
164
|
+
return self._clock() >= self._closed_until
|
|
165
|
+
|
|
166
|
+
async def wait_open(self) -> None:
|
|
167
|
+
"""Return once Valkey confirms the model target is not paused."""
|
|
168
|
+
while True:
|
|
169
|
+
try:
|
|
170
|
+
await self._refresh()
|
|
171
|
+
except RedisError:
|
|
172
|
+
logger.warning("limiter_gate_unreachable", retry_secs=self._retry_secs)
|
|
173
|
+
self.close_for(self._retry_secs)
|
|
174
|
+
if self.is_open():
|
|
175
|
+
return
|
|
176
|
+
await self._sleep(max(0.01, self._closed_until - self._clock()))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _acquire_args(settings: LimiterSettings, model: str, lane: Lane, token: str) -> list[Any]:
|
|
180
|
+
return [
|
|
181
|
+
settings.start_for(model),
|
|
182
|
+
settings.min_limit,
|
|
183
|
+
settings.max_limit,
|
|
184
|
+
settings.share_for(lane),
|
|
185
|
+
settings.lease_ttl_secs,
|
|
186
|
+
token,
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _release_args(
|
|
191
|
+
settings: LimiterSettings, lease: Lease, throttled: bool, share: float = 1.0
|
|
192
|
+
) -> list[Any]:
|
|
193
|
+
return [
|
|
194
|
+
settings.start_for(lease.model),
|
|
195
|
+
settings.min_limit,
|
|
196
|
+
settings.max_limit,
|
|
197
|
+
lease.token,
|
|
198
|
+
1 if throttled else 0,
|
|
199
|
+
settings.ramp_window,
|
|
200
|
+
settings.increase_every,
|
|
201
|
+
settings.decrease_factor,
|
|
202
|
+
settings.pause_secs,
|
|
203
|
+
min(settings.ramp_cap or settings.max_limit, settings.max_limit),
|
|
204
|
+
settings.utilisation,
|
|
205
|
+
settings.peak_halflife_secs,
|
|
206
|
+
share,
|
|
207
|
+
settings.loss_tolerance,
|
|
208
|
+
settings.min_throttles,
|
|
209
|
+
settings.pause_ratio,
|
|
210
|
+
settings.window_secs,
|
|
211
|
+
settings.settle_ratio,
|
|
212
|
+
settings.probe_secs,
|
|
213
|
+
settings.lease_ttl_secs,
|
|
214
|
+
settings.hold_ratio,
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def local_cap(last_known_limit: int | None, live_pods: int, fallback_cap: int) -> int:
|
|
219
|
+
"""Per-pod cap while Valkey is down: `last_known_limit / live_pods`, else the fallback.
|
|
220
|
+
|
|
221
|
+
Keeps the sum over all pods under the shared limit (DS18); never below 1.
|
|
222
|
+
"""
|
|
223
|
+
if last_known_limit is None or live_pods < 1:
|
|
224
|
+
return max(1, fallback_cap)
|
|
225
|
+
return max(1, last_known_limit // live_pods)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class AdaptiveLimiter:
|
|
229
|
+
"""asyncio `Limiter`: per (model, target) AIMD limit with ramp, pause and lane share.
|
|
230
|
+
|
|
231
|
+
One instance per pod, built on the package's own `redis.asyncio` client (injected). Leases
|
|
232
|
+
expire on their own (DS11), so a crashed pod's slots come back without a sweep.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
def __init__(
|
|
236
|
+
self,
|
|
237
|
+
client: redis.asyncio.Redis,
|
|
238
|
+
keys: KeySpace,
|
|
239
|
+
settings: LimiterSettings | None = None,
|
|
240
|
+
*,
|
|
241
|
+
clock: Callable[[], float] = time.monotonic,
|
|
242
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
243
|
+
) -> None:
|
|
244
|
+
"""Register the Lua scripts on `client`; no I/O happens until the first call."""
|
|
245
|
+
self._client = client
|
|
246
|
+
self._keys = keys
|
|
247
|
+
self.settings = settings or LimiterSettings()
|
|
248
|
+
self._clock = clock
|
|
249
|
+
self._sleep = sleep
|
|
250
|
+
self._acquire = client.register_script(_lua.LIMITER_ACQUIRE)
|
|
251
|
+
self._release = client.register_script(_lua.LIMITER_RELEASE)
|
|
252
|
+
self._peek = client.register_script(_lua.LIMITER_PEEK)
|
|
253
|
+
self._renew = client.register_script(_lua.LEASE_RENEW)
|
|
254
|
+
self._gates: dict[tuple[str, str], PauseGate] = {}
|
|
255
|
+
self._shares: dict[str, float] = {} # lease token -> lane share, for DS12 at release
|
|
256
|
+
self._orphans: set[tuple[str, str, str]] = set() # (model, target, token) to drop
|
|
257
|
+
self._rng = random.Random()
|
|
258
|
+
self.last_limits: dict[tuple[str, str], int] = {}
|
|
259
|
+
|
|
260
|
+
def _key_pair(self, model: str, target: str) -> list[str]:
|
|
261
|
+
return [self._keys.limiter(model, target), self._keys.leases(model, target)]
|
|
262
|
+
|
|
263
|
+
def _note(self, model: str, target: str, limit: int | None, paused_secs: float) -> None:
|
|
264
|
+
if limit is not None:
|
|
265
|
+
self.last_limits[(model, target)] = limit
|
|
266
|
+
self.gate(model, target).close_for(paused_secs)
|
|
267
|
+
|
|
268
|
+
async def _attempt(self, model: str, target: str, lane: Lane) -> tuple[Lease | None, float]:
|
|
269
|
+
if self._orphans:
|
|
270
|
+
await self._drop_orphans()
|
|
271
|
+
token = uuid.uuid4().hex
|
|
272
|
+
args = _acquire_args(self.settings, model, lane, token)
|
|
273
|
+
try:
|
|
274
|
+
granted, _inflight, limit, extra = await self._acquire(
|
|
275
|
+
self._key_pair(model, target), args
|
|
276
|
+
)
|
|
277
|
+
except BaseException:
|
|
278
|
+
# The script may have run and only the reply been lost (timeout, cancel): a lease
|
|
279
|
+
# nobody holds would block a slot for `lease_ttl_secs`, so drop the token later.
|
|
280
|
+
if len(self._orphans) < _MAX_ORPHANS:
|
|
281
|
+
self._orphans.add((model, target, token))
|
|
282
|
+
raise
|
|
283
|
+
if int(granted):
|
|
284
|
+
self._note(model, target, int(limit), 0.0)
|
|
285
|
+
self._shares[token] = self.settings.share_for(lane)
|
|
286
|
+
return Lease(model, target, token, float(_lua.as_text(extra))), 0.0
|
|
287
|
+
paused = float(_lua.as_text(extra))
|
|
288
|
+
self._note(model, target, int(limit), paused)
|
|
289
|
+
return None, paused
|
|
290
|
+
|
|
291
|
+
async def try_acquire(self, model: str, target: str, lane: Lane) -> Lease | None:
|
|
292
|
+
"""One non-blocking attempt to take a slot."""
|
|
293
|
+
return (await self._attempt(model, target, lane))[0]
|
|
294
|
+
|
|
295
|
+
async def acquire(
|
|
296
|
+
self,
|
|
297
|
+
model: str,
|
|
298
|
+
target: str,
|
|
299
|
+
lane: Lane,
|
|
300
|
+
timeout_secs: float,
|
|
301
|
+
*,
|
|
302
|
+
should_abort: Callable[[], Awaitable[bool]] | None = None,
|
|
303
|
+
) -> Lease | None:
|
|
304
|
+
"""Take a slot, waiting until one frees up or `timeout_secs` passes (then None).
|
|
305
|
+
|
|
306
|
+
Sleeps out a pause, else backs off with jitter; `should_abort` (e.g. the cancel flag)
|
|
307
|
+
is checked before every sleep and returning True gives up with None.
|
|
308
|
+
"""
|
|
309
|
+
deadline = self._clock() + max(0.0, timeout_secs)
|
|
310
|
+
attempt = 0
|
|
311
|
+
while True:
|
|
312
|
+
lease, paused = await self._attempt(model, target, lane)
|
|
313
|
+
if lease is not None:
|
|
314
|
+
return lease
|
|
315
|
+
remaining = deadline - self._clock()
|
|
316
|
+
if remaining <= 0 or (should_abort is not None and await should_abort()):
|
|
317
|
+
return None
|
|
318
|
+
delay = self.settings.retry_delay(attempt, paused, self._rng)
|
|
319
|
+
await self._sleep(min(delay, remaining))
|
|
320
|
+
attempt += 1
|
|
321
|
+
|
|
322
|
+
async def release(self, lease: Lease, *, throttled: bool) -> None:
|
|
323
|
+
"""Return the slot and grow or shrink the limit, in one Lua call.
|
|
324
|
+
|
|
325
|
+
If Valkey fails the error is raised and the lease is remembered; the next acquire or
|
|
326
|
+
release on this pod drops it, so it does not hold a slot for `lease_ttl_secs`.
|
|
327
|
+
"""
|
|
328
|
+
share = self._shares.pop(lease.token, 1.0)
|
|
329
|
+
try:
|
|
330
|
+
limit, _removed, paused = await self._release(
|
|
331
|
+
self._key_pair(lease.model, lease.target),
|
|
332
|
+
_release_args(self.settings, lease, throttled, share),
|
|
333
|
+
)
|
|
334
|
+
except BaseException:
|
|
335
|
+
if len(self._orphans) < _MAX_ORPHANS:
|
|
336
|
+
self._orphans.add((lease.model, lease.target, lease.token))
|
|
337
|
+
raise
|
|
338
|
+
self._note(lease.model, lease.target, int(limit), float(_lua.as_text(paused)))
|
|
339
|
+
if self._orphans:
|
|
340
|
+
await self._drop_orphans()
|
|
341
|
+
|
|
342
|
+
async def _drop_orphans(self) -> None:
|
|
343
|
+
"""Remove leases whose release (or acquire reply) failed; kept for a later try on error.
|
|
344
|
+
|
|
345
|
+
Bug 4 (S35 pool exhaustion): a failed release left its lease live for `lease_ttl_secs`.
|
|
346
|
+
"""
|
|
347
|
+
orphans = list(self._orphans)
|
|
348
|
+
try:
|
|
349
|
+
async with self._client.pipeline(transaction=False) as pipe:
|
|
350
|
+
for model, target, token in orphans:
|
|
351
|
+
pipe.zrem(self._keys.leases(model, target), token)
|
|
352
|
+
await pipe.execute()
|
|
353
|
+
except RedisError as exc:
|
|
354
|
+
logger.warning(
|
|
355
|
+
"limiter_orphans_kept", leases=len(orphans), error_type=type(exc).__name__
|
|
356
|
+
)
|
|
357
|
+
return
|
|
358
|
+
self._orphans.difference_update(orphans)
|
|
359
|
+
|
|
360
|
+
async def renew(self, leases: Iterable[Lease]) -> list[Lease]:
|
|
361
|
+
"""Extend still-held leases by `lease_ttl_secs` (called by the heartbeat, DS11)."""
|
|
362
|
+
by_target: dict[tuple[str, str], list[Lease]] = {}
|
|
363
|
+
for lease in leases:
|
|
364
|
+
by_target.setdefault((lease.model, lease.target), []).append(lease)
|
|
365
|
+
groups = list(by_target.items())
|
|
366
|
+
replies = await _lua.run_many( # every target in one round trip
|
|
367
|
+
self._client,
|
|
368
|
+
[
|
|
369
|
+
(
|
|
370
|
+
self._renew,
|
|
371
|
+
[self._keys.leases(model, target)],
|
|
372
|
+
[self.settings.lease_ttl_secs, *(lease.token for lease in group)],
|
|
373
|
+
)
|
|
374
|
+
for (model, target), group in groups
|
|
375
|
+
],
|
|
376
|
+
)
|
|
377
|
+
renewed: list[Lease] = []
|
|
378
|
+
for ((model, target), group), (expires, _count) in zip(groups, replies, strict=True):
|
|
379
|
+
renewed += [
|
|
380
|
+
Lease(model, target, ls.token, float(_lua.as_text(expires))) for ls in group
|
|
381
|
+
]
|
|
382
|
+
return renewed
|
|
383
|
+
|
|
384
|
+
async def state(self, model: str, target: str) -> LimiterState:
|
|
385
|
+
"""Read the limit, live leases and pause without changing anything."""
|
|
386
|
+
reply = await self._peek(self._key_pair(model, target), [])
|
|
387
|
+
return self._peeked(model, target, reply)
|
|
388
|
+
|
|
389
|
+
async def states(self, model: str, targets: Iterable[str]) -> list[LimiterState]:
|
|
390
|
+
"""`state` of several targets of one model in one pipelined round trip."""
|
|
391
|
+
names = list(targets)
|
|
392
|
+
replies = await _lua.run_many(
|
|
393
|
+
self._client, [(self._peek, self._key_pair(model, t), []) for t in names]
|
|
394
|
+
)
|
|
395
|
+
return [self._peeked(model, t, r) for t, r in zip(names, replies, strict=True)]
|
|
396
|
+
|
|
397
|
+
def _peeked(self, model: str, target: str, reply: Any) -> LimiterState:
|
|
398
|
+
limit, inflight, paused = reply
|
|
399
|
+
state = LimiterState(
|
|
400
|
+
None if int(limit) < 0 else int(limit), int(inflight), float(_lua.as_text(paused))
|
|
401
|
+
)
|
|
402
|
+
self._note(model, target, state.limit, state.paused_secs)
|
|
403
|
+
return state
|
|
404
|
+
|
|
405
|
+
def gate(self, model: str, target: str) -> PauseGate:
|
|
406
|
+
"""Gate that closes while this model target is paused after a throttle."""
|
|
407
|
+
gate = self._gates.get((model, target))
|
|
408
|
+
if gate is None:
|
|
409
|
+
|
|
410
|
+
async def refresh() -> None:
|
|
411
|
+
await self.state(model, target) # updates this gate via _note
|
|
412
|
+
|
|
413
|
+
gate = PauseGate(refresh, clock=self._clock, sleep=self._sleep)
|
|
414
|
+
self._gates[(model, target)] = gate
|
|
415
|
+
return gate
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class SyncAdaptiveLimiter:
|
|
419
|
+
"""Blocking twin of `AdaptiveLimiter` for sync callers (same Lua, `redis.Redis` client).
|
|
420
|
+
|
|
421
|
+
Used by sync `direct` calls so they share the cluster-wide limit with queued work (DS18).
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
def __init__(
|
|
425
|
+
self,
|
|
426
|
+
client: redis.Redis,
|
|
427
|
+
keys: KeySpace,
|
|
428
|
+
settings: LimiterSettings | None = None,
|
|
429
|
+
*,
|
|
430
|
+
clock: Callable[[], float] = time.monotonic,
|
|
431
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
432
|
+
) -> None:
|
|
433
|
+
"""Register the Lua scripts on `client`; no I/O happens until the first call."""
|
|
434
|
+
self._keys = keys
|
|
435
|
+
self.settings = settings or LimiterSettings()
|
|
436
|
+
self._clock = clock
|
|
437
|
+
self._sleep = sleep
|
|
438
|
+
self._acquire = client.register_script(_lua.LIMITER_ACQUIRE)
|
|
439
|
+
self._release = client.register_script(_lua.LIMITER_RELEASE)
|
|
440
|
+
self._shares: dict[str, float] = {}
|
|
441
|
+
self._rng = random.Random()
|
|
442
|
+
|
|
443
|
+
def acquire(
|
|
444
|
+
self,
|
|
445
|
+
model: str,
|
|
446
|
+
target: str,
|
|
447
|
+
lane: Lane,
|
|
448
|
+
timeout_secs: float,
|
|
449
|
+
*,
|
|
450
|
+
should_abort: Callable[[], bool] | None = None,
|
|
451
|
+
) -> Lease | None:
|
|
452
|
+
"""Take a slot, waiting until one frees up or `timeout_secs` passes (then None).
|
|
453
|
+
|
|
454
|
+
Same pause-aware jittered wait as `AdaptiveLimiter.acquire`, with a sync `should_abort`.
|
|
455
|
+
"""
|
|
456
|
+
keys = [self._keys.limiter(model, target), self._keys.leases(model, target)]
|
|
457
|
+
deadline = self._clock() + max(0.0, timeout_secs)
|
|
458
|
+
attempt = 0
|
|
459
|
+
while True:
|
|
460
|
+
token = uuid.uuid4().hex
|
|
461
|
+
granted, _, _, extra = self._acquire(
|
|
462
|
+
keys, _acquire_args(self.settings, model, lane, token)
|
|
463
|
+
)
|
|
464
|
+
if int(granted):
|
|
465
|
+
self._shares[token] = self.settings.share_for(lane)
|
|
466
|
+
return Lease(model, target, token, float(_lua.as_text(extra)))
|
|
467
|
+
remaining = deadline - self._clock()
|
|
468
|
+
if remaining <= 0 or (should_abort is not None and should_abort()):
|
|
469
|
+
return None
|
|
470
|
+
delay = self.settings.retry_delay(attempt, float(_lua.as_text(extra)), self._rng)
|
|
471
|
+
self._sleep(min(delay, remaining))
|
|
472
|
+
attempt += 1
|
|
473
|
+
|
|
474
|
+
def release(self, lease: Lease, *, throttled: bool) -> None:
|
|
475
|
+
"""Return the slot and grow or shrink the limit, in one Lua call."""
|
|
476
|
+
self._release(
|
|
477
|
+
[
|
|
478
|
+
self._keys.limiter(lease.model, lease.target),
|
|
479
|
+
self._keys.leases(lease.model, lease.target),
|
|
480
|
+
],
|
|
481
|
+
_release_args(self.settings, lease, throttled, self._shares.pop(lease.token, 1.0)),
|
|
482
|
+
)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""In-process per-pod limiter used while Valkey is down (DS18).
|
|
2
|
+
|
|
3
|
+
Each pod caps its own concurrency at `local_cap(...)` so all pods together stay under the last
|
|
4
|
+
shared limit. Thread-safe, so the same instance serves async workers and sync `direct` callers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
|
|
15
|
+
from taskiq_llm_queue.config import Lane
|
|
16
|
+
from taskiq_llm_queue.protocols import Lease
|
|
17
|
+
from taskiq_llm_queue.reliability.limiter import PauseGate
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LocalLimiter:
|
|
21
|
+
"""`Limiter` with a fixed per-pod cap per (model, target), lane share and throttle pause.
|
|
22
|
+
|
|
23
|
+
No learning: the cap is set by the caller from `local_cap`. A throttle storm (at least
|
|
24
|
+
`min_throttles` throttles making up `pause_ratio` of a `window_secs` window) pauses the model
|
|
25
|
+
target for `pause_secs`, closing its gate; a lone throttle does not (S35).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
cap: int,
|
|
31
|
+
*,
|
|
32
|
+
bg_share: float = 0.8,
|
|
33
|
+
pause_secs: float = 2.0,
|
|
34
|
+
lease_ttl_secs: float = 60.0,
|
|
35
|
+
poll_secs: float = 0.05,
|
|
36
|
+
min_throttles: int = 2,
|
|
37
|
+
pause_ratio: float = 0.5,
|
|
38
|
+
window_secs: float = 1.0,
|
|
39
|
+
clock: Callable[[], float] = time.monotonic,
|
|
40
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
41
|
+
sync_sleep: Callable[[float], None] = time.sleep,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Keep the cap and the injected clock and sleeps; nothing starts here."""
|
|
44
|
+
if cap < 1:
|
|
45
|
+
raise ValueError("cap must be >= 1")
|
|
46
|
+
self.cap = cap
|
|
47
|
+
self._bg_share = bg_share
|
|
48
|
+
self._pause_secs = pause_secs
|
|
49
|
+
self._lease_ttl = lease_ttl_secs
|
|
50
|
+
self._poll = poll_secs
|
|
51
|
+
self._min_throttles = min_throttles
|
|
52
|
+
self._pause_ratio = pause_ratio
|
|
53
|
+
self._window = window_secs
|
|
54
|
+
self._clock = clock
|
|
55
|
+
self._sleep = sleep
|
|
56
|
+
self._sync_sleep = sync_sleep
|
|
57
|
+
self._lock = threading.Lock()
|
|
58
|
+
self._held: dict[tuple[str, str], set[str]] = {}
|
|
59
|
+
self._gates: dict[tuple[str, str], PauseGate] = {}
|
|
60
|
+
self._outcomes: dict[tuple[str, str], list[float]] = {} # [window start, ok, throttled]
|
|
61
|
+
|
|
62
|
+
def inflight(self, model: str, target: str) -> int:
|
|
63
|
+
"""Slots currently held for a model target in this pod."""
|
|
64
|
+
with self._lock:
|
|
65
|
+
return len(self._held.get((model, target), ()))
|
|
66
|
+
|
|
67
|
+
def try_acquire(self, model: str, target: str, lane: Lane) -> Lease | None:
|
|
68
|
+
"""One non-blocking attempt to take a slot (safe from any thread)."""
|
|
69
|
+
if not self.gate(model, target).is_open():
|
|
70
|
+
return None
|
|
71
|
+
share = 1.0 if lane is Lane.RT else self._bg_share
|
|
72
|
+
allowed = max(1, int(self.cap * share))
|
|
73
|
+
with self._lock:
|
|
74
|
+
held = self._held.setdefault((model, target), set())
|
|
75
|
+
if len(held) >= allowed:
|
|
76
|
+
return None
|
|
77
|
+
token = uuid.uuid4().hex
|
|
78
|
+
held.add(token)
|
|
79
|
+
return Lease(model, target, token, self._clock() + self._lease_ttl)
|
|
80
|
+
|
|
81
|
+
async def acquire(
|
|
82
|
+
self,
|
|
83
|
+
model: str,
|
|
84
|
+
target: str,
|
|
85
|
+
lane: Lane,
|
|
86
|
+
timeout_secs: float,
|
|
87
|
+
*,
|
|
88
|
+
should_abort: Callable[[], Awaitable[bool]] | None = None,
|
|
89
|
+
) -> Lease | None:
|
|
90
|
+
"""Take a slot, polling until one frees up or `timeout_secs` passes (then None).
|
|
91
|
+
|
|
92
|
+
`should_abort` is checked before every sleep; True gives up with None.
|
|
93
|
+
"""
|
|
94
|
+
deadline = self._clock() + max(0.0, timeout_secs)
|
|
95
|
+
while (lease := self.try_acquire(model, target, lane)) is None:
|
|
96
|
+
remaining = deadline - self._clock()
|
|
97
|
+
if remaining <= 0 or (should_abort is not None and await should_abort()):
|
|
98
|
+
return None
|
|
99
|
+
await self._sleep(min(self._poll, remaining))
|
|
100
|
+
return lease
|
|
101
|
+
|
|
102
|
+
def acquire_sync(
|
|
103
|
+
self,
|
|
104
|
+
model: str,
|
|
105
|
+
target: str,
|
|
106
|
+
lane: Lane,
|
|
107
|
+
timeout_secs: float,
|
|
108
|
+
*,
|
|
109
|
+
should_abort: Callable[[], bool] | None = None,
|
|
110
|
+
) -> Lease | None:
|
|
111
|
+
"""Blocking `acquire` for sync callers."""
|
|
112
|
+
deadline = self._clock() + max(0.0, timeout_secs)
|
|
113
|
+
while (lease := self.try_acquire(model, target, lane)) is None:
|
|
114
|
+
remaining = deadline - self._clock()
|
|
115
|
+
if remaining <= 0 or (should_abort is not None and should_abort()):
|
|
116
|
+
return None
|
|
117
|
+
self._sync_sleep(min(self._poll, remaining))
|
|
118
|
+
return lease
|
|
119
|
+
|
|
120
|
+
def release_sync(self, lease: Lease, *, throttled: bool) -> None:
|
|
121
|
+
"""Return the slot; a throttle storm pauses the model target for `pause_secs`."""
|
|
122
|
+
key = (lease.model, lease.target)
|
|
123
|
+
now = self._clock()
|
|
124
|
+
with self._lock:
|
|
125
|
+
self._held.get(key, set()).discard(lease.token)
|
|
126
|
+
counts = self._outcomes.setdefault(key, [now, 0.0, 0.0])
|
|
127
|
+
if now - counts[0] >= self._window:
|
|
128
|
+
counts[:] = [now, 0.0, 0.0]
|
|
129
|
+
counts[2 if throttled else 1] += 1
|
|
130
|
+
storm = (
|
|
131
|
+
throttled
|
|
132
|
+
and counts[2] >= self._min_throttles
|
|
133
|
+
and counts[2] >= self._pause_ratio * (counts[1] + counts[2])
|
|
134
|
+
)
|
|
135
|
+
if storm:
|
|
136
|
+
self.gate(*key).close_for(self._pause_secs)
|
|
137
|
+
|
|
138
|
+
async def release(self, lease: Lease, *, throttled: bool) -> None:
|
|
139
|
+
"""Async form of `release_sync` (the `Limiter` protocol)."""
|
|
140
|
+
self.release_sync(lease, throttled=throttled)
|
|
141
|
+
|
|
142
|
+
def gate(self, model: str, target: str) -> PauseGate:
|
|
143
|
+
"""Gate closed while this model target is paused after a throttle."""
|
|
144
|
+
with self._lock:
|
|
145
|
+
gate = self._gates.get((model, target))
|
|
146
|
+
if gate is None:
|
|
147
|
+
|
|
148
|
+
async def refresh() -> None:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
gate = PauseGate(refresh, clock=self._clock, sleep=self._sleep)
|
|
152
|
+
self._gates[(model, target)] = gate
|
|
153
|
+
return gate
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Results: the JSON result backend and the per-pod batched result dispatcher.
|
|
2
|
+
|
|
3
|
+
Workers write results through `LLMQResultBackend` and wake the sender's pod with
|
|
4
|
+
`publish_done`; senders wait on `ResultDispatcher.wait`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from taskiq_llm_queue.results.backend import DEFAULT_TAKE_BATCH, LLMQResultBackend, decode_reply
|
|
10
|
+
from taskiq_llm_queue.results.dispatcher import (
|
|
11
|
+
DEFAULT_INTERVALS_MS,
|
|
12
|
+
ResultDispatcher,
|
|
13
|
+
publish_done,
|
|
14
|
+
)
|
|
15
|
+
from taskiq_llm_queue.results.s3_backend import S3ResultBackend
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"DEFAULT_INTERVALS_MS",
|
|
19
|
+
"DEFAULT_TAKE_BATCH",
|
|
20
|
+
"LLMQResultBackend",
|
|
21
|
+
"ResultDispatcher",
|
|
22
|
+
"S3ResultBackend",
|
|
23
|
+
"decode_reply",
|
|
24
|
+
"publish_done",
|
|
25
|
+
]
|