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,38 @@
|
|
|
1
|
+
"""Postgres+Redis LangGraph runtime (LANGGRAPH_RUNTIME_EDITION=pg)."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from langgraph_runtime_pg import (
|
|
6
|
+
checkpoint,
|
|
7
|
+
database,
|
|
8
|
+
lifespan,
|
|
9
|
+
metrics,
|
|
10
|
+
migrate,
|
|
11
|
+
models,
|
|
12
|
+
ops,
|
|
13
|
+
queue,
|
|
14
|
+
redis_stream,
|
|
15
|
+
retry,
|
|
16
|
+
routes,
|
|
17
|
+
store,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
__version__ = version("langgraph-runtime-pg")
|
|
22
|
+
except PackageNotFoundError: # pragma: no cover - editable / source tree edge
|
|
23
|
+
__version__ = "0.0.0"
|
|
24
|
+
__all__ = [
|
|
25
|
+
"__version__",
|
|
26
|
+
"checkpoint",
|
|
27
|
+
"database",
|
|
28
|
+
"lifespan",
|
|
29
|
+
"metrics",
|
|
30
|
+
"migrate",
|
|
31
|
+
"models",
|
|
32
|
+
"ops",
|
|
33
|
+
"queue",
|
|
34
|
+
"redis_stream",
|
|
35
|
+
"retry",
|
|
36
|
+
"routes",
|
|
37
|
+
"store",
|
|
38
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Checkpointer factory wrapping langgraph-checkpoint-postgres."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
|
9
|
+
from psycopg.rows import dict_row
|
|
10
|
+
from psycopg_pool import AsyncConnectionPool
|
|
11
|
+
|
|
12
|
+
from langgraph_runtime_pg.database import to_psycopg_uri
|
|
13
|
+
|
|
14
|
+
_POOL: AsyncConnectionPool[Any] | None = None
|
|
15
|
+
_CHECKPOINTER: AsyncPostgresSaver | None = None
|
|
16
|
+
_SETUP_LOCK = asyncio.Lock()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def setup_checkpointer() -> AsyncPostgresSaver:
|
|
20
|
+
"""Open a psycopg pool and initialize AsyncPostgresSaver tables."""
|
|
21
|
+
global _POOL, _CHECKPOINTER
|
|
22
|
+
async with _SETUP_LOCK:
|
|
23
|
+
if _CHECKPOINTER is not None:
|
|
24
|
+
return _CHECKPOINTER
|
|
25
|
+
if _POOL is not None:
|
|
26
|
+
try:
|
|
27
|
+
await _POOL.close()
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
_POOL = None
|
|
31
|
+
|
|
32
|
+
uri = to_psycopg_uri()
|
|
33
|
+
# autocommit required: setup() runs CREATE INDEX CONCURRENTLY
|
|
34
|
+
pool = AsyncConnectionPool(
|
|
35
|
+
conninfo=uri,
|
|
36
|
+
min_size=1,
|
|
37
|
+
max_size=10,
|
|
38
|
+
open=False,
|
|
39
|
+
kwargs={
|
|
40
|
+
"autocommit": True,
|
|
41
|
+
"prepare_threshold": 0,
|
|
42
|
+
"row_factory": dict_row,
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
try:
|
|
46
|
+
await pool.open()
|
|
47
|
+
saver = AsyncPostgresSaver(cast(Any, pool))
|
|
48
|
+
await saver.setup()
|
|
49
|
+
except Exception:
|
|
50
|
+
try:
|
|
51
|
+
await pool.close()
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
raise
|
|
55
|
+
_POOL = pool
|
|
56
|
+
_CHECKPOINTER = saver
|
|
57
|
+
return _CHECKPOINTER
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def teardown_checkpointer() -> None:
|
|
61
|
+
"""Close the checkpointer pool."""
|
|
62
|
+
global _POOL, _CHECKPOINTER
|
|
63
|
+
_CHECKPOINTER = None
|
|
64
|
+
pool = _POOL
|
|
65
|
+
_POOL = None
|
|
66
|
+
if pool is not None:
|
|
67
|
+
try:
|
|
68
|
+
await pool.close()
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def Checkpointer(*args: Any, unpack_hook: Any = None, **kwargs: Any) -> AsyncPostgresSaver:
|
|
74
|
+
"""Return the process-wide AsyncPostgresSaver (requires setup_checkpointer)."""
|
|
75
|
+
if _CHECKPOINTER is None:
|
|
76
|
+
raise RuntimeError(
|
|
77
|
+
"Checkpointer not initialized; call start_pool()/setup_checkpointer() first "
|
|
78
|
+
"(DATABASE_URI required)"
|
|
79
|
+
)
|
|
80
|
+
return _CHECKPOINTER
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
__all__ = ["Checkpointer", "setup_checkpointer", "teardown_checkpointer"]
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""Postgres pool and connect() over SQLAlchemy async sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import os
|
|
8
|
+
import ssl as ssl_module
|
|
9
|
+
from collections.abc import AsyncIterator
|
|
10
|
+
from contextlib import asynccontextmanager
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
import structlog
|
|
16
|
+
from sqlalchemy import text
|
|
17
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
18
|
+
|
|
19
|
+
from langgraph_runtime_pg.models import Base, RetryCounterRow
|
|
20
|
+
from langgraph_runtime_pg.redis_stream import start_stream, stop_stream
|
|
21
|
+
|
|
22
|
+
logger = structlog.stdlib.get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
_ENGINE = None
|
|
25
|
+
_SESSION_FACTORY: async_sessionmaker[AsyncSession] | None = None
|
|
26
|
+
|
|
27
|
+
# libpq SSL query keys — strip from URL; asyncpg only accepts ssl= connect arg.
|
|
28
|
+
_LIBPQ_SSL_QUERY_KEYS = frozenset(
|
|
29
|
+
{
|
|
30
|
+
"sslmode",
|
|
31
|
+
"sslcert",
|
|
32
|
+
"sslkey",
|
|
33
|
+
"sslrootcert",
|
|
34
|
+
"sslcrl",
|
|
35
|
+
"sslpassword",
|
|
36
|
+
"channel_binding",
|
|
37
|
+
"gssencmode",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_database_uri() -> str:
|
|
43
|
+
uri = os.environ.get("DATABASE_URI")
|
|
44
|
+
if not uri:
|
|
45
|
+
raise RuntimeError("DATABASE_URI is required for langgraph_runtime_pg")
|
|
46
|
+
return uri
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def to_psycopg_uri(uri: str | None = None) -> str:
|
|
50
|
+
"""Normalize DATABASE_URI to a psycopg-style ``postgresql://`` URL."""
|
|
51
|
+
uri = uri or get_database_uri()
|
|
52
|
+
for prefix in (
|
|
53
|
+
"postgresql+asyncpg://",
|
|
54
|
+
"postgres+asyncpg://",
|
|
55
|
+
"postgresql+psycopg://",
|
|
56
|
+
"postgresql+psycopg2://",
|
|
57
|
+
"postgres+psycopg://",
|
|
58
|
+
"postgres+psycopg2://",
|
|
59
|
+
"postgresql://",
|
|
60
|
+
"postgres://",
|
|
61
|
+
):
|
|
62
|
+
if uri.startswith(prefix):
|
|
63
|
+
return "postgresql://" + uri[len(prefix) :]
|
|
64
|
+
if "://" in uri:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"Unsupported DATABASE_URI scheme {uri.split('://', 1)[0]!r}; "
|
|
67
|
+
"expected postgres/postgresql (optionally +asyncpg/+psycopg/+psycopg2)"
|
|
68
|
+
)
|
|
69
|
+
return uri
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def to_async_sqlalchemy_uri(uri: str | None = None) -> str:
|
|
73
|
+
"""Normalize DATABASE_URI to ``postgresql+asyncpg://``."""
|
|
74
|
+
uri = uri or get_database_uri()
|
|
75
|
+
if uri.startswith("postgresql+asyncpg://"):
|
|
76
|
+
return uri
|
|
77
|
+
if uri.startswith("postgres+asyncpg://"):
|
|
78
|
+
return "postgresql+asyncpg://" + uri[len("postgres+asyncpg://") :]
|
|
79
|
+
bare = to_psycopg_uri(uri)
|
|
80
|
+
if bare.startswith("postgresql://"):
|
|
81
|
+
return "postgresql+asyncpg://" + bare.removeprefix("postgresql://")
|
|
82
|
+
return bare
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def asyncpg_engine_args(uri: str | None = None) -> tuple[str, dict[str, Any]]:
|
|
86
|
+
"""Return ``(async_uri, connect_args)`` with libpq sslmode translated for asyncpg."""
|
|
87
|
+
async_uri = to_async_sqlalchemy_uri(uri)
|
|
88
|
+
parts = urlsplit(async_uri)
|
|
89
|
+
kept: list[tuple[str, str]] = []
|
|
90
|
+
sslmode: str | None = None
|
|
91
|
+
sslrootcert: str | None = None
|
|
92
|
+
sslcert: str | None = None
|
|
93
|
+
sslkey: str | None = None
|
|
94
|
+
sslpassword: str | None = None
|
|
95
|
+
|
|
96
|
+
for key, value in parse_qsl(parts.query, keep_blank_values=True):
|
|
97
|
+
kl = key.lower()
|
|
98
|
+
if kl == "sslmode":
|
|
99
|
+
sslmode = value.lower()
|
|
100
|
+
elif kl == "sslrootcert":
|
|
101
|
+
sslrootcert = value
|
|
102
|
+
elif kl == "sslcert":
|
|
103
|
+
sslcert = value
|
|
104
|
+
elif kl == "sslkey":
|
|
105
|
+
sslkey = value
|
|
106
|
+
elif kl == "sslpassword":
|
|
107
|
+
sslpassword = value
|
|
108
|
+
elif kl in _LIBPQ_SSL_QUERY_KEYS:
|
|
109
|
+
continue
|
|
110
|
+
else:
|
|
111
|
+
kept.append((key, value))
|
|
112
|
+
|
|
113
|
+
connect_args: dict[str, Any] = {}
|
|
114
|
+
if sslmode in ("require", "verify-ca", "verify-full") or any((sslrootcert, sslcert, sslkey)):
|
|
115
|
+
if sslmode == "require" and not sslrootcert and not sslcert and not sslkey:
|
|
116
|
+
connect_args["ssl"] = True
|
|
117
|
+
else:
|
|
118
|
+
ctx = (
|
|
119
|
+
ssl_module.create_default_context(cafile=sslrootcert)
|
|
120
|
+
if sslrootcert
|
|
121
|
+
else ssl_module.create_default_context()
|
|
122
|
+
)
|
|
123
|
+
if sslmode == "require":
|
|
124
|
+
# libpq require encrypts without CA verification.
|
|
125
|
+
ctx.check_hostname = False
|
|
126
|
+
ctx.verify_mode = ssl_module.CERT_NONE
|
|
127
|
+
if sslcert and sslkey:
|
|
128
|
+
ctx.load_cert_chain(sslcert, keyfile=sslkey, password=sslpassword)
|
|
129
|
+
connect_args["ssl"] = ctx
|
|
130
|
+
elif sslmode == "disable":
|
|
131
|
+
connect_args["ssl"] = False
|
|
132
|
+
|
|
133
|
+
clean = urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(kept), parts.fragment))
|
|
134
|
+
return clean, connect_args
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
|
138
|
+
if _SESSION_FACTORY is None:
|
|
139
|
+
raise RuntimeError("Call start_pool() before get_session_factory()")
|
|
140
|
+
return _SESSION_FACTORY
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
_ASSISTANT_KEYS = [
|
|
144
|
+
"assistant_id",
|
|
145
|
+
"graph_id",
|
|
146
|
+
"name",
|
|
147
|
+
"description",
|
|
148
|
+
"config",
|
|
149
|
+
"context",
|
|
150
|
+
"metadata",
|
|
151
|
+
"version",
|
|
152
|
+
"created_at",
|
|
153
|
+
"updated_at",
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
_ASSISTANT_VERSION_KEYS = [
|
|
157
|
+
"assistant_id",
|
|
158
|
+
"version",
|
|
159
|
+
"graph_id",
|
|
160
|
+
"config",
|
|
161
|
+
"context",
|
|
162
|
+
"metadata",
|
|
163
|
+
"name",
|
|
164
|
+
"description",
|
|
165
|
+
"created_at",
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
_THREAD_KEYS = [
|
|
169
|
+
"thread_id",
|
|
170
|
+
"status",
|
|
171
|
+
"metadata",
|
|
172
|
+
"config",
|
|
173
|
+
"values",
|
|
174
|
+
"interrupts",
|
|
175
|
+
"error",
|
|
176
|
+
"created_at",
|
|
177
|
+
"updated_at",
|
|
178
|
+
"state_updated_at",
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
_RUN_KEYS = [
|
|
182
|
+
"run_id",
|
|
183
|
+
"thread_id",
|
|
184
|
+
"assistant_id",
|
|
185
|
+
"status",
|
|
186
|
+
"metadata",
|
|
187
|
+
"kwargs",
|
|
188
|
+
"multitask_strategy",
|
|
189
|
+
"created_at",
|
|
190
|
+
"updated_at",
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
_CRON_KEYS = [
|
|
194
|
+
"cron_id",
|
|
195
|
+
"assistant_id",
|
|
196
|
+
"thread_id",
|
|
197
|
+
"schedule",
|
|
198
|
+
"payload",
|
|
199
|
+
"metadata",
|
|
200
|
+
"next_run_date",
|
|
201
|
+
"end_time",
|
|
202
|
+
"user_id",
|
|
203
|
+
"timezone",
|
|
204
|
+
"on_run_completed",
|
|
205
|
+
"enabled",
|
|
206
|
+
"created_at",
|
|
207
|
+
"updated_at",
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def row_to_dict(row: Any, keys: list[str]) -> dict[str, Any]:
|
|
212
|
+
d: dict[str, Any] = {}
|
|
213
|
+
for k in keys:
|
|
214
|
+
attr = k + "_" if k in ("metadata", "values") else k
|
|
215
|
+
val = getattr(row, attr, getattr(row, k, None))
|
|
216
|
+
d[k] = val
|
|
217
|
+
return d
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def assistant_to_dict(r: Any) -> dict:
|
|
221
|
+
return row_to_dict(r, _ASSISTANT_KEYS)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def assistant_version_to_dict(r: Any) -> dict:
|
|
225
|
+
return row_to_dict(r, _ASSISTANT_VERSION_KEYS)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def thread_to_dict(r: Any) -> dict:
|
|
229
|
+
return row_to_dict(r, _THREAD_KEYS)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def run_to_dict(r: Any) -> dict:
|
|
233
|
+
return row_to_dict(r, _RUN_KEYS)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def cron_to_dict(r: Any) -> dict:
|
|
237
|
+
return row_to_dict(r, _CRON_KEYS)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
_INCREMENT_SQL = text(
|
|
241
|
+
"""
|
|
242
|
+
INSERT INTO retry_counters (run_id, count) VALUES (:run_id, 1)
|
|
243
|
+
ON CONFLICT (run_id) DO UPDATE SET count = retry_counters.count + 1
|
|
244
|
+
RETURNING count
|
|
245
|
+
"""
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class PgRetryCounter:
|
|
250
|
+
"""Async retry counter over the retry_counters table."""
|
|
251
|
+
|
|
252
|
+
def __init__(self, session_factory: async_sessionmaker[AsyncSession]):
|
|
253
|
+
self._sf = session_factory
|
|
254
|
+
|
|
255
|
+
async def increment(self, run_id: UUID, *, session: AsyncSession | None = None) -> int:
|
|
256
|
+
"""Atomically bump the counter; pass ``session`` when already in a txn."""
|
|
257
|
+
if session is not None:
|
|
258
|
+
result = await session.execute(_INCREMENT_SQL, {"run_id": run_id})
|
|
259
|
+
return int(result.scalar_one())
|
|
260
|
+
async with self._sf() as own:
|
|
261
|
+
result = await own.execute(_INCREMENT_SQL, {"run_id": run_id})
|
|
262
|
+
count = int(result.scalar_one())
|
|
263
|
+
await own.commit()
|
|
264
|
+
return count
|
|
265
|
+
|
|
266
|
+
async def get(self, run_id: UUID) -> int:
|
|
267
|
+
async with self._sf() as session:
|
|
268
|
+
row = await session.get(RetryCounterRow, run_id)
|
|
269
|
+
return int(row.count) if row is not None else 0
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class _LockedSession:
|
|
273
|
+
"""Serialize AsyncSession ops so asyncio.gather on one conn is safe."""
|
|
274
|
+
|
|
275
|
+
def __init__(self, session: AsyncSession, lock: asyncio.Lock):
|
|
276
|
+
self._session = session
|
|
277
|
+
self._lock = lock
|
|
278
|
+
self._wrapped: dict[str, Any] = {}
|
|
279
|
+
|
|
280
|
+
def __getattr__(self, name: str) -> Any:
|
|
281
|
+
cached = self._wrapped.get(name)
|
|
282
|
+
if cached is not None:
|
|
283
|
+
return cached
|
|
284
|
+
attr = getattr(self._session, name)
|
|
285
|
+
if not callable(attr):
|
|
286
|
+
return attr
|
|
287
|
+
if not inspect.iscoroutinefunction(attr):
|
|
288
|
+
return attr
|
|
289
|
+
|
|
290
|
+
async def _locked(*args: Any, **kwargs: Any):
|
|
291
|
+
async with self._lock:
|
|
292
|
+
return await attr(*args, **kwargs)
|
|
293
|
+
|
|
294
|
+
self._wrapped[name] = _locked
|
|
295
|
+
return _locked
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class PgConnectionProto:
|
|
299
|
+
"""Connection handle with AsyncSession + after-commit/rollback hooks."""
|
|
300
|
+
|
|
301
|
+
def __init__(
|
|
302
|
+
self,
|
|
303
|
+
session: AsyncSession,
|
|
304
|
+
retry_counter: PgRetryCounter,
|
|
305
|
+
session_factory: async_sessionmaker[AsyncSession],
|
|
306
|
+
):
|
|
307
|
+
self._raw_session = session
|
|
308
|
+
self._lock = asyncio.Lock()
|
|
309
|
+
self.session = _LockedSession(session, self._lock)
|
|
310
|
+
self.retry_counter = retry_counter
|
|
311
|
+
self.can_execute = False
|
|
312
|
+
self._sf = session_factory
|
|
313
|
+
self._after_commit: list = []
|
|
314
|
+
self._after_rollback: list = []
|
|
315
|
+
# Empty lists kept for callers that still introspect conn.store keys.
|
|
316
|
+
self.store: dict[str, list] = {
|
|
317
|
+
"assistants": [],
|
|
318
|
+
"assistant_versions": [],
|
|
319
|
+
"threads": [],
|
|
320
|
+
"runs": [],
|
|
321
|
+
"crons": [],
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
def schedule_after_commit(self, cb) -> None:
|
|
325
|
+
"""Run ``cb`` (async zero-arg) after the enclosing ``connect()`` commits."""
|
|
326
|
+
self._after_commit.append(cb)
|
|
327
|
+
|
|
328
|
+
def schedule_after_rollback(self, cb) -> None:
|
|
329
|
+
"""Run ``cb`` (async zero-arg) if the enclosing ``connect()`` rolls back."""
|
|
330
|
+
self._after_rollback.append(cb)
|
|
331
|
+
|
|
332
|
+
@asynccontextmanager
|
|
333
|
+
async def pipeline(self):
|
|
334
|
+
yield None
|
|
335
|
+
|
|
336
|
+
async def execute(self, query: str, *args: Any, **kwargs: Any):
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
async def commit(self) -> None:
|
|
340
|
+
async with self._lock:
|
|
341
|
+
await self._raw_session.commit()
|
|
342
|
+
|
|
343
|
+
def clear(self) -> None:
|
|
344
|
+
for k in self.store:
|
|
345
|
+
self.store[k] = []
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _auto_migrate_enabled() -> bool:
|
|
349
|
+
raw = os.environ.get("LG_RUNTIME_PG_AUTO_MIGRATE", "true").strip().lower()
|
|
350
|
+
return raw not in ("0", "false", "no", "off")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
async def start_pool() -> None:
|
|
354
|
+
global _ENGINE, _SESSION_FACTORY
|
|
355
|
+
from langgraph_runtime_pg.checkpoint import setup_checkpointer
|
|
356
|
+
from langgraph_runtime_pg.migrate import upgrade_head
|
|
357
|
+
from langgraph_runtime_pg.store import setup_store
|
|
358
|
+
|
|
359
|
+
if _ENGINE is not None and _SESSION_FACTORY is not None:
|
|
360
|
+
logger.info("PG pool already started")
|
|
361
|
+
return
|
|
362
|
+
if _ENGINE is not None or _SESSION_FACTORY is not None:
|
|
363
|
+
await stop_pool()
|
|
364
|
+
|
|
365
|
+
raw_uri = get_database_uri()
|
|
366
|
+
# Set LG_RUNTIME_PG_AUTO_MIGRATE=false when migrations run as a pre-deploy job.
|
|
367
|
+
if _auto_migrate_enabled():
|
|
368
|
+
upgrade_head(raw_uri)
|
|
369
|
+
logger.info("Postgres schema at Alembic head")
|
|
370
|
+
pool_size = int(os.environ.get("PG_POOL_SIZE", "20"))
|
|
371
|
+
max_overflow = int(os.environ.get("PG_MAX_OVERFLOW", "20"))
|
|
372
|
+
engine_uri, connect_args = asyncpg_engine_args(raw_uri)
|
|
373
|
+
engine = create_async_engine(
|
|
374
|
+
engine_uri,
|
|
375
|
+
echo=False,
|
|
376
|
+
pool_pre_ping=True,
|
|
377
|
+
pool_size=max(pool_size, 5),
|
|
378
|
+
max_overflow=max(max_overflow, 0),
|
|
379
|
+
connect_args=connect_args,
|
|
380
|
+
)
|
|
381
|
+
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
382
|
+
# Assign globals first so stop_pool() can dispose if a dependent fails.
|
|
383
|
+
try:
|
|
384
|
+
_ENGINE = engine
|
|
385
|
+
_SESSION_FACTORY = session_factory
|
|
386
|
+
await setup_checkpointer()
|
|
387
|
+
await setup_store()
|
|
388
|
+
await start_stream()
|
|
389
|
+
except Exception:
|
|
390
|
+
await stop_pool()
|
|
391
|
+
raise
|
|
392
|
+
logger.info("PG pool started", uri=engine_uri.split("@")[-1])
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
async def stop_pool() -> None:
|
|
396
|
+
global _ENGINE, _SESSION_FACTORY
|
|
397
|
+
from langgraph_runtime_pg.checkpoint import teardown_checkpointer
|
|
398
|
+
from langgraph_runtime_pg.store import teardown_store
|
|
399
|
+
|
|
400
|
+
await stop_stream()
|
|
401
|
+
await teardown_store()
|
|
402
|
+
await teardown_checkpointer()
|
|
403
|
+
if _ENGINE is not None:
|
|
404
|
+
await _ENGINE.dispose()
|
|
405
|
+
_ENGINE = None
|
|
406
|
+
_SESSION_FACTORY = None
|
|
407
|
+
logger.info("PG pool stopped")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@asynccontextmanager
|
|
411
|
+
async def connect(
|
|
412
|
+
*, supports_core_api: bool = False, __test__: bool = False
|
|
413
|
+
) -> AsyncIterator[PgConnectionProto]:
|
|
414
|
+
del __test__ # accepted for API parity; unused
|
|
415
|
+
if _SESSION_FACTORY is None:
|
|
416
|
+
raise RuntimeError("Call start_pool() before connect()")
|
|
417
|
+
async with _SESSION_FACTORY() as session:
|
|
418
|
+
proto = PgConnectionProto(
|
|
419
|
+
session=session,
|
|
420
|
+
retry_counter=PgRetryCounter(_SESSION_FACTORY),
|
|
421
|
+
session_factory=_SESSION_FACTORY,
|
|
422
|
+
)
|
|
423
|
+
try:
|
|
424
|
+
yield proto
|
|
425
|
+
async with proto._lock:
|
|
426
|
+
await session.commit()
|
|
427
|
+
except Exception:
|
|
428
|
+
async with proto._lock:
|
|
429
|
+
await session.rollback()
|
|
430
|
+
for cb in proto._after_rollback:
|
|
431
|
+
try:
|
|
432
|
+
await cb()
|
|
433
|
+
except Exception:
|
|
434
|
+
logger.debug("after_rollback callback failed", exc_info=True)
|
|
435
|
+
raise
|
|
436
|
+
else:
|
|
437
|
+
for cb in proto._after_commit:
|
|
438
|
+
try:
|
|
439
|
+
await cb()
|
|
440
|
+
except Exception:
|
|
441
|
+
logger.debug("after_commit callback failed", exc_info=True)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
async def healthcheck(*, check_db: bool = True) -> None:
|
|
445
|
+
if check_db and _ENGINE is not None:
|
|
446
|
+
async with _ENGINE.connect() as conn:
|
|
447
|
+
await conn.execute(text("SELECT 1"))
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def pool_stats(*args: Any, **kwargs: Any) -> dict[str, dict[str, int]]:
|
|
451
|
+
return {}
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
# Checkpoint/store tables owned outside Base.metadata; truncate in tests too.
|
|
455
|
+
_EXTERNAL_TRUNCATE_TABLES = (
|
|
456
|
+
"checkpoints",
|
|
457
|
+
"checkpoint_blobs",
|
|
458
|
+
"checkpoint_writes",
|
|
459
|
+
"store",
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
async def truncate_all() -> None:
|
|
464
|
+
"""Truncate ORM + checkpoint/store tables (tests)."""
|
|
465
|
+
if _ENGINE is None:
|
|
466
|
+
return
|
|
467
|
+
orm_tables = [t.name for t in reversed(Base.metadata.sorted_tables)]
|
|
468
|
+
async with _ENGINE.begin() as conn:
|
|
469
|
+
existing = {
|
|
470
|
+
row[0]
|
|
471
|
+
for row in (
|
|
472
|
+
await conn.execute(
|
|
473
|
+
text("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")
|
|
474
|
+
)
|
|
475
|
+
).all()
|
|
476
|
+
}
|
|
477
|
+
tables = [t for t in (*orm_tables, *_EXTERNAL_TRUNCATE_TABLES) if t in existing]
|
|
478
|
+
if not tables:
|
|
479
|
+
return
|
|
480
|
+
await conn.execute(text(f"TRUNCATE TABLE {', '.join(tables)} CASCADE"))
|