feat(managementapi): maven repo statistics (S17 Phase 8 plan: 8/13)

Why

The repository-statistics route resolves its repository and refuses every non-hosted kind, then answers 501 on all three format arms. This fills the Maven one: the three stored counters the Repository resource already serves, plus the three live counts Maven's schema declares.

What (the non-obvious parts)

files_count excludes package-level files, and that is what makes it match the list. The files subquery carries maven_files.maven_version_id IS NOT NULL, which drops maven-metadata.xml and its checksum siblings, because ListMavenFilesByVersion cannot return a file bound to no version. That same leg keeps the read on unique_maven_files_ns_id_version_id_file_name, whose predicate is soft_deleted_at IS NULL AND maven_version_id IS NOT NULL. One leg, two reasons, and dropping it breaks both.

Each level's visibility predicate is ANDed into the level below. A version under a soft-deleted package is not merely unlisted, it is uncountable: the versions subquery repeats the package predicate, and the files subquery repeats both. Without that repetition, count-equals-list holds until someone soft-deletes a package.

One statement, three scalar subqueries, no FROM (the recomputeNamespaceComponentsCountStmt precedent). A flat join chain with COUNT(DISTINCT) would fan out per file.

Scoped by maven_repositories.id, not repositories.id. That is the column every Maven management list is scoped by, so count-equals-list is checkable by reading two predicates. The child row resolves through the existing MavenVersionRepositories seam, so this adds exactly one Deps field, taking the nil-checkable seam count from 82 to 83. A missing binding under a hosted repository is a logged 500: a broken write rather than a row to hide behind the 404.

Both seams take the request-resolved namespace id, not the row's copy, which would agree with a leak by construction. findRepositoryForRequest refuses a mismatched row above the format dispatch, so the Maven pin that drove a foreign row through the handler is deleted here rather than kept: the gate answers first, the arm never runs, and the pin fails at its own 200 precondition instead of duplicating a live check. TestRepositoryStatistics_ForeignRow_IsRefused on main holds that refusal across hosted, virtual, and remote, and TestMavenRepositoryStatistics_ScopesTheCountByTheResolvedChildRow keeps the threading assertion on a row the gate admits.

Stored figures serve verbatim. A counter drifted below zero reaches the body unclamped. chore(api): drop statistics floors the counter ... (!1995 - merged) • Hayley Swimelar • 19.4 dropped the minimum: 0 from artifacts_count and size_bytes, so the underflow case runs through validateAgainstContract with the rest of the sweep instead of asserting on a body it could not submit. downloads_count keeps its floor, and the case leaves it alone.

Read cost is unbounded and known to be. ADR-004 caps versions per package at 25,000 and repositories per namespace per artifact type at 1,000. It caps packages per repository at nothing. S17 prices this scope as partition-pruned and rare on ADR-007's reconciliation grounds, and names denormalized per-repository entity counters as the fallback. The trigger for that fallback is database_query_duration_seconds{name="maven_packages_select_repository_entity_counts"} rather than the route's status distribution, because a client-abandoned read records as 200 with zero bytes and no error line.

The read holds its pooled connection for the query's duration, with no per-statement timeout. A pathological repository's count can queue unrelated Maven, npm, and OCI callers behind it in pgxpool.Acquire, since all four share one pool. This stays unbounded for the same reason the row cost does: the fallback is the denormalized counter above, not a tighter deadline, and the query-duration metric already named is what would surface the queuing before it reaches that fallback's trigger.

Two files here belong to the shared gate, not to this arm. statistics_repository_test.go and its integration twin relax the shared family-arm helper from "answers 501" to "reached an arm", now that the Maven arm answers 200. The npm and container arms produce byte-identical text for it (measured against 9f0eee7f5 and 3425db2a0), so the three branches merge as one edit. The relaxation also collapses two helpers into one: once all three arms serve, a placeholder-only helper's last caller sits behind the integration tag, which default lint never compiles, so unused would fire on main in no MR's own pipeline.

Four more test files change because every Deps literal in both packages must gain the new seam or requireNonNilSeams panics at construction: handler_test.go, pact_provider_test.go, read_handlers_integration_test.go, and cmd/artifact-registry/wire_management_test.go.

Diff size

1496 reviewable LOC, over the 500 guardrail: 184 production (maven_packages.go 99, statistics_repository.go 45, handler.go 13, query_names.go 12 of which 11 are gofmt realignment, statistics_resources.go 11, wire_management.go 4), 1 docs, and 1311 test. The production half is inside the band, and splitting the aggregate from its handler arm would land a 501 route over an unreachable store.

