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
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Reliability layer: adaptive limiter, leases, circuit breaker, claims, fallback, live config.
|
|
2
|
+
|
|
3
|
+
All shared state lives in Valkey and changes through Lua scripts that read Valkey `TIME`; the
|
|
4
|
+
local limiter covers Valkey outages. Clients are injected, so importing this does no I/O.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from taskiq_llm_queue.reliability.breaker import BreakerSettings, BreakerState, CircuitBreaker
|
|
10
|
+
from taskiq_llm_queue.reliability.claims import (
|
|
11
|
+
ClaimOutcome,
|
|
12
|
+
ClaimSettings,
|
|
13
|
+
FailureCount,
|
|
14
|
+
RedisClaimStore,
|
|
15
|
+
)
|
|
16
|
+
from taskiq_llm_queue.reliability.config_store import (
|
|
17
|
+
AUDIT_RETENTION_SECS,
|
|
18
|
+
LiveConfigStore,
|
|
19
|
+
fallback_store,
|
|
20
|
+
)
|
|
21
|
+
from taskiq_llm_queue.reliability.fallback import (
|
|
22
|
+
ChainStep,
|
|
23
|
+
FallbackConfig,
|
|
24
|
+
FallbackStep,
|
|
25
|
+
resolve_fallback,
|
|
26
|
+
)
|
|
27
|
+
from taskiq_llm_queue.reliability.limiter import (
|
|
28
|
+
AdaptiveLimiter,
|
|
29
|
+
LimiterSettings,
|
|
30
|
+
LimiterState,
|
|
31
|
+
PauseGate,
|
|
32
|
+
SyncAdaptiveLimiter,
|
|
33
|
+
local_cap,
|
|
34
|
+
)
|
|
35
|
+
from taskiq_llm_queue.reliability.local_limiter import LocalLimiter
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"AUDIT_RETENTION_SECS",
|
|
39
|
+
"AdaptiveLimiter",
|
|
40
|
+
"BreakerSettings",
|
|
41
|
+
"BreakerState",
|
|
42
|
+
"ChainStep",
|
|
43
|
+
"CircuitBreaker",
|
|
44
|
+
"ClaimOutcome",
|
|
45
|
+
"ClaimSettings",
|
|
46
|
+
"FailureCount",
|
|
47
|
+
"FallbackConfig",
|
|
48
|
+
"FallbackStep",
|
|
49
|
+
"LimiterSettings",
|
|
50
|
+
"LimiterState",
|
|
51
|
+
"LiveConfigStore",
|
|
52
|
+
"LocalLimiter",
|
|
53
|
+
"PauseGate",
|
|
54
|
+
"RedisClaimStore",
|
|
55
|
+
"SyncAdaptiveLimiter",
|
|
56
|
+
"fallback_store",
|
|
57
|
+
"local_cap",
|
|
58
|
+
"resolve_fallback",
|
|
59
|
+
]
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""Lua scripts for the limiter, leases, circuit breaker and claim store.
|
|
2
|
+
|
|
3
|
+
Every shared decision runs inside one script and reads the clock with Valkey `TIME`, so pods
|
|
4
|
+
with skewed clocks still agree. Floats go back to Python as strings: Lua numbers in a Redis
|
|
5
|
+
reply are truncated to integers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from redis.exceptions import NoScriptError
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from redis.commands.core import AsyncScript
|
|
17
|
+
|
|
18
|
+
ScriptCall = tuple["AsyncScript", Sequence[Any], Sequence[Any]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def as_text(value: Any) -> str:
|
|
22
|
+
"""Decode a Redis reply item; the package's own pool runs without `decode_responses`."""
|
|
23
|
+
return value.decode() if isinstance(value, bytes) else str(value)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def queue_script(pipe: Any, script: AsyncScript, keys: Sequence[Any], args: Sequence[Any]) -> None:
|
|
27
|
+
"""Queue a registered script on a pipeline as a raw `EVALSHA`.
|
|
28
|
+
|
|
29
|
+
`script(..., client=pipe)` would register it on the pipeline, and redis-py then spends an
|
|
30
|
+
extra `SCRIPT EXISTS` round trip on every `execute`; `settle` handles a `NOSCRIPT` reply.
|
|
31
|
+
"""
|
|
32
|
+
pipe.evalsha(script.sha, len(keys), *keys, *args)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def settle(reply: Any, script: AsyncScript, keys: Sequence[Any], args: Sequence[Any]) -> Any:
|
|
36
|
+
"""Reply of a queued script: re-run it alone on `NOSCRIPT` (it did not run), raise errors."""
|
|
37
|
+
if isinstance(reply, NoScriptError):
|
|
38
|
+
return await script(list(keys), list(args))
|
|
39
|
+
if isinstance(reply, Exception):
|
|
40
|
+
raise reply
|
|
41
|
+
return reply
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def run_many(client: Any, calls: Sequence[ScriptCall]) -> list[Any]:
|
|
45
|
+
"""Run several scripts in one non-transactional pipeline: one round trip, not one each.
|
|
46
|
+
|
|
47
|
+
Works on cluster clients too (the pipeline groups commands by node); replies keep order.
|
|
48
|
+
"""
|
|
49
|
+
if not calls:
|
|
50
|
+
return []
|
|
51
|
+
async with client.pipeline(transaction=False) as pipe:
|
|
52
|
+
for script, keys, args in calls:
|
|
53
|
+
queue_script(pipe, script, keys, args)
|
|
54
|
+
replies = await pipe.execute(raise_on_error=False)
|
|
55
|
+
return [
|
|
56
|
+
await settle(reply, script, keys, args)
|
|
57
|
+
for reply, (script, keys, args) in zip(replies, calls, strict=True)
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_NOW = """
|
|
62
|
+
local _t = redis.call('TIME')
|
|
63
|
+
local now = tonumber(_t[1]) + tonumber(_t[2]) / 1000000
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
# Shared by acquire and release: create the state on first use (stored value wins) and clamp.
|
|
67
|
+
# Writes only when the state is new or the stored limit is outside [min, max], so a refused
|
|
68
|
+
# acquire stays read-only (no hot-key writes while callers wait).
|
|
69
|
+
_LIMIT = """
|
|
70
|
+
local min_l = tonumber(ARGV[2])
|
|
71
|
+
local max_l = tonumber(ARGV[3])
|
|
72
|
+
local stored = tonumber(redis.call('HGET', KEYS[1], 'limit') or '')
|
|
73
|
+
local limit = stored
|
|
74
|
+
if not limit then
|
|
75
|
+
limit = tonumber(ARGV[1])
|
|
76
|
+
redis.call('HSET', KEYS[1], 'ramp', 1, 'ok_count', 0, 'paused_until', 0, 'last_decrease', 0)
|
|
77
|
+
end
|
|
78
|
+
limit = math.max(min_l, math.min(max_l, limit))
|
|
79
|
+
if limit ~= stored then redis.call('HSET', KEYS[1], 'limit', limit) end
|
|
80
|
+
local paused = tonumber(redis.call('HGET', KEYS[1], 'paused_until') or '0') or 0
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
# Live leases (score > now) without pruning, so the count needs no write.
|
|
84
|
+
_LIVE = """
|
|
85
|
+
local function live_leases()
|
|
86
|
+
return redis.call('ZCOUNT', KEYS[2], '(' .. tostring(now), '+inf')
|
|
87
|
+
end
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
# KEYS: state hash, leases zset. ARGV: initial, min, max, share, lease_ttl, token.
|
|
91
|
+
# Reply: {granted 0|1, inflight, limit, expires_at (granted) or pause remaining (refused)}.
|
|
92
|
+
# A refusal writes nothing; expired leases are pruned only when a slot is granted (DS11).
|
|
93
|
+
LIMITER_ACQUIRE = (
|
|
94
|
+
_NOW
|
|
95
|
+
+ _LIVE
|
|
96
|
+
+ _LIMIT
|
|
97
|
+
+ """
|
|
98
|
+
local inflight = live_leases()
|
|
99
|
+
if paused > now then
|
|
100
|
+
return {0, inflight, limit, tostring(paused - now)}
|
|
101
|
+
end
|
|
102
|
+
local allowed = math.max(1, math.floor(limit * tonumber(ARGV[4])))
|
|
103
|
+
if inflight >= allowed then
|
|
104
|
+
return {0, inflight, limit, '0'}
|
|
105
|
+
end
|
|
106
|
+
if redis.call('ZCARD', KEYS[2]) > inflight then
|
|
107
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now)
|
|
108
|
+
end
|
|
109
|
+
local expires = now + tonumber(ARGV[5])
|
|
110
|
+
redis.call('ZADD', KEYS[2], expires, ARGV[6])
|
|
111
|
+
return {1, inflight + 1, limit, tostring(expires)}
|
|
112
|
+
"""
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# KEYS: state hash, leases zset. ARGV: initial, min, max, token, throttled 0|1, ramp_window,
|
|
116
|
+
# increase_every, decrease_factor, pause_secs, ramp_cap, utilisation, peak_halflife, share,
|
|
117
|
+
# loss_tolerance, min_throttles, pause_ratio, window_secs, settle_ratio, probe_secs, lease_ttl,
|
|
118
|
+
# hold_ratio.
|
|
119
|
+
# Reply: {limit, removed 0|1, pause remaining}.
|
|
120
|
+
#
|
|
121
|
+
# Decrease (S35: a 2 % random 429 rate held limits at 3-5): outcomes are counted in a sliding
|
|
122
|
+
# window of `window_secs`; a throttle cuts the limit only when the window holds >= min_throttles
|
|
123
|
+
# throttles making up >= loss_tolerance of its outcomes, at most once per window, and only for a
|
|
124
|
+
# call started after the last cut (one cut per round trip). The pause is kept for throttle
|
|
125
|
+
# storms (ratio >= pause_ratio), not for every 429.
|
|
126
|
+
# Growth: +1 per min(increase_every, limit / 2) busy successes. After a cut the limit it was cut
|
|
127
|
+
# from is kept as `w_max`; from `settle_ratio x w_max` on, growth is one step per `probe_secs`
|
|
128
|
+
# and only on a success at full use (S07: it saw-toothed 44-64 around a capacity of 50 with
|
|
129
|
+
# ~50 leases in use); `w_max` is dropped once the limit passes `w_max / settle_ratio` without
|
|
130
|
+
# a cut (capacity grew). A throttle met while probing, `probe_secs` after the last cut, cuts
|
|
131
|
+
# back to the plateau (x settle_ratio), not x0.7.
|
|
132
|
+
# Futile cuts (S35b: a 30 % random 429 storm kept cutting every second): when the throttle
|
|
133
|
+
# ratio is still >= hold_ratio x the ratio at the last cut (within 10 windows of the last cut
|
|
134
|
+
# or hold), cutting did not help, so the throttles do not come from our concurrency; the limit
|
|
135
|
+
# is then not cut below the start value.
|
|
136
|
+
# DS12: a success counts toward growth only when inflight >= utilisation x the lane's allowed
|
|
137
|
+
# slots; growth is capped at 2 x the peak inflight (decaying with `peak_halflife` seconds).
|
|
138
|
+
LIMITER_RELEASE = (
|
|
139
|
+
_NOW
|
|
140
|
+
+ """
|
|
141
|
+
local score = tonumber(redis.call('ZSCORE', KEYS[2], ARGV[4]) or '')
|
|
142
|
+
local removed = redis.call('ZREM', KEYS[2], ARGV[4])
|
|
143
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now)
|
|
144
|
+
-- concurrency this attempt ran at: the others still in flight plus itself
|
|
145
|
+
local inflight = redis.call('ZCARD', KEYS[2]) + removed
|
|
146
|
+
"""
|
|
147
|
+
+ _LIMIT
|
|
148
|
+
+ """
|
|
149
|
+
local throttled = ARGV[5] == '1'
|
|
150
|
+
local window = tonumber(ARGV[17])
|
|
151
|
+
local settle = tonumber(ARGV[18])
|
|
152
|
+
local h = redis.call('HMGET', KEYS[1], 'win_start', 'win_ok', 'win_thr', 'prev_ok', 'prev_thr',
|
|
153
|
+
'last_decrease', 'w_max', 'last_increase', 'cut_ratio', 'last_hold')
|
|
154
|
+
local win_start = tonumber(h[1] or '0') or 0
|
|
155
|
+
local win_ok = tonumber(h[2] or '0') or 0
|
|
156
|
+
local win_thr = tonumber(h[3] or '0') or 0
|
|
157
|
+
local prev_ok = tonumber(h[4] or '0') or 0
|
|
158
|
+
local prev_thr = tonumber(h[5] or '0') or 0
|
|
159
|
+
local last_decrease = tonumber(h[6] or '0') or 0
|
|
160
|
+
local w_max = tonumber(h[7] or '0') or 0
|
|
161
|
+
local last_increase = tonumber(h[8] or '0') or 0
|
|
162
|
+
local cut_ratio = tonumber(h[9] or '0') or 0
|
|
163
|
+
local last_hold = tonumber(h[10] or '0') or 0
|
|
164
|
+
if now - win_start >= window then
|
|
165
|
+
if now - win_start < 2 * window then
|
|
166
|
+
prev_ok, prev_thr = win_ok, win_thr
|
|
167
|
+
else
|
|
168
|
+
prev_ok, prev_thr = 0, 0
|
|
169
|
+
end
|
|
170
|
+
win_start, win_ok, win_thr = now, 0, 0
|
|
171
|
+
end
|
|
172
|
+
if throttled then win_thr = win_thr + 1 else win_ok = win_ok + 1 end
|
|
173
|
+
redis.call('HSET', KEYS[1], 'win_start', tostring(win_start), 'win_ok', win_ok,
|
|
174
|
+
'win_thr', win_thr, 'prev_ok', prev_ok, 'prev_thr', prev_thr)
|
|
175
|
+
local function remaining()
|
|
176
|
+
if paused > now then return tostring(paused - now) end
|
|
177
|
+
return '0'
|
|
178
|
+
end
|
|
179
|
+
if throttled then
|
|
180
|
+
local carry = math.max(0, 1 - (now - win_start) / window)
|
|
181
|
+
local e_thr = win_thr + prev_thr * carry
|
|
182
|
+
local ratio = e_thr / math.max(1, e_thr + win_ok + prev_ok * carry)
|
|
183
|
+
local started = now
|
|
184
|
+
if score then started = score - tonumber(ARGV[20]) end
|
|
185
|
+
if e_thr >= tonumber(ARGV[15]) and ratio >= tonumber(ARGV[14])
|
|
186
|
+
and now - last_decrease >= window and started >= last_decrease then
|
|
187
|
+
local factor = tonumber(ARGV[8])
|
|
188
|
+
if w_max > 0 and limit >= math.floor(settle * w_max)
|
|
189
|
+
and now - last_decrease >= tonumber(ARGV[19]) then
|
|
190
|
+
factor = settle -- probing hit the known ceiling again: back to the plateau only
|
|
191
|
+
end
|
|
192
|
+
local target = math.max(min_l, math.floor(limit * factor))
|
|
193
|
+
if cut_ratio > 0 and now - math.max(last_decrease, last_hold) <= 10 * window
|
|
194
|
+
and ratio >= tonumber(ARGV[21]) * cut_ratio then
|
|
195
|
+
target = math.max(target, math.min(limit, tonumber(ARGV[1]))) -- futile: hold the start
|
|
196
|
+
if target >= limit then redis.call('HSET', KEYS[1], 'last_hold', tostring(now)) end
|
|
197
|
+
end
|
|
198
|
+
if target < limit then
|
|
199
|
+
redis.call('HSET', KEYS[1], 'w_max', limit, 'ramp', 0, 'ok_count', 0,
|
|
200
|
+
'last_decrease', tostring(now), 'cut_ratio', tostring(ratio))
|
|
201
|
+
limit = target
|
|
202
|
+
redis.call('HSET', KEYS[1], 'limit', limit)
|
|
203
|
+
end
|
|
204
|
+
if ratio >= tonumber(ARGV[16]) then
|
|
205
|
+
paused = math.max(paused, now + tonumber(ARGV[9]))
|
|
206
|
+
redis.call('HSET', KEYS[1], 'paused_until', tostring(paused))
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
return {limit, removed, remaining()}
|
|
210
|
+
end
|
|
211
|
+
local halflife = tonumber(ARGV[12] or '0') or 0
|
|
212
|
+
local peak = tonumber(redis.call('HGET', KEYS[1], 'peak') or '0') or 0
|
|
213
|
+
local peak_at = tonumber(redis.call('HGET', KEYS[1], 'peak_at') or '0') or 0
|
|
214
|
+
if halflife > 0 and now > peak_at then peak = peak * 0.5 ^ ((now - peak_at) / halflife) end
|
|
215
|
+
if inflight >= peak then
|
|
216
|
+
peak = inflight
|
|
217
|
+
redis.call('HSET', KEYS[1], 'peak', inflight, 'peak_at', tostring(now))
|
|
218
|
+
end
|
|
219
|
+
local share = tonumber(ARGV[13] or '1') or 1
|
|
220
|
+
local busy = inflight >= tonumber(ARGV[11]) * math.max(1, math.floor(limit * share))
|
|
221
|
+
local peak_cap = math.max(min_l, math.floor(2 * peak))
|
|
222
|
+
local ok = (tonumber(redis.call('HGET', KEYS[1], 'ok_count') or '0') or 0) + 1
|
|
223
|
+
local need = math.max(1, math.min(tonumber(ARGV[7]), math.ceil(limit / 2)))
|
|
224
|
+
local grow = false
|
|
225
|
+
if not busy then
|
|
226
|
+
ok = ok - 1
|
|
227
|
+
elseif redis.call('HGET', KEYS[1], 'ramp') == '1' then
|
|
228
|
+
if ok >= tonumber(ARGV[6]) then
|
|
229
|
+
limit = math.max(limit, math.min(tonumber(ARGV[10]), peak_cap, limit * 2))
|
|
230
|
+
ok = 0
|
|
231
|
+
end
|
|
232
|
+
elseif w_max > 0 and limit >= w_max / settle then
|
|
233
|
+
redis.call('HSET', KEYS[1], 'w_max', 0) -- passed the old ceiling without a cut
|
|
234
|
+
grow = ok >= need
|
|
235
|
+
elseif w_max > 0 and limit >= math.floor(settle * w_max) then
|
|
236
|
+
-- probe only while the limit binds: every allowed slot in use, not 0.9 of them
|
|
237
|
+
local full = inflight >= math.max(1, math.floor(limit * share))
|
|
238
|
+
grow = full and ok >= need and now - last_increase >= tonumber(ARGV[19])
|
|
239
|
+
else
|
|
240
|
+
grow = ok >= need
|
|
241
|
+
end
|
|
242
|
+
if grow then
|
|
243
|
+
limit = math.max(limit, math.min(max_l, peak_cap, limit + 1))
|
|
244
|
+
ok = 0
|
|
245
|
+
redis.call('HSET', KEYS[1], 'last_increase', tostring(now))
|
|
246
|
+
end
|
|
247
|
+
redis.call('HSET', KEYS[1], 'limit', limit, 'ok_count', ok)
|
|
248
|
+
return {limit, removed, remaining()}
|
|
249
|
+
"""
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# KEYS: state hash, leases zset. Read-only. Reply: {limit or -1, live leases, pause remaining}.
|
|
253
|
+
LIMITER_PEEK = (
|
|
254
|
+
_NOW
|
|
255
|
+
+ """
|
|
256
|
+
local limit = tonumber(redis.call('HGET', KEYS[1], 'limit') or '-1') or -1
|
|
257
|
+
local inflight = redis.call('ZCOUNT', KEYS[2], '(' .. tostring(now), '+inf')
|
|
258
|
+
local paused = tonumber(redis.call('HGET', KEYS[1], 'paused_until') or '0') or 0
|
|
259
|
+
local remaining = 0
|
|
260
|
+
if paused > now then remaining = paused - now end
|
|
261
|
+
return {limit, inflight, tostring(remaining)}
|
|
262
|
+
"""
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# KEYS: leases zset. ARGV: lease_ttl, token... Renews only leases still live (DS11): an
|
|
266
|
+
# expired lease's slot may already be counted free, so it is not brought back.
|
|
267
|
+
# Reply: {new expiry, renewed count}.
|
|
268
|
+
LEASE_RENEW = (
|
|
269
|
+
_NOW
|
|
270
|
+
+ """
|
|
271
|
+
local expires = now + tonumber(ARGV[1])
|
|
272
|
+
local renewed = 0
|
|
273
|
+
for i = 2, #ARGV do
|
|
274
|
+
local score = tonumber(redis.call('ZSCORE', KEYS[1], ARGV[i]) or '')
|
|
275
|
+
if score and score > now then
|
|
276
|
+
renewed = renewed + redis.call('ZADD', KEYS[1], 'XX', 'CH', expires, ARGV[i])
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
return {tostring(expires), renewed}
|
|
280
|
+
"""
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# KEYS: breaker hash. ARGV: op (allow|success|failure|neutral|peek), threshold, window,
|
|
284
|
+
# cooldown, probes. Half-open lets up to `probes` calls through at once; a probe slot comes back
|
|
285
|
+
# on `neutral` (an outcome that counts neither way) or after `cooldown` (a lost probe).
|
|
286
|
+
# Reply: {state, allowed 0|1}.
|
|
287
|
+
BREAKER = (
|
|
288
|
+
_NOW
|
|
289
|
+
+ """
|
|
290
|
+
local op = ARGV[1]
|
|
291
|
+
local threshold = tonumber(ARGV[2])
|
|
292
|
+
local window = tonumber(ARGV[3])
|
|
293
|
+
local cooldown = tonumber(ARGV[4])
|
|
294
|
+
local st = redis.call('HGET', KEYS[1], 'state') or 'closed'
|
|
295
|
+
local opened = tonumber(redis.call('HGET', KEYS[1], 'opened_at') or '0') or 0
|
|
296
|
+
local function open_now()
|
|
297
|
+
redis.call('HSET', KEYS[1], 'state', 'open', 'opened_at', tostring(now), 'fail_count', 0,
|
|
298
|
+
'window_start', tostring(now))
|
|
299
|
+
return {'open', 0}
|
|
300
|
+
end
|
|
301
|
+
if op == 'failure' then
|
|
302
|
+
if st == 'half_open' then return open_now() end
|
|
303
|
+
if st == 'open' then return {'open', 0} end
|
|
304
|
+
local ws = tonumber(redis.call('HGET', KEYS[1], 'window_start') or '0') or 0
|
|
305
|
+
local n = tonumber(redis.call('HGET', KEYS[1], 'fail_count') or '0') or 0
|
|
306
|
+
if now - ws > window then ws = now; n = 0 end
|
|
307
|
+
n = n + 1
|
|
308
|
+
if n >= threshold then return open_now() end
|
|
309
|
+
redis.call('HSET', KEYS[1], 'state', 'closed', 'fail_count', n, 'window_start', tostring(ws))
|
|
310
|
+
return {'closed', 1}
|
|
311
|
+
end
|
|
312
|
+
local max_probes = tonumber(ARGV[5] or '1') or 1
|
|
313
|
+
local probes = tonumber(redis.call('HGET', KEYS[1], 'probes') or '0') or 0
|
|
314
|
+
if op == 'success' then
|
|
315
|
+
if st == 'half_open' then
|
|
316
|
+
redis.call('HSET', KEYS[1], 'state', 'closed', 'fail_count', 0, 'window_start', tostring(now),
|
|
317
|
+
'probes', 0)
|
|
318
|
+
return {'closed', 1}
|
|
319
|
+
end
|
|
320
|
+
if st == 'open' then return {'open', 0} end
|
|
321
|
+
return {'closed', 1}
|
|
322
|
+
end
|
|
323
|
+
if op == 'neutral' then
|
|
324
|
+
if st == 'half_open' and probes > 0 then
|
|
325
|
+
redis.call('HSET', KEYS[1], 'probes', probes - 1)
|
|
326
|
+
return {'half_open', 1}
|
|
327
|
+
end
|
|
328
|
+
if st == 'open' then return {'open', 0} end
|
|
329
|
+
return {st, 1}
|
|
330
|
+
end
|
|
331
|
+
if st == 'open' then
|
|
332
|
+
if now - opened < cooldown then return {'open', 0} end
|
|
333
|
+
if op == 'peek' then return {'half_open', 1} end
|
|
334
|
+
redis.call('HSET', KEYS[1], 'state', 'half_open', 'probe_at', tostring(now), 'probes', 1)
|
|
335
|
+
return {'half_open', 1}
|
|
336
|
+
end
|
|
337
|
+
if st == 'half_open' then
|
|
338
|
+
local probe = tonumber(redis.call('HGET', KEYS[1], 'probe_at') or '0') or 0
|
|
339
|
+
local lost = now - probe >= cooldown
|
|
340
|
+
if probes >= max_probes and not lost then return {'half_open', 0} end
|
|
341
|
+
if op ~= 'peek' then
|
|
342
|
+
if lost then probes = 0 end
|
|
343
|
+
redis.call('HSET', KEYS[1], 'probe_at', tostring(now), 'probes', probes + 1)
|
|
344
|
+
end
|
|
345
|
+
return {'half_open', 1}
|
|
346
|
+
end
|
|
347
|
+
return {'closed', 1}
|
|
348
|
+
"""
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
_CLAIM_TTL = """
|
|
352
|
+
local function ttl_for(deadline)
|
|
353
|
+
return math.max(tonumber(ARGV[3]), math.ceil(deadline - now + tonumber(ARGV[4])))
|
|
354
|
+
end
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
# KEYS: job hash, cancel flag. ARGV: deadline_at, worker, state_ttl, exec_max, stale_after.
|
|
358
|
+
# Reply: a ClaimDecision value (DS1-DS3, DS14).
|
|
359
|
+
CLAIM = (
|
|
360
|
+
_NOW
|
|
361
|
+
+ _CLAIM_TTL
|
|
362
|
+
+ """
|
|
363
|
+
local deadline = tonumber(ARGV[1])
|
|
364
|
+
local state = redis.call('HGET', KEYS[1], 'state')
|
|
365
|
+
if state == 'done' then return 'ack_done' end
|
|
366
|
+
if state == 'cancelled' then return 'ack_cancelled' end
|
|
367
|
+
if state == 'expired' then return 'ack_expired' end
|
|
368
|
+
local fresh = false
|
|
369
|
+
if state == 'running' then
|
|
370
|
+
local hb = tonumber(redis.call('HGET', KEYS[1], 'heartbeat_at') or '0') or 0
|
|
371
|
+
fresh = now - hb <= tonumber(ARGV[5])
|
|
372
|
+
end
|
|
373
|
+
if redis.call('EXISTS', KEYS[2]) == 1 then
|
|
374
|
+
-- a live owner sees the flag itself and writes the cancelled result
|
|
375
|
+
if not fresh then
|
|
376
|
+
redis.call('HSET', KEYS[1], 'state', 'cancelled')
|
|
377
|
+
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
|
|
378
|
+
end
|
|
379
|
+
return 'ack_cancelled'
|
|
380
|
+
end
|
|
381
|
+
if fresh then return 'defer' end
|
|
382
|
+
if now >= deadline then
|
|
383
|
+
redis.call('HSET', KEYS[1], 'state', 'expired')
|
|
384
|
+
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
|
|
385
|
+
return 'ack_expired'
|
|
386
|
+
end
|
|
387
|
+
redis.call('HSET', KEYS[1], 'state', 'running', 'worker', ARGV[2],
|
|
388
|
+
'heartbeat_at', tostring(now), 'deadline_at', tostring(deadline))
|
|
389
|
+
if state == 'running' then redis.call('HINCRBY', KEYS[1], 'reclaims', 1) end
|
|
390
|
+
redis.call('EXPIRE', KEYS[1], ttl_for(deadline))
|
|
391
|
+
return 'run'
|
|
392
|
+
"""
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# KEYS: job hashes. ARGV: worker, state_ttl, exec_max. Reply: indexes (1-based) lost to
|
|
396
|
+
# another worker, or whose record is gone. A record this worker already finished (done,
|
|
397
|
+
# cancelled, expired) or released is not lost: the task is between its own CAS and leaving the
|
|
398
|
+
# running set, and cancelling it there killed its result publish (S35c: 19 spurious losses).
|
|
399
|
+
CLAIM_HEARTBEAT = (
|
|
400
|
+
_NOW
|
|
401
|
+
+ """
|
|
402
|
+
local lost = {}
|
|
403
|
+
for i, key in ipairs(KEYS) do
|
|
404
|
+
local f = redis.call('HMGET', key, 'state', 'worker', 'deadline_at')
|
|
405
|
+
if f[1] == 'running' and f[2] == ARGV[1] then
|
|
406
|
+
local deadline = tonumber(f[3] or '0') or 0
|
|
407
|
+
redis.call('HSET', key, 'heartbeat_at', tostring(now))
|
|
408
|
+
redis.call('EXPIRE', key,
|
|
409
|
+
math.max(tonumber(ARGV[2]), math.ceil(deadline - now + tonumber(ARGV[3]))))
|
|
410
|
+
elseif not f[1] or (f[2] and f[2] ~= ARGV[1]) then
|
|
411
|
+
table.insert(lost, i)
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
return lost
|
|
415
|
+
"""
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
# KEYS: job hash. ARGV: terminal state, state_ttl, worker ('' skips the owner check).
|
|
419
|
+
# Keeps the record at least state_ttl. With a worker, the write is a CAS: a record owned by
|
|
420
|
+
# another worker is left alone, so a stale former owner cannot finish the new owner's run.
|
|
421
|
+
# Reply: 1 written, -1 not the owner.
|
|
422
|
+
CLAIM_COMPLETE = """
|
|
423
|
+
local worker = ARGV[3] or ''
|
|
424
|
+
if worker ~= '' then
|
|
425
|
+
local owner = redis.call('HGET', KEYS[1], 'worker')
|
|
426
|
+
if owner and owner ~= worker then return -1 end
|
|
427
|
+
end
|
|
428
|
+
redis.call('HSET', KEYS[1], 'state', ARGV[1])
|
|
429
|
+
if redis.call('TTL', KEYS[1]) < tonumber(ARGV[2]) then
|
|
430
|
+
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
|
|
431
|
+
end
|
|
432
|
+
return 1
|
|
433
|
+
"""
|
|
434
|
+
|
|
435
|
+
# KEYS: job hash. ARGV: worker ('' skips the owner check). Marks a running task released so the
|
|
436
|
+
# next delivery runs at once; only the current owner may do so when a worker is given.
|
|
437
|
+
# Reply: 1 released, 0 not running, -1 running under another worker.
|
|
438
|
+
CLAIM_RELEASE = """
|
|
439
|
+
if redis.call('HGET', KEYS[1], 'state') ~= 'running' then return 0 end
|
|
440
|
+
local worker = ARGV[1] or ''
|
|
441
|
+
if worker ~= '' and redis.call('HGET', KEYS[1], 'worker') ~= worker then return -1 end
|
|
442
|
+
redis.call('HSET', KEYS[1], 'state', 'released')
|
|
443
|
+
redis.call('HDEL', KEYS[1], 'worker', 'heartbeat_at')
|
|
444
|
+
return 1
|
|
445
|
+
"""
|
|
446
|
+
|
|
447
|
+
# KEYS: config key, audit stream. ARGV: new JSON ('' deletes), actor, retention_ms.
|
|
448
|
+
# Writes the value and its audit entry atomically, trims entries older than retention.
|
|
449
|
+
# Reply: the previous value ('' if none).
|
|
450
|
+
CONFIG_SET = (
|
|
451
|
+
_NOW
|
|
452
|
+
+ """
|
|
453
|
+
local before = redis.call('GET', KEYS[1]) or ''
|
|
454
|
+
if ARGV[1] == '' then redis.call('DEL', KEYS[1]) else redis.call('SET', KEYS[1], ARGV[1]) end
|
|
455
|
+
local now_ms = math.floor(now * 1000)
|
|
456
|
+
redis.call('XADD', KEYS[2], '*', 'key', KEYS[1], 'actor', ARGV[2], 'before', before,
|
|
457
|
+
'after', ARGV[1], 'ts', tostring(now))
|
|
458
|
+
redis.call('XTRIM', KEYS[2], 'MINID', tostring(now_ms - tonumber(ARGV[3])))
|
|
459
|
+
return before
|
|
460
|
+
"""
|
|
461
|
+
)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Circuit breaker per (model, target) in Valkey.
|
|
2
|
+
|
|
3
|
+
Opens after `threshold` counted failures within `window_secs` (callers decide what counts, e.g.
|
|
4
|
+
5xx but not throttles, which the limiter handles); half-open lets `half_open_probes` calls through
|
|
5
|
+
and closes on a probe's success. While open, the worker goes to the next fallback step.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from taskiq_llm_queue.keys import KeySpace
|
|
15
|
+
from taskiq_llm_queue.reliability import _lua
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
import redis.asyncio
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BreakerState(StrEnum):
|
|
22
|
+
"""Breaker position: closed (normal), open (skip target), half-open (probing)."""
|
|
23
|
+
|
|
24
|
+
CLOSED = "closed"
|
|
25
|
+
OPEN = "open"
|
|
26
|
+
HALF_OPEN = "half_open"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class BreakerSettings:
|
|
31
|
+
"""Breaker thresholds; defaults (5 failures in 30 s, half-open after 15 s, one probe).
|
|
32
|
+
|
|
33
|
+
`half_open_probes` probes may run at once; a probe that never reports frees after cooldown.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
threshold: int = 5
|
|
37
|
+
window_secs: float = 30.0
|
|
38
|
+
cooldown_secs: float = 15.0
|
|
39
|
+
half_open_probes: int = 1
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
"""Reject impossible settings at construction (boot time)."""
|
|
43
|
+
if self.threshold < 1 or self.half_open_probes < 1:
|
|
44
|
+
raise ValueError("threshold and half_open_probes must be >= 1")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CircuitBreaker:
|
|
48
|
+
"""Shared breaker for every (model, target); all transitions are one Lua call each."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
client: redis.asyncio.Redis,
|
|
53
|
+
keys: KeySpace,
|
|
54
|
+
settings: BreakerSettings | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Register the Lua script on `client`; no I/O happens until the first call."""
|
|
57
|
+
self._keys = keys
|
|
58
|
+
self.settings = settings or BreakerSettings()
|
|
59
|
+
self._script = client.register_script(_lua.BREAKER)
|
|
60
|
+
|
|
61
|
+
async def _run(self, op: str, model: str, target: str) -> tuple[BreakerState, bool]:
|
|
62
|
+
s = self.settings
|
|
63
|
+
state, allowed = await self._script(
|
|
64
|
+
[self._keys.breaker(model, target)],
|
|
65
|
+
[op, s.threshold, s.window_secs, s.cooldown_secs, s.half_open_probes],
|
|
66
|
+
)
|
|
67
|
+
return BreakerState(_lua.as_text(state)), bool(int(allowed))
|
|
68
|
+
|
|
69
|
+
async def allow(self, model: str, target: str) -> bool:
|
|
70
|
+
"""True if a call may go to this target now (takes the half-open probe slot)."""
|
|
71
|
+
return (await self._run("allow", model, target))[1]
|
|
72
|
+
|
|
73
|
+
async def record(self, model: str, target: str, *, ok: bool) -> BreakerState:
|
|
74
|
+
"""Feed one outcome: `ok=False` counts a failure, a success closes a half-open breaker.
|
|
75
|
+
|
|
76
|
+
Call it only for outcomes that count; use `record_neutral` for the rest (e.g. throttles).
|
|
77
|
+
"""
|
|
78
|
+
return (await self._run("success" if ok else "failure", model, target))[0]
|
|
79
|
+
|
|
80
|
+
async def record_neutral(self, model: str, target: str) -> BreakerState:
|
|
81
|
+
"""Report an attempt that counts neither way; frees its half-open probe slot, if any."""
|
|
82
|
+
return (await self._run("neutral", model, target))[0]
|
|
83
|
+
|
|
84
|
+
async def state(self, model: str, target: str) -> BreakerState:
|
|
85
|
+
"""Current position without taking the probe slot."""
|
|
86
|
+
return (await self._run("peek", model, target))[0]
|