chore(datastore): add container manifest reads (S17 Phase 3 Step 5)

Why

The management API's per-image manifests list has no datastore read behind it. The one existing manifest lister, ContainerManifestStore.ListReferrersPage, is the protocol referrers query: it hard-codes the opposite predicate (subject_digest IS NOT NULL) and orders by digest.

The list serves two views. The default one hides referrer rows so the redesigned image-detail page can keep signatures and attestations behind a toggle, and include_referrers=true returns the full inventory. Both keyset on (created_at, id) descending.

Datastore methods only. Step 10 wires the handler. This is step 5 of the Phase 3 plan, tracked in S17 Phase 3: format artifact reads (#312 - closed) • Hayley Swimelar.

Stacked on chore(datastore): add artifact read keyset inde... (!1125 - merged) • Hayley Swimelar • 19.3, and merges after it, because Step 5 needs the two container_manifests indexes that MR adds.

What is not obvious

The referrer exclusion is a SQL predicate, not a post-fetch filter. Filtering after the page is cut returns short pages and breaks the keyset boundary.

Each view repeats one index declaration verbatim, so the planner serves it from that index with no recheck. The two indexes share a key tuple, so their partition children collide and Postgres labels one idx1. The EXPLAIN test resolves child names through pg_inherits rather than spelling them out.

The projection is the whole row, annotations included, which the list resource does not serialize. The method doc carries the rationale.

Test plan

go test -tags=integration ./internal/datastore/

The keyset walk runs both views in both directions over a fixture with a six-row created_at tie. A page boundary lands inside the tie in three of the four walks (the default ascending walk keeps only three tie rows, inside one page). A bound on created_at alone skips the rest of the tie, and a non-strict bound repeats it.

Two mutations confirm the suite discriminates: dropping the referrer predicate fails four tests including the EXPLAIN one, and dropping the id tiebreaker fails the walk.

Database Review Evidence

Queries

Note

Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral PostgreSQL 17 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. Expand each row's details for the seed shape, rendered SQL, bound args, and raw plan. TestListContainerManifests_ViewsRideTheirOwnIndex asserts the index choice, the absence of a Sort node and filter removals, and partition pruning in CI, so a query-shape drift fails the pipeline rather than waiting on a hand-run EXPLAIN.

Method (datastore.ListContainerManifests.*) Plan node Index Rows (plan / actual) Cost Time Buffers (hit / read) Partitions
DefaultDescFirstPage Limit container_manifests_p28_namespace_id_container_image_id_cre_idx 2500 / 101 22.65 0.033ms 11 / 0 1
DefaultDescDeepPage Limit container_manifests_p28_namespace_id_container_image_id_cre_idx 1607 / 101 63.15 0.057ms 161 / 0 1
DefaultAscFirstPage Limit container_manifests_p28_namespace_id_container_image_id_cre_idx 2500 / 101 66.44 0.072ms 280 / 0 1
DefaultAscDeepPage Limit container_manifests_p28_namespace_id_container_image_id_cre_idx 893 / 101 147.48 0.098ms 387 / 0 1
ReferrersDescFirstPage Limit container_manifests_p28_namespace_id_container_image_id_cr_idx1 5000 / 101 74.06 0.098ms 507 / 0 1
ReferrersDescDeepPage Limit container_manifests_p28_namespace_id_container_image_id_cr_idx1 3213 / 101 111.32 0.115ms 622 / 0 1
ReferrersAscFirstPage Limit container_manifests_p28_namespace_id_container_image_id_cr_idx1 5000 / 101 95.77 0.123ms 703 / 0 1
ReferrersAscDeepPage Limit container_manifests_p28_namespace_id_container_image_id_cr_idx1 1786 / 101 179.50 0.260ms 769 / 0 1

Query notes:

  • The LIMIT is caller-fed ($5/$3), bounded by provenance: the management handlers clamp limit to 100 (parseLimitParam) and Step 10 wires that clamp to this read. The store additionally bounds its allocation hint to the same 100.
  • The row-value cursor bound's scan-node estimates run a consistent ~1.3x over on ROW() < and ~0.7x under on ROW() > against the seeded cardinality. Both directions stay well inside the 10x anomaly bar, and the Limit node is exact everywhere, so the skew has no plan-choice effect at this shape.
datastore.ListContainerManifests.DefaultDescFirstPage (default view, descending, first page)

Summary: Plan matches the intent: backward scan of the partial-index child (..._cre_idx), pruned to one of 64 partitions, no Sort and no filter recheck. The scan-node estimate is exact for the predicate (2500 planned, the seeded default-view cardinality) and the Limit node is exact (101 / 101). No anomalies.

Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=2, blob_storage_blobs=1, blob_storage_attachments=1, container_manifests=7000 (5000 under the target image, 2000 under a sibling, all in one namespace partition, every second row of each image a referrer, created_at spread one minute apart)

Rendered SQL:

SELECT container_manifests.id AS "container_manifests.id",
container_manifests.namespace_id AS "container_manifests.namespace_id",
container_manifests.container_image_id AS "container_manifests.container_image_id",
container_manifests.blob_storage_attachment_id AS "container_manifests.blob_storage_attachment_id",
container_manifests.size AS "container_manifests.size",
container_manifests.created_at AS "container_manifests.created_at",
container_manifests.last_downloaded_at AS "container_manifests.last_downloaded_at",
container_manifests.media_type AS "container_manifests.media_type",
container_manifests.artifact_type AS "container_manifests.artifact_type",
container_manifests.annotations AS "container_manifests.annotations",
container_manifests.digest AS "container_manifests.digest",
container_manifests.blob_sha256 AS "container_manifests.blob_sha256",
container_manifests.subject_digest AS "container_manifests.subject_digest",
container_manifests.architecture AS "container_manifests.architecture",
container_manifests.os AS "container_manifests.os",
container_manifests.os_variant AS "container_manifests.os_variant"
FROM public.container_manifests
WHERE ((container_manifests.namespace_id = $1::uuid) AND (container_manifests.container_image_id = $2::uuid)) AND (container_manifests.subject_digest IS NULL)
ORDER BY container_manifests.created_at DESC, container_manifests.id DESC
LIMIT $3;

Bound args: [11111111-1111-1111-1111-111111111111 22222222-2222-2222-2222-222222222222 101]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Limit  (cost=0.28..22.65 rows=101 width=382) (actual time=0.011..0.033 rows=101 loops=1)
   Buffers: shared hit=11
   ->  Index Scan Backward using container_manifests_p28_namespace_id_container_image_id_cre_idx on container_manifests_p28 container_manifests  (cost=0.28..554.09 rows=2500 width=382) (actual time=0.011..0.028 rows=101 loops=1)
         Index Cond: ((namespace_id = '11111111-1111-1111-1111-111111111111'::uuid) AND (container_image_id = '22222222-2222-2222-2222-222222222222'::uuid))
         Buffers: shared hit=11
 Planning:
   Buffers: shared hit=493
 Planning Time: 1.535 ms
 Execution Time: 0.054 ms
(9 rows)

Timings: planning 1.535ms, execution 0.054ms, total 1.589ms.

datastore.ListContainerManifests.DefaultDescDeepPage (default view, descending, cursor page)

Summary: Backward scan of the partial-index child with the row-value bound folded into the Index Cond, one partition, no Sort. The scan-node estimate for the ROW() < bound runs 1.29x over the true 1250 remaining rows, harmless under the LIMIT. No anomalies.

Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=2, blob_storage_blobs=1, blob_storage_attachments=1, container_manifests=7000 (5000 under the target image, 2000 under a sibling, all in one namespace partition, every second row of each image a referrer, created_at spread one minute apart)

Rendered SQL:

SELECT container_manifests.id AS "container_manifests.id",
container_manifests.namespace_id AS "container_manifests.namespace_id",
container_manifests.container_image_id AS "container_manifests.container_image_id",
container_manifests.blob_storage_attachment_id AS "container_manifests.blob_storage_attachment_id",
container_manifests.size AS "container_manifests.size",
container_manifests.created_at AS "container_manifests.created_at",
container_manifests.last_downloaded_at AS "container_manifests.last_downloaded_at",
container_manifests.media_type AS "container_manifests.media_type",
container_manifests.artifact_type AS "container_manifests.artifact_type",
container_manifests.annotations AS "container_manifests.annotations",
container_manifests.digest AS "container_manifests.digest",
container_manifests.blob_sha256 AS "container_manifests.blob_sha256",
container_manifests.subject_digest AS "container_manifests.subject_digest",
container_manifests.architecture AS "container_manifests.architecture",
container_manifests.os AS "container_manifests.os",
container_manifests.os_variant AS "container_manifests.os_variant"
FROM public.container_manifests
WHERE (((container_manifests.namespace_id = $1::uuid) AND (container_manifests.container_image_id = $2::uuid)) AND (container_manifests.subject_digest IS NULL)) AND ((container_manifests.created_at, container_manifests.id) < ($3::timestamp with time zone, $4::uuid))
ORDER BY container_manifests.created_at DESC, container_manifests.id DESC
LIMIT $5;

Bound args: [11111111-1111-1111-1111-111111111111 22222222-2222-2222-2222-222222222222 2026-01-02 17:40:00 +0000 UTC 33333333-3333-3333-3333-333333333333 101]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Limit  (cost=0.28..63.15 rows=101 width=382) (actual time=0.014..0.057 rows=101 loops=1)
   Buffers: shared hit=161
   ->  Index Scan Backward using container_manifests_p28_namespace_id_container_image_id_cre_idx on container_manifests_p28 container_manifests  (cost=0.28..1000.56 rows=1607 width=382) (actual time=0.013..0.052 rows=101 loops=1)
         Index Cond: ((namespace_id = '11111111-1111-1111-1111-111111111111'::uuid) AND (container_image_id = '22222222-2222-2222-2222-222222222222'::uuid) AND (ROW(created_at, id) < ROW('2026-01-02 17:40:00+00'::timestamp with time zone, '33333333-3333-3333-3333-333333333333'::uuid)))
         Buffers: shared hit=161
 Planning:
   Buffers: shared hit=284
 Planning Time: 1.078 ms
 Execution Time: 0.093 ms
(9 rows)

Timings: planning 1.078ms, execution 0.093ms, total 1.171ms.

datastore.ListContainerManifests.DefaultAscFirstPage (default view, ascending, first page)

Summary: Forward scan of the same partial-index child (one index serves both directions), one partition, no Sort. Scan-node estimate exact (2500), Limit node exact. No anomalies.

Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=2, blob_storage_blobs=1, blob_storage_attachments=1, container_manifests=7000 (5000 under the target image, 2000 under a sibling, all in one namespace partition, every second row of each image a referrer, created_at spread one minute apart)

Rendered SQL:

SELECT container_manifests.id AS "container_manifests.id",
container_manifests.namespace_id AS "container_manifests.namespace_id",
container_manifests.container_image_id AS "container_manifests.container_image_id",
container_manifests.blob_storage_attachment_id AS "container_manifests.blob_storage_attachment_id",
container_manifests.size AS "container_manifests.size",
container_manifests.created_at AS "container_manifests.created_at",
container_manifests.last_downloaded_at AS "container_manifests.last_downloaded_at",
container_manifests.media_type AS "container_manifests.media_type",
container_manifests.artifact_type AS "container_manifests.artifact_type",
container_manifests.annotations AS "container_manifests.annotations",
container_manifests.digest AS "container_manifests.digest",
container_manifests.blob_sha256 AS "container_manifests.blob_sha256",
container_manifests.subject_digest AS "container_manifests.subject_digest",
container_manifests.architecture AS "container_manifests.architecture",
container_manifests.os AS "container_manifests.os",
container_manifests.os_variant AS "container_manifests.os_variant"
FROM public.container_manifests
WHERE ((container_manifests.namespace_id = $1::uuid) AND (container_manifests.container_image_id = $2::uuid)) AND (container_manifests.subject_digest IS NULL)
ORDER BY container_manifests.created_at ASC, container_manifests.id ASC
LIMIT $3;

Bound args: [11111111-1111-1111-1111-111111111111 22222222-2222-2222-2222-222222222222 101]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Limit  (cost=0.28..66.44 rows=101 width=382) (actual time=0.011..0.072 rows=101 loops=1)
   Buffers: shared hit=280
   ->  Index Scan using container_manifests_p28_namespace_id_container_image_id_cre_idx on container_manifests_p28 container_manifests  (cost=0.28..1637.78 rows=2500 width=382) (actual time=0.010..0.067 rows=101 loops=1)
         Index Cond: ((namespace_id = '11111111-1111-1111-1111-111111111111'::uuid) AND (container_image_id = '22222222-2222-2222-2222-222222222222'::uuid))
         Buffers: shared hit=280
 Planning:
   Buffers: shared hit=274
 Planning Time: 0.991 ms
 Execution Time: 0.091 ms
(9 rows)

Timings: planning 0.991ms, execution 0.091ms, total 1.082ms.

datastore.ListContainerManifests.DefaultAscDeepPage (default view, ascending, cursor page)

Summary: Forward scan with the ROW() > bound in the Index Cond, one partition, no Sort. The bound's estimate runs 0.71x under the true 1250 remaining rows, the mirror image of the descending skew, harmless under the LIMIT. No anomalies.

Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=2, blob_storage_blobs=1, blob_storage_attachments=1, container_manifests=7000 (5000 under the target image, 2000 under a sibling, all in one namespace partition, every second row of each image a referrer, created_at spread one minute apart)

Rendered SQL:

SELECT container_manifests.id AS "container_manifests.id",
container_manifests.namespace_id AS "container_manifests.namespace_id",
container_manifests.container_image_id AS "container_manifests.container_image_id",
container_manifests.blob_storage_attachment_id AS "container_manifests.blob_storage_attachment_id",
container_manifests.size AS "container_manifests.size",
container_manifests.created_at AS "container_manifests.created_at",
container_manifests.last_downloaded_at AS "container_manifests.last_downloaded_at",
container_manifests.media_type AS "container_manifests.media_type",
container_manifests.artifact_type AS "container_manifests.artifact_type",
container_manifests.annotations AS "container_manifests.annotations",
container_manifests.digest AS "container_manifests.digest",
container_manifests.blob_sha256 AS "container_manifests.blob_sha256",
container_manifests.subject_digest AS "container_manifests.subject_digest",
container_manifests.architecture AS "container_manifests.architecture",
container_manifests.os AS "container_manifests.os",
container_manifests.os_variant AS "container_manifests.os_variant"
FROM public.container_manifests
WHERE (((container_manifests.namespace_id = $1::uuid) AND (container_manifests.container_image_id = $2::uuid)) AND (container_manifests.subject_digest IS NULL)) AND ((container_manifests.created_at, container_manifests.id) > ($3::timestamp with time zone, $4::uuid))
ORDER BY container_manifests.created_at ASC, container_manifests.id ASC
LIMIT $5;

Bound args: [11111111-1111-1111-1111-111111111111 22222222-2222-2222-2222-222222222222 2026-01-02 17:40:00 +0000 UTC 33333333-3333-3333-3333-333333333333 101]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Limit  (cost=0.41..147.48 rows=101 width=382) (actual time=0.013..0.098 rows=101 loops=1)
   Buffers: shared hit=387
   ->  Index Scan using container_manifests_p28_namespace_id_container_image_id_cre_idx on container_manifests_p28 container_manifests  (cost=0.41..1300.81 rows=893 width=382) (actual time=0.013..0.093 rows=101 loops=1)
         Index Cond: ((namespace_id = '11111111-1111-1111-1111-111111111111'::uuid) AND (container_image_id = '22222222-2222-2222-2222-222222222222'::uuid) AND (ROW(created_at, id) > ROW('2026-01-02 17:40:00+00'::timestamp with time zone, '33333333-3333-3333-3333-333333333333'::uuid)))
         Buffers: shared hit=387
 Planning:
   Buffers: shared hit=267
 Planning Time: 1.162 ms
 Execution Time: 0.135 ms
(9 rows)

Timings: planning 1.162ms, execution 0.135ms, total 1.297ms.

datastore.ListContainerManifests.ReferrersDescFirstPage (include_referrers, descending, first page)

Summary: Backward scan of the total-index child (..._cr_idx1), the view with no predicate, one partition, no Sort. Scan-node estimate exact (5000), Limit node exact. No anomalies.

Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=2, blob_storage_blobs=1, blob_storage_attachments=1, container_manifests=7000 (5000 under the target image, 2000 under a sibling, all in one namespace partition, every second row of each image a referrer, created_at spread one minute apart)

Rendered SQL:

SELECT container_manifests.id AS "container_manifests.id",
container_manifests.namespace_id AS "container_manifests.namespace_id",
container_manifests.container_image_id AS "container_manifests.container_image_id",
container_manifests.blob_storage_attachment_id AS "container_manifests.blob_storage_attachment_id",
container_manifests.size AS "container_manifests.size",
container_manifests.created_at AS "container_manifests.created_at",
container_manifests.last_downloaded_at AS "container_manifests.last_downloaded_at",
container_manifests.media_type AS "container_manifests.media_type",
container_manifests.artifact_type AS "container_manifests.artifact_type",
container_manifests.annotations AS "container_manifests.annotations",
container_manifests.digest AS "container_manifests.digest",
container_manifests.blob_sha256 AS "container_manifests.blob_sha256",
container_manifests.subject_digest AS "container_manifests.subject_digest",
container_manifests.architecture AS "container_manifests.architecture",
container_manifests.os AS "container_manifests.os",
container_manifests.os_variant AS "container_manifests.os_variant"
FROM public.container_manifests
WHERE (container_manifests.namespace_id = $1::uuid) AND (container_manifests.container_image_id = $2::uuid)
ORDER BY container_manifests.created_at DESC, container_manifests.id DESC
LIMIT $3;

Bound args: [11111111-1111-1111-1111-111111111111 22222222-2222-2222-2222-222222222222 101]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Limit  (cost=0.41..74.06 rows=101 width=382) (actual time=0.015..0.098 rows=101 loops=1)
   Buffers: shared hit=507
   ->  Index Scan Backward using container_manifests_p28_namespace_id_container_image_id_cr_idx1 on container_manifests_p28 container_manifests  (cost=0.41..3646.59 rows=5000 width=382) (actual time=0.014..0.093 rows=101 loops=1)
         Index Cond: ((namespace_id = '11111111-1111-1111-1111-111111111111'::uuid) AND (container_image_id = '22222222-2222-2222-2222-222222222222'::uuid))
         Buffers: shared hit=507
 Planning:
   Buffers: shared hit=284
 Planning Time: 1.043 ms
 Execution Time: 0.118 ms
(9 rows)

Timings: planning 1.043ms, execution 0.118ms, total 1.161ms.

datastore.ListContainerManifests.ReferrersDescDeepPage (include_referrers, descending, cursor page)

Summary: Backward scan of the total-index child with the row-value bound in the Index Cond, one partition, no Sort. The ROW() < estimate runs 1.29x over the true 2500, the same skew as the default view. No anomalies.

Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=2, blob_storage_blobs=1, blob_storage_attachments=1, container_manifests=7000 (5000 under the target image, 2000 under a sibling, all in one namespace partition, every second row of each image a referrer, created_at spread one minute apart)

Rendered SQL:

SELECT container_manifests.id AS "container_manifests.id",
container_manifests.namespace_id AS "container_manifests.namespace_id",
container_manifests.container_image_id AS "container_manifests.container_image_id",
container_manifests.blob_storage_attachment_id AS "container_manifests.blob_storage_attachment_id",
container_manifests.size AS "container_manifests.size",
container_manifests.created_at AS "container_manifests.created_at",
container_manifests.last_downloaded_at AS "container_manifests.last_downloaded_at",
container_manifests.media_type AS "container_manifests.media_type",
container_manifests.artifact_type AS "container_manifests.artifact_type",
container_manifests.annotations AS "container_manifests.annotations",
container_manifests.digest AS "container_manifests.digest",
container_manifests.blob_sha256 AS "container_manifests.blob_sha256",
container_manifests.subject_digest AS "container_manifests.subject_digest",
container_manifests.architecture AS "container_manifests.architecture",
container_manifests.os AS "container_manifests.os",
container_manifests.os_variant AS "container_manifests.os_variant"
FROM public.container_manifests
WHERE ((container_manifests.namespace_id = $1::uuid) AND (container_manifests.container_image_id = $2::uuid)) AND ((container_manifests.created_at, container_manifests.id) < ($3::timestamp with time zone, $4::uuid))
ORDER BY container_manifests.created_at DESC, container_manifests.id DESC
LIMIT $5;

Bound args: [11111111-1111-1111-1111-111111111111 22222222-2222-2222-2222-222222222222 2026-01-02 17:40:00 +0000 UTC 33333333-3333-3333-3333-333333333333 101]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Limit  (cost=0.41..111.32 rows=101 width=382) (actual time=0.017..0.115 rows=101 loops=1)
   Buffers: shared hit=622
   ->  Index Scan Backward using container_manifests_p28_namespace_id_container_image_id_cr_idx1 on container_manifests_p28 container_manifests  (cost=0.41..3528.89 rows=3213 width=382) (actual time=0.016..0.110 rows=101 loops=1)
         Index Cond: ((namespace_id = '11111111-1111-1111-1111-111111111111'::uuid) AND (container_image_id = '22222222-2222-2222-2222-222222222222'::uuid) AND (ROW(created_at, id) < ROW('2026-01-02 17:40:00+00'::timestamp with time zone, '33333333-3333-3333-3333-333333333333'::uuid)))
         Buffers: shared hit=622
 Planning:
   Buffers: shared hit=284
 Planning Time: 0.997 ms
 Execution Time: 0.151 ms
(9 rows)

Timings: planning 0.997ms, execution 0.151ms, total 1.148ms.

datastore.ListContainerManifests.ReferrersAscFirstPage (include_referrers, ascending, first page)

Summary: Forward scan of the total-index child, one partition, no Sort. Scan-node estimate exact (5000), Limit node exact. No anomalies.

Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=2, blob_storage_blobs=1, blob_storage_attachments=1, container_manifests=7000 (5000 under the target image, 2000 under a sibling, all in one namespace partition, every second row of each image a referrer, created_at spread one minute apart)

Rendered SQL:

SELECT container_manifests.id AS "container_manifests.id",
container_manifests.namespace_id AS "container_manifests.namespace_id",
container_manifests.container_image_id AS "container_manifests.container_image_id",
container_manifests.blob_storage_attachment_id AS "container_manifests.blob_storage_attachment_id",
container_manifests.size AS "container_manifests.size",
container_manifests.created_at AS "container_manifests.created_at",
container_manifests.last_downloaded_at AS "container_manifests.last_downloaded_at",
container_manifests.media_type AS "container_manifests.media_type",
container_manifests.artifact_type AS "container_manifests.artifact_type",
container_manifests.annotations AS "container_manifests.annotations",
container_manifests.digest AS "container_manifests.digest",
container_manifests.blob_sha256 AS "container_manifests.blob_sha256",
container_manifests.subject_digest AS "container_manifests.subject_digest",
container_manifests.architecture AS "container_manifests.architecture",
container_manifests.os AS "container_manifests.os",
container_manifests.os_variant AS "container_manifests.os_variant"
FROM public.container_manifests
WHERE (container_manifests.namespace_id = $1::uuid) AND (container_manifests.container_image_id = $2::uuid)
ORDER BY container_manifests.created_at ASC, container_manifests.id ASC
LIMIT $3;

Bound args: [11111111-1111-1111-1111-111111111111 22222222-2222-2222-2222-222222222222 101]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Limit  (cost=0.41..95.77 rows=101 width=382) (actual time=0.014..0.123 rows=101 loops=1)
   Buffers: shared hit=703
   ->  Index Scan using container_manifests_p28_namespace_id_container_image_id_cr_idx1 on container_manifests_p28 container_manifests  (cost=0.41..4721.53 rows=5000 width=382) (actual time=0.013..0.117 rows=101 loops=1)
         Index Cond: ((namespace_id = '11111111-1111-1111-1111-111111111111'::uuid) AND (container_image_id = '22222222-2222-2222-2222-222222222222'::uuid))
         Buffers: shared hit=703
 Planning:
   Buffers: shared hit=274
 Planning Time: 0.988 ms
 Execution Time: 0.141 ms
(9 rows)

Timings: planning 0.988ms, execution 0.141ms, total 1.129ms.

datastore.ListContainerManifests.ReferrersAscDeepPage (include_referrers, ascending, cursor page)

Summary: Forward scan with the ROW() > bound, one partition, no Sort. The estimate runs 0.71x under the true 2500, consistent with the ascending skew elsewhere. No anomalies.

Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=2, blob_storage_blobs=1, blob_storage_attachments=1, container_manifests=7000 (5000 under the target image, 2000 under a sibling, all in one namespace partition, every second row of each image a referrer, created_at spread one minute apart)

Rendered SQL:

SELECT container_manifests.id AS "container_manifests.id",
container_manifests.namespace_id AS "container_manifests.namespace_id",
container_manifests.container_image_id AS "container_manifests.container_image_id",
container_manifests.blob_storage_attachment_id AS "container_manifests.blob_storage_attachment_id",
container_manifests.size AS "container_manifests.size",
container_manifests.created_at AS "container_manifests.created_at",
container_manifests.last_downloaded_at AS "container_manifests.last_downloaded_at",
container_manifests.media_type AS "container_manifests.media_type",
container_manifests.artifact_type AS "container_manifests.artifact_type",
container_manifests.annotations AS "container_manifests.annotations",
container_manifests.digest AS "container_manifests.digest",
container_manifests.blob_sha256 AS "container_manifests.blob_sha256",
container_manifests.subject_digest AS "container_manifests.subject_digest",
container_manifests.architecture AS "container_manifests.architecture",
container_manifests.os AS "container_manifests.os",
container_manifests.os_variant AS "container_manifests.os_variant"
FROM public.container_manifests
WHERE ((container_manifests.namespace_id = $1::uuid) AND (container_manifests.container_image_id = $2::uuid)) AND ((container_manifests.created_at, container_manifests.id) > ($3::timestamp with time zone, $4::uuid))
ORDER BY container_manifests.created_at ASC, container_manifests.id ASC
LIMIT $5;

Bound args: [11111111-1111-1111-1111-111111111111 22222222-2222-2222-2222-222222222222 2026-01-02 17:40:00 +0000 UTC 33333333-3333-3333-3333-333333333333 101]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Limit  (cost=0.41..179.50 rows=101 width=382) (actual time=0.021..0.260 rows=101 loops=1)
   Buffers: shared hit=769
   ->  Index Scan using container_manifests_p28_namespace_id_container_image_id_cr_idx1 on container_manifests_p28 container_manifests  (cost=0.41..3167.34 rows=1786 width=382) (actual time=0.020..0.253 rows=101 loops=1)
         Index Cond: ((namespace_id = '11111111-1111-1111-1111-111111111111'::uuid) AND (container_image_id = '22222222-2222-2222-2222-222222222222'::uuid) AND (ROW(created_at, id) > ROW('2026-01-02 17:40:00+00'::timestamp with time zone, '33333333-3333-3333-3333-333333333333'::uuid)))
         Buffers: shared hit=769
 Planning:
   Buffers: shared hit=267
 Planning Time: 2.007 ms
 Execution Time: 0.336 ms
(9 rows)

Timings: planning 2.007ms, execution 0.336ms, total 2.343ms.

Spec coverage

S17 Covered by
AC #17 (closed), manifests newest-first carrying the listed fields _RowFidelity, _KeysetWalkBothDirections
AC #18 (closed), default view excludes referrers and include_referrers returns them _DefaultViewExcludesReferrers
Artifact lists, keyset pagination with a next-page signal _KeysetWalkBothDirections, _HasMoreSignal
Phase 3 artifact tables, no deletion filter and no blob join absence of both in the query
Phase 3 artifact tables, a keyset index per view _ViewsRideTheirOwnIndex
Security Considerations, tenant isolation _ChainScoping

Handler concerns land in Step 10: the 400 on a non-boolean include_referrers, the limit clamp, and the cursor decode.

Two guardrail deviations

Reviewable LOC is 1546, over the 500 ceiling in docs/dev/development-model.md. The tests are 1203 of it (963 integration, 240 unit) and the production delta is 343, inside the 200-400 target. Splitting would part the tests from the code they constrain.

That same doc says no stacked MR chains. This one stacks because the EXPLAIN assertions need Step 2's indexes, an edge the plan's dependency graph records.

Context for LLM agents

Design decisions and rejected alternatives

Jet builds the statement, the file's shared scanner reads the rows. The pattern this MR reproduces from RepositoryStore.List is structural: per-store sort enums with valid(), a cursor carrying every sort key, a Next<X>Cursor helper, limit+1 to derive hasMore, and a standalone statement builder the EXPLAIN test runs. listContainerManifestsStmt builds with the jet builder and returns (string, []any) via .Sql(), the container_blob.go hybrid, so the test still EXPLAINs the exact query the store issues and scanContainerManifest (the documented NULL-annotations mapping) stays the one row shape for this table.

Rejected: scanning through jet's qrm into model.ContainerManifests. Its Annotations *string and SubjectDigest *[]byte are a second row shape for a table whose scanner documents the NULL mappings, and converting the file's remaining raw statements in this MR would swamp the read-path diff. That conversion is #430 (closed).

The projection keeps annotations. At the S12 cap (128 keys, 512-char keys, 4096-char values) a row carries roughly 520 KB the manifests-list resource discards.

Rejected: narrowing the projection. A partially populated ContainerManifest erases the difference its own doc guarantees between absent annotations and an empty object. The Step 10 handler owns the user-facing clamp of limit to 100 (parseLimitParam); at that clamp the worst page reads a tenth of what ListReferrersPage already reads at its 1000-row cap. The store bounds its allocation hint to the same 100, so the pre-allocation tracks real page sizes rather than an oversized Limit. Narrowing needs a row type of its own, which the plan did not ask for.

Order's zero value is ascending, not the API default. ContainerManifestSortAscending = iota matches RepositorySortOrder in the same package. The API default is created_at descending and the handler applies it. Inverting the enum to make the zero value the API default would leave two order enums with opposite numbering in one package.

The store derefs s.client.DB() rather than taking a qrm.DB. Every other method on this store takes a caller handle so the push path composes reads and writes in one transaction. A management list has nothing to compose, and a caller-supplied handle invites a handler to hold a transaction open across an HTTP response. The constructor's nil-client panic now guards a real deref.

Non-goals

  • No handler, no route, no wiring. handler.go and wire_management.go belong to Step 10. Nothing calls ListContainerManifests yet, which is why the MR is behavior-preserving in production.
  • No manifest detail read. The spec defers a digest-addressed detail route to the container-redesign fast-follow.
  • No size sort. It needs its own index, which the spec records as a follow-up. The sort enum has one member for that reason, and the statement builder fixes the column with a comment naming what a second member has to change.
  • No soft-delete filter. container_manifests has no soft_deleted_at. If S20 adds container soft-delete, this read gains the exclusion then.
  • Unit tests cover only the pure surface. The argument-guard table, the statement-shape table, and the enum-validity and cursor-derivation trio run untagged in container_manifest_test.go (guards fire before the client deref, and the rest is pure). Every test that reads rows stays in the integration file, where the plan scoped Step 5's coverage.

Verification already done

Local gates: go build ./..., gofmt, go vet -tags=integration, golangci-lint 2.12 both plain and with --build-tags=integration (clean in the added block, and the pre-existing findings elsewhere in the package are untouched since CI does not lint integration files), go test ./..., and go test -tags=integration ./internal/datastore/....

TestWireStorage_CloudCDNPresent fails locally for want of Google application default credentials, unrelated to this diff. The first internal/datastore/migrations run died on a Postgres container reset under concurrent load and passed on a clean re-run.

Red-before-green was verified empirically: the test commit landed against a panic skeleton.

Related to #312 (closed)

Edited by Hayley Swimelar

Merge request reports

Loading
Loading