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,198 @@
1
+ """Starlette lifespan: PG pool, Redis, graphs, and queue."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import signal
7
+ from collections.abc import AsyncIterator
8
+ from contextlib import asynccontextmanager
9
+ from typing import Any, cast
10
+
11
+ import structlog
12
+ from langchain_core.runnables.config import RunnableConfig, var_child_runnable_config
13
+ from langgraph.constants import CONF
14
+ from starlette.applications import Starlette
15
+
16
+ from langgraph_runtime_pg import queue
17
+ from langgraph_runtime_pg.database import start_pool, stop_pool
18
+
19
+ logger = structlog.stdlib.get_logger(__name__)
20
+
21
+ _LAST_LIFESPAN_ERROR: BaseException | None = None
22
+
23
+
24
+ def get_last_error() -> BaseException | None:
25
+ return _LAST_LIFESPAN_ERROR
26
+
27
+
28
+ @asynccontextmanager
29
+ async def lifespan(
30
+ app: Starlette | None = None,
31
+ cancel_event: asyncio.Event | None = None,
32
+ taskset: set[asyncio.Task] | None = None,
33
+ **kwargs: Any,
34
+ ) -> AsyncIterator[None]:
35
+ import langgraph_api.config as config
36
+ from langgraph_api import (
37
+ __version__,
38
+ _checkpointer as api_checkpointer,
39
+ feature_flags,
40
+ graph,
41
+ store as api_store,
42
+ )
43
+ from langgraph_api.asyncio import SimpleTaskGroup, set_event_loop
44
+ from langgraph_api.http import (
45
+ start_http_client,
46
+ stop_http_client,
47
+ stop_webhook_http_client,
48
+ )
49
+ from langgraph_api.js.ui import start_ui_bundler, stop_ui_bundler
50
+ from langgraph_api.metadata import metadata_loop
51
+
52
+ from langgraph_runtime_pg import __version__ as runtime_version
53
+
54
+ await logger.ainfo(
55
+ f"Starting PG runtime with langgraph-api={__version__} "
56
+ f"and langgraph-runtime-pg={runtime_version}",
57
+ version=__version__,
58
+ runtime_version=runtime_version,
59
+ )
60
+ try:
61
+ current_loop = asyncio.get_running_loop()
62
+ set_event_loop(current_loop)
63
+ except RuntimeError:
64
+ await logger.aerror("Failed to set loop")
65
+
66
+ global _LAST_LIFESPAN_ERROR
67
+ _LAST_LIFESPAN_ERROR = None
68
+
69
+ async def _log_graph_load_failure(err: graph.GraphLoadError) -> None:
70
+ cause = err.__cause__ or err.cause
71
+ log_fields = err.log_fields()
72
+ log_fields["action"] = "fix_user_graph"
73
+ await logger.aerror(
74
+ f"Graph '{err.spec.id}' failed to load: {err.cause_message}",
75
+ **log_fields,
76
+ )
77
+ await logger.adebug(
78
+ "Full graph load failure traceback (internal)",
79
+ **{k: v for k, v in log_fields.items() if k != "user_traceback"},
80
+ exc_info=cause,
81
+ )
82
+
83
+ started_http = False
84
+ started_pool = False
85
+ started_checkpointer = False
86
+ started_ui = False
87
+ try:
88
+ await start_http_client()
89
+ started_http = True
90
+ await start_pool()
91
+ started_pool = True
92
+ await api_checkpointer.start_checkpointer()
93
+ started_checkpointer = True
94
+ await start_ui_bundler()
95
+ started_ui = True
96
+
97
+ async with SimpleTaskGroup(
98
+ cancel=True,
99
+ cancel_event=cancel_event,
100
+ taskgroup_name="Lifespan",
101
+ ) as tg:
102
+ tg.create_task(metadata_loop())
103
+ await api_store.collect_store_from_env()
104
+ store_instance = await api_store.get_store()
105
+ if not api_store.CUSTOM_STORE:
106
+ tg.create_task(store_instance.start_ttl_sweeper())
107
+ else:
108
+ await logger.ainfo("Using custom store. Skipping store TTL sweeper.")
109
+
110
+ if feature_flags.USE_RUNTIME_CONTEXT_API:
111
+ from langgraph._internal._constants import (
112
+ CONFIG_KEY_RUNTIME,
113
+ )
114
+ from langgraph.runtime import Runtime
115
+
116
+ langgraph_config = cast(
117
+ RunnableConfig,
118
+ {CONF: {CONFIG_KEY_RUNTIME: Runtime(store=store_instance)}},
119
+ )
120
+ else:
121
+ from langgraph.constants import CONFIG_KEY_STORE
122
+
123
+ langgraph_config = cast(
124
+ RunnableConfig,
125
+ {CONF: {CONFIG_KEY_STORE: store_instance}},
126
+ )
127
+
128
+ var_child_runnable_config.set(langgraph_config)
129
+
130
+ graph.patch_packages_distributions()
131
+ try:
132
+ await graph.collect_graphs_from_env(True)
133
+ except graph.GraphLoadError as exc:
134
+ _LAST_LIFESPAN_ERROR = exc
135
+ await _log_graph_load_failure(exc)
136
+ raise
137
+ if config.N_JOBS_PER_WORKER > 0:
138
+ tg.create_task(queue_with_signal())
139
+
140
+ from langgraph_api import cron_scheduler
141
+
142
+ tg.create_task(cron_scheduler.cron_scheduler())
143
+
144
+ yield
145
+ except graph.GraphLoadError as exc:
146
+ _LAST_LIFESPAN_ERROR = exc
147
+ raise
148
+ except asyncio.CancelledError:
149
+ pass
150
+ finally:
151
+ try:
152
+ await api_store.exit_store()
153
+ except Exception:
154
+ logger.debug("exit_store failed during lifespan cleanup", exc_info=True)
155
+ if started_checkpointer:
156
+ try:
157
+ await api_checkpointer.exit_checkpointer()
158
+ except Exception:
159
+ logger.debug("exit_checkpointer failed during lifespan cleanup", exc_info=True)
160
+ if started_ui:
161
+ try:
162
+ await stop_ui_bundler()
163
+ except Exception:
164
+ logger.debug("stop_ui_bundler failed during lifespan cleanup", exc_info=True)
165
+ try:
166
+ await graph.stop_remote_graphs()
167
+ except Exception:
168
+ logger.debug("stop_remote_graphs failed during lifespan cleanup", exc_info=True)
169
+ if started_http:
170
+ try:
171
+ await stop_http_client()
172
+ except Exception:
173
+ logger.debug("stop_http_client failed during lifespan cleanup", exc_info=True)
174
+ try:
175
+ await stop_webhook_http_client()
176
+ except Exception:
177
+ logger.debug(
178
+ "stop_webhook_http_client failed during lifespan cleanup",
179
+ exc_info=True,
180
+ )
181
+ if started_pool:
182
+ try:
183
+ await stop_pool()
184
+ except Exception:
185
+ logger.debug("stop_pool failed during lifespan cleanup", exc_info=True)
186
+
187
+
188
+ async def queue_with_signal() -> None:
189
+ try:
190
+ await queue.queue()
191
+ except asyncio.CancelledError:
192
+ pass
193
+ except Exception as exc:
194
+ logger.exception("Queue failed. Signaling shutdown", exc_info=exc)
195
+ signal.raise_signal(signal.SIGINT)
196
+
197
+
198
+ lifespan.get_last_error = get_last_error # type: ignore[attr-defined]
@@ -0,0 +1,7 @@
1
+ """Runtime metrics stub."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def get_metrics() -> dict[str, dict[str, int]]:
7
+ return {}
@@ -0,0 +1,101 @@
1
+ """Alembic migration runner (``langgraph-runtime-pg-migrate``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from alembic import command
10
+ from alembic.config import Config
11
+ from alembic.script import ScriptDirectory
12
+
13
+ from langgraph_runtime_pg.database import get_database_uri, to_psycopg_uri
14
+
15
+ MIGRATIONS_DIR = Path(__file__).resolve().parent / "migrations"
16
+
17
+
18
+ def to_sync_url(uri: str) -> str:
19
+ """Convert DATABASE_URI to a sync psycopg URL for Alembic."""
20
+ return "postgresql+psycopg://" + to_psycopg_uri(uri).removeprefix("postgresql://")
21
+
22
+
23
+ def alembic_config(database_uri: str | None = None) -> Config:
24
+ uri = to_sync_url(database_uri or get_database_uri())
25
+ cfg = Config()
26
+ cfg.set_main_option("script_location", str(MIGRATIONS_DIR))
27
+ cfg.set_main_option("sqlalchemy.url", uri)
28
+ cfg.attributes["configure_logger"] = False
29
+ return cfg
30
+
31
+
32
+ def _head_revision() -> str:
33
+ heads = ScriptDirectory(str(MIGRATIONS_DIR)).get_heads()
34
+ return heads[0] if len(heads) == 1 else "head"
35
+
36
+
37
+ def upgrade_head(database_uri: str | None = None) -> str:
38
+ """Apply all pending migrations; return the head revision id."""
39
+ cfg = alembic_config(database_uri)
40
+ command.upgrade(cfg, "head")
41
+ return _head_revision()
42
+
43
+
44
+ def stamp_head(database_uri: str | None = None) -> None:
45
+ """Mark DB at head without running DDL."""
46
+ command.stamp(alembic_config(database_uri), "head")
47
+
48
+
49
+ def current(database_uri: str | None = None) -> None:
50
+ command.current(alembic_config(database_uri))
51
+
52
+
53
+ def history(database_uri: str | None = None) -> None:
54
+ command.history(alembic_config(database_uri))
55
+
56
+
57
+ def downgrade(revision: str, database_uri: str | None = None) -> None:
58
+ command.downgrade(alembic_config(database_uri), revision)
59
+
60
+
61
+ def main(argv: list[str] | None = None) -> int:
62
+ parser = argparse.ArgumentParser(
63
+ prog="langgraph-runtime-pg-migrate",
64
+ description="Apply langgraph_runtime_pg Postgres schema migrations",
65
+ )
66
+ parser.add_argument(
67
+ "command",
68
+ choices=("upgrade", "stamp", "current", "history", "downgrade"),
69
+ help="Migration action",
70
+ )
71
+ parser.add_argument(
72
+ "revision",
73
+ nargs="?",
74
+ default="head",
75
+ help="For downgrade: target revision (default unused for upgrade)",
76
+ )
77
+ args = parser.parse_args(argv)
78
+
79
+ try:
80
+ if args.command == "upgrade":
81
+ rev = upgrade_head()
82
+ print(f"upgraded to {rev}")
83
+ elif args.command == "stamp":
84
+ stamp_head()
85
+ print("stamped head")
86
+ elif args.command == "current":
87
+ current()
88
+ elif args.command == "history":
89
+ history()
90
+ elif args.command == "downgrade":
91
+ target = args.revision if args.revision != "head" else "-1"
92
+ downgrade(target)
93
+ print(f"downgraded to {target}")
94
+ except Exception as exc:
95
+ print(f"error: {exc}", file=sys.stderr)
96
+ return 1
97
+ return 0
98
+
99
+
100
+ if __name__ == "__main__":
101
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Alembic script location for langgraph_runtime_pg."""
@@ -0,0 +1,51 @@
1
+ """Alembic environment for langgraph_runtime_pg schema migrations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from logging.config import fileConfig
6
+
7
+ from alembic import context
8
+ from sqlalchemy import engine_from_config, pool
9
+
10
+ from langgraph_runtime_pg.models import Base
11
+
12
+ config = context.config
13
+ if config.config_file_name is not None:
14
+ fileConfig(config.config_file_name)
15
+
16
+ target_metadata = Base.metadata
17
+
18
+
19
+ def run_migrations_offline() -> None:
20
+ url = config.get_main_option("sqlalchemy.url")
21
+ context.configure(
22
+ url=url,
23
+ target_metadata=target_metadata,
24
+ literal_binds=True,
25
+ dialect_opts={"paramstyle": "named"},
26
+ compare_type=True,
27
+ )
28
+ with context.begin_transaction():
29
+ context.run_migrations()
30
+
31
+
32
+ def run_migrations_online() -> None:
33
+ connectable = engine_from_config(
34
+ config.get_section(config.config_ini_section, {}),
35
+ prefix="sqlalchemy.",
36
+ poolclass=pool.NullPool,
37
+ )
38
+ with connectable.connect() as connection:
39
+ context.configure(
40
+ connection=connection,
41
+ target_metadata=target_metadata,
42
+ compare_type=True,
43
+ )
44
+ with context.begin_transaction():
45
+ context.run_migrations()
46
+
47
+
48
+ if context.is_offline_mode():
49
+ run_migrations_offline()
50
+ else:
51
+ run_migrations_online()
@@ -0,0 +1,27 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Sequence, Union
11
+
12
+ from alembic import op
13
+ import sqlalchemy as sa
14
+ ${imports if imports else ""}
15
+
16
+ revision: str = ${repr(up_revision)}
17
+ down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
18
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
19
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
20
+
21
+
22
+ def upgrade() -> None:
23
+ ${upgrades if upgrades else "pass"}
24
+
25
+
26
+ def downgrade() -> None:
27
+ ${downgrades if downgrades else "pass"}