Skip to content

perf(a2a): push ListTasks all-sessions query down to SQL#2302

Open
QuentinBisson wants to merge 2 commits into
kagent-dev:mainfrom
QuentinBisson:fix/2197-list-user-tasks-sql
Open

perf(a2a): push ListTasks all-sessions query down to SQL#2302
QuentinBisson wants to merge 2 commits into
kagent-dev:mainfrom
QuentinBisson:fix/2197-list-user-tasks-sql

Conversation

@QuentinBisson

@QuentinBisson QuentinBisson commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #2187, addressing #2197 (Copilot review finding).

What

collectUserTasks served 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 ListUserTasks sqlc query that joins session to task, filters, orders by task.id, and paginates server-side, returning COUNT(*) 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 casts task.data::jsonb per 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)

status and statusTimestampAfter are filtered in SQL via a jsonb cast on task.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:

shape protocol_version state string
v1 'v1' TASK_STATE_WORKING
legacy NULL working

Both spellings are passed (status_v1, status_legacy) and matched with IN (...); the two vocabularies never overlap, so no row is silently dropped. data is always a JSON object (json.Marshal output) so ::jsonb never errors; the timestamp cast is guarded by a CASE (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 GetSession ownership 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 by task.id matches 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_id rule (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; CountUserTasks recovers 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. Existing task_query_store unit tests pass unchanged against a fake that mirrors the query semantics.

@QuentinBisson
QuentinBisson deleted the fix/2197-list-user-tasks-sql branch July 20, 2026 14:08
@QuentinBisson
QuentinBisson restored the fix/2197-list-user-tasks-sql branch July 20, 2026 14:08
@QuentinBisson QuentinBisson reopened this Jul 20, 2026
@QuentinBisson
QuentinBisson force-pushed the fix/2197-list-user-tasks-sql branch 2 times, most recently from 7b31ed2 to c84b063 Compare July 22, 2026 20:12
@QuentinBisson
QuentinBisson marked this pull request as ready for review July 22, 2026 20:15
Copilot AI review requested due to automatic review settings July 22, 2026 20:15
@QuentinBisson
QuentinBisson requested a review from a team as a code owner July 22, 2026 20:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 sessiontask, supports optional single-session scoping, and preserves stable ordering by task.id.

Changes:

  • Add ListUserTasks + CountUserTasks sqlc queries to serve paginated task listings server-side (with COUNT(*) OVER() totals and a fallback count query for over-range pages).
  • Update the A2A task query handler to call ListUserTasks instead 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
QuentinBisson force-pushed the fix/2197-list-user-tasks-sql branch from 5d972c4 to eef3794 Compare July 22, 2026 20:34
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
QuentinBisson force-pushed the fix/2197-list-user-tasks-sql branch from eef3794 to 05e087d Compare July 25, 2026 12:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants