langgraph-checkpoint-postgres 1.0.0__tar.gz → 1.0.2__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.0
3
+ Version: 1.0.2
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
@@ -23,6 +23,9 @@ Implementation of LangGraph CheckpointSaver that uses Postgres.
23
23
 
24
24
  ## Usage
25
25
 
26
+ > [!IMPORTANT]
27
+ > When using Postgres checkpointers for the first time, make sure to call `.setup()` method on them to create required tables. See example below.
28
+
26
29
  ```python
27
30
  from langgraph.checkpoint.postgres import PostgresSaver
28
31
 
@@ -31,6 +34,8 @@ read_config = {"configurable": {"thread_id": "1"}}
31
34
 
32
35
  DB_URI = "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"
33
36
  with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
37
+ # call .setup() the first time you're using the checkpointer
38
+ checkpointer.setup()
34
39
  checkpoint = {
35
40
  "v": 1,
36
41
  "ts": "2024-07-31T20:14:19.804150+00:00",
@@ -4,6 +4,9 @@ Implementation of LangGraph CheckpointSaver that uses Postgres.
4
4
 
5
5
  ## Usage
6
6
 
7
+ > [!IMPORTANT]
8
+ > When using Postgres checkpointers for the first time, make sure to call `.setup()` method on them to create required tables. See example below.
9
+
7
10
  ```python
8
11
  from langgraph.checkpoint.postgres import PostgresSaver
9
12
 
@@ -12,6 +15,8 @@ read_config = {"configurable": {"thread_id": "1"}}
12
15
 
13
16
  DB_URI = "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"
14
17
  with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
