perf(database): batch checkpoint writes to remove N+1 query#2248
Open
anxkhn wants to merge 1 commit into
Open
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>
Contributor
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.
Comment on lines
+40
to
+44
| -- 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; |
Comment on lines
+73
to
+74
| tracer := &countingTracer{substr: "FROM lg_checkpoint_write"} | ||
| client := newCountingClient(t, tracer) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.