feat(managementapi): serve the container remote tag untag eviction (S17 Phase 6 plan: 35/42)

Why

Step 30 shipped the 405 that refuses a tag upsert on a remote container repository, and that refusal advertises Allow: GET, DELETE. The GETs are served by feat(managementapi): serve the container remote... (!1748 - merged) • Hayley Swimelar • 19.4. DELETE on the same path still falls through the shared container kind dispatcher to the pending repository-level 404, so the route advertises a method it does not serve.

This closes the DELETE half of AC #106: an untag on a remote repository evicts the cached tag and answers 202.

What

Three things a reviewer would otherwise have to reconstruct.

The eviction hard-deletes where every sibling arm writes a tombstone, and the schema forces it. container_remote_tags carries no soft_deleted_at, unlike container_remote_images, container_remote_manifests, and container_remote_blobs. The migration gives the reason: a tag is a mutable pointer, re-pointed in place, so it has no slot to release, and that column's absence is what keeps unique_container_remote_tags_ns_id_image_id_name non-partial.

The delete drops all four row shapes that absence admits, so it removes exactly the row FindContainerRemoteTagByName would have returned. Those four shapes are enumerated on the reads' own predicates in feat(managementapi): serve the container remote... (!1748 - merged) • Hayley Swimelar • 19.4. The correlated EXISTS over container_remote_manifests covers a manifest belonging to a different image and a tombstoned manifest. The literal-id EXISTS over container_remote_images covers a tombstoned image and an image in another repository. Scoping on the unique index alone would answer 202 for a tag whose manifest is tombstoned, a row the reads hide and only the reap removes, which hands a caller an oracle for rows it cannot read.

A tag name matching nothing answers 404, not the sibling arms' unconditional 202. The remote image and manifest arms answer 202 whatever happened, because an idempotent tombstone write cannot report whether it marked. This write can. 404 is also what the hosted arm of this same route answers, and what deleteContainerTag already publishes in api/openapi/v1.yaml.

Corrections to the plan

  • The step's file list names internal/managementapi/container_tags.go for the untag arm. That file holds the tag list and detail reads. The tag DELETE handler is container_tag_delete.go, which is where the arm went.
  • handleContainerTagDelete's 21-line doc comment could not survive the signature change. The comment-cap gate measures any block the diff touches, and an unexported function's cap is one line, so it is compressed to one, the same trim step 30 applied to handleContainerImageDelete.
  • TestContainerTagDelete_NonHostedRepositoryIs404 looped a shared non-hosted kind list that includes remote and asserted 404, which AC #106 contradicts once this lands. Remote moved out of that loop into positive dispatch coverage.

Spec coverage

Spec: docs/specs/S17-rest-management-api.md

Acceptance criteria

# Criterion Tests
AC-99 Every artifact route on a virtual repository returns 404 TestContainerTagDelete_VirtualRepositoryIs404, TestContainerTagDelete_RemoteKindDispatch (virtual row)
AC-100 An evicted-but-unreaped row (marked soft_deleted_at) is absent from every list and detail read TestContainerRemoteTagStore_EvictContainerRemoteTag_ScopingNegatives (marked-manifest and marked-image rows), TestEvictContainerRemoteTagStmt_SQL, TestContainerTagDelete_RemoteNotFoundMatrix (already-marked image row)
AC-101 DELETE on a remote container tag returns 202, the target disappears from reads, and the request sends no outbound upstream request; a later protocol pull re-caches it 202 and the reads: TestContainerRemoteEvictionIntegration_TagDeleteEvictsAndRepeatsAs404, TestContainerRemoteTagStore_EvictContainerRemoteTag_DeletesAndRereads, TestContainerTagDelete_RemoteEvictsAndAnswers202. No upstream request: structural, the arm's seam set carries no upstream client, so there is nothing to assert against. Re-cache on the next pull: owned by the cache-fill path, not this route.
AC-102 Evictions emit no artifact_registry_artifact_deleted event, and the hosted deletes' emission is unchanged TestContainerTagDelete_RemoteKindDispatch (no kind emits), TestContainerTagDelete_RemoteEvictsAndAnswers202, requireContainerEvictNoSideEffects, and the integration walks under withFailOnEventTracker
AC-106 PUT tag upsert on a remote repository returns 405 with Allow: GET, DELETE and changes nothing; DELETE on the same path evicts the cached tag and returns 202 405 half, shipped earlier: TestContainerTagUpsert_RemoteRepositoryIs405, TestContainerTagUpsert_RemoteRepositoryChangesNothing, TestContainerRemoteEvictionIntegration_TagUpsertIs405. DELETE half: TestContainerTagDelete_RemoteEvictsAndAnswers202, TestContainerTagDelete_RemoteServesBothContainerFormats, TestContainerTagDelete_RemoteLeavesTheImagesOtherTags, TestContainerRemoteEvictionIntegration_TagDeleteEvictsAndRepeatsAs404

