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