test(e2e): cover the three storage-counter rows

What this adds

The three catalog rows for storage counters, taking the oci and docker catalogs to 45 covered, 0 disagreements:

Scenario Priority
e2e.oci.lifecycle.repository-storage-counters high
e2e.oci.lifecycle.delete-storage-counters high
e2e.docker.lifecycle.delete-storage-counters high

All three pass against a caproni bootstrap rig. Full suite: 102 tests, exit 0.

Neither counter column is written by the request that changes it: both are deltas on a buffered pipeline that a periodic drain writes. Every read here polls, and the arms that assert a column does not move wait long enough for a drain to have landed had there been a delta.

registry.Client gains the two counter columns on Repository, DeleteBlob, and the three management deletes these rows need — DeleteContainerManifest, DeleteContainerTag and BulkDeleteContainerManifests — with the shared image route built in one place.

The reference model, which is the point of the change

container_blobs is keyed on (namespace_id, container_image_id, digest), so a blob reference is per image, not per manifest. Two manifests under one image share one reference row, and deleting it frees the blob at once. The catalog's "only once the repository holds no other reference to it" is therefore observable only with two images sharing a digest: the bytes stay while the second image holds a row, and go when it does not. Verified to the byte — a 1000-byte shared layer holds at the first delete and drops by exactly 1000 at the second.

I got this wrong first, read it as a manifest naming the digest, and was one instruction away from filing a bug against S12's Blob Delete, which states in bold that blob delete checks no manifest reference — by design, matching the OCI Distribution Spec. The behaviour and the counter were both correct; the test was not. e2e/README.md now carries the model under "A blob reference is per image, not per manifest" so the next reader does not repeat it.

The delete journey

One journey per format, covering every delete the two rows name:

Operation artifacts_count size_bytes
Untag unchanged unchanged
Management manifest delete −1 − payload
Repeat, answering 404 unchanged unchanged
Bulk delete entry −1 − payload
Image delete falls to 0 holds until the reap

Both rows share that journey in one file. They are both API rows and differ only in seeding — the docker arm builds an image with crane, because a docker repository refuses a generic artifact as primary content, while the oci arm pushes with oras. Written twice they would drift, which is what the mirrored-suite guardrail is about.

Two statuses came from the contract after I asserted both wrong: an untag and a bulk delete each answer 202.

The rig change

test/caproni lowers drain_interval from thirty seconds to two, and drain_chunk_stale_timeout with it, since it has to stay below the interval. The counter row has four checkpoints; at the default it would spend two minutes waiting. Both values files are updated, per the sync note their headers carry.

Notes for the reviewer

  • Stacked on !2130 (merged), which carries the four container clients these rows use. Target that, not main.
  • 746 insertions, within the reviewable line, which is why this is separate from !2130 (merged) rather than another batch on top of it.
  • !2129 (merged) also edits e2e/internal/catalog/patterns.go and the two catalogs, so whichever of the three lands last will need a hand-merge there. The entries themselves do not overlap.
  • Adjacent but not overlapping: #1018 (closed) covers counter deltas on remote DELETE routes; these rows are hosted.

Merge request reports

Loading
Loading