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
|
+
"""JSON result backend on Redis/Valkey: taskiq's `AsyncResultBackend` plus `ResultStore`.
|
|
2
|
+
|
|
3
|
+
Workers store results through taskiq (`set_result`) or `put`; callers read them back through
|
|
4
|
+
taskiq (`get_result`, deleted on read) or in batches through `take_many`, which the per-pod
|
|
5
|
+
`ResultDispatcher` calls once per interval. Results are JSON only, never pickle.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
import orjson
|
|
14
|
+
from redis.asyncio import BlockingConnectionPool, Redis
|
|
15
|
+
from redis.asyncio.cluster import RedisCluster
|
|
16
|
+
from redis.asyncio.connection import Connection, SSLConnection
|
|
17
|
+
from taskiq import AsyncResultBackend, TaskiqResult
|
|
18
|
+
from taskiq.exceptions import ResultGetError
|
|
19
|
+
|
|
20
|
+
from taskiq_llm_queue._logging import get_logger
|
|
21
|
+
from taskiq_llm_queue.envelope import LLMReply
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from taskiq_llm_queue.config import QueueConfig
|
|
25
|
+
from taskiq_llm_queue.keys import KeySpace
|
|
26
|
+
|
|
27
|
+
logger = get_logger(__name__)
|
|
28
|
+
|
|
29
|
+
DEFAULT_TAKE_BATCH = 1000 # keys per MGET; 10K waiters on one pod -> 10 MGETs per interval
|
|
30
|
+
# taskiq's `TaskiqResult` JSON around one embedded reply, so old readers and taskiq's
|
|
31
|
+
# `get_result` still see the usual shape. Serializing the reply once with orjson (measured, 4 KB
|
|
32
|
+
# reply): encode 14.8 -> 6.0 us English, 40 -> 6.9 us Hindi; decode 68 -> 15 us Hindi.
|
|
33
|
+
_RESULT_HEAD = b'{"is_err":false,"log":null,"return_value":'
|
|
34
|
+
_RESULT_TAIL = b',"execution_time":0.0,"labels":{},"error":null}'
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LLMQResultBackend(AsyncResultBackend[Any]):
|
|
38
|
+
"""Stores task results as JSON under `KeySpace.result(task_id)` with a TTL.
|
|
39
|
+
|
|
40
|
+
Several brokers may share one instance (finding A6): `startup` counts users and
|
|
41
|
+
`shutdown` closes the pool only when the last of them shuts down. An injected client is
|
|
42
|
+
never closed here; its creator owns it. `aclose` force-closes an owned pool.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
keys: KeySpace,
|
|
48
|
+
*,
|
|
49
|
+
redis: Redis | None = None,
|
|
50
|
+
host: str = "localhost",
|
|
51
|
+
port: int = 6379,
|
|
52
|
+
password: str | None = None,
|
|
53
|
+
ssl: bool = False,
|
|
54
|
+
max_connections: int = 32,
|
|
55
|
+
pool_timeout_secs: float = 5.0,
|
|
56
|
+
result_ttl_secs: int = 300,
|
|
57
|
+
delete_on_read: bool = True,
|
|
58
|
+
take_batch: int = DEFAULT_TAKE_BATCH,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Keep settings only; the owned client is created lazily on first use (no I/O here).
|
|
61
|
+
|
|
62
|
+
`delete_on_read` makes taskiq's `get_result` use `GETDEL`. `take_batch`
|
|
63
|
+
caps the keys sent in one `MGET` by `take_many`.
|
|
64
|
+
"""
|
|
65
|
+
if take_batch < 1:
|
|
66
|
+
raise ValueError("take_batch must be >= 1")
|
|
67
|
+
self.keys = keys
|
|
68
|
+
self.result_ttl_secs = result_ttl_secs
|
|
69
|
+
self.delete_on_read = delete_on_read
|
|
70
|
+
self.take_batch = take_batch
|
|
71
|
+
self._redis = redis
|
|
72
|
+
self._owns_client = redis is None
|
|
73
|
+
self._conn_kwargs: dict[str, Any] = {
|
|
74
|
+
"connection_class": SSLConnection if ssl else Connection,
|
|
75
|
+
"host": host,
|
|
76
|
+
"port": port,
|
|
77
|
+
"password": password,
|
|
78
|
+
"max_connections": max_connections,
|
|
79
|
+
"timeout": pool_timeout_secs,
|
|
80
|
+
}
|
|
81
|
+
self._users = 0
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_config(cls, config: QueueConfig, *, redis: Redis | None = None) -> LLMQResultBackend:
|
|
85
|
+
"""Build a backend from `QueueConfig` (prefix, Redis endpoint, pool size, result TTL).
|
|
86
|
+
|
|
87
|
+
Without `redis`, the client comes from `config.create_redis` (URL, credentials, TLS,
|
|
88
|
+
retries) and is owned, so the last `shutdown` closes it.
|
|
89
|
+
"""
|
|
90
|
+
from taskiq_llm_queue.config import create_redis, redis_pool_size
|
|
91
|
+
from taskiq_llm_queue.keys import KeySpace
|
|
92
|
+
|
|
93
|
+
backend = cls(
|
|
94
|
+
KeySpace(config.key_prefix),
|
|
95
|
+
redis=redis or create_redis(config),
|
|
96
|
+
max_connections=redis_pool_size(config),
|
|
97
|
+
result_ttl_secs=config.result_ttl_secs,
|
|
98
|
+
)
|
|
99
|
+
backend._owns_client = redis is None
|
|
100
|
+
return backend
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def redis(self) -> Redis:
|
|
104
|
+
"""The client in use, created on first access with a bounded blocking pool."""
|
|
105
|
+
if self._redis is None:
|
|
106
|
+
pool = BlockingConnectionPool(**self._conn_kwargs)
|
|
107
|
+
self._redis = Redis(connection_pool=pool)
|
|
108
|
+
return self._redis
|
|
109
|
+
|
|
110
|
+
async def startup(self) -> None:
|
|
111
|
+
"""Register one user (e.g. one broker) and make sure the client exists."""
|
|
112
|
+
self._users += 1
|
|
113
|
+
_ = self.redis
|
|
114
|
+
|
|
115
|
+
async def shutdown(self) -> None:
|
|
116
|
+
"""Drop one user; close the owned pool once no user is left. Safe to call repeatedly."""
|
|
117
|
+
self._users = max(0, self._users - 1)
|
|
118
|
+
if self._users == 0:
|
|
119
|
+
await self.aclose()
|
|
120
|
+
|
|
121
|
+
async def aclose(self) -> None:
|
|
122
|
+
"""Close the owned client and pool now, whoever still uses them; idempotent."""
|
|
123
|
+
if self._owns_client and self._redis is not None:
|
|
124
|
+
client, self._redis = self._redis, None
|
|
125
|
+
await client.aclose()
|
|
126
|
+
await client.connection_pool.disconnect()
|
|
127
|
+
|
|
128
|
+
async def set_result(self, task_id: str, result: TaskiqResult[Any]) -> None:
|
|
129
|
+
"""Store a taskiq result as JSON with the configured TTL."""
|
|
130
|
+
await self.redis.set(
|
|
131
|
+
self.keys.result(task_id), result.model_dump_json().encode(), ex=self.result_ttl_secs
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
async def is_result_ready(self, task_id: str) -> bool:
|
|
135
|
+
"""True when a result for `task_id` is stored and not yet taken."""
|
|
136
|
+
return bool(await self.redis.exists(self.keys.result(task_id)))
|
|
137
|
+
|
|
138
|
+
async def get_result(self, task_id: str, with_logs: bool = False) -> TaskiqResult[Any]:
|
|
139
|
+
"""Read one result (`GETDEL` when `delete_on_read`); raises `ResultGetError` if absent."""
|
|
140
|
+
key = self.keys.result(task_id)
|
|
141
|
+
raw = await (self.redis.getdel(key) if self.delete_on_read else self.redis.get(key))
|
|
142
|
+
if raw is None:
|
|
143
|
+
raise ResultGetError
|
|
144
|
+
return TaskiqResult[Any].model_validate_json(raw)
|
|
145
|
+
|
|
146
|
+
async def put(self, reply: LLMReply, *, ttl_secs: int) -> None:
|
|
147
|
+
"""Store an `LLMReply` under its `request_id` (the task id) in taskiq's result format."""
|
|
148
|
+
await self.redis.set(self.keys.result(reply.request_id), encode_reply(reply), ex=ttl_secs)
|
|
149
|
+
|
|
150
|
+
def uses(self, client: object) -> bool:
|
|
151
|
+
"""True when results are written through `client` (so they can share its pipeline)."""
|
|
152
|
+
return self._redis is client
|
|
153
|
+
|
|
154
|
+
def queue_put(self, pipe: Any, reply: LLMReply, *, ttl_secs: int) -> None:
|
|
155
|
+
"""Queue `put`'s write on a caller's pipeline (same key, bytes and TTL)."""
|
|
156
|
+
pipe.set(self.keys.result(reply.request_id), encode_reply(reply), ex=ttl_secs)
|
|
157
|
+
|
|
158
|
+
async def take_many(self, task_ids: Sequence[str]) -> dict[str, LLMReply]:
|
|
159
|
+
"""Fetch and delete whichever results are ready, in one round trip per batch.
|
|
160
|
+
|
|
161
|
+
Each read deletes exactly what it saw, so a result written in between is never lost:
|
|
162
|
+
`MGET`+`DEL` in MULTI/EXEC, or on a cluster client (keys in many slots) one `GETDEL`
|
|
163
|
+
per key in a non-transactional pipeline that redis-py splits by node.
|
|
164
|
+
"""
|
|
165
|
+
ready: dict[str, LLMReply] = {}
|
|
166
|
+
cluster = isinstance(self.redis, RedisCluster)
|
|
167
|
+
for start in range(0, len(task_ids), self.take_batch):
|
|
168
|
+
ids = list(task_ids[start : start + self.take_batch])
|
|
169
|
+
keys = [self.keys.result(task_id) for task_id in ids]
|
|
170
|
+
async with self.redis.pipeline(transaction=not cluster) as pipe:
|
|
171
|
+
if cluster:
|
|
172
|
+
for key in keys:
|
|
173
|
+
pipe.getdel(key)
|
|
174
|
+
values = await pipe.execute()
|
|
175
|
+
else:
|
|
176
|
+
pipe.mget(keys)
|
|
177
|
+
pipe.delete(*keys)
|
|
178
|
+
values, _ = await pipe.execute()
|
|
179
|
+
for task_id, raw in zip(ids, values, strict=True):
|
|
180
|
+
if raw is not None:
|
|
181
|
+
ready[task_id] = decode_reply(task_id, raw)
|
|
182
|
+
return ready
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def encode_reply(reply: LLMReply) -> bytes:
|
|
186
|
+
"""`TaskiqResult` JSON (`is_err=false`) for a reply, serializing the reply only once."""
|
|
187
|
+
try:
|
|
188
|
+
body = orjson.dumps(reply.model_dump(mode="json"))
|
|
189
|
+
except TypeError: # orjson refuses e.g. ints beyond 64 bits; pydantic does not
|
|
190
|
+
body = reply.model_dump_json().encode()
|
|
191
|
+
return _RESULT_HEAD + body + _RESULT_TAIL
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def decode_reply(task_id: str, raw: bytes | str) -> LLMReply:
|
|
195
|
+
"""Turn stored taskiq-result JSON into an `LLMReply`, never raising for bad content.
|
|
196
|
+
|
|
197
|
+
A task that raised, or a value that is not an `LLMReply`, becomes `ok=False` with an
|
|
198
|
+
`error` dict, so the waiting caller is always released with a value.
|
|
199
|
+
"""
|
|
200
|
+
try: # one JSON pass for the common case: a stored reply, not a task error
|
|
201
|
+
data = orjson.loads(raw)
|
|
202
|
+
if isinstance(data, dict) and data.get("is_err") is False:
|
|
203
|
+
return LLMReply.model_validate(data["return_value"])
|
|
204
|
+
except Exception:
|
|
205
|
+
pass # not a reply at all: the full taskiq decode below reports it
|
|
206
|
+
try:
|
|
207
|
+
result = TaskiqResult[Any].model_validate_json(raw)
|
|
208
|
+
if result.is_err:
|
|
209
|
+
error = result.error
|
|
210
|
+
return LLMReply(
|
|
211
|
+
request_id=task_id,
|
|
212
|
+
ok=False,
|
|
213
|
+
error={
|
|
214
|
+
"type": type(error).__name__ if error else "task_error",
|
|
215
|
+
"message": str(error) if error else "",
|
|
216
|
+
"retryable": False,
|
|
217
|
+
},
|
|
218
|
+
)
|
|
219
|
+
return LLMReply.model_validate(result.return_value)
|
|
220
|
+
except Exception as exc: # any bad payload must still release the waiter
|
|
221
|
+
logger.warning("result_undecodable", task_id=task_id, error_type=type(exc).__name__)
|
|
222
|
+
return LLMReply(
|
|
223
|
+
request_id=task_id,
|
|
224
|
+
ok=False,
|
|
225
|
+
error={"type": "invalid_result", "message": type(exc).__name__, "retryable": False},
|
|
226
|
+
)
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Per-pod result dispatcher: one batched read resolves every waiting caller.
|
|
2
|
+
|
|
3
|
+
Callers `await dispatcher.wait(task_id, timeout_secs, lane)`; a background loop polls all
|
|
4
|
+
pending ids with one `ResultStore.take_many` per interval instead of one poll per waiter, and
|
|
5
|
+
an optional pub/sub message on `KeySpace.done_channel(pod)` (carrying the task id) wakes it at
|
|
6
|
+
once to take just the announced ids; the interval sweep stays as the safety net.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextlib
|
|
13
|
+
import heapq
|
|
14
|
+
import math
|
|
15
|
+
import time
|
|
16
|
+
from collections import Counter
|
|
17
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from taskiq_llm_queue._logging import get_logger
|
|
22
|
+
from taskiq_llm_queue.config import Lane
|
|
23
|
+
from taskiq_llm_queue.errors import LLMQueueTimeout, QueueUnavailable
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from redis.asyncio import Redis
|
|
27
|
+
|
|
28
|
+
from taskiq_llm_queue.envelope import LLMReply
|
|
29
|
+
from taskiq_llm_queue.keys import KeySpace
|
|
30
|
+
from taskiq_llm_queue.protocols import ResultStore
|
|
31
|
+
|
|
32
|
+
logger = get_logger(__name__)
|
|
33
|
+
|
|
34
|
+
# Poll interval per lane; the loop polls at the fastest interval among pending waiters.
|
|
35
|
+
DEFAULT_INTERVALS_MS: Mapping[Lane, int] = {Lane.RT: 100, Lane.BG: 500, Lane.BULK: 2000}
|
|
36
|
+
_PUBSUB_RETRY_SECS = 1.0
|
|
37
|
+
# Rebuild the deadline heap once it holds this many entries and more stale than live ones.
|
|
38
|
+
_COMPACT_MIN_ENTRIES = 64
|
|
39
|
+
# A new waiter wakes the loop only if it needs a poll this much before the planned wake-up.
|
|
40
|
+
_WAKE_SLACK_SECS = 0.001
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ResultsUnavailable(QueueUnavailable):
|
|
44
|
+
"""Result reads have failed for `abandon_after_secs`: the waiter should serve itself.
|
|
45
|
+
|
|
46
|
+
Raised to waiting callers so a queued call can run `direct` (local cap) instead of waiting
|
|
47
|
+
to its deadline while Valkey is gone (S21).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(eq=False, slots=True)
|
|
52
|
+
class _Waiter:
|
|
53
|
+
"""One caller waiting for one task id; `seq` tells a stale heap entry from a live one.
|
|
54
|
+
|
|
55
|
+
Heap entries hold only `(deadline, seq, task_id)`, never the waiter: a resolved waiter's
|
|
56
|
+
future (and the reply in it) must be collectable as soon as the caller has it.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
future: asyncio.Future[LLMReply]
|
|
60
|
+
lane: Lane
|
|
61
|
+
deadline: float = field(default=0.0)
|
|
62
|
+
seq: int = 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ResultDispatcher:
|
|
66
|
+
"""Resolves all of one pod's waiting callers from one batched result read per interval.
|
|
67
|
+
|
|
68
|
+
Used by the queued clients after they kiq a task. `clock` and `sleep` are injectable so
|
|
69
|
+
tests can run timeouts without real waiting. Pub/sub wake-up is on when both `redis` and
|
|
70
|
+
`channel` are given; results are still found by polling if a message is lost.
|
|
71
|
+
`on_error` hears every failed read (poll or pub/sub) and `on_recovered` the first good poll
|
|
72
|
+
after one, so a send-only pod notices a Valkey outage (S21) without a call of its own. With
|
|
73
|
+
`abandon_after_secs`, waiters get `ResultsUnavailable` once reads have failed that long.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
store: ResultStore,
|
|
79
|
+
*,
|
|
80
|
+
redis: Redis | None = None,
|
|
81
|
+
channel: str | None = None,
|
|
82
|
+
intervals_ms: Mapping[Lane, int] | None = None,
|
|
83
|
+
clock: Callable[[], float] = time.monotonic,
|
|
84
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
85
|
+
on_error: Callable[[BaseException], object] | None = None,
|
|
86
|
+
on_recovered: Callable[[], object] | None = None,
|
|
87
|
+
abandon_after_secs: float | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Keep collaborators only; nothing starts until `start` or the first `wait`."""
|
|
90
|
+
self._store = store
|
|
91
|
+
self._on_error = on_error
|
|
92
|
+
self._on_recovered = on_recovered
|
|
93
|
+
self._abandon_after_secs = abandon_after_secs
|
|
94
|
+
self._failing_since: float | None = None # first failed read of the current outage
|
|
95
|
+
self._redis = redis
|
|
96
|
+
self._channel = channel
|
|
97
|
+
self._intervals = {**DEFAULT_INTERVALS_MS, **(intervals_ms or {})}
|
|
98
|
+
self._clock = clock
|
|
99
|
+
self._sleep = sleep
|
|
100
|
+
self._waiters: dict[str, _Waiter] = {}
|
|
101
|
+
self._lanes: Counter[Lane] = Counter()
|
|
102
|
+
self._deadlines: list[tuple[float, int, str]] = []
|
|
103
|
+
self._seq = 0
|
|
104
|
+
self._wake = asyncio.Event() # poll now
|
|
105
|
+
self._work = asyncio.Event() # first waiter arrived while idle
|
|
106
|
+
self._wake_at = math.inf # clock time the current nap ends; inf while idle
|
|
107
|
+
self._hinted: set[str] = set() # ids announced on the done channel since the last take
|
|
108
|
+
self._next_sweep: float | None = None # when the next take over every waiter is due
|
|
109
|
+
self._loop_task: asyncio.Task[None] | None = None
|
|
110
|
+
self._pubsub_task: asyncio.Task[None] | None = None
|
|
111
|
+
self.polls = 0 # take_many calls made; read by tests and metrics
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def pending(self) -> int:
|
|
115
|
+
"""Number of callers currently waiting."""
|
|
116
|
+
return len(self._waiters)
|
|
117
|
+
|
|
118
|
+
async def start(self) -> None:
|
|
119
|
+
"""Start the poll loop (and the pub/sub listener if configured); idempotent."""
|
|
120
|
+
if self._loop_task is not None:
|
|
121
|
+
return
|
|
122
|
+
self._wake, self._work = asyncio.Event(), asyncio.Event() # bind to the running loop
|
|
123
|
+
self._loop_task = asyncio.create_task(self._run(), name="llmq-result-dispatcher")
|
|
124
|
+
self._loop_task.add_done_callback(self._on_loop_exit)
|
|
125
|
+
if self._redis is not None and self._channel is not None:
|
|
126
|
+
self._pubsub_task = asyncio.create_task(
|
|
127
|
+
self._listen(self._redis, self._channel), name="llmq-result-wakeup"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
async def stop(self) -> None:
|
|
131
|
+
"""Stop the background tasks and fail any remaining waiters; idempotent."""
|
|
132
|
+
tasks = [t for t in (self._loop_task, self._pubsub_task) if t is not None]
|
|
133
|
+
self._loop_task = self._pubsub_task = None
|
|
134
|
+
for task in tasks:
|
|
135
|
+
task.cancel()
|
|
136
|
+
for task in tasks:
|
|
137
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
138
|
+
await task
|
|
139
|
+
self._fail_all(QueueUnavailable("result dispatcher stopped"))
|
|
140
|
+
|
|
141
|
+
async def wait(self, task_id: str, timeout_secs: float, lane: Lane = Lane.BG) -> LLMReply:
|
|
142
|
+
"""Wait for the result of `task_id`; raises `LLMQueueTimeout` after `timeout_secs`.
|
|
143
|
+
|
|
144
|
+
Cancelling the calling coroutine removes the waiter. One waiter per task id; a second
|
|
145
|
+
concurrent `wait` on the same id is a programming error (`ValueError`).
|
|
146
|
+
"""
|
|
147
|
+
if task_id in self._waiters:
|
|
148
|
+
raise ValueError(f"already waiting for task {task_id!r}")
|
|
149
|
+
await self.start()
|
|
150
|
+
self._seq += 1
|
|
151
|
+
waiter = _Waiter(
|
|
152
|
+
asyncio.get_running_loop().create_future(),
|
|
153
|
+
lane,
|
|
154
|
+
deadline=self._clock() + timeout_secs,
|
|
155
|
+
seq=self._seq,
|
|
156
|
+
)
|
|
157
|
+
self._waiters[task_id] = waiter
|
|
158
|
+
self._lanes[lane] += 1
|
|
159
|
+
heapq.heappush(self._deadlines, (waiter.deadline, waiter.seq, task_id))
|
|
160
|
+
self._maybe_compact()
|
|
161
|
+
if math.isinf(self._wake_at):
|
|
162
|
+
self._work.set()
|
|
163
|
+
elif (
|
|
164
|
+
min(self._clock() + self._intervals[lane] / 1000, waiter.deadline)
|
|
165
|
+
< self._wake_at - _WAKE_SLACK_SECS
|
|
166
|
+
):
|
|
167
|
+
# S31: comparing the interval with the nap *length* woke the loop for most new
|
|
168
|
+
# same-lane waiters ((t + 0.1) - t > 0.1 in floats): ~90 extra full sweeps per 1K.
|
|
169
|
+
self._wake.set() # loop naps longer than this waiter allows
|
|
170
|
+
try:
|
|
171
|
+
return await waiter.future
|
|
172
|
+
finally:
|
|
173
|
+
self._discard(task_id, waiter)
|
|
174
|
+
self._maybe_compact()
|
|
175
|
+
|
|
176
|
+
def _discard(self, task_id: str, waiter: _Waiter) -> _Waiter | None:
|
|
177
|
+
"""Remove `waiter` if it is still the one registered for `task_id`."""
|
|
178
|
+
if self._waiters.get(task_id) is not waiter:
|
|
179
|
+
return None
|
|
180
|
+
del self._waiters[task_id]
|
|
181
|
+
self._lanes[waiter.lane] -= 1
|
|
182
|
+
if not self._lanes[waiter.lane]:
|
|
183
|
+
del self._lanes[waiter.lane]
|
|
184
|
+
return waiter
|
|
185
|
+
|
|
186
|
+
async def _run(self) -> None:
|
|
187
|
+
"""Poll loop: sweep every waiter each fastest-lane interval, take hinted ids on wake-up.
|
|
188
|
+
|
|
189
|
+
A pub/sub wake takes only the announced ids (not every pending one, which cost one
|
|
190
|
+
`GET` per waiter per finished task); a deadline wake takes the ids that are due.
|
|
191
|
+
"""
|
|
192
|
+
while True:
|
|
193
|
+
if not self._waiters:
|
|
194
|
+
self._wake_at = math.inf
|
|
195
|
+
self._next_sweep = None
|
|
196
|
+
self._hinted.clear()
|
|
197
|
+
self._work.clear()
|
|
198
|
+
await self._work.wait()
|
|
199
|
+
self._wake.clear()
|
|
200
|
+
continue
|
|
201
|
+
interval = min(self._intervals[lane] for lane in self._lanes) / 1000
|
|
202
|
+
now = self._clock()
|
|
203
|
+
due = now + interval # a faster lane that just arrived pulls the sweep in
|
|
204
|
+
self._next_sweep = due if self._next_sweep is None else min(self._next_sweep, due)
|
|
205
|
+
nap = max(0.0, self._next_sweep - now)
|
|
206
|
+
self._drop_stale_deadlines()
|
|
207
|
+
if self._deadlines:
|
|
208
|
+
nap = min(nap, max(0.0, self._deadlines[0][0] - now))
|
|
209
|
+
self._wake_at = now + nap
|
|
210
|
+
woken = await self._nap(nap)
|
|
211
|
+
if self._waiters:
|
|
212
|
+
await self._take(woken)
|
|
213
|
+
self._expire()
|
|
214
|
+
|
|
215
|
+
async def _take(self, woken: bool) -> None:
|
|
216
|
+
"""Full sweep when due (or woken without hints, e.g. a new waiter); else hinted/due ids."""
|
|
217
|
+
hinted, self._hinted = self._hinted, set()
|
|
218
|
+
if self._clock() >= (self._next_sweep or 0.0) or (woken and not hinted):
|
|
219
|
+
self._next_sweep = None
|
|
220
|
+
await self._poll(list(self._waiters))
|
|
221
|
+
return
|
|
222
|
+
now = self._clock()
|
|
223
|
+
due: set[str] = set()
|
|
224
|
+
if self._deadlines and self._deadlines[0][0] <= now: # last look before they time out
|
|
225
|
+
due = {e[2] for e in self._deadlines if e[0] <= now and self._live(e) is not None}
|
|
226
|
+
ids = [i for i in hinted | due if i in self._waiters]
|
|
227
|
+
if ids:
|
|
228
|
+
await self._poll(ids)
|
|
229
|
+
|
|
230
|
+
async def _nap(self, secs: float) -> bool:
|
|
231
|
+
"""Sleep `secs` with the injected sleep; True when woken early by the event."""
|
|
232
|
+
if not self._wake.is_set():
|
|
233
|
+
sleeper = asyncio.ensure_future(self._sleep(secs))
|
|
234
|
+
waker = asyncio.ensure_future(self._wake.wait())
|
|
235
|
+
try:
|
|
236
|
+
await asyncio.wait({sleeper, waker}, return_when=asyncio.FIRST_COMPLETED)
|
|
237
|
+
finally:
|
|
238
|
+
sleeper.cancel()
|
|
239
|
+
waker.cancel()
|
|
240
|
+
woken = self._wake.is_set()
|
|
241
|
+
self._wake.clear()
|
|
242
|
+
return woken
|
|
243
|
+
|
|
244
|
+
async def _poll(self, task_ids: list[str]) -> None:
|
|
245
|
+
"""One batched take over `task_ids`; store errors are logged and retried."""
|
|
246
|
+
self.polls += 1
|
|
247
|
+
try:
|
|
248
|
+
replies = await self._store.take_many(task_ids)
|
|
249
|
+
except Exception as exc: # Redis blip: keep waiting, deadlines still apply
|
|
250
|
+
logger.warning(
|
|
251
|
+
"result_poll_failed", pending=len(task_ids), error_type=type(exc).__name__
|
|
252
|
+
)
|
|
253
|
+
self._report_error(exc)
|
|
254
|
+
since = self._failing_since
|
|
255
|
+
limit = self._abandon_after_secs
|
|
256
|
+
if since is not None and limit is not None and self._clock() - since >= limit:
|
|
257
|
+
self._fail_all(ResultsUnavailable(f"result reads failing for {limit:g}s"))
|
|
258
|
+
return
|
|
259
|
+
self._read_ok()
|
|
260
|
+
for task_id, reply in replies.items():
|
|
261
|
+
waiter = self._waiters.get(task_id)
|
|
262
|
+
if waiter is not None and self._discard(task_id, waiter) and not waiter.future.done():
|
|
263
|
+
waiter.future.set_result(reply)
|
|
264
|
+
|
|
265
|
+
def _read_ok(self) -> None:
|
|
266
|
+
"""A read worked: end the current outage (reported once, on the transition)."""
|
|
267
|
+
if self._failing_since is not None:
|
|
268
|
+
self._failing_since = None
|
|
269
|
+
self._notify(self._on_recovered)
|
|
270
|
+
|
|
271
|
+
def _report_error(self, exc: BaseException) -> None:
|
|
272
|
+
if self._failing_since is None:
|
|
273
|
+
self._failing_since = self._clock()
|
|
274
|
+
self._notify(self._on_error, exc)
|
|
275
|
+
|
|
276
|
+
@staticmethod
|
|
277
|
+
def _notify(callback: Callable[..., object] | None, *args: object) -> None:
|
|
278
|
+
"""Run a health callback; it must never break the poll loop or the listener."""
|
|
279
|
+
if callback is None:
|
|
280
|
+
return
|
|
281
|
+
try:
|
|
282
|
+
callback(*args)
|
|
283
|
+
except Exception:
|
|
284
|
+
logger.exception("result_health_callback_failed")
|
|
285
|
+
|
|
286
|
+
def _hint(self, data: object) -> None:
|
|
287
|
+
"""Note an announced task id and wake the loop; an unreadable message wakes a sweep."""
|
|
288
|
+
task_id = data.decode(errors="replace") if isinstance(data, bytes) else data
|
|
289
|
+
if not isinstance(task_id, str):
|
|
290
|
+
self._next_sweep = 0.0
|
|
291
|
+
elif task_id in self._waiters:
|
|
292
|
+
self._hinted.add(task_id)
|
|
293
|
+
else:
|
|
294
|
+
return # not ours any more (resolved or timed out): no take needed
|
|
295
|
+
self._wake.set()
|
|
296
|
+
|
|
297
|
+
def _live(self, entry: tuple[float, int, str]) -> _Waiter | None:
|
|
298
|
+
"""The waiter a heap entry still stands for, or None when the entry is stale."""
|
|
299
|
+
waiter = self._waiters.get(entry[2])
|
|
300
|
+
return waiter if waiter is not None and waiter.seq == entry[1] else None
|
|
301
|
+
|
|
302
|
+
def _drop_stale_deadlines(self) -> None:
|
|
303
|
+
while self._deadlines and self._live(self._deadlines[0]) is None:
|
|
304
|
+
heapq.heappop(self._deadlines)
|
|
305
|
+
|
|
306
|
+
def _maybe_compact(self) -> None:
|
|
307
|
+
"""Rebuild the heap from live waiters once stale entries outnumber them.
|
|
308
|
+
|
|
309
|
+
Resolved waiters leave their entry behind until its deadline; without this a pod
|
|
310
|
+
serving many fast calls with long timeouts would grow the heap without bound.
|
|
311
|
+
"""
|
|
312
|
+
size = len(self._deadlines)
|
|
313
|
+
if size >= _COMPACT_MIN_ENTRIES and size > 2 * len(self._waiters):
|
|
314
|
+
self._deadlines = [e for e in self._deadlines if self._live(e) is not None]
|
|
315
|
+
heapq.heapify(self._deadlines)
|
|
316
|
+
|
|
317
|
+
def _expire(self) -> None:
|
|
318
|
+
"""Fail every waiter whose deadline has passed with `LLMQueueTimeout`."""
|
|
319
|
+
now = self._clock()
|
|
320
|
+
while self._deadlines and self._deadlines[0][0] <= now:
|
|
321
|
+
entry = heapq.heappop(self._deadlines)
|
|
322
|
+
waiter = self._live(entry)
|
|
323
|
+
if waiter is not None and self._discard(entry[2], waiter) and not waiter.future.done():
|
|
324
|
+
waiter.future.set_exception(LLMQueueTimeout(f"no result for task {entry[2]}"))
|
|
325
|
+
|
|
326
|
+
def _fail_all(self, exc: Exception) -> None:
|
|
327
|
+
for task_id, waiter in list(self._waiters.items()):
|
|
328
|
+
if self._discard(task_id, waiter) and not waiter.future.done():
|
|
329
|
+
waiter.future.set_exception(exc)
|
|
330
|
+
self._deadlines.clear()
|
|
331
|
+
|
|
332
|
+
def _on_loop_exit(self, task: asyncio.Task[None]) -> None:
|
|
333
|
+
"""A crashed loop must not leave callers hanging: fail them all."""
|
|
334
|
+
if not task.cancelled() and task.exception() is not None:
|
|
335
|
+
logger.error("result_dispatcher_crashed", exc_info=task.exception())
|
|
336
|
+
self._loop_task = None
|
|
337
|
+
self._fail_all(QueueUnavailable("result dispatcher loop crashed"))
|
|
338
|
+
|
|
339
|
+
async def _listen(self, redis: Redis, channel: str) -> None:
|
|
340
|
+
"""Subscribe to the pod's done channel; each message names a task id to take now."""
|
|
341
|
+
while True:
|
|
342
|
+
pubsub = redis.pubsub()
|
|
343
|
+
try:
|
|
344
|
+
await pubsub.subscribe(channel)
|
|
345
|
+
self._read_ok()
|
|
346
|
+
while True:
|
|
347
|
+
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
|
|
348
|
+
if message is not None and self._waiters:
|
|
349
|
+
self._hint(message.get("data"))
|
|
350
|
+
except asyncio.CancelledError:
|
|
351
|
+
raise
|
|
352
|
+
except Exception as exc: # reconnect; polling keeps working meanwhile
|
|
353
|
+
logger.warning("result_channel_failed", error_type=type(exc).__name__)
|
|
354
|
+
self._report_error(exc)
|
|
355
|
+
await self._sleep(_PUBSUB_RETRY_SECS)
|
|
356
|
+
finally:
|
|
357
|
+
with contextlib.suppress(Exception):
|
|
358
|
+
await pubsub.aclose() # type: ignore[no-untyped-call] # redis stub gap
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
async def publish_done(redis: Redis, keys: KeySpace, pod: str, task_id: str) -> None:
|
|
362
|
+
"""Tell pod `pod`'s dispatcher that `task_id`'s result is stored (workers call this).
|
|
363
|
+
|
|
364
|
+
Best effort: a lost message only delays the result until the next poll.
|
|
365
|
+
"""
|
|
366
|
+
await redis.publish(keys.done_channel(pod), task_id)
|