Skip to content

checkpoint-sqlite: failed store batches can partially commit changes #8590

Description

@JunweiJia

Checked for duplicates

I searched the open and closed issues for SQLite store batch rollback / partial commits and did not find an existing report.

Description

SqliteStore.batch() and AsyncSqliteStore.abatch() can partially persist changes when a later operation in the same batch raises.

Both SQLite store _cursor() context managers execute COMMIT in a finally block. As a result, they commit earlier mutations even when control exits the context because of an exception (or, for async code, cancellation).

One observable case is a TTL refresh performed by a GetOp before a later unserializable PutOp.

Reproduction

Sync:

from langgraph.store.base import GetOp, PutOp
from langgraph.store.sqlite import SqliteStore

original_expiration = "2000-01-01 00:00:00"

with SqliteStore.from_conn_string(
    ":memory:",
    ttl={"default_ttl": 10, "refresh_on_read": True},
) as store:
    store.setup()
    store.put(("test",), "key", {"value": "original"})
    store.conn.execute(
        "UPDATE store SET expires_at = ? WHERE prefix = ? AND key = ?",
        (original_expiration, "test", "key"),
    )

    try:
        store.batch([
            GetOp(("test",), "key", refresh_ttl=True),
            PutOp(("test",), "invalid", {"value": object()}),
        ])
    except TypeError:
        pass

    expires_at = store.conn.execute(
        "SELECT expires_at FROM store WHERE prefix = ? AND key = ?",
        ("test", "key"),
    ).fetchone()[0]

    assert expires_at == original_expiration  # currently fails

The equivalent async reproduction using AsyncSqliteStore.abatch() has the same result.

Actual behavior

The batch raises TypeError, but the TTL refresh from the preceding GetOp is committed. The database is left in a partially updated state.

Expected behavior

A failed batch should be atomic: any exception should roll back all changes made in that cursor context, and the original exception should be re-raised. The async path should also roll back on cancellation.

Root cause and proposed fix

The sync and async SQLite store _cursor() context managers unconditionally commit in finally:

  • libs/checkpoint-sqlite/langgraph/store/sqlite/base.py
  • libs/checkpoint-sqlite/langgraph/store/sqlite/aio.py

I propose changing both context managers to:

  • COMMIT on normal exit;
  • ROLLBACK on any BaseException, then re-raise;
  • add sync and async regression tests demonstrating that an earlier TTL refresh is not persisted when a later operation fails.

I have a tested implementation ready. If this approach is acceptable, could a maintainer please assign this issue to me? I will then open the PR in accordance with the contribution policy.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions