langgraph-checkpoint-postgres 2.0.2__tar.gz → 2.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: 2.0.2
3
+ Version: 2.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
@@ -1,9 +1,9 @@
1
1
  import threading
2
2
  from contextlib import contextmanager
3
- from typing import Any, Iterator, Optional, Sequence, Union
3
+ from typing import Any, Iterator, Optional, Sequence
4
4
 
5
5
  from langchain_core.runnables import RunnableConfig
6
- from psycopg import Connection, Cursor, Pipeline
6
+ from psycopg import Capabilities, Connection, Cursor, Pipeline
7
7
  from psycopg.errors import UndefinedTable
8
8
  from psycopg.rows import DictRow, dict_row
9
9
  from psycopg.types.json import Jsonb
@@ -17,21 +17,11 @@ from langgraph.checkpoint.base import (
17
17
  CheckpointTuple,
18
18
  get_checkpoint_id,
19
19
  )
20
+ from langgraph.checkpoint.postgres import _internal
20
21
  from langgraph.checkpoint.postgres.base import BasePostgresSaver
21
22
  from langgraph.checkpoint.serde.base import SerializerProtocol
22
23
 
23
- Conn = Union[Connection[DictRow], ConnectionPool[Connection[DictRow]]]
24
-
25
-
26
- @contextmanager
27
- def _get_connection(conn: Conn) -> Iterator[Connection[DictRow]]:
28
- if isinstance(conn, Connection):
29
- yield conn
30
- elif isinstance(conn, ConnectionPool):
31
- with conn.connection() as conn:
32
- yield conn
33
- else:
34
- raise TypeError(f"Invalid connection type: {type(conn)}")
24
+ Conn = _internal.Conn # For backward compatibility
35
25
 
36
26
 
37
27
  class PostgresSaver(BasePostgresSaver):
@@ -39,7 +29,7 @@ class PostgresSaver(BasePostgresSaver):
39
29
 
40
30
  def __init__(
41
31
  self,
42
- conn: Conn,
32
+ conn: _internal.Conn,
43
33
  pipe: Optional[Pipeline] = None,
44
34
  serde: Optional[SerializerProtocol] = None,
45
35
  ) -> None:
@@ -52,6 +42,7 @@ class PostgresSaver(BasePostgresSaver):
52
42
  self.conn = conn
53
43
  self.pipe = pipe
54
44
  self.lock = threading.Lock()
45
+ self.supports_pipeline = Capabilities().has_pipeline()
55
46
 
56
47
  @classmethod
57
48
  @contextmanager
@@ -72,9 +63,9 @@ class PostgresSaver(BasePostgresSaver):
72
63
  ) as conn:
73
64
  if pipeline:
74
65
  with conn.pipeline() as pipe:
75
- yield PostgresSaver(conn, pipe)
66
+ yield cls(conn, pipe)
76
67
  else:
77
- yield PostgresSaver(conn)
68
+ yield cls(conn)
78
69
 
79
70
  def setup(self) -> None:
