langgraph-checkpoint-postgres 1.0.3__tar.gz → 1.0.5__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.3 → langgraph_checkpoint_postgres-1.0.5}/PKG-INFO +7 -4
- {langgraph_checkpoint_postgres-1.0.3 → langgraph_checkpoint_postgres-1.0.5}/README.md +4 -2
- {langgraph_checkpoint_postgres-1.0.3 → langgraph_checkpoint_postgres-1.0.5}/langgraph/checkpoint/postgres/__init__.py +88 -64
- {langgraph_checkpoint_postgres-1.0.3 → langgraph_checkpoint_postgres-1.0.5}/langgraph/checkpoint/postgres/aio.py +95 -73
- {langgraph_checkpoint_postgres-1.0.3 → langgraph_checkpoint_postgres-1.0.5}/langgraph/checkpoint/postgres/base.py +36 -24
- {langgraph_checkpoint_postgres-1.0.3 → langgraph_checkpoint_postgres-1.0.5}/pyproject.toml +5 -4
- {langgraph_checkpoint_postgres-1.0.3 → langgraph_checkpoint_postgres-1.0.5}/langgraph/checkpoint/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: langgraph-checkpoint-postgres
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.5
|
|
4
4
|
Summary: Library with a Postgres implementation of LangGraph checkpoint saver.
|
|
5
5
|
Home-page: https://www.github.com/langchain-ai/langgraph
|
|
6
6
|
License: MIT
|
|
@@ -13,7 +13,8 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
14
|
Requires-Dist: langgraph-checkpoint (>=1.0.1,<2.0.0)
|
|
15
15
|
Requires-Dist: orjson (>=3.10.1)
|
|
16
|
-
Requires-Dist: psycopg
|
|
16
|
+
Requires-Dist: psycopg (>=3.0.0,<4.0.0)
|
|
17
|
+
Requires-Dist: psycopg-pool (>=3.0.0,<4.0.0)
|
|
17
18
|
Project-URL: Repository, https://www.github.com/langchain-ai/langgraph
|
|
18
19
|
Description-Content-Type: text/markdown
|
|
19
20
|
|
|
@@ -21,6 +22,10 @@ Description-Content-Type: text/markdown
|
|
|
21
22
|
|
|
22
23
|
Implementation of LangGraph CheckpointSaver that uses Postgres.
|
|
23
24
|
|
|
25
|
+
## Dependencies
|
|
26
|
+
|
|
27
|
+
By default `langgraph-checkpoint-postgres` installs `psycopg` (Psycopg 3) without any extras. However, you can choose a specific installation that best suits your needs [here](https://www.psycopg.org/psycopg3/docs/basic/install.html) (for example, `psycopg[binary]`).
|
|
28
|
+
|
|
24
29
|
## Usage
|
|
25
30
|
|
|
26
31
|
> [!IMPORTANT]
|
|
@@ -63,7 +68,6 @@ with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
|
|
63
68
|
}
|
|
64
69
|
},
|
|
65
70
|
"pending_sends": [],
|
|
66
|
-
"current_tasks": {}
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
# store checkpoint
|
|
@@ -106,7 +110,6 @@ async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
|
|
106
110
|
}
|
|
107
111
|
},
|
|
108
112
|
"pending_sends": [],
|
|
109
|
-
"current_tasks": {}
|
|
110
113
|
}
|
|
111
114
|
|
|
112
115
|
# store checkpoint
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Implementation of LangGraph CheckpointSaver that uses Postgres.
|
|
4
4
|
|
|
5
|
+
## Dependencies
|
|
6
|
+
|
|
7
|
+
By default `langgraph-checkpoint-postgres` installs `psycopg` (Psycopg 3) without any extras. However, you can choose a specific installation that best suits your needs [here](https://www.psycopg.org/psycopg3/docs/basic/install.html) (for example, `psycopg[binary]`).
|
|
8
|
+
|
|
5
9
|
## Usage
|
|
6
10
|
|
|
7
11
|
> [!IMPORTANT]
|
|
@@ -44,7 +48,6 @@ with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
|
|
44
48
|
}
|
|
45
49
|
},
|
|
46
50
|
"pending_sends": [],
|
|
47
|
-
"current_tasks": {}
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
# store checkpoint
|
|
@@ -87,7 +90,6 @@ async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
|
|
87
90
|
}
|
|
88
91
|
},
|
|
89
92
|
"pending_sends": [],
|
|
90
|
-
"current_tasks": {}
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
# store checkpoint
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import threading
|
|
2
2
|
from contextlib import contextmanager
|
|
3
|
-
from typing import Any, Iterator, List, Optional
|
|
3
|
+
from typing import Any, Iterator, List, Optional, Union
|
|
4
4
|
|
|
5
5
|
from langchain_core.runnables import RunnableConfig
|
|
6
6
|
from psycopg import Connection, Cursor, Pipeline
|
|
7
7
|
from psycopg.errors import UndefinedTable
|
|
8
8
|
from psycopg.rows import dict_row
|
|
9
9
|
from psycopg.types.json import Jsonb
|
|
10
|
+
from psycopg_pool import ConnectionPool
|
|
10
11
|
|
|
11
12
|
from langgraph.checkpoint.base import (
|
|
12
13
|
ChannelVersions,
|
|
@@ -21,16 +22,32 @@ from langgraph.checkpoint.postgres.base import (
|
|
|
21
22
|
from langgraph.checkpoint.serde.base import SerializerProtocol
|
|
22
23
|
|
|
23
24
|
|
|
25
|
+
@contextmanager
|
|
26
|
+
def _get_connection(conn: Union[Connection, ConnectionPool]) -> Iterator[Connection]:
|
|
27
|
+
if isinstance(conn, Connection):
|
|
28
|
+
yield conn
|
|
29
|
+
elif isinstance(conn, ConnectionPool):
|
|
30
|
+
with conn.connection() as conn:
|
|
31
|
+
yield conn
|
|
32
|
+
else:
|
|
33
|
+
raise TypeError(f"Invalid connection type: {type(conn)}")
|
|
34
|
+
|
|
35
|
+
|
|
24
36
|
class PostgresSaver(BasePostgresSaver):
|
|
25
37
|
lock: threading.Lock
|
|
26
38
|
|
|
27
39
|
def __init__(
|
|
28
40
|
self,
|
|
29
|
-
conn: Connection,
|
|
41
|
+
conn: Union[Connection, ConnectionPool],
|
|
30
42
|
pipe: Optional[Pipeline] = None,
|
|
31
43
|
serde: Optional[SerializerProtocol] = None,
|
|
32
44
|
) -> None:
|
|
33
45
|
super().__init__(serde=serde)
|
|
46
|
+
if isinstance(conn, ConnectionPool) and pipe is not None:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
"Pipeline should be used only with a single Connection, not ConnectionPool."
|
|
49
|
+
)
|
|
50
|
+
|
|
34
51
|
self.conn = conn
|
|
35
52
|
self.pipe = pipe
|
|
36
53
|
self.lock = threading.Lock()
|
|
@@ -65,22 +82,21 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
65
82
|
already exist and runs database migrations. It MUST be called directly by the user
|
|
66
83
|
the first time checkpointer is used.
|
|
67
84
|
"""
|
|
68
|
-
with self.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
self.pipe.sync()
|
|
85
|
+
with self._cursor() as cur:
|
|
86
|
+
try:
|
|
87
|
+
version = cur.execute(
|
|
88
|
+
"SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1"
|
|
89
|
+
).fetchone()["v"]
|
|
90
|
+
except UndefinedTable:
|
|
91
|
+
version = -1
|
|
92
|
+
for v, migration in zip(
|
|
93
|
+
range(version + 1, len(self.MIGRATIONS)),
|
|
94
|
+
self.MIGRATIONS[version + 1 :],
|
|
95
|
+
):
|
|
96
|
+
cur.execute(migration)
|
|
97
|
+
cur.execute(f"INSERT INTO checkpoint_migrations (v) VALUES ({v})")
|
|
98
|
+
if self.pipe:
|
|
99
|
+
self.pipe.sync()
|
|
84
100
|
|
|
85
101
|
def list(
|
|
86
102
|
self,
|
|
@@ -127,30 +143,34 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
127
143
|
if limit:
|
|
128
144
|
query += f" LIMIT {limit}"
|
|
129
145
|
# if we change this to use .stream() we need to make sure to close the cursor
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
"
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
"
|
|
146
|
+
with self._cursor() as cur:
|
|
147
|
+
cur.execute(query, args, binary=True)
|
|
148
|
+
for value in cur:
|
|
149
|
+
yield CheckpointTuple(
|
|
150
|
+
{
|
|
151
|
+
"configurable": {
|
|
152
|
+
"thread_id": value["thread_id"],
|
|
153
|
+
"checkpoint_ns": value["checkpoint_ns"],
|
|
154
|
+
"checkpoint_id": value["checkpoint_id"],
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
self._load_checkpoint(
|
|
158
|
+
value["checkpoint"],
|
|
159
|
+
value["channel_values"],
|
|
160
|
+
value["pending_sends"],
|
|
161
|
+
),
|
|
162
|
+
self._load_metadata(value["metadata"]),
|
|
163
|
+
{
|
|
164
|
+
"configurable": {
|
|
165
|
+
"thread_id": value["thread_id"],
|
|
166
|
+
"checkpoint_ns": value["checkpoint_ns"],
|
|
167
|
+
"checkpoint_id": value["parent_checkpoint_id"],
|
|
168
|
+
}
|
|
149
169
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
170
|
+
if value["parent_checkpoint_id"]
|
|
171
|
+
else None,
|
|
172
|
+
self._load_writes(value["pending_writes"]),
|
|
173
|
+
)
|
|
154
174
|
|
|
155
175
|
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
|
156
176
|
"""Get a checkpoint tuple from the database.
|
|
@@ -198,7 +218,7 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
198
218
|
where = "WHERE thread_id = %s AND checkpoint_ns = %s ORDER BY checkpoint_id DESC LIMIT 1"
|
|
199
219
|
|
|
200
220
|
with self._cursor() as cur:
|
|
201
|
-
cur
|
|
221
|
+
cur.execute(
|
|
202
222
|
self.SELECT_SQL + where,
|
|
203
223
|
args,
|
|
204
224
|
binary=True,
|
|
@@ -213,10 +233,11 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
213
233
|
"checkpoint_id": value["checkpoint_id"],
|
|
214
234
|
}
|
|
215
235
|
},
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
236
|
+
self._load_checkpoint(
|
|
237
|
+
value["checkpoint"],
|
|
238
|
+
value["channel_values"],
|
|
239
|
+
value["pending_sends"],
|
|
240
|
+
),
|
|
220
241
|
self._load_metadata(value["metadata"]),
|
|
221
242
|
{
|
|
222
243
|
"configurable": {
|
|
@@ -316,7 +337,7 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
316
337
|
writes (List[Tuple[str, Any]]): List of writes to store.
|
|
317
338
|
task_id (str): Identifier for the task creating the writes.
|
|
318
339
|
"""
|
|
319
|
-
with self._cursor() as cur:
|
|
340
|
+
with self._cursor(pipeline=True) as cur:
|
|
320
341
|
cur.executemany(
|
|
321
342
|
self.UPSERT_CHECKPOINT_WRITES_SQL,
|
|
322
343
|
self._dump_writes(
|
|
@@ -330,21 +351,24 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
330
351
|
|
|
331
352
|
@contextmanager
|
|
332
353
|
def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor]:
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
354
|
+
with _get_connection(self.conn) as conn:
|
|
355
|
+
if self.pipe:
|
|
356
|
+
# a connection in pipeline mode can be used concurrently
|
|
357
|
+
# in multiple threads/coroutines, but only one cursor can be
|
|
358
|
+
# used at a time
|
|
359
|
+
try:
|
|
360
|
+
with conn.cursor(binary=True, row_factory=dict_row) as cur:
|
|
361
|
+
yield cur
|
|
362
|
+
finally:
|
|
363
|
+
if pipeline:
|
|
364
|
+
self.pipe.sync()
|
|
365
|
+
elif pipeline:
|
|
366
|
+
# a connection not in pipeline mode can only be used by one
|
|
367
|
+
# thread/coroutine at a time, so we acquire a lock
|
|
368
|
+
with self.lock, conn.pipeline(), conn.cursor(
|
|
369
|
+
binary=True, row_factory=dict_row
|
|
370
|
+
) as cur:
|
|
371
|
+
yield cur
|
|
372
|
+
else:
|
|
373
|
+
with self.lock, conn.cursor(binary=True, row_factory=dict_row) as cur:
|
|
339
374
|
yield cur
|
|
340
|
-
finally:
|
|
341
|
-
if pipeline:
|
|
342
|
-
self.pipe.sync()
|
|
343
|
-
elif pipeline:
|
|
344
|
-
# a connection not in pipeline mode can only be used by one
|
|
345
|
-
# thread/coroutine at a time, so we acquire a lock
|
|
346
|
-
with self.lock, self.conn.pipeline(), self.conn.cursor(binary=True) as cur:
|
|
347
|
-
yield cur
|
|
348
|
-
else:
|
|
349
|
-
with self.lock, self.conn.cursor(binary=True) as cur:
|
|
350
|
-
yield cur
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
from contextlib import asynccontextmanager
|
|
3
|
-
from typing import Any, AsyncIterator, Optional
|
|
3
|
+
from typing import Any, AsyncIterator, Optional, Union
|
|
4
4
|
|
|
5
5
|
from langchain_core.runnables import RunnableConfig
|
|
6
6
|
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline
|
|
7
7
|
from psycopg.errors import UndefinedTable
|
|
8
8
|
from psycopg.rows import dict_row
|
|
9
9
|
from psycopg.types.json import Jsonb
|
|
10
|
+
from psycopg_pool import AsyncConnectionPool
|
|
10
11
|
|
|
11
12
|
from langgraph.checkpoint.base import (
|
|
12
13
|
ChannelVersions,
|
|
@@ -19,16 +20,34 @@ from langgraph.checkpoint.postgres.base import BasePostgresSaver
|
|
|
19
20
|
from langgraph.checkpoint.serde.base import SerializerProtocol
|
|
20
21
|
|
|
21
22
|
|
|
23
|
+
@asynccontextmanager
|
|
24
|
+
async def _get_connection(
|
|
25
|
+
conn: Union[AsyncConnection, AsyncConnectionPool],
|
|
26
|
+
) -> AsyncIterator[AsyncConnection]:
|
|
27
|
+
if isinstance(conn, AsyncConnection):
|
|
28
|
+
yield conn
|
|
29
|
+
elif isinstance(conn, AsyncConnectionPool):
|
|
30
|
+
async with conn.connection() as conn:
|
|
31
|
+
yield conn
|
|
32
|
+
else:
|
|
33
|
+
raise TypeError(f"Invalid connection type: {type(conn)}")
|
|
34
|
+
|
|
35
|
+
|
|
22
36
|
class AsyncPostgresSaver(BasePostgresSaver):
|
|
23
37
|
lock: asyncio.Lock
|
|
24
38
|
|
|
25
39
|
def __init__(
|
|
26
40
|
self,
|
|
27
|
-
conn: AsyncConnection,
|
|
41
|
+
conn: Union[AsyncConnection, AsyncConnectionPool],
|
|
28
42
|
pipe: Optional[AsyncPipeline] = None,
|
|
29
43
|
serde: Optional[SerializerProtocol] = None,
|
|
30
44
|
) -> None:
|
|
31
45
|
super().__init__(serde=serde)
|
|
46
|
+
if isinstance(conn, AsyncConnectionPool) and pipe is not None:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
"Pipeline should be used only with a single AsyncConnection, not AsyncConnectionPool."
|
|
49
|
+
)
|
|
50
|
+
|
|
32
51
|
self.conn = conn
|
|
33
52
|
self.pipe = pipe
|
|
34
53
|
self.lock = asyncio.Lock()
|
|
@@ -45,7 +64,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
45
64
|
pipeline (bool): whether to use AsyncPipeline
|
|
46
65
|
|
|
47
66
|
Returns:
|
|
48
|
-
|
|
67
|
+
AsyncPostgresSaver: A new AsyncPostgresSaver instance.
|
|
49
68
|
"""
|
|
50
69
|
async with await AsyncConnection.connect(
|
|
51
70
|
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
|
|
@@ -63,25 +82,22 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
63
82
|
already exist and runs database migrations. It MUST be called directly by the user
|
|
64
83
|
the first time checkpointer is used.
|
|
65
84
|
"""
|
|
66
|
-
async with self.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
)
|
|
83
|
-
if self.pipe:
|
|
84
|
-
await self.pipe.sync()
|
|
85
|
+
async with self._cursor() as cur:
|
|
86
|
+
try:
|
|
87
|
+
results = await cur.execute(
|
|
88
|
+
"SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1"
|
|
89
|
+
)
|
|
90
|
+
version = (await results.fetchone())["v"]
|
|
91
|
+
except UndefinedTable:
|
|
92
|
+
version = -1
|
|
93
|
+
for v, migration in zip(
|
|
94
|
+
range(version + 1, len(self.MIGRATIONS)),
|
|
95
|
+
self.MIGRATIONS[version + 1 :],
|
|
96
|
+
):
|
|
97
|
+
await cur.execute(migration)
|
|
98
|
+
await cur.execute(f"INSERT INTO checkpoint_migrations (v) VALUES ({v})")
|
|
99
|
+
if self.pipe:
|
|
100
|
+
await self.pipe.sync()
|
|
85
101
|
|
|
86
102
|
async def alist(
|
|
87
103
|
self,
|
|
@@ -110,32 +126,35 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
110
126
|
if limit:
|
|
111
127
|
query += f" LIMIT {limit}"
|
|
112
128
|
# if we change this to use .stream() we need to make sure to close the cursor
|
|
113
|
-
async
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
"
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
self.
|
|
129
|
+
async with self._cursor() as cur:
|
|
130
|
+
await cur.execute(query, args, binary=True)
|
|
131
|
+
async for value in cur:
|
|
132
|
+
yield CheckpointTuple(
|
|
133
|
+
{
|
|
134
|
+
"configurable": {
|
|
135
|
+
"thread_id": value["thread_id"],
|
|
136
|
+
"checkpoint_ns": value["checkpoint_ns"],
|
|
137
|
+
"checkpoint_id": value["checkpoint_id"],
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
await asyncio.to_thread(
|
|
141
|
+
self._load_checkpoint,
|
|
142
|
+
value["checkpoint"],
|
|
143
|
+
value["channel_values"],
|
|
144
|
+
value["pending_sends"],
|
|
126
145
|
),
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
146
|
+
self._load_metadata(value["metadata"]),
|
|
147
|
+
{
|
|
148
|
+
"configurable": {
|
|
149
|
+
"thread_id": value["thread_id"],
|
|
150
|
+
"checkpoint_ns": value["checkpoint_ns"],
|
|
151
|
+
"checkpoint_id": value["parent_checkpoint_id"],
|
|
152
|
+
}
|
|
134
153
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
154
|
+
if value["parent_checkpoint_id"]
|
|
155
|
+
else None,
|
|
156
|
+
await asyncio.to_thread(self._load_writes, value["pending_writes"]),
|
|
157
|
+
)
|
|
139
158
|
|
|
140
159
|
async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
|
141
160
|
"""Get a checkpoint tuple from the database asynchronously.
|
|
@@ -162,7 +181,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
162
181
|
where = "WHERE thread_id = %s AND checkpoint_ns = %s ORDER BY checkpoint_id DESC LIMIT 1"
|
|
163
182
|
|
|
164
183
|
async with self._cursor() as cur:
|
|
165
|
-
|
|
184
|
+
await cur.execute(
|
|
166
185
|
self.SELECT_SQL + where,
|
|
167
186
|
args,
|
|
168
187
|
binary=True,
|
|
@@ -177,12 +196,12 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
177
196
|
"checkpoint_id": value["checkpoint_id"],
|
|
178
197
|
}
|
|
179
198
|
},
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
"
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
199
|
+
await asyncio.to_thread(
|
|
200
|
+
self._load_checkpoint,
|
|
201
|
+
value["checkpoint"],
|
|
202
|
+
value["channel_values"],
|
|
203
|
+
value["pending_sends"],
|
|
204
|
+
),
|
|
186
205
|
self._load_metadata(value["metadata"]),
|
|
187
206
|
{
|
|
188
207
|
"configurable": {
|
|
@@ -272,7 +291,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
272
291
|
writes (Sequence[Tuple[str, Any]]): List of writes to store, each as (channel, value) pair.
|
|
273
292
|
task_id (str): Identifier for the task creating the writes.
|
|
274
293
|
"""
|
|
275
|
-
async with self._cursor() as cur:
|
|
294
|
+
async with self._cursor(pipeline=True) as cur:
|
|
276
295
|
await cur.executemany(
|
|
277
296
|
self.UPSERT_CHECKPOINT_WRITES_SQL,
|
|
278
297
|
await asyncio.to_thread(
|
|
@@ -287,23 +306,26 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
287
306
|
|
|
288
307
|
@asynccontextmanager
|
|
289
308
|
async def _cursor(self, *, pipeline: bool = False) -> AsyncIterator[AsyncCursor]:
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
309
|
+
async with _get_connection(self.conn) as conn:
|
|
310
|
+
if self.pipe:
|
|
311
|
+
# a connection in pipeline mode can be used concurrently
|
|
312
|
+
# in multiple threads/coroutines, but only one cursor can be
|
|
313
|
+
# used at a time
|
|
314
|
+
try:
|
|
315
|
+
async with conn.cursor(binary=True, row_factory=dict_row) as cur:
|
|
316
|
+
yield cur
|
|
317
|
+
finally:
|
|
318
|
+
if pipeline:
|
|
319
|
+
await self.pipe.sync()
|
|
320
|
+
elif pipeline:
|
|
321
|
+
# a connection not in pipeline mode can only be used by one
|
|
322
|
+
# thread/coroutine at a time, so we acquire a lock
|
|
323
|
+
async with self.lock, conn.pipeline(), conn.cursor(
|
|
324
|
+
binary=True, row_factory=dict_row
|
|
325
|
+
) as cur:
|
|
326
|
+
yield cur
|
|
327
|
+
else:
|
|
328
|
+
async with self.lock, conn.cursor(
|
|
329
|
+
binary=True, row_factory=dict_row
|
|
330
|
+
) as cur:
|
|
296
331
|
yield cur
|
|
297
|
-
finally:
|
|
298
|
-
if pipeline:
|
|
299
|
-
await self.pipe.sync()
|
|
300
|
-
elif pipeline:
|
|
301
|
-
# a connection not in pipeline mode can only be used by one
|
|
302
|
-
# thread/coroutine at a time, so we acquire a lock
|
|
303
|
-
async with self.lock, self.conn.pipeline(), self.conn.cursor(
|
|
304
|
-
binary=True
|
|
305
|
-
) as cur:
|
|
306
|
-
yield cur
|
|
307
|
-
else:
|
|
308
|
-
async with self.lock, self.conn.cursor(binary=True) as cur:
|
|
309
|
-
yield cur
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
from base64 import b64decode, b64encode
|
|
2
1
|
from hashlib import md5
|
|
3
2
|
from typing import Any, List, Optional, Tuple
|
|
4
3
|
|
|
@@ -6,13 +5,14 @@ from langchain_core.runnables import RunnableConfig
|
|
|
6
5
|
from psycopg.types.json import Jsonb
|
|
7
6
|
|
|
8
7
|
from langgraph.checkpoint.base import (
|
|
8
|
+
WRITES_IDX_MAP,
|
|
9
9
|
BaseCheckpointSaver,
|
|
10
10
|
Checkpoint,
|
|
11
11
|
EmptyChannelError,
|
|
12
12
|
get_checkpoint_id,
|
|
13
13
|
)
|
|
14
14
|
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
|
15
|
-
from langgraph.checkpoint.serde.types import ChannelProtocol
|
|
15
|
+
from langgraph.checkpoint.serde.types import TASKS, ChannelProtocol
|
|
16
16
|
|
|
17
17
|
MetadataInput = Optional[dict[str, Any]]
|
|
18
18
|
|
|
@@ -57,7 +57,7 @@ MIGRATIONS = [
|
|
|
57
57
|
"ALTER TABLE checkpoint_blobs ALTER COLUMN blob DROP not null;",
|
|
58
58
|
]
|
|
59
59
|
|
|
60
|
-
SELECT_SQL = """
|
|
60
|
+
SELECT_SQL = f"""
|
|
61
61
|
select
|
|
62
62
|
thread_id,
|
|
63
63
|
checkpoint,
|
|
@@ -76,12 +76,20 @@ select
|
|
|
76
76
|
) as channel_values,
|
|
77
77
|
(
|
|
78
78
|
select
|
|
79
|
-
array_agg(array[cw.task_id::text::bytea, cw.channel::bytea, cw.type::bytea, cw.blob])
|
|
79
|
+
array_agg(array[cw.task_id::text::bytea, cw.channel::bytea, cw.type::bytea, cw.blob] order by cw.task_id, cw.idx)
|
|
80
80
|
from checkpoint_writes cw
|
|
81
81
|
where cw.thread_id = checkpoints.thread_id
|
|
82
82
|
and cw.checkpoint_ns = checkpoints.checkpoint_ns
|
|
83
83
|
and cw.checkpoint_id = checkpoints.checkpoint_id
|
|
84
|
-
) as pending_writes
|
|
84
|
+
) as pending_writes,
|
|
85
|
+
(
|
|
86
|
+
select array_agg(array[cw.type::bytea, cw.blob] order by cw.idx)
|
|
87
|
+
from checkpoint_writes cw
|
|
88
|
+
where cw.thread_id = checkpoints.thread_id
|
|
89
|
+
and cw.checkpoint_ns = checkpoints.checkpoint_ns
|
|
90
|
+
and cw.checkpoint_id = checkpoints.parent_checkpoint_id
|
|
91
|
+
and cw.channel = '{TASKS}'
|
|
92
|
+
) as pending_sends
|
|
85
93
|
from checkpoints """
|
|
86
94
|
|
|
87
95
|
UPSERT_CHECKPOINT_BLOBS_SQL = """
|
|
@@ -112,27 +120,26 @@ class BasePostgresSaver(BaseCheckpointSaver):
|
|
|
112
120
|
UPSERT_CHECKPOINT_BLOBS_SQL = UPSERT_CHECKPOINT_BLOBS_SQL
|
|
113
121
|
UPSERT_CHECKPOINTS_SQL = UPSERT_CHECKPOINTS_SQL
|
|
114
122
|
UPSERT_CHECKPOINT_WRITES_SQL = UPSERT_CHECKPOINT_WRITES_SQL
|
|
115
|
-
jsonplus_serde = JsonPlusSerializer()
|
|
116
|
-
|
|
117
|
-
def _load_checkpoint(self, checkpoint: dict[str, Any]) -> Checkpoint:
|
|
118
|
-
if len(checkpoint["pending_sends"]) == 2 and all(
|
|
119
|
-
isinstance(a, str) for a in checkpoint["pending_sends"]
|
|
120
|
-
):
|
|
121
|
-
type, bs = checkpoint["pending_sends"]
|
|
122
|
-
return {
|
|
123
|
-
**checkpoint,
|
|
124
|
-
"pending_sends": self.serde.loads_typed((type, b64decode(bs))),
|
|
125
|
-
}
|
|
126
123
|
|
|
127
|
-
|
|
124
|
+
jsonplus_serde = JsonPlusSerializer()
|
|
128
125
|
|
|
129
|
-
def
|
|
130
|
-
|
|
126
|
+
def _load_checkpoint(
|
|
127
|
+
self,
|
|
128
|
+
checkpoint: dict[str, Any],
|
|
129
|
+
channel_values: list[tuple[bytes, bytes, bytes]],
|
|
130
|
+
pending_sends: list[tuple[bytes, bytes]],
|
|
131
|
+
) -> Checkpoint:
|
|
131
132
|
return {
|
|
132
133
|
**checkpoint,
|
|
133
|
-
"pending_sends":
|
|
134
|
+
"pending_sends": [
|
|
135
|
+
self.serde.loads_typed((c.decode(), b)) for c, b in pending_sends or []
|
|
136
|
+
],
|
|
137
|
+
"channel_values": self._load_blobs(channel_values),
|
|
134
138
|
}
|
|
135
139
|
|
|
140
|
+
def _dump_checkpoint(self, checkpoint: Checkpoint) -> dict[str, Any]:
|
|
141
|
+
return {**checkpoint, "pending_sends": []}
|
|
142
|
+
|
|
136
143
|
def _load_blobs(
|
|
137
144
|
self, blob_values: list[tuple[bytes, bytes, bytes]]
|
|
138
145
|
) -> dict[str, Any]:
|
|
@@ -199,7 +206,7 @@ class BasePostgresSaver(BaseCheckpointSaver):
|
|
|
199
206
|
checkpoint_ns,
|
|
200
207
|
checkpoint_id,
|
|
201
208
|
task_id,
|
|
202
|
-
idx,
|
|
209
|
+
WRITES_IDX_MAP.get(channel, idx),
|
|
203
210
|
channel,
|
|
204
211
|
*self.serde.dumps_typed(value),
|
|
205
212
|
)
|
|
@@ -253,9 +260,14 @@ class BasePostgresSaver(BaseCheckpointSaver):
|
|
|
253
260
|
if config:
|
|
254
261
|
wheres.append("thread_id = %s ")
|
|
255
262
|
param_values.append(config["configurable"]["thread_id"])
|
|
256
|
-
checkpoint_ns = config["configurable"].get("checkpoint_ns"
|
|
257
|
-
|
|
258
|
-
|
|
263
|
+
checkpoint_ns = config["configurable"].get("checkpoint_ns")
|
|
264
|
+
if checkpoint_ns is not None:
|
|
265
|
+
wheres.append("checkpoint_ns = %s")
|
|
266
|
+
param_values.append(checkpoint_ns)
|
|
267
|
+
|
|
268
|
+
if checkpoint_id := get_checkpoint_id(config):
|
|
269
|
+
wheres.append("checkpoint_id = %s ")
|
|
270
|
+
param_values.append(checkpoint_id)
|
|
259
271
|
|
|
260
272
|
# construct predicate for metadata filter
|
|
261
273
|
if filter:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "langgraph-checkpoint-postgres"
|
|
3
|
-
version = "1.0.
|
|
3
|
+
version = "1.0.5"
|
|
4
4
|
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
|
|
5
5
|
authors = []
|
|
6
6
|
license = "MIT"
|
|
@@ -12,10 +12,11 @@ packages = [{ include = "langgraph" }]
|
|
|
12
12
|
python = "^3.9.0,<4.0"
|
|
13
13
|
langgraph-checkpoint = "^1.0.1"
|
|
14
14
|
orjson = ">=3.10.1"
|
|
15
|
-
psycopg =
|
|
15
|
+
psycopg = "^3.0.0"
|
|
16
|
+
psycopg-pool = "^3.0.0"
|
|
16
17
|
|
|
17
18
|
[tool.poetry.group.dev.dependencies]
|
|
18
|
-
ruff = "^0.
|
|
19
|
+
ruff = "^0.6.2"
|
|
19
20
|
codespell = "^2.2.0"
|
|
20
21
|
pytest = "^7.2.1"
|
|
21
22
|
anyio = "^4.4.0"
|
|
@@ -23,7 +24,7 @@ pytest-asyncio = "^0.21.1"
|
|
|
23
24
|
pytest-mock = "^3.11.1"
|
|
24
25
|
pytest-watch = "^4.2.0"
|
|
25
26
|
mypy = "^1.10.0"
|
|
26
|
-
psycopg
|
|
27
|
+
psycopg = {extras = ["binary"], version = ">=3.0.0"}
|
|
27
28
|
langgraph-checkpoint = {path = "../checkpoint", develop = true}
|
|
28
29
|
|
|
29
30
|
[tool.pytest.ini_options]
|
|
File without changes
|