AR GraphQL versions connection (monolith/S14 plan: 6/25)
What does this MR do and why?
This MR adds a GraphQL versions keyset connection to the Artifact Registry Maven and npm package types. It is step 6 of the monolith/S14 plan. The connection is behind the dark artifact_registry_ui feature flag (default disabled).
The main change in this revision addresses the top finding from an adversarial review: the connection now carries a per-operation FieldCallCount budget of limit: 20 on the resolver. The connection hangs off a package row, and a parent packages page is capped at max_page_size: 20 rows, so a legitimate query resolves versions at most 20 times (once per row). Without a budget, a client could alias versions many times on a single row and drive an unbounded number of outbound Artifact Registry requests; the review measured over 1300 outbound requests from one incoming request. The counter keys on the field instance, so the Maven and npm mounts get independent budgets, and because a repository is single-format only one is ever exercised. A 21st aliased selection now raises before any request is issued.
Other changes in this revision:
- The
versionsfield now hasmax_page_size: 20, matching its sibling connections, so one version read requests at most 20 rows. - Field descriptions were corrected to state the real budget ("resolves at most once per package in a page") and the real null semantics (null silently for 401/403/404; null alongside a top-level error for 429, 5xx, or any other 4xx), replacing wording copied from a differently-budgeted sibling.
- The
VersionTypeauthorize comment was corrected: a version travels bare (not wrapped in a presenter), so it has no policy class. Dropping the type's authorization skip would make DeclarativePolicy raise, not "fail closed". - Restored request-spec coverage that had been dropped relative to the sibling packages suite: an alias test proving the 21st selection raises and at most 20 reads are issued; empty connection; silent-null on 401/403/404; top-level error on transport failure and timeout; anonymous and non-member callers; and end-to-end backward paging. Added type-spec assertions that the field is nullable, wired to
VersionsResolver, capped at 20, and carries the corrected description.
Underlying pieces:
- New
ArtifactRegistryVersionGraphQL type with fieldsid,version,createdAt(all experiment-tagged, milestone 19.4). - New
VersionsResolverthat reads repository name, format, organization, and package id off theArtifactPresenter, calls the Artifact Registry client#versions, and returns an externally-paginated connection whosepageInfomirrors the Link-header cursors. - The
versionsconnection is mounted on bothArtifactRegistryMavenPackageandArtifactRegistryNpmPackage. - Standard keyset connection arguments only (
first/after,last/before). A sort argument and publish-attribution fields are separate later steps.
Testing: RuboCop is clean. The version type spec, versions resolver spec, and both package-type specs pass locally (37 examples). The end-to-end request spec runs in CI. The frontend parity jest spec passes (103). GraphQL schema artifacts and the GraphQL API reference doc were regenerated.
References
- Plan: monolith/S14 plan, step 6
- Spec: S14 version list
- Related to #618409 (closed)
Screenshots or screen recordings
N/A. No user-facing UI in this MR; it adds backend GraphQL schema behind the dark artifact_registry_ui flag.
How to set up and validate locally
- Save the following Ruby script and run it with
bundle exec rails runner validate_step6.rb:
# Validates the versions connection's fan-out bound and schema surface.
require 'artifact_registry/client'
failures = []
check = ->(desc, cond) { puts("#{cond ? 'PASS' : 'FAIL'}: #{desc}"); failures << desc unless cond }
# 1. The resolver carries a per-operation FieldCallCount budget of 20 (one per row of a
# max_page_size:20 parent page), not the removed/unbounded budget the review flagged.
budget = Resolvers::ArtifactRegistry::VersionsResolver.extensions
.find { |e| e.is_a?(Hash) && e.key?(Gitlab::Graphql::Limit::FieldCallCount) }
check.call('VersionsResolver budgets FieldCallCount at 20',
budget == { Gitlab::Graphql::Limit::FieldCallCount => { limit: 20 } })
# 2. Both package types mount versions with the same bound and shape.
%w[ArtifactRegistryMavenPackage ArtifactRegistryNpmPackage].each do |type_name|
field = GitlabSchema.types[type_name].fields['versions']
check.call("#{type_name}.versions caps a page at 20 rows", field.max_page_size == 20)
check.call("#{type_name}.versions is nullable", !field.type.non_null?)
check.call("#{type_name}.versions is wired to VersionsResolver",
field.resolver == Resolvers::ArtifactRegistry::VersionsResolver)
desc = field.description
check.call("#{type_name}.versions description states the per-package budget",
desc.include?('once per package in a page'))
check.call("#{type_name}.versions description states the corrected null semantics",
desc.include?('silently for a 401, 403, or 404') &&
desc.include?('top-level error for a 429, a 5xx, or any other 4xx'))
end
# 3. A bare Version has no policy, so dropping the type's skip would raise rather than
# "fail closed" -- the corrected authorize comment's claim.
raised =
begin
DeclarativePolicy.class_for(ArtifactRegistry::Version)
false
rescue StandardError => e
e.message.include?('no policy')
end
check.call('bare ArtifactRegistry::Version has no policy class (dropping the skip would raise, not fail closed)', raised)
puts(failures.empty? ? "\nALL PASS" : "\nFAILURES: #{failures.join('; ')}")- Confirm the output ends with
ALL PASS(all 12 checks). This script was run locally and printsALL PASS.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist.
Feature flag artifact_registry_ui is dark; no changelog (dark), and the field descriptions are schema text rather than i18n.