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,226 @@
|
|
|
1
|
+
"""Exactly-once execution on at-least-once SQS delivery: the claim record per task (DS1-DS3).
|
|
2
|
+
|
|
3
|
+
`<prefix>:{r:<id>}:job` holds `state`, `worker`, `heartbeat_at`, `deadline_at` and `failures`;
|
|
4
|
+
every transition is one Lua CAS using Valkey `TIME`. Used by the claim middleware before a task
|
|
5
|
+
runs, by the heartbeat loop, and by `direct` failover (DS17: claim as `direct:<pod>`).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from redis.crc import key_slot
|
|
16
|
+
|
|
17
|
+
from taskiq_llm_queue._logging import get_logger
|
|
18
|
+
from taskiq_llm_queue.keys import KeySpace
|
|
19
|
+
from taskiq_llm_queue.protocols import ClaimDecision, ClaimState
|
|
20
|
+
from taskiq_llm_queue.reliability import _lua
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
import redis.asyncio
|
|
24
|
+
|
|
25
|
+
from taskiq_llm_queue.config import QueueConfig
|
|
26
|
+
|
|
27
|
+
logger = get_logger(__name__)
|
|
28
|
+
|
|
29
|
+
_TERMINAL = frozenset({ClaimState.DONE, ClaimState.CANCELLED, ClaimState.EXPIRED})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ClaimOutcome(StrEnum):
|
|
33
|
+
"""Result of an owner-checked `complete_owned` / `release_owned` call.
|
|
34
|
+
|
|
35
|
+
`NOT_OWNER` means another worker holds the claim now, so nothing was written.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
APPLIED = "applied"
|
|
39
|
+
NOT_RUNNING = "not_running" # release only: the record was not running
|
|
40
|
+
NOT_OWNER = "not_owner"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class ClaimSettings:
|
|
45
|
+
"""Claim timing: TTLs, worst-case execution and heartbeat staleness (DS3, DS5).
|
|
46
|
+
|
|
47
|
+
A heartbeat older than `stale_after_beats x heartbeat_interval_secs` is stale and the task
|
|
48
|
+
is reclaimed; at least 3 beats, so one missed or late beat never hands a live task over.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
state_ttl_secs: int = 1800
|
|
52
|
+
exec_max_secs: int = 300
|
|
53
|
+
heartbeat_interval_secs: float = 10.0
|
|
54
|
+
max_failures: int = 3
|
|
55
|
+
stale_after_beats: float = 3.0
|
|
56
|
+
|
|
57
|
+
def __post_init__(self) -> None:
|
|
58
|
+
"""Reject a staleness window that a single missed heartbeat could exceed."""
|
|
59
|
+
if self.stale_after_beats < 3:
|
|
60
|
+
raise ValueError("stale_after_beats must be >= 3 heartbeat intervals")
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def stale_after_secs(self) -> float:
|
|
64
|
+
"""Heartbeat age after which a running claim may be taken over."""
|
|
65
|
+
return self.stale_after_beats * self.heartbeat_interval_secs
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_config(cls, config: QueueConfig, **overrides: Any) -> ClaimSettings:
|
|
69
|
+
"""Settings whose state TTL comes from `QueueConfig.state_ttl_secs`."""
|
|
70
|
+
return cls(state_ttl_secs=config.state_ttl_secs, **overrides)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class FailureCount:
|
|
75
|
+
"""Real failures counted for a task and whether it should now go to the DLQ (DS5)."""
|
|
76
|
+
|
|
77
|
+
count: int
|
|
78
|
+
exhausted: bool
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RedisClaimStore:
|
|
82
|
+
"""`ClaimStore` on Valkey: claim, heartbeat, complete, release, failures and cancel flag.
|
|
83
|
+
|
|
84
|
+
A running task with a fresh heartbeat is never acked by another pod (DEFER, DS2); terminal
|
|
85
|
+
records outlive the message so duplicates are acked without a second provider call.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
client: redis.asyncio.Redis,
|
|
91
|
+
keys: KeySpace,
|
|
92
|
+
settings: ClaimSettings | None = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Register the Lua scripts on `client`; no I/O happens until the first call."""
|
|
95
|
+
self._client = client
|
|
96
|
+
self._keys = keys
|
|
97
|
+
self.settings = settings or ClaimSettings()
|
|
98
|
+
self._claim = client.register_script(_lua.CLAIM)
|
|
99
|
+
self._heartbeat = client.register_script(_lua.CLAIM_HEARTBEAT)
|
|
100
|
+
self._complete = client.register_script(_lua.CLAIM_COMPLETE)
|
|
101
|
+
self._release = client.register_script(_lua.CLAIM_RELEASE)
|
|
102
|
+
|
|
103
|
+
async def claim(self, request_id: str, *, deadline_at: float, worker: str) -> ClaimDecision:
|
|
104
|
+
"""Atomically decide whether this worker runs the task (Lua CAS)."""
|
|
105
|
+
s = self.settings
|
|
106
|
+
decision = await self._claim(
|
|
107
|
+
[self._keys.job(request_id), self._keys.cancel(request_id)],
|
|
108
|
+
[
|
|
109
|
+
repr(float(deadline_at)),
|
|
110
|
+
worker,
|
|
111
|
+
s.state_ttl_secs,
|
|
112
|
+
s.exec_max_secs,
|
|
113
|
+
s.stale_after_secs,
|
|
114
|
+
],
|
|
115
|
+
)
|
|
116
|
+
return ClaimDecision(_lua.as_text(decision))
|
|
117
|
+
|
|
118
|
+
async def heartbeat_owned(self, request_ids: Sequence[str], *, worker: str) -> list[str]:
|
|
119
|
+
"""Refresh heartbeat and TTL of tasks this worker runs; return the ids it lost (DS6).
|
|
120
|
+
|
|
121
|
+
Lost means another worker owns the claim now or the record is gone; a claim this worker
|
|
122
|
+
already completed or released is skipped, not lost (its task is just finishing).
|
|
123
|
+
"""
|
|
124
|
+
if not request_ids:
|
|
125
|
+
return []
|
|
126
|
+
s = self.settings
|
|
127
|
+
args: list[Any] = [worker, s.state_ttl_secs, s.exec_max_secs]
|
|
128
|
+
keys = [self._keys.job(rid) for rid in request_ids]
|
|
129
|
+
# Each job key has its own `{r:<id>}` slot and a cluster script may touch only one:
|
|
130
|
+
# one call per slot, all pipelined in a single round trip (standalone Valkey too).
|
|
131
|
+
by_slot: dict[int, list[int]] = {}
|
|
132
|
+
for index, key in enumerate(keys):
|
|
133
|
+
by_slot.setdefault(key_slot(key.encode()), []).append(index)
|
|
134
|
+
groups = list(by_slot.values())
|
|
135
|
+
replies = await _lua.run_many(
|
|
136
|
+
self._client, [(self._heartbeat, [keys[i] for i in group], args) for group in groups]
|
|
137
|
+
)
|
|
138
|
+
missing = sorted(
|
|
139
|
+
group[int(i) - 1] for group, lost in zip(groups, replies, strict=True) for i in lost
|
|
140
|
+
)
|
|
141
|
+
return [request_ids[index] for index in missing]
|
|
142
|
+
|
|
143
|
+
async def heartbeat(self, request_ids: Sequence[str], *, worker: str) -> None:
|
|
144
|
+
"""Refresh heartbeats and TTLs for running tasks."""
|
|
145
|
+
await self.heartbeat_owned(request_ids, worker=worker)
|
|
146
|
+
|
|
147
|
+
async def complete(
|
|
148
|
+
self, request_id: str, state: ClaimState, *, worker: str | None = None
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Mark a task done, cancelled or expired after its result is stored (DS8).
|
|
151
|
+
|
|
152
|
+
With `worker`, only the current owner may complete it (see `complete_owned`).
|
|
153
|
+
"""
|
|
154
|
+
await self.complete_owned(request_id, state, worker=worker)
|
|
155
|
+
|
|
156
|
+
async def complete_owned(
|
|
157
|
+
self, request_id: str, state: ClaimState, *, worker: str | None
|
|
158
|
+
) -> ClaimOutcome:
|
|
159
|
+
"""Owner-checked `complete`: NOT_OWNER when another worker holds the claim now.
|
|
160
|
+
|
|
161
|
+
A record without an owner (released, or missing) is written; `worker=None` skips the check.
|
|
162
|
+
"""
|
|
163
|
+
script, keys, args = self.complete_call(request_id, state, worker=worker)
|
|
164
|
+
return self.complete_outcome(await script(keys, args), request_id, worker=worker)
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def client(self) -> redis.asyncio.Redis:
|
|
168
|
+
"""The client the scripts run on (lets a caller share one pipeline with it)."""
|
|
169
|
+
return self._client
|
|
170
|
+
|
|
171
|
+
def complete_call(
|
|
172
|
+
self, request_id: str, state: ClaimState, *, worker: str | None
|
|
173
|
+
) -> _lua.ScriptCall:
|
|
174
|
+
"""The `complete_owned` script call, to queue in a caller's pipeline (`_lua`)."""
|
|
175
|
+
if state not in _TERMINAL:
|
|
176
|
+
raise ValueError(f"complete() needs a terminal state, got {state!r}")
|
|
177
|
+
args = [state.value, self.settings.state_ttl_secs, worker or ""]
|
|
178
|
+
return self._complete, [self._keys.job(request_id)], args
|
|
179
|
+
|
|
180
|
+
def complete_outcome(self, reply: Any, request_id: str, *, worker: str | None) -> ClaimOutcome:
|
|
181
|
+
"""Interpret a `complete_call` reply: APPLIED, or NOT_OWNER (logged)."""
|
|
182
|
+
if int(reply) < 0:
|
|
183
|
+
logger.warning("claim_complete_not_owner", request_id=request_id, worker=worker)
|
|
184
|
+
return ClaimOutcome.NOT_OWNER
|
|
185
|
+
return ClaimOutcome.APPLIED
|
|
186
|
+
|
|
187
|
+
async def release(self, request_id: str, *, worker: str | None = None) -> None:
|
|
188
|
+
"""Hand a running task back so another pod can start it at once.
|
|
189
|
+
|
|
190
|
+
With `worker`, only the current owner may release it (see `release_owned`).
|
|
191
|
+
"""
|
|
192
|
+
await self.release_owned(request_id, worker=worker)
|
|
193
|
+
|
|
194
|
+
async def release_owned(self, request_id: str, *, worker: str | None) -> ClaimOutcome:
|
|
195
|
+
"""Owner-checked `release`: APPLIED, NOT_RUNNING, or NOT_OWNER (nothing written).
|
|
196
|
+
|
|
197
|
+
`worker=None` releases any running record (the old, unchecked behaviour).
|
|
198
|
+
"""
|
|
199
|
+
reply = int(await self._release([self._keys.job(request_id)], [worker or ""]))
|
|
200
|
+
if reply < 0:
|
|
201
|
+
logger.warning("claim_release_not_owner", request_id=request_id, worker=worker)
|
|
202
|
+
return ClaimOutcome.NOT_OWNER
|
|
203
|
+
return ClaimOutcome.APPLIED if reply else ClaimOutcome.NOT_RUNNING
|
|
204
|
+
|
|
205
|
+
async def state(self, request_id: str) -> ClaimState | None:
|
|
206
|
+
"""Current claim state, or None when there is no record."""
|
|
207
|
+
raw = await self._client.hget(self._keys.job(request_id), "state")
|
|
208
|
+
return None if raw is None else ClaimState(_lua.as_text(raw))
|
|
209
|
+
|
|
210
|
+
async def record_failure(self, request_id: str) -> FailureCount:
|
|
211
|
+
"""Count one real failure (not a throttle or a release) against `max_failures` (DS5)."""
|
|
212
|
+
key = self._keys.job(request_id)
|
|
213
|
+
async with self._client.pipeline(transaction=True) as pipe:
|
|
214
|
+
pipe.hincrby(key, "failures", 1)
|
|
215
|
+
pipe.expire(key, self.settings.state_ttl_secs, nx=True) # never leave a key without TTL
|
|
216
|
+
raw_count, _ = await pipe.execute()
|
|
217
|
+
count = int(raw_count)
|
|
218
|
+
return FailureCount(count, count >= self.settings.max_failures)
|
|
219
|
+
|
|
220
|
+
async def cancel(self, request_id: str) -> None:
|
|
221
|
+
"""Set the cancel flag that workers check before every attempt and sleep (DS14)."""
|
|
222
|
+
await self._client.set(self._keys.cancel(request_id), b"1", ex=self.settings.state_ttl_secs)
|
|
223
|
+
|
|
224
|
+
async def is_cancelled(self, request_id: str) -> bool:
|
|
225
|
+
"""True when the caller cancelled or timed out on this task."""
|
|
226
|
+
return bool(await self._client.exists(self._keys.cancel(request_id)))
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Live JSON config in Valkey (routing, fallback chains) with last-good fallback and audit.
|
|
2
|
+
|
|
3
|
+
Readers validate with pydantic and keep the last good value when the stored JSON is invalid or
|
|
4
|
+
Valkey is unreachable, so a bad edit never breaks live calls. Every write appends who changed
|
|
5
|
+
what to `KeySpace.audit` in the same Lua call, trimmed to the retention window.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ValidationError
|
|
15
|
+
from redis.exceptions import RedisError
|
|
16
|
+
|
|
17
|
+
from taskiq_llm_queue._logging import get_logger
|
|
18
|
+
from taskiq_llm_queue.errors import ConfigError
|
|
19
|
+
from taskiq_llm_queue.keys import KeySpace
|
|
20
|
+
from taskiq_llm_queue.reliability import _lua
|
|
21
|
+
from taskiq_llm_queue.reliability.fallback import FallbackConfig
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
import redis.asyncio
|
|
25
|
+
|
|
26
|
+
logger = get_logger(__name__)
|
|
27
|
+
ModelT = TypeVar("ModelT", bound=BaseModel)
|
|
28
|
+
AUDIT_RETENTION_SECS = 90 * 86400
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LiveConfigStore(Generic[ModelT]): # PEP 695 needs 3.12; package supports 3.11
|
|
32
|
+
"""One live config value of type `model_type` at `key`, with audit trail and reload loop.
|
|
33
|
+
|
|
34
|
+
`current` is the last good value (the startup default until a load succeeds); hot paths read
|
|
35
|
+
it without I/O, and `run_reload` refreshes it every `interval_secs` (`LLMQ_CFG_RELOAD_SECS`).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
client: redis.asyncio.Redis,
|
|
41
|
+
key: str,
|
|
42
|
+
model_type: type[ModelT],
|
|
43
|
+
default: ModelT,
|
|
44
|
+
*,
|
|
45
|
+
audit_key: str,
|
|
46
|
+
audit_retention_secs: int = AUDIT_RETENTION_SECS,
|
|
47
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Keep the client and the startup default; nothing is read until `load`."""
|
|
50
|
+
self._client = client
|
|
51
|
+
self.key = key
|
|
52
|
+
self._type = model_type
|
|
53
|
+
self.default = default
|
|
54
|
+
self._audit_key = audit_key
|
|
55
|
+
self._retention_ms = audit_retention_secs * 1000
|
|
56
|
+
self._sleep = sleep
|
|
57
|
+
self._current = default
|
|
58
|
+
self._warned: set[bytes] = set()
|
|
59
|
+
self.invalid_count = 0
|
|
60
|
+
self._set = client.register_script(_lua.CONFIG_SET)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def current(self) -> ModelT:
|
|
64
|
+
"""Last good value; never raises and never does I/O."""
|
|
65
|
+
return self._current
|
|
66
|
+
|
|
67
|
+
def parse(self, raw: str | bytes | Mapping[str, Any] | ModelT) -> ModelT:
|
|
68
|
+
"""Validate JSON, a mapping or a model instance; raises `ConfigError` when invalid."""
|
|
69
|
+
try:
|
|
70
|
+
if isinstance(raw, self._type):
|
|
71
|
+
return raw
|
|
72
|
+
if isinstance(raw, str | bytes):
|
|
73
|
+
return self._type.model_validate_json(raw)
|
|
74
|
+
return self._type.model_validate(raw)
|
|
75
|
+
except ValidationError as exc:
|
|
76
|
+
raise ConfigError(f"invalid {self.key}: {exc}") from exc
|
|
77
|
+
|
|
78
|
+
async def load(self) -> ModelT:
|
|
79
|
+
"""Read and validate the stored value; on bad data or no Valkey keep the last good one.
|
|
80
|
+
|
|
81
|
+
A missing key means the startup default. Invalid data is logged once per distinct value
|
|
82
|
+
and counted in `invalid_count` (exported as `llmq_config_invalid_total`).
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
raw = await self._client.get(self.key)
|
|
86
|
+
except RedisError as exc:
|
|
87
|
+
logger.warning("live_config_unreachable", key=self.key, error=str(exc))
|
|
88
|
+
return self._current
|
|
89
|
+
if raw is None:
|
|
90
|
+
self._current = self.default
|
|
91
|
+
return self._current
|
|
92
|
+
raw_bytes = raw if isinstance(raw, bytes) else str(raw).encode()
|
|
93
|
+
try:
|
|
94
|
+
self._current = self.parse(raw_bytes)
|
|
95
|
+
except ConfigError as exc:
|
|
96
|
+
self.invalid_count += 1
|
|
97
|
+
if raw_bytes not in self._warned:
|
|
98
|
+
self._warned.add(raw_bytes)
|
|
99
|
+
logger.warning(
|
|
100
|
+
"live_config_invalid",
|
|
101
|
+
key=self.key,
|
|
102
|
+
invalid_count=self.invalid_count,
|
|
103
|
+
error=str(exc),
|
|
104
|
+
)
|
|
105
|
+
return self._current
|
|
106
|
+
|
|
107
|
+
async def set(self, value: str | bytes | Mapping[str, Any] | ModelT, *, actor: str) -> ModelT:
|
|
108
|
+
"""Validate and store a new value, appending an audit entry; returns the stored value.
|
|
109
|
+
|
|
110
|
+
Raises `ConfigError` for invalid input, so a bad CLI edit never reaches Valkey.
|
|
111
|
+
"""
|
|
112
|
+
parsed = self.parse(value)
|
|
113
|
+
await self._set(
|
|
114
|
+
[self.key, self._audit_key], [parsed.model_dump_json(), actor, self._retention_ms]
|
|
115
|
+
)
|
|
116
|
+
self._current = parsed
|
|
117
|
+
return parsed
|
|
118
|
+
|
|
119
|
+
async def reset(self, *, actor: str) -> ModelT:
|
|
120
|
+
"""Delete the stored value (back to the startup default), with an audit entry."""
|
|
121
|
+
await self._set([self.key, self._audit_key], ["", actor, self._retention_ms])
|
|
122
|
+
self._current = self.default
|
|
123
|
+
return self._current
|
|
124
|
+
|
|
125
|
+
async def history(self, count: int = 20) -> list[dict[str, str]]:
|
|
126
|
+
"""Latest audit entries for this key, newest first (for `llm-queue ... get`)."""
|
|
127
|
+
entries = await self._client.xrevrange(self._audit_key, count=count * 4)
|
|
128
|
+
out: list[dict[str, str]] = []
|
|
129
|
+
for _id, fields in entries or ():
|
|
130
|
+
row = {_lua.as_text(k): _lua.as_text(v) for k, v in (fields or {}).items()}
|
|
131
|
+
if row.get("key") == self.key:
|
|
132
|
+
out.append(row)
|
|
133
|
+
return out[:count]
|
|
134
|
+
|
|
135
|
+
async def run_reload(self, interval_secs: float) -> None:
|
|
136
|
+
"""Reload every `interval_secs` until cancelled; errors never end the loop."""
|
|
137
|
+
while True:
|
|
138
|
+
await self.load()
|
|
139
|
+
await self._sleep(interval_secs)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def fallback_store(
|
|
143
|
+
client: redis.asyncio.Redis, keys: KeySpace, default: FallbackConfig | None = None
|
|
144
|
+
) -> LiveConfigStore[FallbackConfig]:
|
|
145
|
+
"""Live store for fallback chains at `KeySpace.fallback`, audited to `KeySpace.audit`."""
|
|
146
|
+
return LiveConfigStore(
|
|
147
|
+
client, keys.fallback, FallbackConfig, default or FallbackConfig(), audit_key=keys.audit
|
|
148
|
+
)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Fallback order for one call: same model on other targets first, other models only on `chain`.
|
|
2
|
+
|
|
3
|
+
Pure logic over `QueueConfig` and the live `FallbackConfig`. Embeddings never
|
|
4
|
+
switch model (vectors from another model are incompatible). The worker walks the steps in order
|
|
5
|
+
and checks limiter headroom itself where a step says `require_headroom`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
14
|
+
|
|
15
|
+
from taskiq_llm_queue.errors import ConfigError
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from taskiq_llm_queue.config import QueueConfig
|
|
19
|
+
from taskiq_llm_queue.envelope import FallbackPolicy, Op
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ChainStep(BaseModel):
|
|
23
|
+
"""One other model in a chain (`alias` or `alias@target`); a plain string is accepted."""
|
|
24
|
+
|
|
25
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
26
|
+
|
|
27
|
+
model: str = Field(min_length=1)
|
|
28
|
+
require_headroom: bool = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FallbackConfig(BaseModel):
|
|
32
|
+
"""Live fallback chains stored as JSON at `KeySpace.fallback`.
|
|
33
|
+
|
|
34
|
+
`chains` maps a primary alias to the other models tried under policy `chain`;
|
|
35
|
+
`skip_targets` lists targets not usable yet per alias (e.g. APAC before sign-off).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
39
|
+
|
|
40
|
+
chains: dict[str, tuple[ChainStep, ...]] = Field(default_factory=dict)
|
|
41
|
+
skip_targets: dict[str, frozenset[str]] = Field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
@field_validator("chains", mode="before")
|
|
44
|
+
@classmethod
|
|
45
|
+
def _strings_to_steps(cls, value: Any) -> Any:
|
|
46
|
+
if not isinstance(value, dict):
|
|
47
|
+
return value
|
|
48
|
+
return {
|
|
49
|
+
alias: [{"model": s} if isinstance(s, str) else s for s in steps]
|
|
50
|
+
if isinstance(steps, list | tuple)
|
|
51
|
+
else steps
|
|
52
|
+
for alias, steps in value.items()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@model_validator(mode="after")
|
|
56
|
+
def _no_self_reference(self) -> FallbackConfig:
|
|
57
|
+
loops = [a for a, steps in self.chains.items() if any(_alias(s.model) == a for s in steps)]
|
|
58
|
+
if loops:
|
|
59
|
+
raise ValueError(f"chains must not list the primary model itself: {loops}")
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class FallbackStep:
|
|
65
|
+
"""One place to try: model alias and target, whether it changes model, headroom rule."""
|
|
66
|
+
|
|
67
|
+
alias: str
|
|
68
|
+
target: str
|
|
69
|
+
switched_model: bool = False
|
|
70
|
+
require_headroom: bool = False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _alias(model: str) -> str:
|
|
74
|
+
return model.partition("@")[0]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_fallback(
|
|
78
|
+
config: QueueConfig,
|
|
79
|
+
model: str,
|
|
80
|
+
*,
|
|
81
|
+
policy: FallbackPolicy = "same_model",
|
|
82
|
+
op: Op = "converse",
|
|
83
|
+
live: FallbackConfig | None = None,
|
|
84
|
+
) -> tuple[FallbackStep, ...]:
|
|
85
|
+
"""Ordered steps for `model` (`alias[@target]`): the requested target, then its other targets.
|
|
86
|
+
|
|
87
|
+
With policy `chain` (and never for embeddings) the chain's models follow, each primary
|
|
88
|
+
target first. Unknown aliases or targets in the live chain are skipped, not raised, because
|
|
89
|
+
live config may lag the deployed `QueueConfig`; the requested model itself must exist.
|
|
90
|
+
"""
|
|
91
|
+
live = live or FallbackConfig()
|
|
92
|
+
alias, cfg, first = config.resolve(model)
|
|
93
|
+
skip = live.skip_targets.get(alias, frozenset())
|
|
94
|
+
steps = [FallbackStep(alias, first)]
|
|
95
|
+
steps += [FallbackStep(alias, t) for t in cfg.targets if t != first and t not in skip]
|
|
96
|
+
if policy != "chain" or op == "embeddings":
|
|
97
|
+
return tuple(steps)
|
|
98
|
+
seen = {alias}
|
|
99
|
+
for step in live.chains.get(alias, ()):
|
|
100
|
+
other, _, pinned = step.model.partition("@")
|
|
101
|
+
if other in seen:
|
|
102
|
+
continue
|
|
103
|
+
try:
|
|
104
|
+
_, other_cfg, _ = config.resolve(step.model)
|
|
105
|
+
except ConfigError:
|
|
106
|
+
continue
|
|
107
|
+
seen.add(other)
|
|
108
|
+
other_skip = live.skip_targets.get(other, frozenset())
|
|
109
|
+
targets = [pinned] if pinned else [t for t in other_cfg.targets if t not in other_skip]
|
|
110
|
+
steps += [FallbackStep(other, t, True, step.require_headroom) for t in targets]
|
|
111
|
+
return tuple(steps)
|