langgraph-checkpoint-postgres 2.0.16__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.
Files changed (14) hide show
  1. {langgraph_checkpoint_postgres-2.0.16 → langgraph_checkpoint_postgres-2.0.17}/PKG-INFO +1 -1
  2. {langgraph_checkpoint_postgres-2.0.16 → langgraph_checkpoint_postgres-2.0.17}/langgraph/store/postgres/aio.py +133 -4
  3. {langgraph_checkpoint_postgres-2.0.16 → langgraph_checkpoint_postgres-2.0.17}/langgraph/store/postgres/base.py +304 -84
  4. {langgraph_checkpoint_postgres-2.0.16 → langgraph_checkpoint_postgres-2.0.17}/pyproject.toml +1 -1
  5. {langgraph_checkpoint_postgres-2.0.16 → langgraph_checkpoint_postgres-2.0.17}/README.md +0 -0
  6. {langgraph_checkpoint_postgres-2.0.16 → langgraph_checkpoint_postgres-2.0.17}/langgraph/checkpoint/postgres/__init__.py +0 -0
  7. {langgraph_checkpoint_postgres-2.0.16 → langgraph_checkpoint_postgres-2.0.17}/langgraph/checkpoint/postgres/_ainternal.py +0 -0
  8. {langgraph_checkpoint_postgres-2.0.16 → langgraph_checkpoint_postgres-2.0.17}/langgraph/checkpoint/postgres/_internal.py +0 -0
  9. {langgraph_checkpoint_postgres-2.0.16 → langgraph_checkpoint_postgres-2.0.17}/langgraph/checkpoint/postgres/aio.py +0 -0
  10. {langgraph_checkpoint_postgres-2.0.16 → langgraph_checkpoint_postgres-2.0.17}/langgraph/checkpoint/postgres/base.py +0 -0
  11. {langgraph_checkpoint_postgres-2.0.16 → langgraph_checkpoint_postgres-2.0.17}/langgraph/checkpoint/postgres/py.typed +0 -0
  12. {langgraph_checkpoint_postgres-2.0.16 → langgraph_checkpoint_postgres-2.0.17}/langgraph/checkpoint/postgres/shallow.py +0 -0
  13. {langgraph_checkpoint_postgres-2.0.16 → langgraph_checkpoint_postgres-2.0.17}/langgraph/store/postgres/__init__.py +0 -0
  14. {langgraph_checkpoint_postgres-2.0.16 → langgraph_checkpoint_postgres-2.0.17}/langgraph/store/postgres/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: langgraph-checkpoint-postgres
3
- Version: 2.0.16
3
+ Version: 2.0.17
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
@@ -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
@@ -25,6 +26,7 @@ from langgraph.store.postgres.base import (
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,
@@ -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
- keys_to_query = ",".join(["%s"] * len(keys))
234
- query = f"""
235
- SELECT key, value, created_at, updated_at
236
- FROM store
237
- WHERE prefix = %s AND key IN ({keys_to_query})
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
- params = (_namespace_to_text(namespace), *keys)
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
- values.append("(%s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)")
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,92 +406,105 @@ 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
- filter_conditions = []
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, filter_params_ = self._get_filter_condition(
425
+ condition, params_ = self._get_filter_condition(
361
426
  key, op_name, val
362
427
  )
363
- filter_conditions.append(condition)
364
- filter_params.extend(filter_params_)
428
+ filter_clauses.append(condition)
429
+ filter_params.extend(params_)
365
430
  else:
366
- filter_conditions.append("value->%s = %s::jsonb")
367
- filter_params.extend([key, json.dumps(value)])
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
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["__estimated_num_vectors"]
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
- # Vector search with CTE for proper score handling
398
- filter_str = (
399
- ""
400
- if not filter_conditions
401
- else " AND " + " AND ".join(filter_conditions)
402
- )
403
- if op.namespace_prefix:
404
- prefix_filter_str = f"WHERE s.prefix LIKE %s {filter_str} "
405
- ns_args: Sequence = (f"{_namespace_to_text(op.namespace_prefix)}%",)
406
- else:
407
- ns_args = ()
408
- if filter_str:
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
- base_query = f"""
414
- WITH scored AS (
415
- SELECT s.prefix, s.key, s.value, s.created_at, s.updated_at, {score_operator} AS neg_score
416
- FROM store s
417
- JOIN store_vectors sv ON s.prefix = sv.prefix AND s.key = sv.key
418
- {prefix_filter_str}
419
- ORDER BY {score_operator} ASC
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
- SELECT * FROM (
423
- SELECT DISTINCT ON (prefix, key)
424
- prefix, key, value, created_at, updated_at, {post_operator} as score
425
- FROM scored
426
- ORDER BY prefix, key, score DESC
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,
@@ -439,24 +512,45 @@ class BasePostgresStore(Generic[C]):
439
512
  op.offset,
440
513
  ]
441
514
 
442
- # Regular search branch
443
515
  else:
444
- base_query = """
445
- SELECT prefix, key, value, created_at, updated_at
446
- FROM store
447
- WHERE prefix LIKE %s
448
- """
449
- params = [f"{_namespace_to_text(op.namespace_prefix)}%"]
450
-
451
- if filter_conditions:
452
- params.extend(filter_params)
453
- base_query += " AND " + " AND ".join(filter_conditions)
454
-
455
- base_query += " ORDER BY updated_at DESC"
456
- base_query += " LIMIT %s OFFSET %s"
457
- params.extend([op.limit, op.offset])
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
- queries.append((base_query, params))
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]]:
@@ -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
- cur.execute(sql)
887
- cur.execute("INSERT INTO store_migrations (v) VALUES (%s)", (v,))
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")
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "langgraph-checkpoint-postgres"
3
- version = "2.0.16"
3
+ version = "2.0.17"
4
4
  description = "Library with a Postgres implementation of LangGraph checkpoint saver."
5
5
  authors = []
6
6
  license = "MIT"