18
+ # call .setup() the first time you're using the checkpointer
19
+ checkpointer.setup()
15
20
  checkpoint = {
16
21
  "v": 1,
17
22
  "ts": "2024-07-31T20:14:19.804150+00:00",
@@ -4,6 +4,7 @@ from typing import Any, Iterator, List, Optional
4
4
 
5
5
  from langchain_core.runnables import RunnableConfig
6
6
  from psycopg import Connection, Cursor, Pipeline
7
+ from psycopg.errors import UndefinedTable
7
8
  from psycopg.rows import dict_row
8
9
  from psycopg.types.json import Jsonb
9
10
 
@@ -23,8 +24,6 @@ from langgraph.checkpoint.serde.base import SerializerProtocol
23
24
  class PostgresSaver(BasePostgresSaver):
24
25
  lock: threading.Lock
25
26
 
26
- is_setup: bool
27
-
28
27
  def __init__(
29
28
  self,
30
29
  conn: Connection,
@@ -35,7 +34,6 @@ class PostgresSaver(BasePostgresSaver):
35
34
  self.conn = conn
36
35
  self.pipe = pipe
37
36
  self.lock = threading.Lock()
38
- self.is_setup = False
39
37
 
40
38
  @classmethod
41
39
  @contextmanager
@@ -63,27 +61,27 @@ class PostgresSaver(BasePostgresSaver):
63
61
  def setup(self) -> None:
64
62
  """Set up the checkpoint database asynchronously.
65
63
 
66
- This method creates the necessary tables in the SQLite database if they don't
67
- already exist. It is called automatically when needed and should not be called
68
- directly by the user.
64
+ This method creates the necessary tables in the Postgres database if they don't
65
+ already exist and runs database migrations. It MUST be called directly by the user
66
+ the first time checkpointer is used.
69
67
  """
70
- if self.is_setup:
71
- return
72
68
  with self.lock:
73
- create_table_queries = [
74
- self.CREATE_CHECKPOINTS_SQL,
75
- self.CREATE_CHECKPOINT_BLOBS_SQL,
76
- self.CREATE_CHECKPOINT_WRITES_SQL,
77
- ]
78
69
  with self.conn.cursor(binary=True) as cur:
79
- for query in create_table_queries:
80
- cur.execute(query)
81
-
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
82
  if self.pipe:
83
83
  self.pipe.sync()
84
84
 
85
- self.is_setup = True
86
-
87
85
  def list(
88
86
  self,
89
87
  config: Optional[RunnableConfig],
@@ -92,7 +90,38 @@ class PostgresSaver(BasePostgresSaver):
92
90
  before: Optional[RunnableConfig] = None,
93
91
  limit: Optional[int] = None,
94
92
  ) -> Iterator[CheckpointTuple]:
95
- self.setup()
93
+ """List checkpoints from the database.
94
+
95
+ This method retrieves a list of checkpoint tuples from the Postgres database based
96
+ on the provided config. The checkpoints are ordered by checkpoint ID in descending order (newest first).
97
+
98
+ Args:
99
+ config (RunnableConfig): The config to use for listing the checkpoints.
100
+ filter (Optional[Dict[str, Any]]): Additional filtering criteria for metadata. Defaults to None.
101
+ before (Optional[RunnableConfig]): If provided, only checkpoints before the specified checkpoint ID are returned. Defaults to None.
102
+ limit (Optional[int]): The maximum number of checkpoints to return. Defaults to None.
103
+
104
+ Yields:
105
+ Iterator[CheckpointTuple]: An iterator of checkpoint tuples.
106
+
107
+ Examples:
108
+ >>> from langgraph.checkpoint.postgres import PostgresSaver
109
+ >>> DB_URI = "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"
110
+ >>> with PostgresSaver.from_conn_string(DB_URI) as memory:
111
+ ... # Run a graph, then list the checkpoints
112
+ >>> config = {"configurable": {"thread_id": "1"}}
113
+ >>> checkpoints = list(memory.list(config, limit=2))
114
+ >>> print(checkpoints)
115
+ [CheckpointTuple(...), CheckpointTuple(...)]
116
+
117
+ >>> config = {"configurable": {"thread_id": "1"}}
118
+ >>> before = {"configurable": {"checkpoint_id": "1ef4f797-8335-6428-8001-8a1503f9b875"}}
119
+ >>> with PostgresSaver.from_conn_string(DB_URI) as memory:
120
+ ... # Run a graph, then list the checkpoints
121
+ >>> checkpoints = list(memory.list(config, before=before))
122
+ >>> print(checkpoints)
123
+ [CheckpointTuple(...), ...]
124
+ """
96
125
  where, args = self._search_where(config, filter, before)
97
126
  query = self.SELECT_SQL + where + " ORDER BY checkpoint_id DESC"
98
127
  if limit:
@@ -111,7 +140,7 @@ class PostgresSaver(BasePostgresSaver):
111
140
  **self._load_checkpoint(value["checkpoint"]),
112
141
  "channel_values": self._load_blobs(value["channel_values"]),
113
142
  },
114
- value["metadata"],
143
+ self._load_metadata(value["metadata"]),
115
144
  {
116
145
  "configurable": {
117
146
  "thread_id": value["thread_id"],
@@ -124,7 +153,40 @@ class PostgresSaver(BasePostgresSaver):
124
153
  )
125
154
 
126
155
  def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
127
- self.setup()
156
+ """Get a checkpoint tuple from the database.
157
+
158
+ This method retrieves a checkpoint tuple from the Postgres database based on the
159
+ provided config. If the config contains a "checkpoint_id" key, the checkpoint with
160
+ the matching thread ID and timestamp is retrieved. Otherwise, the latest checkpoint
161
+ for the given thread ID is retrieved.
162
+
163
+ Args:
164
+ config (RunnableConfig): The config to use for retrieving the checkpoint.
165
+
166
+ Returns:
167
+ Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
168
+
169
+ Examples:
170
+
171
+ Basic:
172
+ >>> config = {"configurable": {"thread_id": "1"}}
173
+ >>> checkpoint_tuple = memory.get_tuple(config)
174
+ >>> print(checkpoint_tuple)
175
+ CheckpointTuple(...)
176
+
177
+ With timestamp:
178
+
179
+ >>> config = {
180
+ ... "configurable": {
181
+ ... "thread_id": "1",
182
+ ... "checkpoint_ns": "",
183
+ ... "checkpoint_id": "1ef4f797-8335-6428-8001-8a1503f9b875",
184
+ ... }
185
+ ... }
186
+ >>> checkpoint_tuple = memory.get_tuple(config)
187
+ >>> print(checkpoint_tuple)
188
+ CheckpointTuple(...)
189
+ """ # noqa
128
190
  thread_id = config["configurable"]["thread_id"]
129
191
  checkpoint_id = get_checkpoint_id(config)
130
192
  checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
@@ -155,7 +217,7 @@ class PostgresSaver(BasePostgresSaver):
155
217
  **self._load_checkpoint(value["checkpoint"]),
156
218
  "channel_values": self._load_blobs(value["channel_values"]),
157
219
  },
158
- value["metadata"],
220
+ self._load_metadata(value["metadata"]),
159
221
  {
160
222
  "configurable": {
161
223
  "thread_id": thread_id,
@@ -175,6 +237,31 @@ class PostgresSaver(BasePostgresSaver):
175
237
  metadata: CheckpointMetadata,
176
238
  new_versions: ChannelVersions,
177
239
  ) -> RunnableConfig:
240
+ """Save a checkpoint to the database.
241
+
242
+ This method saves a checkpoint to the Postgres database. The checkpoint is associated
243
+ with the provided config and its parent config (if any).
244
+
245
+ Args:
246
+ config (RunnableConfig): The config to associate with the checkpoint.
247
+ checkpoint (Checkpoint): The checkpoint to save.
248
+ metadata (CheckpointMetadata): Additional metadata to save with the checkpoint.
249
+ new_versions (ChannelVersions): New channel versions as of this write.
250
+
251
+ Returns:
252
+ RunnableConfig: Updated configuration after storing the checkpoint.
253
+
254
+ Examples:
255
+
256
+ >>> from langgraph.checkpoint.postgres import PostgresSaver
257
+ >>> DB_URI = "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"
258
+ >>> with PostgresSaver.from_conn_string(DB_URI) as memory:
259
+ >>> config = {"configurable": {"thread_id": "1", "checkpoint_ns": ""}}
260
+ >>> checkpoint = {"ts": "2024-05-04T06:32:42.235444+00:00", "id": "1ef4f797-8335-6428-8001-8a1503f9b875", "data": {"key": "value"}}
261
+ >>> saved_config = memory.put(config, checkpoint, {"source": "input", "step": 1, "writes": {"key": "value"}}, {})
262
+ >>> print(saved_config)
263
+ {'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '1ef4f797-8335-6428-8001-8a1503f9b875'}}
264
+ """
178
265
  configurable = config["configurable"].copy()
179
266
  thread_id = configurable.pop("thread_id")
180
267
  checkpoint_ns = configurable.pop("checkpoint_ns")
@@ -198,7 +285,6 @@ class PostgresSaver(BasePostgresSaver):
198
285
  thread_id,
199
286
  checkpoint_ns,
200
287
  copy.pop("channel_values"),
201
- copy["channel_versions"],
202
288
  new_versions,
203
289
  ),
204
290
  )