Test plan

  • go test ./... green repo-wide, golangci-lint 0 issues under default tags, comment caps clean against origin/main. Under --build-tags=integration the only findings on this MR's files are 20 modernize newexpr hits on the new datastore suite's ptr(...) calls, which 12 integration files already on main draw too.
  • Datastore integration compares every figure to the length of its own Phase 3 list rather than a literal. requireMavenCountsMatchLists runs the aggregate and a ListMavenPackages / ListMavenVersionsByPackage / ListMavenFilesByVersion walk over the same repository and requires all three to agree. Ten cases go through it: zeros, a full chain, a package-level file, a soft-deleted row at each of the three levels, a sibling repository, another namespace's identical chain, the same child-row id under another namespace, and an id matching no child row. An eleventh pins that a query failure wraps its cause without naming an identifier, and TestMavenPackageStore_AggregateMavenRepositoryEntityCounts_ArgumentGuards covers the nil-context and zero-UUID guards.
  • An EXPLAIN ANALYZE case seeds a sibling repository holding twenty times the packages, then asserts each level prunes to one of the 64 hash partitions, that files is reached per version through the partial index, and that the packages level seeks on (namespace_id, maven_repository_id) rather than filtering the repository after the scan.
  • Handler table over a fake aggregate (populated figures, zeros, cross-slug 404, the non-hosted and missing 404 matrix, per-seam 500 mapping), handler integration through the mux, the wire-tier nil probe, and the contract_test.go sweep over the arm's six cases: three 200 bodies, both 404 paths, and the seam 500.
  • Six assertions are mutation-verified, not argued. Each mutation was applied, the named test observed red, and the mutation reverted:
    • Swapping PackagesCount and VersionsCount in the response body reds TestMavenRepositoryStatistics_ServesStoredAndLiveFigures.
    • Clamping the stored ArtifactsCount at zero reds TestMavenRepositoryStatistics_NegativeStoredCounter_IsServedVerbatim.
    • Restoring minimum: 0 on StoredArtifactsCount reds the contract sweep's underflow case, which is what shows that case exercises the validator rather than passing on a body nothing checks.
    • Passing scope.row.ID to the aggregate instead of the resolved child-row id reds TestMavenRepositoryStatistics_ScopesTheCountByTheResolvedChildRow.
    • Dropping the maven_version_id IS NOT NULL leg reds the SQL-shape pin on that predicate.
    • Dropping the package-visibility predicate from the versions subquery reds the same pin's count of three maven_packages.soft_deleted_at IS NULL occurrences.
  • E2E: docs/testing/e2e/maven.md gains e2e.maven.setup.repository-statistics, which pins the three live counts against the three lists, zeros on an empty repository, and the 404 on a virtual or remote repository. It reads the stored three off the repository resource rather than off a deploy just made, because those settle through buffered maintenance.
Context for LLM agents

Rationale

  • Count all files and let files_count include maven-metadata.xml. Rejected because the file list is per version, so a package-level row it cannot return would make the count exceed the list by a number no caller can derive.
  • Filter the files level on soft_deleted_at alone, without the IS NOT NULL leg. Rejected twice over: it changes the figure, and it takes the read off a partial index whose predicate names that column.
  • One flat FROM with LEFT JOIN and COUNT(DISTINCT). Rejected for the three-way fan-out per file.
  • Scope by repositories.id, joining maven_repositories inside the statement. Rejected because matching the lists' own scoping column is what makes count-equals-list checkable by reading two predicates.
  • Answer 404 when a hosted Maven row has no maven_repositories binding. Rejected because the binding exists by construction, so its absence is a broken write worth a logged 500, not a repository to hide.
  • Keep the Maven foreign-row pin beside the shared gate. Rejected because the gate refuses the row before the arm executes, so the pin's own precondition no longer holds and it fails rather than duplicating.

Consequences: the aggregate repeats each parent's visibility predicate rather than relying on the join, so a level added to the chain has to repeat every predicate above it, and a count-against-list comparison at one level cannot detect a missing repetition at another.

Non-goals

  • Bounding the scan. S17 prices this scope and names its own fallback.
  • Clamping negative stored counters. They serve as read, and chore(api): drop statistics floors the counter ... (!1995 - merged) • Hayley Swimelar • 19.4 removed the floors that made the document disagree with them.
  • A denormalized per-repository entity counter. That is the named fallback, not the first implementation.
  • Draining the sibling arms' step anchors, or re-fixing the shared helper from another arm's branch.

Related to #316

Edited by Hayley Swimelar

Merge request reports

Loading
Loading