Error cases

Condition Status Tests
Artifact routes: repository format differs from the :format segment 404 TestContainerTagDelete_RemoteNotFoundMatrix (maven remote repository under the docker segment)
Artifact routes: repository kind is virtual 404 TestContainerTagDelete_VirtualRepositoryIs404, TestContainerTagDelete_RemoteKindDispatch
Artifact routes: parent or artifact id missing, not a valid UUID, soft-deleted, or outside the URL's parent chain 404 TestContainerTagDelete_RemoteNotFoundMatrix, TestContainerRemoteEvictionIntegration_TagDeleteHoldsToTheAddressedRepository, TestContainerRemoteTagStore_EvictContainerRemoteTag_ScopingNegatives
Artifact delete: target missing, or outside the URL's parent chain 404 TestContainerTagDelete_RemoteMissingTagIs404, TestContainerTagDelete_RemoteSecondDeleteIs404, TestContainerTagDelete_RemoteUnstorableNameIs404WithoutSeam, TestContainerTagDelete_RemoteForwardsTheNameVerbatim
Artifact writes: method registered by neither the path's own pattern nor a sibling wildcard 405 Owned by the router, TestContract_MethodNotAllowed_MatchesErrorSchema
Tag upsert: repository kind is remote 405 with Allow: GET, DELETE Shipped earlier: TestContainerTagUpsert_RemoteRepositoryIs405
All: unexpected server failure 500 TestContainerTagDelete_RemoteFailureMatrix, TestContainerTagDelete_RemoteEvictorFailureLogsTheTargetIDs, TestContainerTagDelete_RemoteKindDispatch (unmapped kind), TestContainerRemoteTagStore_EvictContainerRemoteTag_ArgumentGuards
All: slug resolves to no namespace 404 Owned by the shared namespace resolve, covered by the repository suites
All: malformed JSON body, auth missing or not permitted 400, 401, 403 Not reachable on this route (no body) or owned by S08/S09, still stubbed

Security considerations

Concern Tests
Existence hiding on writes: an artifact outside the URL's parent chain returns 404 TestContainerTagDelete_RemoteNotFoundMatrix, TestContainerRemoteEvictionIntegration_TagDeleteHoldsToTheAddressedRepository, TestContainerTagDelete_RemoteRepositoryReachesNoHostedSeam
Echoed input: no failure response repeats a submitted identifier or name TestContainerTagDelete_RemoteNotFoundMatrix (body carries neither the image id nor the tag name), TestContainerTagDelete_RemoteFailureMatrix (the 500 body hides the cause)
Tenant isolation: every query scoped by the resolved namespace_id, with the full parent chain verified TestContainerRemoteTagStore_EvictContainerRemoteTag_ScopingNegatives (another namespace, another remote repository, a sibling image), TestContainerTagDelete_RemoteNotFoundMatrix (image under another namespace), TestEvictContainerRemoteTagStmt_SinglePartitionPrune
Injection: all queries use the Jet builder with bound parameters TestEvictContainerRemoteTagStmt_SQL (every scope value bound, none inlined), TestContainerTagDelete_RemoteUnstorableNameIs404WithoutSeam (a NUL byte and invalid UTF-8 never reach the statement)
Write authorization: untag requires delete_artifact (ADR-021) Owned by S09, and the checks are stubbed, so no test here
Destructive routes ship behind the S08 and S09 stubs Owned by S08 and S09, accepted exposure recorded in the spec

