langgraph-checkpoint-postgres 1.0.9__tar.gz → 1.0.10__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.
- {langgraph_checkpoint_postgres-1.0.9 → langgraph_checkpoint_postgres-1.0.10}/PKG-INFO +1 -1
- {langgraph_checkpoint_postgres-1.0.9 → langgraph_checkpoint_postgres-1.0.10}/langgraph/checkpoint/postgres/__init__.py +24 -19
- langgraph_checkpoint_postgres-1.0.10/langgraph/store/postgres/__init__.py +4 -0
- langgraph_checkpoint_postgres-1.0.10/langgraph/store/postgres/aio.py +190 -0
- langgraph_checkpoint_postgres-1.0.10/langgraph/store/postgres/base.py +394 -0
- {langgraph_checkpoint_postgres-1.0.9 → langgraph_checkpoint_postgres-1.0.10}/pyproject.toml +1 -1
- {langgraph_checkpoint_postgres-1.0.9 → langgraph_checkpoint_postgres-1.0.10}/README.md +0 -0
- {langgraph_checkpoint_postgres-1.0.9 → langgraph_checkpoint_postgres-1.0.10}/langgraph/checkpoint/postgres/aio.py +0 -0
- {langgraph_checkpoint_postgres-1.0.9 → langgraph_checkpoint_postgres-1.0.10}/langgraph/checkpoint/postgres/base.py +0 -0
- {langgraph_checkpoint_postgres-1.0.9 → langgraph_checkpoint_postgres-1.0.10}/langgraph/checkpoint/py.typed +0 -0
|
@@ -17,9 +17,7 @@ from langgraph.checkpoint.base import (
|
|
|
17
17
|
CheckpointTuple,
|
|
18
18
|
get_checkpoint_id,
|
|
19
19
|
)
|
|
20
|
-
from langgraph.checkpoint.postgres.base import
|
|
21
|
-
BasePostgresSaver,
|
|
22
|
-
)
|
|
20
|
+
from langgraph.checkpoint.postgres.base import BasePostgresSaver
|
|
23
21
|
from langgraph.checkpoint.serde.base import SerializerProtocol
|
|
24
22
|
|
|
25
23
|
Conn = Union[Connection[DictRow], ConnectionPool[Connection[DictRow]]]
|
|
@@ -167,15 +165,17 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
167
165
|
value["pending_sends"],
|
|
168
166
|
),
|
|
169
167
|
self._load_metadata(value["metadata"]),
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
"
|
|
173
|
-
|
|
174
|
-
|
|
168
|
+
(
|
|
169
|
+
{
|
|
170
|
+
"configurable": {
|
|
171
|
+
"thread_id": value["thread_id"],
|
|
172
|
+
"checkpoint_ns": value["checkpoint_ns"],
|
|
173
|
+
"checkpoint_id": value["parent_checkpoint_id"],
|
|
174
|
+
}
|
|
175
175
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
if value["parent_checkpoint_id"]
|
|
177
|
+
else None
|
|
178
|
+
),
|
|
179
179
|
self._load_writes(value["pending_writes"]),
|
|
180
180
|
)
|
|
181
181
|
|
|
@@ -246,15 +246,17 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
246
246
|
value["pending_sends"],
|
|
247
247
|
),
|
|
248
248
|
self._load_metadata(value["metadata"]),
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
"
|
|
252
|
-
|
|
253
|
-
|
|
249
|
+
(
|
|
250
|
+
{
|
|
251
|
+
"configurable": {
|
|
252
|
+
"thread_id": thread_id,
|
|
253
|
+
"checkpoint_ns": checkpoint_ns,
|
|
254
|
+
"checkpoint_id": value["parent_checkpoint_id"],
|
|
255
|
+
}
|
|
254
256
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
257
|
+
if value["parent_checkpoint_id"]
|
|
258
|
+
else None
|
|
259
|
+
),
|
|
258
260
|
self._load_writes(value["pending_writes"]),
|
|
259
261
|
)
|
|
260
262
|
|
|
@@ -384,3 +386,6 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
384
386
|
else:
|
|
385
387
|
with self.lock, conn.cursor(binary=True, row_factory=dict_row) as cur:
|
|
386
388
|
yield cur
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
__all__ = ["PostgresSaver", "Conn"]
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from typing import Any, AsyncIterator, Iterable, Sequence, cast
|
|
5
|
+
|
|
6
|
+
from psycopg import AsyncConnection, AsyncCursor
|
|
7
|
+
from psycopg.errors import UndefinedTable
|
|
8
|
+
from psycopg.rows import dict_row
|
|
9
|
+
|
|
10
|
+
from langgraph.store.base import GetOp, ListNamespacesOp, Op, PutOp, Result, SearchOp
|
|
11
|
+
from langgraph.store.postgres.base import (
|
|
12
|
+
BasePostgresStore,
|
|
13
|
+
Row,
|
|
14
|
+
_group_ops,
|
|
15
|
+
_row_to_item,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AsyncPostgresStore(BasePostgresStore[AsyncConnection]):
|
|
22
|
+
def __init__(self, conn: AsyncConnection[Any]) -> None:
|
|
23
|
+
self.conn = conn
|
|
24
|
+
self.loop = asyncio.get_running_loop()
|
|
25
|
+
|
|
26
|
+
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
|
|
27
|
+
grouped_ops, num_ops = _group_ops(ops)
|
|
28
|
+
results: list[Result] = [None] * num_ops
|
|
29
|
+
|
|
30
|
+
async with self.conn.pipeline():
|
|
31
|
+
tasks = []
|
|
32
|
+
|
|
33
|
+
if GetOp in grouped_ops:
|
|
34
|
+
tasks.append(
|
|
35
|
+
self._batch_get_ops(
|
|
36
|
+
cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), results
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if PutOp in grouped_ops:
|
|
41
|
+
tasks.append(
|
|
42
|
+
self._batch_put_ops(
|
|
43
|
+
cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp])
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if SearchOp in grouped_ops:
|
|
48
|
+
tasks.append(
|
|
49
|
+
self._batch_search_ops(
|
|
50
|
+
cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]),
|
|
51
|
+
results,
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if ListNamespacesOp in grouped_ops:
|
|
56
|
+
tasks.append(
|
|
57
|
+
self._batch_list_namespaces_ops(
|
|
58
|
+
cast(
|
|
59
|
+
Sequence[tuple[int, ListNamespacesOp]],
|
|
60
|
+
grouped_ops[ListNamespacesOp],
|
|
61
|
+
),
|
|
62
|
+
results,
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
await asyncio.gather(*tasks)
|
|
67
|
+
|
|
68
|
+
return results
|
|
69
|
+
|
|
70
|
+
def batch(self, ops: Iterable[Op]) -> list[Result]:
|
|
71
|
+
return asyncio.run_coroutine_threadsafe(self.abatch(ops), self.loop).result()
|
|
72
|
+
|
|
73
|
+
async def _batch_get_ops(
|
|
74
|
+
self,
|
|
75
|
+
get_ops: Sequence[tuple[int, GetOp]],
|
|
76
|
+
results: list[Result],
|
|
77
|
+
) -> None:
|
|
78
|
+
cursors = []
|
|
79
|
+
for query, params, namespace, items in self._get_batch_GET_ops_queries(get_ops):
|
|
80
|
+
cur = self.conn.cursor(binary=True)
|
|
81
|
+
await cur.execute(query, params)
|
|
82
|
+
cursors.append((cur, namespace, items))
|
|
83
|
+
|
|
84
|
+
for cur, namespace, items in cursors:
|
|
85
|
+
rows = cast(list[Row], await cur.fetchall())
|
|
86
|
+
key_to_row = {row["key"]: row for row in rows}
|
|
87
|
+
for idx, key in items:
|
|
88
|
+
row = key_to_row.get(key)
|
|
89
|
+
if row:
|
|
90
|
+
results[idx] = _row_to_item(namespace, row)
|
|
91
|
+
else:
|
|
92
|
+
results[idx] = None
|
|
93
|
+
|
|
94
|
+
async def _batch_put_ops(
|
|
95
|
+
self,
|
|
96
|
+
put_ops: Sequence[tuple[int, PutOp]],
|
|
97
|
+
) -> None:
|
|
98
|
+
queries = self._get_batch_PUT_queries(put_ops)
|
|
99
|
+
for query, params in queries:
|
|
100
|
+
cur = self.conn.cursor(binary=True)
|
|
101
|
+
await cur.execute(query, params)
|
|
102
|
+
|
|
103
|
+
async def _batch_search_ops(
|
|
104
|
+
self,
|
|
105
|
+
search_ops: Sequence[tuple[int, SearchOp]],
|
|
106
|
+
results: list[Result],
|
|
107
|
+
) -> None:
|
|
108
|
+
queries = self._get_batch_search_queries(search_ops)
|
|
109
|
+
cursors: list[tuple[AsyncCursor[Any], int, SearchOp]] = []
|
|
110
|
+
|
|
111
|
+
for (query, params), (idx, op) in zip(queries, search_ops):
|
|
112
|
+
cur = self.conn.cursor(binary=True)
|
|
113
|
+
await cur.execute(query, params)
|
|
114
|
+
cursors.append((cur, idx, op))
|
|
115
|
+
|
|
116
|
+
for cur, idx, op in cursors:
|
|
117
|
+
rows = cast(list[Row], await cur.fetchall())
|
|
118
|
+
items = [_row_to_item(op.namespace_prefix, row) for row in rows]
|
|
119
|
+
results[idx] = items
|
|
120
|
+
|
|
121
|
+
async def _batch_list_namespaces_ops(
|
|
122
|
+
self,
|
|
123
|
+
list_ops: Sequence[tuple[int, ListNamespacesOp]],
|
|
124
|
+
results: list[Result],
|
|
125
|
+
) -> None:
|
|
126
|
+
queries = self._get_batch_list_namespaces_queries(list_ops)
|
|
127
|
+
cursors: list[tuple[AsyncCursor[Any], int]] = []
|
|
128
|
+
for (query, params), (idx, _) in zip(queries, list_ops):
|
|
129
|
+
cur = self.conn.cursor(binary=True)
|
|
130
|
+
await cur.execute(query, params)
|
|
131
|
+
cursors.append((cur, idx))
|
|
132
|
+
|
|
133
|
+
for cur, idx in cursors:
|
|
134
|
+
rows = cast(list[dict], await cur.fetchall())
|
|
135
|
+
namespaces = [
|
|
136
|
+
tuple(row["truncated_prefix"].decode()[1:].split(".")) for row in rows
|
|
137
|
+
]
|
|
138
|
+
results[idx] = namespaces
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
@asynccontextmanager
|
|
142
|
+
async def from_conn_string(
|
|
143
|
+
cls,
|
|
144
|
+
conn_string: str,
|
|
145
|
+
) -> AsyncIterator["AsyncPostgresStore"]:
|
|
146
|
+
"""Create a new AsyncPostgresStore instance from a connection string.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
conn_string (str): The Postgres connection info string.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
AsyncPostgresStore: A new AsyncPostgresStore instance.
|
|
153
|
+
"""
|
|
154
|
+
async with await AsyncConnection.connect(
|
|
155
|
+
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
|
|
156
|
+
) as conn:
|
|
157
|
+
yield cls(conn=conn)
|
|
158
|
+
|
|
159
|
+
async def setup(self) -> None:
|
|
160
|
+
"""Set up the store database asynchronously.
|
|
161
|
+
|
|
162
|
+
This method creates the necessary tables in the Postgres database if they don't
|
|
163
|
+
already exist and runs database migrations. It MUST be called directly by the user
|
|
164
|
+
the first time the store is used.
|
|
165
|
+
"""
|
|
166
|
+
async with self.conn.cursor() as cur:
|
|
167
|
+
try:
|
|
168
|
+
await cur.execute(
|
|
169
|
+
"SELECT v FROM store_migrations ORDER BY v DESC LIMIT 1"
|
|
170
|
+
)
|
|
171
|
+
row = cast(dict, await cur.fetchone())
|
|
172
|
+
if row is None:
|
|
173
|
+
version = -1
|
|
174
|
+
else:
|
|
175
|
+
version = row["v"]
|
|
176
|
+
except UndefinedTable:
|
|
177
|
+
version = -1
|
|
178
|
+
# Create store_migrations table if it doesn't exist
|
|
179
|
+
await cur.execute(
|
|
180
|
+
"""
|
|
181
|
+
CREATE TABLE IF NOT EXISTS store_migrations (
|
|
182
|
+
v INTEGER PRIMARY KEY
|
|
183
|
+
)
|
|
184
|
+
"""
|
|
185
|
+
)
|
|
186
|
+
for v, migration in enumerate(
|
|
187
|
+
self.MIGRATIONS[version + 1 :], start=version + 1
|
|
188
|
+
):
|
|
189
|
+
await cur.execute(migration)
|
|
190
|
+
await cur.execute("INSERT INTO store_migrations (v) VALUES (%s)", (v,))
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any, Generic, Iterable, Iterator, Sequence, TypeVar, Union, cast
|
|
8
|
+
|
|
9
|
+
import orjson
|
|
10
|
+
from psycopg import BaseConnection, Connection, Cursor
|
|
11
|
+
from psycopg.errors import UndefinedTable
|
|
12
|
+
from psycopg.rows import dict_row
|
|
13
|
+
from psycopg.types.json import Jsonb
|
|
14
|
+
from typing_extensions import TypedDict
|
|
15
|
+
|
|
16
|
+
from langgraph.store.base import (
|
|
17
|
+
BaseStore,
|
|
18
|
+
GetOp,
|
|
19
|
+
Item,
|
|
20
|
+
ListNamespacesOp,
|
|
21
|
+
Op,
|
|
22
|
+
PutOp,
|
|
23
|
+
Result,
|
|
24
|
+
SearchOp,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
MIGRATIONS = [
|
|
31
|
+
"""
|
|
32
|
+
CREATE EXTENSION IF NOT EXISTS ltree;
|
|
33
|
+
""",
|
|
34
|
+
"""
|
|
35
|
+
CREATE TABLE IF NOT EXISTS store (
|
|
36
|
+
-- 'prefix' represents the doc's 'namespace'
|
|
37
|
+
prefix ltree NOT NULL,
|
|
38
|
+
key text NOT NULL,
|
|
39
|
+
value jsonb NOT NULL,
|
|
40
|
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
41
|
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
42
|
+
PRIMARY KEY (prefix, key)
|
|
43
|
+
);
|
|
44
|
+
""",
|
|
45
|
+
"""
|
|
46
|
+
-- For faster listing of namespaces & lookups by namespace with prefix/suffix matching
|
|
47
|
+
CREATE INDEX IF NOT EXISTS store_prefix_idx ON store USING gist (prefix);
|
|
48
|
+
""",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
C = TypeVar("C", bound=BaseConnection)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BasePostgresStore(BaseStore, Generic[C]):
|
|
55
|
+
MIGRATIONS = MIGRATIONS
|
|
56
|
+
conn: C
|
|
57
|
+
|
|
58
|
+
def _get_batch_GET_ops_queries(
|
|
59
|
+
self,
|
|
60
|
+
get_ops: Sequence[tuple[int, GetOp]],
|
|
61
|
+
) -> list[tuple[str, tuple, tuple[str, ...], list]]:
|
|
62
|
+
namespace_groups = defaultdict(list)
|
|
63
|
+
for idx, op in get_ops:
|
|
64
|
+
namespace_groups[op.namespace].append((idx, op.key))
|
|
65
|
+
results = []
|
|
66
|
+
for namespace, items in namespace_groups.items():
|
|
67
|
+
_, keys = zip(*items)
|
|
68
|
+
keys_to_query = ",".join(["%s"] * len(keys))
|
|
69
|
+
query = f"""
|
|
70
|
+
SELECT key, value, created_at, updated_at
|
|
71
|
+
FROM store
|
|
72
|
+
WHERE prefix = %s AND key IN ({keys_to_query})
|
|
73
|
+
"""
|
|
74
|
+
params = (_namespace_to_ltree(namespace), *keys)
|
|
75
|
+
results.append((query, params, namespace, items))
|
|
76
|
+
return results
|
|
77
|
+
|
|
78
|
+
def _get_batch_PUT_queries(
|
|
79
|
+
self,
|
|
80
|
+
put_ops: Sequence[tuple[int, PutOp]],
|
|
81
|
+
) -> list[tuple[str, Sequence]]:
|
|
82
|
+
inserts: list[PutOp] = []
|
|
83
|
+
deletes: list[PutOp] = []
|
|
84
|
+
for _, op in put_ops:
|
|
85
|
+
if op.value is None:
|
|
86
|
+
deletes.append(op)
|
|
87
|
+
else:
|
|
88
|
+
inserts.append(op)
|
|
89
|
+
|
|
90
|
+
queries: list[tuple[str, Sequence]] = []
|
|
91
|
+
|
|
92
|
+
if deletes:
|
|
93
|
+
namespace_groups: dict[tuple[str, ...], list[str]] = defaultdict(list)
|
|
94
|
+
for op in deletes:
|
|
95
|
+
namespace_groups[op.namespace].append(op.key)
|
|
96
|
+
for namespace, keys in namespace_groups.items():
|
|
97
|
+
placeholders = ",".join(["%s"] * len(keys))
|
|
98
|
+
query = (
|
|
99
|
+
f"DELETE FROM store WHERE prefix = %s AND key IN ({placeholders})"
|
|
100
|
+
)
|
|
101
|
+
params = (_namespace_to_ltree(namespace), *keys)
|
|
102
|
+
queries.append((query, params))
|
|
103
|
+
if inserts:
|
|
104
|
+
values = []
|
|
105
|
+
insertion_params = []
|
|
106
|
+
for op in inserts:
|
|
107
|
+
values.append("(%s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)")
|
|
108
|
+
insertion_params.extend(
|
|
109
|
+
[
|
|
110
|
+
_namespace_to_ltree(op.namespace),
|
|
111
|
+
op.key,
|
|
112
|
+
Jsonb(op.value),
|
|
113
|
+
]
|
|
114
|
+
)
|
|
115
|
+
values_str = ",".join(values)
|
|
116
|
+
query = f"""
|
|
117
|
+
INSERT INTO store (prefix, key, value, created_at, updated_at)
|
|
118
|
+
VALUES {values_str}
|
|
119
|
+
ON CONFLICT (prefix, key) DO UPDATE
|
|
120
|
+
SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
|
|
121
|
+
"""
|
|
122
|
+
queries.append((query, insertion_params))
|
|
123
|
+
|
|
124
|
+
return queries
|
|
125
|
+
|
|
126
|
+
def _get_batch_search_queries(
|
|
127
|
+
self,
|
|
128
|
+
search_ops: Sequence[tuple[int, SearchOp]],
|
|
129
|
+
) -> list[tuple[str, Sequence]]:
|
|
130
|
+
queries: list[tuple[str, Sequence]] = []
|
|
131
|
+
for _, op in search_ops:
|
|
132
|
+
query = """
|
|
133
|
+
SELECT key, value, created_at, updated_at
|
|
134
|
+
FROM store
|
|
135
|
+
WHERE prefix <@ %s
|
|
136
|
+
"""
|
|
137
|
+
params: list = [_namespace_to_ltree(op.namespace_prefix)]
|
|
138
|
+
|
|
139
|
+
if op.filter:
|
|
140
|
+
filter_conditions = []
|
|
141
|
+
for key, value in op.filter.items():
|
|
142
|
+
if isinstance(value, list):
|
|
143
|
+
filter_conditions.append("value->%s @> %s::jsonb")
|
|
144
|
+
params.extend([key, json.dumps(value)])
|
|
145
|
+
else:
|
|
146
|
+
filter_conditions.append("value->%s = %s::jsonb")
|
|
147
|
+
params.extend([key, json.dumps(value)])
|
|
148
|
+
query += " AND " + " AND ".join(filter_conditions)
|
|
149
|
+
|
|
150
|
+
query += " LIMIT %s OFFSET %s"
|
|
151
|
+
params.extend([op.limit, op.offset])
|
|
152
|
+
|
|
153
|
+
queries.append((query, params))
|
|
154
|
+
return queries
|
|
155
|
+
|
|
156
|
+
def _get_batch_list_namespaces_queries(
|
|
157
|
+
self,
|
|
158
|
+
list_ops: Sequence[tuple[int, ListNamespacesOp]],
|
|
159
|
+
) -> list[tuple[str, Sequence]]:
|
|
160
|
+
queries: list[tuple[str, Sequence]] = []
|
|
161
|
+
for _, op in list_ops:
|
|
162
|
+
query = "SELECT DISTINCT subltree(prefix, 0, LEAST(nlevel(prefix), %s)) AS truncated_prefix FROM store"
|
|
163
|
+
# https://www.postgresql.org/docs/current/ltree.html
|
|
164
|
+
# The length of a label path cannot exceed 65535 labels.
|
|
165
|
+
params: list[Any] = [op.max_depth if op.max_depth is not None else 65536]
|
|
166
|
+
|
|
167
|
+
conditions = []
|
|
168
|
+
if op.match_conditions:
|
|
169
|
+
for condition in op.match_conditions:
|
|
170
|
+
if condition.match_type == "prefix":
|
|
171
|
+
conditions.append("prefix ~ %s::lquery")
|
|
172
|
+
lquery_pattern = f"{_namespace_to_ltree(condition.path)}.*"
|
|
173
|
+
params.append(lquery_pattern)
|
|
174
|
+
elif condition.match_type == "suffix":
|
|
175
|
+
conditions.append("prefix ~ %s::lquery")
|
|
176
|
+
lquery_pattern = f"*.{_namespace_to_ltree(condition.path)}"
|
|
177
|
+
params.append(lquery_pattern)
|
|
178
|
+
else:
|
|
179
|
+
logger.warning(
|
|
180
|
+
f"Unknown match_type in list_namespaces: {condition.match_type}"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
if conditions:
|
|
184
|
+
query += " WHERE " + " AND ".join(conditions)
|
|
185
|
+
|
|
186
|
+
query += " ORDER BY truncated_prefix LIMIT %s OFFSET %s"
|
|
187
|
+
params.extend([op.limit, op.offset])
|
|
188
|
+
|
|
189
|
+
queries.append((query, params))
|
|
190
|
+
return queries
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class PostgresStore(BasePostgresStore[Connection]):
|
|
194
|
+
def __init__(self, conn: Connection[Any]) -> None:
|
|
195
|
+
self.conn = conn
|
|
196
|
+
|
|
197
|
+
def batch(self, ops: Iterable[Op]) -> list[Result]:
|
|
198
|
+
grouped_ops, num_ops = _group_ops(ops)
|
|
199
|
+
results: list[Result] = [None] * num_ops
|
|
200
|
+
|
|
201
|
+
with self.conn.pipeline():
|
|
202
|
+
if GetOp in grouped_ops:
|
|
203
|
+
self._batch_get_ops(
|
|
204
|
+
cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), results
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if PutOp in grouped_ops:
|
|
208
|
+
self._batch_put_ops(
|
|
209
|
+
cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp])
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
if SearchOp in grouped_ops:
|
|
213
|
+
self._batch_search_ops(
|
|
214
|
+
cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]),
|
|
215
|
+
results,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if ListNamespacesOp in grouped_ops:
|
|
219
|
+
self._batch_list_namespaces_ops(
|
|
220
|
+
cast(
|
|
221
|
+
Sequence[tuple[int, ListNamespacesOp]],
|
|
222
|
+
grouped_ops[ListNamespacesOp],
|
|
223
|
+
),
|
|
224
|
+
results,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
return results
|
|
228
|
+
|
|
229
|
+
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
|
|
230
|
+
return await asyncio.get_running_loop().run_in_executor(None, self.batch, ops)
|
|
231
|
+
|
|
232
|
+
def _batch_get_ops(
|
|
233
|
+
self,
|
|
234
|
+
get_ops: Sequence[tuple[int, GetOp]],
|
|
235
|
+
results: list[Result],
|
|
236
|
+
) -> None:
|
|
237
|
+
cursors = []
|
|
238
|
+
for query, params, namespace, items in self._get_batch_GET_ops_queries(get_ops):
|
|
239
|
+
cur = self.conn.cursor(binary=True)
|
|
240
|
+
cur.execute(query, params)
|
|
241
|
+
cursors.append((cur, namespace, items))
|
|
242
|
+
|
|
243
|
+
for cur, namespace, items in cursors:
|
|
244
|
+
rows = cast(list[Row], cur.fetchall())
|
|
245
|
+
key_to_row = {row["key"]: row for row in rows}
|
|
246
|
+
for idx, key in items:
|
|
247
|
+
row = key_to_row.get(key)
|
|
248
|
+
if row:
|
|
249
|
+
results[idx] = _row_to_item(namespace, row)
|
|
250
|
+
else:
|
|
251
|
+
results[idx] = None
|
|
252
|
+
|
|
253
|
+
def _batch_put_ops(
|
|
254
|
+
self,
|
|
255
|
+
put_ops: Sequence[tuple[int, PutOp]],
|
|
256
|
+
) -> None:
|
|
257
|
+
queries = self._get_batch_PUT_queries(put_ops)
|
|
258
|
+
for query, params in queries:
|
|
259
|
+
cur = self.conn.cursor(binary=True)
|
|
260
|
+
cur.execute(query, params)
|
|
261
|
+
|
|
262
|
+
def _batch_search_ops(
|
|
263
|
+
self,
|
|
264
|
+
search_ops: Sequence[tuple[int, SearchOp]],
|
|
265
|
+
results: list[Result],
|
|
266
|
+
) -> None:
|
|
267
|
+
queries = self._get_batch_search_queries(search_ops)
|
|
268
|
+
cursors: list[tuple[Cursor[Any], int, SearchOp]] = []
|
|
269
|
+
|
|
270
|
+
for (query, params), (idx, op) in zip(queries, search_ops):
|
|
271
|
+
cur = self.conn.cursor(binary=True)
|
|
272
|
+
cur.execute(query, params)
|
|
273
|
+
cursors.append((cur, idx, op))
|
|
274
|
+
|
|
275
|
+
for cur, idx, op in cursors:
|
|
276
|
+
rows = cast(list[Row], cur.fetchall())
|
|
277
|
+
items = [_row_to_item(op.namespace_prefix, row) for row in rows]
|
|
278
|
+
results[idx] = items
|
|
279
|
+
|
|
280
|
+
def _batch_list_namespaces_ops(
|
|
281
|
+
self,
|
|
282
|
+
list_ops: Sequence[tuple[int, ListNamespacesOp]],
|
|
283
|
+
results: list[Result],
|
|
284
|
+
) -> None:
|
|
285
|
+
queries = self._get_batch_list_namespaces_queries(list_ops)
|
|
286
|
+
cursors: list[tuple[Cursor[Any], int]] = []
|
|
287
|
+
for (query, params), (idx, _) in zip(queries, list_ops):
|
|
288
|
+
cur = self.conn.cursor(binary=True)
|
|
289
|
+
cur.execute(query, params)
|
|
290
|
+
cursors.append((cur, idx))
|
|
291
|
+
|
|
292
|
+
for cur, idx in cursors:
|
|
293
|
+
rows = cast(list[dict], cur.fetchall())
|
|
294
|
+
namespaces = [
|
|
295
|
+
tuple(row["truncated_prefix"].decode()[1:].split(".")) for row in rows
|
|
296
|
+
]
|
|
297
|
+
results[idx] = namespaces
|
|
298
|
+
|
|
299
|
+
@classmethod
|
|
300
|
+
@contextmanager
|
|
301
|
+
def from_conn_string(
|
|
302
|
+
cls,
|
|
303
|
+
conn_string: str,
|
|
304
|
+
) -> Iterator["PostgresStore"]:
|
|
305
|
+
"""Create a new BasePostgresStore instance from a connection string.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
conn_string (str): The Postgres connection info string.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
BasePostgresStore: A new BasePostgresStore instance.
|
|
312
|
+
"""
|
|
313
|
+
with Connection.connect(
|
|
314
|
+
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
|
|
315
|
+
) as conn:
|
|
316
|
+
yield cls(conn=conn)
|
|
317
|
+
|
|
318
|
+
def setup(self) -> None:
|
|
319
|
+
"""Set up the store database.
|
|
320
|
+
|
|
321
|
+
This method creates the necessary tables in the Postgres database if they don't
|
|
322
|
+
already exist and runs database migrations. It MUST be called directly by the user
|
|
323
|
+
the first time the store is used.
|
|
324
|
+
"""
|
|
325
|
+
with self.conn.cursor(binary=True) as cur:
|
|
326
|
+
try:
|
|
327
|
+
cur.execute("SELECT v FROM store_migrations ORDER BY v DESC LIMIT 1")
|
|
328
|
+
row = cast(dict, cur.fetchone())
|
|
329
|
+
if row is None:
|
|
330
|
+
version = -1
|
|
331
|
+
else:
|
|
332
|
+
version = row["v"]
|
|
333
|
+
except UndefinedTable:
|
|
334
|
+
self.conn.rollback()
|
|
335
|
+
version = -1
|
|
336
|
+
# Create store_migrations table if it doesn't exist
|
|
337
|
+
cur.execute(
|
|
338
|
+
"""
|
|
339
|
+
CREATE TABLE IF NOT EXISTS store_migrations (
|
|
340
|
+
v INTEGER PRIMARY KEY
|
|
341
|
+
)
|
|
342
|
+
"""
|
|
343
|
+
)
|
|
344
|
+
for v, migration in enumerate(
|
|
345
|
+
self.MIGRATIONS[version + 1 :], start=version + 1
|
|
346
|
+
):
|
|
347
|
+
cur.execute(migration)
|
|
348
|
+
cur.execute("INSERT INTO store_migrations (v) VALUES (%s)", (v,))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class Row(TypedDict):
|
|
352
|
+
key: str
|
|
353
|
+
value: Any
|
|
354
|
+
prefix: bytes
|
|
355
|
+
created_at: datetime
|
|
356
|
+
updated_at: datetime
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _namespace_to_ltree(namespace: tuple[str, ...]) -> str:
|
|
360
|
+
"""Convert namespace tuple to ltree-compatible string."""
|
|
361
|
+
return ".".join(namespace)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _row_to_item(namespace: tuple[str, ...], row: Row) -> Item:
|
|
365
|
+
"""Convert a row from the database into an Item."""
|
|
366
|
+
val = row["value"]
|
|
367
|
+
return Item(
|
|
368
|
+
value=val if isinstance(val, dict) else _json_loads(val),
|
|
369
|
+
key=row["key"],
|
|
370
|
+
namespace=namespace,
|
|
371
|
+
created_at=row["created_at"],
|
|
372
|
+
updated_at=row["updated_at"],
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _group_ops(ops: Iterable[Op]) -> tuple[dict[type, list[tuple[int, Op]]], int]:
|
|
377
|
+
grouped_ops: dict[type, list[tuple[int, Op]]] = defaultdict(list)
|
|
378
|
+
tot = 0
|
|
379
|
+
for idx, op in enumerate(ops):
|
|
380
|
+
grouped_ops[type(op)].append((idx, op))
|
|
381
|
+
tot += 1
|
|
382
|
+
return grouped_ops, tot
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _json_loads(content: Union[bytes, orjson.Fragment]) -> Any:
|
|
386
|
+
if isinstance(content, orjson.Fragment):
|
|
387
|
+
if hasattr(content, "buf"):
|
|
388
|
+
content = content.buf
|
|
389
|
+
else:
|
|
390
|
+
if isinstance(content.contents, bytes):
|
|
391
|
+
content = content.contents
|
|
392
|
+
else:
|
|
393
|
+
content = content.contents.encode()
|
|
394
|
+
return orjson.loads(cast(bytes, content))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|