feat(managementapi): serve referrers_count on both manifest responses (S17 Container Manifest Reads plan: 12/20)

Why

Step 12 of the S17 Container Manifest Reads plan, the first follow-on field family. It serves referrers_count on every manifests-list element and on manifest detail, hosted and cached alike: how many manifests in the same image name this digest as their subject, counting the referrer rows the default list hides. The redesigned version list reads it for the Referrers column, and the referrers route (Step 14) takes its size off the subject's own row rather than carrying a collection total.

Stacked on chore(datastore): the batched referrers count o... (!2582 - merged) • Hayley Swimelar • 19.4, merges after it. Depends on that MR for CountReferrersBySubjectDigests; Step 10's page-facts seam is already on main (feat(managementapi): tag preview, count, and th... (!2540 - merged) • Hayley Swimelar • 19.4).

What

The facts seam batches on an (id, digest) pair now, not on ids. The tag reads batch on manifest ids, while this count groups on subject_digest and is image-scoped. Parallel ids and digests slices would make that correspondence positional, so containerManifestPageIDs became containerManifestPageRefs over a containerManifestPageRef. The plan forecast an adjacent line here. Steps 16 and 18 extend the same seam and want the id half only, which containerManifestRefIDs projects.

A cached row's 0 has two writers, deliberately. The fetch short-circuits before the statement, since container_remote_manifests stores no subject_digest, and containerManifestBaseFromRemoteModel writes the literal 0 the plan's Files entry asks for. TestContainerManifestBaseBuilders_TheCachedArmServesZeroReferrers hands the builder a non-zero fact and asserts the served 0, so a later step lifting the short-circuit cannot ship a silent zero.

No handler-side batch guard, because maxPageSize already equals the store's inclusive limit. TestContainerManifestList_ReferrerBatchStaysUnderTheStoreLimit seeds 101 manifests at limit=1000 and pins the batch at 100, so a later maxPageSize raise fails loudly instead of 500ing every full page.

Spec coverage

Spec: docs/specs/S17-rest-management-api.md (Manifest resource fields, Referrers list, Page weight)

Acceptance criteria

# Criterion Tests
AC-133 referrers_count on the list and the detail: manifests in the same image naming this digest as their subject, 0 when none, direct referrers only, image-scoped, and the rows the default list hides counted TestContainerManifestList_ReferrersCount, TestContainerManifestDetail_ReferrersCount, TestContainerManifestList_ReferrersCountSpansTheRowsTheDefaultListHides, TestContainerManifestList_ReferrersCountIsDirectOnly, TestContainerManifestList_ReferrersCountIsScopedToTheAddressedImage, TestContainerManifestDetail_ReferrerRowCarriesItsOwnCount, TestContainerManifestReferrers_NoneServesZeroNotNull, TestContainerManifestReferrers_BothLeavesAgree, TestContainerReadsIntegration_ManifestReferrerPartition (real Postgres). The route-exhaustion equality clause is Step 14's, which has no route to walk yet.
AC-137 The remote arm serves 0 on every row and issues no statement TestContainerRemoteManifestList_ReferrersCountIsZeroAndIssuesNoCount, TestContainerRemoteManifestDetail_ReferrersCountIsZero, TestFetchContainerManifestPageFacts_CachedArmIssuesNoReferrersCount, TestContainerManifestBaseBuilders_TheCachedArmServesZeroReferrers. The criterion's annotations, children, parents, and raw clauses belong to Steps 19, 18, 16, and 20.
AC-138 A page costs one referrers statement at limit=1 and at limit=100 alike, and the detail costs the same TestContainerManifestList_ReferrerFactsCostOneReadPerPage, TestContainerManifestDetail_ReferrerCountCostsTheSameReads, TestContainerManifestList_EmptyPageIssuesNoReferrerCount. The referrers share only; tags is Step 10's and the other two families are Steps 16 and 18.
Contract referrers_count declared on the shared base alone, integer, required where declared TestContract_ContainerManifestSchema_DeclaresReferrersCount, TestContainerManifestReferrers_OnTheBaseOfBothLeaves, with TestContract_ContainerManifestSchemas_ShareOneBase unchanged
Seam The batch is the page's digests in page order, scoped to the resolved namespace and image, and stays inside the store's limit TestFetchContainerManifestPageFacts_BuildsTheReferrersBatch, TestFetchContainerManifestDetailFacts_BuildsTheReferrersBatch, TestContainerManifestList_ReferrerBatchIsThePageDigestsInPageOrder, TestContainerManifestList_ReferrerBatchStaysUnderTheStoreLimit, TestContainerManifestPageRefs_KeepsPageOrder
Per-format docker and oci on both kinds and both routes (guardrail 6) TestContainerManifestReferrers_PerFormat

