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