langchain-ai / langchain-ai/langgraph
Checkpoint savers disagree on list(limit=-1): InMemorySaver returns nothing, SQLite savers return everything
- Dominant language
- Python
- Stars
- 41.9k
- Forks
- 7.1k
- Avg merge
- 23h 7m
- Merged PRs (30d)
- 30
Description
### Checked other resources
- [x] This is a bug, not a usage question.
- [x] I added a clear and descriptive title that summarizes this issue.
- [x] I used the GitHub search to find a similar question and didn't find it.
- [x] I am sure that this is a bug in LangGraph rather than my code.
- [x] The bug is not resolved by updating to the latest stable version of LangGraph (or the specific integration package).
- [x] This is not related to the langchain-community package.
- [x] I posted a self-contained, minimal, reproducible example. A maintainer can copy it and run it AS IS.
### Related Issues / PRs
```
#7308 — Add test coverage for `before` and `limit` in `InMemorySaver` `list`/`alist`
```
### Reproduction Steps / Example Code (Python)
```python
import asyncio
from langgraph.checkpoint.base import create_checkpoint, empty_checkpoint
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
READ = {"configurable": {"thread_id": "t", "checkpoint_ns": ""}}
LIMITS = (None, 0, 2, -1)
def seed_sync(saver):
cfg = {"configurable": {"thread_id": "t", "checkpoint_ns": ""}}
ckpt = empty_checkpoint()
for i in range(5):
cfg = saver.put(cfg, ckpt, {"step": i}, {})
ckpt = create_checkpoint(ckpt, None, i)
async def seed_async(saver):
cfg = {"configurable": {"thread_id": "t", "checkpoint_ns": ""}}
ckpt = empty_checkpoint()
for i in range(5):
cfg = await saver.aput(cfg, ckpt, {"step": i}, {})
ckpt = create_checkpoint(ckpt, None, i)
async def main():
mem = InMemorySaver()
await seed_async(mem)
print("InMemorySaver ", {l: len([c async for c in mem.alist(READ, limit=l)]) for l in LIMITS})
async with AsyncSqliteSaver.from_conn_string(":memory:") as s:
await seed_async(s)
print("AsyncSqliteSaver ", {l: len([c async for c in s.alist(READ, limit=l)]) for l in LIMITS})
with SqliteSaver.from_conn_string(":memory:") as s:
seed_sync(s)
print("SqliteSaver ", {l: len(list(s.list(READ, limit=l))) for l in LIMITS})
asyncio.run(main())
`
Output:
`
InMemorySaver {None: 5, 0: 0, 2: 2, -1: 0}
AsyncSqliteSaver {None: 5, 0: 0, 2: 2, -1: 5}
SqliteSaver {None: 5, 0: 0, 2: 2, -1: 5}
`
```
### Error Message and Stack Trace (if applicable)
```shell
```
### Description
```
Five checkpoints are written to one thread, then listed back with several
values of `limit`. All three savers agree on `None`, `0` and `2`. They
disagree on a negative limit: `InMemorySaver` returns no checkpoints, while
both SQLite savers return all five.
Neither result is obviously wrong on its own — the problem is that they are
opposite, and nothing reports it. A caller that computes a limit arithmetically
and lands on a negative value gets "no history" on the in-memory saver and the
complete history on SQLite.
The two implementations take different paths:
`libs/checkpoint/langgraph/checkpoint/memory/__init__.py`
if limit is not None and limit <= 0:
break
elif limit is not None:
limit -= 1
The guard is `<= 0`, so any negative limit stops iteration before the first
result.
`libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py` (and the sync
saver alongside it)
query += " LIMIT ?"
The value is passed to SQLite unchanged, and SQLite documents `LIMIT` with a
negative argument as meaning no upper bound. So the limit is silently dropped
and every row comes back.
The conformance suite has a `test_list` spec, and #7308 already notes that
`limit` coverage on `InMemorySaver` is thin, so this looks like an untested
edge rather than an intentional difference.
Three behaviours seem defensible and I do not want to assume which one you
want:
1. Raise `ValueError` for `limit < 0`, rejecting it at the interface.
2. Treat any negative value as no limit, matching SQLite.
3. Treat any negative value as zero, matching `InMemorySaver`.
Whichever you prefer, a conformance spec case would keep the savers aligned.
I have the fix and regression tests ready locally for whichever option you
choose — happy to submit, please assign this to me if the approach looks right.
```
### System Info
```
langgraph-checkpoint 4.2.0
langgraph-checkpoint-sqlite 3.1.1
Python 3.13.2
OS Windows 11 (26200)
Also reproduced against langgraph main at 1e44bda4.
```
Contributor guide
Research direction
Start with the limit handling in libs/checkpoint/langgraph/checkpoint/memory/__init__.py and the LIMIT query construction in libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py and its synchronous counterpart. Read the conformance suite's test_list spec and related issue #7308 before choosing the intended negative-limit behavior. Done means the savers agree and the behavior is covered by a conformance regression case.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python, sqlite
- Domain
- backend, databases
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100