feat(managementapi): namespace statistics (S17 Phase 8 plan: 6/13)

Why

Step 3 registered the four statistics scopes as 501 placeholders so their routes could not drift from the contract while the handlers landed one at a time. This fills the namespace scope: GET /api/v1/:slug/statistics.

Plan: docs/plans/2026-08-12-s17-phase8-statistics.md, ### Step 6: Namespace statistics. Spec: docs/specs/S17-rest-management-api.md, Statistics (Phase 8), acceptance criteria 112 and 121.

What (the non-obvious parts)

A missing namespace_statistics row serves zeros, not 404. The spec fixes this deliberately: the row exists by construction (a seed plus an AFTER INSERT trigger on namespaces), so its absence means the trigger did not run, and S22's next reconciliation pass corrects from source. Answering 404 would tell a caller the namespace does not exist. The two live figures still serve.

That absence is logged at Warn. It is not the only signal — gitlab_artifact_registry_namespaces_missing_statistics counts the condition fleet-wide and the orphan sweep repairs it within one interval — but that gauge is unlabelled, so this line is what attributes the condition to a namespace at read time.

A nil row with a nil error is a separate path, writeContractViolation and a 500. The production store never does it, the consumer interface permits it, and the alternative is a nil dereference. It logs at ERROR where the miss logs at WARN, so one log search cannot return both.

The two stored figures serve negatives as-is, and that diverges from the contract. Both can go below zero when an increment is dropped: S22 puts no CHECK (>= 0) on its counter columns, because a non-negativity check would abort a whole batched chunk update and wedge every unrelated scope in it. The other two figures cannot go negative — repositories_count is a COUNT, and downloads_count sums a column whose only writer increments by one.

Serving them unmodified is what the spec requires, not a choice this MR makes. #### Statistics resources marks both figures stored, and its opening sentence defines a stored field as one that reads its column "as-is". S22 hands that decision to the consumer rather than dictating it ("how a consumer presents it is that consumer's decision"), and S17 is where the delegation gets exercised.

api/openapi/v1.yaml put minimum: 0 on every statistics figure, so a served negative violated the published schema. The floors came out in chore(api): drop statistics floors the counter ... (!1995 - merged) • Hayley Swimelar • 19.4. It merged first, this branch rebased onto it, and the negative case now runs through the contract sweep with the rest of the per-scope rows.

The two seams are read sequentially, stored first. Concurrency would hold two pool connections per in-flight request to save one primary-key lookup, and it would lose the fail-fast property where a stored-read failure short-circuits before the aggregate's scan.

downloads_count counts Maven archive GETs and nothing else. MavenRepositoryStore.BumpRepoDownloadCount is the column's only writer in the tree, reached from internal/format/maven/ alone. No npm or OCI read path increments it, and no reconciliation pass recomputes it. A namespace serving millions of npm tarball or OCI blob pulls therefore reports downloads_count: 0, which reads the same as a namespace nobody has touched. That is worth knowing before the figure reaches a dashboard.

Reviewable LOC

1,078 added lines across 17 files, past the 500 guideline. Splitting a 109-line handler further would not improve defect detection, and the rest is what tests it.

File group Added lines
Production Go (4 files) 109
Test Go (12 files) 966
OpenAPI comment (1 file) 3

statistics_namespace.go is 84 of the production total. statistics_namespace_test.go (457) and statistics_namespace_integration_test.go (281) are 738 of the test total.

Test plan

  • go test -count=1 ./internal/managementapi/ ./cmd/artifact-registry/ green, and green under -race.
  • go test -tags=integration ./internal/managementapi/ green against testcontainers.
  • golangci-lint run --build-tags=integration --max-same-issues=0 --max-issues-per-linter=0 --uniq-by-line=false clean on ./internal/managementapi/....
  • Each behavior was checked against a mutated implementation rather than trusted: clamping negatives, feeding the aggregate's SizeBytes into deduplicated_size_bytes, logging the miss at Info, mapping ErrNotFound to 404, treating a nil row as zeros, and narrowing the aggregate to one format or scoping it to uuid.Nil each fail a test in the fast tier.

No e2e catalog scenario is added or affected. The catalogs under docs/testing/ cover per-format protocol flows and carry no management-API scenarios.

Authorization is not enforced on this route. That is the standing S09 gap the spec's Security Considerations records, not something this MR introduces, and it applies to the whole management surface.

Related to #316

Edited by Hayley Swimelar

Merge request reports

Loading
Loading