@@ -210,7 +296,7 @@ class PostgresSaver(BasePostgresSaver):
210
296
  checkpoint["id"],
211
297
  checkpoint_id,
212
298
  Jsonb(self._dump_checkpoint(copy)),
213
- Jsonb(metadata),
299
+ self._dump_metadata(metadata),
214
300
  ),
215
301
  )
216
302
  return next_config
@@ -221,6 +307,15 @@ class PostgresSaver(BasePostgresSaver):
221
307
  writes: List[tuple[str, Any]],
222
308
  task_id: str,
223
309
  ) -> None:
310
+ """Store intermediate writes linked to a checkpoint.
311
+
312
+ This method saves intermediate writes associated with a checkpoint to the Postgres database.
313
+
314
+ Args:
315
+ config (RunnableConfig): Configuration of the related checkpoint.
316
+ writes (List[Tuple[str, Any]]): List of writes to store.
317
+ task_id (str): Identifier for the task creating the writes.
318
+ """
224
319
  with self._cursor() as cur:
225
320
  cur.executemany(
226
321
  self.UPSERT_CHECKPOINT_WRITES_SQL,
@@ -235,7 +330,6 @@ class PostgresSaver(BasePostgresSaver):
235
330
 
236
331
  @contextmanager
237
332
  def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor]:
238
- self.setup()
239
333
  if self.pipe:
240
334
  # a connection in pipeline mode can be used concurrently
241
335
  # in multiple threads/coroutines, but only one cursor can be
@@ -244,7 +338,8 @@ class PostgresSaver(BasePostgresSaver):
244
338
  with self.conn.cursor(binary=True) as cur:
245
339
  yield cur
246
340
  finally:
247
- self.pipe.sync()
341
+ if pipeline:
342
+ self.pipe.sync()
248
343
  elif pipeline:
249
344
  # a connection not in pipeline mode can only be used by one