Test plan

  • Unit, store: every argument guard with its own sentinel, and the statement pin TestEvictContainerRemoteTagStmt_SQL, which asserts the manifest arm is correlated to the row being deleted. Dropping that correlation makes the statement ask only whether the image has some live manifest, and the pin fails on that mutation.
  • Unit, handler: the whole kind dispatch as positive cases (hosted, remote, virtual, unmapped), 202 on evict, 404 on a miss, a logged 500 on a store failure, and the storableTagName screen reaching no seam.
  • Integration, store: seed a cached tag, evict, re-read through both step-20 reads, and evict again. Five scoping negatives, each asserting the row is still present afterwards rather than only that the boolean was false: another image, another namespace, another remote repository, a tombstoned manifest, a tombstoned image.
  • Integration, handler: DELETE through managementapi.NewHandler against a real database returns 202, the row is gone, a repeat is 404, and the image's other tags survive.
  • Local runs: gofmt, go build ./..., go vet on both build tags, the unit suites, both integration suites against Postgres with CI's lock tuning, and golangci-lint on both builds after a cache clean.

Not changed, and why

  • api/openapi/v1.yaml: the route's status set is unchanged, and no operation is added, renamed, or removed, so the Bruno collection is unchanged too. Two descriptions are edited, prose only. The PUT-405 Allow paragraph promised the existence-hiding 404 "until the container remote tag read and the tag eviction ship", and this MR is the tag eviction, so it now states the shipped contract. And deleteContainerTag states that a remote untag's 202 answers a cache eviction rather than a durable untag, wording supplied in review.
  • docs/testing/: both container e2e catalogs scope remote repositories out ("Out of scope until the capability ships: Virtual and remote (proxy/cache) repositories"), so no scenario covers this route yet.
  • The plan's Status table: its rows have a single writer, docs(plans): record the step 33 and 34 MRs in t... (!1846 - merged) • Hayley Swimelar • 19.4.

Reviewable size

1610 added, 120 deleted, past the 500-line bar. The production change is 262 added and 61 deleted across six Go files, api/openapi/v1.yaml is 9 added and 6 deleted, and the remaining 1339 added lines are tests. The split that would help does not exist here, because the guardrails are what drive the test volume: every kind value as a positive hit, every argument guard against its own sentinel, five scoping negatives each proving the row survived, and both the statement pin and the partition-prune suite. Landing the arm without them is the thing the size is buying against.

The plan estimated ~90 production and ~250 test lines. Production came in at 240 because the step also carries the seam, the wiring, and the kind-switch restructure of the hosted arm, none of which the estimate priced.

Depends on

feat(managementapi): serve the container remote... (!1748 - merged) • Hayley Swimelar • 19.4 creates ContainerRemoteTagStore and the remote tag reads this arm extends, and this MR targets its branch, so that one merges first. Retargeting this to main ahead of it would land an eviction arm calling a store that does not exist.

Database Review Evidence

Queries

Note

Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral PostgreSQL 17.10 container (matching GL_PG_CURR_VERSION from .gitlab-ci-other-versions.yml), with synthesized seed data rolled back per query and the container torn down at the end of the run. Numbers reflect moderate cardinality and do not capture production-scale effects. See Database review evidence for seed sizing, methodology, and the anomalies the skill flags. Expand each row's details for the seed shape, rendered SQL, bound args, and raw plan.

EvictContainerRemoteTag is planned twice. tags50 is the write-target sizing the recipe prescribes for a DELETE (the addressed row plus 49 siblings). At that size the addressed partition is a single page, so the planner reads it whole and the plan cannot say which index serves the statement. tags5000 repeats the run with 4954 tags in that partition to answer it. The seed set is otherwise identical, and the two plans agree on every other node.

