perf(database): batch checkpoint writes to remove N+1 query#2248
perf(database): batch checkpoint writes to remove N+1 query#2248anxkhn wants to merge 2 commits into
Conversation
ListCheckpoints fetched every checkpoint for a thread and then ran a separate ListCheckpointWrites query for each one, so reading a thread's history issued 1+N queries inside a single transaction and grew with the conversation length (each LangGraph superstep adds a checkpoint). The LangGraph checkpointer sends limit=-1 when no limit is set, which takes the unbounded list branch, so this is the normal history-read path on GET /api/langgraph/checkpoints. Add a single ListCheckpointWritesForCheckpoints query that loads the writes for all returned checkpoints at once (checkpoint_id = ANY(...)), bucket them by checkpoint ID in memory, and attach them to each tuple. This turns the write fan-out into one query regardless of history length while preserving the existing per-checkpoint write ordering. Add a regression test that seeds a multi-checkpoint thread and asserts the writes are grouped onto the right checkpoints and that only one lg_checkpoint_write query runs. Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR removes an N+1 query pattern in the Go Postgres database client when listing LangGraph checkpoint history by batching checkpoint-write reads into a single SQL query, improving performance and reducing connection hold time in the normal history-read path.
Changes:
- Add a new batched SQL query (
ListCheckpointWritesForCheckpoints) that loads writes for multiple checkpoints usingcheckpoint_id = ANY(...). - Update
postgresClient.ListCheckpointsto fetch all writes in one query and bucket them by checkpoint ID before assembling tuples. - Add a database test that guards against regressions back to the N+1 query pattern and validates write grouping/order.
Reviewed changes
Copilot reviewed 3 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| go/core/internal/database/queries/langgraph.sql | Adds a new :many query to fetch writes for multiple checkpoints in one call. |
| go/core/internal/database/gen/querier.go | Extends the generated Querier interface with the new batched query method. |
| go/core/internal/database/gen/langgraph.sql.go | Adds sqlc-generated method/params for ListCheckpointWritesForCheckpoints. |
| go/core/internal/database/client_postgres.go | Replaces per-checkpoint write fan-out with a single batched query and bucketing logic. |
| go/core/internal/database/client_langgraph_test.go | Adds a regression test to ensure only one checkpoint-write SELECT runs and writes are correctly grouped/ordered. |
Files not reviewed (2)
- go/core/internal/database/gen/langgraph.sql.go: Generated file
- go/core/internal/database/gen/querier.go: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| -- name: ListCheckpointWritesForCheckpoints :many | ||
| SELECT * FROM lg_checkpoint_write | ||
| WHERE user_id = $1 AND thread_id = $2 AND checkpoint_ns = $3 | ||
| AND checkpoint_id = ANY($4::text[]) AND deleted_at IS NULL | ||
| ORDER BY checkpoint_id, task_id, write_idx; |
| tracer := &countingTracer{substr: "FROM lg_checkpoint_write"} | ||
| client := newCountingClient(t, tracer) |
EItanya
left a comment
There was a problem hiding this comment.
The pre-existing singular ListCheckpointWrites query (and its generated
method) is left in place: it is no longer called from Go after this change, but
removing it would enlarge the generated interface diff. Happy to drop it in a
follow-up (or in this PR) if you prefer.
Let's just do it here since it's unused
| AND checkpoint_id = $4 AND deleted_at IS NULL | ||
| ORDER BY task_id, write_idx; | ||
|
|
||
| -- name: ListCheckpointWritesForCheckpoints :many |
There was a problem hiding this comment.
the singular listcheckpointwrites query above is now dead code since this pr removed its only caller, drop it and regenerate so the sqlc surface stays in sync.
What
Reading a LangGraph thread's checkpoint history through
postgresClient.ListCheckpoints(
go/core/internal/database/client_postgres.go) issued 1+N database queries.After loading the thread's checkpoints, it looped over each one and ran a
separate
ListCheckpointWritesquery per checkpoint:All of these run inside the single transaction opened at the top of
ListCheckpoints, so they are serialized on one pooled connection.This is the normal history-read path, not an edge case:
GET /api/langgraph/checkpoints(route ingo/core/internal/httpserver/server.go) callsHandleListCheckpoints(
go/core/internal/httpserver/handlers/checkpoints.go), which reads thelimitquery param defaulting to0and passes it straight through.checkpointIDis requested andlimitis not> 0,ListCheckpointstakes the unbounded list branch (q.ListCheckpoints, whoseSQL has no
LIMIT).limit=-1whenever no limit is set(
python/packages/kagent-langgraph/src/kagent/langgraph/_checkpointer.py:limit = limit if limit else -1), which is not> 0, so it hits that branch.A LangGraph thread accumulates one checkpoint per superstep, so
Ngrows withconversation length. Reading a long thread's history therefore issues
1+Nqueries and holds the connection for the whole fan-out.
Fix
Add a single
:manyquery that loads the writes for all returned checkpointsat once, using
checkpoint_id = ANY(...)(
go/core/internal/database/queries/langgraph.sql):ListCheckpointsnow runs that one query, buckets the rows into amap[checkpointID][]writein a single pass, and attaches each slice to itstuple. This turns the write fan-out into one query regardless of history length
(so the whole call is 2 queries instead of
1+N).Behavior is preserved:
ANY(...)pattern already exists in this codebase (seeIncrementMemoryAccessCountinqueries/memory.sql, which usesANY($1::text[])).ordered by
task_id, write_idx; the batched query orders bycheckpoint_id, task_id, write_idx, so within each checkpoint the relativeorder is identical.
gets
[]*LangGraphCheckpointWrite{}, matching the previousmake(..., len(writes))result.GetCheckpoint) flows through the same code witha one-element batch and yields the same result.
The generated code (
gen/langgraph.sql.go,gen/querier.go) is the committedoutput of
sqlcv1.30.0 (make -C go sqlc-generateproduces no diff).Tests
Added
TestListCheckpointsBatchesWritesingo/core/internal/database/client_langgraph_test.go. It seeds a thread with 5checkpoints (3 writes each), reads the full history, and asserts:
lg_checkpoint_writequery runs (apgx.QueryTracercountsstatements containing
FROM lg_checkpoint_write) - this is the N+1 guard, andit was
numCPbefore the change, andwrite_idxorder.The test uses the same
testcontainers-backed Postgres as the existingdatabase tests, so it skips under
go test -shortand runs in CI.Locally (this change):
make -C go sqlc-generate-> no diffgo build ./...,go vet ./core/internal/database/...-> passmake -C go lint(golangci-linton the database package) -> 0 issuesgo test -short ./core/internal/database/... ./core/internal/httpserver/...-> pass
Note
The pre-existing singular
ListCheckpointWritesquery (and its generatedmethod) is left in place: it is no longer called from Go after this change, but
removing it would enlarge the generated interface diff. Happy to drop it in a
follow-up (or in this PR) if you prefer.