250
345
  # thread/coroutine at a time, so we acquire a lock
@@ -4,6 +4,7 @@ from typing import Any, AsyncIterator, Optional
4
4
 
5
5
  from langchain_core.runnables import RunnableConfig
6
6
  from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline
7
+ from psycopg.errors import UndefinedTable
7
8
  from psycopg.rows import dict_row
8
9
  from psycopg.types.json import Jsonb
9
10
 
@@ -21,8 +22,6 @@ from langgraph.checkpoint.serde.base import SerializerProtocol
21
22
  class AsyncPostgresSaver(BasePostgresSaver):
22
23
  lock: asyncio.Lock
23
24
 
24
- is_setup: bool
25
-
26
25
  def __init__(
27
26
  self,
28
27
  conn: AsyncConnection,
@@ -33,7 +32,6 @@ class AsyncPostgresSaver(BasePostgresSaver):
33
32
  self.conn = conn
34
33
  self.pipe = pipe
35
34
  self.lock = asyncio.Lock()
36
- self.is_setup = False
37
35
 
38
36
  @classmethod
39
37
  @asynccontextmanager
@@ -61,27 +59,31 @@ class AsyncPostgresSaver(BasePostgresSaver):
61
59
  async def setup(self) -> None:
62
60
  """Set up the checkpoint database asynchronously.
63
61
 
64
- This method creates the necessary tables in the SQLite database if they don't
65
- already exist. It is called automatically when needed and should not be called
66
- directly by the user.
62
+ This method creates the necessary tables in the Postgres database if they don't
63
+ already exist and runs database migrations. It MUST be called directly by the user
64
+ the first time checkpointer is used.
67
65
  """
68
- if self.is_setup:
69
- return
70
66
  async with self.lock:
71
- create_table_queries = [
72
- self.CREATE_CHECKPOINTS_SQL,
73
- self.CREATE_CHECKPOINT_BLOBS_SQL,
74
- self.CREATE_CHECKPOINT_WRITES_SQL,
75
- ]
76
- async with self.conn.cursor() as cur:
77
- for query in create_table_queries:
78
- await cur.execute(query)
79
-
67
+ async with self.conn.cursor(binary=True) as cur:
68
+ try:
69
+ version = (
70
+ await cur.execute(
71
+ "SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1"
72
+ )
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
+ await cur.execute(migration)
81
+ await cur.execute(
82
+ f"INSERT INTO checkpoint_migrations (v) VALUES ({v})"
83
+ )
80
84
  if self.pipe:
81
85
  await self.pipe.sync()
82
86
 
83
- self.is_setup = True
84
-
85
87
  async def alist(
86
88
  self,
87
89
  config: Optional[RunnableConfig],
@@ -90,7 +92,20 @@ class AsyncPostgresSaver(BasePostgresSaver):
90
92
  before: Optional[RunnableConfig] = None,
91
93
  limit: Optional[int] = None,
92
94
  ) -> AsyncIterator[CheckpointTuple]:
93
- await self.setup()
95
+ """List checkpoints from the database asynchronously.
96
+
97
+ This method retrieves a list of checkpoint tuples from the Postgres database based
98
+ on the provided config. The checkpoints are ordered by checkpoint ID in descending order (newest first).
99
+
100
+ Args:
101
+ config (Optional[RunnableConfig]): Base configuration for filtering checkpoints.
102
+ filter (Optional[Dict[str, Any]]): Additional filtering criteria for metadata.
103
+ before (Optional[RunnableConfig]): If provided, only checkpoints before the specified checkpoint ID are returned. Defaults to None.
104
+ limit (Optional[int]): Maximum number of checkpoints to return.
105
+
106
+ Yields:
107
+ AsyncIterator[CheckpointTuple]: An asynchronous iterator of matching checkpoint tuples.
108
+ """
94
109
  where, args = self._search_where(config, filter, before)
95
110
  query = self.SELECT_SQL + where + " ORDER BY checkpoint_id DESC"
96
111
  if limit:
@@ -111,7 +126,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
111
126
  self._load_blobs, value["channel_values"]
112
127
  ),
113
128
  },
