langgraph-checkpoint-postgres 1.0.2__tar.gz → 1.0.4__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.2 → langgraph_checkpoint_postgres-1.0.4}/PKG-INFO +11 -4
- {langgraph_checkpoint_postgres-1.0.2 → langgraph_checkpoint_postgres-1.0.4}/README.md +8 -3
- {langgraph_checkpoint_postgres-1.0.2 → langgraph_checkpoint_postgres-1.0.4}/langgraph/checkpoint/postgres/__init__.py +82 -60
- {langgraph_checkpoint_postgres-1.0.2 → langgraph_checkpoint_postgres-1.0.4}/langgraph/checkpoint/postgres/aio.py +89 -68
- {langgraph_checkpoint_postgres-1.0.2 → langgraph_checkpoint_postgres-1.0.4}/langgraph/checkpoint/postgres/base.py +10 -4
- {langgraph_checkpoint_postgres-1.0.2 → langgraph_checkpoint_postgres-1.0.4}/pyproject.toml +4 -3
- {langgraph_checkpoint_postgres-1.0.2 → langgraph_checkpoint_postgres-1.0.4}/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.4
|
|
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,11 +22,18 @@ 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]
|
|
27
32
|
> When using Postgres checkpointers for the first time, make sure to call `.setup()` method on them to create required tables. See example below.
|
|
28
33
|
|
|
34
|
+
> [!IMPORTANT]
|
|
35
|
+
> When manually creating Postgres connections and passing them to `PostgresSaver` or `AsyncPostgresSaver`, make sure to include `autocommit=True` and `row_factory=dict_row` (`from psycopg.rows import dict_row`). See a full example in this [how-to guide](https://langchain-ai.github.io/langgraph/how-tos/persistence_postgres/).
|
|
36
|
+
|
|
29
37
|
```python
|
|
30
38
|
from langgraph.checkpoint.postgres import PostgresSaver
|
|
31
39
|
|
|
@@ -60,7 +68,6 @@ with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
|
|
60
68
|
}
|
|
61
69
|
},
|
|
62
70
|
"pending_sends": [],
|
|
63
|
-
"current_tasks": {}
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
# store checkpoint
|
|
@@ -103,7 +110,6 @@ async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
|
|
103
110
|
}
|
|
104
111
|
},
|
|
105
112
|
"pending_sends": [],
|
|
106
|
-
"current_tasks": {}
|
|
107
113
|
}
|
|
108
114
|
|
|
109
115
|
# store checkpoint
|
|
@@ -115,3 +121,4 @@ async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
|
|
115
121
|
# list checkpoints
|
|
116
122
|
[c async for c in checkpointer.alist(read_config)]
|
|
117
123
|
```
|
|
124
|
+
|
|
@@ -2,11 +2,18 @@
|
|
|
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]
|
|
8
12
|
> When using Postgres checkpointers for the first time, make sure to call `.setup()` method on them to create required tables. See example below.
|
|
9
13
|
|
|
14
|
+
> [!IMPORTANT]
|
|
15
|
+
> When manually creating Postgres connections and passing them to `PostgresSaver` or `AsyncPostgresSaver`, make sure to include `autocommit=True` and `row_factory=dict_row` (`from psycopg.rows import dict_row`). See a full example in this [how-to guide](https://langchain-ai.github.io/langgraph/how-tos/persistence_postgres/).
|
|
16
|
+
|
|
10
17
|
```python
|
|
11
18
|
from langgraph.checkpoint.postgres import PostgresSaver
|
|
12
19
|
|
|
@@ -41,7 +48,6 @@ with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
|
|
41
48
|
}
|
|
42
49
|
},
|
|
43
50
|
"pending_sends": [],
|
|
44
|
-
"current_tasks": {}
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
# store checkpoint
|
|
@@ -84,7 +90,6 @@ async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
|
|
84
90
|
}
|
|
85
91
|
},
|
|
86
92
|
"pending_sends": [],
|
|
87
|
-
"current_tasks": {}
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
# store checkpoint
|
|
@@ -95,4 +100,4 @@ async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
|
|
95
100
|
|
|
96
101
|
# list checkpoints
|
|
97
102
|
[c async for c in checkpointer.alist(read_config)]
|
|
98
|
-
```
|
|
103
|
+
```
|
|
@@ -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,33 @@ 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
|
+
{
|
|
158
|
+
**self._load_checkpoint(value["checkpoint"]),
|
|
159
|
+
"channel_values": self._load_blobs(value["channel_values"]),
|
|
160
|
+
},
|
|
161
|
+
self._load_metadata(value["metadata"]),
|
|
162
|
+
{
|
|
163
|
+
"configurable": {
|
|
164
|
+
"thread_id": value["thread_id"],
|
|
165
|
+
"checkpoint_ns": value["checkpoint_ns"],
|
|
166
|
+
"checkpoint_id": value["parent_checkpoint_id"],
|
|
167
|
+
}
|
|
149
168
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
169
|
+
if value["parent_checkpoint_id"]
|
|
170
|
+
else None,
|
|
171
|
+
self._load_writes(value["pending_writes"]),
|
|
172
|
+
)
|
|
154
173
|
|
|
155
174
|
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
|
156
175
|
"""Get a checkpoint tuple from the database.
|
|
@@ -198,7 +217,7 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
198
217
|
where = "WHERE thread_id = %s AND checkpoint_ns = %s ORDER BY checkpoint_id DESC LIMIT 1"
|
|
199
218
|
|
|
200
219
|
with self._cursor() as cur:
|
|
201
|
-
cur
|
|
220
|
+
cur.execute(
|
|
202
221
|
self.SELECT_SQL + where,
|
|
203
222
|
args,
|
|
204
223
|
binary=True,
|
|
@@ -316,7 +335,7 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
316
335
|
writes (List[Tuple[str, Any]]): List of writes to store.
|
|
317
336
|
task_id (str): Identifier for the task creating the writes.
|
|
318
337
|
"""
|
|
319
|
-
with self._cursor() as cur:
|
|
338
|
+
with self._cursor(pipeline=True) as cur:
|
|
320
339
|
cur.executemany(
|
|
321
340
|
self.UPSERT_CHECKPOINT_WRITES_SQL,
|
|
322
341
|
self._dump_writes(
|
|
@@ -330,21 +349,24 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
330
349
|
|
|
331
350
|
@contextmanager
|
|
332
351
|
def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor]:
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
352
|
+
with _get_connection(self.conn) as conn:
|
|
353
|
+
if self.pipe:
|
|
354
|
+
# a connection in pipeline mode can be used concurrently
|
|
355
|
+
# in multiple threads/coroutines, but only one cursor can be
|
|
356
|
+
# used at a time
|
|
357
|
+
try:
|
|
358
|
+
with conn.cursor(binary=True, row_factory=dict_row) as cur:
|
|
359
|
+
yield cur
|
|
360
|
+
finally:
|
|
361
|
+
if pipeline:
|
|
362
|
+
self.pipe.sync()
|
|
363
|
+
elif pipeline:
|
|
364
|
+
# a connection not in pipeline mode can only be used by one
|
|
365
|
+
# thread/coroutine at a time, so we acquire a lock
|
|
366
|
+
with self.lock, conn.pipeline(), conn.cursor(
|
|
367
|
+
binary=True, row_factory=dict_row
|
|
368
|
+
) as cur:
|
|
369
|
+
yield cur
|
|
370
|
+
else:
|
|
371
|
+
with self.lock, conn.cursor(binary=True, row_factory=dict_row) as cur:
|
|
339
372
|
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()
|
|
@@ -63,26 +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
|
-
f"INSERT INTO checkpoint_migrations (v) VALUES ({v})"
|
|
83
|
-
)
|
|
84
|
-
if self.pipe:
|
|
85
|
-
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()
|
|
86
101
|
|
|
87
102
|
async def alist(
|
|
88
103
|
self,
|
|
@@ -111,32 +126,35 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
111
126
|
if limit:
|
|
112
127
|
query += f" LIMIT {limit}"
|
|
113
128
|
# if we change this to use .stream() we need to make sure to close the cursor
|
|
114
|
-
async
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
"
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
self.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
"
|
|
132
|
-
|
|
133
|
-
"
|
|
134
|
-
|
|
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
|
+
{
|
|
141
|
+
**self._load_checkpoint(value["checkpoint"]),
|
|
142
|
+
"channel_values": await asyncio.to_thread(
|
|
143
|
+
self._load_blobs, value["channel_values"]
|
|
144
|
+
),
|
|
145
|
+
},
|
|
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
|
+
}
|
|
135
153
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
154
|
+
if value["parent_checkpoint_id"]
|
|
155
|
+
else None,
|
|
156
|
+
await asyncio.to_thread(self._load_writes, value["pending_writes"]),
|
|
157
|
+
)
|
|
140
158
|
|
|
141
159
|
async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
|
142
160
|
"""Get a checkpoint tuple from the database asynchronously.
|
|
@@ -163,7 +181,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
163
181
|
where = "WHERE thread_id = %s AND checkpoint_ns = %s ORDER BY checkpoint_id DESC LIMIT 1"
|
|
164
182
|
|
|
165
183
|
async with self._cursor() as cur:
|
|
166
|
-
|
|
184
|
+
await cur.execute(
|
|
167
185
|
self.SELECT_SQL + where,
|
|
168
186
|
args,
|
|
169
187
|
binary=True,
|
|
@@ -273,7 +291,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
273
291
|
writes (Sequence[Tuple[str, Any]]): List of writes to store, each as (channel, value) pair.
|
|
274
292
|
task_id (str): Identifier for the task creating the writes.
|
|
275
293
|
"""
|
|
276
|
-
async with self._cursor() as cur:
|
|
294
|
+
async with self._cursor(pipeline=True) as cur:
|
|
277
295
|
await cur.executemany(
|
|
278
296
|
self.UPSERT_CHECKPOINT_WRITES_SQL,
|
|
279
297
|
await asyncio.to_thread(
|
|
@@ -288,23 +306,26 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
288
306
|
|
|
289
307
|
@asynccontextmanager
|
|
290
308
|
async def _cursor(self, *, pipeline: bool = False) -> AsyncIterator[AsyncCursor]:
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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:
|
|
297
331
|
yield cur
|
|
298
|
-
finally:
|
|
299
|
-
if pipeline:
|
|
300
|
-
await self.pipe.sync()
|
|
301
|
-
elif pipeline:
|
|
302
|
-
# a connection not in pipeline mode can only be used by one
|
|
303
|
-
# thread/coroutine at a time, so we acquire a lock
|
|
304
|
-
async with self.lock, self.conn.pipeline(), self.conn.cursor(
|
|
305
|
-
binary=True
|
|
306
|
-
) as cur:
|
|
307
|
-
yield cur
|
|
308
|
-
else:
|
|
309
|
-
async with self.lock, self.conn.cursor(binary=True) as cur:
|
|
310
|
-
yield cur
|
|
@@ -6,6 +6,7 @@ from langchain_core.runnables import RunnableConfig
|
|
|
6
6
|
from psycopg.types.json import Jsonb
|
|
7
7
|
|
|
8
8
|
from langgraph.checkpoint.base import (
|
|
9
|
+
WRITES_IDX_MAP,
|
|
9
10
|
BaseCheckpointSaver,
|
|
10
11
|
Checkpoint,
|
|
11
12
|
EmptyChannelError,
|
|
@@ -112,6 +113,7 @@ class BasePostgresSaver(BaseCheckpointSaver):
|
|
|
112
113
|
UPSERT_CHECKPOINT_BLOBS_SQL = UPSERT_CHECKPOINT_BLOBS_SQL
|
|
113
114
|
UPSERT_CHECKPOINTS_SQL = UPSERT_CHECKPOINTS_SQL
|
|
114
115
|
UPSERT_CHECKPOINT_WRITES_SQL = UPSERT_CHECKPOINT_WRITES_SQL
|
|
116
|
+
|
|
115
117
|
jsonplus_serde = JsonPlusSerializer()
|
|
116
118
|
|
|
117
119
|
def _load_checkpoint(self, checkpoint: dict[str, Any]) -> Checkpoint:
|
|
@@ -199,7 +201,7 @@ class BasePostgresSaver(BaseCheckpointSaver):
|
|
|
199
201
|
checkpoint_ns,
|
|
200
202
|
checkpoint_id,
|
|
201
203
|
task_id,
|
|
202
|
-
idx,
|
|
204
|
+
WRITES_IDX_MAP.get(channel, idx),
|
|
203
205
|
channel,
|
|
204
206
|
*self.serde.dumps_typed(value),
|
|
205
207
|
)
|
|
@@ -253,9 +255,13 @@ class BasePostgresSaver(BaseCheckpointSaver):
|
|
|
253
255
|
if config:
|
|
254
256
|
wheres.append("thread_id = %s ")
|
|
255
257
|
param_values.append(config["configurable"]["thread_id"])
|
|
256
|
-
checkpoint_ns
|
|
257
|
-
|
|
258
|
-
|
|
258
|
+
if checkpoint_ns := config["configurable"].get("checkpoint_ns"):
|
|
259
|
+
wheres.append("checkpoint_ns = %s")
|
|
260
|
+
param_values.append(checkpoint_ns)
|
|
261
|
+
|
|
262
|
+
if checkpoint_id := get_checkpoint_id(config):
|
|
263
|
+
wheres.append("checkpoint_id = %s ")
|
|
264
|
+
param_values.append(checkpoint_id)
|
|
259
265
|
|
|
260
266
|
# construct predicate for metadata filter
|
|
261
267
|
if filter:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "langgraph-checkpoint-postgres"
|
|
3
|
-
version = "1.0.
|
|
3
|
+
version = "1.0.4"
|
|
4
4
|
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
|
|
5
5
|
authors = []
|
|
6
6
|
license = "MIT"
|
|
@@ -12,7 +12,8 @@ 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
19
|
ruff = "^0.1.4"
|
|
@@ -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
|