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-lint0 issues under default tags, comment caps clean againstorigin/main. Under--build-tags=integrationthe only findings on this MR's files are 20modernizenewexprhits on the new datastore suite'sptr(...)calls, which 12 integration files already onmaindraw too.- Datastore integration compares every figure to the length of its own Phase 3 list rather than a literal.
requireMavenCountsMatchListsruns the aggregate and aListMavenPackages/ListMavenVersionsByPackage/ListMavenFilesByVersionwalk 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, andTestMavenPackageStore_AggregateMavenRepositoryEntityCounts_ArgumentGuardscovers the nil-context and zero-UUID guards. - An
EXPLAIN ANALYZEcase 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 missing404matrix, per-seam500mapping), handler integration through the mux, the wire-tier nil probe, and thecontract_test.gosweep over the arm's six cases: three200bodies, both404paths, and the seam500. - Six assertions are mutation-verified, not argued. Each mutation was applied, the named test observed red, and the mutation reverted:
- Swapping
PackagesCountandVersionsCountin the response body redsTestMavenRepositoryStatistics_ServesStoredAndLiveFigures. - Clamping the stored
ArtifactsCountat zero redsTestMavenRepositoryStatistics_NegativeStoredCounter_IsServedVerbatim. - Restoring
minimum: 0onStoredArtifactsCountreds 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.IDto the aggregate instead of the resolved child-row id redsTestMavenRepositoryStatistics_ScopesTheCountByTheResolvedChildRow. - Dropping the
maven_version_id IS NOT NULLleg 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 NULLoccurrences.
- Swapping
- E2E:
docs/testing/e2e/maven.mdgainse2e.maven.setup.repository-statistics, which pins the three live counts against the three lists, zeros on an empty repository, and the404on avirtualorremoterepository. 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_countincludemaven-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_atalone, without theIS NOT NULLleg. Rejected twice over: it changes the figure, and it takes the read off a partial index whose predicate names that column. - One flat
FROMwithLEFT JOINandCOUNT(DISTINCT). Rejected for the three-way fan-out per file. - Scope by
repositories.id, joiningmaven_repositoriesinside the statement. Rejected because matching the lists' own scoping column is what makes count-equals-list checkable by reading two predicates. - Answer
404when a hosted Maven row has nomaven_repositoriesbinding. Rejected because the binding exists by construction, so its absence is a broken write worth a logged500, 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