langgraph-checkpoint-postgres 2.0.7__tar.gz → 2.0.9__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.
Files changed (14) hide show
  1. {langgraph_checkpoint_postgres-2.0.7 → langgraph_checkpoint_postgres-2.0.9}/PKG-INFO +1 -1
  2. {langgraph_checkpoint_postgres-2.0.7 → langgraph_checkpoint_postgres-2.0.9}/langgraph/checkpoint/postgres/__init__.py +2 -1
  3. {langgraph_checkpoint_postgres-2.0.7 → langgraph_checkpoint_postgres-2.0.9}/langgraph/checkpoint/postgres/aio.py +3 -2
  4. {langgraph_checkpoint_postgres-2.0.7 → langgraph_checkpoint_postgres-2.0.9}/langgraph/checkpoint/postgres/base.py +11 -0
  5. langgraph_checkpoint_postgres-2.0.9/langgraph/checkpoint/postgres/shallow.py +918 -0
  6. {langgraph_checkpoint_postgres-2.0.7 → langgraph_checkpoint_postgres-2.0.9}/langgraph/store/postgres/aio.py +81 -19
  7. {langgraph_checkpoint_postgres-2.0.7 → langgraph_checkpoint_postgres-2.0.9}/langgraph/store/postgres/base.py +66 -18
  8. {langgraph_checkpoint_postgres-2.0.7 → langgraph_checkpoint_postgres-2.0.9}/pyproject.toml +1 -1
  9. {langgraph_checkpoint_postgres-2.0.7 → langgraph_checkpoint_postgres-2.0.9}/README.md +0 -0
  10. {langgraph_checkpoint_postgres-2.0.7 → langgraph_checkpoint_postgres-2.0.9}/langgraph/checkpoint/postgres/_ainternal.py +0 -0
  11. {langgraph_checkpoint_postgres-2.0.7 → langgraph_checkpoint_postgres-2.0.9}/langgraph/checkpoint/postgres/_internal.py +0 -0
  12. {langgraph_checkpoint_postgres-2.0.7 → langgraph_checkpoint_postgres-2.0.9}/langgraph/checkpoint/postgres/py.typed +0 -0
  13. {langgraph_checkpoint_postgres-2.0.7 → langgraph_checkpoint_postgres-2.0.9}/langgraph/store/postgres/__init__.py +0 -0
  14. {langgraph_checkpoint_postgres-2.0.7 → langgraph_checkpoint_postgres-2.0.9}/langgraph/store/postgres/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: langgraph-checkpoint-postgres
3
- Version: 2.0.7
3
+ Version: 2.0.9
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
@@ -19,6 +19,7 @@ from langgraph.checkpoint.base import (
19
19
  )
20
20
  from langgraph.checkpoint.postgres import _internal
21
21
  from langgraph.checkpoint.postgres.base import BasePostgresSaver
22
+ from langgraph.checkpoint.postgres.shallow import ShallowPostgresSaver
22
23
  from langgraph.checkpoint.serde.base import SerializerProtocol
23
24
 
24
25
  Conn = _internal.Conn # For backward compatibility
@@ -396,4 +397,4 @@ class PostgresSaver(BasePostgresSaver):
396
397
  yield cur
397
398
 
398
399
 
399
- __all__ = ["PostgresSaver", "BasePostgresSaver", "Conn"]
400
+ __all__ = ["PostgresSaver", "BasePostgresSaver", "ShallowPostgresSaver", "Conn"]
@@ -19,6 +19,7 @@ from langgraph.checkpoint.base import (
19
19
  )
20
20
  from langgraph.checkpoint.postgres import _ainternal
21
21
  from langgraph.checkpoint.postgres.base import BasePostgresSaver
22
+ from langgraph.checkpoint.postgres.shallow import AsyncShallowPostgresSaver
22
23
  from langgraph.checkpoint.serde.base import SerializerProtocol
23
24
 
24
25
  Conn = _ainternal.Conn # For backward compatibility
@@ -54,7 +55,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
54
55
  pipeline: bool = False,
55
56
  serde: Optional[SerializerProtocol] = None,
56
57
  ) -> AsyncIterator["AsyncPostgresSaver"]:
57
- """Create a new PostgresSaver instance from a connection string.
58
+ """Create a new AsyncPostgresSaver instance from a connection string.
58
59
 
59
60
  Args:
60
61
  conn_string (str): The Postgres connection info string.
@@ -464,4 +465,4 @@ class AsyncPostgresSaver(BasePostgresSaver):
464
465
  ).result()
465
466
 
466
467
 
467
- __all__ = ["AsyncPostgresSaver", "Conn"]
468
+ __all__ = ["AsyncPostgresSaver", "AsyncShallowPostgresSaver", "Conn"]
@@ -57,6 +57,17 @@ MIGRATIONS = [
57
57
  PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
58
58
  );""",
59
59
  "ALTER TABLE checkpoint_blobs ALTER COLUMN blob DROP not null;",
60
+ """
61
+ """,
62
+ """
63
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoints_thread_id_idx ON checkpoints(thread_id);
64
+ """,
65
+ """
66
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoint_blobs_thread_id_idx ON checkpoint_blobs(thread_id);
67
+ """,
68
+ """
69
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoint_writes_thread_id_idx ON checkpoint_writes(thread_id);
70
+ """,
60
71
  ]
61
72
 
62
73
  SELECT_SQL = f"""
