Artifact Registry GraphQL resolvers: decide on a shared fan-out budget at the AcquiresClient choke point
The Artifact Registry GraphQL resolvers each carry their own Gitlab::Graphql::Limit::FieldCallCount extension to bound how many outbound Artifact Registry (AR) HTTP requests a single GraphQL operation can trigger. In practice this does not work as intended: FieldCallCount keys per field, not per operation, so it does not bound the operation's total AR fan-out. This issue captures the architectural decision, surfaced during review of !251068 (merged), of whether to move that budget to a single shared key at the AcquiresClient choke point.
Background
The single-artifact resolvers (PackageResolver, ImageResolver) use FieldCallCount with limit: 1, and the connection resolvers (PackagesResolver, ImagesResolver, and the upcoming VersionsResolver and ManifestsResolver) use limit: 20. The intent is to cap the number of AR reads one GraphQL operation can drive.
The problem is that FieldCallCount maintains an independent counter per field. Each field gets its own budget, so the sum of per-field budgets is what actually bounds an operation, not any single operation-wide limit. There is no shared counter across the AR resolver family, so the extension cannot express "this operation may issue at most N AR requests total."
Observations
-
Per-field keying means one operation can still drive many AR reads. Each field has its own independent budget, so a single query selecting
package,image,packages, andimagestriggers four AR reads plus one repository read (five round trips), all within budget because each counter is separate. -
It counts selections, not requests. The counter increments before the resolver's
resolvebody runs, so a format-mismatched selection that makes no AR call (for example, selecting theimagefield on a maven repository, which returns null without calling AR) still consumes its budget. Solimit: 1means "one selection of this field," not "one AR request." -
Reading N artifacts costs 2N AR round trips instead of 1+N. Because
limit: 1forbids a second selection of the same field in one operation, a client reading N artifacts must send N separate GraphQL requests. The repository read is deduplicated only within a single request (viaGitlab::SafeRequestStore.fetchinRepositoryResolver), andSafeRequestStoredoes not span requests, so each of the N requests re-reads the repository. That is N repository reads plus N artifact reads = 2N round trips, versus 1 repository read plus N artifact reads = 1+N if the operation could batch them. Multiplexing does not help either, becauseFieldCallCountkeys onoperation_fingerprint, which hashes the query document and ignores variables.
Proposed direction
One option to evaluate: move the fan-out budget off the per-field FieldCallCount extensions and onto a single shared budget keyed at the AcquiresClient choke point, the concern every AR resolver goes through to obtain the client and issue a read. A single shared key would bound the whole operation's AR fan-out regardless of which fields are selected, and could count actual requests rather than selections.
This is an architectural decision because it changes fan-out semantics for the entire AR resolver family, including the connection resolvers landing in later S14 steps (versions, manifests). Tradeoffs to consider:
- Correctness of the bound: does a shared key at
AcquiresClientactually cap the operation's total AR fan-out in all selection shapes, and how is the limit chosen across single-artifact and connection resolvers? - Requests vs selections: whether to count actual AR requests (incrementing when a read is issued) instead of selections, so no-op selections do not consume budget.
- Impact on the later connection resolvers: how the shared budget interacts with
versionsandmanifestsresolvers before they land. - Interaction with repo-read dedup: whether a shared budget changes the
SafeRequestStorerepository-read deduplication story, which currently only spans a single request, and whether batching N artifacts into one operation would let a single repository read cover them.
Out of scope for !251068 (merged)
The immediate action in !251068 (merged) is only to correct the misleading code comment on the FieldCallCount extension so it describes what the extension actually does (bounds aliased re-selection of one field) rather than claiming it bounds the operation's AR cost. This issue tracks the larger decision.
Acceptance criterion: bound fan-out per HTTP request, not per operation
The GraphQL _json multiplex batch is capped only by total text size (MAX_QUERY_SIZE, about 10,000 chars), with no operation-count cap. Because FieldCallCount keys on operation_fingerprint, each near-duplicate operation in a batch gets a fresh 20-call budget. About 64 minimal distinct operations fit in one batch, each re-arming the budget, so a single authenticated HTTP request can drive on the order of 1,400 outbound AR reads. A shared budget keyed per operation (even at AcquiresClient) does not close this, since the multiplex gives each operation its own query and context. The bound must be per HTTP request: either a request-scoped counter shared across all _json entries (for example stored on the controller request rather than the query context), or a multiplex operation-count cap in GraphqlController. Acceptance criterion: the chosen mechanism must bound the total AR fan-out of a single HTTP request (including a multiplex _json batch), not just a single operation.
Fan-out wall-clock duration
The manifests connection issues up to 20 sequential per-row AR reads in one operation. The AR client read_timeout is 20 seconds (Gitlab::HTTP DEFAULT_TIMEOUT_OPTIONS), so a slow AR turns a full page into a multi-minute stall. This can exceed the 30 second GraphQL execution timeout and abort every row at once instead of degrading per row. Options to evaluate: a tighter dedicated read timeout for sub-collection reads, or bounding the fan-out wall-clock another way.
Both points were surfaced in review of !251073 (merged) by mkhalifa3.
References
- Follow-up from !251068 (merged) review by @radbatnag