Proposal: add safe keep_latest pruning to PostgresSaver

Hi maintainers,

I would like to propose implementing the existing checkpoint prune interface for PostgresSaver and AsyncPostgresSaver.

For an initial contribution, I propose supporting safe pruning for ordinary checkpoints while failing closed for threads containing DeltaChannel state. Full DeltaChannel-aware ancestor pruning could be implemented as a follow-up if maintainers prefer that direction.

Motivation

Applications using the open-source Postgres checkpointer directly can accumulate an unbounded number of records in:

  • checkpoints
  • checkpoint_writes
  • checkpoint_blobs

LangGraph normally creates multiple checkpoints during each graph run. Application-level message trimming can reduce the size of the current state, and DeltaChannel can reduce repeated payload storage, but neither mechanism limits the number of checkpoint records.

Agent Server provides a keep_latest TTL strategy, but applications using PostgresSaver directly do not currently have an equivalent retention mechanism.

BaseCheckpointSaver already exposes a pruning interface similar to:

saver.prune(
    thread_ids,
    strategy="keep_latest",
)

However, PostgresSaver and AsyncPostgresSaver do not currently implement it.

Existing work

There is already an open Postgres implementation PR:

That PR implements prune using DISTINCT ON to retain the newest checkpoint for each thread and checkpoint namespace.

However, Postgres DeltaChannel support was added after that work began. A DeltaChannel checkpoint may depend on an earlier seed, ancestor checkpoints, and channel writes to reconstruct its current value.

Deleting every checkpoint except the newest database row could therefore make the retained state unrecoverable.

I previously asked about the intended direction on PR #6883 but have not yet received guidance.

Related Forum discussions include:

Neither discussion appears to provide a retention implementation for applications using the OSS Postgres checkpointer directly.

Proposed phase-one scope

Implement prune and aprune for:

  • PostgresSaver
  • AsyncPostgresSaver

The implementation would support the existing delete and keep_latest strategies.

delete

For the requested thread IDs:

  • Delete all checkpoints.
  • Delete all associated checkpoint writes.
  • Delete all associated checkpoint blobs.
  • Preserve data belonging to unrelated threads.

Deleting the complete thread is safe for DeltaChannel because no retained state needs to remain recoverable.

keep_latest

For ordinary, non-delta checkpoints:

  • Retain the latest checkpoint for each (thread_id, checkpoint_ns).
  • Preserve every write required to load or resume the retained state.
  • Preserve blobs referenced by the retained checkpoint’s channel versions.
  • Delete older checkpoints.
  • Delete writes that are no longer required.
  • Delete blobs that are no longer referenced.
  • Preserve data belonging to unrelated threads and namespaces.
  • Perform validation and deletion atomically.

After pruning, the retained checkpoint must remain readable through get_tuple() or aget_tuple(), and graph execution must be able to continue from it.

DeltaChannel safety

For the initial implementation, I propose failing closed when keep_latest is requested for a thread containing DeltaChannel state.

The implementation would:

  1. Inspect every requested thread before deleting anything.
  2. Detect whether any retained state uses DeltaChannel storage.
  3. Abort the entire operation if DeltaChannel state is present.
  4. Leave checkpoints, writes, and blobs unchanged.
  5. Raise a clear unsupported-operation error explaining why pruning was refused.

Conceptually:

saver.prune(
    ["ordinary-thread"],
    strategy="keep_latest",
)
# Prunes successfully.

For a DeltaChannel thread:

saver.prune(
    ["delta-thread"],
    strategy="keep_latest",
)

The operation would fail before modifying data:

keep_latest pruning is not yet supported for threads containing
DeltaChannel state because ancestor checkpoints may be required
for reconstruction. No checkpoint data was deleted.

For a mixed batch containing ordinary and DeltaChannel threads, I propose all-or-nothing behavior: validation should fail before any thread is modified.

This would prevent silent state corruption while still providing useful and safe pruning for existing non-delta Postgres users.

Why not implement DeltaChannel pruning in phase one?

Correct DeltaChannel pruning requires more than retaining the latest database row.

An implementation may need to:

  • Identify the latest logical recovery point.
  • Find the seed required by each DeltaChannel.
  • Walk the relevant ancestor checkpoint chain.
  • Retain the union of dependencies across multiple channels.
  • Preserve the required channel writes and seed blobs.
  • Handle channels whose seeds occur at different ancestor depths.
  • Preserve behavior across checkpoint namespaces and subgraphs.
  • Verify that the retained state can still be reconstructed and resumed.