@@ -0,0 +1,918 @@
1
+ import asyncio
2
+ import threading
3
+ from collections.abc import AsyncIterator, Iterator, Sequence
4
+ from contextlib import asynccontextmanager, contextmanager
5
+ from typing import Any, Optional
6
+
7
+ from langchain_core.runnables import RunnableConfig
8
+ from psycopg import (
9
+ AsyncConnection,
10
+ AsyncCursor,
11
+ AsyncPipeline,
12
+ Capabilities,
13
+ Connection,
14
+ Cursor,
15
+ Pipeline,
16
+ )
17
+ from psycopg.rows import DictRow, dict_row
18
+ from psycopg.types.json import Jsonb
19
+ from psycopg_pool import AsyncConnectionPool, ConnectionPool
20
+
21
+ from langgraph.checkpoint.base import (
22
+ WRITES_IDX_MAP,
23
+ ChannelVersions,
24
+ Checkpoint,
25
+ CheckpointMetadata,
26
+ CheckpointTuple,
27
+ )
28
+ from langgraph.checkpoint.postgres import _ainternal, _internal
29
+ from langgraph.checkpoint.postgres.base import BasePostgresSaver
30
+ from langgraph.checkpoint.serde.base import SerializerProtocol
31
+ from langgraph.checkpoint.serde.types import TASKS
32
+
33
+ """
34
+ To add a new migration, add a new string to the MIGRATIONS list.
35
+ The position of the migration in the list is the version number.
36
+ """
37
+ MIGRATIONS = [
38
+ """CREATE TABLE IF NOT EXISTS checkpoint_migrations (
39
+ v INTEGER PRIMARY KEY
40
+ );""",
41
+ """CREATE TABLE IF NOT EXISTS checkpoints (
42
+ thread_id TEXT NOT NULL,
43
+ checkpoint_ns TEXT NOT NULL DEFAULT '',
44
+ type TEXT,
45
+ checkpoint JSONB NOT NULL,
46
+ metadata JSONB NOT NULL DEFAULT '{}',
47
+ PRIMARY KEY (thread_id, checkpoint_ns)
48
+ );""",
49
+ """CREATE TABLE IF NOT EXISTS checkpoint_blobs (
50
+ thread_id TEXT NOT NULL,
51
+ checkpoint_ns TEXT NOT NULL DEFAULT '',
52
+ channel TEXT NOT NULL,
53
+ type TEXT NOT NULL,
54
+ blob BYTEA,
55
+ PRIMARY KEY (thread_id, checkpoint_ns, channel)
56
+ );""",
57
+ """CREATE TABLE IF NOT EXISTS checkpoint_writes (
58
+ thread_id TEXT NOT NULL,
59
+ checkpoint_ns TEXT NOT NULL DEFAULT '',
60
+ checkpoint_id TEXT NOT NULL,
61
+ task_id TEXT NOT NULL,
62
+ idx INTEGER NOT NULL,
63
+ channel TEXT NOT NULL,
64
+ type TEXT,
65
+ blob BYTEA NOT NULL,
66
+ PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
67
+ );""",
68
+ """
69
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoints_thread_id_idx ON checkpoints(thread_id);
70
+ """,
71
+ """
72
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoint_blobs_thread_id_idx ON checkpoint_blobs(thread_id);
73
+ """,
74
+ """
75
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoint_writes_thread_id_idx ON checkpoint_writes(thread_id);
76
+ """,
77
+ ]
78
+
79
+ SELECT_SQL = f"""
80
+ select
81
+ thread_id,
82
+ checkpoint,
83
+ checkpoint_ns,
84
+ metadata,
85
+ (
86
+ select array_agg(array[bl.channel::bytea, bl.type::bytea, bl.blob])
87
+ from jsonb_each_text(checkpoint -> 'channel_versions')
88
+ inner join checkpoint_blobs bl
89
+ on bl.thread_id = checkpoints.thread_id
90
+ and bl.checkpoint_ns = checkpoints.checkpoint_ns
91
+ and bl.channel = jsonb_each_text.key
92
+ ) as channel_values,
93
+ (
94
+ select
95
+ array_agg(array[cw.task_id::text::bytea, cw.channel::bytea, cw.type::bytea, cw.blob] order by cw.task_id, cw.idx)
96
+ from checkpoint_writes cw
97
+ where cw.thread_id = checkpoints.thread_id
98
+ and cw.checkpoint_ns = checkpoints.checkpoint_ns
99
+ and cw.checkpoint_id = (checkpoint->>'id')
100
+ ) as pending_writes,
101
+ (
102
+ select array_agg(array[cw.type::bytea, cw.blob] order by cw.task_id, cw.idx)
103
+ from checkpoint_writes cw
104
+ where cw.thread_id = checkpoints.thread_id
105
+ and cw.checkpoint_ns = checkpoints.checkpoint_ns
106
+ and cw.channel = '{TASKS}'
107
+ ) as pending_sends
108
+ from checkpoints """
109
+
110
+ UPSERT_CHECKPOINT_BLOBS_SQL = """
111
+ INSERT INTO checkpoint_blobs (thread_id, checkpoint_ns, channel, type, blob)
112
+ VALUES (%s, %s, %s, %s, %s)
113
+ ON CONFLICT (thread_id, checkpoint_ns, channel) DO UPDATE SET
114
+ type = EXCLUDED.type,
115
+ blob = EXCLUDED.blob;
116
+ """
117
+
118
+ UPSERT_CHECKPOINTS_SQL = """
119
+ INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint, metadata)
120
+ VALUES (%s, %s, %s, %s)
121
+ ON CONFLICT (thread_id, checkpoint_ns)
122
+ DO UPDATE SET
123
+ checkpoint = EXCLUDED.checkpoint,
124
+ metadata = EXCLUDED.metadata;
125
+ """
126
+
127
+ UPSERT_CHECKPOINT_WRITES_SQL = """
128
+ INSERT INTO checkpoint_writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, blob)
129
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
130
+ ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) DO UPDATE SET
131
+ channel = EXCLUDED.channel,
132
+ type = EXCLUDED.type,
133
+ blob = EXCLUDED.blob;
134
+ """
135
+
136
+ INSERT_CHECKPOINT_WRITES_SQL = """
137
+ INSERT INTO checkpoint_writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, blob)
138
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
139
+ ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) DO NOTHING
140
+ """
141
+
142
+
143
+ def _dump_blobs(
144
+ serde: SerializerProtocol,
145
+ thread_id: str,
146
+ checkpoint_ns: str,
147
+ values: dict[str, Any],
148
+ versions: ChannelVersions,
149
+ ) -> list[tuple[str, str, str, str, str, Optional[bytes]]]:
150
+ if not versions:
151
+ return []
152
+
153
+ return [
154
+ (
155
+ thread_id,
156
+ checkpoint_ns,
157
+ k,
158
+ *(serde.dumps_typed(values[k]) if k in values else ("empty", None)),
159
+ )
160
+ for k in versions
161
+ ]
162
+
163
+
164
+ class ShallowPostgresSaver(BasePostgresSaver):
165
+ """A checkpoint saver that uses Postgres to store checkpoints.
166
+
167
+ This checkpointer ONLY stores the most recent checkpoint and does NOT retain any history.
168
+ It is meant to be a light-weight drop-in replacement for the PostgresSaver that
169
+ supports most of the LangGraph persistence functionality with the exception of time travel.
170
+ """
171
+
172
+ SELECT_SQL = SELECT_SQL
173
+ MIGRATIONS = MIGRATIONS
174
+ UPSERT_CHECKPOINT_BLOBS_SQL = UPSERT_CHECKPOINT_BLOBS_SQL
175
+ UPSERT_CHECKPOINTS_SQL = UPSERT_CHECKPOINTS_SQL
176
+ UPSERT_CHECKPOINT_WRITES_SQL = UPSERT_CHECKPOINT_WRITES_SQL
177
+ INSERT_CHECKPOINT_WRITES_SQL = INSERT_CHECKPOINT_WRITES_SQL
178
+
179
+ lock: threading.Lock
180
+
181
+ def __init__(
182
+ self,
183
+ conn: _internal.Conn,
184
+ pipe: Optional[Pipeline] = None,
185
+ serde: Optional[SerializerProtocol] = None,
186
+ ) -> None:
187
+ super().__init__(serde=serde)
188
+ if isinstance(conn, ConnectionPool) and pipe is not None:
189
+ raise ValueError(
190
+ "Pipeline should be used only with a single Connection, not ConnectionPool."
191
+ )
192
+
193
+ self.conn = conn
194
+ self.pipe = pipe
195
+ self.lock = threading.Lock()
196
+ self.supports_pipeline = Capabilities().has_pipeline()
197
+
198
+ @classmethod
199
+ @contextmanager
200
+ def from_conn_string(
201
+ cls, conn_string: str, *, pipeline: bool = False
202
+ ) -> Iterator["ShallowPostgresSaver"]:
203
+ """Create a new ShallowPostgresSaver instance from a connection string.
204
+
205
+ Args:
206
+ conn_string (str): The Postgres connection info string.
207
+ pipeline (bool): whether to use Pipeline
208
+
209
+ Returns:
210
+ ShallowPostgresSaver: A new ShallowPostgresSaver instance.
211
+ """
212
+ with Connection.connect(
213
+ conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
214
+ ) as conn:
215
+ if pipeline:
216
+ with conn.pipeline() as pipe:
217
+ yield cls(conn, pipe)
218
+ else:
219
+ yield cls(conn)
220
+
221
+ def setup(self) -> None:
222
+ """Set up the checkpoint database asynchronously.
223
+
224
+ This method creates the necessary tables in the Postgres database if they don't
225
+ already exist and runs database migrations. It MUST be called directly by the user
226
+ the first time checkpointer is used.
227
+ """
228
+ with self._cursor() as cur:
229
+ cur.execute(self.MIGRATIONS[0])
230
+ results = cur.execute(
231
+ "SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1"
232
+ )
233
+ row = results.fetchone()
234
+ if row is None:
235
+ version = -1
236
+ else:
237
+ version = row["v"]
238
+ for v, migration in zip(
239
+ range(version + 1, len(self.MIGRATIONS)),
240
+ self.MIGRATIONS[version + 1 :],
241
+ ):
242
+ cur.execute(migration)
243
+ cur.execute(f"INSERT INTO checkpoint_migrations (v) VALUES ({v})")
244
+ if self.pipe:
245
+ self.pipe.sync()
246
+
247
+ def list(
248
+ self,
249
+ config: Optional[RunnableConfig],
250
+ *,
251
+ filter: Optional[dict[str, Any]] = None,
252
+ before: Optional[RunnableConfig] = None,
253
+ limit: Optional[int] = None,
254
+ ) -> Iterator[CheckpointTuple]:
255
+ """List checkpoints from the database.
256
+
257
+ This method retrieves a list of checkpoint tuples from the Postgres database based
258
+ on the provided config. For ShallowPostgresSaver, this method returns a list with
259
+ ONLY the most recent checkpoint.
260
+ """
261
+ where, args = self._search_where(config, filter, before)
262
+ query = self.SELECT_SQL + where
263
+ if limit:
264
+ query += f" LIMIT {limit}"
265
+ with self._cursor() as cur:
266
+ cur.execute(self.SELECT_SQL + where, args, binary=True)
267
+ for value in cur:
268
+ checkpoint = self._load_checkpoint(
269
+ value["checkpoint"],
270
+ value["channel_values"],
271
+ value["pending_sends"],
272
+ )
273
+ yield CheckpointTuple(
274
+ config={
275
+ "configurable": {
276
+ "thread_id": value["thread_id"],
277
+ "checkpoint_ns": value["checkpoint_ns"],
278
+ "checkpoint_id": checkpoint["id"],
279
+ }
280
+ },
281
+ checkpoint=checkpoint,
282
+ metadata=self._load_metadata(value["metadata"]),
283
+ pending_writes=self._load_writes(value["pending_writes"]),
284
+ )
285
+
286
+ def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
287
+ """Get a checkpoint tuple from the database.
288
+
289
+ This method retrieves a checkpoint tuple from the Postgres database based on the
290
+ provided config (matching the thread ID in the config).
291
+
292
+ Args:
293
+ config (RunnableConfig): The config to use for retrieving the checkpoint.
294
+
295
+ Returns:
296
+ Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
297
+
298
+ Examples:
299
+
300
+ Basic:
301
+ >>> config = {"configurable": {"thread_id": "1"}}
302
+ >>> checkpoint_tuple = memory.get_tuple(config)
303
+ >>> print(checkpoint_tuple)
304
+ CheckpointTuple(...)
305
+
306
+ With timestamp:
307
+
308
+ >>> config = {
309
+ ... "configurable": {
310
+ ... "thread_id": "1",
311
+ ... "checkpoint_ns": "",
312
+ ... "checkpoint_id": "1ef4f797-8335-6428-8001-8a1503f9b875",
313
+ ... }
314
+ ... }
315
+ >>> checkpoint_tuple = memory.get_tuple(config)
316
+ >>> print(checkpoint_tuple)
317
+ CheckpointTuple(...)
318
+ """ # noqa
319
+ thread_id = config["configurable"]["thread_id"]
320
+ checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
321
+ args = (thread_id, checkpoint_ns)
322
+ where = "WHERE thread_id = %s AND checkpoint_ns = %s"
323
+
324
+ with self._cursor() as cur:
325
+ cur.execute(
326
+ self.SELECT_SQL + where,
327
+ args,
328
+ binary=True,
329
+ )
330
+
331
+ for value in cur:
332
+ checkpoint = self._load_checkpoint(
333
+ value["checkpoint"],
334
+ value["channel_values"],
335
+ value["pending_sends"],
336
+ )
337
+ return CheckpointTuple(
338
+ config={
339
+ "configurable": {
340
+ "thread_id": thread_id,
341
+ "checkpoint_ns": checkpoint_ns,
342
+ "checkpoint_id": checkpoint["id"],
343
+ }
344
+ },
345
+ checkpoint=checkpoint,
346
+ metadata=self._load_metadata(value["metadata"]),
347
+ pending_writes=self._load_writes(value["pending_writes"]),
348
+ )
349
+
350
+ def put(
351
+ self,
352
+ config: RunnableConfig,
353
+ checkpoint: Checkpoint,
354
+ metadata: CheckpointMetadata,
355
+ new_versions: ChannelVersions,
356
+ ) -> RunnableConfig:
357
+ """Save a checkpoint to the database.
358
+
359
+ This method saves a checkpoint to the Postgres database. The checkpoint is associated
360
+ with the provided config. For ShallowPostgresSaver, this method saves ONLY the most recent
361
+ checkpoint and overwrites a previous checkpoint, if it exists.
362
+
363
+ Args:
364
+ config (RunnableConfig): The config to associate with the checkpoint.
365
+ checkpoint (Checkpoint): The checkpoint to save.
366
+ metadata (CheckpointMetadata): Additional metadata to save with the checkpoint.
367
+ new_versions (ChannelVersions): New channel versions as of this write.
368
+
369
+ Returns:
370
+ RunnableConfig: Updated configuration after storing the checkpoint.
371
+
372
+ Examples:
373
+
374
+ >>> from langgraph.checkpoint.postgres import ShallowPostgresSaver
375
+ >>> DB_URI = "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"
376
+ >>> with ShallowPostgresSaver.from_conn_string(DB_URI) as memory:
377
+ >>> config = {"configurable": {"thread_id": "1", "checkpoint_ns": ""}}
378
+ >>> checkpoint = {"ts": "2024-05-04T06:32:42.235444+00:00", "id": "1ef4f797-8335-6428-8001-8a1503f9b875", "channel_values": {"key": "value"}}
379
+ >>> saved_config = memory.put(config, checkpoint, {"source": "input", "step": 1, "writes": {"key": "value"}}, {})
380
+ >>> print(saved_config)
381
+ {'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '1ef4f797-8335-6428-8001-8a1503f9b875'}}
382
+ """
383
+ configurable = config["configurable"].copy()
384
+ thread_id = configurable.pop("thread_id")
385
+ checkpoint_ns = configurable.pop("checkpoint_ns")
386
+
387
+ copy = checkpoint.copy()
388
+ next_config = {
389
+ "configurable": {
390
+ "thread_id": thread_id,
391
+ "checkpoint_ns": checkpoint_ns,
392
+ "checkpoint_id": checkpoint["id"],
393
+ }
394
+ }
395
+
396
+ with self._cursor(pipeline=True) as cur:
397
+ cur.execute(
398
+ """DELETE FROM checkpoint_writes
399
+ WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id NOT IN (%s, %s)""",
400
+ (
401
+ thread_id,
402
+ checkpoint_ns,
403
+ checkpoint["id"],
404
+ configurable.get("checkpoint_id", ""),
405
+ ),
406
+ )
407
+ cur.executemany(
408
+ self.UPSERT_CHECKPOINT_BLOBS_SQL,
409
+ _dump_blobs(
410
+ self.serde,
411
+ thread_id,
412
+ checkpoint_ns,
413
+ copy.pop("channel_values"), # type: ignore[misc]
414
+ new_versions,
415
+ ),
416
+ )
417
+ cur.execute(
418
+ self.UPSERT_CHECKPOINTS_SQL,
419
+ (
420
+ thread_id,
421
+ checkpoint_ns,
422
+ Jsonb(self._dump_checkpoint(copy)),
423
+ self._dump_metadata(metadata),
424
+ ),
425
+ )
426
+ return next_config
427
+
428
+ def put_writes(
429
+ self,
430
+ config: RunnableConfig,
431
+ writes: Sequence[tuple[str, Any]],
432
+ task_id: str,
433
+ ) -> None:
434
+ """Store intermediate writes linked to a checkpoint.
435
+
436
+ This method saves intermediate writes associated with a checkpoint to the Postgres database.
437
+
438
+ Args:
439
+ config (RunnableConfig): Configuration of the related checkpoint.
440
+ writes (List[Tuple[str, Any]]): List of writes to store.
441
+ task_id (str): Identifier for the task creating the writes.
442
+ """
443
+ query = (
444
+ self.UPSERT_CHECKPOINT_WRITES_SQL
445
+ if all(w[0] in WRITES_IDX_MAP for w in writes)
446
+ else self.INSERT_CHECKPOINT_WRITES_SQL
447
+ )
448
+ with self._cursor(pipeline=True) as cur:
449
+ cur.executemany(
450
+ query,
451
+ self._dump_writes(
452
+ config["configurable"]["thread_id"],
453
+ config["configurable"]["checkpoint_ns"],
454
+ config["configurable"]["checkpoint_id"],
455
+ task_id,
456
+ writes,
457
+ ),
458
+ )
459
+
460
+ @contextmanager
461
+ def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor[DictRow]]:
462
+ """Create a database cursor as a context manager.
463
+
464
+ Args:
465
+ pipeline (bool): whether to use pipeline for the DB operations inside the context manager.
466
+ Will be applied regardless of whether the ShallowPostgresSaver instance was initialized with a pipeline.
467
+ If pipeline mode is not supported, will fall back to using transaction context manager.
468
+ """
469
+ with _internal.get_connection(self.conn) as conn:
470
+ if self.pipe:
471
+ # a connection in pipeline mode can be used concurrently
472
+ # in multiple threads/coroutines, but only one cursor can be
473
+ # used at a time
474
+ try:
475
+ with conn.cursor(binary=True, row_factory=dict_row) as cur:
476
+ yield cur
477
+ finally:
478
+ if pipeline:
479
+ self.pipe.sync()
480
+ elif pipeline:
481
+ # a connection not in pipeline mode can only be used by one
482
+ # thread/coroutine at a time, so we acquire a lock
483
+ if self.supports_pipeline:
484
+ with (
485
+ self.lock,
486
+ conn.pipeline(),
487
+ conn.cursor(binary=True, row_factory=dict_row) as cur,
488
+ ):
489
+ yield cur
490
+ else:
491
+ # Use connection's transaction context manager when pipeline mode not supported
492
+ with (
493
+ self.lock,
494
+ conn.transaction(),
495
+ conn.cursor(binary=True, row_factory=dict_row) as cur,
496
+ ):
497
+ yield cur
498
+ else:
499
+ with self.lock, conn.cursor(binary=True, row_factory=dict_row) as cur:
500
+ yield cur
501
+
502
+
503
+ class AsyncShallowPostgresSaver(BasePostgresSaver):
504
+ """A checkpoint saver that uses Postgres to store checkpoints asynchronously.
505
+
506
+ This checkpointer ONLY stores the most recent checkpoint and does NOT retain any history.
507
+ It is meant to be a light-weight drop-in replacement for the AsyncPostgresSaver that
508
+ supports most of the LangGraph persistence functionality with the exception of time travel.
509
+ """
510
+
511
+ SELECT_SQL = SELECT_SQL
512
+ MIGRATIONS = MIGRATIONS
513
+ UPSERT_CHECKPOINT_BLOBS_SQL = UPSERT_CHECKPOINT_BLOBS_SQL
514
+ UPSERT_CHECKPOINTS_SQL = UPSERT_CHECKPOINTS_SQL
515
+ UPSERT_CHECKPOINT_WRITES_SQL = UPSERT_CHECKPOINT_WRITES_SQL
516
+ INSERT_CHECKPOINT_WRITES_SQL = INSERT_CHECKPOINT_WRITES_SQL
517
+ lock: asyncio.Lock
518
+
519
+ def __init__(
520
+ self,
521
+ conn: _ainternal.Conn,
522
+ pipe: Optional[AsyncPipeline] = None,
523
+ serde: Optional[SerializerProtocol] = None,
524
+ ) -> None:
525
+ super().__init__(serde=serde)
526
+ if isinstance(conn, AsyncConnectionPool) and pipe is not None:
527
+ raise ValueError(
528
+ "Pipeline should be used only with a single AsyncConnection, not AsyncConnectionPool."
529
+ )
530
+
531
+ self.conn = conn
532
+ self.pipe = pipe
533
+ self.lock = asyncio.Lock()
534
+ self.loop = asyncio.get_running_loop()
535
+ self.supports_pipeline = Capabilities().has_pipeline()
536
+
537
+ @classmethod
538
+ @asynccontextmanager
539
+ async def from_conn_string(
540
+ cls,
541
+ conn_string: str,
542
+ *,
543
+ pipeline: bool = False,
544
+ serde: Optional[SerializerProtocol] = None,
545
+ ) -> AsyncIterator["AsyncShallowPostgresSaver"]:
546
+ """Create a new AsyncShallowPostgresSaver instance from a connection string.
547
+
548
+ Args:
549
+ conn_string (str): The Postgres connection info string.
550
+ pipeline (bool): whether to use AsyncPipeline
551
+
552
+ Returns:
553
+ AsyncShallowPostgresSaver: A new AsyncShallowPostgresSaver instance.
554
+ """
555
+ async with await AsyncConnection.connect(
556
+ conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
557
+ ) as conn:
558
+ if pipeline:
559
+ async with conn.pipeline() as pipe:
560
+ yield cls(conn=conn, pipe=pipe, serde=serde)
561
+ else:
562
+ yield cls(conn=conn, serde=serde)
563
+
564
+ async def setup(self) -> None:
565
+ """Set up the checkpoint database asynchronously.
566
+
567
+ This method creates the necessary tables in the Postgres database if they don't
568
+ already exist and runs database migrations. It MUST be called directly by the user
569
+ the first time checkpointer is used.
570
+ """
571
+ async with self._cursor() as cur:
572
+ await cur.execute(self.MIGRATIONS[0])
573
+ results = await cur.execute(
574
+ "SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1"
575
+ )
576
+ row = await results.fetchone()
577
+ if row is None:
578
+ version = -1
579
+ else:
580
+ version = row["v"]
581
+ for v, migration in zip(
582
+ range(version + 1, len(self.MIGRATIONS)),
583
+ self.MIGRATIONS[version + 1 :],
584
+ ):
585
+ await cur.execute(migration)
586
+ await cur.execute(f"INSERT INTO checkpoint_migrations (v) VALUES ({v})")
587
+ if self.pipe:
588
+ await self.pipe.sync()
589
+
590
+ async def alist(
591
+ self,
592
+ config: Optional[RunnableConfig],
593
+ *,
594
+ filter: Optional[dict[str, Any]] = None,
595
+ before: Optional[RunnableConfig] = None,
596
+ limit: Optional[int] = None,
597
+ ) -> AsyncIterator[CheckpointTuple]:
598
+ """List checkpoints from the database asynchronously.
599
+
600
+ This method retrieves a list of checkpoint tuples from the Postgres database based
601
+ on the provided config. For ShallowPostgresSaver, this method returns a list with
602
+ ONLY the most recent checkpoint.
603
+ """
604
+ where, args = self._search_where(config, filter, before)
605
+ query = self.SELECT_SQL + where
606
+ if limit:
607
+ query += f" LIMIT {limit}"
608
+ async with self._cursor() as cur:
609
+ await cur.execute(self.SELECT_SQL + where, args, binary=True)
610
+ async for value in cur:
611
+ checkpoint = await asyncio.to_thread(
612
+ self._load_checkpoint,
613
+ value["checkpoint"],
614
+ value["channel_values"],
615
+ value["pending_sends"],
616
+ )
617
+ yield CheckpointTuple(
618
+ config={
619
+ "configurable": {
620
+ "thread_id": value["thread_id"],
621
+ "checkpoint_ns": value["checkpoint_ns"],
622
+ "checkpoint_id": checkpoint["id"],
623
+ }
624
+ },
625
+ checkpoint=checkpoint,
626
+ metadata=self._load_metadata(value["metadata"]),
627
+ pending_writes=await asyncio.to_thread(
628
+ self._load_writes, value["pending_writes"]
629
+ ),
630
+ )
631
+
632
+ async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
633
+ """Get a checkpoint tuple from the database asynchronously.
634
+
635
+ This method retrieves a checkpoint tuple from the Postgres database based on the
636
+ provided config (matching the thread ID in the config).
637
+
638
+ Args:
639
+ config (RunnableConfig): The config to use for retrieving the checkpoint.
640
+
641
+ Returns:
642
+ Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
643
+ """
644
+ thread_id = config["configurable"]["thread_id"]
645
+ checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
646
+ args = (thread_id, checkpoint_ns)
647
+ where = "WHERE thread_id = %s AND checkpoint_ns = %s"
648
+
649
+ async with self._cursor() as cur:
650
+ await cur.execute(
651
+ self.SELECT_SQL + where,
652
+ args,
653
+ binary=True,
654
+ )
655
+
656
+ async for value in cur:
657
+ checkpoint = await asyncio.to_thread(
658
+ self._load_checkpoint,
659
+ value["checkpoint"],
660
+ value["channel_values"],
661
+ value["pending_sends"],
662
+ )
663
+ return CheckpointTuple(
664
+ config={
665
+ "configurable": {
666
+ "thread_id": thread_id,
667
+ "checkpoint_ns": checkpoint_ns,
668
+ "checkpoint_id": checkpoint["id"],
669
+ }
670
+ },
671
+ checkpoint=checkpoint,
672
+ metadata=self._load_metadata(value["metadata"]),
673
+ pending_writes=await asyncio.to_thread(
674
+ self._load_writes, value["pending_writes"]
675
+ ),
676
+ )
677
+
678
+ async def aput(
679
+ self,
680
+ config: RunnableConfig,
681
+ checkpoint: Checkpoint,
682
+ metadata: CheckpointMetadata,
683
+ new_versions: ChannelVersions,
684
+ ) -> RunnableConfig:
685
+ """Save a checkpoint to the database asynchronously.
686
+
687
+ This method saves a checkpoint to the Postgres database. The checkpoint is associated
688
+ with the provided config. For AsyncShallowPostgresSaver, this method saves ONLY the most recent
689
+ checkpoint and overwrites a previous checkpoint, if it exists.
690
+
691
+ Args:
692
+ config (RunnableConfig): The config to associate with the checkpoint.
693
+ checkpoint (Checkpoint): The checkpoint to save.
694
+ metadata (CheckpointMetadata): Additional metadata to save with the checkpoint.
695
+ new_versions (ChannelVersions): New channel versions as of this write.
696
+
697
+ Returns:
698
+ RunnableConfig: Updated configuration after storing the checkpoint.
699
+ """
700
+ configurable = config["configurable"].copy()
701
+ thread_id = configurable.pop("thread_id")
702
+ checkpoint_ns = configurable.pop("checkpoint_ns")
703
+
704
+ copy = checkpoint.copy()
705
+ next_config = {
706
+ "configurable": {
707
+ "thread_id": thread_id,
708
+ "checkpoint_ns": checkpoint_ns,
709
+ "checkpoint_id": checkpoint["id"],
710
+ }
711
+ }
712
+
713
+ async with self._cursor(pipeline=True) as cur:
714
+ await cur.execute(
715
+ """DELETE FROM checkpoint_writes
716
+ WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id NOT IN (%s, %s)""",
717
+ (
718
+ thread_id,
719
+ checkpoint_ns,
720
+ checkpoint["id"],
721
+ configurable.get("checkpoint_id", ""),
722
+ ),
723
+ )
724
+ await cur.executemany(
725
+ self.UPSERT_CHECKPOINT_BLOBS_SQL,
726
+ _dump_blobs(
727
+ self.serde,
728
+ thread_id,
729
+ checkpoint_ns,
730
+ copy.pop("channel_values"), # type: ignore[misc]
731
+ new_versions,
732
+ ),
733
+ )
734
+ await cur.execute(
735
+ self.UPSERT_CHECKPOINTS_SQL,
736
+ (
737
+ thread_id,
738
+ checkpoint_ns,
739
+ Jsonb(self._dump_checkpoint(copy)),
740
+ self._dump_metadata(metadata),
741
+ ),
742
+ )
743
+ return next_config
744
+
745
+ async def aput_writes(
746
+ self,
747
+ config: RunnableConfig,
748
+ writes: Sequence[tuple[str, Any]],
749
+ task_id: str,
750
+ ) -> None:
751
+ """Store intermediate writes linked to a checkpoint asynchronously.
752
+
753
+ This method saves intermediate writes associated with a checkpoint to the database.
754
+
755
+ Args:
756
+ config (RunnableConfig): Configuration of the related checkpoint.
757
+ writes (Sequence[Tuple[str, Any]]): List of writes to store, each as (channel, value) pair.
758
+ task_id (str): Identifier for the task creating the writes.
759
+ """
760
+ query = (
761
+ self.UPSERT_CHECKPOINT_WRITES_SQL
762
+ if all(w[0] in WRITES_IDX_MAP for w in writes)
763
+ else self.INSERT_CHECKPOINT_WRITES_SQL
764
+ )
765
+ params = await asyncio.to_thread(
766
+ self._dump_writes,
767
+ config["configurable"]["thread_id"],
768
+ config["configurable"]["checkpoint_ns"],
769
+ config["configurable"]["checkpoint_id"],
770
+ task_id,
771
+ writes,
772
+ )
773
+ async with self._cursor(pipeline=True) as cur:
774
+ await cur.executemany(query, params)
775
+
776
+ @asynccontextmanager
777
+ async def _cursor(
778
+ self, *, pipeline: bool = False
779
+ ) -> AsyncIterator[AsyncCursor[DictRow]]:
780
+ """Create a database cursor as a context manager.
781
+
782
+ Args:
783
+ pipeline (bool): whether to use pipeline for the DB operations inside the context manager.
784
+ Will be applied regardless of whether the AsyncShallowPostgresSaver instance was initialized with a pipeline.
785
+ If pipeline mode is not supported, will fall back to using transaction context manager.
786
+ """
787
+ async with _ainternal.get_connection(self.conn) as conn:
788
+ if self.pipe:
789
+ # a connection in pipeline mode can be used concurrently
790
+ # in multiple threads/coroutines, but only one cursor can be
791
+ # used at a time
792
+ try:
793
+ async with conn.cursor(binary=True, row_factory=dict_row) as cur:
794
+ yield cur
795
+ finally:
796
+ if pipeline:
797
+ await self.pipe.sync()
798
+ elif pipeline:
799
+ # a connection not in pipeline mode can only be used by one
800
+ # thread/coroutine at a time, so we acquire a lock
801
+ if self.supports_pipeline:
802
+ async with (
803
+ self.lock,
804
+ conn.pipeline(),
805
+ conn.cursor(binary=True, row_factory=dict_row) as cur,
806
+ ):
807
+ yield cur
808
+ else:
809
+ # Use connection's transaction context manager when pipeline mode not supported
810
+ async with (
811
+ self.lock,
812
+ conn.transaction(),
813
+ conn.cursor(binary=True, row_factory=dict_row) as cur,
814
+ ):
815
+ yield cur
816
+ else:
817
+ async with (
818
+ self.lock,
819
+ conn.cursor(binary=True, row_factory=dict_row) as cur,
820
+ ):
821
+ yield cur
822
+
823
+ def list(
824
+ self,
825
+ config: Optional[RunnableConfig],
826
+ *,
827
+ filter: Optional[dict[str, Any]] = None,
828
+ before: Optional[RunnableConfig] = None,
829
+ limit: Optional[int] = None,
830
+ ) -> Iterator[CheckpointTuple]:
831
+ """List checkpoints from the database.
832
+
833
+ This method retrieves a list of checkpoint tuples from the Postgres database based
834
+ on the provided config. For ShallowPostgresSaver, this method returns a list with
835
+ ONLY the most recent checkpoint.
836
+ """
837
+ aiter_ = self.alist(config, filter=filter, before=before, limit=limit)
838
+ while True:
839
+ try:
840
+ yield asyncio.run_coroutine_threadsafe(
841
+ anext(aiter_), # noqa: F821
842
+ self.loop,
843
+ ).result()
844
+ except StopAsyncIteration:
845
+ break
846
+
847
+ def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
848
+ """Get a checkpoint tuple from the database.
849
+
850
+ This method retrieves a checkpoint tuple from the Postgres database based on the
851
+ provided config (matching the thread ID in the config).
852
+
853
+ Args:
854
+ config (RunnableConfig): The config to use for retrieving the checkpoint.
855
+
856
+ Returns:
857
+ Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
858
+ """
859
+ try:
860
+ # check if we are in the main thread, only bg threads can block
861
+ # we don't check in other methods to avoid the overhead
862
+ if asyncio.get_running_loop() is self.loop:
863
+ raise asyncio.InvalidStateError(
864
+ "Synchronous calls to AsyncShallowPostgresSaver are only allowed from a "
865
+ "different thread. From the main thread, use the async interface."
866
+ "For example, use `await checkpointer.aget_tuple(...)` or `await "
867
+ "graph.ainvoke(...)`."
868
+ )
869
+ except RuntimeError:
870
+ pass
871
+ return asyncio.run_coroutine_threadsafe(
872
+ self.aget_tuple(config), self.loop
873
+ ).result()
874
+
875
+ def put(
876
+ self,
877
+ config: RunnableConfig,
878
+ checkpoint: Checkpoint,
879
+ metadata: CheckpointMetadata,
880
+ new_versions: ChannelVersions,
881
+ ) -> RunnableConfig:
882
+ """Save a checkpoint to the database.
883
+
884
+ This method saves a checkpoint to the Postgres database. The checkpoint is associated
885
+ with the provided config. For AsyncShallowPostgresSaver, this method saves ONLY the most recent
886
+ checkpoint and overwrites a previous checkpoint, if it exists.
887
+
888
+ Args:
889
+ config (RunnableConfig): The config to associate with the checkpoint.
890
+ checkpoint (Checkpoint): The checkpoint to save.
891
+ metadata (CheckpointMetadata): Additional metadata to save with the checkpoint.
892
+ new_versions (ChannelVersions): New channel versions as of this write.
893
+
894
+ Returns:
895
+ RunnableConfig: Updated configuration after storing the checkpoint.
896
+ """
897
+ return asyncio.run_coroutine_threadsafe(
898
+ self.aput(config, checkpoint, metadata, new_versions), self.loop
899
+ ).result()
900
+
901
+ def put_writes(
902
+ self,
903
+ config: RunnableConfig,
904
+ writes: Sequence[tuple[str, Any]],
905
+ task_id: str,
906
+ ) -> None:
907
+ """Store intermediate writes linked to a checkpoint.
908
+
909
+ This method saves intermediate writes associated with a checkpoint to the database.
910
+
911
+ Args:
912
+ config (RunnableConfig): Configuration of the related checkpoint.
913
+ writes (Sequence[Tuple[str, Any]]): List of writes to store, each as (channel, value) pair.
914
+ task_id (str): Identifier for the task creating the writes.
915
+ """
916
+ return asyncio.run_coroutine_threadsafe(
917
+ self.aput_writes(config, writes, task_id), self.loop
918
+ ).result()
@@ -6,7 +6,6 @@ from typing import Any, Callable, Optional, Union, cast
6
6
 