Error cases

# Condition Tests
E-1 The count fails: the whole page or detail is a logged 500 carrying container_image_id, never a row served unreferenced TestContainerManifestReferrers_StoreFailureIsALogged500 (asserts the field value and the full message), TestFetchContainerManifestFacts_ReferrersErrorFailsTheHostedReadAlone
E-2 An empty page issues no count, because the store rejects an empty digest set TestContainerManifestList_EmptyPageIssuesNoReferrerCount, TestFetchContainerManifestPageFacts_EmptyPageMakesNoCall
E-3 A digest absent from the grouped result reads 0 rather than as an error TestContainerManifestFactsFrom_AbsentManifestReadsAsZero, TestContainerManifestReferrers_NoneServesZeroNotNull

Security considerations

# Concern Tests
S-1 The count is scoped to the resolved namespace and image, so a sibling image's referrers are excluded TestContainerManifestList_ReferrersCountIsScopedToTheAddressedImage, TestFetchContainerManifestPageFacts_BuildsTheReferrersBatch, TestContainerReadsIntegration_ManifestReferrerPartition
S-2 The digest key cannot silently miss: handler and store both key raw bytes TestContainerReadsIntegration_ManifestReferrerPartition against a real database, proved non-vacuous by a temporary raw-bytes-to-hex mutation that reproduced a universal zero
S-3 An image of another repository stays unreachable Pre-existing and unchanged: TestContainerManifestList_ParentResolution and the detail suite's chain cases

Reviewable LOC

1,423 changed lines (insertions plus deletions) across 20 files, past guardrail 18's 500: 147 production Go, 1,257 test Go, 13 contract YAML, 6 docs. Production is 121 inserted lines.

Splitting would not help. The field, its contract declaration, and its tests are one reviewable unit, and the plan already split the statement into chore(datastore): the batched referrers count o... (!2582 - merged) • Hayley Swimelar • 19.4. The test half is 11x the plan's ~110 forecast, which is where the size is: AC #138 (closed) wants a query count at two page sizes on both routes, the spec's direct-only and per-image scoping rules each need a fixture that discriminates, and both resource-key sets are test-enforced because kin-openapi leaves additionalProperties open on the two leaves.

Test plan

  • Unit: go test ./internal/managementapi/... ./cmd/..., green (22.0s, 3.2s).
  • Integration against a real Postgres 17: internal/managementapi green in 81.1s, plus the two cmd/artifact-registry management wiring tests in 5.4s.
  • golangci-lint run: 0 issues. Integration-tagged with --max-same-issues=0 --max-issues-per-linter=0 --uniq-by-line=false: 0 new against this branch's base, and the full run reproduces an 11-finding pre-existing baseline in four ./cmd/... files this branch does not touch.
  • go build ./..., gofmt, go vet, and scripts/ci/check-comment-caps.sh clean; markdownlint and Vale clean on the three touched docs.

No route is added, changed, or re-registered, so no e2e scenario is added (guardrail 12) and no run-recipe edit is owed (guardrail 21). The two manifest-detail-via-api catalog rows gained referrers_count, because the field set they enumerate would otherwise go stale.

Coordination

feat(managementapi): serve the raw manifest pay... (!2587 - merged) • Hayley Swimelar • 19.5 (both e2e catalogs) and feat(managementapi): serve annotations on the c... (!2574 - merged) • Hayley Swimelar • 19.4 (four shared test files) conflict against this branch and merge clean against its base, so whichever lands second resolves a keep-both union on append-only seams. feat(managementapi): children preview, count, a... (!2588 - merged) • Hayley Swimelar • 19.5 extends the same facts struct and both key lists, and conflicts against this branch and its base alike.