80
71
  """Set up the checkpoint database asynchronously.
@@ -365,7 +356,14 @@ class PostgresSaver(BasePostgresSaver):
365
356
 
366
357
  @contextmanager
367
358
  def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor[DictRow]]:
368
- with _get_connection(self.conn) as conn:
359
+ """Create a database cursor as a context manager.
360
+
361
+ Args:
362
+ pipeline (bool): whether to use pipeline for the DB operations inside the context manager.
363
+ Will be applied regardless of whether the PostgresSaver instance was initialized with a pipeline.
364
+ If pipeline mode is not supported, will fall back to using transaction context manager.
365
+ """
366
+ with _internal.get_connection(self.conn) as conn:
369
367
  if self.pipe:
370
368
  # a connection in pipeline mode can be used concurrently
371
369
  # in multiple threads/coroutines, but only one cursor can be
@@ -379,13 +377,20 @@ class PostgresSaver(BasePostgresSaver):
379
377
  elif pipeline:
380
378
  # a connection not in pipeline mode can only be used by one
381
379
  # thread/coroutine at a time, so we acquire a lock
382
- with self.lock, conn.pipeline(), conn.cursor(
383
- binary=True, row_factory=dict_row
384
- ) as cur:
385
- yield cur
380
+ if self.supports_pipeline:
381
+ with self.lock, conn.pipeline(), conn.cursor(
382
+ binary=True, row_factory=dict_row
383
+ ) as cur:
384
+ yield cur
385
+ else:
386
+ # Use connection's transaction context manager when pipeline mode not supported
387
+ with self.lock, conn.transaction(), conn.cursor(
388
+ binary=True, row_factory=dict_row
389
+ ) as cur:
390
+ yield cur
386
391
  else:
387
392
  with self.lock, conn.cursor(binary=True, row_factory=dict_row) as cur:
388
393
  yield cur
389
394
 
390
395
 
391
- __all__ = ["PostgresSaver", "Conn"]
396
+ __all__ = ["PostgresSaver", "BasePostgresSaver", "Conn"]
@@ -0,0 +1,23 @@
1
+ """Shared async utility functions for the Postgres checkpoint & storage classes."""
2
+
3
+ from contextlib import asynccontextmanager
4
+ from typing import AsyncIterator, Union
5
+
6
+ from psycopg import AsyncConnection
7
+ from psycopg.rows import DictRow
8
+ from psycopg_pool import AsyncConnectionPool
9
+
10
+ Conn = Union[AsyncConnection[DictRow], AsyncConnectionPool[AsyncConnection[DictRow]]]
11
+
12
+
13
+ @asynccontextmanager
14
+ async def get_connection(
15
+ conn: Conn,
16
+ ) -> AsyncIterator[AsyncConnection[DictRow]]:
17
+ if isinstance(conn, AsyncConnection):
18
+ yield conn
19
+ elif isinstance(conn, AsyncConnectionPool):
20
+ async with conn.connection() as conn:
21
+ yield conn
22
+ else:
23
+ raise TypeError(f"Invalid connection type: {type(conn)}")
@@ -0,0 +1,21 @@
1
+ """Shared utility functions for the Postgres checkpoint & storage classes."""
2
+
3
+ from contextlib import contextmanager
4
+ from typing import Iterator, Union
5
+
6
+ from psycopg import Connection
7
+ from psycopg.rows import DictRow
8
+ from psycopg_pool import ConnectionPool
9
+
10
+ Conn = Union[Connection[DictRow], ConnectionPool[Connection[DictRow]]]
11
+
12
+
13
+ @contextmanager
14
+ def get_connection(conn: Conn) -> Iterator[Connection[DictRow]]:
15
+ if isinstance(conn, Connection):
16
+ yield conn
17
+ elif isinstance(conn, ConnectionPool):
18
+ with conn.connection() as conn:
19
+ yield conn
20
+ else:
21
+ raise TypeError(f"Invalid connection type: {type(conn)}")
@@ -1,9 +1,9 @@
1
1
  import asyncio
2
2
  from contextlib import asynccontextmanager
3
- from typing import Any, AsyncIterator, Iterator, Optional, Sequence, Union
3
+ from typing import Any, AsyncIterator, Iterator, Optional, Sequence
4
4
 
5
5
  from langchain_core.runnables import RunnableConfig
6
- from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline
6
+ from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities
7
7
  from psycopg.errors import UndefinedTable
8
8
  from psycopg.rows import DictRow, dict_row
9
9
  from psycopg.types.json import Jsonb
@@ -17,23 +17,11 @@ from langgraph.checkpoint.base import (
17
17
  CheckpointTuple,
18
18
  get_checkpoint_id,
19
19
  )
20
+ from langgraph.checkpoint.postgres import _ainternal
20
21
  from langgraph.checkpoint.postgres.base import BasePostgresSaver
21
22
  from langgraph.checkpoint.serde.base import SerializerProtocol
22
23
 
23
- Conn = Union[AsyncConnection[DictRow], AsyncConnectionPool[AsyncConnection[DictRow]]]
24
-
25
-
26
- @asynccontextmanager
27
- async def _get_connection(
28
- conn: Conn,
29
- ) -> AsyncIterator[AsyncConnection[DictRow]]:
30
- if isinstance(conn, AsyncConnection):
31
- yield conn
32
- elif isinstance(conn, AsyncConnectionPool):
33
- async with conn.connection() as conn:
34
- yield conn
35
- else:
36
- raise TypeError(f"Invalid connection type: {type(conn)}")
24
+ Conn = _ainternal.Conn # For backward compatibility
37
25
 
38
26
 
39
27
  class AsyncPostgresSaver(BasePostgresSaver):
@@ -41,7 +29,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
41
29
 
42
30
  def __init__(
43
31
  self,
44
- conn: Conn,
32
+ conn: _ainternal.Conn,
45
33
  pipe: Optional[AsyncPipeline] = None,
46
34
  serde: Optional[SerializerProtocol] = None,
47
35
  ) -> None:
@@ -55,6 +43,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
55
43
  self.pipe = pipe
56
44
  self.lock = asyncio.Lock()
57
45
  self.loop = asyncio.get_running_loop()
46
+ self.supports_pipeline = Capabilities().has_pipeline()
58
47
 
59
48
  @classmethod
60
49
  @asynccontextmanager
@@ -79,9 +68,9 @@ class AsyncPostgresSaver(BasePostgresSaver):
79
68
  ) as conn:
80
69
  if pipeline:
81
70
  async with conn.pipeline() as pipe:
82
- yield AsyncPostgresSaver(conn=conn, pipe=pipe, serde=serde)
71
+ yield cls(conn=conn, pipe=pipe, serde=serde)
83
72
  else:
84
- yield AsyncPostgresSaver(conn=conn, serde=serde)
73
+ yield cls(conn=conn, serde=serde)
85
74
 
86
75
  async def setup(self) -> None:
87
76
  """Set up the checkpoint database asynchronously.
@@ -156,15 +145,17 @@ class AsyncPostgresSaver(BasePostgresSaver):
156
145
  value["pending_sends"],
157
146
  ),
158
147
  self._load_metadata(value["metadata"]),
159
- {
160
- "configurable": {
161
- "thread_id": value["thread_id"],
162
- "checkpoint_ns": value["checkpoint_ns"],
163
- "checkpoint_id": value["parent_checkpoint_id"],
148
+ (
149
+ {
150
+ "configurable": {
151
+ "thread_id": value["thread_id"],
152
+ "checkpoint_ns": value["checkpoint_ns"],
153
+ "checkpoint_id": value["parent_checkpoint_id"],
154
+ }
164
155
  }
165
- }
166
- if value["parent_checkpoint_id"]
167
- else None,
156
+ if value["parent_checkpoint_id"]
157
+ else None
158
+ ),
168
159
  await asyncio.to_thread(self._load_writes, value["pending_writes"]),
169
160
  )
170
161
 
@@ -215,15 +206,17 @@ class AsyncPostgresSaver(BasePostgresSaver):
215
206
  value["pending_sends"],
216
207
  ),
217
208
  self._load_metadata(value["metadata"]),
218
- {
219
- "configurable": {
220
- "thread_id": thread_id,
221
- "checkpoint_ns": checkpoint_ns,
222
- "checkpoint_id": value["parent_checkpoint_id"],
209
+ (
210
+ {
211
+ "configurable": {
212
+ "thread_id": thread_id,
213
+ "checkpoint_ns": checkpoint_ns,
214
+ "checkpoint_id": value["parent_checkpoint_id"],
215
+ }
223
216
  }
224
- }
225
- if value["parent_checkpoint_id"]
226
- else None,
217
+ if value["parent_checkpoint_id"]
218
+ else None
219
+ ),
227
220
  await asyncio.to_thread(self._load_writes, value["pending_writes"]),
228
221
  )
229
222
 
