langgraph-checkpoint-postgres 1.0.0__tar.gz → 1.0.1__tar.gz
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_checkpoint_postgres-1.0.0 → langgraph_checkpoint_postgres-1.0.1}/PKG-INFO +1 -1
- {langgraph_checkpoint_postgres-1.0.0 → langgraph_checkpoint_postgres-1.0.1}/langgraph/checkpoint/postgres/__init__.py +18 -22
- {langgraph_checkpoint_postgres-1.0.0 → langgraph_checkpoint_postgres-1.0.1}/langgraph/checkpoint/postgres/aio.py +23 -24
- {langgraph_checkpoint_postgres-1.0.0 → langgraph_checkpoint_postgres-1.0.1}/langgraph/checkpoint/postgres/base.py +56 -40
- {langgraph_checkpoint_postgres-1.0.0 → langgraph_checkpoint_postgres-1.0.1}/pyproject.toml +1 -1
- {langgraph_checkpoint_postgres-1.0.0 → langgraph_checkpoint_postgres-1.0.1}/README.md +0 -0
- {langgraph_checkpoint_postgres-1.0.0 → langgraph_checkpoint_postgres-1.0.1}/langgraph/checkpoint/py.typed +0 -0
|
@@ -4,6 +4,7 @@ from typing import Any, Iterator, List, Optional
|
|
|
4
4
|
|
|
5
5
|
from langchain_core.runnables import RunnableConfig
|
|
6
6
|
from psycopg import Connection, Cursor, Pipeline
|
|
7
|
+
from psycopg.errors import UndefinedTable
|
|
7
8
|
from psycopg.rows import dict_row
|
|
8
9
|
from psycopg.types.json import Jsonb
|
|
9
10
|
|
|
@@ -23,8 +24,6 @@ from langgraph.checkpoint.serde.base import SerializerProtocol
|
|
|
23
24
|
class PostgresSaver(BasePostgresSaver):
|
|
24
25
|
lock: threading.Lock
|
|
25
26
|
|
|
26
|
-
is_setup: bool
|
|
27
|
-
|
|
28
27
|
def __init__(
|
|
29
28
|
self,
|
|
30
29
|
conn: Connection,
|
|
@@ -35,7 +34,6 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
35
34
|
self.conn = conn
|
|
36
35
|
self.pipe = pipe
|
|
37
36
|
self.lock = threading.Lock()
|
|
38
|
-
self.is_setup = False
|
|
39
37
|
|
|
40
38
|
@classmethod
|
|
41
39
|
@contextmanager
|
|
@@ -67,23 +65,23 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
67
65
|
already exist. It is called automatically when needed and should not be called
|
|
68
66
|
directly by the user.
|
|
69
67
|
"""
|
|
70
|
-
if self.is_setup:
|
|
71
|
-
return
|
|
72
68
|
with self.lock:
|
|
73
|
-
create_table_queries = [
|
|
74
|
-
self.CREATE_CHECKPOINTS_SQL,
|
|
75
|
-
self.CREATE_CHECKPOINT_BLOBS_SQL,
|
|
76
|
-
self.CREATE_CHECKPOINT_WRITES_SQL,
|
|
77
|
-
]
|
|
78
69
|
with self.conn.cursor(binary=True) as cur:
|
|
79
|
-
|
|
80
|
-
cur.execute(
|
|
81
|
-
|
|
70
|
+
try:
|
|
71
|
+
version = cur.execute(
|
|
72
|
+
"SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1"
|
|
73
|
+
).fetchone()["v"]
|
|
74
|
+
except UndefinedTable:
|
|
75
|
+
version = -1
|
|
76
|
+
for v, migration in zip(
|
|
77
|
+
range(version + 1, len(self.MIGRATIONS)),
|
|
78
|
+
self.MIGRATIONS[version + 1 :],
|
|
79
|
+
):
|
|
80
|
+
cur.execute(migration)
|
|
81
|
+
cur.execute(f"INSERT INTO checkpoint_migrations (v) VALUES ({v})")
|
|
82
82
|
if self.pipe:
|
|
83
83
|
self.pipe.sync()
|
|
84
84
|
|
|
85
|
-
self.is_setup = True
|
|
86
|
-
|
|
87
85
|
def list(
|
|
88
86
|
self,
|
|
89
87
|
config: Optional[RunnableConfig],
|
|
@@ -92,7 +90,6 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
92
90
|
before: Optional[RunnableConfig] = None,
|
|
93
91
|
limit: Optional[int] = None,
|
|
94
92
|
) -> Iterator[CheckpointTuple]:
|
|
95
|
-
self.setup()
|
|
96
93
|
where, args = self._search_where(config, filter, before)
|
|
97
94
|
query = self.SELECT_SQL + where + " ORDER BY checkpoint_id DESC"
|
|
98
95
|
if limit:
|
|
@@ -111,7 +108,7 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
111
108
|
**self._load_checkpoint(value["checkpoint"]),
|
|
112
109
|
"channel_values": self._load_blobs(value["channel_values"]),
|
|
113
110
|
},
|
|
114
|
-
value["metadata"],
|
|
111
|
+
self._load_metadata(value["metadata"]),
|
|
115
112
|
{
|
|
116
113
|
"configurable": {
|
|
117
114
|
"thread_id": value["thread_id"],
|
|
@@ -124,7 +121,6 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
124
121
|
)
|
|
125
122
|
|
|
126
123
|
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
|
127
|
-
self.setup()
|
|
128
124
|
thread_id = config["configurable"]["thread_id"]
|
|
129
125
|
checkpoint_id = get_checkpoint_id(config)
|
|
130
126
|
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
|
@@ -155,7 +151,7 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
155
151
|
**self._load_checkpoint(value["checkpoint"]),
|
|
156
152
|
"channel_values": self._load_blobs(value["channel_values"]),
|
|
157
153
|
},
|
|
158
|
-
value["metadata"],
|
|
154
|
+
self._load_metadata(value["metadata"]),
|
|
159
155
|
{
|
|
160
156
|
"configurable": {
|
|
161
157
|
"thread_id": thread_id,
|
|
@@ -210,7 +206,7 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
210
206
|
checkpoint["id"],
|
|
211
207
|
checkpoint_id,
|
|
212
208
|
Jsonb(self._dump_checkpoint(copy)),
|
|
213
|
-
|
|
209
|
+
self._dump_metadata(metadata),
|
|
214
210
|
),
|
|
215
211
|
)
|
|
216
212
|
return next_config
|
|
@@ -235,7 +231,6 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
235
231
|
|
|
236
232
|
@contextmanager
|
|
237
233
|
def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor]:
|
|
238
|
-
self.setup()
|
|
239
234
|
if self.pipe:
|
|
240
235
|
# a connection in pipeline mode can be used concurrently
|
|
241
236
|
# in multiple threads/coroutines, but only one cursor can be
|
|
@@ -244,7 +239,8 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
244
239
|
with self.conn.cursor(binary=True) as cur:
|
|
245
240
|
yield cur
|
|
246
241
|
finally:
|
|
247
|
-
|
|
242
|
+
if pipeline:
|
|
243
|
+
self.pipe.sync()
|
|
248
244
|
elif pipeline:
|
|
249
245
|
# a connection not in pipeline mode can only be used by one
|
|
250
246
|
# thread/coroutine at a time, so we acquire a lock
|
|
@@ -4,6 +4,7 @@ from typing import Any, AsyncIterator, Optional
|
|
|
4
4
|
|
|
5
5
|
from langchain_core.runnables import RunnableConfig
|
|
6
6
|
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline
|
|
7
|
+
from psycopg.errors import UndefinedTable
|
|
7
8
|
from psycopg.rows import dict_row
|
|
8
9
|
from psycopg.types.json import Jsonb
|
|
9
10
|
|
|
@@ -21,8 +22,6 @@ from langgraph.checkpoint.serde.base import SerializerProtocol
|
|
|
21
22
|
class AsyncPostgresSaver(BasePostgresSaver):
|
|
22
23
|
lock: asyncio.Lock
|
|
23
24
|
|
|
24
|
-
is_setup: bool
|
|
25
|
-
|
|
26
25
|
def __init__(
|
|
27
26
|
self,
|
|
28
27
|
conn: AsyncConnection,
|
|
@@ -33,7 +32,6 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
33
32
|
self.conn = conn
|
|
34
33
|
self.pipe = pipe
|
|
35
34
|
self.lock = asyncio.Lock()
|
|
36
|
-
self.is_setup = False
|
|
37
35
|
|
|
38
36
|
@classmethod
|
|
39
37
|
@asynccontextmanager
|
|
@@ -65,23 +63,27 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
65
63
|
already exist. It is called automatically when needed and should not be called
|
|
66
64
|
directly by the user.
|
|
67
65
|
"""
|
|
68
|
-
if self.is_setup:
|
|
69
|
-
return
|
|
70
66
|
async with self.lock:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
67
|
+
async with self.conn.cursor(binary=True) as cur:
|
|
68
|
+
try:
|
|
69
|
+
version = (
|
|
70
|
+
await cur.execute(
|
|
71
|
+
"SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1"
|
|
72
|
+
)
|
|
73
|
+
).fetchone()["v"]
|
|
74
|
+
except UndefinedTable:
|
|
75
|
+
version = -1
|
|
76
|
+
for v, migration in zip(
|
|
77
|
+
range(version + 1, len(self.MIGRATIONS)),
|
|
78
|
+
self.MIGRATIONS[version + 1 :],
|
|
79
|
+
):
|
|
80
|
+
await cur.execute(migration)
|
|
81
|
+
await cur.execute(
|
|
82
|
+
f"INSERT INTO checkpoint_migrations (v) VALUES ({v})"
|
|
83
|
+
)
|
|
80
84
|
if self.pipe:
|
|
81
85
|
await self.pipe.sync()
|
|
82
86
|
|
|
83
|
-
self.is_setup = True
|
|
84
|
-
|
|
85
87
|
async def alist(
|
|
86
88
|
self,
|
|
87
89
|
config: Optional[RunnableConfig],
|
|
@@ -90,7 +92,6 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
90
92
|
before: Optional[RunnableConfig] = None,
|
|
91
93
|
limit: Optional[int] = None,
|
|
92
94
|
) -> AsyncIterator[CheckpointTuple]:
|
|
93
|
-
await self.setup()
|
|
94
95
|
where, args = self._search_where(config, filter, before)
|
|
95
96
|
query = self.SELECT_SQL + where + " ORDER BY checkpoint_id DESC"
|
|
96
97
|
if limit:
|
|
@@ -111,7 +112,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
111
112
|
self._load_blobs, value["channel_values"]
|
|
112
113
|
),
|
|
113
114
|
},
|
|
114
|
-
value["metadata"],
|
|
115
|
+
self._load_metadata(value["metadata"]),
|
|
115
116
|
{
|
|
116
117
|
"configurable": {
|
|
117
118
|
"thread_id": value["thread_id"],
|
|
@@ -124,7 +125,6 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
124
125
|
)
|
|
125
126
|
|
|
126
127
|
async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
|
127
|
-
await self.setup()
|
|
128
128
|
thread_id = config["configurable"]["thread_id"]
|
|
129
129
|
checkpoint_id = get_checkpoint_id(config)
|
|
130
130
|
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
|
@@ -157,7 +157,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
157
157
|
self._load_blobs, value["channel_values"]
|
|
158
158
|
),
|
|
159
159
|
},
|
|
160
|
-
value["metadata"],
|
|
160
|
+
self._load_metadata(value["metadata"]),
|
|
161
161
|
{
|
|
162
162
|
"configurable": {
|
|
163
163
|
"thread_id": thread_id,
|
|
@@ -177,7 +177,6 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
177
177
|
metadata: CheckpointMetadata,
|
|
178
178
|
new_versions: ChannelVersions,
|
|
179
179
|
) -> RunnableConfig:
|
|
180
|
-
await self.setup()
|
|
181
180
|
configurable = config["configurable"].copy()
|
|
182
181
|
thread_id = configurable.pop("thread_id")
|
|
183
182
|
checkpoint_ns = configurable.pop("checkpoint_ns")
|
|
@@ -214,7 +213,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
214
213
|
checkpoint["id"],
|
|
215
214
|
checkpoint_id,
|
|
216
215
|
Jsonb(self._dump_checkpoint(copy)),
|
|
217
|
-
|
|
216
|
+
self._dump_metadata(metadata),
|
|
218
217
|
),
|
|
219
218
|
)
|
|
220
219
|
return next_config
|
|
@@ -240,7 +239,6 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
240
239
|
|
|
241
240
|
@asynccontextmanager
|
|
242
241
|
async def _cursor(self, *, pipeline: bool = False) -> AsyncIterator[AsyncCursor]:
|
|
243
|
-
await self.setup()
|
|
244
242
|
if self.pipe:
|
|
245
243
|
# a connection in pipeline mode can be used concurrently
|
|
246
244
|
# in multiple threads/coroutines, but only one cursor can be
|
|
@@ -249,7 +247,8 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
249
247
|
async with self.conn.cursor(binary=True) as cur:
|
|
250
248
|
yield cur
|
|
251
249
|
finally:
|
|
252
|
-
|
|
250
|
+
if pipeline:
|
|
251
|
+
await self.pipe.sync()
|
|
253
252
|
elif pipeline:
|
|
254
253
|
# a connection not in pipeline mode can only be used by one
|
|
255
254
|
# thread/coroutine at a time, so we acquire a lock
|
|
@@ -11,10 +11,51 @@ from langgraph.checkpoint.base import (
|
|
|
11
11
|
EmptyChannelError,
|
|
12
12
|
get_checkpoint_id,
|
|
13
13
|
)
|
|
14
|
+
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
|
14
15
|
from langgraph.checkpoint.serde.types import ChannelProtocol
|
|
15
16
|
|
|
16
17
|
MetadataInput = Optional[dict[str, Any]]
|
|
17
18
|
|
|
19
|
+
"""
|
|
20
|
+
To add a new migration, add a new string to the MIGRATIONS list.
|
|
21
|
+
The position of the migration in the list is the version number.
|
|
22
|
+
"""
|
|
23
|
+
MIGRATIONS = [
|
|
24
|
+
"""CREATE TABLE IF NOT EXISTS checkpoint_migrations (
|
|
25
|
+
v INTEGER PRIMARY KEY
|
|
26
|
+
);""",
|
|
27
|
+
"""CREATE TABLE IF NOT EXISTS checkpoints (
|
|
28
|
+
thread_id TEXT NOT NULL,
|
|
29
|
+
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
|
30
|
+
checkpoint_id TEXT NOT NULL,
|
|
31
|
+
parent_checkpoint_id TEXT,
|
|
32
|
+
type TEXT,
|
|
33
|
+
checkpoint JSONB NOT NULL,
|
|
34
|
+
metadata JSONB NOT NULL DEFAULT '{}',
|
|
35
|
+
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id)
|
|
36
|
+
);""",
|
|
37
|
+
"""CREATE TABLE IF NOT EXISTS checkpoint_blobs (
|
|
38
|
+
thread_id TEXT NOT NULL,
|
|
39
|
+
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
|
40
|
+
channel TEXT NOT NULL,
|
|
41
|
+
version TEXT NOT NULL,
|
|
42
|
+
type TEXT NOT NULL,
|
|
43
|
+
blob BYTEA NOT NULL,
|
|
44
|
+
PRIMARY KEY (thread_id, checkpoint_ns, channel, version)
|
|
45
|
+
);""",
|
|
46
|
+
"""CREATE TABLE IF NOT EXISTS checkpoint_writes (
|
|
47
|
+
thread_id TEXT NOT NULL,
|
|
48
|
+
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
|
49
|
+
checkpoint_id TEXT NOT NULL,
|
|
50
|
+
task_id TEXT NOT NULL,
|
|
51
|
+
idx INTEGER NOT NULL,
|
|
52
|
+
channel TEXT NOT NULL,
|
|
53
|
+
type TEXT,
|
|
54
|
+
blob BYTEA NOT NULL,
|
|
55
|
+
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
|
|
56
|
+
);""",
|
|
57
|
+
]
|
|
58
|
+
|
|
18
59
|
SELECT_SQL = """
|
|
19
60
|
select
|
|
20
61
|
thread_id,
|
|
@@ -42,43 +83,6 @@ select
|
|
|
42
83
|
) as pending_writes
|
|
43
84
|
from checkpoints """
|
|
44
85
|
|
|
45
|
-
CREATE_CHECKPOINTS_SQL = """
|
|
46
|
-
CREATE TABLE IF NOT EXISTS checkpoints (
|
|
47
|
-
thread_id TEXT NOT NULL,
|
|
48
|
-
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
|
49
|
-
checkpoint_id TEXT NOT NULL,
|
|
50
|
-
parent_checkpoint_id TEXT,
|
|
51
|
-
type TEXT,
|
|
52
|
-
checkpoint JSONB NOT NULL,
|
|
53
|
-
metadata JSONB NOT NULL DEFAULT '{}',
|
|
54
|
-
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id)
|
|
55
|
-
);
|
|
56
|
-
"""
|
|
57
|
-
CREATE_CHECKPOINT_BLOBS_SQL = """
|
|
58
|
-
CREATE TABLE IF NOT EXISTS checkpoint_blobs (
|
|
59
|
-
thread_id TEXT NOT NULL,
|
|
60
|
-
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
|
61
|
-
channel TEXT NOT NULL,
|
|
62
|
-
version TEXT NOT NULL,
|
|
63
|
-
type TEXT NOT NULL,
|
|
64
|
-
blob BYTEA NOT NULL,
|
|
65
|
-
PRIMARY KEY (thread_id, checkpoint_ns, channel, version)
|
|
66
|
-
);"""
|
|
67
|
-
|
|
68
|
-
CREATE_CHECKPOINT_WRITES_SQL = """
|
|
69
|
-
CREATE TABLE IF NOT EXISTS checkpoint_writes (
|
|
70
|
-
thread_id TEXT NOT NULL,
|
|
71
|
-
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
|
72
|
-
checkpoint_id TEXT NOT NULL,
|
|
73
|
-
task_id TEXT NOT NULL,
|
|
74
|
-
idx INTEGER NOT NULL,
|
|
75
|
-
channel TEXT NOT NULL,
|
|
76
|
-
type TEXT,
|
|
77
|
-
blob BYTEA NOT NULL,
|
|
78
|
-
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
|
|
79
|
-
);
|
|
80
|
-
"""
|
|
81
|
-
|
|
82
86
|
UPSERT_CHECKPOINT_BLOBS_SQL = """
|
|
83
87
|
INSERT INTO checkpoint_blobs (thread_id, checkpoint_ns, channel, version, type, blob)
|
|
84
88
|
VALUES (%s, %s, %s, %s, %s, %s)
|
|
@@ -103,12 +107,11 @@ UPSERT_CHECKPOINT_WRITES_SQL = """
|
|
|
103
107
|
|
|
104
108
|
class BasePostgresSaver(BaseCheckpointSaver):
|
|
105
109
|
SELECT_SQL = SELECT_SQL
|
|
106
|
-
|
|
107
|
-
CREATE_CHECKPOINT_BLOBS_SQL = CREATE_CHECKPOINT_BLOBS_SQL
|
|
108
|
-
CREATE_CHECKPOINT_WRITES_SQL = CREATE_CHECKPOINT_WRITES_SQL
|
|
110
|
+
MIGRATIONS = MIGRATIONS
|
|
109
111
|
UPSERT_CHECKPOINT_BLOBS_SQL = UPSERT_CHECKPOINT_BLOBS_SQL
|
|
110
112
|
UPSERT_CHECKPOINTS_SQL = UPSERT_CHECKPOINTS_SQL
|
|
111
113
|
UPSERT_CHECKPOINT_WRITES_SQL = UPSERT_CHECKPOINT_WRITES_SQL
|
|
114
|
+
jsonplus_serde = JsonPlusSerializer()
|
|
112
115
|
|
|
113
116
|
def _load_checkpoint(self, checkpoint: dict[str, Any]) -> Checkpoint:
|
|
114
117
|
if len(checkpoint["pending_sends"]) == 2 and all(
|
|
@@ -202,6 +205,19 @@ class BasePostgresSaver(BaseCheckpointSaver):
|
|
|
202
205
|
for idx, (channel, value) in enumerate(writes)
|
|
203
206
|
]
|
|
204
207
|
|
|
208
|
+
def _load_metadata(self, metadata: dict[str, Any]) -> dict[str, Any]:
|
|
209
|
+
return self.jsonplus_serde.loads(self.jsonplus_serde.dumps(metadata))
|
|
210
|
+
|
|
211
|
+
def _dump_metadata(self, metadata) -> str:
|
|
212
|
+
serialized_metadata_type, serialized_metadata = self.jsonplus_serde.dumps_typed(
|
|
213
|
+
metadata
|
|
214
|
+
)
|
|
215
|
+
if serialized_metadata_type != "json":
|
|
216
|
+
raise TypeError(
|
|
217
|
+
f"Failed to properly serialize metadata -- expected 'json', got '{serialized_metadata_type}'"
|
|
218
|
+
)
|
|
219
|
+
return serialized_metadata.decode()
|
|
220
|
+
|
|
205
221
|
def get_next_version(self, current: Optional[str], channel: ChannelProtocol) -> str:
|
|
206
222
|
if current is None:
|
|
207
223
|
current_v = 0
|
|
File without changes
|
|
File without changes
|