The Partitions cell lists container_remote_tags, container_remote_manifests, and container_remote_images in that order. Each modulus comes from pg_inherits, not from the plan text, which names only the partitions it actually scans.

Rows (plan / actual) reads from the root Delete node, which reports 0 / 0 for a DELETE without RETURNING whatever the statement matched. The row the delete resolves shows on the driving scan instead, estimated 1 and actual 1 in both plans.

container_remote_tags carries no triggers, nothing references it by foreign key, and its own foreign keys are all NO ACTION, so the plan tree below is the whole of the statement's work rather than the visible part of a cascade.

Method Plan node Index Rows (plan / actual) Cost Time Buffers (hit / read) Partitions
datastore.EvictContainerRemoteTag.tags50 Delete n/a (Seq Scan over a 54-row partition) 0 / 0 18.56 0.036ms 8 / 0 1/64, 1/64, 1/64
datastore.EvictContainerRemoteTag.tags5000 Delete unique_container_remote_tags_ns_id_image_id_name 0 / 0 24.93 0.041ms 10 / 0 1/64, 1/64, 1/64
datastore.EvictContainerRemoteTag.tags50

Summary: The plan matches the untag's intent. Every partitioned table it touches prunes to one of 64 partitions, and the image EXISTS arm collapses to a One-Time Filter over an InitPlan: none of its four conjuncts reference the outer table, so it is uncorrelated and evaluates once for the statement rather than once per candidate row. At the 54-row write-target cardinality the recipe prescribes, the addressed partition is one page, so the planner reads it whole (Rows Removed by Filter: 53) instead of descending the unique index. That is a cost decision about a single page, not a missing index: tags5000 below is the same statement against a non-trivial partition. No anomalies.

Seed shape: namespaces=1, repositories=3, container_remote_repositories=2, blob_storage_blobs=1, blob_storage_attachments=1, container_remote_images=5000, container_remote_manifests=5000, container_remote_tags=54

The 54 tags are the addressed row, 49 siblings of the same image that must survive the untag, and one row for each shape the predicates exist to reject: a tag pointing at another image's manifest, a tag on a manifest carrying soft_deleted_at, a tag under an image carrying soft_deleted_at, and a tag under an image of a second remote repository. The three seeded repositories rows are the addressed remote-kind repository, a second remote-kind repository, and a hosted-kind one.

Rendered SQL:

DELETE FROM public.container_remote_tags
WHERE ((((container_remote_tags.namespace_id = $1::uuid) AND (container_remote_tags.container_remote_image_id = $2::uuid)) AND (container_remote_tags.name = $3::text)) AND (EXISTS (
           SELECT container_remote_manifests.id AS "container_remote_manifests.id"
           FROM public.container_remote_manifests
           WHERE (((container_remote_manifests.id = container_remote_tags.container_remote_manifest_id) AND (container_remote_manifests.namespace_id = container_remote_tags.namespace_id)) AND (container_remote_manifests.container_remote_image_id = container_remote_tags.container_remote_image_id)) AND (container_remote_manifests.soft_deleted_at IS NULL)
      ))) AND (EXISTS (
           SELECT container_remote_images.id AS "container_remote_images.id"
           FROM public.container_remote_images
           WHERE (((container_remote_images.id = $4::uuid) AND (container_remote_images.namespace_id = $5::uuid)) AND (container_remote_images.container_remote_repository_id = $6::uuid)) AND (container_remote_images.soft_deleted_at IS NULL)
      ));

