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.
@@ -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.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[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,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
- 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
+ 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
- if value["parent_checkpoint_id"]
152
- else None,
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 = self.conn.execute(
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
- **self._load_checkpoint(value["checkpoint"]),
218
- "channel_values": self._load_blobs(value["channel_values"]),
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
- 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:
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
- PostgresSaver: A new PostgresSaver instance.
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.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"]
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
- 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"],
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,
@@ -177,12 +196,12 @@ class AsyncPostgresSaver(BasePostgresSaver):
177
196
  "checkpoint_id": value["checkpoint_id"],
178
197
  }
179
198
  },
180
- {
181
- **self._load_checkpoint(value["checkpoint"]),
182
- "channel_values": await asyncio.to_thread(
183
- self._load_blobs, value["channel_values"]
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
- 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
@@ -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
- return checkpoint
124
+ jsonplus_serde = JsonPlusSerializer()
128
125
 
129
- def _dump_checkpoint(self, checkpoint: Checkpoint) -> dict[str, Any]:
130
- type, bs = self.serde.dumps_typed(checkpoint["pending_sends"])
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": (type, b64encode(bs).decode()),
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
- wheres.append("checkpoint_ns = %s")
258
- param_values.append(checkpoint_ns)
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"
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 = {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
- ruff = "^0.1.4"
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-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]