114
- value["metadata"],
129
+ self._load_metadata(value["metadata"]),
115
130
  {
116
131
  "configurable": {
117
132
  "thread_id": value["thread_id"],
@@ -124,7 +139,19 @@ class AsyncPostgresSaver(BasePostgresSaver):
124
139
  )
125
140
 
126
141
  async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
127
- await self.setup()
142
+ """Get a checkpoint tuple from the database asynchronously.
143
+
144
+ This method retrieves a checkpoint tuple from the Postgres database based on the
145
+ provided config. If the config contains a "checkpoint_id" key, the checkpoint with
146
+ the matching thread ID and "checkpoint_id" is retrieved. Otherwise, the latest checkpoint
147
+ for the given thread ID is retrieved.
148
+
149
+ Args:
150
+ config (RunnableConfig): The config to use for retrieving the checkpoint.
151
+
152
+ Returns:
153
+ Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
154
+ """
128
155
  thread_id = config["configurable"]["thread_id"]
129
156
  checkpoint_id = get_checkpoint_id(config)
130
157
  checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
@@ -157,7 +184,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
157
184
  self._load_blobs, value["channel_values"]
158
185
  ),
159
186
  },
160
- value["metadata"],
187
+ self._load_metadata(value["metadata"]),
161
188
  {
162
189
  "configurable": {
163
190
  "thread_id": thread_id,
@@ -177,7 +204,20 @@ class AsyncPostgresSaver(BasePostgresSaver):
177
204
  metadata: CheckpointMetadata,
178
205
  new_versions: ChannelVersions,
179
206
  ) -> RunnableConfig:
180
- await self.setup()
207
+ """Save a checkpoint to the database asynchronously.
208
+
209
+ This method saves a checkpoint to the Postgres database. The checkpoint is associated
210
+ with the provided config and its parent config (if any).
211
+
212
+ Args:
213
+ config (RunnableConfig): The config to associate with the checkpoint.
214
+ checkpoint (Checkpoint): The checkpoint to save.
215
+ metadata (CheckpointMetadata): Additional metadata to save with the checkpoint.
216
+ new_versions (ChannelVersions): New channel versions as of this write.
217
+
218
+ Returns:
219
+ RunnableConfig: Updated configuration after storing the checkpoint.
220
+ """
181
221
  configurable = config["configurable"].copy()
182
222
  thread_id = configurable.pop("thread_id")
183
223
  checkpoint_ns = configurable.pop("checkpoint_ns")
@@ -202,7 +242,6 @@ class AsyncPostgresSaver(BasePostgresSaver):
202
242
  thread_id,
203
243
  checkpoint_ns,
204
244
  copy.pop("channel_values"),
205
- copy["channel_versions"],
206
245
  new_versions,
207
246
  ),
208
247
  )
@@ -214,7 +253,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
214
253
  checkpoint["id"],
215
254
  checkpoint_id,
216
255
  Jsonb(self._dump_checkpoint(copy)),
217
- Jsonb(metadata),
256
+ self._dump_metadata(metadata),
218
257
  ),
219
258
  )
220
259
  return next_config
@@ -225,6 +264,15 @@ class AsyncPostgresSaver(BasePostgresSaver):
225
264
  writes: list[tuple[str, Any]],
226
265
  task_id: str,
227
266
  ) -> None:
267
+ """Store intermediate writes linked to a checkpoint asynchronously.
268
+
269
+ This method saves intermediate writes associated with a checkpoint to the database.
270
+
271
+ Args:
272
+ config (RunnableConfig): Configuration of the related checkpoint.
273
+ writes (Sequence[Tuple[str, Any]]): List of writes to store, each as (channel, value) pair.
274
+ task_id (str): Identifier for the task creating the writes.
275
+ """
228
276
  async with self._cursor() as cur:
