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