perf(a2a): push ListTasks all-sessions query down to SQL#2302
Open
QuentinBisson wants to merge 2 commits into
Open
perf(a2a): push ListTasks all-sessions query down to SQL#2302QuentinBisson wants to merge 2 commits into
QuentinBisson wants to merge 2 commits into
Conversation
QuentinBisson
force-pushed
the
fix/2197-list-user-tasks-sql
branch
2 times, most recently
from
July 22, 2026 20:12
7b31ed2 to
c84b063
Compare
QuentinBisson
marked this pull request as ready for review
July 22, 2026 20:15
Contributor
There was a problem hiding this comment.
Pull request overview
This PR improves the scalability of A2A ListTasks for “all sessions” queries by pushing filtering, ordering, pagination, and total counting down into a single SQL query (removing the previous N+1 + in-memory aggregation path). It adds a dedicated ListUserTasks API/query that joins session→task, supports optional single-session scoping, and preserves stable ordering by task.id.
Changes:
- Add
ListUserTasks+CountUserTaskssqlc queries to serve paginated task listings server-side (withCOUNT(*) OVER()totals and a fallback count query for over-range pages). - Update the A2A task query handler to call
ListUserTasksinstead of loading all sessions/tasks and filtering/sorting in memory. - Add a Postgres integration test covering both persisted task row shapes, filters, pagination behavior, and cross-user isolation.
Reviewed changes
Copilot reviewed 7 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| go/core/pkg/a2acompat/trpcv0/convert.go | Exposes a helper to map v1 task states to legacy persisted state strings. |
| go/core/internal/database/queries/tasks.sql | Adds ListUserTasks and CountUserTasks SQL queries with filtering/pagination and totals. |
| go/core/internal/database/gen/tasks.sql.go | Regenerates sqlc output for the new queries and parameter structs. |
| go/core/internal/database/gen/querier.go | Extends the generated Querier interface to include the new query methods. |
| go/core/internal/database/client_postgres.go | Implements ListUserTasks in the Postgres client (including total recovery when page is empty past end). |
| go/api/database/client.go | Introduces ListUserTasksParams and adds ListUserTasks to the DB client interface. |
| go/core/internal/a2a/task_query_store.go | Switches ListTasks implementation to call ListUserTasks and compute next page token from returned page size. |
| go/core/internal/a2a/task_query_store_test.go | Updates the in-memory fake store to mirror SQL semantics (filter/order/paginate + total). |
| go/core/internal/database/client_test.go | Adds a real-DB integration test for ListUserTasks behavior and edge cases. |
Files not reviewed (2)
- go/core/internal/database/gen/querier.go: Generated file
- go/core/internal/database/gen/tasks.sql.go: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+48
to
+49
| WHEN (task.data::jsonb -> 'status' ->> 'timestamp') ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}' | ||
| THEN (task.data::jsonb -> 'status' ->> 'timestamp')::timestamptz |
Comment on lines
+76
to
+77
| WHEN (task.data::jsonb -> 'status' ->> 'timestamp') ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}' | ||
| THEN (task.data::jsonb -> 'status' ->> 'timestamp')::timestamptz |
| total = int(r.Total) | ||
| task, err := parseVersionedTask(r.Data, r.ProtocolVersion) | ||
| if err != nil { | ||
| return nil, 0, fmt.Errorf("failed to parse task row %d: %w", i, err) |
QuentinBisson
force-pushed
the
fix/2197-list-user-tasks-sql
branch
from
July 22, 2026 20:34
5d972c4 to
eef3794
Compare
collectUserTasks loaded every session for a user, then every task per session, then filtered, sorted, and paginated in memory (an N+1 query whose result set and app-side memory scaled with the user's total task count even for a small page). Replace it with a single ListUserTasks sqlc query that joins session to task, filters, orders by task id, and paginates server-side, returning COUNT(*) OVER() as the total. Status and statusTimestampAfter are filtered in SQL via a jsonb cast on task.data (no schema migration). Rows exist in two shapes — v1 (protocol_version 'v1', state "TASK_STATE_WORKING") and legacy (protocol_version NULL, state "working") — so both spellings are passed and matched; the vocabularies never overlap. The timestamp cast is guarded by a CASE so a malformed value cannot error the query. COUNT(*) OVER() rides on the returned rows, so a page requested past the end of the set (e.g. rows deleted between paginated calls) comes back empty and carries no total; CountUserTasks recovers the true filtered count in that case. The single-session path keeps its fail-closed GetSession ownership check; the join scopes every path to the caller's own sessions. Adds a Postgres integration test covering both row shapes, cross-user isolation, the session predicate, status/timestamp filters, pagination, and the empty over-range page total. Signed-off-by: QuentinBisson <quentin@giantswarm.io>
The timestamp CASE guard only matched a YYYY-MM-DD prefix, so a value like "2026-07-01not-a-time" passed the guard and errored the ::timestamptz cast. Match a full RFC3339 timestamp (time + Z/offset) in both ListUserTasks and CountUserTasks so the guard actually protects the cast. Report the task id in the ListUserTasks parse error instead of the row index so operators can locate the bad row. Signed-off-by: QuentinBisson <quentin@giantswarm.io>
QuentinBisson
force-pushed
the
fix/2197-list-user-tasks-sql
branch
from
July 25, 2026 12:53
eef3794 to
05e087d
Compare
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.
Follow-up to #2187, addressing #2197 (Copilot review finding).
What
collectUserTasksserved an all-sessions ListTasks query by loading every session for the user, then every task per session, then filtering, sorting, and paginating in memory — an N+1 query whose result set and app-side memory scaled with the user's total task count even for a small page.This replaces that path with a single
ListUserTaskssqlc query that joinssessiontotask, filters, orders bytask.id, and paginates server-side, returningCOUNT(*) OVER()as the total. The app now materializes one page instead of the user's entire task set.This is a memory/transfer win, not (yet) a DB-latency win: as option 1 (no schema change) the query still scans the user's sessions and tasks — there is no index on
session.user_id, and the status filter caststask.data::jsonbper row — so DB-side latency still scales with the user's task count. Option 2 (generated column + index) is the durable fix for that.Status filtering (issue option 1)
statusandstatusTimestampAfterare filtered in SQL via ajsonbcast ontask.data— no schema migration (option 1 from the issue, per the request to "start with option 1 for status for now").Rows exist in two persisted shapes and both are matched:
protocol_version'v1'TASK_STATE_WORKINGNULLworkingBoth spellings are passed (
status_v1,status_legacy) and matched withIN (...); the two vocabularies never overlap, so no row is silently dropped.datais always a JSON object (json.Marshaloutput) so::jsonbnever errors; the timestamp cast is guarded by aCASE(ordered evaluation) so a present-but-malformed value can't error the query.Option 2 (generated column + index) remains the durable path for a later migration.
Behavior preserved
The single-session path keeps its fail-closed
GetSessionownership check (a missing/other-user context is an error, not an empty page); the join scopes every path to the caller's own sessions. Ordering bytask.idmatches the prior in-memory sort, so page tokens stay stable.Rebased onto the task-owner scoping from #2265, so the query now applies both gates the Go loop applied: the session belongs to the caller, and the task itself does under the
task.user_idrule (including the NULL-owner fallback for pre-migration rows). The session join alone would have handed a foreign task parked in the caller's session to the caller.COUNT(*) OVER()rides on the returned rows, so a page requested past the end of the set (e.g. rows deleted between paginated calls) comes back empty and would carry no total;CountUserTasksrecovers the true filtered count in that case, matching the old in-memory behavior.Tests
Adds a Postgres integration test (
TestListUserTasks) covering both row shapes, cross-user isolation, the single-session predicate, status and timestamp filters, LIMIT/OFFSET pagination with a stable total, and the empty over-range page. Two cases cover the owner gates: a NULL-owner row resolving through its session, and a task owned by another user sitting in the caller's session staying hidden. Existingtask_query_storeunit tests pass unchanged against a fake that mirrors the query semantics.