Context for LLM agents

Design rationale, and what was rejected

The (id, digest) pair, rather than parallel slices. The facts map is keyed by manifest id, because that is what the tag families batch on, and the referrers count groups on subject_digest. Passing ids []uuid.UUID beside digests [][]byte satisfies both reads and makes the correspondence between them positional: a caller that built one slice from a reordered page would serve every row another row's count, silently, with no test able to see it. containerManifestPageRef makes the pairing structural, and containerManifestRefIDs projects the id half for the families that want it.

One helper for both call shapes. fetchContainerManifestReferrerCounts is called by the page fetch and the detail fetch rather than inlining the params build twice. Unlike the tag families there is no preview-versus-complete split to express, because a count has no array beside it (plan D3), so the two call sites differ only in how many refs they hand over.

Sequential, never an errgroup. Plan D3: a page then holds one pooled connection at a time, and its facts cost the sum of the landed families' figures rather than their maximum. The alternative buys back at most 300 ms on a hosted page at every ceiling at once, at the price of one pooled connection per family for every in-flight page.

Any family's failure fails the whole response. A partial count is a wrong count, so a failed referrers read is a logged 500 rather than a page whose rows read unreferenced. The call site carries container_image_id, because the store's error names no identifier and the 500 writer restores namespace_id and nothing finer.

No counter, no index, no cache. Rejected under an operator ruling, and the measurement is the spec owner's to act on: docs/dev/database-query-patterns.md records the count at 15 ms on the all-visible fixture after VACUUM and 78 to 94 ms on the pre-vacuum interleaved fixture, against a per-query 100 ms budget. A covering index would not move the pre-vacuum arm, whose cost is an uncleared-visibility-map heap fetch.

Non-goals

  • The grouped statement itself, its argument guards, its unit and integration tests, and its EXPLAIN pin and ceiling timing. chore(datastore): the batched referrers count o... (!2582 - merged) • Hayley Swimelar • 19.4 owns those, and this step only calls it.
  • The referrers route. Step 14 serves it and walks AC #133 (closed)'s route-exhaustion equality against it.
  • children, children_preview, children_count, parent_digests, parents_preview, parents_count, and annotations. Steps 16, 18, and 19 each add a family to the same seam.
  • A per-request budget across the family arms. The two landed families add up at their measured worsts, the tags read's 58 ms plus this count's 94 ms pre-vacuum arm, while neither statement breaches the per-query budget alone, and two heavier arms are still to land. Worth settling before Steps 16 and 18 rather than here.
  • The count's error and cancellation series. database_query_errors_total and database_query_cancellations_total are booked in internal/datastore per docs/dev/database-query-patterns.md, not at this call site.
  • A configuration knob for anything here. Plan D3 forbids one, so a knob would be evidence the plan is wrong rather than a feature.

Falsifications recorded against the plan

  • Step 12's Files list omits internal/managementapi/container_manifest_detail.go, which the detail fetch's signature change forces, and omits both e2e catalogs. It names fake_container_readers_test.go twice. Its LOC forecast is off 3.7x on source and 11.4x on test.
  • The plan's rule that a field step makes no catalog edit was already false at Step 10 (2ed72d011), which amended the same two cells.
  • Stale line cites, all still findable by search: configuration-reference.md:371 is :375; D10's seam-count literal is 99 at handler_test.go:446, not 98 at :443; D10's containerRemoteEvictionResolverAdapter is at wire_management.go:852, not :756-761; D3's include_referrers conditional is at container_manifest.go:892, not :937-939; ChildManifestsExist is at :463, not :472; containerManifestResourceKeys is at fake_container_readers_test.go:207, not :139.
  • S17's Fetch mechanism says each count batches "the page's manifest ids". This one batches digests and has no array beside it, which the plan reconciles and the spec has not.

🤖 Generated with Claude Code

Related to #1150 (closed)

Merge request reports

Loading
Loading