@@ -323,7 +316,14 @@ class AsyncPostgresSaver(BasePostgresSaver):
323
316
  async def _cursor(
324
317
  self, *, pipeline: bool = False
325
318
  ) -> AsyncIterator[AsyncCursor[DictRow]]:
326
- async with _get_connection(self.conn) as conn:
319
+ """Create a database cursor as a context manager.
320
+
321
+ Args:
322
+ pipeline (bool): whether to use pipeline for the DB operations inside the context manager.
323
+ Will be applied regardless of whether the AsyncPostgresSaver instance was initialized with a pipeline.
324
+ If pipeline mode is not supported, will fall back to using transaction context manager.
325
+ """
326
+ async with _ainternal.get_connection(self.conn) as conn:
327
327
  if self.pipe:
328
328
  # a connection in pipeline mode can be used concurrently
329
329
  # in multiple threads/coroutines, but only one cursor can be
@@ -337,10 +337,17 @@ class AsyncPostgresSaver(BasePostgresSaver):
337
337
  elif pipeline:
338
338
  # a connection not in pipeline mode can only be used by one
339
339
  # thread/coroutine at a time, so we acquire a lock
340
- async with self.lock, conn.pipeline(), conn.cursor(
341
- binary=True, row_factory=dict_row
342
- ) as cur:
343
- yield cur
340
+ if self.supports_pipeline:
341
+ async with self.lock, conn.pipeline(), conn.cursor(
342
+ binary=True, row_factory=dict_row
343
+ ) as cur:
344
+ yield cur
345
+ else:
346
+ # Use connection's transaction context manager when pipeline mode not supported
347
+ async with self.lock, conn.transaction(), conn.cursor(
348
+ binary=True, row_factory=dict_row
349
+ ) as cur:
350
+ yield cur
344
351
  else:
345
352
  async with self.lock, conn.cursor(
346
353
  binary=True, row_factory=dict_row
@@ -452,3 +459,6 @@ class AsyncPostgresSaver(BasePostgresSaver):
452
459
  return asyncio.run_coroutine_threadsafe(
453
460
  self.aput_writes(config, writes, task_id), self.loop
454
461
  ).result()
462
+
463
+
464
+ __all__ = ["AsyncPostgresSaver", "Conn"]
@@ -84,7 +84,7 @@ select
84
84
  and cw.checkpoint_id = checkpoints.checkpoint_id
85
85
  ) as pending_writes,
