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.
@@ -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
+ )
@@ -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"]