langgraph-checkpoint-postgres 1.0.0__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/PKG-INFO +112 -0
- langgraph_checkpoint_postgres-1.0.0/README.md +93 -0
- langgraph_checkpoint_postgres-1.0.0/langgraph/checkpoint/postgres/__init__.py +255 -0
- langgraph_checkpoint_postgres-1.0.0/langgraph/checkpoint/postgres/aio.py +262 -0
- langgraph_checkpoint_postgres-1.0.0/langgraph/checkpoint/postgres/base.py +256 -0
- langgraph_checkpoint_postgres-1.0.0/langgraph/checkpoint/py.typed +0 -0
- langgraph_checkpoint_postgres-1.0.0/pyproject.toml +52 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: langgraph-checkpoint-postgres
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Library with a Postgres implementation of LangGraph checkpoint saver.
|
|
5
|
+
Home-page: https://www.github.com/langchain-ai/langgraph
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.9.0,<4.0.0
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Dist: langgraph-checkpoint (>=1.0.1,<2.0.0)
|
|
15
|
+
Requires-Dist: orjson (>=3.10.1)
|
|
16
|
+
Requires-Dist: psycopg[binary] (>=3.1.19)
|
|
17
|
+
Project-URL: Repository, https://www.github.com/langchain-ai/langgraph
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# LangGraph Checkpoint Postgres
|
|
21
|
+
|
|
22
|
+
Implementation of LangGraph CheckpointSaver that uses Postgres.
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from langgraph.checkpoint.postgres import PostgresSaver
|
|
28
|
+
|
|
29
|
+
write_config = {"configurable": {"thread_id": "1", "checkpoint_ns": ""}}
|
|
30
|
+
read_config = {"configurable": {"thread_id": "1"}}
|
|
31
|
+
|
|
32
|
+
DB_URI = "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"
|
|
33
|
+
with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
|
34
|
+
checkpoint = {
|
|
35
|
+
"v": 1,
|
|
36
|
+
"ts": "2024-07-31T20:14:19.804150+00:00",
|
|
37
|
+
"id": "1ef4f797-8335-6428-8001-8a1503f9b875",
|
|
38
|
+
"channel_values": {
|
|
39
|
+
"my_key": "meow",
|
|
40
|
+
"node": "node"
|
|
41
|
+
},
|
|
42
|
+
"channel_versions": {
|
|
43
|
+
"__start__": 2,
|
|
44
|
+
"my_key": 3,
|
|
45
|
+
"start:node": 3,
|
|
46
|
+
"node": 3
|
|
47
|
+
},
|
|
48
|
+
"versions_seen": {
|
|
49
|
+
"__input__": {},
|
|
50
|
+
"__start__": {
|
|
51
|
+
"__start__": 1
|
|
52
|
+
},
|
|
53
|
+
"node": {
|
|
54
|
+
"start:node": 2
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"pending_sends": [],
|
|
58
|
+
"current_tasks": {}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# store checkpoint
|
|
62
|
+
checkpointer.put(write_config, checkpoint, {}, {})
|
|
63
|
+
|
|
64
|
+
# load checkpoint
|
|
65
|
+
checkpointer.get(read_config)
|
|
66
|
+
|
|
67
|
+
# list checkpoints
|
|
68
|
+
list(checkpointer.list(read_config))
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Async
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
|
75
|
+
|
|
76
|
+
async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
|
77
|
+
checkpoint = {
|
|
78
|
+
"v": 1,
|
|
79
|
+
"ts": "2024-07-31T20:14:19.804150+00:00",
|
|
80
|
+
"id": "1ef4f797-8335-6428-8001-8a1503f9b875",
|
|
81
|
+
"channel_values": {
|
|
82
|
+
"my_key": "meow",
|
|
83
|
+
"node": "node"
|
|
84
|
+
},
|
|
85
|
+
"channel_versions": {
|
|
86
|
+
"__start__": 2,
|
|
87
|
+
"my_key": 3,
|
|
88
|
+
"start:node": 3,
|
|
89
|
+
"node": 3
|
|
90
|
+
},
|
|
91
|
+
"versions_seen": {
|
|
92
|
+
"__input__": {},
|
|
93
|
+
"__start__": {
|
|
94
|
+
"__start__": 1
|
|
95
|
+
},
|
|
96
|
+
"node": {
|
|
97
|
+
"start:node": 2
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
"pending_sends": [],
|
|
101
|
+
"current_tasks": {}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# store checkpoint
|
|
105
|
+
await checkpointer.aput(write_config, checkpoint, {}, {})
|
|
106
|
+
|
|
107
|
+
# load checkpoint
|
|
108
|
+
await checkpointer.aget(read_config)
|
|
109
|
+
|
|
110
|
+
# list checkpoints
|
|
111
|
+
[c async for c in checkpointer.alist(read_config)]
|
|
112
|
+
```
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# LangGraph Checkpoint Postgres
|
|
2
|
+
|
|
3
|
+
Implementation of LangGraph CheckpointSaver that uses Postgres.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from langgraph.checkpoint.postgres import PostgresSaver
|
|
9
|
+
|
|
10
|
+
write_config = {"configurable": {"thread_id": "1", "checkpoint_ns": ""}}
|
|
11
|
+
read_config = {"configurable": {"thread_id": "1"}}
|
|
12
|
+
|
|
13
|
+
DB_URI = "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"
|
|
14
|
+
with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
|
15
|
+
checkpoint = {
|
|
16
|
+
"v": 1,
|
|
17
|
+
"ts": "2024-07-31T20:14:19.804150+00:00",
|
|
18
|
+
"id": "1ef4f797-8335-6428-8001-8a1503f9b875",
|
|
19
|
+
"channel_values": {
|
|
20
|
+
"my_key": "meow",
|
|
21
|
+
"node": "node"
|
|
22
|
+
},
|
|
23
|
+
"channel_versions": {
|
|
24
|
+
"__start__": 2,
|
|
25
|
+
"my_key": 3,
|
|
26
|
+
"start:node": 3,
|
|
27
|
+
"node": 3
|
|
28
|
+
},
|
|
29
|
+
"versions_seen": {
|
|
30
|
+
"__input__": {},
|
|
31
|
+
"__start__": {
|
|
32
|
+
"__start__": 1
|
|
33
|
+
},
|
|
34
|
+
"node": {
|
|
35
|
+
"start:node": 2
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"pending_sends": [],
|
|
39
|
+
"current_tasks": {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# store checkpoint
|
|
43
|
+
checkpointer.put(write_config, checkpoint, {}, {})
|
|
44
|
+
|
|
45
|
+
# load checkpoint
|
|
46
|
+
checkpointer.get(read_config)
|
|
47
|
+
|
|
48
|
+
# list checkpoints
|
|
49
|
+
list(checkpointer.list(read_config))
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Async
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
|
56
|
+
|
|
57
|
+
async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
|
58
|
+
checkpoint = {
|
|
59
|
+
"v": 1,
|
|
60
|
+
"ts": "2024-07-31T20:14:19.804150+00:00",
|
|
61
|
+
"id": "1ef4f797-8335-6428-8001-8a1503f9b875",
|
|
62
|
+
"channel_values": {
|
|
63
|
+
"my_key": "meow",
|
|
64
|
+
"node": "node"
|
|
65
|
+
},
|
|
66
|
+
"channel_versions": {
|
|
67
|
+
"__start__": 2,
|
|
68
|
+
"my_key": 3,
|
|
69
|
+
"start:node": 3,
|
|
70
|
+
"node": 3
|
|
71
|
+
},
|
|
72
|
+
"versions_seen": {
|
|
73
|
+
"__input__": {},
|
|
74
|
+
"__start__": {
|
|
75
|
+
"__start__": 1
|
|
76
|
+
},
|
|
77
|
+
"node": {
|
|
78
|
+
"start:node": 2
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"pending_sends": [],
|
|
82
|
+
"current_tasks": {}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# store checkpoint
|
|
86
|
+
await checkpointer.aput(write_config, checkpoint, {}, {})
|
|
87
|
+
|
|
88
|
+
# load checkpoint
|
|
89
|
+
await checkpointer.aget(read_config)
|
|
90
|
+
|
|
91
|
+
# list checkpoints
|
|
92
|
+
[c async for c in checkpointer.alist(read_config)]
|
|
93
|
+
```
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from typing import Any, Iterator, List, Optional
|
|
4
|
+
|
|
5
|
+
from langchain_core.runnables import RunnableConfig
|
|
6
|
+
from psycopg import Connection, Cursor, Pipeline
|
|
7
|
+
from psycopg.rows import dict_row
|
|
8
|
+
from psycopg.types.json import Jsonb
|
|
9
|
+
|
|
10
|
+
from langgraph.checkpoint.base import (
|
|
11
|
+
ChannelVersions,
|
|
12
|
+
Checkpoint,
|
|
13
|
+
CheckpointMetadata,
|
|
14
|
+
CheckpointTuple,
|
|
15
|
+
get_checkpoint_id,
|
|
16
|
+
)
|
|
17
|
+
from langgraph.checkpoint.postgres.base import (
|
|
18
|
+
BasePostgresSaver,
|
|
19
|
+
)
|
|
20
|
+
from langgraph.checkpoint.serde.base import SerializerProtocol
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PostgresSaver(BasePostgresSaver):
|
|
24
|
+
lock: threading.Lock
|
|
25
|
+
|
|
26
|
+
is_setup: bool
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
conn: Connection,
|
|
31
|
+
pipe: Optional[Pipeline] = None,
|
|
32
|
+
serde: Optional[SerializerProtocol] = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
super().__init__(serde=serde)
|
|
35
|
+
self.conn = conn
|
|
36
|
+
self.pipe = pipe
|
|
37
|
+
self.lock = threading.Lock()
|
|
38
|
+
self.is_setup = False
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
@contextmanager
|
|
42
|
+
def from_conn_string(
|
|
43
|
+
cls, conn_string: str, *, pipeline: bool = False
|
|
44
|
+
) -> Iterator["PostgresSaver"]:
|
|
45
|
+
"""Create a new PostgresSaver instance from a connection string.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
conn_string (str): The Postgres connection info string.
|
|
49
|
+
pipeline (bool): whether to use Pipeline
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
PostgresSaver: A new PostgresSaver instance.
|
|
53
|
+
"""
|
|
54
|
+
with Connection.connect(
|
|
55
|
+
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
|
|
56
|
+
) as conn:
|
|
57
|
+
if pipeline:
|
|
58
|
+
with conn.pipeline() as pipe:
|
|
59
|
+
yield PostgresSaver(conn, pipe)
|
|
60
|
+
else:
|
|
61
|
+
yield PostgresSaver(conn)
|
|
62
|
+
|
|
63
|
+
def setup(self) -> None:
|
|
64
|
+
"""Set up the checkpoint database asynchronously.
|
|
65
|
+
|
|
66
|
+
This method creates the necessary tables in the SQLite database if they don't
|
|
67
|
+
already exist. It is called automatically when needed and should not be called
|
|
68
|
+
directly by the user.
|
|
69
|
+
"""
|
|
70
|
+
if self.is_setup:
|
|
71
|
+
return
|
|
72
|
+
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
|
+
with self.conn.cursor(binary=True) as cur:
|
|
79
|
+
for query in create_table_queries:
|
|
80
|
+
cur.execute(query)
|
|
81
|
+
|
|
82
|
+
if self.pipe:
|
|
83
|
+
self.pipe.sync()
|
|
84
|
+
|
|
85
|
+
self.is_setup = True
|
|
86
|
+
|
|
87
|
+
def list(
|
|
88
|
+
self,
|
|
89
|
+
config: Optional[RunnableConfig],
|
|
90
|
+
*,
|
|
91
|
+
filter: Optional[dict[str, Any]] = None,
|
|
92
|
+
before: Optional[RunnableConfig] = None,
|
|
93
|
+
limit: Optional[int] = None,
|
|
94
|
+
) -> Iterator[CheckpointTuple]:
|
|
95
|
+
self.setup()
|
|
96
|
+
where, args = self._search_where(config, filter, before)
|
|
97
|
+
query = self.SELECT_SQL + where + " ORDER BY checkpoint_id DESC"
|
|
98
|
+
if limit:
|
|
99
|
+
query += f" LIMIT {limit}"
|
|
100
|
+
# if we change this to use .stream() we need to make sure to close the cursor
|
|
101
|
+
for value in self.conn.execute(query, args, binary=True):
|
|
102
|
+
yield CheckpointTuple(
|
|
103
|
+
{
|
|
104
|
+
"configurable": {
|
|
105
|
+
"thread_id": value["thread_id"],
|
|
106
|
+
"checkpoint_ns": value["checkpoint_ns"],
|
|
107
|
+
"checkpoint_id": value["checkpoint_id"],
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
**self._load_checkpoint(value["checkpoint"]),
|
|
112
|
+
"channel_values": self._load_blobs(value["channel_values"]),
|
|
113
|
+
},
|
|
114
|
+
value["metadata"],
|
|
115
|
+
{
|
|
116
|
+
"configurable": {
|
|
117
|
+
"thread_id": value["thread_id"],
|
|
118
|
+
"checkpoint_ns": value["checkpoint_ns"],
|
|
119
|
+
"checkpoint_id": value["parent_checkpoint_id"],
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if value["parent_checkpoint_id"]
|
|
123
|
+
else None,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
|
127
|
+
self.setup()
|
|
128
|
+
thread_id = config["configurable"]["thread_id"]
|
|
129
|
+
checkpoint_id = get_checkpoint_id(config)
|
|
130
|
+
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
|
131
|
+
if checkpoint_id:
|
|
132
|
+
args = (thread_id, checkpoint_ns, checkpoint_id)
|
|
133
|
+
where = "WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id = %s"
|
|
134
|
+
else:
|
|
135
|
+
args = (thread_id, checkpoint_ns)
|
|
136
|
+
where = "WHERE thread_id = %s AND checkpoint_ns = %s ORDER BY checkpoint_id DESC LIMIT 1"
|
|
137
|
+
|
|
138
|
+
with self._cursor() as cur:
|
|
139
|
+
cur = self.conn.execute(
|
|
140
|
+
self.SELECT_SQL + where,
|
|
141
|
+
args,
|
|
142
|
+
binary=True,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
for value in cur:
|
|
146
|
+
return CheckpointTuple(
|
|
147
|
+
{
|
|
148
|
+
"configurable": {
|
|
149
|
+
"thread_id": thread_id,
|
|
150
|
+
"checkpoint_ns": checkpoint_ns,
|
|
151
|
+
"checkpoint_id": value["checkpoint_id"],
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
**self._load_checkpoint(value["checkpoint"]),
|
|
156
|
+
"channel_values": self._load_blobs(value["channel_values"]),
|
|
157
|
+
},
|
|
158
|
+
value["metadata"],
|
|
159
|
+
{
|
|
160
|
+
"configurable": {
|
|
161
|
+
"thread_id": thread_id,
|
|
162
|
+
"checkpoint_ns": checkpoint_ns,
|
|
163
|
+
"checkpoint_id": value["parent_checkpoint_id"],
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if value["parent_checkpoint_id"]
|
|
167
|
+
else None,
|
|
168
|
+
self._load_writes(value["pending_writes"]),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def put(
|
|
172
|
+
self,
|
|
173
|
+
config: RunnableConfig,
|
|
174
|
+
checkpoint: Checkpoint,
|
|
175
|
+
metadata: CheckpointMetadata,
|
|
176
|
+
new_versions: ChannelVersions,
|
|
177
|
+
) -> RunnableConfig:
|
|
178
|
+
configurable = config["configurable"].copy()
|
|
179
|
+
thread_id = configurable.pop("thread_id")
|
|
180
|
+
checkpoint_ns = configurable.pop("checkpoint_ns")
|
|
181
|
+
checkpoint_id = configurable.pop(
|
|
182
|
+
"checkpoint_id", configurable.pop("thread_ts", None)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
copy = checkpoint.copy()
|
|
186
|
+
next_config = {
|
|
187
|
+
"configurable": {
|
|
188
|
+
"thread_id": thread_id,
|
|
189
|
+
"checkpoint_ns": checkpoint_ns,
|
|
190
|
+
"checkpoint_id": checkpoint["id"],
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
with self._cursor(pipeline=True) as cur:
|
|
195
|
+
cur.executemany(
|
|
196
|
+
self.UPSERT_CHECKPOINT_BLOBS_SQL,
|
|
197
|
+
self._dump_blobs(
|
|
198
|
+
thread_id,
|
|
199
|
+
checkpoint_ns,
|
|
200
|
+
copy.pop("channel_values"),
|
|
201
|
+
copy["channel_versions"],
|
|
202
|
+
new_versions,
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
cur.execute(
|
|
206
|
+
self.UPSERT_CHECKPOINTS_SQL,
|
|
207
|
+
(
|
|
208
|
+
thread_id,
|
|
209
|
+
checkpoint_ns,
|
|
210
|
+
checkpoint["id"],
|
|
211
|
+
checkpoint_id,
|
|
212
|
+
Jsonb(self._dump_checkpoint(copy)),
|
|
213
|
+
Jsonb(metadata),
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
return next_config
|
|
217
|
+
|
|
218
|
+
def put_writes(
|
|
219
|
+
self,
|
|
220
|
+
config: RunnableConfig,
|
|
221
|
+
writes: List[tuple[str, Any]],
|
|
222
|
+
task_id: str,
|
|
223
|
+
) -> None:
|
|
224
|
+
with self._cursor() as cur:
|
|
225
|
+
cur.executemany(
|
|
226
|
+
self.UPSERT_CHECKPOINT_WRITES_SQL,
|
|
227
|
+
self._dump_writes(
|
|
228
|
+
config["configurable"]["thread_id"],
|
|
229
|
+
config["configurable"]["checkpoint_ns"],
|
|
230
|
+
config["configurable"]["checkpoint_id"],
|
|
231
|
+
task_id,
|
|
232
|
+
writes,
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
@contextmanager
|
|
237
|
+
def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor]:
|
|
238
|
+
self.setup()
|
|
239
|
+
if self.pipe:
|
|
240
|
+
# a connection in pipeline mode can be used concurrently
|
|
241
|
+
# in multiple threads/coroutines, but only one cursor can be
|
|
242
|
+
# used at a time
|
|
243
|
+
try:
|
|
244
|
+
with self.conn.cursor(binary=True) as cur:
|
|
245
|
+
yield cur
|
|
246
|
+
finally:
|
|
247
|
+
self.pipe.sync()
|
|
248
|
+
elif pipeline:
|
|
249
|
+
# a connection not in pipeline mode can only be used by one
|
|
250
|
+
# thread/coroutine at a time, so we acquire a lock
|
|
251
|
+
with self.lock, self.conn.pipeline(), self.conn.cursor(binary=True) as cur:
|
|
252
|
+
yield cur
|
|
253
|
+
else:
|
|
254
|
+
with self.lock, self.conn.cursor(binary=True) as cur:
|
|
255
|
+
yield cur
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
from typing import Any, AsyncIterator, Optional
|
|
4
|
+
|
|
5
|
+
from langchain_core.runnables import RunnableConfig
|
|
6
|
+
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline
|
|
7
|
+
from psycopg.rows import dict_row
|
|
8
|
+
from psycopg.types.json import Jsonb
|
|
9
|
+
|
|
10
|
+
from langgraph.checkpoint.base import (
|
|
11
|
+
ChannelVersions,
|
|
12
|
+
Checkpoint,
|
|
13
|
+
CheckpointMetadata,
|
|
14
|
+
CheckpointTuple,
|
|
15
|
+
get_checkpoint_id,
|
|
16
|
+
)
|
|
17
|
+
from langgraph.checkpoint.postgres.base import BasePostgresSaver
|
|
18
|
+
from langgraph.checkpoint.serde.base import SerializerProtocol
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AsyncPostgresSaver(BasePostgresSaver):
|
|
22
|
+
lock: asyncio.Lock
|
|
23
|
+
|
|
24
|
+
is_setup: bool
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
conn: AsyncConnection,
|
|
29
|
+
pipe: Optional[AsyncPipeline] = None,
|
|
30
|
+
serde: Optional[SerializerProtocol] = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
super().__init__(serde=serde)
|
|
33
|
+
self.conn = conn
|
|
34
|
+
self.pipe = pipe
|
|
35
|
+
self.lock = asyncio.Lock()
|
|
36
|
+
self.is_setup = False
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
@asynccontextmanager
|
|
40
|
+
async def from_conn_string(
|
|
41
|
+
cls, conn_string: str, *, pipeline: bool = False
|
|
42
|
+
) -> AsyncIterator["AsyncPostgresSaver"]:
|
|
43
|
+
"""Create a new PostgresSaver instance from a connection string.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
conn_string (str): The Postgres connection info string.
|
|
47
|
+
pipeline (bool): whether to use AsyncPipeline
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
PostgresSaver: A new PostgresSaver instance.
|
|
51
|
+
"""
|
|
52
|
+
async with await AsyncConnection.connect(
|
|
53
|
+
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
|
|
54
|
+
) as conn:
|
|
55
|
+
if pipeline:
|
|
56
|
+
async with conn.pipeline() as pipe:
|
|
57
|
+
yield AsyncPostgresSaver(conn, pipe)
|
|
58
|
+
else:
|
|
59
|
+
yield AsyncPostgresSaver(conn)
|
|
60
|
+
|
|
61
|
+
async def setup(self) -> None:
|
|
62
|
+
"""Set up the checkpoint database asynchronously.
|
|
63
|
+
|
|
64
|
+
This method creates the necessary tables in the SQLite database if they don't
|
|
65
|
+
already exist. It is called automatically when needed and should not be called
|
|
66
|
+
directly by the user.
|
|
67
|
+
"""
|
|
68
|
+
if self.is_setup:
|
|
69
|
+
return
|
|
70
|
+
async with self.lock:
|
|
71
|
+
create_table_queries = [
|
|
72
|
+
self.CREATE_CHECKPOINTS_SQL,
|
|
73
|
+
self.CREATE_CHECKPOINT_BLOBS_SQL,
|
|
74
|
+
self.CREATE_CHECKPOINT_WRITES_SQL,
|
|
75
|
+
]
|
|
76
|
+
async with self.conn.cursor() as cur:
|
|
77
|
+
for query in create_table_queries:
|
|
78
|
+
await cur.execute(query)
|
|
79
|
+
|
|
80
|
+
if self.pipe:
|
|
81
|
+
await self.pipe.sync()
|
|
82
|
+
|
|
83
|
+
self.is_setup = True
|
|
84
|
+
|
|
85
|
+
async def alist(
|
|
86
|
+
self,
|
|
87
|
+
config: Optional[RunnableConfig],
|
|
88
|
+
*,
|
|
89
|
+
filter: Optional[dict[str, Any]] = None,
|
|
90
|
+
before: Optional[RunnableConfig] = None,
|
|
91
|
+
limit: Optional[int] = None,
|
|
92
|
+
) -> AsyncIterator[CheckpointTuple]:
|
|
93
|
+
await self.setup()
|
|
94
|
+
where, args = self._search_where(config, filter, before)
|
|
95
|
+
query = self.SELECT_SQL + where + " ORDER BY checkpoint_id DESC"
|
|
96
|
+
if limit:
|
|
97
|
+
query += f" LIMIT {limit}"
|
|
98
|
+
# if we change this to use .stream() we need to make sure to close the cursor
|
|
99
|
+
async for value in await self.conn.execute(query, args, binary=True):
|
|
100
|
+
yield CheckpointTuple(
|
|
101
|
+
{
|
|
102
|
+
"configurable": {
|
|
103
|
+
"thread_id": value["thread_id"],
|
|
104
|
+
"checkpoint_ns": value["checkpoint_ns"],
|
|
105
|
+
"checkpoint_id": value["checkpoint_id"],
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
**self._load_checkpoint(value["checkpoint"]),
|
|
110
|
+
"channel_values": await asyncio.to_thread(
|
|
111
|
+
self._load_blobs, value["channel_values"]
|
|
112
|
+
),
|
|
113
|
+
},
|
|
114
|
+
value["metadata"],
|
|
115
|
+
{
|
|
116
|
+
"configurable": {
|
|
117
|
+
"thread_id": value["thread_id"],
|
|
118
|
+
"checkpoint_ns": value["checkpoint_ns"],
|
|
119
|
+
"checkpoint_id": value["parent_checkpoint_id"],
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if value["parent_checkpoint_id"]
|
|
123
|
+
else None,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
|
127
|
+
await self.setup()
|
|
128
|
+
thread_id = config["configurable"]["thread_id"]
|
|
129
|
+
checkpoint_id = get_checkpoint_id(config)
|
|
130
|
+
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
|
131
|
+
if checkpoint_id:
|
|
132
|
+
args = (thread_id, checkpoint_ns, checkpoint_id)
|
|
133
|
+
where = "WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id = %s"
|
|
134
|
+
else:
|
|
135
|
+
args = (thread_id, checkpoint_ns)
|
|
136
|
+
where = "WHERE thread_id = %s AND checkpoint_ns = %s ORDER BY checkpoint_id DESC LIMIT 1"
|
|
137
|
+
|
|
138
|
+
async with self._cursor() as cur:
|
|
139
|
+
cur = await self.conn.execute(
|
|
140
|
+
self.SELECT_SQL + where,
|
|
141
|
+
args,
|
|
142
|
+
binary=True,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
async for value in cur:
|
|
146
|
+
return CheckpointTuple(
|
|
147
|
+
{
|
|
148
|
+
"configurable": {
|
|
149
|
+
"thread_id": thread_id,
|
|
150
|
+
"checkpoint_ns": checkpoint_ns,
|
|
151
|
+
"checkpoint_id": value["checkpoint_id"],
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
**self._load_checkpoint(value["checkpoint"]),
|
|
156
|
+
"channel_values": await asyncio.to_thread(
|
|
157
|
+
self._load_blobs, value["channel_values"]
|
|
158
|
+
),
|
|
159
|
+
},
|
|
160
|
+
value["metadata"],
|
|
161
|
+
{
|
|
162
|
+
"configurable": {
|
|
163
|
+
"thread_id": thread_id,
|
|
164
|
+
"checkpoint_ns": checkpoint_ns,
|
|
165
|
+
"checkpoint_id": value["parent_checkpoint_id"],
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if value["parent_checkpoint_id"]
|
|
169
|
+
else None,
|
|
170
|
+
await asyncio.to_thread(self._load_writes, value["pending_writes"]),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
async def aput(
|
|
174
|
+
self,
|
|
175
|
+
config: RunnableConfig,
|
|
176
|
+
checkpoint: Checkpoint,
|
|
177
|
+
metadata: CheckpointMetadata,
|
|
178
|
+
new_versions: ChannelVersions,
|
|
179
|
+
) -> RunnableConfig:
|
|
180
|
+
await self.setup()
|
|
181
|
+
configurable = config["configurable"].copy()
|
|
182
|
+
thread_id = configurable.pop("thread_id")
|
|
183
|
+
checkpoint_ns = configurable.pop("checkpoint_ns")
|
|
184
|
+
checkpoint_id = configurable.pop(
|
|
185
|
+
"checkpoint_id", configurable.pop("thread_ts", None)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
copy = checkpoint.copy()
|
|
189
|
+
next_config = {
|
|
190
|
+
"configurable": {
|
|
191
|
+
"thread_id": thread_id,
|
|
192
|
+
"checkpoint_ns": checkpoint_ns,
|
|
193
|
+
"checkpoint_id": checkpoint["id"],
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async with self._cursor(pipeline=True) as cur:
|
|
198
|
+
await cur.executemany(
|
|
199
|
+
self.UPSERT_CHECKPOINT_BLOBS_SQL,
|
|
200
|
+
await asyncio.to_thread(
|
|
201
|
+
self._dump_blobs,
|
|
202
|
+
thread_id,
|
|
203
|
+
checkpoint_ns,
|
|
204
|
+
copy.pop("channel_values"),
|
|
205
|
+
copy["channel_versions"],
|
|
206
|
+
new_versions,
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
await cur.execute(
|
|
210
|
+
self.UPSERT_CHECKPOINTS_SQL,
|
|
211
|
+
(
|
|
212
|
+
thread_id,
|
|
213
|
+
checkpoint_ns,
|
|
214
|
+
checkpoint["id"],
|
|
215
|
+
checkpoint_id,
|
|
216
|
+
Jsonb(self._dump_checkpoint(copy)),
|
|
217
|
+
Jsonb(metadata),
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
return next_config
|
|
221
|
+
|
|
222
|
+
async def aput_writes(
|
|
223
|
+
self,
|
|
224
|
+
config: RunnableConfig,
|
|
225
|
+
writes: list[tuple[str, Any]],
|
|
226
|
+
task_id: str,
|
|
227
|
+
) -> None:
|
|
228
|
+
async with self._cursor() as cur:
|
|
229
|
+
await cur.executemany(
|
|
230
|
+
self.UPSERT_CHECKPOINT_WRITES_SQL,
|
|
231
|
+
await asyncio.to_thread(
|
|
232
|
+
self._dump_writes,
|
|
233
|
+
config["configurable"]["thread_id"],
|
|
234
|
+
config["configurable"]["checkpoint_ns"],
|
|
235
|
+
config["configurable"]["checkpoint_id"],
|
|
236
|
+
task_id,
|
|
237
|
+
writes,
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
@asynccontextmanager
|
|
242
|
+
async def _cursor(self, *, pipeline: bool = False) -> AsyncIterator[AsyncCursor]:
|
|
243
|
+
await self.setup()
|
|
244
|
+
if self.pipe:
|
|
245
|
+
# a connection in pipeline mode can be used concurrently
|
|
246
|
+
# in multiple threads/coroutines, but only one cursor can be
|
|
247
|
+
# used at a time
|
|
248
|
+
try:
|
|
249
|
+
async with self.conn.cursor(binary=True) as cur:
|
|
250
|
+
yield cur
|
|
251
|
+
finally:
|
|
252
|
+
await self.pipe.sync()
|
|
253
|
+
elif pipeline:
|
|
254
|
+
# a connection not in pipeline mode can only be used by one
|
|
255
|
+
# thread/coroutine at a time, so we acquire a lock
|
|
256
|
+
async with self.lock, self.conn.pipeline(), self.conn.cursor(
|
|
257
|
+
binary=True
|
|
258
|
+
) as cur:
|
|
259
|
+
yield cur
|
|
260
|
+
else:
|
|
261
|
+
async with self.lock, self.conn.cursor(binary=True) as cur:
|
|
262
|
+
yield cur
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
from base64 import b64decode, b64encode
|
|
2
|
+
from hashlib import md5
|
|
3
|
+
from typing import Any, List, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
from langchain_core.runnables import RunnableConfig
|
|
6
|
+
from psycopg.types.json import Jsonb
|
|
7
|
+
|
|
8
|
+
from langgraph.checkpoint.base import (
|
|
9
|
+
BaseCheckpointSaver,
|
|
10
|
+
Checkpoint,
|
|
11
|
+
EmptyChannelError,
|
|
12
|
+
get_checkpoint_id,
|
|
13
|
+
)
|
|
14
|
+
from langgraph.checkpoint.serde.types import ChannelProtocol
|
|
15
|
+
|
|
16
|
+
MetadataInput = Optional[dict[str, Any]]
|
|
17
|
+
|
|
18
|
+
SELECT_SQL = """
|
|
19
|
+
select
|
|
20
|
+
thread_id,
|
|
21
|
+
checkpoint,
|
|
22
|
+
checkpoint_ns,
|
|
23
|
+
checkpoint_id,
|
|
24
|
+
parent_checkpoint_id,
|
|
25
|
+
metadata,
|
|
26
|
+
(
|
|
27
|
+
select array_agg(array[bl.channel::bytea, bl.type::bytea, bl.blob])
|
|
28
|
+
from jsonb_each_text(checkpoint -> 'channel_versions')
|
|
29
|
+
inner join checkpoint_blobs bl
|
|
30
|
+
on bl.thread_id = checkpoints.thread_id
|
|
31
|
+
and bl.checkpoint_ns = checkpoints.checkpoint_ns
|
|
32
|
+
and bl.channel = jsonb_each_text.key
|
|
33
|
+
and bl.version = jsonb_each_text.value
|
|
34
|
+
) as channel_values,
|
|
35
|
+
(
|
|
36
|
+
select
|
|
37
|
+
array_agg(array[cw.task_id::text::bytea, cw.channel::bytea, cw.type::bytea, cw.blob])
|
|
38
|
+
from checkpoint_writes cw
|
|
39
|
+
where cw.thread_id = checkpoints.thread_id
|
|
40
|
+
and cw.checkpoint_ns = checkpoints.checkpoint_ns
|
|
41
|
+
and cw.checkpoint_id = checkpoints.checkpoint_id
|
|
42
|
+
) as pending_writes
|
|
43
|
+
from checkpoints """
|
|
44
|
+
|
|
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
|
+
UPSERT_CHECKPOINT_BLOBS_SQL = """
|
|
83
|
+
INSERT INTO checkpoint_blobs (thread_id, checkpoint_ns, channel, version, type, blob)
|
|
84
|
+
VALUES (%s, %s, %s, %s, %s, %s)
|
|
85
|
+
ON CONFLICT (thread_id, checkpoint_ns, channel, version) DO NOTHING
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
UPSERT_CHECKPOINTS_SQL = """
|
|
89
|
+
INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, checkpoint, metadata)
|
|
90
|
+
VALUES (%s, %s, %s, %s, %s, %s)
|
|
91
|
+
ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id)
|
|
92
|
+
DO UPDATE SET
|
|
93
|
+
checkpoint = EXCLUDED.checkpoint,
|
|
94
|
+
metadata = EXCLUDED.metadata;
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
UPSERT_CHECKPOINT_WRITES_SQL = """
|
|
98
|
+
INSERT INTO checkpoint_writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, blob)
|
|
99
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
100
|
+
ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) DO NOTHING
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class BasePostgresSaver(BaseCheckpointSaver):
|
|
105
|
+
SELECT_SQL = SELECT_SQL
|
|
106
|
+
CREATE_CHECKPOINTS_SQL = CREATE_CHECKPOINTS_SQL
|
|
107
|
+
CREATE_CHECKPOINT_BLOBS_SQL = CREATE_CHECKPOINT_BLOBS_SQL
|
|
108
|
+
CREATE_CHECKPOINT_WRITES_SQL = CREATE_CHECKPOINT_WRITES_SQL
|
|
109
|
+
UPSERT_CHECKPOINT_BLOBS_SQL = UPSERT_CHECKPOINT_BLOBS_SQL
|
|
110
|
+
UPSERT_CHECKPOINTS_SQL = UPSERT_CHECKPOINTS_SQL
|
|
111
|
+
UPSERT_CHECKPOINT_WRITES_SQL = UPSERT_CHECKPOINT_WRITES_SQL
|
|
112
|
+
|
|
113
|
+
def _load_checkpoint(self, checkpoint: dict[str, Any]) -> Checkpoint:
|
|
114
|
+
if len(checkpoint["pending_sends"]) == 2 and all(
|
|
115
|
+
isinstance(a, str) for a in checkpoint["pending_sends"]
|
|
116
|
+
):
|
|
117
|
+
type, bs = checkpoint["pending_sends"]
|
|
118
|
+
return {
|
|
119
|
+
**checkpoint,
|
|
120
|
+
"pending_sends": self.serde.loads_typed((type, b64decode(bs))),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return checkpoint
|
|
124
|
+
|
|
125
|
+
def _dump_checkpoint(self, checkpoint: Checkpoint) -> dict[str, Any]:
|
|
126
|
+
type, bs = self.serde.dumps_typed(checkpoint["pending_sends"])
|
|
127
|
+
return {
|
|
128
|
+
**checkpoint,
|
|
129
|
+
"pending_sends": (type, b64encode(bs).decode()),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
def _load_blobs(
|
|
133
|
+
self, blob_values: list[tuple[bytes, bytes, bytes]]
|
|
134
|
+
) -> dict[str, Any]:
|
|
135
|
+
if not blob_values:
|
|
136
|
+
return {}
|
|
137
|
+
return {
|
|
138
|
+
k.decode(): self.serde.loads_typed((t.decode(), v))
|
|
139
|
+
for k, t, v in blob_values
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
def _dump_blobs(
|
|
143
|
+
self,
|
|
144
|
+
thread_id: str,
|
|
145
|
+
checkpoint_ns: str,
|
|
146
|
+
values: dict[str, Any],
|
|
147
|
+
versions: dict[str, str],
|
|
148
|
+
new_versions: Optional[dict[str, str]],
|
|
149
|
+
) -> list[tuple[str, str, str, str, str, bytes]]:
|
|
150
|
+
if not versions:
|
|
151
|
+
return []
|
|
152
|
+
|
|
153
|
+
if new_versions:
|
|
154
|
+
versions = new_versions
|
|
155
|
+
|
|
156
|
+
return [
|
|
157
|
+
(
|
|
158
|
+
thread_id,
|
|
159
|
+
checkpoint_ns,
|
|
160
|
+
k,
|
|
161
|
+
ver,
|
|
162
|
+
*self.serde.dumps_typed(values[k]),
|
|
163
|
+
)
|
|
164
|
+
for k, ver in versions.items()
|
|
165
|
+
if k in values
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
def _load_writes(
|
|
169
|
+
self, writes: list[tuple[bytes, bytes, bytes, bytes]]
|
|
170
|
+
) -> list[tuple[str, str, Any]]:
|
|
171
|
+
return (
|
|
172
|
+
[
|
|
173
|
+
(
|
|
174
|
+
tid.decode(),
|
|
175
|
+
channel.decode(),
|
|
176
|
+
self.serde.loads_typed((t.decode(), v)),
|
|
177
|
+
)
|
|
178
|
+
for tid, channel, t, v in writes
|
|
179
|
+
]
|
|
180
|
+
if writes
|
|
181
|
+
else []
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def _dump_writes(
|
|
185
|
+
self,
|
|
186
|
+
thread_id: str,
|
|
187
|
+
checkpoint_ns: str,
|
|
188
|
+
checkpoint_id: str,
|
|
189
|
+
task_id: str,
|
|
190
|
+
writes: list[tuple[str, Any]],
|
|
191
|
+
) -> list[tuple[str, str, str, int, str, str, bytes]]:
|
|
192
|
+
return [
|
|
193
|
+
(
|
|
194
|
+
thread_id,
|
|
195
|
+
checkpoint_ns,
|
|
196
|
+
checkpoint_id,
|
|
197
|
+
task_id,
|
|
198
|
+
idx,
|
|
199
|
+
channel,
|
|
200
|
+
*self.serde.dumps_typed(value),
|
|
201
|
+
)
|
|
202
|
+
for idx, (channel, value) in enumerate(writes)
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
def get_next_version(self, current: Optional[str], channel: ChannelProtocol) -> str:
|
|
206
|
+
if current is None:
|
|
207
|
+
current_v = 0
|
|
208
|
+
elif isinstance(current, int):
|
|
209
|
+
current_v = current
|
|
210
|
+
else:
|
|
211
|
+
current_v = int(current.split(".")[0])
|
|
212
|
+
next_v = current_v + 1
|
|
213
|
+
try:
|
|
214
|
+
next_h = md5(self.serde.dumps_typed(channel.checkpoint())[1]).hexdigest()
|
|
215
|
+
except EmptyChannelError:
|
|
216
|
+
next_h = ""
|
|
217
|
+
return f"{next_v:032}.{next_h}"
|
|
218
|
+
|
|
219
|
+
def _search_where(
|
|
220
|
+
self,
|
|
221
|
+
config: Optional[RunnableConfig],
|
|
222
|
+
filter: MetadataInput,
|
|
223
|
+
before: Optional[RunnableConfig] = None,
|
|
224
|
+
) -> Tuple[str, List[Any]]:
|
|
225
|
+
"""Return WHERE clause predicates for alist() given config, filter, cursor.
|
|
226
|
+
|
|
227
|
+
This method returns a tuple of a string and a tuple of values. The string
|
|
228
|
+
is the parametered WHERE clause predicate (including the WHERE keyword):
|
|
229
|
+
"WHERE column1 = $1 AND column2 IS $2". The list of values contains the
|
|
230
|
+
values for each of the corresponding parameters.
|
|
231
|
+
"""
|
|
232
|
+
wheres = []
|
|
233
|
+
param_values = []
|
|
234
|
+
|
|
235
|
+
# construct predicate for config filter
|
|
236
|
+
if config:
|
|
237
|
+
wheres.append("thread_id = %s ")
|
|
238
|
+
param_values.append(config["configurable"]["thread_id"])
|
|
239
|
+
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
|
240
|
+
wheres.append("checkpoint_ns = %s")
|
|
241
|
+
param_values.append(checkpoint_ns)
|
|
242
|
+
|
|
243
|
+
# construct predicate for metadata filter
|
|
244
|
+
if filter:
|
|
245
|
+
wheres.append("metadata @> %s ")
|
|
246
|
+
param_values.append(Jsonb(filter))
|
|
247
|
+
|
|
248
|
+
# construct predicate for `before`
|
|
249
|
+
if before is not None:
|
|
250
|
+
wheres.append("checkpoint_id < %s ")
|
|
251
|
+
param_values.append(get_checkpoint_id(before))
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
"WHERE " + " AND ".join(wheres) if wheres else "",
|
|
255
|
+
param_values,
|
|
256
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "langgraph-checkpoint-postgres"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
|
|
5
|
+
authors = []
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
repository = "https://www.github.com/langchain-ai/langgraph"
|
|
9
|
+
packages = [{ include = "langgraph" }]
|
|
10
|
+
|
|
11
|
+
[tool.poetry.dependencies]
|
|
12
|
+
python = "^3.9.0,<4.0"
|
|
13
|
+
langgraph-checkpoint = "^1.0.1"
|
|
14
|
+
orjson = ">=3.10.1"
|
|
15
|
+
psycopg = {extras = ["binary"], version = ">=3.1.19"}
|
|
16
|
+
|
|
17
|
+
[tool.poetry.group.dev.dependencies]
|
|
18
|
+
ruff = "^0.1.4"
|
|
19
|
+
codespell = "^2.2.0"
|
|
20
|
+
pytest = "^7.2.1"
|
|
21
|
+
anyio = "^4.4.0"
|
|
22
|
+
pytest-asyncio = "^0.21.1"
|
|
23
|
+
pytest-mock = "^3.11.1"
|
|
24
|
+
pytest-watch = "^4.2.0"
|
|
25
|
+
mypy = "^1.10.0"
|
|
26
|
+
psycopg-pool = "^3.2.2"
|
|
27
|
+
langgraph-checkpoint = {path = "../checkpoint", develop = true}
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
# --strict-markers will raise errors on unknown marks.
|
|
31
|
+
# https://docs.pytest.org/en/7.1.x/how-to/mark.html#raising-errors-on-unknown-marks
|
|
32
|
+
#
|
|
33
|
+
# https://docs.pytest.org/en/7.1.x/reference/reference.html
|
|
34
|
+
# --strict-config any warnings encountered while parsing the `pytest`
|
|
35
|
+
# section of the configuration file raise errors.
|
|
36
|
+
addopts = "--strict-markers --strict-config --durations=5 -vv"
|
|
37
|
+
asyncio_mode = "auto"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["poetry-core"]
|
|
42
|
+
build-backend = "poetry.core.masonry.api"
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
lint.select = [
|
|
46
|
+
"E", # pycodestyle
|
|
47
|
+
"F", # Pyflakes
|
|
48
|
+
"UP", # pyupgrade
|
|
49
|
+
"B", # flake8-bugbear
|
|
50
|
+
"I", # isort
|
|
51
|
+
]
|
|
52
|
+
lint.ignore = ["E501", "B008", "UP007", "UP006"]
|