Bound args: [e597c7c7-c487-4db2-92e6-418b13d94c9a, 1425d088-7d8d-4010-b253-069b1aebca7b, latest, 1425d088-7d8d-4010-b253-069b1aebca7b, e597c7c7-c487-4db2-92e6-418b13d94c9a, bbae01af-4b81-48f2-93ac-319f03534b6f]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Delete on container_remote_tags  (cost=8.59..18.56 rows=0 width=0) (actual time=0.035..0.036 rows=0 loops=1)
   Delete on container_remote_tags_p60 container_remote_tags_1
   Buffers: shared hit=8
   InitPlan 1
     ->  Index Scan using container_remote_images_p60_pkey on container_remote_images_p60 container_remote_images  (cost=0.28..8.30 rows=1 width=0) (actual time=0.010..0.010 rows=1 loops=1)
           Index Cond: ((id = '1425d088-7d8d-4010-b253-069b1aebca7b'::uuid) AND (namespace_id = 'e597c7c7-c487-4db2-92e6-418b13d94c9a'::uuid))
           Filter: ((soft_deleted_at IS NULL) AND (container_remote_repository_id = 'bbae01af-4b81-48f2-93ac-319f03534b6f'::uuid))
           Buffers: shared hit=3
   ->  Result  (cost=0.28..10.26 rows=1 width=20) (actual time=0.025..0.029 rows=1 loops=1)
         One-Time Filter: (InitPlan 1).col1
         Buffers: shared hit=7
         ->  Nested Loop  (cost=0.28..10.26 rows=1 width=20) (actual time=0.014..0.017 rows=1 loops=1)
               Buffers: shared hit=4
               ->  Seq Scan on container_remote_tags_p60 container_remote_tags_1  (cost=0.00..1.95 rows=1 width=58) (actual time=0.005..0.008 rows=1 loops=1)
                     Filter: ((namespace_id = 'e597c7c7-c487-4db2-92e6-418b13d94c9a'::uuid) AND (container_remote_image_id = '1425d088-7d8d-4010-b253-069b1aebca7b'::uuid) AND (name = 'latest'::text))
                     Rows Removed by Filter: 53
                     Buffers: shared hit=1
               ->  Index Scan using container_remote_manifests_p60_pkey on container_remote_manifests_p60 container_remote_manifests  (cost=0.28..8.30 rows=1 width=58) (actual time=0.008..0.008 rows=1 loops=1)
                     Index Cond: ((id = container_remote_tags_1.container_remote_manifest_id) AND (namespace_id = 'e597c7c7-c487-4db2-92e6-418b13d94c9a'::uuid))
                     Filter: ((soft_deleted_at IS NULL) AND (container_remote_image_id = '1425d088-7d8d-4010-b253-069b1aebca7b'::uuid))
                     Buffers: shared hit=3
 Planning:
   Buffers: shared hit=99 read=1
 Planning Time: 0.704 ms
 Execution Time: 0.205 ms

Timings: planning 0.704ms, execution 0.205ms, total 0.909ms.

datastore.EvictContainerRemoteTag.tags5000

Summary: The same statement against a non-trivial partition, and the row-identifying conjuncts ride unique_container_remote_tags_ns_id_image_id_name, which is what holds the delete to one row. All three of its columns land as Index Cond and nothing is removed by filter, so the index is fully used rather than partially. The manifest arm stays a primary-key probe per candidate row, and the image arm stays a One-Time Filter. No anomalies.

The plan names the partition-local child index container_remote_tags_p11_namespace_id_container_remote_ima_idx, which PostgreSQL truncates at 63 characters. pg_inherits confirms its parent is unique_container_remote_tags_ns_id_image_id_name. The partition number differs from tags50 only because each run generates a fresh namespace UUID.

Seed shape: namespaces=1, repositories=3, container_remote_repositories=2, blob_storage_blobs=1, blob_storage_attachments=1, container_remote_images=5000, container_remote_manifests=5000, container_remote_tags=4954

Identical to tags50 except the sibling tag count, which goes from 49 to 4949.

Rendered SQL:

