taskiq-llm-queue 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. taskiq_llm_queue/__init__.py +138 -0
  2. taskiq_llm_queue/_logging.py +40 -0
  3. taskiq_llm_queue/_version.py +5 -0
  4. taskiq_llm_queue/broker/__init__.py +86 -0
  5. taskiq_llm_queue/broker/acker.py +239 -0
  6. taskiq_llm_queue/broker/batching.py +188 -0
  7. taskiq_llm_queue/broker/broker.py +937 -0
  8. taskiq_llm_queue/broker/delivery.py +50 -0
  9. taskiq_llm_queue/broker/errors.py +178 -0
  10. taskiq_llm_queue/broker/events.py +51 -0
  11. taskiq_llm_queue/broker/filters.py +32 -0
  12. taskiq_llm_queue/broker/heartbeat.py +157 -0
  13. taskiq_llm_queue/broker/queue.py +141 -0
  14. taskiq_llm_queue/broker/s3_offload.py +346 -0
  15. taskiq_llm_queue/broker/scheduler.py +113 -0
  16. taskiq_llm_queue/broker/serializer.py +32 -0
  17. taskiq_llm_queue/cli.py +488 -0
  18. taskiq_llm_queue/client.py +1409 -0
  19. taskiq_llm_queue/config.py +592 -0
  20. taskiq_llm_queue/context.py +62 -0
  21. taskiq_llm_queue/core.py +832 -0
  22. taskiq_llm_queue/envelope.py +136 -0
  23. taskiq_llm_queue/errors.py +71 -0
  24. taskiq_llm_queue/failover.py +286 -0
  25. taskiq_llm_queue/http_transport.py +220 -0
  26. taskiq_llm_queue/keys.py +87 -0
  27. taskiq_llm_queue/langchain/__init__.py +64 -0
  28. taskiq_llm_queue/langchain/_client.py +142 -0
  29. taskiq_llm_queue/langchain/bedrock.py +291 -0
  30. taskiq_llm_queue/langchain/openai.py +103 -0
  31. taskiq_llm_queue/lifecycle.py +512 -0
  32. taskiq_llm_queue/metrics/__init__.py +45 -0
  33. taskiq_llm_queue/metrics/emf.py +100 -0
  34. taskiq_llm_queue/metrics/prometheus.py +66 -0
  35. taskiq_llm_queue/metrics_names.py +19 -0
  36. taskiq_llm_queue/protocols.py +140 -0
  37. taskiq_llm_queue/providers/__init__.py +62 -0
  38. taskiq_llm_queue/providers/bedrock.py +188 -0
  39. taskiq_llm_queue/providers/classify.py +251 -0
  40. taskiq_llm_queue/providers/embeddings.py +87 -0
  41. taskiq_llm_queue/providers/hosts.py +34 -0
  42. taskiq_llm_queue/providers/openai_compat.py +142 -0
  43. taskiq_llm_queue/providers/registry.py +173 -0
  44. taskiq_llm_queue/py.typed +0 -0
  45. taskiq_llm_queue/reliability/__init__.py +59 -0
  46. taskiq_llm_queue/reliability/_lua.py +461 -0
  47. taskiq_llm_queue/reliability/breaker.py +86 -0
  48. taskiq_llm_queue/reliability/claims.py +226 -0
  49. taskiq_llm_queue/reliability/config_store.py +148 -0
  50. taskiq_llm_queue/reliability/fallback.py +111 -0
  51. taskiq_llm_queue/reliability/limiter.py +482 -0
  52. taskiq_llm_queue/reliability/local_limiter.py +153 -0
  53. taskiq_llm_queue/results/__init__.py +25 -0
  54. taskiq_llm_queue/results/backend.py +226 -0
  55. taskiq_llm_queue/results/dispatcher.py +366 -0
  56. taskiq_llm_queue/results/s3_backend.py +105 -0
  57. taskiq_llm_queue/routing.py +173 -0
  58. taskiq_llm_queue/routing_hooks.py +90 -0
  59. taskiq_llm_queue/tasks.py +453 -0
  60. taskiq_llm_queue-0.1.0a1.dist-info/METADATA +296 -0
  61. taskiq_llm_queue-0.1.0a1.dist-info/RECORD +65 -0
  62. taskiq_llm_queue-0.1.0a1.dist-info/WHEEL +4 -0
  63. taskiq_llm_queue-0.1.0a1.dist-info/entry_points.txt +2 -0
  64. taskiq_llm_queue-0.1.0a1.dist-info/licenses/LICENSE +21 -0
  65. taskiq_llm_queue-0.1.0a1.dist-info/licenses/NOTICE +15 -0
@@ -0,0 +1,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
+ ]