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,270 @@
1
+ """Background run queue (Redis wake, SKIP LOCKED claim, worker dispatch)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import concurrent.futures
7
+ import functools
8
+ import threading
9
+ from collections.abc import Callable, Coroutine
10
+ from contextlib import ExitStack
11
+ from typing import cast
12
+
13
+ import structlog
14
+
15
+ from langgraph_runtime_pg import database, ops
16
+ from langgraph_runtime_pg.redis_stream import (
17
+ bg_job_heartbeat_secs,
18
+ wait_for_queue_wake,
19
+ )
20
+
21
+ logger = structlog.stdlib.get_logger(__name__)
22
+
23
+ _WORKERS_LOCK = threading.Lock()
24
+ WORKERS: set = set()
25
+
26
+ SHUTDOWN_GRACE_PERIOD_SECS = 5
27
+
28
+
29
+ def _workers_add(item: object) -> None:
30
+ with _WORKERS_LOCK:
31
+ WORKERS.add(item)
32
+
33
+
34
+ def _workers_discard(item: object) -> None:
35
+ with _WORKERS_LOCK:
36
+ WORKERS.discard(item)
37
+
38
+
39
+ def _workers_snapshot() -> list:
40
+ with _WORKERS_LOCK:
41
+ return list(WORKERS)
42
+
43
+
44
+ class BgLoopRunner(asyncio.Runner): # type: ignore[misc]
45
+ """asyncio.Runner that owns a loop in a dedicated thread."""
46
+
47
+ executor: concurrent.futures.ThreadPoolExecutor
48
+
49
+ def __init__(self, idx: int):
50
+ super().__init__()
51
+ self.idx = idx
52
+
53
+ def __enter__(self):
54
+ self.executor = concurrent.futures.ThreadPoolExecutor(
55
+ 1, thread_name_prefix=f"bg-loop-{self.idx}"
56
+ )
57
+ self.executor.submit(self.get_loop).result()
58
+ return self
59
+
60
+ def __exit__(self, exc_type, exc_val, exc_tb):
61
+ try:
62
+ loop = self.get_loop()
63
+ for task in asyncio.all_tasks(loop):
64
+ task.cancel("Stopping background loop")
65
+ except Exception:
66
+ pass
67
+ try:
68
+ self.executor.shutdown(wait=False)
69
+ except Exception:
70
+ pass
71
+ return super().__exit__(exc_type, exc_val, exc_tb)
72
+
73
+ def submit(
74
+ self,
75
+ coro: Coroutine,
76
+ *,
77
+ name: str | None = None,
78
+ callback: Callable | None = None,
79
+ ):
80
+ fut = self.executor.submit(self.run, coro, name=name)
81
+ _workers_add(fut)
82
+ if callback:
83
+ fut.add_done_callback(callback)
84
+ return fut
85
+
86
+ def run(self, coro: Coroutine, *, name: str | None = None): # type: ignore[override]
87
+ if asyncio.events._get_running_loop() is not None:
88
+ raise RuntimeError("Runner.run() cannot be called from a running event loop")
89
+ self._lazy_init() # type: ignore[attr-defined]
90
+ task = self._loop.create_task(coro, name=name) # type: ignore[attr-defined]
91
+ try:
92
+ return self._loop.run_until_complete(task) # type: ignore[attr-defined]
93
+ except asyncio.exceptions.CancelledError:
94
+ raise
95
+
96
+
97
+ def get_num_workers() -> int:
98
+ with _WORKERS_LOCK:
99
+ return len(WORKERS)
100
+
101
+
102
+ async def queue() -> None:
103
+ from langgraph_api import config, graph, webhook, worker
104
+ from langgraph_api.asyncio import AsyncQueue
105
+
106
+ concurrency = config.N_JOBS_PER_WORKER
107
+ loop = asyncio.get_running_loop()
108
+ last_stats_secs: float | None = None
109
+ last_sweep_secs: float | None = None
110
+ runners = AsyncQueue[BgLoopRunner](concurrency)
111
+ WEBHOOKS: set = set()
112
+
113
+ with ExitStack() as stack:
114
+ if config.BG_JOB_ISOLATED_LOOPS:
115
+ await logger.ainfo("Starting queue with isolated loops")
116
+ executor = stack.enter_context(concurrent.futures.ThreadPoolExecutor())
117
+ RUNNERS = {stack.enter_context(BgLoopRunner(idx)) for idx in range(concurrency)}
118
+ for r in RUNNERS:
119
+ runners.put_nowait(r)
120
+ r.get_loop().set_default_executor(executor)
121
+ else:
122
+ await logger.ainfo("Starting queue with shared loop")
123
+ for _ in range(concurrency):
124
+ runners.put_nowait(cast(BgLoopRunner, object()))
125
+ expired_runners: list[BgLoopRunner] = []
126
+
127
+ def cleanup(task, runner: BgLoopRunner):
128
+ _workers_discard(task)
129
+ try:
130
+ if config.BG_JOB_ISOLATED_LOOPS:
131
+ loop.call_soon_threadsafe(runners.put_nowait, runner)
132
+ else:
133
+ runners.put_nowait(runner)
134
+ except Exception as exc:
135
+ expired_runners.append(runner)
136
+ logger.exception("Background worker cleanup failed", exc_info=exc)
137
+
138
+ try:
139
+ if task.cancelled():
140
+ return
141
+ task_exc = task.exception()
142
+ if task_exc:
143
+ if not isinstance(task_exc, asyncio.CancelledError):
144
+ logger.exception(
145
+ f"Background worker failed for task {task}",
146
+ exc_info=task_exc,
147
+ )
148
+ return
149
+ result = task.result()
150
+ if result and result.get("webhook"):
151
+ if config.BG_JOB_ISOLATED_LOOPS:
152
+ hook_fut = asyncio.run_coroutine_threadsafe(
153
+ webhook.call_webhook(result), loop
154
+ )
155
+ WEBHOOKS.add(hook_fut)
156
+ hook_fut.add_done_callback(WEBHOOKS.remove)
157
+ else:
158
+ hook_task = loop.create_task(
159
+ webhook.call_webhook(result),
160
+ name=f"webhook-{result['run']['run_id']}",
161
+ )
162
+ WEBHOOKS.add(hook_task)
163
+ hook_task.add_done_callback(WEBHOOKS.remove)
164
+ except asyncio.CancelledError:
165
+ pass
166
+ except Exception as exc:
167
+ logger.exception("Background worker cleanup failed", exc_info=exc)
168
+
169
+ await logger.ainfo(f"Starting {concurrency} background workers")
170
+ try:
171
+ run = None
172
+ while True:
173
+ if expired_runners:
174
+ for runner in expired_runners:
175
+ await runners.put(runner)
176
+ expired_runners.clear()
177
+ await runners.wait()
178
+ try:
179
+ sweep_every = bg_job_heartbeat_secs() * 2
180
+ do_sweep = (
181
+ last_sweep_secs is None or loop.time() - last_sweep_secs > sweep_every
182
+ )
183
+ if calc_stats := (
184
+ last_stats_secs is None
185
+ or loop.time() - last_stats_secs > config.STATS_INTERVAL_SECS
186
+ ):
187
+ last_stats_secs = loop.time()
188
+ active = get_num_workers()
189
+ await logger.ainfo(
190
+ "Worker stats",
191
+ max=concurrency,
192
+ available=concurrency - active,
193
+ active=active,
194
+ )
195
+
196
+ if run is None and last_stats_secs is not None:
197
+ await wait_for_queue_wake(timeout=0.5)
198
+
199
+ run = None
200
+ async for run, attempt in ops.Runs.next(wait=False, limit=runners.qsize()):
201
+ runner = runners.get_nowait()
202
+ graph_id = (
203
+ run["kwargs"].get("config", {}).get("configurable", {}).get("graph_id")
204
+ )
205
+ task_name = f"run-{run['run_id']}-attempt-{attempt}"
206
+ if not config.BG_JOB_ISOLATED_LOOPS or (
207
+ graph_id and graph.is_js_graph(graph_id)
208
+ ):
209
+ task = asyncio.create_task(
210
+ worker.worker(run, attempt, loop),
211
+ name=task_name,
212
+ )
213
+ task.add_done_callback(functools.partial(cleanup, runner=runner))
214
+ _workers_add(task)
215
+ else:
216
+ runner.submit(
217
+ worker.worker(run, attempt, loop),
218
+ name=task_name,
219
+ callback=functools.partial(cleanup, runner=runner),
220
+ )
221
+
222
+ if calc_stats or do_sweep:
223
+ async with database.connect() as conn:
224
+ if calc_stats:
225
+ stats = await ops.Runs.stats(conn)
226
+ await logger.ainfo("Queue stats", **stats)
227
+ if do_sweep:
228
+ last_sweep_secs = loop.time()
229
+ swept = await ops.Runs.sweep()
230
+ if swept:
231
+ await logger.awarning(
232
+ "Swept stale runs",
233
+ count=len(swept),
234
+ )
235
+ except Exception as exc:
236
+ logger.exception("Background worker scheduler failed", exc_info=exc)
237
+ await asyncio.sleep(1)
238
+ finally:
239
+ logger.info("Shutting down background workers")
240
+ workers = _workers_snapshot()
241
+ webhooks = list(WEBHOOKS)
242
+ for task in workers:
243
+ task.cancel()
244
+ for task in webhooks:
245
+ task.cancel()
246
+ # Isolated loops return concurrent.futures.Future; bridge to asyncio.
247
+ from langgraph_api.utils.future import chain_future
248
+
249
+ futs: list[asyncio.Future] = []
250
+ if config.BG_JOB_ISOLATED_LOOPS:
251
+ futs.extend(
252
+ cast(asyncio.Future, chain_future(f, loop.create_future())) for f in workers
253
+ )
254
+ futs.extend(
255
+ cast(asyncio.Future, chain_future(f, loop.create_future())) for f in webhooks
256
+ )
257
+ else:
258
+ futs.extend(cast(asyncio.Future, f) for f in workers)
259
+ futs.extend(cast(asyncio.Future, f) for f in webhooks)
260
+ if futs:
261
+ try:
262
+ await asyncio.wait_for(
263
+ asyncio.gather(*futs, return_exceptions=True),
264
+ SHUTDOWN_GRACE_PERIOD_SECS,
265
+ )
266
+ except TimeoutError:
267
+ logger.warning(
268
+ "Background workers did not finish within grace period",
269
+ timeout=SHUTDOWN_GRACE_PERIOD_SECS,
270
+ )