Skip to content

(bug) Revision queries cannot ask for the latest revision per parent, so a batch blocks the API worker for seconds #6563

Description

@mmabrouk

When the playground needs the newest revision of several workflows at once, one request to POST /workflows/revisions/query blocks an API worker for about two seconds. Several of those together stall the API long enough that unrelated requests time out. A user sees an agent turn fail with session <id> record log is unreadable; cannot rebuild the conversation, even though the record log is fine.

Steps to reproduce

  1. Open a project with several workflows, where at least one workflow has many revisions.
  2. Open the playground on a session and let the UI load the workflow list.
  3. Watch the API access log and the CPU of the API worker.

What happens

windowing.limit applies to the whole result set, not to each parent. A limit of 5 over 5 workflows can return 5 revisions of the first workflow and none of the others. The frontend has no safe way to ask for "the latest one each", so it drops the limit and takes everything. The comment in web/packages/agenta-entities/src/workflow/api/api.ts:1293 says so:

When fetching for a single workflow, limit to 1 (latest) to reduce payload.
With multiple workflows the global limit would cut across all, so skip it.

On the project measured, 19 workflows hold 269 revisions, and two of them hold 179 revisions and 30 MB between them. A batch over those two returns 177 revisions.

Measured inside the API container on that real data:

Stage Cost
Build models, including jsonschema.check_schema 385 ms
model_dump 146 ms
Serialize to JSON 92 ms, giving 76.4 MB
gzip at level 5 1293 ms, giving 18.9 MB
Total, none of it yielding to the event loop 1916 ms

Evidence from the incident

Session f5cf97a3-7e1a-46f0-a4f4-5f0a45946834 on the agenta-oss-team stack, images v0.114.5, 2026-09-03.

The runner asked for the session record log at 12:00:57.79 and gave up at 12:01:02.81, after its five second budget:

[sessions/records-query] query FAILED session=f5cf97a3-...: The operation was aborted due to timeout
[keepalive] evict key=...:f5cf97a3-... reason=no-park:failed

The API served no response at all between 12:00:59.6 and 12:01:05.7, then answered in a burst. Using the per-minute scheduler request as a traffic-independent probe, the API's latency was 0.01 s median over 983 ticks in 17 hours, with only two ticks above one second: 1.62 s at 11:40:00 and 4.90 s at 12:01:00. The second is this failure. Seven calls to /api/workflows/revisions/query landed in the 11:40 minute.

While the loop was blocked, the cache client hit its 0.5 s socket timeout, which is the visible warning:

[WARN.] [cache] GET  namespace=check_action_access key={'permission': 'view_sessions', ...}
[WARN.] Timeout reading from redis-volatile:6379

Redis itself is healthy: 5 MB in use of 512 MB, no evictions, slowest command 73 ms. The database query is not the problem either: an EXPLAIN ANALYZE of the record log read runs in 4.7 ms.

A second, silent instance of the same gap

web/packages/agenta-entities/src/testset/api/api.ts:193 reaches for the missing capability a different way:

testset_refs: testsetIds.map((id) => ({id, limit: 1})),

The API drops that limit. Reference is class Reference(Identifier, Slug, Version) with no model_config, so pydantic ignores extra keys. Checked on the running API:

Reference(**{"id": "...", "limit": 1})
  -> version=None slug=None id=UUID('...')
  -> dumped: {'id': UUID('...')}

So that batch fetches every revision of every testset. Testsets are small on the project measured, so nothing hurts today, but the defect is latent.

Scope

Six routers expose POST /<entity>/revisions/query with the same request shape, and all six reach GitDAO.query_revisions: workflows, testsets, evaluators, environments, applications, and queries. The fix belongs in the shared layer, and the request field should land on all six together.

Plan

The design workspace is at docs/design/revision-query-grouping/, covering the reasoning,
the measurements, the interface, and the shapes we rejected. It was reviewed by Codex and
the plan changed as a result.

Interface: one optional field, a sibling of windowing, on all six request models.

{
  "workflow_refs": [{"id": "..."}, {"id": "..."}],
  "grouping":  { "by": "artifact" }
}

The first release returns at most one revision per requested parent. It carries no
"newest N per parent" option and no paging over parents. Both were cut because no caller
needs them and each adds a branch we cannot specify yet.

  • Settle what "newest" means per endpoint, including version 0, which can be either an
    auto-created placeholder or a real configured revision. Also settle whether grouping
    means "newest matching revision" or "newest revision, returned only if it matches".
  • Add RevisionGrouping to the git core DTOs and grouping to all six request models.
  • Update the body and parameter parsers and merge helpers. Make a malformed grouping
    return a client error. See the warning below.
  • Extend GitDAOInterface.query_revisions, the concrete DAO, and the six services, and
    apply the fold in SQL with DISTINCT ON plus an outer ordering stage.
  • Check the OpenAPI schema describes the request body, then regenerate both clients.
  • Move the three batch callers, including the legacy one in
    web/oss/src/state/entities/testset/revisionEntity.ts.
  • Test against real PostgreSQL across all six HTTP paths, including a malformed
    grouping.

Do not land the field before the behavior

The workflows and environments routers read await request.json() and expand it into a
parser with an explicit keyword signature, inside a bare except Exception: pass. Verified
on the running API:

parse_workflow_revision_query_request_from_body(**{"workflow_refs": [...], "grouping": {...}})
  -> TypeError: got an unexpected keyword argument 'grouping'

The router swallows that and leaves the body None, so the parent references are discarded
and the query becomes project-wide. Shipping the field before the parsers accept it would
make this incident worse, not better.

Two related costs found in the same investigation

Separate from this issue, and both reduced by shrinking the payload.

  • WorkflowRevisionData._validate runs jsonschema.check_schema every time a model is built, so every read re-checks a schema that was already validated at write time. 2.2 ms per revision.
  • api/entrypoints/routers.py:503 adds GZipMiddleware(minimum_size=1000, compresslevel=5), which compresses synchronously on the event loop. 1293 ms on the 76 MB body, about two thirds of the total. This may be the more urgent of the two: the OSS nginx config already sets gzip on for application/json, so in that deployment the API duplicates the proxy's work. The stack where this incident happened runs Traefik rather than nginx, so each hosting path needs checking before the middleware is removed.

Fixed already

One correctness bug found while investigating this, now fixed in
web/packages/agenta-entities/src/testset/api/api.ts. fetchLatestRevisionsBatch sent no windowing, and query_revisions applies an ORDER BY only when windowing is present. With no ordering, the rows came back in unspecified order and the loop kept whichever revision arrived last, so a function named "latest" returned an arbitrary revision. It now asks for newest first, keeps the first revision per testset, and prefers a configured revision over a version 0 placeholder. The dead limit and the docstring describing a ReferenceWithLimit feature that never existed are gone. This does not stop the over-fetch, which needs the API change above.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions