langgraph-checkpoint-postgres 1.0.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: langgraph-checkpoint-postgres
3
- Version: 1.0.3
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[binary] (>=3.1.19)
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.lock:
69
- with self.conn.cursor(binary=True) as cur:
70
- try:
71
- version = cur.execute(
72
- "SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1"
73
- ).fetchone()["v"]
74
- except UndefinedTable:
75
- version = -1
76
- for v, migration in zip(
77
- range(version + 1, len(self.MIGRATIONS)),
78
- self.MIGRATIONS[version + 1 :],
79
- ):
80
- cur.execute(migration)
81
- cur.execute(f"INSERT INTO checkpoint_migrations (v) VALUES ({v})")
82
- if self.pipe:
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
- for value in self.conn.execute(query, args, binary=True):
131
- yield CheckpointTuple(
132
- {
133
- "configurable": {
134
- "thread_id": value["thread_id"],
135
- "checkpoint_ns": value["checkpoint_ns"],
136
- "checkpoint_id": value["checkpoint_id"],
137
- }
138
- },
139
- {
140
- **self._load_checkpoint(value["checkpoint"]),
141
- "channel_values": self._load_blobs(value["channel_values"]),
142
- },
143
- self._load_metadata(value["metadata"]),
144
- {
145
- "configurable": {
146
- "thread_id": value["thread_id"],
147
- "checkpoint_ns": value["checkpoint_ns"],
148
- "checkpoint_id": value["parent_checkpoint_id"],
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
- if value["parent_checkpoint_id"]
152
- else None,
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 = self.conn.execute(
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
- if self.pipe:
334
- # a connection in pipeline mode can be used concurrently
335
- # in multiple threads/coroutines, but only one cursor can be
336
- # used at a time
337
- try:
338
- with self.conn.cursor(binary=True) as cur:
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,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.lock:
67
- async with self.conn.cursor(binary=True) as cur:
68
- try:
69
- results = await cur.execute(
70
- "SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1"
71
- )
72
- version = (await results.fetchone())["v"]
73
- except UndefinedTable:
74
- version = -1
75
- for v, migration in zip(
76
- range(version + 1, len(self.MIGRATIONS)),
77
- self.MIGRATIONS[version + 1 :],
78
- ):
79
- await cur.execute(migration)
80
- await cur.execute(
81
- f"INSERT INTO checkpoint_migrations (v) VALUES ({v})"
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 for value in await self.conn.execute(query, args, binary=True):
114
- yield CheckpointTuple(
115
- {
116
- "configurable": {
117
- "thread_id": value["thread_id"],
118
- "checkpoint_ns": value["checkpoint_ns"],
119
- "checkpoint_id": value["checkpoint_id"],
120
- }
121
- },
122
- {
123
- **self._load_checkpoint(value["checkpoint"]),
124
- "channel_values": await asyncio.to_thread(
125
- self._load_blobs, value["channel_values"]
126
- ),
127
- },
128
- self._load_metadata(value["metadata"]),
129
- {
130
- "configurable": {
131
- "thread_id": value["thread_id"],
132
- "checkpoint_ns": value["checkpoint_ns"],
133
- "checkpoint_id": value["parent_checkpoint_id"],
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
+ }
134
153
  }
135
- }
136
- if value["parent_checkpoint_id"]
137
- else None,
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
- cur = await self.conn.execute(
184
+ await cur.execute(
166
185
  self.SELECT_SQL + where,
167
186
  args,
168
187
  binary=True,
@@ -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
- if self.pipe:
291
- # a connection in pipeline mode can be used concurrently
292
- # in multiple threads/coroutines, but only one cursor can be
293
- # used at a time
294
- try:
295
- async with self.conn.cursor(binary=True) as cur:
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
@@ -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 = config["configurable"].get("checkpoint_ns", "")
257
- wheres.append("checkpoint_ns = %s")
258
- param_values.append(checkpoint_ns)
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"
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 = {extras = ["binary"], version = ">=3.1.19"}
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-pool = "^3.2.2"
27
+ psycopg = {extras = ["binary"], version = ">=3.0.0"}
27
28
  langgraph-checkpoint = {path = "../checkpoint", develop = true}
28
29
 
29
30
  [tool.pytest.ini_options]