feat(managementapi): format statistics (S17 Phase 8 plan: 7/13)

Why

The four per-format statistics routes answer 501. Step 3 registered them as placeholders and Step 5 landed the aggregate they read, so this fills the shells: GET /api/v1/:slug/{docker,oci,maven,npm}/statistics serves the format's active repository count and its three counter sums.

The consumers are the monolith UI's per-format figures.

This was built stacked on chore(datastore): repositories aggregate (S17 P... (!1970 - merged) • Hayley Swimelar • 19.4, which owns RepositoryStore.AggregateRepositoryCounters and the RepositoryAggregator seam it reads. It is rebased onto main after that merged, so the diff here is Step 7 alone.

What is not obvious from the diff

  • The format is a construction-time parameter, not a path read. The four routes are separate literals because a {format} wildcard at that position overlaps /api/v1/:slug/repositories/statistics, and net/http.ServeMux panics at registration on exactly that pair. One handler function serves all four, and each registration binds its own datastore.RepositoryFormat* constant, so the four are distinct handler values. An unmapped constant has no wire string and would serialize "format": "", so construction panics on it, matching requireDeps's boot-time failure.

  • An unknown format segment gets the bare mux's text/plain 404, not an envelope. The subtree registers no fallback deliberately: that is how the contract sweep tells a declared-but-unserved operation from a served one. server.ResponseInterceptorMiddleware rewrites it to the S01 envelope on the served chain, so the handler tests assert what the tree serves directly.

  • downloads_count is live for Maven only. Maven's delivered archive GET writes repositories.downloads_count through MavenRepositoryStore.BumpRepoDownloadCount; npm, docker, and container have no writer until S18. So the maven route's figure moves in production and the other three read zero. docs/specs/S17-rest-management-api.md's S18 dependency row still calls that bump a no-op stub, which has been wrong since the hosted downloads_count bump gained its delivery gate. The docs(specs) correction is tracked in S17 spec: the S18 row still calls BumpRepoDownl... (#975) • Hayley Swimelar.

  • size_bytes sums per-repository footprints and does not dedup across them. It is the one byte figure on this surface that dedups at a narrower scope than its own response, per the spec's Size semantics.

  • The sums serve as summed, negatives included. CounterDrainStore.ApplyRepoDeltas commits a decrement past zero rather than clamping, so repositories.artifacts_count and size_bytes are negative-capable between a dropped increment and the reconciliation pass that corrects them from source. The spec defines this scope's three figures as "Sums of the repositories counters over the format's repositories", and a floored sum is not that sum, so the handler passes them through. repositories_count is a COUNT and downloads_count has only an incrementing writer, so neither can arrive negative anyway.

    The contract agrees, since chore(api): drop statistics floors the counter ... (!1995 - merged) • Hayley Swimelar • 19.4 dropped minimum: 0 from the figures the counter pipeline can drive negative and kept it on the ones it cannot, closing Statistics contract declares minimum: 0 that no... (#921 - closed) • Unassigned. That merged and this branch is rebased onto it, so the underflowed response is validated against the schema like every other success case, as a row of TestStatisticsFormat_ResponsesMatchOpenAPIContract.

    The monolith consumer cannot hit a stale floor: its AR client is hand-written (ee/lib/artifact_registry/ on master, no generated code, no response-schema validation) and has no statistics reader yet, so an underflow window is a displayed negative, not a decode failure.

  • The format-filtered plan shape is pinned in the parent MR, by TestAggregateRepositoryCountersStmt_FilteredFormRidesThePartialFormatIndex. This MR is that statement's first production caller with a non-nil format.

  • No new Deps field and no wire_management.go wiring. RepositoryAggregator arrived with Step 5, so the Deps seam-count pin in handler_test.go stays at main's 86. The plan's Step 7 Files list names wire_management.go anyway; it appears here only to delete the three anchor comments this step consumes.

Shared seams: what conflicts with this branch, and how it resolves

Step 6 (feat(managementapi): namespace statistics (S17 ... (!2001 - merged) • Hayley Swimelar • 19.4) and Step 19 (feat(managementapi): maven version stats (S17 P... (!2021 - merged) • Hayley Swimelar • 19.4) merged on 2026-08-28 and this branch is rebased onto both, so the conflicts this section used to resolve are history. Re-measured with git merge-tree --write-tree at this branch's 9f0a75dff and main at b0a19460a:

  • feat(managementapi): container repo stats (S17 ... (!2030) • Hayley Swimelar • 19.4 (Step 10, ff24856a9) merges clean against this branch, against main, and against this branch's base.
  • feat(managementapi): npm version stats (S17 Pha... (!2035) • Hayley Swimelar • 19.4 (Step 20, aebc05d72) has since rebased onto main, so the 16-file list this bullet used to carry is gone. It conflicts against this branch on internal/managementapi/contract_routes_test.go alone and merges clean against main and against this branch's base, so the conflict is this pair's rather than something a rebase of either side clears. Both sides delete adjacent rows of contractPendingOperations, this branch the four get<Format>Statistics rows and Step 20 the getVersionStatistics npm row, so the resolution takes both deletions.
  • refactor(managementapi): bind every route (S09 ... (!2062 - merged) • David Fernandez • 19.4 (S09 Enforcement step 16, fc7d72fae) conflicts against this branch on one region of internal/managementapi/handler.go and merges clean against main and against this branch's base. It rewrites how the four format rows register (routes.handle on a bindingMux, each row carrying requirePermission(authz.ActionReadRepository, scopeNamespace)), and this branch rewrites what they register (handleFormatStatistics with the aggregate and the row's own format constant), so the resolution keeps that MR's rows with this branch's arguments and comment. The two region-level one-sided resolutions each fail to compile: mux is gone once registration moves to the binding mux, and the zero-argument handleFormatStatistics() is gone once this branch lands. That MR also carries TestPackageSources_RegisterEveryRouteThroughTheBindingHelper, which walks the package's non-test sources for a raw mux.Handle bypass, so a resolution that reinstates one is caught there too.

The durable seam is the pair every statistics step edits by design: the servedStatisticsScopes opt-out map in handler_test.go and the pending-operations map in contract_routes_test.go. Where two in-flight steps' rows land adjacent, take both sides' edits rather than either alone. A one-sided resolution fails loudly rather than silently, because a sweep drives each map: at 9f0a75dff, dropping this branch's four servedStatisticsScopes rows fails TestHandler_StatisticsRoutes_Return501WithEnvelope on all four routes, and restoring its four contractPendingOperations rows fails TestContractOperations_AreRegisteredAndServed on all four at contract_routes_test.go:323, with a message naming the fix. So this section saves whoever rebases a re-measurement, not a defect.

The handler.go statistics region banner is settled by this diff: main carries Step 6's wording, stale since this scope landed, and this branch replaces it with "the repositories aggregate the statistics scopes share", which stays true whichever step lands next.

Whoever rebases second should re-measure against these SHAs rather than trust the verdicts.

Diff size

810 reviewable LOC (783 added, 27 removed) across 11 files, none vendored, generated, or binary, so it is over the 500 ceiling. Split by group: production 62 added / 21 removed across 4 files (statistics_format.go, handler.go, statistics_resources.go, wire_management.go), tests 721 added / 6 removed across 7 files. Not split: the tests land with the implementation they constrain, and separating them would leave the test-first discipline and the "every MR includes tests for the code it introduces" guardrail unmet on whichever half merged first.

Test plan

  • go test ./internal/managementapi/ -count=1 and -race are green, as is the full go test ./....
  • go test -tags=integration ./internal/managementapi/ -run FormatStatistics is green against a real Postgres.
  • golangci-lint run and the integration-tagged run (--build-tags=integration --max-same-issues=0 --max-issues-per-linter=0 --uniq-by-line=false) both report 0 issues on internal/managementapi.
  • Each assertion was checked against the mutation it exists to catch: passing nil for the format rolls up every format and fails the per-route table; serving one format's figures on another's route fails the docker/oci case; omitting a zero figure fails the assert.JSONEq on the empty format; and the unknown-segment 404 carries a served-route control, without which it passed vacuously while the whole subtree was unimplemented; and re-adding a floor to either summed figure fails the verbatim-negatives case.
  • The two round-2 tests were checked the same way: deleting the errors.Is classification from logAndWriteInternalError fails both cancellation rows, and dropping the schema's downloads_count floor fails the contract-rejection row.
  • The integration fixture gives all four formats and all three kinds a live row plus one soft-deleted row, with per-row counters whose subset sums are unique, and a require.Positive guard fails the suite if a future format arrives without one.

No e2e catalog scenario is added or affected: docs/testing/ holds the four protocol-client catalogs and none of them reads statistics.

Plan reconciliation

Touched but not in the step's Files list: handler_test.go (the servedStatisticsScopes opt-out, above) and statistics_format_internal_test.go (a white-box file for the construction panic, which is unreachable from the external test package).

Related to #316

Edited by Hayley Swimelar

Merge request reports

Loading
Loading