langgraph-checkpoint-postgres 1.0.6__tar.gz → 1.0.8__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.6
3
+ Version: 1.0.8
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
@@ -11,7 +11,7 @@ Classifier: Programming Language :: Python :: 3.9
11
11
  Classifier: Programming Language :: Python :: 3.10
12
12
  Classifier: Programming Language :: Python :: 3.11
13
13
  Classifier: Programming Language :: Python :: 3.12
14
- Requires-Dist: langgraph-checkpoint (>=1.0.8,<2.0.0)
14
+ Requires-Dist: langgraph-checkpoint (>=1.0.11,<2.0.0)
15
15
  Requires-Dist: orjson (>=3.10.1)
16
16
  Requires-Dist: psycopg (>=3.0.0,<4.0.0)
17
17
  Requires-Dist: psycopg-pool (>=3.0.0,<4.0.0)
@@ -1,15 +1,16 @@
1
1
  import threading
2
2
  from contextlib import contextmanager
3
- from typing import Any, Iterator, List, Optional, Union
3
+ from typing import Any, Iterator, Optional, Sequence, Union
4
4
 
5
5
  from langchain_core.runnables import RunnableConfig
6
6
  from psycopg import Connection, Cursor, Pipeline
7
7
  from psycopg.errors import UndefinedTable
8
- from psycopg.rows import dict_row
8
+ from psycopg.rows import DictRow, dict_row
9
9
  from psycopg.types.json import Jsonb
10
10
  from psycopg_pool import ConnectionPool
11
11
 