I am willing to work on DeltaChannel-aware pruning as well, but I propose treating it as a follow-up unless maintainers prefer to include it in the initial implementation.

Expected behavior

For an ordinary thread:

saver.prune(
    ["thread-id"],
    strategy="keep_latest",
)

After pruning:

  • The latest checkpoint remains available for each namespace.
  • The current graph state remains recoverable.
  • Graph execution can continue.
  • Older checkpoint history is removed.
  • Unreferenced writes and blobs are removed.
  • Unrelated threads remain unchanged.

For a DeltaChannel thread:

saver.prune(
    ["delta-thread-id"],
    strategy="keep_latest",
)

After the failed operation:

  • No checkpoint has been deleted.
  • No checkpoint write has been deleted.
  • No checkpoint blob has been deleted.
  • The thread remains readable and resumable.

Proposed test coverage

I propose covering the following cases for both synchronous and asynchronous savers.

General behavior

  • Empty thread_ids input is a no-op.
  • Unknown thread IDs are a no-op.
  • Unsupported strategies raise an error without modifying data.
  • Multiple thread IDs can be supplied in one call.
  • Sync and async behavior are equivalent.

delete

  • All checkpoints for the selected threads are deleted.
  • All associated writes are deleted.
  • All associated blobs are deleted.
  • Unrelated threads are preserved.
  • DeltaChannel threads can be deleted completely.

keep_latest

  • The newest checkpoint is retained per (thread_id, checkpoint_ns).
  • Multiple checkpoint namespaces are handled independently.
  • Older checkpoints are removed.
  • The retained checkpoint can be loaded.
  • Graph execution can continue from the retained checkpoint.
  • Required pending writes remain available.
  • Blobs referenced by retained channel versions remain available.
  • Unreferenced writes are removed.
  • Unreferenced blobs are removed.
  • Unrelated threads and namespaces remain unchanged.

DeltaChannel protection

  • A DeltaChannel thread is detected before deletion.
  • keep_latest fails without modifying its data.
  • A mixed ordinary/DeltaChannel batch is not partially pruned.
  • The rejected DeltaChannel thread remains readable and resumable.

If appropriate, I would also add or extend the Postgres checkpoint conformance tests.

Non-goals for phase one

The initial contribution would not include:

  • DeltaChannel-aware ancestor pruning.
  • Keeping the latest N logical checkpoints.
  • Time-based retention.
  • A background TTL scheduler.
  • Agent Server retention APIs.
  • Changes to the existing public prune interface.

The goal is to provide a small, reviewable, and safe implementation of an interface that already exists.

Possible follow-up

A separate follow-up could implement DeltaChannel-aware keep_latest behavior by retaining:

  • The latest logical recovery point.
  • The minimum ancestor checkpoints required for reconstruction.
  • The required seed blobs.
  • The required channel writes.

The intended semantics could follow Agent Server’s Delta-aware keep_latest behavior if that implementation or its tests can be shared with the OSS checkpointer.

Questions for maintainers

  1. Would failing closed for DeltaChannel threads be acceptable for an initial implementation?
  2. Should this work extend PR #6883, or would maintainers prefer a new focused PR based on the latest main branch?
  3. Is retaining the latest checkpoint per (thread_id, checkpoint_ns) the intended OSS keep_latest contract?
  4. Is there a preferred exception type for unsupported DeltaChannel pruning?
  5. Should a mixed batch use all-or-nothing behavior when one thread contains DeltaChannel state?
  6. Are there Agent Server semantics or tests that the OSS implementation should follow?
  7. Would maintainers prefer full DeltaChannel-aware pruning in the initial contribution instead of the proposed phased approach?

I would be happy to implement the agreed scope, including synchronous, asynchronous, and Postgres test coverage.

If this direction is acceptable, could a maintainer please confirm the scope and assign the corresponding GitHub issue to me before I begin the PR?

Thanks!

@wangjiawei-vegetable Hello and welcome to the LangChain community.
I would suggest opening this issue on GitHub - langchain-ai/langgraph: Build resilient agents. · GitHub repo, rather than here, so the relevant maintainers can see it and assign you if it is feasible.

Thanks for the guidance. This proposal is now being tracked in GitHub Issue #8531:

Further discussion and implementation decisions can continue there.