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,512 @@
|
|
|
1
|
+
"""Building and draining the worker side: brokers per model, receivers, heartbeat (§6, §7.4).
|
|
2
|
+
|
|
3
|
+
`build_brokers` creates one `HybridSQSBroker` per distinct queue set (rt priority 0, default 1,
|
|
4
|
+
bulk 2) with limiter-aware gates and receive budgets (DS4, DS13). `Workers` runs taskiq
|
|
5
|
+
`Receiver`s in-process and stops them in the ordered drain of §7.4. `PodHeartbeat` keeps every
|
|
6
|
+
claim and lease this pod holds alive, in every mode (DS3, DS6, DS11, DS17).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import math
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
from redis.exceptions import RedisError
|
|
19
|
+
from taskiq.acks import AcknowledgeType
|
|
20
|
+
from taskiq.receiver import Receiver
|
|
21
|
+
|
|
22
|
+
from taskiq_llm_queue import metrics_names as mn
|
|
23
|
+
from taskiq_llm_queue._logging import get_logger
|
|
24
|
+
from taskiq_llm_queue.broker.broker import HybridSQSBroker
|
|
25
|
+
from taskiq_llm_queue.broker.events import BrokerHooks
|
|
26
|
+
from taskiq_llm_queue.broker.queue import SQSQueue
|
|
27
|
+
from taskiq_llm_queue.broker.serializer import WireSerializer
|
|
28
|
+
from taskiq_llm_queue.config import Lane
|
|
29
|
+
from taskiq_llm_queue.reliability.breaker import BreakerSettings
|
|
30
|
+
from taskiq_llm_queue.reliability.claims import ClaimSettings
|
|
31
|
+
from taskiq_llm_queue.reliability.limiter import AdaptiveLimiter, LimiterSettings
|
|
32
|
+
from taskiq_llm_queue.routing_hooks import QueueHooks
|
|
33
|
+
from taskiq_llm_queue.tasks import WorkerRuntime, job_filter, register_tasks
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from aiobotocore.session import AioSession
|
|
37
|
+
from redis.asyncio import Redis
|
|
38
|
+
from taskiq import AsyncResultBackend
|
|
39
|
+
|
|
40
|
+
from taskiq_llm_queue.config import ModelConfig, QueueConfig
|
|
41
|
+
from taskiq_llm_queue.metrics import MetricsSink
|
|
42
|
+
from taskiq_llm_queue.protocols import Lease, ReceiveGate
|
|
43
|
+
from taskiq_llm_queue.reliability.claims import RedisClaimStore
|
|
44
|
+
from taskiq_llm_queue.routing_hooks import FailoverPolicy
|
|
45
|
+
|
|
46
|
+
logger = get_logger(__name__)
|
|
47
|
+
|
|
48
|
+
DEFAULT_WORKER_SLOTS = 50
|
|
49
|
+
DEFAULT_JOB_SLOTS = 4
|
|
50
|
+
JOBS_BROKER = "jobs"
|
|
51
|
+
# Queue key -> (priority, lanes it serves); rt first (A1), bulk last.
|
|
52
|
+
QUEUE_PRIORITY: Mapping[str, int] = {"rt": 0, "default": 1, "bulk": 2}
|
|
53
|
+
_QUEUE_LANE: Mapping[str, Lane] = {"rt": Lane.RT, "default": Lane.BG, "bulk": Lane.BULK}
|
|
54
|
+
_FLUSH_SECS = 10.0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class MetricsHooks(QueueHooks, BrokerHooks):
|
|
58
|
+
"""Client, worker and broker events reported to a `MetricsSink` (names: `metrics_names`).
|
|
59
|
+
|
|
60
|
+
The default `hooks` of `LLMQueue`; labels are only those in `metrics_names.LABELS`.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, sink: MetricsSink) -> None:
|
|
64
|
+
"""Report to `sink`."""
|
|
65
|
+
self.sink = sink
|
|
66
|
+
|
|
67
|
+
def on_call(self, model: str, lane: str, path: str, ok: bool, secs: float) -> None:
|
|
68
|
+
"""Count a finished client call by outcome."""
|
|
69
|
+
labels = {"model": model, "lane": lane, "path": path, "kind": "ok" if ok else "error"}
|
|
70
|
+
self.sink.incr(mn.CALLS_TOTAL, 1.0, labels)
|
|
71
|
+
|
|
72
|
+
def on_attempt(self, model: str, target: str, kind: str, secs: float) -> None:
|
|
73
|
+
"""Provider latency per attempt; throttles counted separately."""
|
|
74
|
+
self.sink.observe(mn.PROVIDER_SECONDS, secs, {"model": model, "target": target})
|
|
75
|
+
if kind == "throttle":
|
|
76
|
+
self.sink.incr(mn.THROTTLES_TOTAL, 1.0, {"model": model, "target": target})
|
|
77
|
+
|
|
78
|
+
def on_fallback(self, model: str, step: str) -> None:
|
|
79
|
+
"""Count calls served by a fallback step."""
|
|
80
|
+
self.sink.incr(mn.FALLBACK_TOTAL, 1.0, {"model": model, "step": step})
|
|
81
|
+
|
|
82
|
+
def on_queue_wait_ms(self, model: str, lane: str, wait_ms: int) -> None:
|
|
83
|
+
"""Time from enqueue to execution start."""
|
|
84
|
+
self.sink.observe(mn.QUEUE_WAIT_SECONDS, wait_ms / 1000, {"model": model, "lane": lane})
|
|
85
|
+
|
|
86
|
+
def on_sync_fallback(self, model: str) -> None:
|
|
87
|
+
"""Count sync calls that ran direct."""
|
|
88
|
+
self.sink.incr(mn.SYNC_FALLBACK_TOTAL, 1.0, {"model": model})
|
|
89
|
+
|
|
90
|
+
def on_release(self, queue: str, count: int, reason: str) -> None:
|
|
91
|
+
"""Count messages released because this pod didn't know the task (DS9)."""
|
|
92
|
+
if reason.startswith("unknown_task") or reason == "unsupported_envelope":
|
|
93
|
+
self.sink.incr(mn.RELEASED_UNKNOWN_TOTAL, float(count), {"queue": queue})
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def limiter_settings(config: QueueConfig) -> LimiterSettings:
|
|
97
|
+
"""`LimiterSettings` from `QueueConfig.limiter`; per-model starts from `limiter_initial`."""
|
|
98
|
+
tuning = config.limiter
|
|
99
|
+
initial = {alias: m.limiter_initial for alias, m in config.models.items()}
|
|
100
|
+
initial.update(tuning.initial)
|
|
101
|
+
return LimiterSettings(
|
|
102
|
+
initial=initial,
|
|
103
|
+
min_limit=tuning.min_limit,
|
|
104
|
+
max_limit=tuning.max_limit,
|
|
105
|
+
ramp_window=tuning.ramp_window,
|
|
106
|
+
increase_every=tuning.increase_every,
|
|
107
|
+
decrease_factor=tuning.decrease_factor,
|
|
108
|
+
pause_secs=tuning.pause_secs,
|
|
109
|
+
bg_share=tuning.bg_share,
|
|
110
|
+
utilisation=tuning.utilisation,
|
|
111
|
+
ramp_cap=tuning.ramp_cap,
|
|
112
|
+
lease_ttl_secs=tuning.lease_ttl_secs,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def breaker_settings(config: QueueConfig) -> BreakerSettings:
|
|
117
|
+
"""`BreakerSettings` from `QueueConfig.breaker`."""
|
|
118
|
+
b = config.breaker
|
|
119
|
+
return BreakerSettings(b.threshold, b.window_secs, b.cooldown_secs)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def claim_settings(config: QueueConfig) -> ClaimSettings:
|
|
123
|
+
"""`ClaimSettings` from the config: state TTL, worst-case execution, `max_failures`."""
|
|
124
|
+
return ClaimSettings(
|
|
125
|
+
state_ttl_secs=config.state_ttl_secs,
|
|
126
|
+
exec_max_secs=round(max(config.timeout_secs.values())),
|
|
127
|
+
max_failures=config.max_failures,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def queue_for_lane(model: ModelConfig, lane: Lane) -> str | None:
|
|
132
|
+
"""Queue name or URL a call in `lane` goes to: rt → `rt`, bulk → `bulk`, else `default`.
|
|
133
|
+
|
|
134
|
+
Falls back to whatever the model has (default, then rt), so a model with one queue works.
|
|
135
|
+
"""
|
|
136
|
+
order = {Lane.RT: ("rt", "default", "bulk"), Lane.BG: ("default", "rt", "bulk")}
|
|
137
|
+
for key in order.get(lane, ("bulk", "default", "rt")):
|
|
138
|
+
if key in model.queues:
|
|
139
|
+
return model.queues[key]
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def queue_name(value: str) -> str:
|
|
144
|
+
"""Routing name of a queue given as a name or URL (its last path segment)."""
|
|
145
|
+
return value.rstrip("/").rsplit("/", 1)[-1]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ChainGate:
|
|
149
|
+
"""`ReceiveGate` open while any target of a model has headroom and Valkey is up (DS13, DS18).
|
|
150
|
+
|
|
151
|
+
Wraps the limiter's per-target pause gates; while the failover policy says Valkey is down
|
|
152
|
+
it stays closed, so workers stop taking tasks they could not claim.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def __init__(
|
|
156
|
+
self, gates: list[ReceiveGate], redis_down: Callable[[], bool], *, retry_secs: float = 1.0
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Keep the target gates and the Valkey-down probe."""
|
|
159
|
+
self._gates = gates
|
|
160
|
+
self._redis_down = redis_down
|
|
161
|
+
self._retry_secs = retry_secs
|
|
162
|
+
|
|
163
|
+
def is_open(self) -> bool:
|
|
164
|
+
"""True when Valkey is up and at least one target is not paused."""
|
|
165
|
+
return not self._redis_down() and any(g.is_open() for g in self._gates)
|
|
166
|
+
|
|
167
|
+
async def wait_open(self) -> None:
|
|
168
|
+
"""Return once Valkey is up and one target gate opened."""
|
|
169
|
+
while True:
|
|
170
|
+
if self._redis_down():
|
|
171
|
+
await asyncio.sleep(self._retry_secs)
|
|
172
|
+
continue
|
|
173
|
+
waiters = [asyncio.ensure_future(g.wait_open()) for g in self._gates]
|
|
174
|
+
try:
|
|
175
|
+
await asyncio.wait(waiters, return_when=asyncio.FIRST_COMPLETED)
|
|
176
|
+
finally:
|
|
177
|
+
for waiter in waiters:
|
|
178
|
+
waiter.cancel()
|
|
179
|
+
if self.is_open():
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def receive_budget(
|
|
184
|
+
alias: str,
|
|
185
|
+
model: ModelConfig,
|
|
186
|
+
limiter: AdaptiveLimiter,
|
|
187
|
+
lanes: Mapping[str, Lane],
|
|
188
|
+
failover: FailoverPolicy | None,
|
|
189
|
+
) -> Callable[[SQSQueue], Any]:
|
|
190
|
+
"""DS4 budget for one model's queues: the best limiter headroom across its targets.
|
|
191
|
+
|
|
192
|
+
Returns 0 while Valkey is down or unreachable, so nothing is received that can't run.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
async def budget(queue: SQSQueue) -> int:
|
|
196
|
+
if failover is not None and failover.redis_down():
|
|
197
|
+
return 0
|
|
198
|
+
share = limiter.settings.share_for(lanes.get(queue.name, Lane.BG))
|
|
199
|
+
try: # every target in one pipelined round trip, on each poll
|
|
200
|
+
states = await limiter.states(alias, model.targets)
|
|
201
|
+
except RedisError as exc:
|
|
202
|
+
if failover is not None:
|
|
203
|
+
failover.report_redis_failure(exc)
|
|
204
|
+
return 0
|
|
205
|
+
return max((state.headroom(share) for state in states), default=0)
|
|
206
|
+
|
|
207
|
+
return budget
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass(slots=True)
|
|
211
|
+
class BrokerSet:
|
|
212
|
+
"""The brokers of one `LLMQueue`: per model alias (shared when queues are equal) and jobs."""
|
|
213
|
+
|
|
214
|
+
by_alias: dict[str, HybridSQSBroker] = field(default_factory=dict)
|
|
215
|
+
jobs: HybridSQSBroker | None = None
|
|
216
|
+
|
|
217
|
+
def all(self) -> list[HybridSQSBroker]:
|
|
218
|
+
"""Every distinct broker, models first."""
|
|
219
|
+
seen: list[HybridSQSBroker] = []
|
|
220
|
+
for broker in [*self.by_alias.values(), self.jobs]:
|
|
221
|
+
if broker is not None and all(broker is not b for b in seen):
|
|
222
|
+
seen.append(broker)
|
|
223
|
+
return seen
|
|
224
|
+
|
|
225
|
+
def slots(self, broker: HybridSQSBroker) -> int:
|
|
226
|
+
"""`max_async_tasks` for a broker's receiver (its scheduler capacity)."""
|
|
227
|
+
return broker.scheduler.max_inflight
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _scheduler_split(slots: int, queues: int) -> tuple[int, int]:
|
|
231
|
+
"""(`reserve_for_priority0`, `min_slots_for_lower`) for a multi-queue broker."""
|
|
232
|
+
if queues < 2:
|
|
233
|
+
return 0, 0
|
|
234
|
+
return slots // 5, min(2, slots // 4)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def build_brokers(
|
|
238
|
+
config: QueueConfig,
|
|
239
|
+
runtime: WorkerRuntime,
|
|
240
|
+
limiter: AdaptiveLimiter,
|
|
241
|
+
result_backend: AsyncResultBackend[Any],
|
|
242
|
+
*,
|
|
243
|
+
failover: FailoverPolicy | None = None,
|
|
244
|
+
hooks: BrokerHooks | None = None,
|
|
245
|
+
session: AioSession | None = None,
|
|
246
|
+
) -> BrokerSet:
|
|
247
|
+
"""One broker per distinct model queue set, plus a jobs broker when `job_queues` is set.
|
|
248
|
+
|
|
249
|
+
Every broker gets the three stable tasks, metadata labels (receipt handle for release),
|
|
250
|
+
the shared result backend, `runtime.on_lost` and the orjson `WireSerializer` (reads
|
|
251
|
+
stdlib-JSON messages from older pods and vice versa). `session` is the pod's one shared
|
|
252
|
+
aiobotocore session (credential chain resolved once). Nothing connects until `startup()`.
|
|
253
|
+
"""
|
|
254
|
+
brokers = BrokerSet()
|
|
255
|
+
by_queues: dict[tuple[str, ...], HybridSQSBroker] = {}
|
|
256
|
+
redis_down = failover.redis_down if failover is not None else (lambda: False)
|
|
257
|
+
common: dict[str, Any] = {
|
|
258
|
+
"endpoint_url": config.sqs_endpoint_url,
|
|
259
|
+
"region_name": config.aws_region,
|
|
260
|
+
"expose_message_metadata": True,
|
|
261
|
+
"hooks": hooks,
|
|
262
|
+
"on_lost": runtime.on_lost,
|
|
263
|
+
"release_running_on_shutdown": True,
|
|
264
|
+
"session": session,
|
|
265
|
+
}
|
|
266
|
+
for alias, model in config.models.items():
|
|
267
|
+
if not model.queues:
|
|
268
|
+
continue
|
|
269
|
+
key = tuple(sorted(model.queues.values()))
|
|
270
|
+
if (shared := by_queues.get(key)) is not None:
|
|
271
|
+
brokers.by_alias[alias] = shared
|
|
272
|
+
continue
|
|
273
|
+
gate = ChainGate([limiter.gate(alias, t) for t in model.targets], redis_down)
|
|
274
|
+
lanes = {queue_name(v): _QUEUE_LANE[k] for k, v in model.queues.items()}
|
|
275
|
+
queues = [
|
|
276
|
+
SQSQueue(
|
|
277
|
+
name=queue_name(value),
|
|
278
|
+
url=value if "://" in value else None,
|
|
279
|
+
priority=QUEUE_PRIORITY[kind],
|
|
280
|
+
wait_time_secs=config.sqs_wait_time_secs,
|
|
281
|
+
gate=gate,
|
|
282
|
+
)
|
|
283
|
+
for kind, value in sorted(model.queues.items(), key=lambda kv: QUEUE_PRIORITY[kv[0]])
|
|
284
|
+
]
|
|
285
|
+
slots = config.worker_slots.get(alias, DEFAULT_WORKER_SLOTS)
|
|
286
|
+
reserve, lower = _scheduler_split(slots, len(queues))
|
|
287
|
+
broker = HybridSQSBroker(
|
|
288
|
+
queues,
|
|
289
|
+
default_queue=queue_name(queue_for_lane(model, Lane.BG) or queues[0].name),
|
|
290
|
+
max_inflight=slots,
|
|
291
|
+
reserve_for_priority0=reserve,
|
|
292
|
+
min_slots_for_lower=lower,
|
|
293
|
+
receive_budget=receive_budget(alias, model, limiter, lanes, failover),
|
|
294
|
+
**common,
|
|
295
|
+
).with_result_backend(result_backend)
|
|
296
|
+
broker.with_serializer(WireSerializer())
|
|
297
|
+
register_tasks(broker, runtime)
|
|
298
|
+
by_queues[key] = brokers.by_alias[alias] = broker
|
|
299
|
+
job_queues = sorted(set(config.job_queues.values()))
|
|
300
|
+
if job_queues:
|
|
301
|
+
jobs = HybridSQSBroker(
|
|
302
|
+
[
|
|
303
|
+
SQSQueue(
|
|
304
|
+
name=queue_name(v),
|
|
305
|
+
url=v if "://" in v else None,
|
|
306
|
+
wait_time_secs=config.sqs_wait_time_secs,
|
|
307
|
+
)
|
|
308
|
+
for v in job_queues
|
|
309
|
+
],
|
|
310
|
+
max_inflight=config.worker_slots.get(JOBS_BROKER, DEFAULT_JOB_SLOTS),
|
|
311
|
+
message_filter=job_filter(runtime),
|
|
312
|
+
**common,
|
|
313
|
+
).with_result_backend(result_backend)
|
|
314
|
+
jobs.with_serializer(WireSerializer())
|
|
315
|
+
register_tasks(jobs, runtime)
|
|
316
|
+
brokers.jobs = jobs
|
|
317
|
+
return brokers
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class Workers:
|
|
321
|
+
"""In-process taskiq receivers for every broker and their drain.
|
|
322
|
+
|
|
323
|
+
`start` launches one `Receiver.listen` per broker (`ack_type=WHEN_SAVED`, `max_async_tasks`
|
|
324
|
+
= the broker's slots). `drain` is steps 1-4 of §7.4; claims are kept alive by `PodHeartbeat`.
|
|
325
|
+
"""
|
|
326
|
+
|
|
327
|
+
def __init__(self, brokers: BrokerSet, runtime: WorkerRuntime) -> None:
|
|
328
|
+
"""Keep the brokers and runtime; nothing starts here."""
|
|
329
|
+
self._brokers = brokers
|
|
330
|
+
self._runtime = runtime
|
|
331
|
+
self._finish = asyncio.Event()
|
|
332
|
+
self._receivers: list[Receiver] = []
|
|
333
|
+
self._listeners: list[asyncio.Task[None]] = []
|
|
334
|
+
|
|
335
|
+
def start(self) -> None:
|
|
336
|
+
"""Start receivers; brokers must already be started."""
|
|
337
|
+
self._finish = asyncio.Event()
|
|
338
|
+
for broker in self._brokers.all():
|
|
339
|
+
receiver = Receiver(
|
|
340
|
+
broker,
|
|
341
|
+
max_async_tasks=self._brokers.slots(broker),
|
|
342
|
+
ack_type=AcknowledgeType.WHEN_SAVED,
|
|
343
|
+
run_startup=False,
|
|
344
|
+
propagate_exceptions=False,
|
|
345
|
+
)
|
|
346
|
+
self._receivers.append(receiver)
|
|
347
|
+
self._listeners.append(
|
|
348
|
+
asyncio.create_task(receiver.listen(self._finish), name="llmq-receiver")
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
async def pause(self) -> None:
|
|
352
|
+
"""§7.4 step 1: stop receiving and release buffered, unstarted messages."""
|
|
353
|
+
await asyncio.gather(*(b.pause_receiving() for b in self._brokers.all()))
|
|
354
|
+
|
|
355
|
+
def resume(self) -> None:
|
|
356
|
+
"""Undo `pause`."""
|
|
357
|
+
for broker in self._brokers.all():
|
|
358
|
+
broker.resume_receiving()
|
|
359
|
+
|
|
360
|
+
async def drain(self, drain_secs: float) -> None:
|
|
361
|
+
"""§7.4 steps 1-4: pause, let running tasks finish, then hand leftovers back."""
|
|
362
|
+
await self.pause()
|
|
363
|
+
for receiver in self._receivers:
|
|
364
|
+
receiver.wait_tasks_timeout = drain_secs
|
|
365
|
+
self._finish.set()
|
|
366
|
+
if self._listeners:
|
|
367
|
+
_, pending = await asyncio.wait(self._listeners, timeout=drain_secs + _FLUSH_SECS)
|
|
368
|
+
for task in pending:
|
|
369
|
+
task.cancel()
|
|
370
|
+
await asyncio.gather(*self._listeners, return_exceptions=True)
|
|
371
|
+
leftovers = self._runtime.stop_running("drain")
|
|
372
|
+
if leftovers:
|
|
373
|
+
logger.info("drain_hand_back", running=len(leftovers))
|
|
374
|
+
await asyncio.wait(leftovers, timeout=_FLUSH_SECS)
|
|
375
|
+
self._listeners.clear()
|
|
376
|
+
self._receivers.clear()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@dataclass(frozen=True, slots=True)
|
|
380
|
+
class ClaimGroup:
|
|
381
|
+
"""Claims held under one worker identity: `ids()` lists them, `on_lost` hears lost ones.
|
|
382
|
+
|
|
383
|
+
A pod has two: its worker tasks (`pod_id`) and its DS17 direct calls (`direct:<pod_id>`).
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
worker: str
|
|
387
|
+
ids: Callable[[], Sequence[str]]
|
|
388
|
+
on_lost: Callable[[list[str]], None]
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class PodRegistry:
|
|
392
|
+
"""Live pods of one key prefix: a sorted set (`KeySpace.pods`) scored by expiry.
|
|
393
|
+
|
|
394
|
+
Each heartbeat re-adds this pod and drops expired ones; the count is the `live_pods` that
|
|
395
|
+
splits the last known limit into a per-pod local cap while Valkey is down (DS18).
|
|
396
|
+
"""
|
|
397
|
+
|
|
398
|
+
def __init__(
|
|
399
|
+
self,
|
|
400
|
+
client: Redis,
|
|
401
|
+
key: str,
|
|
402
|
+
pod_id: str,
|
|
403
|
+
*,
|
|
404
|
+
ttl_secs: float,
|
|
405
|
+
clock: Callable[[], float] = time.time,
|
|
406
|
+
) -> None:
|
|
407
|
+
"""Keep the client, the set's key, this pod's id and how long an entry lives."""
|
|
408
|
+
self._client = client
|
|
409
|
+
self._key = key
|
|
410
|
+
self._pod = pod_id
|
|
411
|
+
self._ttl = ttl_secs
|
|
412
|
+
self._clock = clock
|
|
413
|
+
|
|
414
|
+
async def beat(self) -> int:
|
|
415
|
+
"""Mark this pod alive and return how many pods are."""
|
|
416
|
+
now = self._clock()
|
|
417
|
+
async with self._client.pipeline(transaction=True) as pipe:
|
|
418
|
+
pipe.zadd(self._key, {self._pod: now + self._ttl})
|
|
419
|
+
pipe.zremrangebyscore(self._key, "-inf", now)
|
|
420
|
+
pipe.zcard(self._key)
|
|
421
|
+
pipe.expire(self._key, math.ceil(2 * self._ttl))
|
|
422
|
+
_, _, count, _ = await pipe.execute()
|
|
423
|
+
return int(count)
|
|
424
|
+
|
|
425
|
+
async def leave(self) -> None:
|
|
426
|
+
"""Remove this pod (clean shutdown); expiry covers a crash."""
|
|
427
|
+
await self._client.zrem(self._key, self._pod)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class PodHeartbeat:
|
|
431
|
+
"""One loop per pod, in every mode: claims, leases and the live-pod count (DS3, DS6, DS11).
|
|
432
|
+
|
|
433
|
+
Covers worker tasks and DS17 direct claims, and renews every lease of the worker, direct
|
|
434
|
+
and sync paths (one shared set). A Valkey blip is logged; the loop never stops.
|
|
435
|
+
"""
|
|
436
|
+
|
|
437
|
+
def __init__(
|
|
438
|
+
self,
|
|
439
|
+
*,
|
|
440
|
+
claims: RedisClaimStore | None,
|
|
441
|
+
limiter: AdaptiveLimiter | None,
|
|
442
|
+
leases: set[Lease],
|
|
443
|
+
groups: Sequence[ClaimGroup],
|
|
444
|
+
interval_secs: float,
|
|
445
|
+
pods: PodRegistry | None = None,
|
|
446
|
+
on_live_pods: Callable[[int], None] | None = None,
|
|
447
|
+
) -> None:
|
|
448
|
+
"""Keep what to refresh; nothing starts until `run()`."""
|
|
449
|
+
self._claims = claims
|
|
450
|
+
self._limiter = limiter
|
|
451
|
+
self._leases = leases
|
|
452
|
+
self._groups = list(groups)
|
|
453
|
+
self.interval_secs = interval_secs
|
|
454
|
+
self._pods = pods
|
|
455
|
+
self._on_live_pods = on_live_pods
|
|
456
|
+
|
|
457
|
+
async def run(self) -> None:
|
|
458
|
+
"""Beat every `interval_secs` until cancelled."""
|
|
459
|
+
while True:
|
|
460
|
+
await asyncio.sleep(self.interval_secs)
|
|
461
|
+
await self.beat_once()
|
|
462
|
+
|
|
463
|
+
async def beat_once(self) -> None:
|
|
464
|
+
"""Refresh claims, renew leases and the pod registry once; errors are logged."""
|
|
465
|
+
for group in self._groups:
|
|
466
|
+
await self._beat_claims(group)
|
|
467
|
+
leases = self._lease_snapshot()
|
|
468
|
+
if self._limiter is not None and leases:
|
|
469
|
+
try:
|
|
470
|
+
await self._limiter.renew(leases)
|
|
471
|
+
except Exception as exc:
|
|
472
|
+
logger.warning(
|
|
473
|
+
"lease_renew_failed", leases=len(leases), error_type=type(exc).__name__
|
|
474
|
+
)
|
|
475
|
+
if self._pods is not None:
|
|
476
|
+
try:
|
|
477
|
+
live = await self._pods.beat()
|
|
478
|
+
except Exception as exc:
|
|
479
|
+
logger.warning("pod_registry_failed", error_type=type(exc).__name__)
|
|
480
|
+
else:
|
|
481
|
+
if self._on_live_pods is not None:
|
|
482
|
+
safe_call(self._on_live_pods, live)
|
|
483
|
+
|
|
484
|
+
async def _beat_claims(self, group: ClaimGroup) -> None:
|
|
485
|
+
ids = list(dict.fromkeys(group.ids()))
|
|
486
|
+
if self._claims is None or not ids:
|
|
487
|
+
return
|
|
488
|
+
try:
|
|
489
|
+
lost = await self._claims.heartbeat_owned(ids, worker=group.worker)
|
|
490
|
+
except Exception as exc: # keep beating; a blip must not stop the loop
|
|
491
|
+
logger.warning("claim_heartbeat_failed", error_type=type(exc).__name__)
|
|
492
|
+
return
|
|
493
|
+
if lost:
|
|
494
|
+
logger.warning("claims_lost", worker=group.worker, running=len(lost))
|
|
495
|
+
safe_call(group.on_lost, lost)
|
|
496
|
+
|
|
497
|
+
def _lease_snapshot(self) -> list[Lease]:
|
|
498
|
+
"""Copy the shared lease set; sync callers add to it from their own thread."""
|
|
499
|
+
for _ in range(3):
|
|
500
|
+
try:
|
|
501
|
+
return list(self._leases)
|
|
502
|
+
except RuntimeError: # changed size during iteration
|
|
503
|
+
continue
|
|
504
|
+
return []
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def safe_call(callback: Callable[..., object], *args: object) -> None:
|
|
508
|
+
"""Run a heartbeat callback, logging (never raising) whatever it throws."""
|
|
509
|
+
try:
|
|
510
|
+
callback(*args)
|
|
511
|
+
except Exception:
|
|
512
|
+
logger.exception("heartbeat_callback_failed")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Metric sinks: the `MetricsSink` protocol, CloudWatch EMF lines and an optional Prometheus one.
|
|
2
|
+
|
|
3
|
+
Production emits EMF through the log pipeline (D89); Prometheus needs the `[metrics]` extra and
|
|
4
|
+
is imported only when `PrometheusMetrics` is built. Names and labels come from `metrics_names`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from typing import Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
from taskiq_llm_queue.metrics.emf import EmfMetrics
|
|
13
|
+
from taskiq_llm_queue.metrics.prometheus import PrometheusMetrics
|
|
14
|
+
|
|
15
|
+
Labels = Mapping[str, str]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@runtime_checkable
|
|
19
|
+
class MetricsSink(Protocol):
|
|
20
|
+
"""Where the package reports counters, observations and gauges; labels from `LABELS` only."""
|
|
21
|
+
|
|
22
|
+
def incr(self, name: str, value: float = 1.0, labels: Labels | None = None) -> None:
|
|
23
|
+
"""Add `value` to a counter."""
|
|
24
|
+
|
|
25
|
+
def observe(self, name: str, value: float, labels: Labels | None = None) -> None:
|
|
26
|
+
"""Record one observation (latency in seconds, sizes) for a distribution."""
|
|
27
|
+
|
|
28
|
+
def gauge(self, name: str, value: float, labels: Labels | None = None) -> None:
|
|
29
|
+
"""Set a gauge to `value`."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NullMetrics:
|
|
33
|
+
"""`MetricsSink` that drops everything; the default when an app wires no sink."""
|
|
34
|
+
|
|
35
|
+
def incr(self, name: str, value: float = 1.0, labels: Labels | None = None) -> None:
|
|
36
|
+
"""Drop the counter update."""
|
|
37
|
+
|
|
38
|
+
def observe(self, name: str, value: float, labels: Labels | None = None) -> None:
|
|
39
|
+
"""Drop the observation."""
|
|
40
|
+
|
|
41
|
+
def gauge(self, name: str, value: float, labels: Labels | None = None) -> None:
|
|
42
|
+
"""Drop the gauge update."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = ["EmfMetrics", "Labels", "MetricsSink", "NullMetrics", "PrometheusMetrics"]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""CloudWatch Embedded Metric Format (EMF) emitter: one JSON line per metric update (D89).
|
|
2
|
+
|
|
3
|
+
The lines go to a logger (default) or a text stream; the existing log shipper forwards them and
|
|
4
|
+
CloudWatch extracts the metrics, so no in-cluster Prometheus is needed. Labels outside
|
|
5
|
+
`metrics_names.LABELS` are dropped (cardinality and privacy), with one warning per label name.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable, Mapping
|
|
14
|
+
from typing import TextIO
|
|
15
|
+
|
|
16
|
+
import orjson
|
|
17
|
+
|
|
18
|
+
from taskiq_llm_queue._logging import get_logger
|
|
19
|
+
from taskiq_llm_queue.metrics_names import LABELS
|
|
20
|
+
|
|
21
|
+
logger = get_logger(__name__)
|
|
22
|
+
DEFAULT_NAMESPACE = "LLMQueue"
|
|
23
|
+
_EMF_LOGGER = "taskiq_llm_queue.metrics.emf.lines"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EmfMetrics:
|
|
27
|
+
"""`MetricsSink` writing EMF JSON lines with namespace, dimensions and a millisecond stamp.
|
|
28
|
+
|
|
29
|
+
`static_dimensions` (e.g. `{"env": "prod"}`) are added to every line and are not limited
|
|
30
|
+
to `LABELS`, since the app controls them. Thread-safe; never raises on a call path.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
namespace: str = DEFAULT_NAMESPACE,
|
|
36
|
+
*,
|
|
37
|
+
stream: TextIO | None = None,
|
|
38
|
+
logger: logging.Logger | None = None,
|
|
39
|
+
static_dimensions: Mapping[str, str] | None = None,
|
|
40
|
+
clock: Callable[[], float] = time.time,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Pick the output: `stream` if given, else `logger` (default a package logger at INFO)."""
|
|
43
|
+
self.namespace = namespace
|
|
44
|
+
self._stream = stream
|
|
45
|
+
self._logger = logger or logging.getLogger(_EMF_LOGGER)
|
|
46
|
+
self._static = dict(static_dimensions or {})
|
|
47
|
+
self._clock = clock
|
|
48
|
+
self._lock = threading.Lock()
|
|
49
|
+
self._warned: set[str] = set()
|
|
50
|
+
|
|
51
|
+
def incr(self, name: str, value: float = 1.0, labels: Mapping[str, str] | None = None) -> None:
|
|
52
|
+
"""Emit a counter increment (unit `Count`)."""
|
|
53
|
+
self._emit(name, value, "Count", labels)
|
|
54
|
+
|
|
55
|
+
def observe(self, name: str, value: float, labels: Mapping[str, str] | None = None) -> None:
|
|
56
|
+
"""Emit one observation; `*_seconds` names get unit `Seconds`."""
|
|
57
|
+
self._emit(name, value, "Seconds" if name.endswith("_seconds") else "None", labels)
|
|
58
|
+
|
|
59
|
+
def gauge(self, name: str, value: float, labels: Mapping[str, str] | None = None) -> None:
|
|
60
|
+
"""Emit a gauge value (unit `None`)."""
|
|
61
|
+
self._emit(name, value, "None", labels)
|
|
62
|
+
|
|
63
|
+
def render(
|
|
64
|
+
self, name: str, value: float, unit: str, labels: Mapping[str, str] | None = None
|
|
65
|
+
) -> str:
|
|
66
|
+
"""The EMF JSON line for one metric value (exposed for tests and custom shippers)."""
|
|
67
|
+
dims = dict(self._static)
|
|
68
|
+
for key, val in (labels or {}).items():
|
|
69
|
+
if key in LABELS:
|
|
70
|
+
dims[key] = str(val)
|
|
71
|
+
elif key not in self._warned:
|
|
72
|
+
self._warned.add(key)
|
|
73
|
+
logger.warning("emf_label_dropped", label=key)
|
|
74
|
+
doc: dict[str, object] = {
|
|
75
|
+
"_aws": {
|
|
76
|
+
"Timestamp": int(self._clock() * 1000),
|
|
77
|
+
"CloudWatchMetrics": [
|
|
78
|
+
{
|
|
79
|
+
"Namespace": self.namespace,
|
|
80
|
+
"Dimensions": [sorted(dims)],
|
|
81
|
+
"Metrics": [{"Name": name, "Unit": unit}],
|
|
82
|
+
}
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
**dims,
|
|
86
|
+
name: value,
|
|
87
|
+
}
|
|
88
|
+
return orjson.dumps(doc).decode()
|
|
89
|
+
|
|
90
|
+
def _emit(self, name: str, value: float, unit: str, labels: Mapping[str, str] | None) -> None:
|
|
91
|
+
try:
|
|
92
|
+
line = self.render(name, value, unit, labels)
|
|
93
|
+
if self._stream is None:
|
|
94
|
+
self._logger.info(line)
|
|
95
|
+
return
|
|
96
|
+
with self._lock:
|
|
97
|
+
self._stream.write(line + "\n")
|
|
98
|
+
self._stream.flush()
|
|
99
|
+
except Exception: # metrics must never break a call
|
|
100
|
+
logger.exception("emf_emit_failed", metric=name)
|