langgraph-checkpoint-postgres 1.0.0__tar.gz → 1.0.1__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.1
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
@@ -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
@@ -67,23 +65,23 @@ class PostgresSaver(BasePostgresSaver):
67
65
  already exist. It is called automatically when needed and should not be called
68
66
  directly by the user.
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,6 @@ class PostgresSaver(BasePostgresSaver):
92
90
  before: Optional[RunnableConfig] = None,
93
91
  limit: Optional[int] = None,
94
92
  ) -> Iterator[CheckpointTuple]:
95
- self.setup()
96
93
  where, args = self._search_where(config, filter, before)
97
94
  query = self.SELECT_SQL + where + " ORDER BY checkpoint_id DESC"
98
95
  if limit:
@@ -111,7 +108,7 @@ class PostgresSaver(BasePostgresSaver):
111
108
  **self._load_checkpoint(value["checkpoint"]),
112
109
  "channel_values": self._load_blobs(value["channel_values"]),
113
110
  },
114
- value["metadata"],
111
+ self._load_metadata(value["metadata"]),
115
112
  {
116
113
  "configurable": {
117
114
  "thread_id": value["thread_id"],
@@ -124,7 +121,6 @@ class PostgresSaver(BasePostgresSaver):
124
121
  )
125
122
 
126
123
  def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
127
- self.setup()
128
124
  thread_id = config["configurable"]["thread_id"]
129
125
  checkpoint_id = get_checkpoint_id(config)
130
126
  checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
@@ -155,7 +151,7 @@ class PostgresSaver(BasePostgresSaver):
155
151
  **self._load_checkpoint(value["checkpoint"]),
156
152
  "channel_values": self._load_blobs(value["channel_values"]),
157
153
  },
158
- value["metadata"],
154
+ self._load_metadata(value["metadata"]),
159
155
  {
160
156
  "configurable": {
161
157
  "thread_id": thread_id,
@@ -210,7 +206,7 @@ class PostgresSaver(BasePostgresSaver):
210
206
  checkpoint["id"],
211
207
  checkpoint_id,
212
208
  Jsonb(self._dump_checkpoint(copy)),
213
- Jsonb(metadata),
209
+ self._dump_metadata(metadata),
214
210
  ),
215
211
  )
216
212
  return next_config
@@ -235,7 +231,6 @@ class PostgresSaver(BasePostgresSaver):
235
231
 
236
232
  @contextmanager
237
233
  def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor]:
238
- self.setup()
239
234
  if self.pipe:
240
235
  # a connection in pipeline mode can be used concurrently
241
236
  # in multiple threads/coroutines, but only one cursor can be
@@ -244,7 +239,8 @@ class PostgresSaver(BasePostgresSaver):
244
239
  with self.conn.cursor(binary=True) as cur:
245
240
  yield cur
246
241
  finally:
247
- self.pipe.sync()
242
+ if pipeline:
243
+ self.pipe.sync()
248
244
  elif pipeline:
249
245
  # a connection not in pipeline mode can only be used by one
250
246
  # 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
@@ -65,23 +63,27 @@ class AsyncPostgresSaver(BasePostgresSaver):
65
63
  already exist. It is called automatically when needed and should not be called
66
64
  directly by the user.
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,6 @@ class AsyncPostgresSaver(BasePostgresSaver):
90
92
  before: Optional[RunnableConfig] = None,
91
93
  limit: Optional[int] = None,
92
94
  ) -> AsyncIterator[CheckpointTuple]:
93
- await self.setup()
94
95
  where, args = self._search_where(config, filter, before)
95
96
  query = self.SELECT_SQL + where + " ORDER BY checkpoint_id DESC"
96
97
  if limit:
@@ -111,7 +112,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
111
112
  self._load_blobs, value["channel_values"]
112
113
  ),
113
114
  },
114
- value["metadata"],
115
+ self._load_metadata(value["metadata"]),
115
116
  {
116
117
  "configurable": {
117
118
  "thread_id": value["thread_id"],
@@ -124,7 +125,6 @@ class AsyncPostgresSaver(BasePostgresSaver):
124
125
  )
125
126
 
126
127
  async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
127
- await self.setup()
128
128
  thread_id = config["configurable"]["thread_id"]
129
129
  checkpoint_id = get_checkpoint_id(config)
130
130
  checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
@@ -157,7 +157,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
157
157
  self._load_blobs, value["channel_values"]
158
158
  ),
159
159
  },
