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
@@ -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)