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