229
277
  await cur.executemany(
230
278
  self.UPSERT_CHECKPOINT_WRITES_SQL,
@@ -240,7 +288,6 @@ class AsyncPostgresSaver(BasePostgresSaver):
240
288
 
241
289
  @asynccontextmanager
242
290
  async def _cursor(self, *, pipeline: bool = False) -> AsyncIterator[AsyncCursor]:
243
- await self.setup()
244
291
  if self.pipe:
245
292
  # a connection in pipeline mode can be used concurrently
246
293
  # in multiple threads/coroutines, but only one cursor can be
@@ -249,7 +296,8 @@ class AsyncPostgresSaver(BasePostgresSaver):
249
296
  async with self.conn.cursor(binary=True) as cur:
250
297
  yield cur
251
298
  finally:
252
- await self.pipe.sync()
299
+ if pipeline:
300
+ await self.pipe.sync()
253
301
  elif pipeline:
254
302
  # a connection not in pipeline mode can only be used by one
255
303
  # thread/coroutine at a time, so we acquire a lock
@@ -11,10 +11,52 @@ from langgraph.checkpoint.base import (
11
11
  EmptyChannelError,
12
12
  get_checkpoint_id,
13
13
  )
14
+ from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
14
15
  from langgraph.checkpoint.serde.types import ChannelProtocol
15
16
 
16
17
  MetadataInput = Optional[dict[str, Any]]
17
18
 
19
+ """
20
+ To add a new migration, add a new string to the MIGRATIONS list.
21
+ The position of the migration in the list is the version number.
22
+ """
23
+ MIGRATIONS = [
24
+ """CREATE TABLE IF NOT EXISTS checkpoint_migrations (
25
+ v INTEGER PRIMARY KEY
26
+ );""",
27
+ """CREATE TABLE IF NOT EXISTS checkpoints (
28
+ thread_id TEXT NOT NULL,
29
+ checkpoint_ns TEXT NOT NULL DEFAULT '',
30
+ checkpoint_id TEXT NOT NULL,
31
+ parent_checkpoint_id TEXT,
32
+ type TEXT,
33
+ checkpoint JSONB NOT NULL,
34
+ metadata JSONB NOT NULL DEFAULT '{}',
35
+ PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id)
36
+ );""",
37
+ """CREATE TABLE IF NOT EXISTS checkpoint_blobs (
38
+ thread_id TEXT NOT NULL,
39
+ checkpoint_ns TEXT NOT NULL DEFAULT '',
40
+ channel TEXT NOT NULL,
41
+ version TEXT NOT NULL,
42
+ type TEXT NOT NULL,
43
+ blob BYTEA,
44
+ PRIMARY KEY (thread_id, checkpoint_ns, channel, version)
45
+ );""",
46
+ """CREATE TABLE IF NOT EXISTS checkpoint_writes (
47
+ thread_id TEXT NOT NULL,
48
+ checkpoint_ns TEXT NOT NULL DEFAULT '',
49
+ checkpoint_id TEXT NOT NULL,
50
+ task_id TEXT NOT NULL,
51
+ idx INTEGER NOT NULL,
52
+ channel TEXT NOT NULL,
53
+ type TEXT,
54
+ blob BYTEA NOT NULL,
55
+ PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
56
+ );""",
57
+ "ALTER TABLE checkpoint_blobs ALTER COLUMN blob DROP not null;",
58
+ ]
59
+
18
60
  SELECT_SQL = """
19
61
  select
20
62
  thread_id,
@@ -42,43 +84,6 @@ select
42
84
  ) as pending_writes
43
85
  from checkpoints """
44
86
 
45
- CREATE_CHECKPOINTS_SQL = """
46
- CREATE TABLE IF NOT EXISTS checkpoints (
47
- thread_id TEXT NOT NULL,
48
- checkpoint_ns TEXT NOT NULL DEFAULT '',
49
- checkpoint_id TEXT NOT NULL,
50
- parent_checkpoint_id TEXT,
51
- type TEXT,
52
- checkpoint JSONB NOT NULL,
53
- metadata JSONB NOT NULL DEFAULT '{}',
54
- PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id)
55
- );
56
- """
57
- CREATE_CHECKPOINT_BLOBS_SQL = """
58
- CREATE TABLE IF NOT EXISTS checkpoint_blobs (
59
- thread_id TEXT NOT NULL,
60
- checkpoint_ns TEXT NOT NULL DEFAULT '',
61
- channel TEXT NOT NULL,
62
- version TEXT NOT NULL,
63
- type TEXT NOT NULL,
64
- blob BYTEA NOT NULL,
65
- PRIMARY KEY (thread_id, checkpoint_ns, channel, version)
66
- );"""
67
-
68
- CREATE_CHECKPOINT_WRITES_SQL = """
69
- CREATE TABLE IF NOT EXISTS checkpoint_writes (
70
- thread_id TEXT NOT NULL,
71
- checkpoint_ns TEXT NOT NULL DEFAULT '',
72
- checkpoint_id TEXT NOT NULL,
73
- task_id TEXT NOT NULL,
74
- idx INTEGER NOT NULL,
75
- channel TEXT NOT NULL,
76
- type TEXT,
77
- blob BYTEA NOT NULL,
78
- PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
79
- );
80
- """
81
-
82
87
  UPSERT_CHECKPOINT_BLOBS_SQL = """
83
88
  INSERT INTO checkpoint_blobs (thread_id, checkpoint_ns, channel, version, type, blob)
84
89
  VALUES (%s, %s, %s, %s, %s, %s)
@@ -103,12 +108,11 @@ UPSERT_CHECKPOINT_WRITES_SQL = """
103
108
 
104
109
  class BasePostgresSaver(BaseCheckpointSaver):
105
110
  SELECT_SQL = SELECT_SQL
106
- CREATE_CHECKPOINTS_SQL = CREATE_CHECKPOINTS_SQL
107
- CREATE_CHECKPOINT_BLOBS_SQL = CREATE_CHECKPOINT_BLOBS_SQL
108
- CREATE_CHECKPOINT_WRITES_SQL = CREATE_CHECKPOINT_WRITES_SQL
111
+ MIGRATIONS = MIGRATIONS
109
112
  UPSERT_CHECKPOINT_BLOBS_SQL = UPSERT_CHECKPOINT_BLOBS_SQL
110
113
  UPSERT_CHECKPOINTS_SQL = UPSERT_CHECKPOINTS_SQL
111
114
  UPSERT_CHECKPOINT_WRITES_SQL = UPSERT_CHECKPOINT_WRITES_SQL
115
+ jsonplus_serde = JsonPlusSerializer()
112
116
 
113
117
  def _load_checkpoint(self, checkpoint: dict[str, Any]) -> Checkpoint:
114
118
  if len(checkpoint["pending_sends"]) == 2 and all(
@@ -137,6 +141,7 @@ class BasePostgresSaver(BaseCheckpointSaver):
137
141
  return {
138
142
  k.decode(): self.serde.loads_typed((t.decode(), v))
139
143
  for k, t, v in blob_values
144
+ if t.decode() != "empty"
140
145
  }
141
146
 
142
147
  def _dump_blobs(
@@ -145,24 +150,23 @@ class BasePostgresSaver(BaseCheckpointSaver):
145
150
  checkpoint_ns: str,
146
151
  values: dict[str, Any],
147
152
  versions: dict[str, str],
148
- new_versions: Optional[dict[str, str]],
149
153
  ) -> list[tuple[str, str, str, str, str, bytes]]:
150
154
  if not versions:
151
155
  return []
152
156
 
153
- if new_versions:
154
- versions = new_versions
155
-
156
157
  return [
157
158
  (
158
159
  thread_id,
159
160
  checkpoint_ns,
160
161
  k,
161
162
  ver,
162
- *self.serde.dumps_typed(values[k]),
163
+ *(
164
+ self.serde.dumps_typed(values[k])
165
+ if k in values
166
+ else ("empty", None)
167
+ ),
163
168
  )
164
169
  for k, ver in versions.items()
165
- if k in values
166
170
  ]
167
171
 
168
172
  def _load_writes(
@@ -202,6 +206,19 @@ class BasePostgresSaver(BaseCheckpointSaver):
202
206
  for idx, (channel, value) in enumerate(writes)
203
207
  ]
204
208
 
209
+ def _load_metadata(self, metadata: dict[str, Any]) -> dict[str, Any]:
210
+ return self.jsonplus_serde.loads(self.jsonplus_serde.dumps(metadata))
211
+
212
+ def _dump_metadata(self, metadata) -> str:
213
+ serialized_metadata_type, serialized_metadata = self.jsonplus_serde.dumps_typed(
214
+ metadata
215
+ )
216
+ if serialized_metadata_type != "json":
217
+ raise TypeError(
218
+ f"Failed to properly serialize metadata -- expected 'json', got '{serialized_metadata_type}'"
219
+ )
220
+ return serialized_metadata.decode()
221
+
205
222
  def get_next_version(self, current: Optional[str], channel: ChannelProtocol) -> str:
206
223
  if current is None:
207
224
  current_v = 0
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "langgraph-checkpoint-postgres"
3
- version = "1.0.0"
3
+ version = "1.0.2"
4
4
  description = "Library with a Postgres implementation of LangGraph checkpoint saver."
5
5
  authors = []
6
6
  license = "MIT"