86
86
  (
87
- select array_agg(array[cw.type::bytea, cw.blob] order by cw.idx)
87
+ select array_agg(array[cw.type::bytea, cw.blob] order by cw.task_id, cw.idx)
88
88
  from checkpoint_writes cw
89
89
  where cw.thread_id = checkpoints.thread_id
90
90
  and cw.checkpoint_ns = checkpoints.checkpoint_ns
@@ -133,6 +133,7 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
133
133
  INSERT_CHECKPOINT_WRITES_SQL = INSERT_CHECKPOINT_WRITES_SQL
134
134
 
135
135
  jsonplus_serde = JsonPlusSerializer()
136
+ supports_pipeline: bool
136
137
 
137
138
  def _load_checkpoint(
138
139
  self,
@@ -0,0 +1,294 @@
1
+ import asyncio
2
+ import logging
3
+ from contextlib import asynccontextmanager
4
+ from typing import (
5
+ Any,
6
+ AsyncIterator,
7
+ Callable,
8
+ Iterable,
9
+ Optional,
10
+ Sequence,
11
+ Union,
12
+ cast,
13
+ )
14
+
15
+ import orjson
16
+ from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities
17
+ from psycopg.errors import UndefinedTable
18
+ from psycopg.rows import DictRow, dict_row
19
+ from psycopg_pool import AsyncConnectionPool
20
+
21
+ from langgraph.checkpoint.postgres import _ainternal
22
+ from langgraph.store.base import GetOp, ListNamespacesOp, Op, PutOp, Result, SearchOp
23
+ from langgraph.store.base.batch import AsyncBatchedBaseStore
24
+ from langgraph.store.postgres.base import (
25
+ BasePostgresStore,
26
+ PoolConfig,
27
+ Row,
28
+ _decode_ns_bytes,
29
+ _group_ops,
30
+ _row_to_item,
31
+ )
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Conn]):
37
+ __slots__ = ("_deserializer", "pipe", "lock", "supports_pipeline")
38
+
39
+ def __init__(
40
+ self,
41
+ conn: _ainternal.Conn,
42
+ *,
43
+ pipe: Optional[AsyncPipeline] = None,
44
+ deserializer: Optional[
45
+ Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
46
+ ] = None,
47
+ ) -> None:
48
+ if isinstance(conn, AsyncConnectionPool) and pipe is not None:
49
+ raise ValueError(
50
+ "Pipeline should be used only with a single AsyncConnection, not AsyncConnectionPool."
51
+ )
52
+ super().__init__()
53
+ self._deserializer = deserializer
54
+ self.conn = conn
55
+ self.pipe = pipe
56
+ self.lock = asyncio.Lock()
57
+ self.loop = asyncio.get_running_loop()
58
+ self.supports_pipeline = Capabilities().has_pipeline()
59
+
60
+ async def abatch(self, ops: Iterable[Op]) -> list[Result]:
61
+ grouped_ops, num_ops = _group_ops(ops)
62
+ results: list[Result] = [None] * num_ops
63
+
64
+ async with _ainternal.get_connection(self.conn) as conn:
65
+ if self.pipe:
66
+ async with self.pipe:
67
+ await self._execute_batch(grouped_ops, results, conn)
68
+ else:
69
+ await self._execute_batch(grouped_ops, results, conn)
70
+
71
+ return results
72
+
73
+ async def _execute_batch(
74
+ self,
75
+ grouped_ops: dict,
76
+ results: list[Result],
77
+ conn: AsyncConnection[DictRow],
78
+ ) -> None:
79
+ async with self._cursor(conn, pipeline=True) as cur:
80
+ if GetOp in grouped_ops:
81
+ await self._batch_get_ops(
82
+ cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]),
83
+ results,
84
+ cur,
85
+ )
86
+
87
+ if SearchOp in grouped_ops:
88
+ await self._batch_search_ops(
89
+ cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]),
90
+ results,
91
+ cur,
92
+ )
93
+
94
+ if ListNamespacesOp in grouped_ops:
95
+ await self._batch_list_namespaces_ops(
96
+ cast(
97
+ Sequence[tuple[int, ListNamespacesOp]],
98
+ grouped_ops[ListNamespacesOp],
99
+ ),
100
+ results,
101
+ cur,
102
+ )
103
+
104
+ if PutOp in grouped_ops:
105
+ await self._batch_put_ops(
106
+ cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp]),
107
+ cur,
108
+ )
109
+
110
+ async def _batch_get_ops(
111
+ self,
112
+ get_ops: Sequence[tuple[int, GetOp]],
113
+ results: list[Result],
114
+ cur: AsyncCursor[DictRow],
115
+ ) -> None:
116
+ for query, params, namespace, items in self._get_batch_GET_ops_queries(get_ops):
117
+ await cur.execute(query, params)
118
+ rows = cast(list[Row], await cur.fetchall())
119
+ key_to_row = {row["key"]: row for row in rows}
120
+ for idx, key in items:
121
+ row = key_to_row.get(key)
122
+ if row:
123
+ results[idx] = _row_to_item(
124
+ namespace, row, loader=self._deserializer
125
+ )
126
+ else:
127
+ results[idx] = None
128
+
129
+ async def _batch_put_ops(
130
+ self,
131
+ put_ops: Sequence[tuple[int, PutOp]],
132
+ cur: AsyncCursor[DictRow],
133
+ ) -> None:
134
+ queries = self._get_batch_PUT_queries(put_ops)
135
+ for query, params in queries:
136
+ await cur.execute(query, params)
137
+
138
+ async def _batch_search_ops(
139
+ self,
140
+ search_ops: Sequence[tuple[int, SearchOp]],
141
+ results: list[Result],
142
+ cur: AsyncCursor[DictRow],
143
+ ) -> None:
144
+ queries = self._get_batch_search_queries(search_ops)
145
+ for (query, params), (idx, _) in zip(queries, search_ops):
146
+ await cur.execute(query, params)
147
+ rows = cast(list[Row], await cur.fetchall())
148
+ items = [
149
+ _row_to_item(
150
+ _decode_ns_bytes(row["prefix"]), row, loader=self._deserializer
151
+ )
152
+ for row in rows
153
+ ]
154
+ results[idx] = items
155
+
156
+ async def _batch_list_namespaces_ops(
157
+ self,
158
+ list_ops: Sequence[tuple[int, ListNamespacesOp]],
159
+ results: list[Result],
160
+ cur: AsyncCursor[DictRow],
161
+ ) -> None:
162
+ queries = self._get_batch_list_namespaces_queries(list_ops)
163
+ for (query, params), (idx, _) in zip(queries, list_ops):
164
+ await cur.execute(query, params)
165
+ rows = cast(list[dict], await cur.fetchall())
166
+ namespaces = [_decode_ns_bytes(row["truncated_prefix"]) for row in rows]
167
+ results[idx] = namespaces
168
+
169
+ @asynccontextmanager
170
+ async def _cursor(
171
+ self, conn: AsyncConnection[DictRow], *, pipeline: bool = False
172
+ ) -> AsyncIterator[AsyncCursor[Any]]:
173
+ """Create a database cursor as a context manager.
174
+
175
+ Args:
176
+ conn: The database connection to use
177
+ pipeline: whether to use pipeline for the DB operations inside the context manager.
178
+ Will be applied regardless of whether the PostgresStore instance was initialized with a pipeline.
179
+ If pipeline mode is not supported, will fall back to using transaction context manager.
180
+ """
181
+ if self.pipe:
182
+ # a connection in pipeline mode can be used concurrently
183
+ # in multiple threads/coroutines, but only one cursor can be
184
+ # used at a time
185
+ async with conn.cursor(binary=True) as cur:
186
+ try:
187
+ yield cur
188
+ finally:
189
+ if pipeline:
190
+ await self.pipe.sync()
191
+ elif pipeline:
192
+ # a connection not in pipeline mode can only be used by one
193
+ # thread/coroutine at a time, so we acquire a lock
194
+ if self.supports_pipeline:
195
+ async with self.lock, conn.pipeline(), conn.cursor(binary=True) as cur:
196
+ yield cur
197
+ else:
198
+ async with self.lock, conn.transaction(), conn.cursor(
199
+ binary=True
200
+ ) as cur:
201
+ yield cur
202
+ else:
203
+ async with conn.cursor(binary=True) as cur:
204
+ yield cur
205
+
206
+ def batch(self, ops: Iterable[Op]) -> list[Result]:
207
+ return asyncio.run_coroutine_threadsafe(self.abatch(ops), self.loop).result()
208
+
209
+ @classmethod
210
+ @asynccontextmanager
211
+ async def from_conn_string(
212
+ cls,
213
+ conn_string: str,
214
+ *,
215
+ pipeline: bool = False,
216
+ pool_config: Optional[PoolConfig] = None,
217
+ ) -> AsyncIterator["AsyncPostgresStore"]:
218
+ """Create a new AsyncPostgresStore instance from a connection string.
219
+
220
+ Args:
221
+ conn_string (str): The Postgres connection info string.
222
+ pipeline (bool): Whether to use AsyncPipeline (only for single connections)
223
+ pool_config (Optional[PoolConfig]): Configuration for the connection pool.
224
+ If provided, will create a connection pool and use it instead of a single connection.
225
+ This overrides the `pipeline` argument.
226
+
227
+ Returns:
228
+ AsyncPostgresStore: A new AsyncPostgresStore instance.
229
+ """
230
+ if pool_config is not None:
231
+ pc = pool_config.copy()
232
+ async with cast(
233
+ AsyncConnectionPool[AsyncConnection[DictRow]],
234
+ AsyncConnectionPool(
235
+ conn_string,
236
+ min_size=pc.pop("min_size", 1),
237
+ max_size=pc.pop("max_size", None),
238
+ kwargs={
239
+ "autocommit": True,
240
+ "prepare_threshold": 0,
241
+ "row_factory": dict_row,
242
+ **(pc.pop("kwargs", None) or {}),
243
+ },
244
+ **cast(dict, pc),
245
+ ),
246
+ ) as pool:
247
+ yield cls(conn=pool)
248
+ else:
249
+ async with await AsyncConnection.connect(
250
+ conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
251
+ ) as conn:
252
+ if pipeline:
253
+ async with conn.pipeline() as pipe:
254
+ yield cls(conn=conn, pipe=pipe)
255
+ else:
256
+ yield cls(conn=conn)
257
+
258
+ async def setup(self) -> None:
259
+ """Set up the store database asynchronously.
260
+
261
+ This method creates the necessary tables in the Postgres database if they don't
262
+ already exist and runs database migrations. It MUST be called directly by the user
263
+ the first time the store is used.
264
+ """
265
+ async with _ainternal.get_connection(self.conn) as conn:
266
+ async with conn.cursor() as cur:
267
+ try:
268
+ await cur.execute(
269
+ "SELECT v FROM store_migrations ORDER BY v DESC LIMIT 1"
270
+ )
271
+ row = cast(dict, await cur.fetchone())
272
+ if row is None:
273
+ version = -1
274
+ else:
275
+ version = row["v"]
276
+ except UndefinedTable:
277
+ version = -1
278
+ # Create store_migrations table if it doesn't exist
279
+ await cur.execute(
280
+ """
281
+ CREATE TABLE IF NOT EXISTS store_migrations (
282
+ v INTEGER PRIMARY KEY
283
+ )
284
+ """
285
+ )
286
+ for v, migration in enumerate(
287
+ self.MIGRATIONS[version + 1 :], start=version + 1
288
+ ):
289
+ await cur.execute(migration)
290
+ await cur.execute(
291
+ "INSERT INTO store_migrations (v) VALUES (%s)", (v,)
292
+ )
293
+ if self.pipe:
294
+ await self.pipe.sync()
@@ -1,6 +1,7 @@
1
1
  import asyncio