12
12
  from langgraph.checkpoint.base import (
13
+ WRITES_IDX_MAP,
13
14
  ChannelVersions,
14
15
  Checkpoint,
15
16
  CheckpointMetadata,
@@ -21,9 +22,11 @@ from langgraph.checkpoint.postgres.base import (
21
22
  )
22
23
  from langgraph.checkpoint.serde.base import SerializerProtocol
23
24
 
25
+ Conn = Union[Connection[DictRow], ConnectionPool[Connection[DictRow]]]
26
+
24
27
 
25
28
  @contextmanager
26
- def _get_connection(conn: Union[Connection, ConnectionPool]) -> Iterator[Connection]:
29
+ def _get_connection(conn: Conn) -> Iterator[Connection[DictRow]]:
27
30
  if isinstance(conn, Connection):
28
31
  yield conn
29
32
  elif isinstance(conn, ConnectionPool):
@@ -38,7 +41,7 @@ class PostgresSaver(BasePostgresSaver):
38
41
 
39
42
  def __init__(
40
43
  self,
41
- conn: Union[Connection, ConnectionPool],
44
+ conn: Conn,
42
45
  pipe: Optional[Pipeline] = None,
43
46
  serde: Optional[SerializerProtocol] = None,
44
47
  ) -> None:
@@ -84,9 +87,13 @@ class PostgresSaver(BasePostgresSaver):
84
87
  """
85
88
  with self._cursor() as cur:
86
89
  try:
87
- version = cur.execute(
90
+ row = cur.execute(
88
91
  "SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1"
89
- ).fetchone()["v"]
92
+ ).fetchone()
93
+ if row is None:
94
+ version = -1
95
+ else:
96
+ version = row["v"]
90
97
  except UndefinedTable:
91
98
  version = -1
92
99
  for v, migration in zip(
@@ -211,7 +218,7 @@ class PostgresSaver(BasePostgresSaver):
211
218
  checkpoint_id = get_checkpoint_id(config)
212
219
  checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
213
220
  if checkpoint_id:
214
- args = (thread_id, checkpoint_ns, checkpoint_id)
221
+ args: tuple[Any, ...] = (thread_id, checkpoint_ns, checkpoint_id)
215
222
  where = "WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id = %s"
216
223
  else:
217
224
  args = (thread_id, checkpoint_ns)
@@ -305,7 +312,7 @@ class PostgresSaver(BasePostgresSaver):
305
312
  self._dump_blobs(
306
313
  thread_id,
307
314
  checkpoint_ns,
308
- copy.pop("channel_values"),
315
+ copy.pop("channel_values"), # type: ignore[misc]
309
316
  new_versions,
310
317
  ),
311
318
  )
@@ -325,7 +332,7 @@ class PostgresSaver(BasePostgresSaver):
325
332
  def put_writes(
326
333
  self,
327
334
  config: RunnableConfig,
328
- writes: List[tuple[str, Any]],
335
+ writes: Sequence[tuple[str, Any]],
329
336
  task_id: str,
330
337
  ) -> None:
331
338
  """Store intermediate writes linked to a checkpoint.
@@ -337,9 +344,14 @@ class PostgresSaver(BasePostgresSaver):
337
344
  writes (List[Tuple[str, Any]]): List of writes to store.
338
345
  task_id (str): Identifier for the task creating the writes.
339
346
  """
347
+ query = (
348
+ self.UPSERT_CHECKPOINT_WRITES_SQL
349
+ if all(w[0] in WRITES_IDX_MAP for w in writes)
350
+ else self.INSERT_CHECKPOINT_WRITES_SQL
351
+ )
340
352
  with self._cursor(pipeline=True) as cur:
341
353
  cur.executemany(
342
- self.UPSERT_CHECKPOINT_WRITES_SQL,
354
+ query,
343
355
  self._dump_writes(
344
356
  config["configurable"]["thread_id"],
345
357
  config["configurable"]["checkpoint_ns"],
@@ -350,7 +362,7 @@ class PostgresSaver(BasePostgresSaver):
350
362
  )
351
363
 
352
364
  @contextmanager
353
- def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor]:
365
+ def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor[DictRow]]:
354
366
  with _get_connection(self.conn) as conn:
355
367
  if self.pipe:
356
368
  # a connection in pipeline mode can be used concurrently
@@ -1,15 +1,16 @@
1
1
  import asyncio
2
2
  from contextlib import asynccontextmanager
3
- from typing import Any, AsyncIterator, Iterator, List, Optional, Union
3
+ from typing import Any, AsyncIterator, Iterator, Optional, Sequence, Union
4
4
 
5
5
  from langchain_core.runnables import RunnableConfig
6
6
  from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline
7
7
  from psycopg.errors import UndefinedTable
8
- from psycopg.rows import dict_row
8
+ from psycopg.rows import DictRow, dict_row
9
9
  from psycopg.types.json import Jsonb
10
10
  from psycopg_pool import AsyncConnectionPool
11
11
 
12
12
  from langgraph.checkpoint.base import (
13
+ WRITES_IDX_MAP,
13
14
  ChannelVersions,
14
15
  Checkpoint,
15
16
  CheckpointMetadata,
@@ -19,11 +20,13 @@ from langgraph.checkpoint.base import (
19
20
  from langgraph.checkpoint.postgres.base import BasePostgresSaver
20
21
  from langgraph.checkpoint.serde.base import SerializerProtocol
21
22
 
23
+ Conn = Union[AsyncConnection[DictRow], AsyncConnectionPool[AsyncConnection[DictRow]]]
24
+
22
25
 
23
26
  @asynccontextmanager
24
27
  async def _get_connection(
25
- conn: Union[AsyncConnection, AsyncConnectionPool],
26
- ) -> AsyncIterator[AsyncConnection]:
28
+ conn: Conn,
29
+ ) -> AsyncIterator[AsyncConnection[DictRow]]:
27
30
  if isinstance(conn, AsyncConnection):
28
31
  yield conn
29
32
  elif isinstance(conn, AsyncConnectionPool):
@@ -38,7 +41,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
38
41
 
39
42
  def __init__(
40
43
  self,
41
- conn: Union[AsyncConnection, AsyncConnectionPool],
44
+ conn: Conn,
42
45
  pipe: Optional[AsyncPipeline] = None,
43
46
  serde: Optional[SerializerProtocol] = None,
44
47
  ) -> None:
@@ -56,7 +59,11 @@ class AsyncPostgresSaver(BasePostgresSaver):
56
59
  @classmethod
57
60
  @asynccontextmanager
58
61
  async def from_conn_string(
59
- cls, conn_string: str, *, pipeline: bool = False
62
+ cls,
63
+ conn_string: str,
64
+ *,
65
+ pipeline: bool = False,
66
+ serde: Optional[SerializerProtocol] = None,
60
67
  ) -> AsyncIterator["AsyncPostgresSaver"]:
61
68
  """Create a new PostgresSaver instance from a connection string.
62
69
 
@@ -72,9 +79,9 @@ class AsyncPostgresSaver(BasePostgresSaver):
72
79
  ) as conn:
