langgraph-runtime-pg 0.11.1__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.
- langgraph_runtime_pg/__init__.py +38 -0
- langgraph_runtime_pg/checkpoint.py +83 -0
- langgraph_runtime_pg/database.py +480 -0
- langgraph_runtime_pg/lifespan.py +198 -0
- langgraph_runtime_pg/metrics.py +7 -0
- langgraph_runtime_pg/migrate.py +101 -0
- langgraph_runtime_pg/migrations/__init__.py +1 -0
- langgraph_runtime_pg/migrations/env.py +51 -0
- langgraph_runtime_pg/migrations/script.py.mako +27 -0
- langgraph_runtime_pg/migrations/versions/001_initial_schema.py +331 -0
- langgraph_runtime_pg/migrations/versions/__init__.py +1 -0
- langgraph_runtime_pg/models.py +196 -0
- langgraph_runtime_pg/ops.py +3538 -0
- langgraph_runtime_pg/queue.py +270 -0
- langgraph_runtime_pg/redis_stream.py +894 -0
- langgraph_runtime_pg/retry.py +39 -0
- langgraph_runtime_pg/routes.py +9 -0
- langgraph_runtime_pg/store.py +183 -0
- langgraph_runtime_pg-0.11.1.dist-info/METADATA +47 -0
- langgraph_runtime_pg-0.11.1.dist-info/RECORD +23 -0
- langgraph_runtime_pg-0.11.1.dist-info/WHEEL +4 -0
- langgraph_runtime_pg-0.11.1.dist-info/entry_points.txt +2 -0
- langgraph_runtime_pg-0.11.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
"""Redis Streams + Pub/Sub fanout into local asyncio queues (requires ``REDIS_URI``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import importlib
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections import defaultdict, deque
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
from uuid import UUID
|
|
17
|
+
|
|
18
|
+
import orjson
|
|
19
|
+
import redis.asyncio as redis
|
|
20
|
+
from redis.driver_info import DriverInfo
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
_REDIS: redis.Redis | None = None
|
|
25
|
+
_REDIS_POOL: redis.ConnectionPool | None = None
|
|
26
|
+
_WAKE_EVENT: asyncio.Event | None = None
|
|
27
|
+
_WAKE_TASK: asyncio.Task | None = None
|
|
28
|
+
# Monotonic wake generation — avoids asyncio.Event wait/clear lost-wakeup races.
|
|
29
|
+
_WAKE_GEN = 0
|
|
30
|
+
|
|
31
|
+
QUEUE_WAKE_CHANNEL = "langgraph:run_queue"
|
|
32
|
+
RUN_FANOUT_PATTERN = "lg:run-fanout:*"
|
|
33
|
+
THREAD_FANOUT_PATTERN = "lg:thread-fanout:*"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _redis_max_connections() -> int:
|
|
37
|
+
raw = os.environ.get("REDIS_MAX_CONNECTIONS", "64")
|
|
38
|
+
try:
|
|
39
|
+
return max(int(raw), 8)
|
|
40
|
+
except ValueError:
|
|
41
|
+
return 64
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _require_redis_uri() -> str:
|
|
45
|
+
uri = os.environ.get("REDIS_URI")
|
|
46
|
+
if not uri:
|
|
47
|
+
raise RuntimeError("REDIS_URI is required for langgraph_runtime_pg redis_stream")
|
|
48
|
+
return uri
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _sanitize_redis_uri(uri: str) -> str:
|
|
52
|
+
"""Drop deprecated ``lib_name`` / ``lib_version`` query params from ``REDIS_URI``.
|
|
53
|
+
|
|
54
|
+
redis-py still accepts those query keys but warns on every connection; pass
|
|
55
|
+
metadata via ``driver_info`` instead (see ``_redis_pool_kwargs``).
|
|
56
|
+
"""
|
|
57
|
+
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
|
58
|
+
|
|
59
|
+
parsed = urlparse(uri)
|
|
60
|
+
if not parsed.query:
|
|
61
|
+
return uri
|
|
62
|
+
kept = [
|
|
63
|
+
(k, v)
|
|
64
|
+
for k, v in parse_qsl(parsed.query, keep_blank_values=True)
|
|
65
|
+
if k not in ("lib_name", "lib_version")
|
|
66
|
+
]
|
|
67
|
+
return urlunparse(parsed._replace(query=urlencode(kept)))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _redis_pool_kwargs() -> dict[str, Any]:
|
|
71
|
+
"""Explicit CLIENT SETINFO metadata via ``driver_info`` (not deprecated kwargs).
|
|
72
|
+
|
|
73
|
+
Passing ``lib_name`` / ``lib_version`` triggers redis-py DeprecationWarning on
|
|
74
|
+
every connection. Setting ``lib_version`` on ``DriverInfo`` also avoids
|
|
75
|
+
``importlib.metadata.version("redis")`` filesystem probes during connect.
|
|
76
|
+
"""
|
|
77
|
+
name = os.environ.get("REDIS_LIB_NAME", "redis-py")
|
|
78
|
+
version = os.environ.get("REDIS_LIB_VERSION")
|
|
79
|
+
if not version:
|
|
80
|
+
try:
|
|
81
|
+
redis_mod = importlib.import_module("redis")
|
|
82
|
+
version = str(getattr(redis_mod, "__version__", "") or "").strip()
|
|
83
|
+
except Exception:
|
|
84
|
+
version = ""
|
|
85
|
+
if not version:
|
|
86
|
+
version = "unknown"
|
|
87
|
+
return {"driver_info": DriverInfo(name=name, lib_version=version)}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _ensure_uuid(id_: str | UUID) -> UUID:
|
|
91
|
+
return UUID(str(id_)) if isinstance(id_, str) else id_
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _heartbeat_key(run_id: UUID | str) -> str:
|
|
95
|
+
return f"lg:run-heartbeat:{_ensure_uuid(run_id)}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def bg_job_heartbeat_secs() -> float:
|
|
99
|
+
"""Heartbeat interval (seconds); ``LG_BG_JOB_HEARTBEAT`` overrides config default."""
|
|
100
|
+
raw = os.environ.get("LG_BG_JOB_HEARTBEAT")
|
|
101
|
+
if raw:
|
|
102
|
+
return max(float(raw), 1.0)
|
|
103
|
+
try:
|
|
104
|
+
from langgraph_api import config
|
|
105
|
+
|
|
106
|
+
return max(float(config.BG_JOB_HEARTBEAT), 1.0)
|
|
107
|
+
except Exception:
|
|
108
|
+
return 120.0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _heartbeat_ttl_secs() -> int:
|
|
112
|
+
"""Redis heartbeat key TTL (~2x interval)."""
|
|
113
|
+
return max(int(bg_job_heartbeat_secs() * 2), 4)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def heartbeat_refresh_interval_secs() -> float:
|
|
117
|
+
return max(bg_job_heartbeat_secs() / 2.0, 1.0)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def set_run_heartbeat(run_id: UUID | str, *, ttl: int | None = None) -> None:
|
|
121
|
+
"""Refresh worker heartbeat for a running run."""
|
|
122
|
+
if _REDIS is None:
|
|
123
|
+
return
|
|
124
|
+
await _REDIS.set(_heartbeat_key(run_id), b"1", ex=ttl or _heartbeat_ttl_secs())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def clear_run_heartbeat(run_id: UUID | str) -> None:
|
|
128
|
+
"""Clear heartbeat when a worker finishes or releases a run."""
|
|
129
|
+
if _REDIS is None:
|
|
130
|
+
return
|
|
131
|
+
await _REDIS.delete(_heartbeat_key(run_id))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def has_run_heartbeat(run_id: UUID | str) -> bool | None:
|
|
135
|
+
"""True/False if Redis is up; None if heartbeat cannot be checked."""
|
|
136
|
+
if _REDIS is None:
|
|
137
|
+
return None
|
|
138
|
+
try:
|
|
139
|
+
return bool(await _REDIS.exists(_heartbeat_key(run_id)))
|
|
140
|
+
except Exception:
|
|
141
|
+
logger.exception("Failed to check run heartbeat for %s", run_id)
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Same-ms stream ID collisions: bump seq so `_seen_ids` does not drop frames.
|
|
146
|
+
_id_lock = threading.Lock()
|
|
147
|
+
_id_last_ms = 0
|
|
148
|
+
_id_seq = 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _generate_ms_seq_id() -> str:
|
|
152
|
+
"""Monotonic Redis-style ``ms-seq`` id (safe under same-ms bursts + clock skew)."""
|
|
153
|
+
global _id_last_ms, _id_seq
|
|
154
|
+
ms = int(time.time() * 1000)
|
|
155
|
+
with _id_lock:
|
|
156
|
+
if ms > _id_last_ms:
|
|
157
|
+
_id_last_ms = ms
|
|
158
|
+
_id_seq = 0
|
|
159
|
+
else:
|
|
160
|
+
# Same ms or clock skew — keep ids non-decreasing for resume cursors.
|
|
161
|
+
_id_seq += 1
|
|
162
|
+
return f"{_id_last_ms}-{_id_seq}"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _parse_ms_seq_id(mid: str) -> tuple[int, int] | None:
|
|
166
|
+
"""Parse Redis-style ``ms-seq`` IDs; None if not in that form."""
|
|
167
|
+
try:
|
|
168
|
+
ms_s, seq_s = mid.split("-", 1)
|
|
169
|
+
return int(ms_s), int(seq_s)
|
|
170
|
+
except (TypeError, ValueError):
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def ms_seq_id_gt(left: str, right: str) -> bool:
|
|
175
|
+
"""True if ``left`` is strictly after ``right`` (Redis stream ID order)."""
|
|
176
|
+
pl, pr = _parse_ms_seq_id(left), _parse_ms_seq_id(right)
|
|
177
|
+
if pl is not None and pr is not None:
|
|
178
|
+
return pl > pr
|
|
179
|
+
return left > right
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def ms_seq_id_sort_key(mid: str) -> tuple[int, int, str]:
|
|
183
|
+
parsed = _parse_ms_seq_id(mid)
|
|
184
|
+
if parsed is not None:
|
|
185
|
+
return (*parsed, "")
|
|
186
|
+
return (0, 0, mid)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _stream_key(thread_id: Any, run_id: UUID) -> str:
|
|
190
|
+
return f"lg:run-stream:{thread_id}:{run_id}"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _control_key(thread_id: Any, run_id: UUID) -> str:
|
|
194
|
+
return f"lg:run-control:{thread_id}:{run_id}"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _fanout_channel(thread_id: Any, run_id: UUID) -> str:
|
|
198
|
+
return f"lg:run-fanout:{thread_id}:{run_id}"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _thread_fanout_channel(thread_id: UUID) -> str:
|
|
202
|
+
return f"lg:thread-fanout:{thread_id}"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
THREADLESS_KEY = "no-thread"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _channel_str(channel: Any) -> str:
|
|
209
|
+
if isinstance(channel, bytes):
|
|
210
|
+
return channel.decode()
|
|
211
|
+
return str(channel)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _parse_run_fanout_channel(channel: str) -> tuple[Any, UUID] | None:
|
|
215
|
+
prefix = "lg:run-fanout:"
|
|
216
|
+
if not channel.startswith(prefix):
|
|
217
|
+
return None
|
|
218
|
+
rest = channel[len(prefix) :]
|
|
219
|
+
if ":" not in rest:
|
|
220
|
+
return None
|
|
221
|
+
thread_part, run_part = rest.rsplit(":", 1)
|
|
222
|
+
try:
|
|
223
|
+
run_id = UUID(run_part)
|
|
224
|
+
except ValueError:
|
|
225
|
+
return None
|
|
226
|
+
if thread_part == THREADLESS_KEY:
|
|
227
|
+
return THREADLESS_KEY, run_id
|
|
228
|
+
try:
|
|
229
|
+
return UUID(thread_part), run_id
|
|
230
|
+
except ValueError:
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _parse_thread_fanout_channel(channel: str) -> UUID | None:
|
|
235
|
+
prefix = "lg:thread-fanout:"
|
|
236
|
+
if not channel.startswith(prefix):
|
|
237
|
+
return None
|
|
238
|
+
try:
|
|
239
|
+
return UUID(channel[len(prefix) :])
|
|
240
|
+
except ValueError:
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass
|
|
245
|
+
class Message:
|
|
246
|
+
topic: bytes
|
|
247
|
+
data: bytes
|
|
248
|
+
id: bytes | None = None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class ContextQueue(asyncio.Queue):
|
|
252
|
+
async def __aenter__(self):
|
|
253
|
+
return self
|
|
254
|
+
|
|
255
|
+
async def __aexit__(self, *args: object) -> None:
|
|
256
|
+
while not self.empty():
|
|
257
|
+
try:
|
|
258
|
+
self.get_nowait()
|
|
259
|
+
except asyncio.QueueEmpty:
|
|
260
|
+
break
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _encode_envelope(message: Message, *, control: bool = False) -> bytes:
|
|
264
|
+
topic = message.topic.decode() if isinstance(message.topic, bytes) else message.topic
|
|
265
|
+
mid = message.id.decode() if isinstance(message.id, bytes) else (message.id or "")
|
|
266
|
+
return orjson.dumps(
|
|
267
|
+
{
|
|
268
|
+
"topic": topic,
|
|
269
|
+
"data": base64.b64encode(message.data).decode("ascii"),
|
|
270
|
+
"id": mid,
|
|
271
|
+
"control": control,
|
|
272
|
+
}
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _decode_envelope(payload: bytes | str) -> Message | None:
|
|
277
|
+
try:
|
|
278
|
+
raw = payload if isinstance(payload, (bytes, bytearray)) else payload.encode()
|
|
279
|
+
obj = orjson.loads(raw)
|
|
280
|
+
topic = obj["topic"].encode() if isinstance(obj["topic"], str) else obj["topic"]
|
|
281
|
+
data = base64.b64decode(obj["data"])
|
|
282
|
+
mid = obj.get("id") or ""
|
|
283
|
+
mid_b = mid.encode() if isinstance(mid, str) else mid
|
|
284
|
+
return Message(topic=topic, data=data, id=mid_b or None)
|
|
285
|
+
except Exception:
|
|
286
|
+
logger.exception("Failed to decode fanout envelope")
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class _SeenIds:
|
|
291
|
+
"""Bounded FIFO dedup set — prune oldest half when full (never wipe all)."""
|
|
292
|
+
|
|
293
|
+
def __init__(self, maxlen: int = 10_000):
|
|
294
|
+
self._maxlen = maxlen
|
|
295
|
+
self._order: deque[str] = deque()
|
|
296
|
+
self._ids: set[str] = set()
|
|
297
|
+
|
|
298
|
+
def __contains__(self, mid: str) -> bool:
|
|
299
|
+
return mid in self._ids
|
|
300
|
+
|
|
301
|
+
def add(self, mid: str) -> bool:
|
|
302
|
+
"""Record ``mid``. Return False if already seen."""
|
|
303
|
+
if mid in self._ids:
|
|
304
|
+
return False
|
|
305
|
+
self._ids.add(mid)
|
|
306
|
+
self._order.append(mid)
|
|
307
|
+
if len(self._order) > self._maxlen:
|
|
308
|
+
for _ in range(len(self._order) // 2):
|
|
309
|
+
self._ids.discard(self._order.popleft())
|
|
310
|
+
return True
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
async def _fanout_put(queues: list, message: Message) -> None:
|
|
314
|
+
if not queues:
|
|
315
|
+
return
|
|
316
|
+
results = await asyncio.gather(*[q.put(message) for q in queues], return_exceptions=True)
|
|
317
|
+
for r in results:
|
|
318
|
+
if isinstance(r, Exception):
|
|
319
|
+
logger.exception("Failed to put message in queue: %s", r)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class StreamManager:
|
|
323
|
+
def __init__(self, redis_client: redis.Redis):
|
|
324
|
+
self._redis = redis_client
|
|
325
|
+
self.queues: dict = defaultdict(lambda: defaultdict(list))
|
|
326
|
+
self.control_keys: dict = defaultdict(lambda: defaultdict())
|
|
327
|
+
self.control_queues: dict = defaultdict(lambda: defaultdict(list))
|
|
328
|
+
self.thread_streams: dict = defaultdict(list)
|
|
329
|
+
self.message_stores: dict = defaultdict(lambda: defaultdict(list))
|
|
330
|
+
self._seen_ids = _SeenIds()
|
|
331
|
+
# Buffer+subscriber lock — avoids miss/double-deliver races with concurrent put().
|
|
332
|
+
self._buf_lock = asyncio.Lock()
|
|
333
|
+
self._mux_tasks: list[asyncio.Task] = []
|
|
334
|
+
self._cleanup_tasks: set[asyncio.Task] = set()
|
|
335
|
+
|
|
336
|
+
def start_mux(self) -> None:
|
|
337
|
+
if self._mux_tasks:
|
|
338
|
+
return
|
|
339
|
+
self._mux_tasks = [
|
|
340
|
+
asyncio.create_task(self._run_fanout_mux(), name="redis-run-fanout-mux"),
|
|
341
|
+
asyncio.create_task(self._thread_fanout_mux(), name="redis-thread-fanout-mux"),
|
|
342
|
+
]
|
|
343
|
+
|
|
344
|
+
def get_queues(self, run_id: UUID | str, thread_id: UUID | str | None) -> list[asyncio.Queue]:
|
|
345
|
+
run_id = _ensure_uuid(run_id)
|
|
346
|
+
thread_id = THREADLESS_KEY if thread_id is None else _ensure_uuid(thread_id)
|
|
347
|
+
return self.queues[thread_id][run_id]
|
|
348
|
+
|
|
349
|
+
def get_control_queues(
|
|
350
|
+
self, run_id: UUID | str, thread_id: UUID | str | None
|
|
351
|
+
) -> list[asyncio.Queue]:
|
|
352
|
+
run_id = _ensure_uuid(run_id)
|
|
353
|
+
thread_id = THREADLESS_KEY if thread_id is None else _ensure_uuid(thread_id)
|
|
354
|
+
return self.control_queues[thread_id][run_id]
|
|
355
|
+
|
|
356
|
+
def get_control_key(self, run_id: UUID | str, thread_id: UUID | str | None) -> Message | None:
|
|
357
|
+
run_id = _ensure_uuid(run_id)
|
|
358
|
+
thread_id = THREADLESS_KEY if thread_id is None else _ensure_uuid(thread_id)
|
|
359
|
+
return self.control_keys.get(thread_id, {}).get(run_id)
|
|
360
|
+
|
|
361
|
+
async def aget_control_key(
|
|
362
|
+
self, run_id: UUID | str, thread_id: UUID | str | None
|
|
363
|
+
) -> Message | None:
|
|
364
|
+
"""Local control key, else hydrate from Redis (cross-replica cancel-before-listen)."""
|
|
365
|
+
run_id = _ensure_uuid(run_id)
|
|
366
|
+
thread_id = THREADLESS_KEY if thread_id is None else _ensure_uuid(thread_id)
|
|
367
|
+
local = self.control_keys.get(thread_id, {}).get(run_id)
|
|
368
|
+
if local is not None:
|
|
369
|
+
return local
|
|
370
|
+
if self._redis is None:
|
|
371
|
+
return None
|
|
372
|
+
try:
|
|
373
|
+
data = await self._redis.get(_control_key(thread_id, run_id))
|
|
374
|
+
except Exception:
|
|
375
|
+
logger.exception("Failed to read control key for run %s", run_id)
|
|
376
|
+
return None
|
|
377
|
+
if data is None:
|
|
378
|
+
return None
|
|
379
|
+
payload = data if isinstance(data, (bytes, bytearray)) else str(data).encode()
|
|
380
|
+
message = Message(topic=f"run:{run_id}:control".encode(), data=bytes(payload))
|
|
381
|
+
self.control_keys[thread_id][run_id] = message
|
|
382
|
+
return message
|
|
383
|
+
|
|
384
|
+
def _mark_seen(self, scope: str, message: Message) -> bool:
|
|
385
|
+
"""False if already delivered in ``scope`` (ids are not globally unique across runs)."""
|
|
386
|
+
mid = message.id.decode() if message.id else None
|
|
387
|
+
if mid is None:
|
|
388
|
+
return True
|
|
389
|
+
return self._seen_ids.add(f"{scope}:{mid}")
|
|
390
|
+
|
|
391
|
+
async def _deliver_local(
|
|
392
|
+
self,
|
|
393
|
+
thread_id: Any,
|
|
394
|
+
run_id: UUID,
|
|
395
|
+
message: Message,
|
|
396
|
+
*,
|
|
397
|
+
control: bool,
|
|
398
|
+
already_locked: bool = False,
|
|
399
|
+
) -> None:
|
|
400
|
+
async def _do() -> None:
|
|
401
|
+
if not self._mark_seen(f"run:{thread_id}:{run_id}", message):
|
|
402
|
+
return
|
|
403
|
+
if control:
|
|
404
|
+
self.control_keys[thread_id][run_id] = message
|
|
405
|
+
queues = self.control_queues[thread_id][run_id]
|
|
406
|
+
else:
|
|
407
|
+
queues = self.queues[thread_id][run_id]
|
|
408
|
+
await _fanout_put(queues, message)
|
|
409
|
+
|
|
410
|
+
if already_locked:
|
|
411
|
+
await _do()
|
|
412
|
+
else:
|
|
413
|
+
async with self._buf_lock:
|
|
414
|
+
await _do()
|
|
415
|
+
|
|
416
|
+
async def _deliver_thread_local(self, thread_id: UUID, message: Message) -> None:
|
|
417
|
+
async with self._buf_lock:
|
|
418
|
+
if not self._mark_seen(f"thread:{thread_id}", message):
|
|
419
|
+
return
|
|
420
|
+
await _fanout_put(self.thread_streams[thread_id], message)
|
|
421
|
+
|
|
422
|
+
async def _run_fanout_mux(self) -> None:
|
|
423
|
+
"""Shared pattern-subscribe mux for all run fanout channels."""
|
|
424
|
+
while True:
|
|
425
|
+
pubsub = self._redis.pubsub()
|
|
426
|
+
try:
|
|
427
|
+
await pubsub.psubscribe(RUN_FANOUT_PATTERN)
|
|
428
|
+
async for msg in pubsub.listen():
|
|
429
|
+
if msg is None or msg.get("type") != "pmessage":
|
|
430
|
+
continue
|
|
431
|
+
parsed = _parse_run_fanout_channel(_channel_str(msg.get("channel")))
|
|
432
|
+
if parsed is None:
|
|
433
|
+
continue
|
|
434
|
+
thread_id, run_id = parsed
|
|
435
|
+
decoded = _decode_envelope(msg["data"])
|
|
436
|
+
if decoded is None:
|
|
437
|
+
continue
|
|
438
|
+
topic = (
|
|
439
|
+
decoded.topic.decode()
|
|
440
|
+
if isinstance(decoded.topic, bytes)
|
|
441
|
+
else decoded.topic
|
|
442
|
+
)
|
|
443
|
+
await self._deliver_local(
|
|
444
|
+
thread_id, run_id, decoded, control="control" in topic
|
|
445
|
+
)
|
|
446
|
+
except asyncio.CancelledError:
|
|
447
|
+
raise
|
|
448
|
+
except Exception:
|
|
449
|
+
logger.exception("Run fanout mux failed; reconnecting")
|
|
450
|
+
await asyncio.sleep(1)
|
|
451
|
+
finally:
|
|
452
|
+
try:
|
|
453
|
+
await pubsub.punsubscribe(RUN_FANOUT_PATTERN)
|
|
454
|
+
await pubsub.aclose()
|
|
455
|
+
except Exception:
|
|
456
|
+
pass
|
|
457
|
+
|
|
458
|
+
async def _thread_fanout_mux(self) -> None:
|
|
459
|
+
"""Shared pattern-subscribe mux for all thread fanout channels."""
|
|
460
|
+
while True:
|
|
461
|
+
pubsub = self._redis.pubsub()
|
|
462
|
+
try:
|
|
463
|
+
await pubsub.psubscribe(THREAD_FANOUT_PATTERN)
|
|
464
|
+
async for msg in pubsub.listen():
|
|
465
|
+
if msg is None or msg.get("type") != "pmessage":
|
|
466
|
+
continue
|
|
467
|
+
thread_id = _parse_thread_fanout_channel(_channel_str(msg.get("channel")))
|
|
468
|
+
if thread_id is None:
|
|
469
|
+
continue
|
|
470
|
+
decoded = _decode_envelope(msg["data"])
|
|
471
|
+
if decoded is None:
|
|
472
|
+
continue
|
|
473
|
+
await self._deliver_thread_local(thread_id, decoded)
|
|
474
|
+
except asyncio.CancelledError:
|
|
475
|
+
raise
|
|
476
|
+
except Exception:
|
|
477
|
+
logger.exception("Thread fanout mux failed; reconnecting")
|
|
478
|
+
await asyncio.sleep(1)
|
|
479
|
+
finally:
|
|
480
|
+
try:
|
|
481
|
+
await pubsub.punsubscribe(THREAD_FANOUT_PATTERN)
|
|
482
|
+
await pubsub.aclose()
|
|
483
|
+
except Exception:
|
|
484
|
+
pass
|
|
485
|
+
|
|
486
|
+
async def put(
|
|
487
|
+
self,
|
|
488
|
+
run_id: UUID | str | None,
|
|
489
|
+
thread_id: UUID | str | None,
|
|
490
|
+
message: Message,
|
|
491
|
+
resumable: bool = False,
|
|
492
|
+
) -> None:
|
|
493
|
+
if self._redis is None:
|
|
494
|
+
raise RuntimeError("Redis not connected; call start_stream() first")
|
|
495
|
+
if run_id is None:
|
|
496
|
+
raise ValueError("run_id is required")
|
|
497
|
+
|
|
498
|
+
run_id = _ensure_uuid(run_id)
|
|
499
|
+
thread_id = THREADLESS_KEY if thread_id is None else _ensure_uuid(thread_id)
|
|
500
|
+
|
|
501
|
+
topic = message.topic.decode() if isinstance(message.topic, bytes) else message.topic
|
|
502
|
+
control = "control" in topic
|
|
503
|
+
|
|
504
|
+
if resumable:
|
|
505
|
+
entry_id = await self._redis.xadd(
|
|
506
|
+
_stream_key(thread_id, run_id),
|
|
507
|
+
{b"topic": message.topic, b"data": message.data},
|
|
508
|
+
)
|
|
509
|
+
message.id = entry_id if isinstance(entry_id, bytes) else str(entry_id).encode()
|
|
510
|
+
else:
|
|
511
|
+
message.id = _generate_ms_seq_id().encode()
|
|
512
|
+
|
|
513
|
+
# Lock buffer+deliver (add_queue race); skip buffering raw run:*:control (not STREAM_CODEC).
|
|
514
|
+
async with self._buf_lock:
|
|
515
|
+
if not control:
|
|
516
|
+
buf = self.message_stores[thread_id][run_id]
|
|
517
|
+
buf.append(message)
|
|
518
|
+
if len(buf) > 2_000:
|
|
519
|
+
del buf[: len(buf) - 2_000]
|
|
520
|
+
if control:
|
|
521
|
+
self.control_keys[thread_id][run_id] = message
|
|
522
|
+
await self._deliver_local(
|
|
523
|
+
thread_id, run_id, message, control=control, already_locked=True
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
if control:
|
|
527
|
+
# TTL: pending cancels that never enter Runs.enter must not leak keys.
|
|
528
|
+
await self._redis.set(
|
|
529
|
+
_control_key(thread_id, run_id),
|
|
530
|
+
message.data,
|
|
531
|
+
ex=max(_heartbeat_ttl_secs() * 4, 3600),
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
envelope = _encode_envelope(message, control=control)
|
|
535
|
+
await self._redis.publish(_fanout_channel(thread_id, run_id), envelope)
|
|
536
|
+
|
|
537
|
+
async def put_thread(
|
|
538
|
+
self,
|
|
539
|
+
thread_id: UUID | str,
|
|
540
|
+
message: Message,
|
|
541
|
+
) -> None:
|
|
542
|
+
if self._redis is None:
|
|
543
|
+
raise RuntimeError("Redis not connected; call start_stream() first")
|
|
544
|
+
|
|
545
|
+
thread_id = _ensure_uuid(thread_id)
|
|
546
|
+
message.id = _generate_ms_seq_id().encode()
|
|
547
|
+
await self._deliver_thread_local(thread_id, message)
|
|
548
|
+
|
|
549
|
+
envelope = _encode_envelope(message, control=False)
|
|
550
|
+
await self._redis.publish(_thread_fanout_channel(thread_id), envelope)
|
|
551
|
+
|
|
552
|
+
async def add_queue(
|
|
553
|
+
self,
|
|
554
|
+
run_id: UUID | str,
|
|
555
|
+
thread_id: UUID | str | None,
|
|
556
|
+
*,
|
|
557
|
+
after_id: str | None = None,
|
|
558
|
+
replay: bool = True,
|
|
559
|
+
) -> asyncio.Queue:
|
|
560
|
+
run_id = _ensure_uuid(run_id)
|
|
561
|
+
q = ContextQueue()
|
|
562
|
+
thread_id = THREADLESS_KEY if thread_id is None else _ensure_uuid(thread_id)
|
|
563
|
+
# Replay then register under lock; after_id/replay=False avoid double-delivery after restore.
|
|
564
|
+
async with self._buf_lock:
|
|
565
|
+
if replay:
|
|
566
|
+
for message in list(self.message_stores.get(thread_id, {}).get(run_id, [])):
|
|
567
|
+
if after_id is not None and message.id is not None:
|
|
568
|
+
try:
|
|
569
|
+
if not ms_seq_id_gt(message.id.decode(), after_id):
|
|
570
|
+
continue
|
|
571
|
+
except Exception:
|
|
572
|
+
pass
|
|
573
|
+
try:
|
|
574
|
+
q.put_nowait(message)
|
|
575
|
+
except asyncio.QueueFull:
|
|
576
|
+
break
|
|
577
|
+
self.queues[thread_id][run_id].append(q)
|
|
578
|
+
return q
|
|
579
|
+
|
|
580
|
+
async def add_control_queue(
|
|
581
|
+
self, run_id: UUID | str, thread_id: UUID | str | None
|
|
582
|
+
) -> asyncio.Queue:
|
|
583
|
+
run_id = _ensure_uuid(run_id)
|
|
584
|
+
thread_id = THREADLESS_KEY if thread_id is None else _ensure_uuid(thread_id)
|
|
585
|
+
q = ContextQueue()
|
|
586
|
+
self.control_queues[thread_id][run_id].append(q)
|
|
587
|
+
return q
|
|
588
|
+
|
|
589
|
+
async def add_thread_stream(self, thread_id: UUID | str) -> asyncio.Queue:
|
|
590
|
+
thread_id = _ensure_uuid(thread_id)
|
|
591
|
+
q = ContextQueue()
|
|
592
|
+
self.thread_streams[thread_id].append(q)
|
|
593
|
+
return q
|
|
594
|
+
|
|
595
|
+
async def remove_thread_stream(self, thread_id: UUID | str, queue: asyncio.Queue) -> None:
|
|
596
|
+
thread_id = _ensure_uuid(thread_id)
|
|
597
|
+
if thread_id not in self.thread_streams:
|
|
598
|
+
return
|
|
599
|
+
try:
|
|
600
|
+
self.thread_streams[thread_id].remove(queue)
|
|
601
|
+
except ValueError:
|
|
602
|
+
pass
|
|
603
|
+
if not self.thread_streams[thread_id]:
|
|
604
|
+
del self.thread_streams[thread_id]
|
|
605
|
+
|
|
606
|
+
async def remove_queue(
|
|
607
|
+
self, run_id: UUID | str, thread_id: UUID | str | None, queue: asyncio.Queue
|
|
608
|
+
):
|
|
609
|
+
run_id = _ensure_uuid(run_id)
|
|
610
|
+
thread_id = THREADLESS_KEY if thread_id is None else _ensure_uuid(thread_id)
|
|
611
|
+
if thread_id in self.queues and run_id in self.queues[thread_id]:
|
|
612
|
+
try:
|
|
613
|
+
self.queues[thread_id][run_id].remove(queue)
|
|
614
|
+
except ValueError:
|
|
615
|
+
pass
|
|
616
|
+
if not self.queues[thread_id][run_id]:
|
|
617
|
+
del self.queues[thread_id][run_id]
|
|
618
|
+
|
|
619
|
+
async def remove_control_queue(
|
|
620
|
+
self, run_id: UUID | str, thread_id: UUID | str | None, queue: asyncio.Queue
|
|
621
|
+
):
|
|
622
|
+
run_id = _ensure_uuid(run_id)
|
|
623
|
+
thread_id = THREADLESS_KEY if thread_id is None else _ensure_uuid(thread_id)
|
|
624
|
+
if thread_id in self.control_queues and run_id in self.control_queues[thread_id]:
|
|
625
|
+
try:
|
|
626
|
+
self.control_queues[thread_id][run_id].remove(queue)
|
|
627
|
+
except ValueError:
|
|
628
|
+
pass
|
|
629
|
+
if not self.control_queues[thread_id][run_id]:
|
|
630
|
+
del self.control_queues[thread_id][run_id]
|
|
631
|
+
|
|
632
|
+
def restore_messages(
|
|
633
|
+
self, run_id: UUID | str, thread_id: UUID | str | None, message_id: str | None
|
|
634
|
+
) -> Iterator[Message]:
|
|
635
|
+
run_id = _ensure_uuid(run_id)
|
|
636
|
+
thread_id = THREADLESS_KEY if thread_id is None else _ensure_uuid(thread_id)
|
|
637
|
+
if message_id is None:
|
|
638
|
+
return
|
|
639
|
+
store = self.message_stores.get(thread_id, {}).get(run_id)
|
|
640
|
+
if not store:
|
|
641
|
+
return
|
|
642
|
+
try:
|
|
643
|
+
for message in store:
|
|
644
|
+
if message.id and ms_seq_id_gt(message.id.decode(), message_id):
|
|
645
|
+
yield message
|
|
646
|
+
except TypeError:
|
|
647
|
+
# Rare numeric cursor — treat as list index.
|
|
648
|
+
message_idx = int(message_id) + 1
|
|
649
|
+
yield from store[message_idx:]
|
|
650
|
+
|
|
651
|
+
async def arestore_messages(
|
|
652
|
+
self,
|
|
653
|
+
run_id: UUID | str,
|
|
654
|
+
thread_id: UUID | str | None,
|
|
655
|
+
message_id: str | None,
|
|
656
|
+
) -> list[Message]:
|
|
657
|
+
if self._redis is None:
|
|
658
|
+
raise RuntimeError("Redis not connected; call start_stream() first")
|
|
659
|
+
if message_id is None:
|
|
660
|
+
return []
|
|
661
|
+
run_id = _ensure_uuid(run_id)
|
|
662
|
+
thread_id = THREADLESS_KEY if thread_id is None else _ensure_uuid(thread_id)
|
|
663
|
+
rows = await self._redis.xrange(
|
|
664
|
+
_stream_key(thread_id, run_id),
|
|
665
|
+
min=message_id,
|
|
666
|
+
max="+",
|
|
667
|
+
)
|
|
668
|
+
out: list[Message] = []
|
|
669
|
+
for entry_id, fields in rows or []:
|
|
670
|
+
if fields is None:
|
|
671
|
+
continue
|
|
672
|
+
eid = entry_id if isinstance(entry_id, bytes) else str(entry_id).encode()
|
|
673
|
+
# XRANGE min is inclusive — skip the cursor id itself.
|
|
674
|
+
if not ms_seq_id_gt(eid.decode(), message_id):
|
|
675
|
+
continue
|
|
676
|
+
topic = fields.get(b"topic", fields.get("topic", b""))
|
|
677
|
+
data = fields.get(b"data", fields.get("data", b""))
|
|
678
|
+
if isinstance(topic, str):
|
|
679
|
+
topic = topic.encode()
|
|
680
|
+
if isinstance(data, str):
|
|
681
|
+
data = data.encode()
|
|
682
|
+
out.append(Message(topic=topic, data=data, id=eid))
|
|
683
|
+
return out
|
|
684
|
+
|
|
685
|
+
async def restore_messages_async(
|
|
686
|
+
self,
|
|
687
|
+
run_id: UUID | str,
|
|
688
|
+
thread_id: UUID | str | None,
|
|
689
|
+
message_id: str | None,
|
|
690
|
+
) -> list[Message]:
|
|
691
|
+
"""Resume from Redis Streams when present; fall back to local buffer."""
|
|
692
|
+
if message_id is None:
|
|
693
|
+
return []
|
|
694
|
+
try:
|
|
695
|
+
from_redis = await self.arestore_messages(run_id, thread_id, message_id)
|
|
696
|
+
except Exception:
|
|
697
|
+
logger.exception("Redis stream restore failed for run %s", run_id)
|
|
698
|
+
from_redis = []
|
|
699
|
+
if from_redis:
|
|
700
|
+
return from_redis
|
|
701
|
+
return list(self.restore_messages(run_id, thread_id, message_id))
|
|
702
|
+
|
|
703
|
+
async def clear_run_buffers(
|
|
704
|
+
self,
|
|
705
|
+
run_id: UUID | str,
|
|
706
|
+
thread_id: UUID | str | None,
|
|
707
|
+
*,
|
|
708
|
+
stream_ttl_secs: int = 3600,
|
|
709
|
+
local_grace_secs: float = 120.0,
|
|
710
|
+
) -> None:
|
|
711
|
+
"""Drop control state now; expire Redis stream; free local frames after grace."""
|
|
712
|
+
run_id = _ensure_uuid(run_id)
|
|
713
|
+
thread_id = THREADLESS_KEY if thread_id is None else _ensure_uuid(thread_id)
|
|
714
|
+
if thread_id in self.control_keys:
|
|
715
|
+
self.control_keys[thread_id].pop(run_id, None)
|
|
716
|
+
if not self.control_keys[thread_id]:
|
|
717
|
+
del self.control_keys[thread_id]
|
|
718
|
+
if self._redis is not None:
|
|
719
|
+
try:
|
|
720
|
+
pipe = self._redis.pipeline()
|
|
721
|
+
pipe.delete(_control_key(thread_id, run_id))
|
|
722
|
+
pipe.expire(_stream_key(thread_id, run_id), max(int(stream_ttl_secs), 60))
|
|
723
|
+
await pipe.execute()
|
|
724
|
+
except Exception:
|
|
725
|
+
logger.exception("Failed to clear Redis buffers for run %s", run_id)
|
|
726
|
+
|
|
727
|
+
async def _drop_local_store() -> None:
|
|
728
|
+
try:
|
|
729
|
+
await asyncio.sleep(max(float(local_grace_secs), 0.0))
|
|
730
|
+
except asyncio.CancelledError:
|
|
731
|
+
return
|
|
732
|
+
if thread_id in self.message_stores:
|
|
733
|
+
self.message_stores[thread_id].pop(run_id, None)
|
|
734
|
+
if not self.message_stores[thread_id]:
|
|
735
|
+
del self.message_stores[thread_id]
|
|
736
|
+
|
|
737
|
+
try:
|
|
738
|
+
task = asyncio.create_task(
|
|
739
|
+
_drop_local_store(),
|
|
740
|
+
name=f"clear-run-buffers-{run_id}",
|
|
741
|
+
)
|
|
742
|
+
self._cleanup_tasks.add(task)
|
|
743
|
+
task.add_done_callback(self._cleanup_tasks.discard)
|
|
744
|
+
except RuntimeError:
|
|
745
|
+
if thread_id in self.message_stores:
|
|
746
|
+
self.message_stores[thread_id].pop(run_id, None)
|
|
747
|
+
if not self.message_stores[thread_id]:
|
|
748
|
+
del self.message_stores[thread_id]
|
|
749
|
+
|
|
750
|
+
def get_queues_by_thread_id(self, thread_id: UUID | str) -> list[asyncio.Queue]:
|
|
751
|
+
all_queues: list[asyncio.Queue] = []
|
|
752
|
+
thread_id = _ensure_uuid(thread_id)
|
|
753
|
+
if thread_id in self.queues:
|
|
754
|
+
for run_id in self.queues[thread_id]:
|
|
755
|
+
all_queues.extend(self.queues[thread_id][run_id])
|
|
756
|
+
return all_queues
|
|
757
|
+
|
|
758
|
+
async def aclose_fanout(self) -> None:
|
|
759
|
+
tasks = list(self._mux_tasks) + list(self._cleanup_tasks)
|
|
760
|
+
for t in tasks:
|
|
761
|
+
t.cancel()
|
|
762
|
+
if tasks:
|
|
763
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
764
|
+
self._mux_tasks.clear()
|
|
765
|
+
self._cleanup_tasks.clear()
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
stream_manager: StreamManager | None = None
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
async def _wake_listener_loop() -> None:
|
|
772
|
+
"""Pubsub loop: set ``_WAKE_EVENT`` on each queue-wake publish."""
|
|
773
|
+
global _WAKE_EVENT
|
|
774
|
+
while _REDIS is not None and _WAKE_EVENT is not None:
|
|
775
|
+
pubsub = _REDIS.pubsub()
|
|
776
|
+
try:
|
|
777
|
+
await pubsub.subscribe(QUEUE_WAKE_CHANNEL)
|
|
778
|
+
async for msg in pubsub.listen():
|
|
779
|
+
if msg is None or msg.get("type") != "message":
|
|
780
|
+
continue
|
|
781
|
+
_signal_local_wake()
|
|
782
|
+
except asyncio.CancelledError:
|
|
783
|
+
raise
|
|
784
|
+
except Exception:
|
|
785
|
+
logger.exception("Queue wake listener failed; reconnecting")
|
|
786
|
+
await asyncio.sleep(1)
|
|
787
|
+
finally:
|
|
788
|
+
try:
|
|
789
|
+
await pubsub.unsubscribe(QUEUE_WAKE_CHANNEL)
|
|
790
|
+
await pubsub.aclose()
|
|
791
|
+
except Exception:
|
|
792
|
+
pass
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
async def start_stream() -> None:
|
|
796
|
+
"""Connect to Redis and install a fresh StreamManager."""
|
|
797
|
+
global stream_manager, _REDIS, _REDIS_POOL, _WAKE_EVENT, _WAKE_TASK
|
|
798
|
+
|
|
799
|
+
if stream_manager is not None and _REDIS is not None and _WAKE_TASK is not None:
|
|
800
|
+
return
|
|
801
|
+
# Partial leftover from a failed prior start — tear down before reconnect.
|
|
802
|
+
if stream_manager is not None or _REDIS is not None or _WAKE_TASK is not None:
|
|
803
|
+
await stop_stream()
|
|
804
|
+
|
|
805
|
+
uri = _sanitize_redis_uri(_require_redis_uri())
|
|
806
|
+
# Explicit pool size — default redis-py max_connections is too low under load.
|
|
807
|
+
_REDIS_POOL = redis.ConnectionPool.from_url(
|
|
808
|
+
uri,
|
|
809
|
+
decode_responses=False,
|
|
810
|
+
max_connections=_redis_max_connections(),
|
|
811
|
+
**_redis_pool_kwargs(),
|
|
812
|
+
)
|
|
813
|
+
client = redis.Redis(connection_pool=_REDIS_POOL)
|
|
814
|
+
await client.ping()
|
|
815
|
+
_REDIS = client
|
|
816
|
+
stream_manager = StreamManager(_REDIS)
|
|
817
|
+
stream_manager.start_mux()
|
|
818
|
+
|
|
819
|
+
_WAKE_EVENT = asyncio.Event()
|
|
820
|
+
_WAKE_TASK = asyncio.create_task(_wake_listener_loop(), name="redis-queue-wake")
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
async def stop_stream() -> None:
|
|
824
|
+
"""Disconnect Redis and clear local fanout state."""
|
|
825
|
+
global stream_manager, _REDIS, _REDIS_POOL, _WAKE_EVENT, _WAKE_TASK
|
|
826
|
+
|
|
827
|
+
if _WAKE_TASK is not None:
|
|
828
|
+
_WAKE_TASK.cancel()
|
|
829
|
+
await asyncio.gather(_WAKE_TASK, return_exceptions=True)
|
|
830
|
+
_WAKE_TASK = None
|
|
831
|
+
_WAKE_EVENT = None
|
|
832
|
+
|
|
833
|
+
if stream_manager is not None:
|
|
834
|
+
await stream_manager.aclose_fanout()
|
|
835
|
+
stream_manager.queues.clear()
|
|
836
|
+
stream_manager.control_queues.clear()
|
|
837
|
+
stream_manager.message_stores.clear()
|
|
838
|
+
stream_manager.thread_streams.clear()
|
|
839
|
+
stream_manager = None
|
|
840
|
+
|
|
841
|
+
if _REDIS is not None:
|
|
842
|
+
await _REDIS.aclose()
|
|
843
|
+
_REDIS = None
|
|
844
|
+
if _REDIS_POOL is not None:
|
|
845
|
+
await _REDIS_POOL.aclose()
|
|
846
|
+
_REDIS_POOL = None
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def get_stream_manager() -> StreamManager:
|
|
850
|
+
if stream_manager is None:
|
|
851
|
+
raise RuntimeError(
|
|
852
|
+
"Stream manager not started; call start_stream() first (REDIS_URI required)"
|
|
853
|
+
)
|
|
854
|
+
return stream_manager
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _signal_local_wake() -> None:
|
|
858
|
+
"""Bump wake generation and set the shared Event."""
|
|
859
|
+
global _WAKE_GEN
|
|
860
|
+
_WAKE_GEN += 1
|
|
861
|
+
if _WAKE_EVENT is not None:
|
|
862
|
+
_WAKE_EVENT.set()
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
async def wake_run_queue() -> None:
|
|
866
|
+
"""Notify all replicas that a pending run is available."""
|
|
867
|
+
_signal_local_wake()
|
|
868
|
+
if _REDIS is None:
|
|
869
|
+
return
|
|
870
|
+
await _REDIS.publish(QUEUE_WAKE_CHANNEL, b"wake")
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
async def wait_for_queue_wake(timeout: float = 0.5) -> bool:
|
|
874
|
+
"""Wait for wake or timeout; generation counter avoids Event wait/clear lost-wakeup."""
|
|
875
|
+
if _WAKE_EVENT is None:
|
|
876
|
+
await asyncio.sleep(timeout)
|
|
877
|
+
return False
|
|
878
|
+
start = _WAKE_GEN
|
|
879
|
+
if _WAKE_EVENT.is_set():
|
|
880
|
+
_WAKE_EVENT.clear()
|
|
881
|
+
if start != _WAKE_GEN:
|
|
882
|
+
_WAKE_EVENT.set()
|
|
883
|
+
return True
|
|
884
|
+
try:
|
|
885
|
+
await asyncio.wait_for(_WAKE_EVENT.wait(), timeout=timeout)
|
|
886
|
+
except TimeoutError:
|
|
887
|
+
return start != _WAKE_GEN
|
|
888
|
+
except asyncio.CancelledError:
|
|
889
|
+
raise
|
|
890
|
+
end = _WAKE_GEN
|
|
891
|
+
_WAKE_EVENT.clear()
|
|
892
|
+
if end != _WAKE_GEN:
|
|
893
|
+
_WAKE_EVENT.set()
|
|
894
|
+
return True
|