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,136 @@
|
|
|
1
|
+
"""Wire contract: the task envelope sent through SQS and the result returned through Redis.
|
|
2
|
+
|
|
3
|
+
Both sides of a rolling deploy read these, so the format is versioned (`v`). A worker accepts
|
|
4
|
+
its own version and the previous one; anything newer raises `UnsupportedEnvelopeError` so the
|
|
5
|
+
worker releases the message instead of dropping it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
import orjson
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
16
|
+
|
|
17
|
+
from taskiq_llm_queue.config import DEFAULT_ID_HASH_SALT, Lane
|
|
18
|
+
from taskiq_llm_queue.errors import UnsupportedEnvelopeError
|
|
19
|
+
|
|
20
|
+
ENVELOPE_VERSION = 1
|
|
21
|
+
TASK_CALL = "taskiq_llm_queue.call"
|
|
22
|
+
TASK_EMBED = "taskiq_llm_queue.embed"
|
|
23
|
+
TASK_JOB = "taskiq_llm_queue.job"
|
|
24
|
+
|
|
25
|
+
Op = Literal["converse", "invoke_model", "chat", "embeddings"]
|
|
26
|
+
FallbackPolicy = Literal["same_model", "chain"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Envelope(BaseModel):
|
|
30
|
+
"""One LLM call as a task argument; `params` are provider-native and passed through as-is.
|
|
31
|
+
|
|
32
|
+
Large params travel by reference (`params_ref`, a Redis key) instead of inline.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
36
|
+
|
|
37
|
+
v: int = ENVELOPE_VERSION
|
|
38
|
+
request_id: str
|
|
39
|
+
parent_request_id: str | None = None
|
|
40
|
+
session_id: str | None = None
|
|
41
|
+
user_id: str | None = None
|
|
42
|
+
reply_to: str | None = None
|
|
43
|
+
lane: Lane = Lane.BG
|
|
44
|
+
op: Op
|
|
45
|
+
fallback: FallbackPolicy = "same_model"
|
|
46
|
+
model: str
|
|
47
|
+
params: dict[str, Any] | None = None
|
|
48
|
+
params_ref: str | None = None
|
|
49
|
+
est_tokens: int = Field(default=0, ge=0)
|
|
50
|
+
enqueued_at: float = Field(default_factory=time.time)
|
|
51
|
+
deadline_at: float
|
|
52
|
+
trace: dict[str, str] = Field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
@model_validator(mode="after")
|
|
55
|
+
def _params_xor_ref(self) -> Envelope:
|
|
56
|
+
if (self.params is None) == (self.params_ref is None):
|
|
57
|
+
raise ValueError("exactly one of params / params_ref must be set")
|
|
58
|
+
if self.deadline_at < self.enqueued_at:
|
|
59
|
+
raise ValueError("deadline_at is before enqueued_at")
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class CallMeta(BaseModel):
|
|
64
|
+
"""How a call was actually served; surfaced as `response_metadata` / `ConverseResult.meta`."""
|
|
65
|
+
|
|
66
|
+
model_config = ConfigDict(frozen=True)
|
|
67
|
+
|
|
68
|
+
served_model: str = ""
|
|
69
|
+
served_target: str = ""
|
|
70
|
+
fallback_used: bool = False
|
|
71
|
+
queue_wait_ms: int = 0
|
|
72
|
+
provider_ms: int = 0
|
|
73
|
+
attempts: int = 0
|
|
74
|
+
worker: str = ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class LLMReply(BaseModel):
|
|
78
|
+
"""Result stored by the worker: the provider-native response, or a classified error."""
|
|
79
|
+
|
|
80
|
+
model_config = ConfigDict(frozen=True)
|
|
81
|
+
|
|
82
|
+
request_id: str
|
|
83
|
+
ok: bool
|
|
84
|
+
response: dict[str, Any] | None = None
|
|
85
|
+
meta: CallMeta = Field(default_factory=CallMeta)
|
|
86
|
+
error: dict[str, Any] | None = None
|
|
87
|
+
cancelled: bool = False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_envelope(data: bytes | str | dict[str, Any]) -> Envelope:
|
|
91
|
+
"""Decode an envelope, enforcing the version rule before field validation.
|
|
92
|
+
|
|
93
|
+
Raises `UnsupportedEnvelopeError` for a newer version and `ValueError` for malformed input.
|
|
94
|
+
"""
|
|
95
|
+
raw = orjson.loads(data) if isinstance(data, bytes | str) else data
|
|
96
|
+
if not isinstance(raw, dict):
|
|
97
|
+
raise ValueError("envelope is not a JSON object")
|
|
98
|
+
version = raw.get("v")
|
|
99
|
+
if not isinstance(version, int):
|
|
100
|
+
raise ValueError("envelope has no integer 'v'")
|
|
101
|
+
if version > ENVELOPE_VERSION:
|
|
102
|
+
raise UnsupportedEnvelopeError(f"envelope v{version} is newer than v{ENVELOPE_VERSION}")
|
|
103
|
+
if version < ENVELOPE_VERSION - 1:
|
|
104
|
+
raise ValueError(f"envelope v{version} is no longer supported")
|
|
105
|
+
return Envelope.model_validate(raw)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def keyed_digest(value: str, salt: str | bytes = DEFAULT_ID_HASH_SALT) -> bytes:
|
|
109
|
+
"""8-byte keyed BLAKE2b of `value`; equal on every pod that shares `salt` (max 64 bytes).
|
|
110
|
+
|
|
111
|
+
Keyed, so ids cannot be recovered by hashing guesses without the salt.
|
|
112
|
+
"""
|
|
113
|
+
key = salt.encode("utf-8") if isinstance(salt, str) else salt
|
|
114
|
+
return hashlib.blake2b(value.encode("utf-8"), digest_size=8, key=key).digest()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def hashed_id(value: str | None, salt: str | bytes = DEFAULT_ID_HASH_SALT) -> str | None:
|
|
118
|
+
"""Stable, non-reversible id for labels, logs and metrics (raw ids stay in the body).
|
|
119
|
+
|
|
120
|
+
`salt` is `QueueConfig.id_hash_salt` (`LLMQ_ID_HASH_SALT`); set your own in production.
|
|
121
|
+
"""
|
|
122
|
+
return None if value is None else keyed_digest(value, salt).hex()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def task_labels(
|
|
126
|
+
envelope: Envelope, *, hash_ids: bool = True, salt: str | bytes = DEFAULT_ID_HASH_SALT
|
|
127
|
+
) -> dict[str, str]:
|
|
128
|
+
"""Taskiq labels for one envelope: routing and context fields, ids hashed by default."""
|
|
129
|
+
labels = {"lane": envelope.lane.value, "model": envelope.model}
|
|
130
|
+
for key, value in (("session_id", envelope.session_id), ("user_id", envelope.user_id)):
|
|
131
|
+
shown = hashed_id(value, salt) if hash_ids else value
|
|
132
|
+
if shown is not None:
|
|
133
|
+
labels[key] = shown
|
|
134
|
+
if envelope.reply_to:
|
|
135
|
+
labels["reply_to"] = envelope.reply_to
|
|
136
|
+
return labels
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Exception tree and provider-error classification shared by every layer.
|
|
2
|
+
|
|
3
|
+
Callers catch one type, `LLMQueueError`; workers turn provider exceptions into `ErrorInfo` so
|
|
4
|
+
the limiter, retry and fallback logic never depend on SDK-specific exception classes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LLMQueueError(Exception):
|
|
14
|
+
"""Base class for every error this package raises to callers."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LLMQueueTimeout(LLMQueueError): # public name is part of the frozen API
|
|
18
|
+
"""The caller waited longer than its lane timeout or deadline for a result."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LLMQueueCancelled(LLMQueueError):
|
|
22
|
+
"""The call was cancelled (`LLMQueue.cancel`, or its caller went away) before it finished."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class QueueUnavailable(LLMQueueError): # public name is part of the frozen API
|
|
26
|
+
"""SQS or Redis/Valkey could not be reached and no fallback path was allowed."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConfigError(LLMQueueError):
|
|
30
|
+
"""Invalid configuration, raised at startup (never on a live request path)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UnsupportedEnvelopeError(LLMQueueError):
|
|
34
|
+
"""A message carries an envelope version newer than this worker understands.
|
|
35
|
+
|
|
36
|
+
Workers release such messages for another pod instead of acking them, so a rolling deploy
|
|
37
|
+
never drops work.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ProviderError(LLMQueueError):
|
|
42
|
+
"""The provider call failed after the worker's retries and fallbacks were exhausted."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self, message: str, *, retryable: bool, retry_after_secs: float | None = None
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Keep whether the caller may retry later and any provider-advised wait."""
|
|
48
|
+
super().__init__(message)
|
|
49
|
+
self.retryable = retryable
|
|
50
|
+
self.retry_after_secs = retry_after_secs
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ErrorKind(StrEnum):
|
|
54
|
+
"""How the worker reacts to a provider error: shrink limits, retry, or give up."""
|
|
55
|
+
|
|
56
|
+
THROTTLE = "throttle"
|
|
57
|
+
RETRYABLE = "retryable"
|
|
58
|
+
FATAL = "fatal"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class ErrorInfo:
|
|
63
|
+
"""Provider-neutral classification of one failed call, produced by `Provider.classify`.
|
|
64
|
+
|
|
65
|
+
`code` keeps the provider's own spelling for logs only; decisions use `kind`, because
|
|
66
|
+
fakes and real services spell the same failure differently.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
kind: ErrorKind
|
|
70
|
+
code: str = ""
|
|
71
|
+
retry_after_secs: float | None = None
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""SQS and Valkey failover state for the sender side (D73, D74, DS17, DS18).
|
|
2
|
+
|
|
3
|
+
A failed SQS send puts that model on `direct` for `sqs_failover_secs`; a Valkey failure puts the
|
|
4
|
+
whole pod on `direct` with a local per-pod cap until a health probe (or a backoff window) says
|
|
5
|
+
Valkey is back. `LLMQueue` calls the `FailoverPolicy` methods; nothing here blocks or raises.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
import os
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import TYPE_CHECKING, Literal
|
|
18
|
+
|
|
19
|
+
from taskiq_llm_queue._logging import get_logger
|
|
20
|
+
from taskiq_llm_queue.config import env_value
|
|
21
|
+
from taskiq_llm_queue.errors import ConfigError
|
|
22
|
+
from taskiq_llm_queue.metrics_names import FAILOVER_ACTIVE
|
|
23
|
+
from taskiq_llm_queue.reliability.limiter import local_cap
|
|
24
|
+
from taskiq_llm_queue.reliability.local_limiter import LocalLimiter
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from taskiq_llm_queue.metrics import MetricsSink
|
|
28
|
+
|
|
29
|
+
logger = get_logger(__name__)
|
|
30
|
+
Probe = Callable[[], Awaitable[object]]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class FailoverSettings:
|
|
35
|
+
"""Failover timings and the local cap (`LLMQ_SQS_FAILOVER_SECS`, `LLMQ_LOCAL_LIMIT_CAP`).
|
|
36
|
+
|
|
37
|
+
`live_pods` < 1 means unknown: the local cap is then `local_cap_fallback`, never
|
|
38
|
+
`last_known_limit` per pod, so an unknown pod count cannot overshoot the provider limit.
|
|
39
|
+
A running `LLMQueue` replaces it with the pod-registry count (`FailoverState.set_live_pods`).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
sqs_failover_secs: float = 300.0
|
|
43
|
+
redis_retry_min_secs: float = 1.0
|
|
44
|
+
redis_retry_max_secs: float = 30.0
|
|
45
|
+
local_cap_fallback: int = 20
|
|
46
|
+
live_pods: int = 0
|
|
47
|
+
|
|
48
|
+
def __post_init__(self) -> None:
|
|
49
|
+
"""Reject impossible values at construction (boot time)."""
|
|
50
|
+
if self.sqs_failover_secs <= 0 or self.local_cap_fallback < 1:
|
|
51
|
+
raise ConfigError("sqs_failover_secs must be > 0 and local_cap_fallback >= 1")
|
|
52
|
+
if not 0 < self.redis_retry_min_secs <= self.redis_retry_max_secs:
|
|
53
|
+
raise ConfigError("need 0 < redis_retry_min_secs <= redis_retry_max_secs")
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_env(
|
|
57
|
+
cls, environ: Mapping[str, str] | None = None, *, live_pods: int = 0
|
|
58
|
+
) -> FailoverSettings:
|
|
59
|
+
"""Read `LLMQ_SQS_FAILOVER_SECS` and `LLMQ_LOCAL_LIMIT_CAP`; bad values raise `ConfigError`.
|
|
60
|
+
|
|
61
|
+
The deprecated `LLMQ_SQS_FAILOVER_S` is still read (warned once).
|
|
62
|
+
"""
|
|
63
|
+
env = os.environ if environ is None else environ
|
|
64
|
+
values: dict[str, str] = {}
|
|
65
|
+
errors: list[str] = []
|
|
66
|
+
for name, conv in (("SQS_FAILOVER_SECS", float), ("LOCAL_LIMIT_CAP", int)):
|
|
67
|
+
raw = env_value(env, name)
|
|
68
|
+
if raw is None:
|
|
69
|
+
continue
|
|
70
|
+
try:
|
|
71
|
+
conv(raw)
|
|
72
|
+
except ValueError as exc:
|
|
73
|
+
errors.append(f"LLMQ_{name}: {exc}")
|
|
74
|
+
values[name] = raw
|
|
75
|
+
if errors:
|
|
76
|
+
raise ConfigError("; ".join(errors))
|
|
77
|
+
return cls(
|
|
78
|
+
sqs_failover_secs=float(values.get("SQS_FAILOVER_SECS", 300.0)),
|
|
79
|
+
local_cap_fallback=int(values.get("LOCAL_LIMIT_CAP", 20)),
|
|
80
|
+
live_pods=live_pods,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class FailoverState:
|
|
85
|
+
"""`FailoverPolicy`: per-model SQS failover windows and pod-wide Valkey-down mode.
|
|
86
|
+
|
|
87
|
+
With a `probe` (e.g. `client.ping`) Valkey-down lasts until a probe succeeds, retried with
|
|
88
|
+
exponential backoff on the running loop. Without one, or from a thread with no loop, the
|
|
89
|
+
mode lapses after the backoff window so the next call tries Valkey again.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
settings: FailoverSettings | None = None,
|
|
95
|
+
*,
|
|
96
|
+
probe: Probe | None = None,
|
|
97
|
+
metrics: MetricsSink | None = None,
|
|
98
|
+
clock: Callable[[], float] = time.monotonic,
|
|
99
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Keep settings, the optional Valkey probe, metrics sink and injected clock/sleep."""
|
|
102
|
+
self.settings = settings or FailoverSettings()
|
|
103
|
+
self._probe = probe
|
|
104
|
+
self._metrics = metrics
|
|
105
|
+
self._clock = clock
|
|
106
|
+
self._sleep = sleep
|
|
107
|
+
self._lock = threading.Lock()
|
|
108
|
+
self._sqs_until: dict[str, float] = {}
|
|
109
|
+
self._redis_down = False
|
|
110
|
+
self._redis_retry_at = 0.0
|
|
111
|
+
self._redis_backoff = self.settings.redis_retry_min_secs
|
|
112
|
+
self._probe_task: asyncio.Task[None] | None = None
|
|
113
|
+
self._limits: dict[tuple[str, str], int] = {}
|
|
114
|
+
self._local: dict[tuple[str, str], LocalLimiter] = {}
|
|
115
|
+
# Local limiters with calls still in flight when Valkey came back; reused if it fails
|
|
116
|
+
# again before they finish, so a flap never starts a fresh cap on top of busy slots.
|
|
117
|
+
self._busy: dict[tuple[str, str], LocalLimiter] = {}
|
|
118
|
+
self._live_pods: int | None = None
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------------------ SQS
|
|
121
|
+
|
|
122
|
+
def path_override(self, model: str) -> Literal["direct"] | None:
|
|
123
|
+
"""`direct` while Valkey is down or this model's SQS failover window is open, else None."""
|
|
124
|
+
if self.redis_down() or self.sqs_failover_active(model):
|
|
125
|
+
return "direct"
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
def report_sqs_failure(self, model: str, exc: BaseException) -> None:
|
|
129
|
+
"""Open (or extend) the model's SQS failover window after a failed or ambiguous send."""
|
|
130
|
+
alias = model.partition("@")[0]
|
|
131
|
+
with self._lock:
|
|
132
|
+
entering = alias not in self._sqs_until
|
|
133
|
+
self._sqs_until[alias] = self._clock() + self.settings.sqs_failover_secs
|
|
134
|
+
if entering:
|
|
135
|
+
logger.error(
|
|
136
|
+
"sqs_failover_active",
|
|
137
|
+
model=alias,
|
|
138
|
+
failover_secs=self.settings.sqs_failover_secs,
|
|
139
|
+
error_type=type(exc).__name__,
|
|
140
|
+
)
|
|
141
|
+
self._gauge(1.0, {"model": alias, "kind": "sqs"})
|
|
142
|
+
|
|
143
|
+
def sqs_failover_active(self, model: str) -> bool:
|
|
144
|
+
"""True while `model` (alias or `alias@target`) is inside its failover window."""
|
|
145
|
+
alias = model.partition("@")[0]
|
|
146
|
+
with self._lock:
|
|
147
|
+
until = self._sqs_until.get(alias)
|
|
148
|
+
if until is None:
|
|
149
|
+
return False
|
|
150
|
+
if self._clock() < until:
|
|
151
|
+
return True
|
|
152
|
+
del self._sqs_until[alias]
|
|
153
|
+
logger.warning("sqs_failover_ended", model=alias)
|
|
154
|
+
self._gauge(0.0, {"model": alias, "kind": "sqs"})
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
# ------------------------------------------------------------------ Valkey
|
|
158
|
+
|
|
159
|
+
def report_redis_failure(self, exc: BaseException) -> None:
|
|
160
|
+
"""Enter (or stay in) Valkey-down mode and start the health probe if one is configured."""
|
|
161
|
+
with self._lock:
|
|
162
|
+
entering = not self._redis_down
|
|
163
|
+
if entering:
|
|
164
|
+
self._redis_backoff = self.settings.redis_retry_min_secs
|
|
165
|
+
else:
|
|
166
|
+
self._redis_backoff = min(
|
|
167
|
+
self._redis_backoff * 2, self.settings.redis_retry_max_secs
|
|
168
|
+
)
|
|
169
|
+
self._redis_down = True
|
|
170
|
+
self._redis_retry_at = self._clock() + self._redis_backoff
|
|
171
|
+
if entering:
|
|
172
|
+
logger.error("valkey_local_mode_active", error_type=type(exc).__name__)
|
|
173
|
+
self._gauge(1.0, {"kind": "redis"})
|
|
174
|
+
self._start_probe()
|
|
175
|
+
|
|
176
|
+
def report_redis_ok(self) -> None:
|
|
177
|
+
"""Leave Valkey-down mode (a probe or a real call succeeded).
|
|
178
|
+
|
|
179
|
+
Idle local limiters are dropped (rebuilt per outage); busy ones are kept aside so the
|
|
180
|
+
in-flight calls stay counted if Valkey fails again before they finish.
|
|
181
|
+
"""
|
|
182
|
+
with self._lock:
|
|
183
|
+
if not self._redis_down:
|
|
184
|
+
return
|
|
185
|
+
self._redis_down = False
|
|
186
|
+
self._redis_backoff = self.settings.redis_retry_min_secs
|
|
187
|
+
self._busy = {k: lim for k, lim in self._local.items() if lim.inflight(*k) > 0}
|
|
188
|
+
self._local.clear()
|
|
189
|
+
logger.warning("valkey_local_mode_ended")
|
|
190
|
+
self._gauge(0.0, {"kind": "redis"})
|
|
191
|
+
|
|
192
|
+
def redis_down(self) -> bool:
|
|
193
|
+
"""True while Valkey is considered down (probe running, or inside the backoff window)."""
|
|
194
|
+
with self._lock:
|
|
195
|
+
if not self._redis_down:
|
|
196
|
+
return False
|
|
197
|
+
probing = self._probe_task is not None and not self._probe_task.done()
|
|
198
|
+
return probing or self._clock() < self._redis_retry_at
|
|
199
|
+
|
|
200
|
+
async def probe_redis(self) -> bool:
|
|
201
|
+
"""Run the probe once; success leaves Valkey-down mode. False when no probe is set."""
|
|
202
|
+
if self._probe is None:
|
|
203
|
+
return False
|
|
204
|
+
try:
|
|
205
|
+
await self._probe()
|
|
206
|
+
except Exception as exc: # any failure of the probe means "still down"
|
|
207
|
+
logger.debug("valkey_probe_failed", error_type=type(exc).__name__)
|
|
208
|
+
return False
|
|
209
|
+
self.report_redis_ok()
|
|
210
|
+
return True
|
|
211
|
+
|
|
212
|
+
async def _probe_loop(self) -> None:
|
|
213
|
+
delay = self.settings.redis_retry_min_secs
|
|
214
|
+
while self._redis_down:
|
|
215
|
+
await self._sleep(delay)
|
|
216
|
+
if await self.probe_redis():
|
|
217
|
+
return
|
|
218
|
+
delay = min(delay * 2, self.settings.redis_retry_max_secs)
|
|
219
|
+
|
|
220
|
+
def _start_probe(self) -> None:
|
|
221
|
+
if self._probe is None:
|
|
222
|
+
return
|
|
223
|
+
try:
|
|
224
|
+
loop = asyncio.get_running_loop()
|
|
225
|
+
except RuntimeError: # sync caller in a thread: the backoff window covers it
|
|
226
|
+
return
|
|
227
|
+
with self._lock:
|
|
228
|
+
if self._probe_task is None or self._probe_task.done():
|
|
229
|
+
self._probe_task = loop.create_task(self._probe_loop(), name="llmq-valkey-probe")
|
|
230
|
+
|
|
231
|
+
# ------------------------------------------------------------------ local cap
|
|
232
|
+
|
|
233
|
+
def note_limit(self, model: str, target: str, limit: int | None) -> None:
|
|
234
|
+
"""Remember the last shared limit seen for a model target (feeds the local cap)."""
|
|
235
|
+
if limit is not None:
|
|
236
|
+
with self._lock:
|
|
237
|
+
self._limits[(model, target)] = limit
|
|
238
|
+
|
|
239
|
+
def set_live_pods(self, count: int) -> None:
|
|
240
|
+
"""Live pod count from the pod registry; feeds the next local cap (DS18)."""
|
|
241
|
+
if count >= 1:
|
|
242
|
+
with self._lock:
|
|
243
|
+
self._live_pods = count
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def live_pods(self) -> int:
|
|
247
|
+
"""Pods sharing the provider limit: the registry count, else `settings.live_pods`."""
|
|
248
|
+
with self._lock:
|
|
249
|
+
live = self._live_pods
|
|
250
|
+
return self.settings.live_pods if live is None else live
|
|
251
|
+
|
|
252
|
+
def local_cap(self, model: str, target: str) -> int:
|
|
253
|
+
"""Per-pod cap while Valkey is down: last known limit / live pods, else the fallback."""
|
|
254
|
+
with self._lock:
|
|
255
|
+
last = self._limits.get((model, target))
|
|
256
|
+
return local_cap(last, self.live_pods, self.settings.local_cap_fallback)
|
|
257
|
+
|
|
258
|
+
def local_limiter(self, model: str, target: str) -> LocalLimiter:
|
|
259
|
+
"""The pod's `LocalLimiter` for a model target, built once per Valkey outage."""
|
|
260
|
+
key = (model, target)
|
|
261
|
+
with self._lock:
|
|
262
|
+
limiter = self._local.get(key)
|
|
263
|
+
if limiter is None and self._redis_down:
|
|
264
|
+
busy = self._busy.pop(key, None)
|
|
265
|
+
if busy is not None and busy.inflight(model, target) > 0:
|
|
266
|
+
limiter = self._local[key] = busy
|
|
267
|
+
if limiter is None:
|
|
268
|
+
limiter = LocalLimiter(self.local_cap(model, target), sleep=self._sleep)
|
|
269
|
+
with self._lock:
|
|
270
|
+
limiter = self._local.setdefault(key, limiter)
|
|
271
|
+
return limiter
|
|
272
|
+
|
|
273
|
+
async def aclose(self) -> None:
|
|
274
|
+
"""Stop the probe task; safe to call twice."""
|
|
275
|
+
task, self._probe_task = self._probe_task, None
|
|
276
|
+
if task is not None:
|
|
277
|
+
task.cancel()
|
|
278
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
279
|
+
await task
|
|
280
|
+
|
|
281
|
+
def _gauge(self, value: float, labels: dict[str, str]) -> None:
|
|
282
|
+
if self._metrics is not None:
|
|
283
|
+
try:
|
|
284
|
+
self._metrics.gauge(FAILOVER_ACTIVE, value, labels)
|
|
285
|
+
except Exception: # metrics must never break failover
|
|
286
|
+
logger.exception("failover_metric_failed")
|