73
80
  if pipeline:
74
81
  async with conn.pipeline() as pipe:
75
- yield AsyncPostgresSaver(conn, pipe)
82
+ yield AsyncPostgresSaver(conn=conn, pipe=pipe, serde=serde)
76
83
  else:
77
- yield AsyncPostgresSaver(conn)
84
+ yield AsyncPostgresSaver(conn=conn, serde=serde)
78
85
 
79
86
  async def setup(self) -> None:
80
87
  """Set up the checkpoint database asynchronously.
@@ -88,7 +95,11 @@ class AsyncPostgresSaver(BasePostgresSaver):
88
95
  results = await cur.execute(
89
96
  "SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1"
90
97
  )
91
- version = (await results.fetchone())["v"]
98
+ row = await results.fetchone()
99
+ if row is None:
100
+ version = -1
101
+ else:
102
+ version = row["v"]
92
103
  except UndefinedTable:
93
104
  version = -1
94
105
  for v, migration in zip(
@@ -175,7 +186,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
175
186
  checkpoint_id = get_checkpoint_id(config)
176
187
  checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
177
188
  if checkpoint_id:
178
- args = (thread_id, checkpoint_ns, checkpoint_id)
189
+ args: tuple[Any, ...] = (thread_id, checkpoint_ns, checkpoint_id)
179
190
  where = "WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id = %s"
180
191
  else:
181
192
  args = (thread_id, checkpoint_ns)
@@ -260,7 +271,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
260
271
  self._dump_blobs,
261
272
  thread_id,
262
273
  checkpoint_ns,
263
- copy.pop("channel_values"),
274
+ copy.pop("channel_values"), # type: ignore[misc]
264
275
  new_versions,
265
276
  ),
266
277
  )
@@ -280,7 +291,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
280
291
  async def aput_writes(
281
292
  self,
282
293
  config: RunnableConfig,
283
- writes: list[tuple[str, Any]],
294
+ writes: Sequence[tuple[str, Any]],
284
295
  task_id: str,
285
296
  ) -> None:
286
297
  """Store intermediate writes linked to a checkpoint asynchronously.
@@ -292,21 +303,26 @@ class AsyncPostgresSaver(BasePostgresSaver):
292
303
  writes (Sequence[Tuple[str, Any]]): List of writes to store, each as (channel, value) pair.
293
304
  task_id (str): Identifier for the task creating the writes.
294
305
  """
306
+ query = (
307
+ self.UPSERT_CHECKPOINT_WRITES_SQL
308
+ if all(w[0] in WRITES_IDX_MAP for w in writes)
309
+ else self.INSERT_CHECKPOINT_WRITES_SQL
310
+ )
311
+ params = await asyncio.to_thread(
312
+ self._dump_writes,
313
+ config["configurable"]["thread_id"],
314
+ config["configurable"]["checkpoint_ns"],
315
+ config["configurable"]["checkpoint_id"],
316
+ task_id,
317
+ writes,
318
+ )
295
319
  async with self._cursor(pipeline=True) as cur:
296
- await cur.executemany(
297
- self.UPSERT_CHECKPOINT_WRITES_SQL,
298
- await asyncio.to_thread(
299
- self._dump_writes,
300
- config["configurable"]["thread_id"],
301
- config["configurable"]["checkpoint_ns"],
302
- config["configurable"]["checkpoint_id"],
303
- task_id,
304
- writes,
305
- ),
306
- )
320
+ await cur.executemany(query, params)
307
321
 
308
322
  @asynccontextmanager
309
- async def _cursor(self, *, pipeline: bool = False) -> AsyncIterator[AsyncCursor]:
323
+ async def _cursor(
324
+ self, *, pipeline: bool = False
325
+ ) -> AsyncIterator[AsyncCursor[DictRow]]:
310
326
  async with _get_connection(self.conn) as conn:
311
327
  if self.pipe:
312
328
  # a connection in pipeline mode can be used concurrently
@@ -357,7 +373,8 @@ class AsyncPostgresSaver(BasePostgresSaver):
357
373
  while True:
358
374
  try:
359
375
  yield asyncio.run_coroutine_threadsafe(
360
- anext(aiter_), self.loop
376
+ anext(aiter_),
377
+ self.loop,
361
378
  ).result()
362
379
  except StopAsyncIteration:
363
380
  break
@@ -376,6 +393,18 @@ class AsyncPostgresSaver(BasePostgresSaver):
376
393
  Returns:
377
394
  Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
378
395
  """
