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