langgraph-checkpoint-postgres 2.0.15__tar.gz → 2.0.17__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-2.0.15 → langgraph_checkpoint_postgres-2.0.17}/PKG-INFO +1 -1
- {langgraph_checkpoint_postgres-2.0.15 → langgraph_checkpoint_postgres-2.0.17}/langgraph/store/postgres/aio.py +135 -6
- {langgraph_checkpoint_postgres-2.0.15 → langgraph_checkpoint_postgres-2.0.17}/langgraph/store/postgres/base.py +309 -89
- {langgraph_checkpoint_postgres-2.0.15 → langgraph_checkpoint_postgres-2.0.17}/pyproject.toml +1 -1
- {langgraph_checkpoint_postgres-2.0.15 → langgraph_checkpoint_postgres-2.0.17}/README.md +0 -0
- {langgraph_checkpoint_postgres-2.0.15 → langgraph_checkpoint_postgres-2.0.17}/langgraph/checkpoint/postgres/__init__.py +0 -0
- {langgraph_checkpoint_postgres-2.0.15 → langgraph_checkpoint_postgres-2.0.17}/langgraph/checkpoint/postgres/_ainternal.py +0 -0
- {langgraph_checkpoint_postgres-2.0.15 → langgraph_checkpoint_postgres-2.0.17}/langgraph/checkpoint/postgres/_internal.py +0 -0
- {langgraph_checkpoint_postgres-2.0.15 → langgraph_checkpoint_postgres-2.0.17}/langgraph/checkpoint/postgres/aio.py +0 -0
- {langgraph_checkpoint_postgres-2.0.15 → langgraph_checkpoint_postgres-2.0.17}/langgraph/checkpoint/postgres/base.py +0 -0
- {langgraph_checkpoint_postgres-2.0.15 → langgraph_checkpoint_postgres-2.0.17}/langgraph/checkpoint/postgres/py.typed +0 -0
- {langgraph_checkpoint_postgres-2.0.15 → langgraph_checkpoint_postgres-2.0.17}/langgraph/checkpoint/postgres/shallow.py +0 -0
- {langgraph_checkpoint_postgres-2.0.15 → langgraph_checkpoint_postgres-2.0.17}/langgraph/store/postgres/__init__.py +0 -0
- {langgraph_checkpoint_postgres-2.0.15 → langgraph_checkpoint_postgres-2.0.17}/langgraph/store/postgres/py.typed +0 -0
|
@@ -2,6 +2,7 @@ import asyncio
|
|
|
2
2
|
import logging
|
|
3
3
|
from collections.abc import AsyncIterator, Iterable, Sequence
|
|
4
4
|
from contextlib import asynccontextmanager
|
|
5
|
+
from types import TracebackType
|
|
5
6
|
from typing import Any, Callable, Optional, Union, cast
|
|
6
7
|
|
|
7
8
|
import orjson
|
|
@@ -20,11 +21,12 @@ from langgraph.store.base import (
|
|
|
20
21
|
)
|
|
21
22
|
from langgraph.store.base.batch import AsyncBatchedBaseStore
|
|
22
23
|
from langgraph.store.postgres.base import (
|
|
23
|
-
|
|
24
|
+
PLACEHOLDER,
|
|
24
25
|
BasePostgresStore,
|
|
25
26
|
PoolConfig,
|
|
26
27
|
PostgresIndexConfig,
|
|
27
28
|
Row,
|
|
29
|
+
TTLConfig,
|
|
28
30
|
_decode_ns_bytes,
|
|
29
31
|
_ensure_index_config,
|
|
30
32
|
_group_ops,
|
|
@@ -106,6 +108,11 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
|
|
|
106
108
|
Semantic search is disabled by default. You can enable it by providing an `index` configuration
|
|
107
109
|
when creating the store. Without this configuration, all `index` arguments passed to
|
|
108
110
|
`put` or `aput` will have no effect.
|
|
111
|
+
|
|
112
|
+
Note:
|
|
113
|
+
If you provide a TTL configuration, you must explicitly call `start_ttl_sweeper()` to begin
|
|
114
|
+
the background task that removes expired items. Call `stop_ttl_sweeper()` to properly
|
|
115
|
+
clean up resources when you're done with the store.
|
|
109
116
|
"""
|
|
110
117
|
|
|
111
118
|
__slots__ = (
|
|
@@ -115,7 +122,11 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
|
|
|
115
122
|
"supports_pipeline",
|
|
116
123
|
"index_config",
|
|
117
124
|
"embeddings",
|
|
125
|
+
"ttl_config",
|
|
126
|
+
"_ttl_sweeper_task",
|
|
127
|
+
"_ttl_stop_event",
|
|
118
128
|
)
|
|
129
|
+
supports_ttl: bool = True
|
|
119
130
|
|
|
120
131
|
def __init__(
|
|
121
132
|
self,
|
|
@@ -126,6 +137,7 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
|
|
|
126
137
|
Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
|
|
127
138
|
] = None,
|
|
128
139
|
index: Optional[PostgresIndexConfig] = None,
|
|
140
|
+
ttl: Optional[TTLConfig] = None,
|
|
129
141
|
) -> None:
|
|
130
142
|
if isinstance(conn, AsyncConnectionPool) and pipe is not None:
|
|
131
143
|
raise ValueError(
|
|
@@ -141,10 +153,13 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
|
|
|
141
153
|
self.index_config = index
|
|
142
154
|
if self.index_config:
|
|
143
155
|
self.embeddings, self.index_config = _ensure_index_config(self.index_config)
|
|
144
|
-
|
|
145
156
|
else:
|
|
146
157
|
self.embeddings = None
|
|
147
158
|
|
|
159
|
+
self.ttl_config = ttl
|
|
160
|
+
self._ttl_sweeper_task: Optional[asyncio.Task[None]] = None
|
|
161
|
+
self._ttl_stop_event = asyncio.Event()
|
|
162
|
+
|
|
148
163
|
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
|
|
149
164
|
grouped_ops, num_ops = _group_ops(ops)
|
|
150
165
|
results: list[Result] = [None] * num_ops
|
|
@@ -167,6 +182,7 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
|
|
|
167
182
|
pipeline: bool = False,
|
|
168
183
|
pool_config: Optional[PoolConfig] = None,
|
|
169
184
|
index: Optional[PostgresIndexConfig] = None,
|
|
185
|
+
ttl: Optional[TTLConfig] = None,
|
|
170
186
|
) -> AsyncIterator["AsyncPostgresStore"]:
|
|
171
187
|
"""Create a new AsyncPostgresStore instance from a connection string.
|
|
172
188
|
|
|
@@ -198,16 +214,16 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
|
|
|
198
214
|
**cast(dict, pc),
|
|
199
215
|
),
|
|
200
216
|
) as pool:
|
|
201
|
-
yield cls(conn=pool, index=index)
|
|
217
|
+
yield cls(conn=pool, index=index, ttl=ttl)
|
|
202
218
|
else:
|
|
203
219
|
async with await AsyncConnection.connect(
|
|
204
220
|
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
|
|
205
221
|
) as conn:
|
|
206
222
|
if pipeline:
|
|
207
223
|
async with conn.pipeline() as pipe:
|
|
208
|
-
yield cls(conn=conn, pipe=pipe, index=index)
|
|
224
|
+
yield cls(conn=conn, pipe=pipe, index=index, ttl=ttl)
|
|
209
225
|
else:
|
|
210
|
-
yield cls(conn=conn, index=index)
|
|
226
|
+
yield cls(conn=conn, index=index, ttl=ttl)
|
|
211
227
|
|
|
212
228
|
async def setup(self) -> None:
|
|
213
229
|
"""Set up the store database asynchronously.
|
|
@@ -256,6 +272,119 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
|
|
|
256
272
|
"INSERT INTO vector_migrations (v) VALUES (%s)", (v,)
|
|
257
273
|
)
|
|
258
274
|
|
|
275
|
+
async def sweep_ttl(self) -> int:
|
|
276
|
+
"""Delete expired store items based on TTL.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
int: The number of deleted items.
|
|
280
|
+
"""
|
|
281
|
+
async with self._cursor() as cur:
|
|
282
|
+
await cur.execute(
|
|
283
|
+
"""
|
|
284
|
+
DELETE FROM store
|
|
285
|
+
WHERE expires_at IS NOT NULL AND expires_at < NOW()
|
|
286
|
+
"""
|
|
287
|
+
)
|
|
288
|
+
deleted_count = cur.rowcount
|
|
289
|
+
return deleted_count
|
|
290
|
+
|
|
291
|
+
async def start_ttl_sweeper(
|
|
292
|
+
self, sweep_interval_minutes: Optional[int] = None
|
|
293
|
+
) -> asyncio.Task[None]:
|
|
294
|
+
"""Periodically delete expired store items based on TTL.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Task that can be awaited or cancelled.
|
|
298
|
+
"""
|
|
299
|
+
if not self.ttl_config:
|
|
300
|
+
return asyncio.create_task(asyncio.sleep(0))
|
|
301
|
+
|
|
302
|
+
if self._ttl_sweeper_task is not None and not self._ttl_sweeper_task.done():
|
|
303
|
+
return self._ttl_sweeper_task
|
|
304
|
+
|
|
305
|
+
self._ttl_stop_event.clear()
|
|
306
|
+
|
|
307
|
+
interval = float(
|
|
308
|
+
sweep_interval_minutes or self.ttl_config.get("sweep_interval_minutes") or 5
|
|
309
|
+
)
|
|
310
|
+
logger.info(f"Starting store TTL sweeper with interval {interval} minutes")
|
|
311
|
+
|
|
312
|
+
async def _sweep_loop() -> None:
|
|
313
|
+
while not self._ttl_stop_event.is_set():
|
|
314
|
+
try:
|
|
315
|
+
try:
|
|
316
|
+
await asyncio.wait_for(
|
|
317
|
+
self._ttl_stop_event.wait(),
|
|
318
|
+
timeout=interval * 60,
|
|
319
|
+
)
|
|
320
|
+
break
|
|
321
|
+
except asyncio.TimeoutError:
|
|
322
|
+
pass
|
|
323
|
+
|
|
324
|
+
expired_items = await self.sweep_ttl()
|
|
325
|
+
if expired_items > 0:
|
|
326
|
+
logger.info(f"Store swept {expired_items} expired items")
|
|
327
|
+
except asyncio.CancelledError:
|
|
328
|
+
break
|
|
329
|
+
except Exception as exc:
|
|
330
|
+
logger.exception("Store TTL sweep iteration failed", exc_info=exc)
|
|
331
|
+
|
|
332
|
+
task = asyncio.create_task(_sweep_loop())
|
|
333
|
+
task.set_name("ttl_sweeper")
|
|
334
|
+
self._ttl_sweeper_task = task
|
|
335
|
+
return task
|
|
336
|
+
|
|
337
|
+
async def stop_ttl_sweeper(self, timeout: Optional[float] = None) -> bool:
|
|
338
|
+
"""Stop the TTL sweeper task if it's running.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
timeout: Maximum time to wait for the task to stop, in seconds.
|
|
342
|
+
If None, wait indefinitely.
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
bool: True if the task was successfully stopped or wasn't running,
|
|
346
|
+
False if the timeout was reached before the task stopped.
|
|
347
|
+
"""
|
|
348
|
+
if self._ttl_sweeper_task is None or self._ttl_sweeper_task.done():
|
|
349
|
+
return True
|
|
350
|
+
|
|
351
|
+
logger.info("Stopping TTL sweeper task")
|
|
352
|
+
self._ttl_stop_event.set()
|
|
353
|
+
|
|
354
|
+
if timeout is not None:
|
|
355
|
+
try:
|
|
356
|
+
await asyncio.wait_for(self._ttl_sweeper_task, timeout=timeout)
|
|
357
|
+
success = True
|
|
358
|
+
except asyncio.TimeoutError:
|
|
359
|
+
success = False
|
|
360
|
+
else:
|
|
361
|
+
await self._ttl_sweeper_task
|
|
362
|
+
success = True
|
|
363
|
+
|
|
364
|
+
if success:
|
|
365
|
+
self._ttl_sweeper_task = None
|
|
366
|
+
logger.info("TTL sweeper task stopped")
|
|
367
|
+
else:
|
|
368
|
+
logger.warning("Timed out waiting for TTL sweeper task to stop")
|
|
369
|
+
|
|
370
|
+
return success
|
|
371
|
+
|
|
372
|
+
async def __aenter__(self) -> "AsyncPostgresStore":
|
|
373
|
+
return self
|
|
374
|
+
|
|
375
|
+
async def __aexit__(
|
|
376
|
+
self,
|
|
377
|
+
exc_type: Optional[type[BaseException]],
|
|
378
|
+
exc_val: Optional[BaseException],
|
|
379
|
+
exc_tb: Optional["TracebackType"],
|
|
380
|
+
) -> None:
|
|
381
|
+
# Ensure the TTL sweeper task is stopped when exiting the context
|
|
382
|
+
if hasattr(self, "_ttl_sweeper_task") and self._ttl_sweeper_task is not None:
|
|
383
|
+
# Set the event to signal the task to stop
|
|
384
|
+
self._ttl_stop_event.set()
|
|
385
|
+
# We don't wait for the task to complete here to avoid blocking
|
|
386
|
+
# The task will clean up itself gracefully
|
|
387
|
+
|
|
259
388
|
async def _execute_batch(
|
|
260
389
|
self,
|
|
261
390
|
grouped_ops: dict,
|
|
@@ -360,7 +489,7 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
|
|
|
360
489
|
for (idx, _), vector in zip(embedding_requests, vectors):
|
|
361
490
|
_paramslist = queries[idx][1]
|
|
362
491
|
for i in range(len(_paramslist)):
|
|
363
|
-
if _paramslist[i] is
|
|
492
|
+
if _paramslist[i] is PLACEHOLDER:
|
|
364
493
|
_paramslist[i] = vector
|
|
365
494
|
|
|
366
495
|
for (idx, _), (query, params) in zip(search_ops, queries):
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import concurrent.futures
|
|
2
3
|
import json
|
|
3
4
|
import logging
|
|
4
5
|
import threading
|
|
@@ -39,6 +40,7 @@ from langgraph.store.base import (
|
|
|
39
40
|
Result,
|
|
40
41
|
SearchItem,
|
|
41
42
|
SearchOp,
|
|
43
|
+
TTLConfig,
|
|
42
44
|
ensure_embeddings,
|
|
43
45
|
get_text_at_path,
|
|
44
46
|
tokenize_path,
|
|
@@ -73,6 +75,17 @@ CREATE TABLE IF NOT EXISTS store (
|
|
|
73
75
|
"""
|
|
74
76
|
-- For faster lookups by prefix
|
|
75
77
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS store_prefix_idx ON store USING btree (prefix text_pattern_ops);
|
|
78
|
+
""",
|
|
79
|
+
"""
|
|
80
|
+
-- Add expires_at column to store table
|
|
81
|
+
ALTER TABLE store
|
|
82
|
+
ADD COLUMN expires_at TIMESTAMP WITH TIME ZONE,
|
|
83
|
+
ADD COLUMN ttl_minutes INT;
|
|
84
|
+
""",
|
|
85
|
+
"""
|
|
86
|
+
-- Add indexes for efficient TTL sweeping
|
|
87
|
+
CREATE INDEX idx_store_expires_at ON store (expires_at)
|
|
88
|
+
WHERE expires_at IS NOT NULL;
|
|
76
89
|
""",
|
|
77
90
|
]
|
|
78
91
|
|
|
@@ -224,20 +237,55 @@ class BasePostgresStore(Generic[C]):
|
|
|
224
237
|
self,
|
|
225
238
|
get_ops: Sequence[tuple[int, GetOp]],
|
|
226
239
|
) -> list[tuple[str, tuple, tuple[str, ...], list]]:
|
|
240
|
+
"""
|
|
241
|
+
Build queries to fetch (and optionally refresh the TTL of) multiple keys per namespace.
|
|
242
|
+
|
|
243
|
+
Each returned element is a tuple of:
|
|
244
|
+
(sql_query_string, sql_params, namespace, items_for_this_namespace)
|
|
245
|
+
|
|
246
|
+
where items_for_this_namespace is the original list of (idx, key, refresh_ttl).
|
|
247
|
+
"""
|
|
248
|
+
|
|
227
249
|
namespace_groups = defaultdict(list)
|
|
250
|
+
refresh_ttls = defaultdict(list)
|
|
228
251
|
for idx, op in get_ops:
|
|
229
252
|
namespace_groups[op.namespace].append((idx, op.key))
|
|
253
|
+
refresh_ttls[op.namespace].append(op.refresh_ttl)
|
|
254
|
+
|
|
230
255
|
results = []
|
|
231
256
|
for namespace, items in namespace_groups.items():
|
|
232
257
|
_, keys = zip(*items)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
258
|
+
this_refresh_ttls = refresh_ttls[namespace]
|
|
259
|
+
|
|
260
|
+
query = """
|
|
261
|
+
WITH passed_in AS (
|
|
262
|
+
SELECT unnest(%s::text[]) AS key,
|
|
263
|
+
unnest(%s::bool[]) AS do_refresh
|
|
264
|
+
),
|
|
265
|
+
updated AS (
|
|
266
|
+
UPDATE store s
|
|
267
|
+
SET expires_at = NOW() + (s.ttl_minutes || ' minutes')::interval
|
|
268
|
+
FROM passed_in p
|
|
269
|
+
WHERE s.prefix = %s
|
|
270
|
+
AND s.key = p.key
|
|
271
|
+
AND p.do_refresh = TRUE
|
|
272
|
+
AND s.ttl_minutes IS NOT NULL
|
|
273
|
+
RETURNING s.key
|
|
274
|
+
)
|
|
275
|
+
SELECT s.key, s.value, s.created_at, s.updated_at
|
|
276
|
+
FROM store s
|
|
277
|
+
JOIN passed_in p ON s.key = p.key
|
|
278
|
+
WHERE s.prefix = %s
|
|
238
279
|
"""
|
|
239
|
-
|
|
280
|
+
ns_text = _namespace_to_text(namespace)
|
|
281
|
+
params = (
|
|
282
|
+
list(keys), # -> unnest(%s::text[])
|
|
283
|
+
list(this_refresh_ttls), # -> unnest(%s::bool[])
|
|
284
|
+
ns_text, # -> prefix = %s (for UPDATE)
|
|
285
|
+
ns_text, # -> prefix = %s (for final SELECT)
|
|
286
|
+
)
|
|
240
287
|
results.append((query, params, namespace, items))
|
|
288
|
+
|
|
241
289
|
return results
|
|
242
290
|
|
|
243
291
|
def _prepare_batch_PUT_queries(
|
|
@@ -247,7 +295,6 @@ class BasePostgresStore(Generic[C]):
|
|
|
247
295
|
list[tuple[str, Sequence]],
|
|
248
296
|
Optional[tuple[str, Sequence[tuple[str, str, str, str]]]],
|
|
249
297
|
]:
|
|
250
|
-
# Last-write wins
|
|
251
298
|
dedupped_ops: dict[tuple[tuple[str, ...], str], PutOp] = {}
|
|
252
299
|
for _, op in put_ops:
|
|
253
300
|
dedupped_ops[(op.namespace, op.key)] = op
|
|
@@ -281,15 +328,26 @@ class BasePostgresStore(Generic[C]):
|
|
|
281
328
|
insertion_params = []
|
|
282
329
|
vector_values = []
|
|
283
330
|
embedding_request_params = []
|
|
331
|
+
# Handle TTL expiration
|
|
284
332
|
|
|
285
333
|
# First handle main store insertions
|
|
286
334
|
for op in inserts:
|
|
287
|
-
|
|
335
|
+
if op.ttl is not None:
|
|
336
|
+
expires_at_str = f"NOW() + INTERVAL '{op.ttl*60} seconds'"
|
|
337
|
+
ttl_minutes = op.ttl
|
|
338
|
+
else:
|
|
339
|
+
expires_at_str = "NULL"
|
|
340
|
+
ttl_minutes = None
|
|
341
|
+
|
|
342
|
+
values.append(
|
|
343
|
+
f"(%s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, {expires_at_str}, %s)"
|
|
344
|
+
)
|
|
288
345
|
insertion_params.extend(
|
|
289
346
|
[
|
|
290
347
|
_namespace_to_text(op.namespace),
|
|
291
348
|
op.key,
|
|
292
349
|
Jsonb(cast(dict, op.value)),
|
|
350
|
+
ttl_minutes,
|
|
293
351
|
]
|
|
294
352
|
)
|
|
295
353
|
|
|
@@ -303,7 +361,7 @@ class BasePostgresStore(Generic[C]):
|
|
|
303
361
|
k = op.key
|
|
304
362
|
|
|
305
363
|
if op.index is None:
|
|
306
|
-
paths = self.index_config["__tokenized_fields"]
|
|
364
|
+
paths = cast(dict, self.index_config)["__tokenized_fields"]
|
|
307
365
|
else:
|
|
308
366
|
paths = [(ix, tokenize_path(ix)) for ix in op.index]
|
|
309
367
|
|
|
@@ -318,11 +376,13 @@ class BasePostgresStore(Generic[C]):
|
|
|
318
376
|
|
|
319
377
|
values_str = ",".join(values)
|
|
320
378
|
query = f"""
|
|
321
|
-
INSERT INTO store (prefix, key, value, created_at, updated_at)
|
|
379
|
+
INSERT INTO store (prefix, key, value, created_at, updated_at, expires_at, ttl_minutes)
|
|
322
380
|
VALUES {values_str}
|
|
323
381
|
ON CONFLICT (prefix, key) DO UPDATE
|
|
324
382
|
SET value = EXCLUDED.value,
|
|
325
|
-
updated_at = CURRENT_TIMESTAMP
|
|
383
|
+
updated_at = CURRENT_TIMESTAMP,
|
|
384
|
+
expires_at = EXCLUDED.expires_at,
|
|
385
|
+
ttl_minutes = EXCLUDED.ttl_minutes
|
|
326
386
|
"""
|
|
327
387
|
queries.append((query, insertion_params))
|
|
328
388
|
|
|
@@ -346,117 +406,151 @@ class BasePostgresStore(Generic[C]):
|
|
|
346
406
|
list[tuple[str, list[Union[None, str, list[float]]]]], # queries, params
|
|
347
407
|
list[tuple[int, str]], # idx, query_text pairs to embed
|
|
348
408
|
]:
|
|
409
|
+
"""
|
|
410
|
+
Build per-SearchOp SQL queries (with optional TTL refresh) plus embedding requests.
|
|
411
|
+
Returns:
|
|
412
|
+
- queries: list of (SQL, param_list)
|
|
413
|
+
- embedding_requests: list of (original_index_in_search_ops, text_query)
|
|
414
|
+
"""
|
|
415
|
+
|
|
349
416
|
queries = []
|
|
350
417
|
embedding_requests = []
|
|
351
|
-
|
|
352
418
|
for idx, (_, op) in enumerate(search_ops):
|
|
353
|
-
# Build filter conditions first
|
|
354
419
|
filter_params = []
|
|
355
|
-
|
|
420
|
+
filter_clauses = []
|
|
356
421
|
if op.filter:
|
|
357
422
|
for key, value in op.filter.items():
|
|
358
423
|
if isinstance(value, dict):
|
|
359
424
|
for op_name, val in value.items():
|
|
360
|
-
condition,
|
|
425
|
+
condition, params_ = self._get_filter_condition(
|
|
361
426
|
key, op_name, val
|
|
362
427
|
)
|
|
363
|
-
|
|
364
|
-
filter_params.extend(
|
|
428
|
+
filter_clauses.append(condition)
|
|
429
|
+
filter_params.extend(params_)
|
|
365
430
|
else:
|
|
366
|
-
|
|
367
|
-
filter_params.extend([key,
|
|
431
|
+
filter_clauses.append("value->%s = %s::jsonb")
|
|
432
|
+
filter_params.extend([key, orjson.dumps(value).decode("utf-8")])
|
|
433
|
+
|
|
434
|
+
ns_condition = "TRUE"
|
|
435
|
+
ns_param: Optional[Sequence[Union[str]]] = None
|
|
436
|
+
if op.namespace_prefix:
|
|
437
|
+
ns_condition = "store.prefix LIKE %s"
|
|
438
|
+
ns_param = (f"{_namespace_to_text(op.namespace_prefix)}%",)
|
|
439
|
+
else:
|
|
440
|
+
ns_param = ()
|
|
441
|
+
|
|
442
|
+
extra_filters = (
|
|
443
|
+
" AND " + " AND ".join(filter_clauses) if filter_clauses else ""
|
|
444
|
+
)
|
|
368
445
|
|
|
369
|
-
# Vector search branch
|
|
370
446
|
if op.query and self.index_config:
|
|
447
|
+
# We'll embed the text later, so record the request.
|
|
371
448
|
embedding_requests.append((idx, op.query))
|
|
372
449
|
|
|
373
|
-
score_operator, post_operator =
|
|
450
|
+
score_operator, post_operator = get_distance_operator(self)
|
|
451
|
+
post_operator = post_operator.replace("scored", "uniq")
|
|
374
452
|
vector_type = (
|
|
375
453
|
cast(PostgresIndexConfig, self.index_config)
|
|
376
454
|
.get("ann_index_config", {})
|
|
377
455
|
.get("vector_type", "vector")
|
|
378
456
|
)
|
|
379
457
|
|
|
458
|
+
# For hamming bit vectors, or “regular” vectors
|
|
380
459
|
if (
|
|
381
460
|
vector_type == "bit"
|
|
382
|
-
and self.index_config.get("distance_type") == "hamming"
|
|
461
|
+
and cast(dict, self.index_config).get("distance_type") == "hamming"
|
|
383
462
|
):
|
|
384
463
|
score_operator = score_operator % (
|
|
385
464
|
"%s",
|
|
386
|
-
self.index_config["dims"],
|
|
465
|
+
cast(dict, self.index_config)["dims"],
|
|
387
466
|
)
|
|
388
467
|
else:
|
|
389
|
-
score_operator = score_operator % (
|
|
390
|
-
"%s",
|
|
391
|
-
vector_type,
|
|
392
|
-
)
|
|
468
|
+
score_operator = score_operator % ("%s", vector_type)
|
|
393
469
|
|
|
394
|
-
vectors_per_doc_estimate = self.index_config[
|
|
470
|
+
vectors_per_doc_estimate = cast(dict, self.index_config)[
|
|
471
|
+
"__estimated_num_vectors"
|
|
472
|
+
]
|
|
395
473
|
expanded_limit = (op.limit * vectors_per_doc_estimate * 2) + 1
|
|
396
474
|
|
|
397
|
-
#
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
prefix_filter_str = f"WHERE {filter_str} "
|
|
410
|
-
else:
|
|
411
|
-
prefix_filter_str = ""
|
|
475
|
+
# “sub_scored” does the main vector search
|
|
476
|
+
# Then we do DISTINCT ON to drop duplicates if your store can have them
|
|
477
|
+
# Finally we limit & offset
|
|
478
|
+
vector_search_cte = f"""
|
|
479
|
+
SELECT store.prefix, store.key, store.value, store.created_at, store.updated_at,
|
|
480
|
+
{score_operator} AS neg_score
|
|
481
|
+
FROM store
|
|
482
|
+
JOIN store_vectors sv ON store.prefix = sv.prefix AND store.key = sv.key
|
|
483
|
+
WHERE {ns_condition} {extra_filters}
|
|
484
|
+
ORDER BY {score_operator} ASC
|
|
485
|
+
LIMIT %s
|
|
486
|
+
"""
|
|
412
487
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
488
|
+
search_results_sql = f"""
|
|
489
|
+
WITH scored AS (
|
|
490
|
+
{vector_search_cte}
|
|
491
|
+
)
|
|
492
|
+
SELECT uniq.prefix, uniq.key, uniq.value, uniq.created_at, uniq.updated_at,
|
|
493
|
+
{post_operator} AS score
|
|
494
|
+
FROM (
|
|
495
|
+
SELECT DISTINCT ON (scored.prefix, scored.key)
|
|
496
|
+
scored.prefix, scored.key, scored.value, scored.created_at, scored.updated_at, scored.neg_score
|
|
497
|
+
FROM scored
|
|
498
|
+
ORDER BY scored.prefix, scored.key, scored.neg_score ASC
|
|
499
|
+
) uniq
|
|
500
|
+
ORDER BY score DESC
|
|
420
501
|
LIMIT %s
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
) AS unique_docs
|
|
428
|
-
ORDER BY score DESC
|
|
429
|
-
LIMIT %s
|
|
430
|
-
OFFSET %s
|
|
431
|
-
"""
|
|
432
|
-
params = [
|
|
433
|
-
_PLACEHOLDER, # Vector placeholder
|
|
434
|
-
*ns_args,
|
|
502
|
+
OFFSET %s
|
|
503
|
+
"""
|
|
504
|
+
|
|
505
|
+
search_results_params = [
|
|
506
|
+
PLACEHOLDER,
|
|
507
|
+
*ns_param,
|
|
435
508
|
*filter_params,
|
|
436
|
-
|
|
509
|
+
PLACEHOLDER,
|
|
437
510
|
expanded_limit,
|
|
438
511
|
op.limit,
|
|
439
512
|
op.offset,
|
|
440
513
|
]
|
|
441
514
|
|
|
442
|
-
# Regular search branch
|
|
443
515
|
else:
|
|
444
|
-
base_query = """
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
516
|
+
base_query = f"""
|
|
517
|
+
SELECT store.prefix, store.key, store.value, store.created_at, store.updated_at, NULL AS score
|
|
518
|
+
FROM store
|
|
519
|
+
WHERE {ns_condition} {extra_filters}
|
|
520
|
+
ORDER BY store.updated_at DESC
|
|
521
|
+
LIMIT %s
|
|
522
|
+
OFFSET %s
|
|
523
|
+
"""
|
|
524
|
+
search_results_sql = base_query
|
|
525
|
+
search_results_params = [
|
|
526
|
+
*ns_param,
|
|
527
|
+
*filter_params,
|
|
528
|
+
op.limit,
|
|
529
|
+
op.offset,
|
|
530
|
+
]
|
|
458
531
|
|
|
459
|
-
|
|
532
|
+
if op.refresh_ttl:
|
|
533
|
+
# Wrap entire primary query in a CTE, then perform "update_at"
|
|
534
|
+
final_sql = f"""
|
|
535
|
+
WITH search_results AS (
|
|
536
|
+
{search_results_sql}
|
|
537
|
+
),
|
|
538
|
+
updated AS (
|
|
539
|
+
UPDATE store s
|
|
540
|
+
SET expires_at = NOW() + (s.ttl_minutes || ' minutes')::interval
|
|
541
|
+
FROM search_results sr
|
|
542
|
+
WHERE s.prefix = sr.prefix
|
|
543
|
+
AND s.key = sr.key
|
|
544
|
+
AND s.ttl_minutes IS NOT NULL
|
|
545
|
+
)
|
|
546
|
+
SELECT sr.prefix, sr.key, sr.value, sr.created_at, sr.updated_at, sr.score
|
|
547
|
+
FROM search_results sr
|
|
548
|
+
"""
|
|
549
|
+
final_params = search_results_params[:] # copy
|
|
550
|
+
else:
|
|
551
|
+
final_sql = search_results_sql
|
|
552
|
+
final_params = search_results_params
|
|
553
|
+
queries.append((final_sql, final_params))
|
|
460
554
|
|
|
461
555
|
return queries, embedding_requests
|
|
462
556
|
|
|
@@ -602,6 +696,11 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
|
|
|
602
696
|
Make sure to call `setup()` before first use to create necessary tables and indexes.
|
|
603
697
|
The pgvector extension must be available to use vector search.
|
|
604
698
|
|
|
699
|
+
Note:
|
|
700
|
+
If you provide a TTL configuration, you must explicitly call `start_ttl_sweeper()` to begin
|
|
701
|
+
the background thread that removes expired items. Call `stop_ttl_sweeper()` to properly
|
|
702
|
+
clean up resources when you're done with the store.
|
|
703
|
+
|
|
605
704
|
"""
|
|
606
705
|
|
|
607
706
|
__slots__ = (
|
|
@@ -611,7 +710,10 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
|
|
|
611
710
|
"supports_pipeline",
|
|
612
711
|
"index_config",
|
|
613
712
|
"embeddings",
|
|
713
|
+
"_ttl_sweeper_thread",
|
|
714
|
+
"_ttl_stop_event",
|
|
614
715
|
)
|
|
716
|
+
supports_ttl: bool = True
|
|
615
717
|
|
|
616
718
|
def __init__(
|
|
617
719
|
self,
|
|
@@ -622,6 +724,7 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
|
|
|
622
724
|
Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
|
|
623
725
|
] = None,
|
|
624
726
|
index: Optional[PostgresIndexConfig] = None,
|
|
727
|
+
ttl: Optional[TTLConfig] = None,
|
|
625
728
|
) -> None:
|
|
626
729
|
super().__init__()
|
|
627
730
|
self._deserializer = deserializer
|
|
@@ -634,6 +737,9 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
|
|
|
634
737
|
self.embeddings, self.index_config = _ensure_index_config(self.index_config)
|
|
635
738
|
else:
|
|
636
739
|
self.embeddings = None
|
|
740
|
+
self.ttl_config = ttl
|
|
741
|
+
self._ttl_sweeper_thread: Optional[threading.Thread] = None
|
|
742
|
+
self._ttl_stop_event = threading.Event()
|
|
637
743
|
|
|
638
744
|
@classmethod
|
|
639
745
|
@contextmanager
|
|
@@ -644,6 +750,7 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
|
|
|
644
750
|
pipeline: bool = False,
|
|
645
751
|
pool_config: Optional[PoolConfig] = None,
|
|
646
752
|
index: Optional[PostgresIndexConfig] = None,
|
|
753
|
+
ttl: Optional[TTLConfig] = None,
|
|
647
754
|
) -> Iterator["PostgresStore"]:
|
|
648
755
|
"""Create a new PostgresStore instance from a connection string.
|
|
649
756
|
|
|
@@ -675,16 +782,123 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
|
|
|
675
782
|
**cast(dict, pc),
|
|
676
783
|
),
|
|
677
784
|
) as pool:
|
|
678
|
-
yield cls(conn=pool, index=index)
|
|
785
|
+
yield cls(conn=pool, index=index, ttl=ttl)
|
|
679
786
|
else:
|
|
680
787
|
with Connection.connect(
|
|
681
788
|
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
|
|
682
789
|
) as conn:
|
|
683
790
|
if pipeline:
|
|
684
791
|
with conn.pipeline() as pipe:
|
|
685
|
-
yield cls(conn, pipe=pipe, index=index)
|
|
792
|
+
yield cls(conn, pipe=pipe, index=index, ttl=ttl)
|
|
686
793
|
else:
|
|
687
|
-
yield cls(conn, index=index)
|
|
794
|
+
yield cls(conn, index=index, ttl=ttl)
|
|
795
|
+
|
|
796
|
+
def sweep_ttl(self) -> int:
|
|
797
|
+
"""Delete expired store items based on TTL.
|
|
798
|
+
|
|
799
|
+
Returns:
|
|
800
|
+
int: The number of deleted items.
|
|
801
|
+
"""
|
|
802
|
+
with self._cursor() as cur:
|
|
803
|
+
cur.execute(
|
|
804
|
+
"""
|
|
805
|
+
DELETE FROM store
|
|
806
|
+
WHERE expires_at IS NOT NULL AND expires_at < NOW()
|
|
807
|
+
"""
|
|
808
|
+
)
|
|
809
|
+
deleted_count = cur.rowcount
|
|
810
|
+
return deleted_count
|
|
811
|
+
|
|
812
|
+
def start_ttl_sweeper(
|
|
813
|
+
self, sweep_interval_minutes: Optional[int] = None
|
|
814
|
+
) -> concurrent.futures.Future[None]:
|
|
815
|
+
"""Periodically delete expired store items based on TTL.
|
|
816
|
+
|
|
817
|
+
Returns:
|
|
818
|
+
Future that can be waited on or cancelled.
|
|
819
|
+
"""
|
|
820
|
+
if not self.ttl_config:
|
|
821
|
+
future: concurrent.futures.Future[None] = concurrent.futures.Future()
|
|
822
|
+
future.set_result(None)
|
|
823
|
+
return future
|
|
824
|
+
|
|
825
|
+
if self._ttl_sweeper_thread and self._ttl_sweeper_thread.is_alive():
|
|
826
|
+
logger.info("TTL sweeper thread is already running")
|
|
827
|
+
# Return a future that can be used to cancel the existing thread
|
|
828
|
+
future = concurrent.futures.Future()
|
|
829
|
+
future.add_done_callback(
|
|
830
|
+
lambda f: self._ttl_stop_event.set() if f.cancelled() else None
|
|
831
|
+
)
|
|
832
|
+
return future
|
|
833
|
+
|
|
834
|
+
self._ttl_stop_event.clear()
|
|
835
|
+
|
|
836
|
+
interval = float(
|
|
837
|
+
sweep_interval_minutes or self.ttl_config.get("sweep_interval_minutes") or 5
|
|
838
|
+
)
|
|
839
|
+
logger.info(f"Starting store TTL sweeper with interval {interval} minutes")
|
|
840
|
+
|
|
841
|
+
future = concurrent.futures.Future()
|
|
842
|
+
|
|
843
|
+
def _sweep_loop() -> None:
|
|
844
|
+
try:
|
|
845
|
+
while not self._ttl_stop_event.is_set():
|
|
846
|
+
if self._ttl_stop_event.wait(interval * 60):
|
|
847
|
+
break
|
|
848
|
+
|
|
849
|
+
try:
|
|
850
|
+
expired_items = self.sweep_ttl()
|
|
851
|
+
if expired_items > 0:
|
|
852
|
+
logger.info(f"Store swept {expired_items} expired items")
|
|
853
|
+
except Exception as exc:
|
|
854
|
+
logger.exception(
|
|
855
|
+
"Store TTL sweep iteration failed", exc_info=exc
|
|
856
|
+
)
|
|
857
|
+
future.set_result(None)
|
|
858
|
+
except Exception as exc:
|
|
859
|
+
future.set_exception(exc)
|
|
860
|
+
|
|
861
|
+
thread = threading.Thread(target=_sweep_loop, daemon=True, name="ttl-sweeper")
|
|
862
|
+
self._ttl_sweeper_thread = thread
|
|
863
|
+
thread.start()
|
|
864
|
+
|
|
865
|
+
future.add_done_callback(
|
|
866
|
+
lambda f: self._ttl_stop_event.set() if f.cancelled() else None
|
|
867
|
+
)
|
|
868
|
+
return future
|
|
869
|
+
|
|
870
|
+
def stop_ttl_sweeper(self, timeout: Optional[float] = None) -> bool:
|
|
871
|
+
"""Stop the TTL sweeper thread if it's running.
|
|
872
|
+
|
|
873
|
+
Args:
|
|
874
|
+
timeout: Maximum time to wait for the thread to stop, in seconds.
|
|
875
|
+
If None, wait indefinitely.
|
|
876
|
+
|
|
877
|
+
Returns:
|
|
878
|
+
bool: True if the thread was successfully stopped or wasn't running,
|
|
879
|
+
False if the timeout was reached before the thread stopped.
|
|
880
|
+
"""
|
|
881
|
+
if not self._ttl_sweeper_thread or not self._ttl_sweeper_thread.is_alive():
|
|
882
|
+
return True
|
|
883
|
+
|
|
884
|
+
logger.info("Stopping TTL sweeper thread")
|
|
885
|
+
self._ttl_stop_event.set()
|
|
886
|
+
|
|
887
|
+
self._ttl_sweeper_thread.join(timeout)
|
|
888
|
+
success = not self._ttl_sweeper_thread.is_alive()
|
|
889
|
+
|
|
890
|
+
if success:
|
|
891
|
+
self._ttl_sweeper_thread = None
|
|
892
|
+
logger.info("TTL sweeper thread stopped")
|
|
893
|
+
else:
|
|
894
|
+
logger.warning("Timed out waiting for TTL sweeper thread to stop")
|
|
895
|
+
|
|
896
|
+
return success
|
|
897
|
+
|
|
898
|
+
def __del__(self) -> None:
|
|
899
|
+
"""Ensure the TTL sweeper thread is stopped when the object is garbage collected."""
|
|
900
|
+
if hasattr(self, "_ttl_stop_event") and hasattr(self, "_ttl_sweeper_thread"):
|
|
901
|
+
self.stop_ttl_sweeper(timeout=0.1)
|
|
688
902
|
|
|
689
903
|
@contextmanager
|
|
690
904
|
def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor[DictRow]]:
|
|
@@ -828,7 +1042,7 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
|
|
|
828
1042
|
for (idx, _), embedding in zip(embedding_requests, embeddings):
|
|
829
1043
|
_paramslist = queries[idx][1]
|
|
830
1044
|
for i in range(len(_paramslist)):
|
|
831
|
-
if _paramslist[i] is
|
|
1045
|
+
if _paramslist[i] is PLACEHOLDER:
|
|
832
1046
|
_paramslist[i] = embedding
|
|
833
1047
|
|
|
834
1048
|
for (idx, _), (query, params) in zip(search_ops, queries):
|
|
@@ -883,8 +1097,14 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
|
|
|
883
1097
|
with self._cursor() as cur:
|
|
884
1098
|
version = _get_version(cur, table="store_migrations")
|
|
885
1099
|
for v, sql in enumerate(self.MIGRATIONS[version + 1 :], start=version + 1):
|
|
886
|
-
|
|
887
|
-
|
|
1100
|
+
try:
|
|
1101
|
+
cur.execute(sql)
|
|
1102
|
+
cur.execute("INSERT INTO store_migrations (v) VALUES (%s)", (v,))
|
|
1103
|
+
except Exception as e:
|
|
1104
|
+
logger.error(
|
|
1105
|
+
f"Failed to apply migration {v}.\nSql={sql}\nError={e}"
|
|
1106
|
+
)
|
|
1107
|
+
raise
|
|
888
1108
|
|
|
889
1109
|
if self.index_config:
|
|
890
1110
|
version = _get_version(cur, table="vector_migrations")
|
|
@@ -1055,7 +1275,7 @@ def _decode_ns_bytes(namespace: Union[str, bytes, list]) -> tuple[str, ...]:
|
|
|
1055
1275
|
return tuple(namespace.split("."))
|
|
1056
1276
|
|
|
1057
1277
|
|
|
1058
|
-
def
|
|
1278
|
+
def get_distance_operator(store: Any) -> tuple[str, str]:
|
|
1059
1279
|
"""Get the distance operator and score expression based on config."""
|
|
1060
1280
|
# Note: Today, we are not using ANN indices due to restrictions
|
|
1061
1281
|
# on PGVector's support for mixing vector and non-vector filters
|
|
@@ -1121,4 +1341,4 @@ def _ensure_index_config(
|
|
|
1121
1341
|
return embeddings, index_config
|
|
1122
1342
|
|
|
1123
1343
|
|
|
1124
|
-
|
|
1344
|
+
PLACEHOLDER = object()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|