396
+ try:
397
+ # check if we are in the main thread, only bg threads can block
398
+ # we don't check in other methods to avoid the overhead
399
+ if asyncio.get_running_loop() is self.loop:
400
+ raise asyncio.InvalidStateError(
401
+ "Synchronous calls to AsyncPostgresSaver are only allowed from a "
402
+ "different thread. From the main thread, use the async interface."
403
+ "For example, use `await checkpointer.aget_tuple(...)` or `await "
404
+ "graph.ainvoke(...)`."
405
+ )
406
+ except RuntimeError:
407
+ pass
379
408
  return asyncio.run_coroutine_threadsafe(
380
409
  self.aget_tuple(config), self.loop
381
410
  ).result()
@@ -408,7 +437,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
408
437
  def put_writes(
409
438
  self,
410
439
  config: RunnableConfig,
411
- writes: List[tuple[str, Any]],
440
+ writes: Sequence[tuple[str, Any]],
412
441
  task_id: str,
413
442
  ) -> None:
414
443
  """Store intermediate writes linked to a checkpoint.
@@ -1,5 +1,5 @@
1
- from hashlib import md5
2
- from typing import Any, List, Optional, Tuple
1
+ import random
2
+ from typing import Any, List, Optional, Sequence, Tuple, cast
3
3
 
4
4
  from langchain_core.runnables import RunnableConfig
5
5
  from psycopg.types.json import Jsonb
@@ -7,8 +7,9 @@ from psycopg.types.json import Jsonb
7
7
  from langgraph.checkpoint.base import (
8
8
  WRITES_IDX_MAP,
9
9
  BaseCheckpointSaver,
10
+ ChannelVersions,
10
11
  Checkpoint,
11
- EmptyChannelError,
12
+ CheckpointMetadata,
12
13
  get_checkpoint_id,
13
14
  )
14
15
  from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
@@ -108,18 +109,28 @@ UPSERT_CHECKPOINTS_SQL = """
108
109
  """
109
110
 
110
111
  UPSERT_CHECKPOINT_WRITES_SQL = """
112
+ INSERT INTO checkpoint_writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, blob)
113
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
114
+ ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) DO UPDATE SET
115
+ channel = EXCLUDED.channel,
116
+ type = EXCLUDED.type,
117
+ blob = EXCLUDED.blob;
118
+ """
119
+
120
+ INSERT_CHECKPOINT_WRITES_SQL = """
111
121
  INSERT INTO checkpoint_writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, blob)
112
122
  VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
113
123
  ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) DO NOTHING
