feat(managementapi): serve the container remote tag reads (S17 Phase 6 plan: 20/42)
Why
The container tag list and detail routes answered a pending existence-hiding 404 on a kind=remote repository. This serves them from the container_remote_tags cache table.
Implements Step 20 of docs/plans/2026-08-13-s17-phase6-virtual-remote-repositories.md against docs/specs/S17-rest-management-api.md. Acceptance is the container tag slices of AC #95, #96 (closed), and #97 (closed).
What the diff does not say
The keyset lists by name with no id tiebreaker. The only keyset-capable index on container_remote_tags is UNIQUE (namespace_id, container_remote_image_id, name), so name is already a total order within one image, and a tiebreaker would push the plan off the index. This diverges deliberately from the Step 14 manifests template, which keysets by created_at plus id.
That table has no created_at, updated_at, or soft_deleted_at columns, only upstream_checked_at and a nullable upstream_etag. So there is no soft-delete filter to write (eviction here is a hard delete arriving with a later step), and a remote tag serves null for both timestamps.
cache uses json:"cache,omitempty" while created_at and updated_at are pointers without omitempty. That asymmetry is the contract: AC #96 (closed) wants the cache key absent on hosted, and AC #97 (closed) wants explicit null timestamps on remote.
The tag routes carry their own kind dispatch. resolveContainerRepository is shared with the container image, manifest, and delete routes, whose remote arms other steps own, so flipping it would un-gate work that has not landed. hostedVersionFileRepository, declared in versions.go, is the in-repo precedent.
This fixes a defect live on main for the hosted route. An unscreened tag name reached the statement. LabKit pins pgx to simple-protocol mode, so a NUL truncates the wire query (08P01) and invalid UTF-8 fails server-side (22021), which makes GET .../tags/%80 a client-typable 500. storableTagName already existed and the untag route already called it. The screen now runs above the kind dispatch, and the screened name is threaded into both arms.
Interaction with Step 19
Step 19 is open and targets main as feat(managementapi): serve container remote ima... (!1747 - merged) • Hayley Swimelar • 19.4. The two share 11 files, so whichever merges second needs a rebase.
They compose rather than conflict semantically. Step 19's own comment records that the tag routes stay pending until a management-facing reader over container_remote_tags and their own remote arms land, which is this MR. Both reach the remote parent through the same FindRemoteDetailsByRepositoryID read and scope by container_remote_repository_id. Step 19 formalizes that as a ContainerRemoteRepositoryResolver field on containerReaders, and this MR carries it on the tag routes' own dispatch. If Step 19 lands first, the follow-up rebase should consolidate onto its remoteRepos field rather than keep two carriers of the same resolver.
API compatibility
ContainerTag.created_at and updated_at widen from required non-nullable to nullable. That is a semantic change to a required field, against the contract's additive-only v1 clause. It is unreachable today, because the create handler forces hosted and kind is immutable afterwards, so no remote container repository can exist.
The widening needs an ordering constraint against remote-create enablement: it must be published before any path can create a remote container repository, or a regenerated hosted-typed client breaks.
Reviewable LOC
~3808 total, past the 500 guardrail. Tests 2897, production Go 870, OpenAPI 41, across 27 files. The largest production files are internal/managementapi/container_tags.go (340) and internal/datastore/container_remote_tags.go (331); the two largest overall are their own test suites.
Splitting does not help. The plan's step requires the contract addition, the store, and the handler arm that serves them in one change, and separating the store from its only consumer would ship a dead symbol.
E2E scenarios
No e2e scenario is added or affected: docs/testing/e2e/docker.md and oci.md both list virtual and remote (proxy/cache) repositories under "Out of scope until the capability ships", and nothing fills the tables these reads serve. wire_oci_remote.go does construct datastore.NewContainerRemoteCacheStore, but only as a read seam: the fill, UpsertCacheFill, is reachable just through oci.NewRemoteCacheStore, which has no production call site.
Spec coverage
| Acceptance slice or error case | Tests |
|---|---|
| AC #95 cache-backed tag list | TestContainerTagList_Remote_Defaults, TestContainerTagList_Remote_KeysetWalk, TestContainerRemoteTagStore_ListContainerRemoteTags, TestContainerReadsIntegration_RemoteTagKeysetWalk |
| AC #95 cache-backed tag detail | TestContainerTagDetail_Remote_Returns200, TestContainerRemoteTagStore_FindContainerRemoteTagByName, TestContainerReadsIntegration_RemoteTagDetail |
| AC #95 an unpulled coordinate is absent | TestContainerTagList_Remote_TaglessImage_Returns200Empty, TestContainerTagDetail_Remote_NotFound_IsOneResponse |
AC #96 (closed) cache on remote, both upstream_etag states |
TestContainerTagList_Remote_EveryRowCarriesCache, TestContainerTagDetail_Remote_NullUpstreamETag, TestArtifactCacheFrom |
| AC #96 (closed) the key absent on hosted | TestContainerTagResponses_HostedOmitTheCacheKey, TestContainerTagFromRow |
AC #97 (closed) null created_at and updated_at on remote |
TestContainerTagFromRemoteRow, TestContainerTagList_Remote_EveryRowCarriesCache, TestContainerTagDetail_Remote_Returns200 |
| Response shape against the OpenAPI contract | TestContainerRemoteTagHandlers_ResponsesMatchOpenAPIContract |
| Unstorable tag name | TestContainerTagDetail_UnstorableNameIs404WithoutStatement |
| Nil parent id | TestContainerTagList_Remote_NilImageID_Returns404, TestContainerTagList_NilImageID_Returns404, TestContainerRemoteTagStore_ListContainerRemoteTags_ArgumentGuards, TestContainerRemoteTagStore_FindContainerRemoteTagByName_ArgumentGuards |
| Tenant and repository isolation | TestContainerRemoteTagStore_ChainScoping (namespace and repository), TestContainerTagReads_Remote_WrongRepositoryScope_ServeNoRows (repository) |
Chain-join visibility, the row shapes the missing soft_deleted_at admits |
TestContainerRemoteTagStore_ChainJoinDropsUnreachableRows |
| Existence-hiding 404s and kind dispatch | TestContainerTagList_Remote_MissingImage_Returns404, TestContainerTagList_Remote_ImageOfAnotherRepository_Returns404, TestContainerTagRoutes_KindDispatch |
| Binding-row miss split on the parent's liveness | TestContainerTagRoutes_Remote_BindingRowOutcomes |
| Store and parent read failures | TestContainerTagList_Remote_StoreFailure_Returns500, TestContainerTagList_Remote_ParentReadFailure_Returns500, TestContainerTagDetail_Remote_NilRow_Returns500 |
| Index-backed keyset order | TestContainerRemoteTagStore_DeepPageIsIndexBacked |
| Partition pruning | TestContainerRemoteTagStore_DeepPageIsIndexBacked (list), TestFindContainerRemoteTagByNameStmt_SinglePartitionPrune (finder) |
| Keyset correctness across pages | TestContainerRemoteTagStore_CursorBoundIsStrict, TestContainerRemoteTagStore_KeysetWalk, TestContainerRemoteTagStore_UnmatchedCursor |
| Digest attribution, each tag against its own manifest | TestContainerRemoteTagStore_RowFidelity |
| Wire-layer cache seams | TestWireManagementAPIWithDeps_NilCacheSeamsPanicAtTheWireLayer |
Test plan
go test ./internal/managementapi/... ./internal/datastore/... ./cmd/artifact-registry/... covers the handler tables, the resource mappers, the statement builders, and the wiring.
go test -tags=integration ./internal/managementapi/... ./internal/datastore/... runs the store against PostgreSQL. TestContainerRemoteTagStore_DeepPageIsIndexBacked seeds 5000 analyzed rows under the target image plus a 2000-row sibling, then asserts that the ascending first page, the ascending deep page, and the descending deep page each scan a partition of unique_container_remote_tags_ns_id_image_id_name, materialize no post-scan Sort, and prune all three joined relations to one partition.
Context for LLM agents
Design rationale and rejected alternatives
name-only keyset, noidtiebreaker. Rejected: mirroring the Step 14 manifests template (created_at,id).container_remote_tagshas nocreated_at, and its only keyset-capable index isUNIQUE (namespace_id, container_remote_image_id, name). Appendingidputs a column the index does not carry into the ORDER BY, which costs the index fold and reintroduces the materialized sortTestContainerRemoteTagStore_DeepPageIsIndexBackedpins against.nameis unique within one image under that index, so it is already a total order and the tiebreaker buys nothing.- A second kind dispatch (
resolveContainerTagRepository) instead of wideningresolveContainerRepository. Rejected: adding the remote arm to the shared resolver. That resolver also gates the image reads, the manifest reads, the image, manifest, and tag deletes, and the tag upsert. Widening it serves all of them from stores that do not exist yet.hostedVersionFileRepositoryis the in-repo precedent for two route families holding independent kind gates. The duplication is expected to collapse once the remaining remote arms land. omitemptyoncachebut not on the timestamps. Rejected: uniform pointer-with-omitempty, and uniform pointer-without. The two fields encode different rules. AC #96 (closed) makes the presence ofcachethe discriminator between hosted and remote, so hosted must omit the key. AC #97 (closed) makes the timestamps explicitlynullon remote, so the keys must stay present.TestContainerTagFromRowandTestContainerTagFromRemoteRowpin the pair at the mapper level so a handler wired to the wrong mapper fails loudly.
Non-goals
- Eviction and cache fill. The store has a finder and a keyset list only. The hard delete arrives with the untag eviction step, and nothing writes the rows these reads serve: the wired OCI arm builds
datastore.NewContainerRemoteCacheStoreas a read seam only, andUpsertCacheFillis reachable just throughoci.NewRemoteCacheStore, which has no production call site. - The remaining container remote arms. Image reads, manifest reads, and the three deletes keep their pending 404 through
resolveContainerRepository. Step 19 covers the first two. - Closing the API-compatibility window. This MR discloses the
created_atandupdated_atwidening and the publish-before-remote-create ordering it implies. Enforcing that ordering is not in scope here.
Accepted follow-up
TestContainerRemoteTagStore_DeepPageIsIndexBacked seeds all 5000 target-image tags against one manifest row, so its Rows Removed by Filter assertion cannot currently catch a soft-delete check degrading to a post-join filter on the manifests side. The assertion is inert rather than wrong, and diversifying the seeded manifests is the fix.
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 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.
The seed departs upward from the default recipe. It is committed and
VACUUM (FULL, ANALYZE)d rather than rolled back around the measurement.
The two joined tables carry 5000 rows each instead of one, since at one row
a Seq Scan over them is the honest plan and says nothing about production.
The target image's 5000 tags point at 500 distinct manifests, the shape
TestContainerRemoteTagStore_DeepPageIsIndexBacked does not build, and that
is what makes the manifest join the per-row primary-key lookup below.
Another 2000 tags sit under a live sibling image in the same remote
repository and 2000 under an image of a second remote repository, so both
container_remote_image_id and container_remote_repository_id have to
discriminate. Lists are measured at the caller's maximum page size
(maxPageSize = 100, internal/managementapi/list.go), so LIMIT binds
101 with the hasMore probe row. Every plan ran twice at identical cost.
| Method | Plan node | Index | Rows (plan / actual) | Cost | Time | Buffers (hit / read) | Partitions |
|---|---|---|---|---|---|---|---|
ContainerRemoteTagStore.ListContainerRemoteTags.CursorNilAsc |
Limit | unique_container_remote_tags_ns_id_image_id_name |
101 / 101 | 20.38 | 0.355ms | 310 / 0 | 1/64, 1/64, 1/64 |
ContainerRemoteTagStore.ListContainerRemoteTags.CursorNilDesc |
Limit | unique_container_remote_tags_ns_id_image_id_name |
101 / 101 | 20.38 | 0.302ms | 310 / 0 | 1/64, 1/64, 1/64 |
ContainerRemoteTagStore.ListContainerRemoteTags.CursorSetAsc |
Limit | unique_container_remote_tags_ns_id_image_id_name |
101 / 101 | 60.81 | 0.202ms | 312 / 0 | 1/64, 1/64, 1/64 |
ContainerRemoteTagStore.ListContainerRemoteTags.CursorSetDesc |
Limit | unique_container_remote_tags_ns_id_image_id_name |
101 / 101 | 24.88 | 0.201ms | 312 / 0 | 1/64, 1/64, 1/64 |
ContainerRemoteTagStore.FindContainerRemoteTagByName |
Limit | unique_container_remote_tags_ns_id_image_id_name |
1 / 1 | 24.94 | 0.028ms | 9 / 0 | 1/64, 1/64, 1/64 |
ContainerRemoteTagStore.ListContainerRemoteTags.CursorNilAsc
Summary: Plan matches the method's intent. The keyset order comes straight from unique_container_remote_tags_ns_id_image_id_name with no post-scan Sort, and the literal namespace_id prunes container_remote_tags, container_remote_manifests, and container_remote_images to one of 64 partitions each. The manifest join is a Memoized primary-key lookup per returned row and the image join one primary-key lookup, so the join work scales with the page, not with the image's tag count. No anomalies.
Seed shape: namespaces=1, repositories=2, container_remote_repositories=2, blob_storage_blobs=3, blob_storage_attachments=3, container_remote_images=5003, container_remote_manifests=5502, container_remote_tags=9000
Rendered SQL:
SELECT container_remote_tags.namespace_id AS "container_remote_tags.namespace_id",
container_remote_tags.id AS "container_remote_tags.id",
container_remote_tags.container_remote_image_id AS "container_remote_tags.container_remote_image_id",
container_remote_tags.container_remote_manifest_id AS "container_remote_tags.container_remote_manifest_id",
container_remote_tags.upstream_checked_at AS "container_remote_tags.upstream_checked_at",
container_remote_tags.name AS "container_remote_tags.name",
container_remote_tags.upstream_etag AS "container_remote_tags.upstream_etag",
container_remote_manifests.digest AS "container_remote_manifests.digest"
FROM public.container_remote_tags
INNER JOIN public.container_remote_manifests ON ((((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))
INNER JOIN public.container_remote_images ON (((container_remote_images.id = container_remote_tags.container_remote_image_id) AND (container_remote_images.namespace_id = container_remote_tags.namespace_id)) AND (container_remote_images.soft_deleted_at IS NULL))
WHERE ((container_remote_tags.namespace_id = $1::uuid) AND (container_remote_tags.container_remote_image_id = $2::uuid)) AND (container_remote_images.container_remote_repository_id = $3::uuid)
ORDER BY container_remote_tags.name ASC
LIMIT $4;Bound args: [0198f0a0-0000-7000-8000-000000000001, 0198f0a0-0000-7000-8000-0000000000c1, 0198f0a0-0000-7000-8000-0000000000a1, 101]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.86..20.38 rows=101 width=150) (actual time=0.075..0.355 rows=101 loops=1)
Buffers: shared hit=310
-> Nested Loop (cost=0.86..963.27 rows=4980 width=150) (actual time=0.074..0.348 rows=101 loops=1)
Buffers: shared hit=310
-> Nested Loop (cost=0.58..892.71 rows=4980 width=150) (actual time=0.051..0.299 rows=101 loops=1)
Buffers: shared hit=307
-> Index Scan using container_remote_tags_p31_namespace_id_container_remote_ima_idx on container_remote_tags_p31 container_remote_tags (cost=0.29..540.22 rows=5000 width=117) (actual time=0.019..0.040 rows=101 loops=1)
Index Cond: ((namespace_id = '0198f0a0-0000-7000-8000-000000000001'::uuid) AND (container_remote_image_id = '0198f0a0-0000-7000-8000-0000000000c1'::uuid))
Buffers: shared hit=4
-> Memoize (cost=0.29..0.46 rows=1 width=81) (actual time=0.002..0.002 rows=1 loops=101)
Cache Key: container_remote_tags.container_remote_manifest_id
Cache Mode: logical
Hits: 0 Misses: 101 Evictions: 0 Overflows: 0 Memory Usage: 20kB
Buffers: shared hit=303
-> Index Scan using container_remote_manifests_p31_pkey on container_remote_manifests_p31 container_remote_manifests (cost=0.28..0.45 rows=1 width=81) (actual time=0.001..0.001 rows=1 loops=101)
Index Cond: ((id = container_remote_tags.container_remote_manifest_id) AND (namespace_id = '0198f0a0-0000-7000-8000-000000000001'::uuid))
Filter: ((soft_deleted_at IS NULL) AND (container_remote_image_id = '0198f0a0-0000-7000-8000-0000000000c1'::uuid))
Buffers: shared hit=303
-> Materialize (cost=0.28..8.31 rows=1 width=32) (actual time=0.000..0.000 rows=1 loops=101)
Buffers: shared hit=3
-> Index Scan using container_remote_images_p31_pkey on container_remote_images_p31 container_remote_images (cost=0.28..8.30 rows=1 width=32) (actual time=0.016..0.016 rows=1 loops=1)
Index Cond: ((id = '0198f0a0-0000-7000-8000-0000000000c1'::uuid) AND (namespace_id = '0198f0a0-0000-7000-8000-000000000001'::uuid))
Filter: ((soft_deleted_at IS NULL) AND (container_remote_repository_id = '0198f0a0-0000-7000-8000-0000000000a1'::uuid))
Buffers: shared hit=3
Planning:
Buffers: shared hit=2
Planning Time: 0.467 ms
Execution Time: 0.449 msTimings: planning 0.467ms, execution 0.449ms, total 0.916ms.
ContainerRemoteTagStore.ListContainerRemoteTags.CursorNilDesc
Summary: Descending runs as an Index Scan Backward over the same index, at the same cost as the ascending page, so the direction is free rather than a sorted re-read. Same one-partition-of-64 pruning on all three tables and the same Memoized primary-key manifest lookup. No anomalies.
Seed shape: namespaces=1, repositories=2, container_remote_repositories=2, blob_storage_blobs=3, blob_storage_attachments=3, container_remote_images=5003, container_remote_manifests=5502, container_remote_tags=9000
Rendered SQL:
SELECT container_remote_tags.namespace_id AS "container_remote_tags.namespace_id",
container_remote_tags.id AS "container_remote_tags.id",
container_remote_tags.container_remote_image_id AS "container_remote_tags.container_remote_image_id",
container_remote_tags.container_remote_manifest_id AS "container_remote_tags.container_remote_manifest_id",
container_remote_tags.upstream_checked_at AS "container_remote_tags.upstream_checked_at",
container_remote_tags.name AS "container_remote_tags.name",
container_remote_tags.upstream_etag AS "container_remote_tags.upstream_etag",
container_remote_manifests.digest AS "container_remote_manifests.digest"
FROM public.container_remote_tags
INNER JOIN public.container_remote_manifests ON ((((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))
INNER JOIN public.container_remote_images ON (((container_remote_images.id = container_remote_tags.container_remote_image_id) AND (container_remote_images.namespace_id = container_remote_tags.namespace_id)) AND (container_remote_images.soft_deleted_at IS NULL))
WHERE ((container_remote_tags.namespace_id = $1::uuid) AND (container_remote_tags.container_remote_image_id = $2::uuid)) AND (container_remote_images.container_remote_repository_id = $3::uuid)
ORDER BY container_remote_tags.name DESC
LIMIT $4;Bound args: [0198f0a0-0000-7000-8000-000000000001, 0198f0a0-0000-7000-8000-0000000000c1, 0198f0a0-0000-7000-8000-0000000000a1, 101]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.86..20.38 rows=101 width=150) (actual time=0.044..0.302 rows=101 loops=1)
Buffers: shared hit=310
-> Nested Loop (cost=0.86..963.27 rows=4980 width=150) (actual time=0.044..0.295 rows=101 loops=1)
Buffers: shared hit=310
-> Nested Loop (cost=0.58..892.71 rows=4980 width=150) (actual time=0.031..0.258 rows=101 loops=1)
Buffers: shared hit=307
-> Index Scan Backward using container_remote_tags_p31_namespace_id_container_remote_ima_idx on container_remote_tags_p31 container_remote_tags (cost=0.29..540.22 rows=5000 width=117) (actual time=0.012..0.026 rows=101 loops=1)
Index Cond: ((namespace_id = '0198f0a0-0000-7000-8000-000000000001'::uuid) AND (container_remote_image_id = '0198f0a0-0000-7000-8000-0000000000c1'::uuid))
Buffers: shared hit=4
-> Memoize (cost=0.29..0.46 rows=1 width=81) (actual time=0.002..0.002 rows=1 loops=101)
Cache Key: container_remote_tags.container_remote_manifest_id
Cache Mode: logical
Hits: 0 Misses: 101 Evictions: 0 Overflows: 0 Memory Usage: 20kB
Buffers: shared hit=303
-> Index Scan using container_remote_manifests_p31_pkey on container_remote_manifests_p31 container_remote_manifests (cost=0.28..0.45 rows=1 width=81) (actual time=0.001..0.001 rows=1 loops=101)
Index Cond: ((id = container_remote_tags.container_remote_manifest_id) AND (namespace_id = '0198f0a0-0000-7000-8000-000000000001'::uuid))
Filter: ((soft_deleted_at IS NULL) AND (container_remote_image_id = '0198f0a0-0000-7000-8000-0000000000c1'::uuid))
Buffers: shared hit=303
-> Materialize (cost=0.28..8.31 rows=1 width=32) (actual time=0.000..0.000 rows=1 loops=101)
Buffers: shared hit=3
-> Index Scan using container_remote_images_p31_pkey on container_remote_images_p31 container_remote_images (cost=0.28..8.30 rows=1 width=32) (actual time=0.006..0.007 rows=1 loops=1)
Index Cond: ((id = '0198f0a0-0000-7000-8000-0000000000c1'::uuid) AND (namespace_id = '0198f0a0-0000-7000-8000-000000000001'::uuid))
Filter: ((soft_deleted_at IS NULL) AND (container_remote_repository_id = '0198f0a0-0000-7000-8000-0000000000a1'::uuid))
Buffers: shared hit=3
Planning:
Buffers: shared hit=2
Planning Time: 0.322 ms
Execution Time: 0.359 msTimings: planning 0.322ms, execution 0.359ms, total 0.681ms.
ContainerRemoteTagStore.ListContainerRemoteTags.CursorSetAsc
Summary: The cursor bound folds into the index range: name > 'explain-2500' is an Index Cond, not a Filter, so a page 2500 rows deep reads the same 101 index entries the first page does and Rows Removed by Filter never appears. Pruning and the Memoized manifest lookup are unchanged from the first page. No anomalies.
Seed shape: namespaces=1, repositories=2, container_remote_repositories=2, blob_storage_blobs=3, blob_storage_attachments=3, container_remote_images=5003, container_remote_manifests=5502, container_remote_tags=9000
Rendered SQL:
SELECT container_remote_tags.namespace_id AS "container_remote_tags.namespace_id",
container_remote_tags.id AS "container_remote_tags.id",
container_remote_tags.container_remote_image_id AS "container_remote_tags.container_remote_image_id",
container_remote_tags.container_remote_manifest_id AS "container_remote_tags.container_remote_manifest_id",
container_remote_tags.upstream_checked_at AS "container_remote_tags.upstream_checked_at",
container_remote_tags.name AS "container_remote_tags.name",
container_remote_tags.upstream_etag AS "container_remote_tags.upstream_etag",
container_remote_manifests.digest AS "container_remote_manifests.digest"
FROM public.container_remote_tags
INNER JOIN public.container_remote_manifests ON ((((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))
INNER JOIN public.container_remote_images ON (((container_remote_images.id = container_remote_tags.container_remote_image_id) AND (container_remote_images.namespace_id = container_remote_tags.namespace_id)) AND (container_remote_images.soft_deleted_at IS NULL))
WHERE (((container_remote_tags.namespace_id = $1::uuid) AND (container_remote_tags.container_remote_image_id = $2::uuid)) AND (container_remote_images.container_remote_repository_id = $3::uuid)) AND (container_remote_tags.name > $4::text)
ORDER BY container_remote_tags.name ASC
LIMIT $5;Bound args: [0198f0a0-0000-7000-8000-000000000001, 0198f0a0-0000-7000-8000-0000000000c1, 0198f0a0-0000-7000-8000-0000000000a1, explain-2500, 101]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.86..60.81 rows=101 width=150) (actual time=0.041..0.202 rows=101 loops=1)
Buffers: shared hit=312
-> Nested Loop (cost=0.86..803.36 rows=1352 width=150) (actual time=0.041..0.197 rows=101 loops=1)
Buffers: shared hit=312
-> Nested Loop (cost=0.58..778.15 rows=1352 width=150) (actual time=0.033..0.171 rows=101 loops=1)
Buffers: shared hit=309
-> Index Scan using container_remote_tags_p31_namespace_id_container_remote_ima_idx on container_remote_tags_p31 container_remote_tags (cost=0.29..317.50 rows=1357 width=117) (actual time=0.011..0.022 rows=101 loops=1)
Index Cond: ((namespace_id = '0198f0a0-0000-7000-8000-000000000001'::uuid) AND (container_remote_image_id = '0198f0a0-0000-7000-8000-0000000000c1'::uuid) AND (name > 'explain-2500'::text))
Buffers: shared hit=6
-> Memoize (cost=0.29..0.86 rows=1 width=81) (actual time=0.001..0.001 rows=1 loops=101)
Cache Key: container_remote_tags.container_remote_manifest_id
Cache Mode: logical
Hits: 0 Misses: 101 Evictions: 0 Overflows: 0 Memory Usage: 20kB
Buffers: shared hit=303
-> Index Scan using container_remote_manifests_p31_pkey on container_remote_manifests_p31 container_remote_manifests (cost=0.28..0.85 rows=1 width=81) (actual time=0.001..0.001 rows=1 loops=101)
Index Cond: ((id = container_remote_tags.container_remote_manifest_id) AND (namespace_id = '0198f0a0-0000-7000-8000-000000000001'::uuid))
Filter: ((soft_deleted_at IS NULL) AND (container_remote_image_id = '0198f0a0-0000-7000-8000-0000000000c1'::uuid))
Buffers: shared hit=303
-> Materialize (cost=0.28..8.31 rows=1 width=32) (actual time=0.000..0.000 rows=1 loops=101)
Buffers: shared hit=3
-> Index Scan using container_remote_images_p31_pkey on container_remote_images_p31 container_remote_images (cost=0.28..8.30 rows=1 width=32) (actual time=0.005..0.006 rows=1 loops=1)
Index Cond: ((id = '0198f0a0-0000-7000-8000-0000000000c1'::uuid) AND (namespace_id = '0198f0a0-0000-7000-8000-000000000001'::uuid))
Filter: ((soft_deleted_at IS NULL) AND (container_remote_repository_id = '0198f0a0-0000-7000-8000-0000000000a1'::uuid))
Buffers: shared hit=3
Planning:
Buffers: shared hit=2
Planning Time: 0.335 ms
Execution Time: 0.251 msTimings: planning 0.335ms, execution 0.251ms, total 0.586ms.
ContainerRemoteTagStore.ListContainerRemoteTags.CursorSetDesc
Summary: The backward walk takes the mirrored bound as an Index Cond (name < 'explain-2500'), so paging backward from the middle of the dataset costs what paging forward does. No Sort, no filter discards, one partition of 64 per table. No anomalies.
Seed shape: namespaces=1, repositories=2, container_remote_repositories=2, blob_storage_blobs=3, blob_storage_attachments=3, container_remote_images=5003, container_remote_manifests=5502, container_remote_tags=9000
Rendered SQL:
SELECT container_remote_tags.namespace_id AS "container_remote_tags.namespace_id",
container_remote_tags.id AS "container_remote_tags.id",
container_remote_tags.container_remote_image_id AS "container_remote_tags.container_remote_image_id",
container_remote_tags.container_remote_manifest_id AS "container_remote_tags.container_remote_manifest_id",
container_remote_tags.upstream_checked_at AS "container_remote_tags.upstream_checked_at",
container_remote_tags.name AS "container_remote_tags.name",
container_remote_tags.upstream_etag AS "container_remote_tags.upstream_etag",
container_remote_manifests.digest AS "container_remote_manifests.digest"
FROM public.container_remote_tags
INNER JOIN public.container_remote_manifests ON ((((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))
INNER JOIN public.container_remote_images ON (((container_remote_images.id = container_remote_tags.container_remote_image_id) AND (container_remote_images.namespace_id = container_remote_tags.namespace_id)) AND (container_remote_images.soft_deleted_at IS NULL))
WHERE (((container_remote_tags.namespace_id = $1::uuid) AND (container_remote_tags.container_remote_image_id = $2::uuid)) AND (container_remote_images.container_remote_repository_id = $3::uuid)) AND (container_remote_tags.name < $4::text)
ORDER BY container_remote_tags.name DESC
LIMIT $5;Bound args: [0198f0a0-0000-7000-8000-000000000001, 0198f0a0-0000-7000-8000-0000000000c1, 0198f0a0-0000-7000-8000-0000000000a1, explain-2500, 101]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.86..24.88 rows=101 width=150) (actual time=0.040..0.201 rows=101 loops=1)
Buffers: shared hit=312
-> Nested Loop (cost=0.86..863.40 rows=3627 width=150) (actual time=0.040..0.196 rows=101 loops=1)
Buffers: shared hit=312
-> Nested Loop (cost=0.58..809.75 rows=3627 width=150) (actual time=0.032..0.170 rows=101 loops=1)
Buffers: shared hit=309
-> Index Scan Backward using container_remote_tags_p31_namespace_id_container_remote_ima_idx on container_remote_tags_p31 container_remote_tags (cost=0.29..463.48 rows=3642 width=117) (actual time=0.010..0.020 rows=101 loops=1)
Index Cond: ((namespace_id = '0198f0a0-0000-7000-8000-000000000001'::uuid) AND (container_remote_image_id = '0198f0a0-0000-7000-8000-0000000000c1'::uuid) AND (name < 'explain-2500'::text))
Buffers: shared hit=6
-> Memoize (cost=0.29..0.52 rows=1 width=81) (actual time=0.001..0.001 rows=1 loops=101)
Cache Key: container_remote_tags.container_remote_manifest_id
Cache Mode: logical
Hits: 0 Misses: 101 Evictions: 0 Overflows: 0 Memory Usage: 20kB
Buffers: shared hit=303
-> Index Scan using container_remote_manifests_p31_pkey on container_remote_manifests_p31 container_remote_manifests (cost=0.28..0.51 rows=1 width=81) (actual time=0.001..0.001 rows=1 loops=101)
Index Cond: ((id = container_remote_tags.container_remote_manifest_id) AND (namespace_id = '0198f0a0-0000-7000-8000-000000000001'::uuid))
Filter: ((soft_deleted_at IS NULL) AND (container_remote_image_id = '0198f0a0-0000-7000-8000-0000000000c1'::uuid))
Buffers: shared hit=303
-> Materialize (cost=0.28..8.31 rows=1 width=32) (actual time=0.000..0.000 rows=1 loops=101)
Buffers: shared hit=3
-> Index Scan using container_remote_images_p31_pkey on container_remote_images_p31 container_remote_images (cost=0.28..8.30 rows=1 width=32) (actual time=0.005..0.006 rows=1 loops=1)
Index Cond: ((id = '0198f0a0-0000-7000-8000-0000000000c1'::uuid) AND (namespace_id = '0198f0a0-0000-7000-8000-000000000001'::uuid))
Filter: ((soft_deleted_at IS NULL) AND (container_remote_repository_id = '0198f0a0-0000-7000-8000-0000000000a1'::uuid))
Buffers: shared hit=3
Planning:
Buffers: shared hit=2
Planning Time: 0.322 ms
Execution Time: 0.248 msTimings: planning 0.322ms, execution 0.248ms, total 0.570ms.
ContainerRemoteTagStore.FindContainerRemoteTagByName
Summary: The finder binds the unique index's full key (namespace_id, container_remote_image_id, name), so the row costs one index descent plus a primary-key lookup for the manifest and one for the image, 9 buffer hits in total. All three tables prune to one partition of 64. No anomalies.
Seed shape: namespaces=1, repositories=2, container_remote_repositories=2, blob_storage_blobs=3, blob_storage_attachments=3, container_remote_images=5003, container_remote_manifests=5502, container_remote_tags=9000
Rendered SQL:
SELECT container_remote_tags.namespace_id AS "container_remote_tags.namespace_id",
container_remote_tags.id AS "container_remote_tags.id",
container_remote_tags.container_remote_image_id AS "container_remote_tags.container_remote_image_id",
container_remote_tags.container_remote_manifest_id AS "container_remote_tags.container_remote_manifest_id",
container_remote_tags.upstream_checked_at AS "container_remote_tags.upstream_checked_at",
container_remote_tags.name AS "container_remote_tags.name",
container_remote_tags.upstream_etag AS "container_remote_tags.upstream_etag",
container_remote_manifests.digest AS "container_remote_manifests.digest"
FROM public.container_remote_tags
INNER JOIN public.container_remote_manifests ON ((((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))
INNER JOIN public.container_remote_images ON (((container_remote_images.id = container_remote_tags.container_remote_image_id) AND (container_remote_images.namespace_id = container_remote_tags.namespace_id)) AND (container_remote_images.soft_deleted_at IS NULL))
WHERE (((container_remote_tags.namespace_id = $1::uuid) AND (container_remote_tags.container_remote_image_id = $2::uuid)) AND (container_remote_images.container_remote_repository_id = $3::uuid)) AND (container_remote_tags.name = $4::text)
LIMIT $5;Bound args: [0198f0a0-0000-7000-8000-000000000001, 0198f0a0-0000-7000-8000-0000000000c1, 0198f0a0-0000-7000-8000-0000000000a1, explain-2500, 1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.85..24.94 rows=1 width=150) (actual time=0.028..0.028 rows=1 loops=1)
Buffers: shared hit=9
-> Nested Loop (cost=0.85..24.94 rows=1 width=150) (actual time=0.027..0.028 rows=1 loops=1)
Buffers: shared hit=9
-> Nested Loop (cost=0.57..16.62 rows=1 width=150) (actual time=0.016..0.016 rows=1 loops=1)
Buffers: shared hit=6
-> Index Scan using container_remote_tags_p31_namespace_id_container_remote_ima_idx on container_remote_tags_p31 container_remote_tags (cost=0.29..8.31 rows=1 width=117) (actual time=0.009..0.009 rows=1 loops=1)
Index Cond: ((namespace_id = '0198f0a0-0000-7000-8000-000000000001'::uuid) AND (container_remote_image_id = '0198f0a0-0000-7000-8000-0000000000c1'::uuid) AND (name = 'explain-2500'::text))
Buffers: shared hit=3
-> Index Scan using container_remote_manifests_p31_pkey on container_remote_manifests_p31 container_remote_manifests (cost=0.28..8.30 rows=1 width=81) (actual time=0.006..0.006 rows=1 loops=1)
Index Cond: ((id = container_remote_tags.container_remote_manifest_id) AND (namespace_id = '0198f0a0-0000-7000-8000-000000000001'::uuid))
Filter: ((soft_deleted_at IS NULL) AND (container_remote_image_id = '0198f0a0-0000-7000-8000-0000000000c1'::uuid))
Buffers: shared hit=3
-> Index Scan using container_remote_images_p31_pkey on container_remote_images_p31 container_remote_images (cost=0.28..8.30 rows=1 width=32) (actual time=0.011..0.011 rows=1 loops=1)
Index Cond: ((id = '0198f0a0-0000-7000-8000-0000000000c1'::uuid) AND (namespace_id = '0198f0a0-0000-7000-8000-000000000001'::uuid))
Filter: ((soft_deleted_at IS NULL) AND (container_remote_repository_id = '0198f0a0-0000-7000-8000-0000000000a1'::uuid))
Buffers: shared hit=3
Planning:
Buffers: shared hit=2
Planning Time: 0.266 ms
Execution Time: 0.062 msTimings: planning 0.266ms, execution 0.062ms, total 0.328ms.
Related to #314