7
7
  import orjson
8
8
  from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities
9
- from psycopg.errors import UndefinedTable
10
9
  from psycopg.rows import DictRow, dict_row
11
10
  from psycopg_pool import AsyncConnectionPool
12
11
 
@@ -37,6 +36,75 @@ logger = logging.getLogger(__name__)
37
36
 
38
37
 
39
38
  class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Conn]):
39
+ """Asynchronous Postgres-backed store with optional vector search using pgvector.
40
+
41
+ !!! example "Examples"
42
+ Basic setup and key-value storage:
43
+ ```python
44
+ from langgraph.store.postgres import AsyncPostgresStore
45
+
46
+ async with AsyncPostgresStore.from_conn_string(
47
+ "postgresql://user:pass@localhost:5432/dbname"
48
+ ) as store:
49
+ await store.setup()
50
+
51
+ # Store and retrieve data
52
+ await store.aput(("users", "123"), "prefs", {"theme": "dark"})
53
+ item = await store.aget(("users", "123"), "prefs")
54
+ ```
55
+
56
+ Vector search using LangChain embeddings:
57
+ ```python
58
+ from langchain.embeddings import init_embeddings
59
+ from langgraph.store.postgres import AsyncPostgresStore
60
+
61
+ async with AsyncPostgresStore.from_conn_string(
62
+ "postgresql://user:pass@localhost:5432/dbname",
63
+ index={
64
+ "dims": 1536,
65
+ "embed": init_embeddings("openai:text-embedding-3-small"),
66
+ "fields": ["text"] # specify which fields to embed. Default is the whole serialized value
67
+ }
68
+ ) as store:
69
+ await store.setup() # Do this once to run migrations
70
+
71
+ # Store documents
72
+ await store.aput(("docs",), "doc1", {"text": "Python tutorial"})
73
+ await store.aput(("docs",), "doc2", {"text": "TypeScript guide"})
74
+ # Don't index the following
75
+ await store.aput(("docs",), "doc3", {"text": "Other guide"}, index=False)
76
+
77
+ # Search by similarity
78
+ results = await store.asearch(("docs",), query="python programming")
79
+ ```
80
+
81
+ Using connection pooling for better performance:
82
+ ```python
83
+ from langgraph.store.postgres import AsyncPostgresStore, PoolConfig
84
+
85
+ async with AsyncPostgresStore.from_conn_string(
86
+ "postgresql://user:pass@localhost:5432/dbname",
87
+ pool_config=PoolConfig(
88
+ min_size=5,
89
+ max_size=20
90
+ )
91
+ ) as store:
92
+ await store.setup()
93
+ # Use store with connection pooling...
94
+ ```
95
+
96
+ Warning:
97
+ Make sure to:
98
+ 1. Call `setup()` before first use to create necessary tables and indexes
99
+ 2. Have the pgvector extension available to use vector search
100
+ 3. Use Python 3.10+ for async functionality
101
+
102
+ Note:
103
+ Semantic search is disabled by default. You can enable it by providing an `index` configuration
104
+ when creating the store. Without this configuration, all `index` arguments passed to
105
+ `put` or `aput`will have no effect.
106
+ """
107
+
40
108
  __slots__ = (
41
109
  "_deserializer",
42
110
  "pipe",
@@ -87,9 +155,6 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
87
155
 
88
156
  return results
89
157
 
90
- def batch(self, ops: Iterable[Op]) -> list[Result]:
91
- return asyncio.run_coroutine_threadsafe(self.abatch(ops), self.loop).result()
92
-
93
158
  @classmethod
94
159
  @asynccontextmanager
95
160
  async def from_conn_string(
@@ -150,22 +215,19 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
150
215
  """
151
216
 
152
217
  async def _get_version(cur: AsyncCursor[DictRow], table: str) -> int:
153
- try:
154
- await cur.execute(f"SELECT v FROM {table} ORDER BY v DESC LIMIT 1")
155
- row = await cur.fetchone()
156
- if row is None:
157
- version = -1
158
- else:
159
- version = row["v"]
160
- except UndefinedTable:
161
- version = -1
162
- await cur.execute(
163
- f"""
164
- CREATE TABLE IF NOT EXISTS {table} (
165
- v INTEGER PRIMARY KEY
166
- )
167
- """
218
+ await cur.execute(
219
+ f"""
220
+ CREATE TABLE IF NOT EXISTS {table} (
221
+ v INTEGER PRIMARY KEY
168
222
  )
223
+ """
224
+ )
225
+ await cur.execute(f"SELECT v FROM {table} ORDER BY v DESC LIMIT 1")
226
+ row = cast(dict, await cur.fetchone())
227
+ if row is None:
228
+ version = -1
229
+ else:
230
+ version = row["v"]
169
231
  return version
170
232
 
171
233
  async with self._cursor() as cur:
@@ -21,7 +21,6 @@ from typing import (
21
21
 
22
22
  import orjson
23
23
  from psycopg import Capabilities, Connection, Cursor, Pipeline
24
- from psycopg.errors import UndefinedTable
25
24
  from psycopg.rows import DictRow, dict_row
26
25
  from psycopg.types.json import Jsonb
27
26
  from psycopg_pool import ConnectionPool
@@ -73,7 +72,7 @@ CREATE TABLE IF NOT EXISTS store (
73
72
  """,
74
73
  """
75
74
  -- For faster lookups by prefix
76
- CREATE INDEX IF NOT EXISTS store_prefix_idx ON store USING btree (prefix text_pattern_ops);
75
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS store_prefix_idx ON store USING btree (prefix text_pattern_ops);
77
76
  """,
78
77
  ]
79
78
 
@@ -107,7 +106,7 @@ CREATE TABLE IF NOT EXISTS store_vectors (
107
106
  ),
108
107
  Migration(
109
108
  """
110
- CREATE INDEX IF NOT EXISTS store_vectors_embedding_idx ON store_vectors
109
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS store_vectors_embedding_idx ON store_vectors
111
110
  USING %(index_type)s (embedding %(ops)s)%(index_params)s;
112
111
  """,
113
112
  condition=lambda store: bool(
@@ -534,6 +533,58 @@ class BasePostgresStore(Generic[C]):
534
533
 
535
534
 
536
535
  class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
536
+ """Postgres-backed store with optional vector search using pgvector.
537
+
538
+ !!! example "Examples"
539
+ Basic setup and key-value storage:
540
+ ```python
541
+ from langgraph.store.postgres import PostgresStore
542
+
543
+ store = PostgresStore(
544
+ connection_string="postgresql://user:pass@localhost:5432/dbname"
545
+ )
546
+ store.setup()
547
+
548
+ # Store and retrieve data
549
+ store.put(("users", "123"), "prefs", {"theme": "dark"})
550
+ item = store.get(("users", "123"), "prefs")
551
+ ```
552
+
553
+ Vector search using LangChain embeddings:
554
+ ```python
555
+ from langchain.embeddings import init_embeddings
556
+ from langgraph.store.postgres import PostgresStore
557
+
558
+ store = PostgresStore(
559
+ connection_string="postgresql://user:pass@localhost:5432/dbname",
560
+ index={
561
+ "dims": 1536,
562
+ "embed": init_embeddings("openai:text-embedding-3-small"),
563
+ "fields": ["text"] # specify which fields to embed. Default is the whole serialized value
564
+ }
565
+ )
566
+ store.setup() # Do this once to run migrations
567
+
568
+ # Store documents
569
+ store.put(("docs",), "doc1", {"text": "Python tutorial"})
570
+ store.put(("docs",), "doc2", {"text": "TypeScript guide"})
571
+ store.put(("docs",), "doc2", {"text": "Other guide"}, index=False) # don't index
572
+
573
+ # Search by similarity
574
+ results = store.search(("docs",), query="python programming")
575
+ ```
576
+
577
+ Note:
578
+ Semantic search is disabled by default. You can enable it by providing an `index` configuration
579
+ when creating the store. Without this configuration, all `index` arguments passed to
580
+ `put` or `aput`will have no effect.
581
+
582
+ Warning:
583
+ Make sure to call `setup()` before first use to create necessary tables and indexes.
584
+ The pgvector extension must be available to use vector search.
585
+
586
+ """
587
+
537
588
  __slots__ = (
538
589
  "_deserializer",
539
590
  "pipe",
@@ -795,22 +846,19 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
795
846
  """
796
847
 
797
848
  def _get_version(cur: Cursor[dict[str, Any]], table: str) -> int:
798
- try:
799
- cur.execute(f"SELECT v FROM {table} ORDER BY v DESC LIMIT 1")
800
- row = cast(dict, cur.fetchone())
801
- if row is None:
802
- version = -1
803
- else:
804
- version = row["v"]
805
- except UndefinedTable:
806
- version = -1
807
- cur.execute(
808
- f"""
809
- CREATE TABLE IF NOT EXISTS {table} (
810
- v INTEGER PRIMARY KEY
811
- )
812
- """
849
+ cur.execute(
850
+ f"""
851
+ CREATE TABLE IF NOT EXISTS {table} (
852
+ v INTEGER PRIMARY KEY
813
853
  )
854
+ """
855
+ )
856
+ cur.execute(f"SELECT v FROM {table} ORDER BY v DESC LIMIT 1")
857
+ row = cast(dict, cur.fetchone())
858
+ if row is None:
859
+ version = -1
860
+ else:
861
+ version = row["v"]
814
862
  return version
815
863
 
816
864
  with self._cursor() as cur:
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "langgraph-checkpoint-postgres"
3
- version = "2.0.7"
3
+ version = "2.0.9"
4
4
  description = "Library with a Postgres implementation of LangGraph checkpoint saver."
5
5
  authors = []
6
6
  license = "MIT"