DELETE FROM public.container_remote_tags
WHERE ((((container_remote_tags.namespace_id = $1::uuid) AND (container_remote_tags.container_remote_image_id = $2::uuid)) AND (container_remote_tags.name = $3::text)) AND (EXISTS (
           SELECT container_remote_manifests.id AS "container_remote_manifests.id"
           FROM public.container_remote_manifests
           WHERE (((container_remote_manifests.id = container_remote_tags.container_remote_manifest_id) AND (container_remote_manifests.namespace_id = container_remote_tags.namespace_id)) AND (container_remote_manifests.container_remote_image_id = container_remote_tags.container_remote_image_id)) AND (container_remote_manifests.soft_deleted_at IS NULL)
      ))) AND (EXISTS (
           SELECT container_remote_images.id AS "container_remote_images.id"
           FROM public.container_remote_images
           WHERE (((container_remote_images.id = $4::uuid) AND (container_remote_images.namespace_id = $5::uuid)) AND (container_remote_images.container_remote_repository_id = $6::uuid)) AND (container_remote_images.soft_deleted_at IS NULL)
      ));

Bound args: [0989387e-891f-42d1-b883-652eb118cc2f, bd85d230-3af6-4ce4-bb37-49a2a5485e24, latest, bd85d230-3af6-4ce4-bb37-49a2a5485e24, 0989387e-891f-42d1-b883-652eb118cc2f, 46c5cbdf-6679-4254-97d8-3072a2a3d9af]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Delete on container_remote_tags  (cost=8.87..24.93 rows=0 width=0) (actual time=0.040..0.041 rows=0 loops=1)
   Delete on container_remote_tags_p11 container_remote_tags_1
   Buffers: shared hit=10
   InitPlan 1
     ->  Index Scan using container_remote_images_p11_pkey on container_remote_images_p11 container_remote_images  (cost=0.28..8.30 rows=1 width=0) (actual time=0.008..0.008 rows=1 loops=1)
           Index Cond: ((id = 'bd85d230-3af6-4ce4-bb37-49a2a5485e24'::uuid) AND (namespace_id = '0989387e-891f-42d1-b883-652eb118cc2f'::uuid))
           Filter: ((soft_deleted_at IS NULL) AND (container_remote_repository_id = '46c5cbdf-6679-4254-97d8-3072a2a3d9af'::uuid))
           Buffers: shared hit=3
   ->  Result  (cost=0.56..16.62 rows=1 width=20) (actual time=0.033..0.034 rows=1 loops=1)
         One-Time Filter: (InitPlan 1).col1
         Buffers: shared hit=9
         ->  Nested Loop  (cost=0.56..16.62 rows=1 width=20) (actual time=0.024..0.025 rows=1 loops=1)
               Buffers: shared hit=6
               ->  Index Scan using container_remote_tags_p11_namespace_id_container_remote_ima_idx on container_remote_tags_p11 container_remote_tags_1  (cost=0.28..8.30 rows=1 width=58) (actual time=0.010..0.010 rows=1 loops=1)
                     Index Cond: ((namespace_id = '0989387e-891f-42d1-b883-652eb118cc2f'::uuid) AND (container_remote_image_id = 'bd85d230-3af6-4ce4-bb37-49a2a5485e24'::uuid) AND (name = 'latest'::text))
                     Buffers: shared hit=3
               ->  Index Scan using container_remote_manifests_p11_pkey on container_remote_manifests_p11 container_remote_manifests  (cost=0.28..8.30 rows=1 width=58) (actual time=0.013..0.013 rows=1 loops=1)
                     Index Cond: ((id = container_remote_tags_1.container_remote_manifest_id) AND (namespace_id = '0989387e-891f-42d1-b883-652eb118cc2f'::uuid))
                     Filter: ((soft_deleted_at IS NULL) AND (container_remote_image_id = 'bd85d230-3af6-4ce4-bb37-49a2a5485e24'::uuid))
                     Buffers: shared hit=3
 Planning:
   Buffers: shared hit=91
 Planning Time: 0.640 ms
 Execution Time: 0.215 ms

Timings: planning 0.640ms, execution 0.215ms, total 0.855ms.

Related to #314

Edited by Hayley Swimelar

Merge request reports

Loading
Loading