160
- value["metadata"],
160
+ self._load_metadata(value["metadata"]),
161
161
  {
162
162
  "configurable": {
163
163
  "thread_id": thread_id,
@@ -177,7 +177,6 @@ class AsyncPostgresSaver(BasePostgresSaver):
177
177
  metadata: CheckpointMetadata,
178
178
  new_versions: ChannelVersions,
179
179
  ) -> RunnableConfig:
180
- await self.setup()
181
180
  configurable = config["configurable"].copy()
182
181
  thread_id = configurable.pop("thread_id")
183
182
  checkpoint_ns = configurable.pop("checkpoint_ns")
@@ -214,7 +213,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
214
213
  checkpoint["id"],
215
214
  checkpoint_id,
216
215
  Jsonb(self._dump_checkpoint(copy)),
217
- Jsonb(metadata),
216
+ self._dump_metadata(metadata),
218
217
  ),
219
218
  )
220
219
  return next_config
@@ -240,7 +239,6 @@ class AsyncPostgresSaver(BasePostgresSaver):
240
239
 
241
240
  @asynccontextmanager
242
241
  async def _cursor(self, *, pipeline: bool = False) -> AsyncIterator[AsyncCursor]:
243
- await self.setup()
244
242
  if self.pipe:
245
243
  # a connection in pipeline mode can be used concurrently
246
244
  # in multiple threads/coroutines, but only one cursor can be
@@ -249,7 +247,8 @@ class AsyncPostgresSaver(BasePostgresSaver):
249
247
  async with self.conn.cursor(binary=True) as cur:
250
248
  yield cur
251
249
  finally:
252
- await self.pipe.sync()
250
+ if pipeline:
251
+ await self.pipe.sync()
253
252
  elif pipeline:
254
253
  # a connection not in pipeline mode can only be used by one
255
254
  # thread/coroutine at a time, so we acquire a lock
@@ -11,10 +11,51 @@ 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 NOT NULL,
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
+ ]
58
+
18
59
  SELECT_SQL = """
19
60
  select
20
61
  thread_id,
@@ -42,43 +83,6 @@ select
42
83
  ) as pending_writes
43
84
  from checkpoints """
44
85
 
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
86
  UPSERT_CHECKPOINT_BLOBS_SQL = """
83
87
  INSERT INTO checkpoint_blobs (thread_id, checkpoint_ns, channel, version, type, blob)
84
88
  VALUES (%s, %s, %s, %s, %s, %s)
@@ -103,12 +107,11 @@ UPSERT_CHECKPOINT_WRITES_SQL = """
103
107
 
104
108
  class BasePostgresSaver(BaseCheckpointSaver):
105
109
  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
110
+ MIGRATIONS = MIGRATIONS
109
111
  UPSERT_CHECKPOINT_BLOBS_SQL = UPSERT_CHECKPOINT_BLOBS_SQL
110
112
  UPSERT_CHECKPOINTS_SQL = UPSERT_CHECKPOINTS_SQL
111
113
  UPSERT_CHECKPOINT_WRITES_SQL = UPSERT_CHECKPOINT_WRITES_SQL
114
+ jsonplus_serde = JsonPlusSerializer()
112
115
 
113
116
  def _load_checkpoint(self, checkpoint: dict[str, Any]) -> Checkpoint:
114
117
  if len(checkpoint["pending_sends"]) == 2 and all(
@@ -202,6 +205,19 @@ class BasePostgresSaver(BaseCheckpointSaver):
202
205
  for idx, (channel, value) in enumerate(writes)
203
206
  ]
204
207
 
208
+ def _load_metadata(self, metadata: dict[str, Any]) -> dict[str, Any]:
209
+ return self.jsonplus_serde.loads(self.jsonplus_serde.dumps(metadata))
210
+
211
+ def _dump_metadata(self, metadata) -> str:
212
+ serialized_metadata_type, serialized_metadata = self.jsonplus_serde.dumps_typed(
213
+ metadata
214
+ )
215
+ if serialized_metadata_type != "json":
216
+ raise TypeError(
217
+ f"Failed to properly serialize metadata -- expected 'json', got '{serialized_metadata_type}'"
218
+ )
219
+ return serialized_metadata.decode()
220
+
205
221
  def get_next_version(self, current: Optional[str], channel: ChannelProtocol) -> str:
206
222
  if current is None:
207
223
  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.1"
4
4
  description = "Library with a Postgres implementation of LangGraph checkpoint saver."
5
5
  authors = []
6
6
  license = "MIT"