2
2
  import json
3
3
  import logging
4
+ import threading
4
5
  from collections import defaultdict
5
6
  from contextlib import contextmanager
6
7
  from datetime import datetime
@@ -18,12 +19,15 @@ from typing import (
18
19
  )
19
20
 
20
21
  import orjson
21
- from psycopg import BaseConnection, Connection, Cursor
22
+ from psycopg import Capabilities, Connection, Cursor, Pipeline
22
23
  from psycopg.errors import UndefinedTable
23
- from psycopg.rows import dict_row
24
+ from psycopg.rows import DictRow, dict_row
24
25
  from psycopg.types.json import Jsonb
26
+ from psycopg_pool import ConnectionPool
25
27
  from typing_extensions import TypedDict
26
28
 
29
+ from langgraph.checkpoint.postgres import _ainternal as _ainternal
30
+ from langgraph.checkpoint.postgres import _internal as _pg_internal
27
31
  from langgraph.store.base import (
28
32
  BaseStore,
29
33
  GetOp,
@@ -56,7 +60,32 @@ CREATE INDEX IF NOT EXISTS store_prefix_idx ON store USING btree (prefix text_pa
56
60
  """,
57
61
  ]
58
62
 
59
- C = TypeVar("C", bound=BaseConnection)
63
+ C = TypeVar("C", bound=Union[_pg_internal.Conn, _ainternal.Conn])
64
+
65
+
66
+ class PoolConfig(TypedDict, total=False):
67
+ """Connection pool settings for PostgreSQL connections.
68
+
69
+ Controls connection lifecycle and resource utilization:
70
+ - Small pools (1-5) suit low-concurrency workloads
71
+ - Larger pools handle concurrent requests but consume more resources
72
+ - Setting max_size prevents resource exhaustion under load
73
+ """
74
+
75
+ min_size: int
76
+ """Minimum number of connections maintained in the pool. Defaults to 1."""
77
+
78
+ max_size: Optional[int]
79
+ """Maximum number of connections allowed in the pool. None means unlimited."""
80
+
81
+ kwargs: dict
82
+ """Additional connection arguments passed to each connection in the pool.
83
+
84
+ Default kwargs set automatically:
85
+ - autocommit: True
86
+ - prepare_threshold: 0
87
+ - row_factory: dict_row
88
+ """
60
89
 
61
90
 
62
91
  class BasePostgresStore(Generic[C]):
@@ -88,9 +117,14 @@ class BasePostgresStore(Generic[C]):
88
117
  self,
89
118
  put_ops: Sequence[tuple[int, PutOp]],
90
119
  ) -> list[tuple[str, Sequence]]:
120
+ # Last-write wins
121
+ dedupped_ops: dict[tuple[tuple[str, ...], str], PutOp] = {}
122
+ for _, op in put_ops:
123
+ dedupped_ops[(op.namespace, op.key)] = op
124
+
91
125
  inserts: list[PutOp] = []
92
126
  deletes: list[PutOp] = []
93
- for _, op in put_ops:
127
+ for op in dedupped_ops.values():
94
128
  if op.value is None:
95
129
  deletes.append(op)
96
130
  else:
@@ -219,13 +253,14 @@ class BasePostgresStore(Generic[C]):
219
253
  return queries
220
254
 
221
255
 
222
- class PostgresStore(BaseStore, BasePostgresStore[Connection]):
223
- __slots__ = ("_deserializer",)
256
+ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
257
+ __slots__ = ("_deserializer", "pipe", "lock", "supports_pipeline")
224
258
 
225
259
  def __init__(
226
260
  self,
227
- conn: Connection[Any],
261
+ conn: _pg_internal.Conn,
228
262
  *,
263
+ pipe: Optional[Pipeline] = None,
229
264
  deserializer: Optional[
230
265
  Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
231
266
  ] = None,
@@ -233,26 +268,110 @@ class PostgresStore(BaseStore, BasePostgresStore[Connection]):
233
268
  super().__init__()
234
269
  self._deserializer = deserializer
235
270
  self.conn = conn
271
+ self.pipe = pipe
272
+ self.supports_pipeline = Capabilities().has_pipeline()
273
+ self.lock = threading.Lock()
274
+
275
+ @classmethod
276
+ @contextmanager
277
+ def from_conn_string(
278
+ cls,
279
+ conn_string: str,
280
+ *,
281
+ pipeline: bool = False,
282
+ pool_config: Optional[PoolConfig] = None,
283
+ ) -> Iterator["PostgresStore"]:
284
+ """Create a new PostgresStore instance from a connection string.
285
+
286
+ Args:
287
+ conn_string (str): The Postgres connection info string.
288
+ pipeline (bool): whether to use Pipeline (only for single connections)
289
+ pool_config (Optional[PoolArgs]): Configuration for the connection pool.
290
+ If provided, will create a connection pool and use it instead of a single connection.
291
+ This overrides the `pipeline` argument.
292
+ Returns:
293
+ PostgresStore: A new PostgresStore instance.
294
+ """
295
+ if pool_config is not None:
296
+ pc = pool_config.copy()
297
+ with cast(
298
+ ConnectionPool[Connection[DictRow]],
299
+ ConnectionPool(
300
+ conn_string,
301
+ min_size=pc.pop("min_size", 1),
302
+ max_size=pc.pop("max_size", None),
303
+ kwargs={
304
+ "autocommit": True,
305
+ "prepare_threshold": 0,
306
+ "row_factory": dict_row,
307
+ **(pc.pop("kwargs", None) or {}),
308
+ },
309
+ **cast(dict, pc),
310
+ ),
311
+ ) as pool:
312
+ yield cls(conn=pool)
313
+ else:
314
+ with Connection.connect(
315
+ conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
316
+ ) as conn:
317
+ if pipeline:
318
+ with conn.pipeline() as pipe:
319
+ yield cls(conn, pipe=pipe)
320
+ else:
321
+ yield cls(conn)
322
+
323
+ @contextmanager
324
+ def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor[DictRow]]:
325
+ """Create a database cursor as a context manager.
326
+
327
+ Args:
328
+ pipeline (bool): whether to use pipeline for the DB operations inside the context manager.
329
+ Will be applied regardless of whether the PostgresStore instance was initialized with a pipeline.
330
+ If pipeline mode is not supported, will fall back to using transaction context manager.
331
+ """
332
+ with _pg_internal.get_connection(self.conn) as conn:
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 conn.cursor(binary=True, row_factory=dict_row) as cur:
339
+ 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
+ if self.supports_pipeline:
347
+ with self.lock, conn.pipeline(), conn.cursor(
348
+ binary=True, row_factory=dict_row
349
+ ) as cur:
350
+ yield cur
351
+ else:
352
+ with self.lock, conn.transaction(), conn.cursor(
353
+ binary=True, row_factory=dict_row
354
+ ) as cur:
355
+ yield cur
356
+ else:
357
+ with conn.cursor(binary=True, row_factory=dict_row) as cur:
358
+ yield cur
236
359
 
237
360
  def batch(self, ops: Iterable[Op]) -> list[Result]:
238
361
  grouped_ops, num_ops = _group_ops(ops)
239
362
  results: list[Result] = [None] * num_ops
240
363
 
241
- with self.conn.pipeline():
364
+ with self._cursor(pipeline=True) as cur:
242
365
  if GetOp in grouped_ops:
243
366
  self._batch_get_ops(
244
- cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), results
245
- )
246
-
247
- if PutOp in grouped_ops:
248
- self._batch_put_ops(
249
- cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp])
367
+ cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), results, cur
250
368
  )
251
369
 
252
370
  if SearchOp in grouped_ops:
253
371
  self._batch_search_ops(
254
372
  cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]),
255
373
  results,
374
+ cur,
256
375
  )
257
376
 
258
377
  if ListNamespacesOp in grouped_ops:
@@ -262,25 +381,23 @@ class PostgresStore(BaseStore, BasePostgresStore[Connection]):
262
381
  grouped_ops[ListNamespacesOp],
263
382
  ),
264
383
  results,
384
+ cur,
385
+ )
386
+ if PutOp in grouped_ops:
387
+ self._batch_put_ops(
388
+ cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp]), cur
265
389
  )
266
390
 
267
391
  return results
268
392
 
269
- async def abatch(self, ops: Iterable[Op]) -> list[Result]:
270
- return await asyncio.get_running_loop().run_in_executor(None, self.batch, ops)
271
-
272
393
  def _batch_get_ops(
273
394
  self,
274
395
  get_ops: Sequence[tuple[int, GetOp]],
275
396
  results: list[Result],
397
+ cur: Cursor[DictRow],
276
398
  ) -> None:
277
- cursors = []
278
399
  for query, params, namespace, items in self._get_batch_GET_ops_queries(get_ops):
279
- cur = self.conn.cursor(binary=True)
280
400
  cur.execute(query, params)
281
- cursors.append((cur, namespace, items))
282
-
283
- for cur, namespace, items in cursors:
284
401
  rows = cast(list[Row], cur.fetchall())
285
402
  key_to_row = {row["key"]: row for row in rows}
286
403
  for idx, key in items:
@@ -295,70 +412,44 @@ class PostgresStore(BaseStore, BasePostgresStore[Connection]):
295
412
  def _batch_put_ops(
296
413
  self,
297
414
  put_ops: Sequence[tuple[int, PutOp]],
415
+ cur: Cursor[DictRow],
298
416
  ) -> None:
299
417
  queries = self._get_batch_PUT_queries(put_ops)
300
418
  for query, params in queries:
301
- cur = self.conn.cursor(binary=True)
302
419
  cur.execute(query, params)
303
420
 
304
421
  def _batch_search_ops(
305
422
  self,
306
423
  search_ops: Sequence[tuple[int, SearchOp]],
307
424
  results: list[Result],
425
+ cur: Cursor[DictRow],
308
426
  ) -> None:
309
- queries = self._get_batch_search_queries(search_ops)
310
- cursors: list[tuple[Cursor[Any], int]] = []
311
-
312
- for (query, params), (idx, _) in zip(queries, search_ops):
313
- cur = self.conn.cursor(binary=True)
427
+ for (query, params), (idx, _) in zip(
428
+ self._get_batch_search_queries(search_ops), search_ops
429
+ ):
314
430
  cur.execute(query, params)
315
- cursors.append((cur, idx))
316
-
317
- for cur, idx in cursors:
318
431
  rows = cast(list[Row], cur.fetchall())
319
- items = [
432
+ results[idx] = [
320
433
  _row_to_item(
321
434
  _decode_ns_bytes(row["prefix"]), row, loader=self._deserializer
322
435
  )
323
436
  for row in rows
324
437
  ]
325
- results[idx] = items
326
438
 
327
439
  def _batch_list_namespaces_ops(
328
440
  self,
329
441
  list_ops: Sequence[tuple[int, ListNamespacesOp]],
330
442
  results: list[Result],
443
+ cur: Cursor[DictRow],
331
444
  ) -> None:
332
- queries = self._get_batch_list_namespaces_queries(list_ops)
333
- cursors: list[tuple[Cursor[Any], int]] = []
334
- for (query, params), (idx, _) in zip(queries, list_ops):
335
- cur = self.conn.cursor(binary=True)
445
+ for (query, params), (idx, _) in zip(
446
+ self._get_batch_list_namespaces_queries(list_ops), list_ops
447
+ ):
336
448
  cur.execute(query, params)
337
- cursors.append((cur, idx))
449
+ results[idx] = [_decode_ns_bytes(row["truncated_prefix"]) for row in cur]
338
450
 
339
- for cur, idx in cursors:
340
- rows = cast(list[dict], cur.fetchall())
341
- namespaces = [_decode_ns_bytes(row["truncated_prefix"]) for row in rows]
342
- results[idx] = namespaces
343
-
344
- @classmethod
345
- @contextmanager
346
- def from_conn_string(
347
- cls,
348
- conn_string: str,
349
- ) -> Iterator["PostgresStore"]:
350
- """Create a new BasePostgresStore instance from a connection string.
351
-
352
- Args:
353
- conn_string (str): The Postgres connection info string.
354
-
355
- Returns:
356
- BasePostgresStore: A new BasePostgresStore instance.
357
- """
358
- with Connection.connect(
359
- conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
360
- ) as conn:
361
- yield cls(conn=conn)
451
+ async def abatch(self, ops: Iterable[Op]) -> list[Result]:
452
+ return await asyncio.get_running_loop().run_in_executor(None, self.batch, ops)
362
453
 
363
454
  def setup(self) -> None:
364
455
  """Set up the store database.
@@ -367,7 +458,7 @@ class PostgresStore(BaseStore, BasePostgresStore[Connection]):
367
458
  already exist and runs database migrations. It MUST be called directly by the user
368
459
  the first time the store is used.
369
460
  """
370
- with self.conn.cursor(binary=True) as cur:
461
+ with self._cursor() as cur:
371
462
  try:
372
463
  cur.execute("SELECT v FROM store_migrations ORDER BY v DESC LIMIT 1")
373
464
  row = cast(dict, cur.fetchone())
@@ -376,9 +467,7 @@ class PostgresStore(BaseStore, BasePostgresStore[Connection]):
376
467
  else:
377
468
  version = row["v"]
378
469
  except UndefinedTable:
379
- self.conn.rollback()
380
470
  version = -1
381
- # Create store_migrations table if it doesn't exist
382
471
  cur.execute(
383
472
  """
384
473
  CREATE TABLE IF NOT EXISTS store_migrations (
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "langgraph-checkpoint-postgres"
3
- version = "2.0.2"
3
+ version = "2.0.4"
4
4
  description = "Library with a Postgres implementation of LangGraph checkpoint saver."
5
5
  authors = []
6
6
  license = "MIT"
@@ -1,218 +0,0 @@
1
- import asyncio
2
- import logging
3
- from contextlib import asynccontextmanager
4
- from typing import (
5
- Any,
6
- AsyncIterator,
7
- Callable,
8
- Iterable,
9
- Optional,
10
- Sequence,
11
- Union,
12
- cast,
13
- )
14
-
15
- import orjson
16
- from psycopg import AsyncConnection, AsyncCursor
17
- from psycopg.errors import UndefinedTable
18
- from psycopg.rows import dict_row
19
-
20
- from langgraph.store.base import GetOp, ListNamespacesOp, Op, PutOp, Result, SearchOp
21
- from langgraph.store.base.batch import AsyncBatchedBaseStore
22
- from langgraph.store.postgres.base import (
23
- BasePostgresStore,
24
- Row,
25
- _decode_ns_bytes,
26
- _group_ops,
27
- _row_to_item,
28
- )
29
-
30
- logger = logging.getLogger(__name__)
31
-
32
-
33
- class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[AsyncConnection]):
34
- __slots__ = ("_deserializer",)
35
-
36
- def __init__(
37
- self,
38
- conn: AsyncConnection[Any],
39
- *,
40
- deserializer: Optional[
41
- Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
42
- ] = None,
43
- ) -> None:
44
- super().__init__()
45
- self._deserializer = deserializer
46
- self.conn = conn
47
- self.loop = asyncio.get_running_loop()
48
-
49
- async def abatch(self, ops: Iterable[Op]) -> list[Result]:
50
- grouped_ops, num_ops = _group_ops(ops)
51
- results: list[Result] = [None] * num_ops
52
-
53
- async with self.conn.pipeline():
54
- tasks = []
55
-
56
- if GetOp in grouped_ops:
57
- tasks.append(
58
- self._batch_get_ops(
59
- cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), results
60
- )
61
- )
62
-
63
- if PutOp in grouped_ops:
64
- tasks.append(
65
- self._batch_put_ops(
66
- cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp])
67
- )
68
- )
69
-
70
- if SearchOp in grouped_ops:
71
- tasks.append(
72
- self._batch_search_ops(
73
- cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]),
74
- results,
75
- )
76
- )
77
-
78
- if ListNamespacesOp in grouped_ops:
79
- tasks.append(
80
- self._batch_list_namespaces_ops(
81
- cast(
82
- Sequence[tuple[int, ListNamespacesOp]],
83
- grouped_ops[ListNamespacesOp],
84
- ),
85
- results,
86
- )
87
- )
88
-
89
- await asyncio.gather(*tasks)
90
-
91
- return results
92
-
93
- def batch(self, ops: Iterable[Op]) -> list[Result]:
94
- return asyncio.run_coroutine_threadsafe(self.abatch(ops), self.loop).result()
95
-
96
- async def _batch_get_ops(
97
- self,
98
- get_ops: Sequence[tuple[int, GetOp]],
99
- results: list[Result],
100
- ) -> None:
101
- cursors = []
102
- for query, params, namespace, items in self._get_batch_GET_ops_queries(get_ops):
103
- cur = self.conn.cursor(binary=True)
104
- await cur.execute(query, params)
105
- cursors.append((cur, namespace, items))
106
-
107
- for cur, namespace, items in cursors:
108
- rows = cast(list[Row], await cur.fetchall())
109
- key_to_row = {row["key"]: row for row in rows}
110
- for idx, key in items:
111
- row = key_to_row.get(key)
112
- if row:
113
- results[idx] = _row_to_item(
114
- namespace, row, loader=self._deserializer
115
- )
116
- else:
117
- results[idx] = None
118
-
119
- async def _batch_put_ops(
120
- self,
121
- put_ops: Sequence[tuple[int, PutOp]],
122
- ) -> None:
123
- queries = self._get_batch_PUT_queries(put_ops)
124
- for query, params in queries:
125
- cur = self.conn.cursor(binary=True)
126
- await cur.execute(query, params)
127
-
128
- async def _batch_search_ops(
129
- self,
130
- search_ops: Sequence[tuple[int, SearchOp]],
131
- results: list[Result],
132
- ) -> None:
133
- queries = self._get_batch_search_queries(search_ops)
134
- cursors: list[tuple[AsyncCursor[Any], int]] = []
135
-
136
- for (query, params), (idx, _) in zip(queries, search_ops):
137
- cur = self.conn.cursor(binary=True)
138
- await cur.execute(query, params)
139
- cursors.append((cur, idx))
140
-
141
- for cur, idx in cursors:
142
- rows = cast(list[Row], await cur.fetchall())
143
- items = [
144
- _row_to_item(
145
- _decode_ns_bytes(row["prefix"]), row, loader=self._deserializer
146
- )
147
- for row in rows
148
- ]
149
- results[idx] = items
150
-
151
- async def _batch_list_namespaces_ops(
152
- self,
153
- list_ops: Sequence[tuple[int, ListNamespacesOp]],
154
- results: list[Result],
155
- ) -> None:
156
- queries = self._get_batch_list_namespaces_queries(list_ops)
157
- cursors: list[tuple[AsyncCursor[Any], int]] = []
158
- for (query, params), (idx, _) in zip(queries, list_ops):
159
- cur = self.conn.cursor(binary=True)
160
- await cur.execute(query, params)
161
- cursors.append((cur, idx))
162
-
163
- for cur, idx in cursors:
164
- rows = cast(list[dict], await cur.fetchall())
165
- namespaces = [_decode_ns_bytes(row["truncated_prefix"]) for row in rows]
166
- results[idx] = namespaces
167
-
168
- @classmethod
169
- @asynccontextmanager
170
- async def from_conn_string(
171
- cls,
172
- conn_string: str,
173
- ) -> AsyncIterator["AsyncPostgresStore"]:
174
- """Create a new AsyncPostgresStore instance from a connection string.
175
-
176
- Args:
177
- conn_string (str): The Postgres connection info string.
178
-
179
- Returns:
180
- AsyncPostgresStore: A new AsyncPostgresStore instance.
181
- """
182
- async with await AsyncConnection.connect(
183
- conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
184
- ) as conn:
185
- yield cls(conn=conn)
186
-
187
- async def setup(self) -> None:
188
- """Set up the store database asynchronously.
189
-
190
- This method creates the necessary tables in the Postgres database if they don't
191
- already exist and runs database migrations. It MUST be called directly by the user
192
- the first time the store is used.
193
- """
194
- async with self.conn.cursor() as cur:
195
- try:
196
- await cur.execute(
197
- "SELECT v FROM store_migrations ORDER BY v DESC LIMIT 1"
198
- )
199
- row = cast(dict, await cur.fetchone())
200
- if row is None:
201
- version = -1
202
- else:
203
- version = row["v"]
204
- except UndefinedTable:
205
- version = -1
206
- # Create store_migrations table if it doesn't exist
207
- await cur.execute(
208
- """
209
- CREATE TABLE IF NOT EXISTS store_migrations (
210
- v INTEGER PRIMARY KEY
211
- )
212
- """
213
- )
214
- for v, migration in enumerate(
215
- self.MIGRATIONS[version + 1 :], start=version + 1
216
- ):
217
- await cur.execute(migration)
218
- await cur.execute("INSERT INTO store_migrations (v) VALUES (%s)", (v,))