114
124
  """
115
125
 
116
126
 
117
- class BasePostgresSaver(BaseCheckpointSaver):
127
+ class BasePostgresSaver(BaseCheckpointSaver[str]):
118
128
  SELECT_SQL = SELECT_SQL
119
129
  MIGRATIONS = MIGRATIONS
120
130
  UPSERT_CHECKPOINT_BLOBS_SQL = UPSERT_CHECKPOINT_BLOBS_SQL
121
131
  UPSERT_CHECKPOINTS_SQL = UPSERT_CHECKPOINTS_SQL
122
132
  UPSERT_CHECKPOINT_WRITES_SQL = UPSERT_CHECKPOINT_WRITES_SQL
133
+ INSERT_CHECKPOINT_WRITES_SQL = INSERT_CHECKPOINT_WRITES_SQL
123
134
 
124
135
  jsonplus_serde = JsonPlusSerializer()
125
136
 
@@ -156,8 +167,8 @@ class BasePostgresSaver(BaseCheckpointSaver):
156
167
  thread_id: str,
157
168
  checkpoint_ns: str,
158
169
  values: dict[str, Any],
159
- versions: dict[str, str],
160
- ) -> list[tuple[str, str, str, str, str, bytes]]:
170
+ versions: ChannelVersions,
171
+ ) -> list[tuple[str, str, str, str, str, Optional[bytes]]]:
161
172
  if not versions:
162
173
  return []
163
174
 
@@ -166,7 +177,7 @@ class BasePostgresSaver(BaseCheckpointSaver):
166
177
  thread_id,
167
178
  checkpoint_ns,
168
179
  k,
169
- ver,
180
+ cast(str, ver),
170
181
  *(
171
182
  self.serde.dumps_typed(values[k])
172
183
  if k in values
@@ -198,8 +209,8 @@ class BasePostgresSaver(BaseCheckpointSaver):
198
209
  checkpoint_ns: str,
199
210
  checkpoint_id: str,
200
211
  task_id: str,
201
- writes: list[tuple[str, Any]],
202
- ) -> list[tuple[str, str, str, int, str, str, bytes]]:
212
+ writes: Sequence[tuple[str, Any]],
213
+ ) -> list[tuple[str, str, str, str, int, str, str, bytes]]:
203
214
  return [
204
215
  (
205
216
  thread_id,
@@ -213,17 +224,11 @@ class BasePostgresSaver(BaseCheckpointSaver):
213
224
  for idx, (channel, value) in enumerate(writes)
214
225
  ]
215
226
 
216
- def _load_metadata(self, metadata: dict[str, Any]) -> dict[str, Any]:
227
+ def _load_metadata(self, metadata: dict[str, Any]) -> CheckpointMetadata:
217
228
  return self.jsonplus_serde.loads(self.jsonplus_serde.dumps(metadata))
218
229
 
219
- def _dump_metadata(self, metadata) -> str:
220
- serialized_metadata_type, serialized_metadata = self.jsonplus_serde.dumps_typed(
221
- metadata
222
- )
223
- if serialized_metadata_type != "json":
224
- raise TypeError(
225
- f"Failed to properly serialize metadata -- expected 'json', got '{serialized_metadata_type}'"
226
- )
230
+ def _dump_metadata(self, metadata: CheckpointMetadata) -> str:
231
+ serialized_metadata = self.jsonplus_serde.dumps(metadata)
227
232
  return serialized_metadata.decode()
228
233
 
229
234
  def get_next_version(self, current: Optional[str], channel: ChannelProtocol) -> str:
@@ -234,11 +239,8 @@ class BasePostgresSaver(BaseCheckpointSaver):
234
239
  else:
235
240
  current_v = int(current.split(".")[0])
236
241
  next_v = current_v + 1
237
- try:
238
- next_h = md5(self.serde.dumps_typed(channel.checkpoint())[1]).hexdigest()
239
- except EmptyChannelError:
240
- next_h = ""
241
- return f"{next_v:032}.{next_h}"
242
+ next_h = random.random()
243
+ return f"{next_v:032}.{next_h:016}"
242
244
 
243
245
  def _search_where(
244
246
  self,
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "langgraph-checkpoint-postgres"
3
- version = "1.0.6"
3
+ version = "1.0.8"
4
4
  description = "Library with a Postgres implementation of LangGraph checkpoint saver."
5
5
  authors = []
6
6
  license = "MIT"
@@ -10,7 +10,7 @@ packages = [{ include = "langgraph" }]
10
10
 
11
11
  [tool.poetry.dependencies]
12
12
  python = "^3.9.0,<4.0"
13
- langgraph-checkpoint = "^1.0.8"
13
+ langgraph-checkpoint = "^1.0.11"
14
14
  orjson = ">=3.10.1"
15
15
  psycopg = "^3.0.0"
16
16
  psycopg-pool = "^3.0.0"
@@ -51,3 +51,13 @@ lint.select = [
51
51
  "I", # isort
52
52
  ]
53
53
  lint.ignore = ["E501", "B008", "UP007", "UP006"]
54
+
55
+ [tool.mypy]
56
+ # https://mypy.readthedocs.io/en/stable/config_file.html
57
+ disallow_untyped_defs = "True"
58
+ explicit_package_bases = "True"
59
+ warn_no_return = "False"
60
+ warn_unused_ignores = "True"
61
+ warn_redundant_casts = "True"
62
+ allow_redefinition = "True"
63
+ disable_error_code = "typeddict-item, return-value"