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
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]