refactor(datastore): jet-convert container_manifest, narrow list row
Why
internal/datastore/container_manifest.go was the last datastore file built entirely on raw SQL strings, and docs/dev/database-query-patterns.md mandates the jet builder for datastore queries.
The manifests-list projection also carried annotations, a column the S17 list resource never serializes. At the S12 cap on real PG 17, that column is 590,848 B per row: a max page of 100 reads 59.1 MB to render 33.2 KB of JSON (1,780:1), and the full-row scan costs ~145-200 ms and ~126-230 MB of allocations per page. This MR converts the whole file to jet and narrows the list projection to the 8 columns the resource returns.
Targets main directly now that its base merged, chore(datastore): add container manifest reads ... (!1131 - merged) • Hayley Swimelar • 19.3. Supersedes refactor(datastore): convert container_manifest... (!1211 - merged) • Hayley Swimelar • 19.3, which delivered the conversion without the narrowing.
What
- Rows scan into the generated
model.ContainerManifestsand convert throughcontainerManifestFromModelandcontainerManifestListRowFromModel, the idiom the rest of the package uses. We rejected alias tags on the model fields and a wider domain-type surface (29 use sites). The tag route carries the silent zero-scan tracked in Document go-jet's silent zero-scan in the query... (#410 - closed) • Hayley Swimelar. - The new flat public
ContainerManifestListRowcarries exactly the 8 S17 resource fields. Annotations is structurally absent, so a partially populatedContainerManifestcan never erase the absent-vs-empty annotations contract. NextContainerManifestCursorand theListContainerManifestsreturn retype to the list row, and the retype reaches four files outsideinternal/datastore:ContainerManifestReaderandencodeContainerManifestBoundaryininternal/managementapi/container_manifests.go,containerManifestFromRowininternal/managementapi/container_resources.go, and the two fake readers underinternal/managementapiandcmd/artifact-registry. Those call sites landed with the handler MR, feat(managementapi): serve the container artifa... (!1133 - merged) • Hayley Swimelar • 19.3, so this MR carries their edits instead of deferring them. All four are signature-only.ListReferrersPagekeeps the full row because OCI descriptors carry annotations.- The empty page still returns nil and cursor bounding still holds, ported through the conversion from the base branch's review fixes.
- jet's qrm zeroes SQL NULL into
json.RawMessagenatively, so the hand scanner's NULL plumbing retires with the scanner.qrm.ErrNoRowsis now the single not-found sentinel. ChildManifestsExistbinds a single= ANY($n::bytea[])array parameter, independent of digest count, and the referrers builder drops its unreachable no-LIMIT branch.
Size
The diff is 2,229 changed lines (1,378 insertions, 851 deletions) across 14 files, against the 500-line guardrail. Seven are internal/datastore and its query-patterns guide, four are the retype's call sites, and three are comment-only churn in internal/format/oci retiring the hand scanner's vocabulary. The scanner retirement is atomic: converting statements one MR at a time would leave two scanning mechanisms in the file. Most of the delta is test churn, first from the conversion rewriting every scan assertion, then from the narrowing's projection, converter, and row-fidelity pins.
Benchmarks
Single-row scan by annotations size: hand scanner vs jet
BenchmarkManifestScanByAnnotationSize, a by-digest read of one manifest, hand scanner vs jet, swept over three annotations payloads (benchstat, n=10). The sweep answers what a single arm at the cap cannot: whether jet's extra allocations scale with the payload or are a fixed per-call cost.
| Arm | Metric | Hand scanner | jet | Delta |
|---|---|---|---|---|
null_annotations |
sec/op | 373.2µ ± 21% | 363.4µ ± 6% | ~ (p=0.481) |
| B/op | 6.780Ki ± 0% | 36.922Ki ± 0% | +444.55% | |
| allocs/op | 130.0 ± 0% | 522.0 ± 0% | +301.54% | |
typical_annotations |
sec/op | 342.8µ ± 18% | 386.1µ ± 13% | +12.63% |
| B/op | 7.569Ki ± 0% | 38.535Ki ± 0% | +409.13% | |
| allocs/op | 134.0 ± 0% | 531.0 ± 0% | +296.27% | |
cap_annotations |
sec/op | 3.022m ± 8% | 3.573m ± 7% | +18.22% |
| B/op | 1.464Mi ± 2% | 2.610Mi ± 1% | +78.24% | |
| allocs/op | 140.0 ± 1% | 541.0 ± 0% | +286.43% |
Every delta is p=0.000 (n=10) except the null_annotations sec/op row, which is not significant. The baseline is 9b630ad6, this branch's last commit before the conversion, with the swept benchmark grafted in so both sides run byte-identical benchmark text. The jet statement also adds LIMIT 1, which the raw statement lacked.
The allocation delta is fixed, not payload-scaled: +392, +397, and +401 across three orders of magnitude of annotations. Two costs make it up, and only one is the scan. Building and serializing the statement accounts for 181 allocs and 13.2 KiB, measured alone (getContainerManifestByDigestStmt(...).Sql(), no database, n=5), because GetContainerManifestByDigest rebuilds the statement inside the call instead of hoisting it. The remaining ~210 allocations are qrm's reflection-based scan. Every single-row manifest read pays both, the annotation-free reads that dominate production included.
Bytes carry a fixed ~30 KiB plus a payload-scaled +1.15 MiB at the cap, and the scaled part is not the jsonb decode. The generated model types annotations as *string, so the payload lands in a string and containerManifestFromModel converts it again into json.RawMessage. That is two payload-sized allocations where the hand scanner's []byte scan target made one.
Latency carries a fixed component too, roughly the 15 µs the statement building costs. The null_annotations arm cannot resolve it: the hand scanner's variance there is ±21%, wider than the effect, which is why that row reads as not significant rather than as zero.
The plan gates the conversion on 30% sec/op. The worst arm is +18.22%, under the gate, so the conversion ships with these deltas recorded. What a single cap-sized arm hid is the fixed cost, which reads as GC pressure on every single-row read rather than latency on the large ones. The list path avoids the scaled component entirely, because it no longer projects annotations, and it amortizes the per-call build across the page. Hoisting the statement out of the call is tracked separately, Rebuilding jet statements per call costs ~180 a... (#523) • Unassigned: it would recover the 181 build allocations on every jet read in the package, not only this one.
List page: narrow row vs full row
BenchmarkListManifestsAtCapAnnotations, a 100-row page at the S12 annotations cap on real PG 17 via testcontainers (benchstat, n=6):
| Metric | list_row_projection | full_row_projection | full ÷ list |
|---|---|---|---|
| sec/op | 897.6µ ± 40% | 199.9m ± 10% | ~223× |
| B/op | 342.9Ki ± 0% | 230.5Mi ± 0% | ~688× |
| allocs/op | 3.667k | 5.934k | 1.6× |
Spec coverage
List-row narrowing
| # | Criterion | Spec | Tests / gates |
|---|---|---|---|
| AC-1 | ListContainerManifests returns ([]ContainerManifestListRow, bool, error) and the SELECT projects exactly the 8 resource columns (presence and absence) |
S17:244, S17:338 | TestListContainerManifestsStmt via assertProjectsManifestListColumns (extended, red on absence); return type compile-enforced by every retyped list test |
| AC-2 | ContainerManifestListRow carries exactly the 8 S17 fields, no annotations field, doc signposts protocol readers to ContainerManifest |
S17:244, S17:389 | The type lands in this commit with no annotations field (structural); TestContainerManifestListRowFromModel pins the 8-field surface by whole-struct equality (new, red) |
| AC-3 | NextContainerManifestCursor takes ContainerManifestListRow; no ContainerManifest overload remains |
- | Compile-time: walkManifestList and TestListContainerManifests_RowFidelity call it with the list row; overload absence is a grep at the Step 4 gate |
| AC-4 | GetContainerManifestByDigest, CreateContainerManifest, ListReferrersPage, and every annotations NULL/round-trip test byte-for-byte untouched |
S12:1557 | Suites unchanged and green in this commit's skip run (TestGetContainerManifestByDigest_*, TestCreateContainerManifest_*, TestListReferrersPage_*); git diff 887d2f38 scope check at the Step 4 gate |
| AC-5 | Converter covers the populated row, each nullable's NULL case, and the empty-input case, with fixtures a transposed converter fails | S17:244 | TestContainerManifestListRowFromModel (new, red): all 16 model fields distinct and non-zero, byte patterns i/i+32/i+64 on digest, blob_sha256, subject_digest |
| AC-6 | RowFidelity asserts all 8 fields with distinct non-zero seeds (exact ID equality included) plus the sparse row's NULL mapping; no unprojected-column assertion remains |
S17:338 | TestListContainerManifests_RowFidelity (reshaped, red): populated row seeded with an explicit UUIDv7 id |
| AC-7 | The four list-behavior integration tests and the EXPLAIN test pass unmodified in semantics (retype only) | S17:338, S17:339 | TestListContainerManifests_DefaultViewExcludesReferrers, _KeysetWalkBothDirections, _ChainScoping, _HasMoreSignal (helpers retyped, red until implementation); _ViewsRideTheirOwnIndex untouched and green |
| AC-8 | BenchmarkListManifestsAtCapAnnotations runs both arms over the same 100 cap rows; the recorded run shows the list arm at least two orders of magnitude below the full-row arm in B/op |
S12:1122 | Benchmark lands in this commit; the recorded run is the implementation commit's deliverable (the list arm panics against the skeleton by design) |
| AC-9 | Unit and integration suites green, go vet -tags=integration and go-lint-ci clean, delta under 500 reviewable LOC, plan file untracked |
- | Step 4 verification gate, not test-shaped |
Jet conversion
| # | Criterion | Tests / gates |
|---|---|---|
| AC-1 | No raw SQL remains in internal/datastore/container_manifest.go; every statement builds with jet |
Mechanical gate: the plan's two rg anchors (SQL string assignments, const stmt) return nothing |
| AC-2 | Retired symbols gone repo-wide, code and comments | Mechanical gate: retired-symbol rg -w sweep over internal/ and docs/dev/ returns nothing |
| AC-3 | All store method signatures unchanged; diff confined to the ten planned files; no caller edits | go build ./... with callers untouched; diff audit against the stacked base |
| AC-4 | Unit suite green, integration suite green, lint clean | go test ./..., go test -tags=integration ./internal/datastore/, go-lint-ci ./... |
| AC-5 | The four .Sql() builder tests and the converter test pass, including the 3-args-at-5-digests N-independence pin and the 14-arg Create shape |
TestCreateContainerManifestStmt_SQL, TestGetContainerManifestByDigestStmt_SQL, TestChildManifestsExistStmt_SQL, TestListReferrersPageStmt_SQL, TestContainerManifestFromModel |
| AC-6 | Converted EXPLAIN tests pass against builder-produced SQL, still asserting single-partition pruning, Index Scan, truncated child-index suffixes | TestGetContainerManifestByDigest_SinglePartitionPruning, TestListReferrersPage_PartialIndexScan, TestListContainerManifests_ViewsRideTheirOwnIndex |
| AC-7 | NULL-vs-{} annotations contract holds end to end |
TestContainerManifestFromModel NULL/empty/multi-key cases; TestGetContainerManifestByDigest_NullAnnotations; TestListReferrersPage_NullAnnotations; TestCreateContainerManifest_AnnotationsRoundTrip_GeneratedShapes; TestListContainerManifests_RowFidelity sparse row |
| AC-8 | Benchmark delta recorded in the MR description | BenchmarkManifestScanByAnnotationSize base vs jet via benchstat, three arms (this description) |
| AC-9 | docs/dev/database-query-patterns.md jsonb section describes the shipped jet form |
Doc edit in the implementation commit |
| AC-10 | MR description: why-first, Related to line, e2e-catalog exemption statement |
This description |
Test plan
go test ./...
go test -tags=integration ./internal/datastore/
go-lint-ci ./...Behavior-preserving refactor of the internal datastore surface plus a projection narrowing with no HTTP surface: no e2e scenario added or affected, docs/testing/ untouched. The handler surface belongs to the stacked handler MR.
Context for LLM agents
Rationale
- Alias tags on the model fields, for both the full 16-column scan and the narrow 8-column row. Rejected: a mistyped tag scans zero values with no error.
- Widening the domain type to match jet's model. Rejected: 29 use sites churn for a datastore-internal concern.
- Hybrid fallback, keeping the hand scanner for the annotations column only. Rejected: two scanning mechanisms in one file is the state this MR ends.
- Keeping the full 16-column projection for the list. Rejected: the list resource never serializes annotations, and the full row costs ~223x the time and ~688x the bytes per page at the S12 cap (the benchmark tables above).
- Chosen: scan into the generated
model.ContainerManifests, convert throughcontainerManifestFromModel(protocol reads) andcontainerManifestListRowFromModel(list), the package's NULL-normalization points.
qrm row identity
- The qrm scanner builds slice row identity from the projected primary-key columns (go-jet v2.15.0,
qrm/scan_context.go). A projected key subset that repeats across rows silently merges those rows into one, and a projection with no key columns falls back to per-row identity. - The rule now recorded in
docs/dev/database-query-patterns.md: project primary-key columns whose values stay distinct across the returned rows, and the full key always qualifies. Of the composite key(id, namespace_id)the list projects onlyid: every page is scoped to one namespace, where the key guarantees distinctidvalues, so per-row identity holds. Projectingnamespace_idwithoutidwould collapse the page into one row.
Non-goals
- No route, response shape, or wiring changes. The management API's reader interface, cursor encoder, and resource mapper retype to
ContainerManifestListRow, which is the whole of this MR's reach intointernal/managementapi. - No spec edits. The ~520 KB annotations figures in S12 prose are stale next to the 590,848 B measurement, and updating them is a spec-owner follow-up.
- No caller churn beyond the retype itself: the retyped list surface (
ListContainerManifestsreturns,NextContainerManifestCursorparameter) reaches four files outsideinternal/datastore, all signature-only, and the protocol store methods keep their signatures and semantics. - No
.Sql()unit test for the list builder beyond the projection presence/absence assertions: the stacked base MR ships that builder and owns its test.
Verification already done
- All gates green at cdba1566, re-run after the rebase onto main:
go test ./...,go test -tags=integration ./internal/datastore/(177 s, no failures),go-lint-ci ./.... - Red-before-green held for the narrowing: the test-author commit (ceaad504) pinned the 8-column projection (presence and absence per column) and the converter against the then-16-column statement, red until the implementation commit filled the skeletons.
- The stacked base's four review fixes, empty-page-nil and cursor bounding among them, were ported through the conversion and stay pinned by the retyped integration tests.
Related to #453 (closed)
Related to #430 (closed)
Database Review Evidence
Queries
Note
Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral PostgreSQL
17 (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.
The statements are this MR's jet-built forms, diffed against the stacked target branch rather than main. The Index column names the parent index, and each plan shows the partition-child name the scan actually used. The list variants mirror the four the stacked base MR evidenced on the full projection, so the two tables compare directly.
| Method | Plan node | Index | Rows (plan / actual) | Cost | Time | Buffers (hit / read) | Partitions |
|---|---|---|---|---|---|---|---|
datastore.childManifestsExistStmt |
Index Scan | unique_container_manifests_ns_id_ci_id_digest |
2 / 2 | 18.65 | 0.016ms | 5 / 0 | 1 |
datastore.createContainerManifestStmt |
Insert | n/a (arbiter unique_container_manifests_ns_id_ci_id_digest) |
1 / 1 | 0.01 | 0.503ms | 157 / 0 | 1 |
datastore.DeleteByID |
Delete | pk_container_manifests |
0 / 0 | 8.30 | 0.137ms | 23 / 0 | 1 |
datastore.getContainerManifestByDigestStmt |
Limit | unique_container_manifests_ns_id_ci_id_digest |
1 / 1 | 8.30 | 0.011ms | 3 / 0 | 1 |
datastore.listContainerManifestsStmt.DefaultView_FirstPage |
Limit | index_container_manifests_on_ns_id_ci_id_created_at_id (partial) |
11 / 11 | 2.68 | 0.012ms | 3 / 0 | 1 |
datastore.listContainerManifestsStmt.DefaultView_DeepPage |
Limit | index_container_manifests_on_ns_id_ci_id_created_at_id (partial) |
11 / 11 | 4.45 | 0.019ms | 3 / 0 | 1 |
datastore.listContainerManifestsStmt.ReferrersView_FirstPage |
Limit | index_container_manifests_on_ns_id_ci_id_created_at_id_all |
11 / 11 | 3.92 | 0.014ms | 4 / 0 | 1 |
datastore.listContainerManifestsStmt.ReferrersView_DeepPage |
Limit | index_container_manifests_on_ns_id_ci_id_created_at_id_all |
11 / 11 | 3.19 | 0.015ms | 3 / 0 | 1 |
datastore.listReferrersPageStmt.TypeEmpty_CursorEmpty |
Limit | index_container_manifests_on_ns_id_ci_id_subject_digest_digest (partial) |
10 / 10 | 2.92 | 0.019ms | 3 / 0 | 1 |
datastore.listReferrersPageStmt.TypeSet_CursorEmpty |
Limit | index_container_manifests_on_ns_id_ci_id_subject_digest_digest (partial) |
10 / 10 | 13.09 | 0.018ms | 4 / 0 | 1 |
datastore.listReferrersPageStmt.TypeEmpty_CursorSet |
Limit | index_container_manifests_on_ns_id_ci_id_subject_digest_digest (partial) |
10 / 10 | 4.06 | 0.019ms | 3 / 0 | 1 |
datastore.listReferrersPageStmt.TypeSet_CursorSet |
Limit | index_container_manifests_on_ns_id_ci_id_subject_digest_digest (partial) |
10 / 10 | 18.56 | 0.021ms | 4 / 0 | 1 |
datastore.childManifestsExistStmt
Summary: Plan matches the batched existence probe: one Index Scan over the unique digest index child (container_manifests_p03_..._dig_idx) with the whole digest set folded into the Index Cond as = ANY(bytea[]), pruned to one partition, and 2 of the 3 probed digests found. Shape-equivalent to the raw statement it replaces: jet adds only the explicit ::bytea[] cast on the array parameter the driver previously implied. The result stays caller-capped (one row per probed digest, unique per image), so no LIMIT is needed. No anomalies.
Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=2, blob_storage_blobs=1, blob_storage_attachments=1, container_manifests=6000 (5000 in the queried image, of which 2500 are referrers of one subject digest with artifact_type alternating between two values, plus 1000 in a sibling image. Every row shares one namespace, so the whole seed lands in a single hash partition.)
Rendered SQL:
SELECT container_manifests.digest AS "container_manifests.digest",
container_manifests.size AS "container_manifests.size"
FROM public.container_manifests
WHERE ((container_manifests.namespace_id = $1::uuid) AND (container_manifests.container_image_id = $2::uuid)) AND (container_manifests.digest = ANY($3::bytea[]));Bound args: [namespace_id=8739e589-20c2-448a-b511-3e07f0dd5dc2, container_image_id=ca7e3464-5625-406b-9aaa-0785577df966, digests=[0x00000000000000000000000000000000000000000000000000000000000009c4 (seeded), 0x00000000000000000000000000000000000000000000000000000000000009c5 (seeded), 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff (absent)]]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Index Scan using container_manifests_p03_namespace_id_container_image_id_dig_idx on container_manifests_p03 container_manifests (cost=0.28..18.65 rows=2 width=41) (actual time=0.013..0.016 rows=2 loops=1)
Index Cond: ((namespace_id = '8739e589-20c2-448a-b511-3e07f0dd5dc2'::uuid) AND (container_image_id = 'ca7e3464-5625-406b-9aaa-0785577df966'::uuid) AND (digest = ANY ('{"\\x00000000000000000000000000000000000000000000000000000000000009c4","\\x00000000000000000000000000000000000000000000000000000000000009c5","\\xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}'::bytea[])))
Buffers: shared hit=5
Planning:
Buffers: shared hit=244
Planning Time: 0.901 ms
Execution Time: 0.026 msTimings: planning 0.901ms, execution 0.026ms, total 0.927ms.
datastore.createContainerManifestStmt
Summary: Single-row INSERT routed to one hash partition (p29), with unique_container_manifests_ns_id_ci_id_digest as the ON CONFLICT arbiter and the 16-column RETURNING serving the read-back (1 tuple inserted, 0 conflicting). Column list, placeholder order, conflict target, and RETURNING set match the raw statement. The redundant $8::jsonb cast is dropped and the INSERT column context types the parameter. Execution time is FK-trigger dominated (the three parent checks), unchanged by the conversion. No anomalies.
Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=2, blob_storage_blobs=1, blob_storage_attachments=1, container_manifests=6000 (5000 in the queried image, of which 2500 are referrers of one subject digest with artifact_type alternating between two values, plus 1000 in a sibling image. Every row shares one namespace, so the whole seed lands in a single hash partition.)
Rendered SQL:
INSERT INTO public.container_manifests (id, namespace_id, container_image_id, blob_storage_attachment_id, size, media_type, artifact_type, annotations, digest, blob_sha256, subject_digest, architecture, os, os_variant)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
ON CONFLICT (namespace_id, container_image_id, digest) DO NOTHING
RETURNING 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";Bound args: [id=gen_random_uuid(), namespace_id=<seeded>, container_image_id=<seeded>, blob_storage_attachment_id=<seeded>, size=2048, media_type='application/vnd.oci.image.manifest.v1+json', artifact_type='application/vnd.example.sbom', annotations='{"key":"value"}', digest=0x00000000000000000000000000000000000000000000000000000000000f423f, blob_sha256=0xabababababababababababababababababababababababababababababababab, subject_digest=0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd, architecture='amd64', os='linux', os_variant='v8']
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Insert on container_manifests (cost=0.00..0.01 rows=1 width=368) (actual time=0.502..0.503 rows=1 loops=1)
Conflict Resolution: NOTHING
Conflict Arbiter Indexes: unique_container_manifests_ns_id_ci_id_digest
Tuples Inserted: 1
Conflicting Tuples: 0
Buffers: shared hit=157
-> Result (cost=0.00..0.01 rows=1 width=368) (actual time=0.002..0.002 rows=1 loops=1)
Planning:
Buffers: shared hit=129
Planning Time: 0.504 ms
Trigger for constraint fk_container_manifests_blob_storage_attachment_id_bsa on container_manifests_p29: time=0.073 calls=1
Trigger for constraint fk_container_manifests_container_image_id_container_images on container_manifests_p29: time=2.810 calls=1
Trigger for constraint fk_container_manifests_namespace_id_namespaces on container_manifests_p29: time=0.020 calls=1
Execution Time: 3.443 msTimings: planning 0.504ms, execution 3.443ms, total 3.947ms.
datastore.DeleteByID
Summary: Delete prunes to one partition (p05) at plan time and locates the row through the primary-key index child on (id, namespace_id), matching exactly 1 row. The SQL shape is identical to the raw statement this replaces. Execution time is dominated by the referencing tables' FK triggers (container_manifest_relations, container_tags), a pre-existing schema property the conversion does not touch. No anomalies.
Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=2, blob_storage_blobs=1, blob_storage_attachments=1, container_manifests=6000 (5000 in the queried image, of which 2500 are referrers of one subject digest with artifact_type alternating between two values, plus 1000 in a sibling image. Every row shares one namespace, so the whole seed lands in a single hash partition.)
Rendered SQL:
DELETE FROM public.container_manifests
WHERE (container_manifests.namespace_id = $1::uuid) AND (container_manifests.id = $2::uuid);Bound args: [namespace_id=66c0baeb-d0ce-4b18-8b05-223aa4ca5459, id=8eca8639-6172-49be-abfa-173efc77f487 (a seeded row)]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Delete on container_manifests (cost=0.28..8.30 rows=0 width=0) (actual time=0.136..0.137 rows=0 loops=1)
Delete on container_manifests_p05 container_manifests_1
Buffers: shared hit=23
-> Index Scan using container_manifests_p05_pkey on container_manifests_p05 container_manifests_1 (cost=0.28..8.30 rows=1 width=10) (actual time=0.016..0.016 rows=1 loops=1)
Index Cond: ((id = '8eca8639-6172-49be-abfa-173efc77f487'::uuid) AND (namespace_id = '66c0baeb-d0ce-4b18-8b05-223aa4ca5459'::uuid))
Buffers: shared hit=3
Planning Time: 0.206 ms
Trigger for constraint container_manifest_relations_parent_container_manifest_id_fkey5 on container_manifests_p05: time=2.594 calls=1
Trigger for constraint container_manifest_relations_child_container_manifest_id__fkey5 on container_manifests_p05: time=0.267 calls=1
Trigger for constraint container_tags_container_manifest_id_namespace_id_fkey5 on container_manifests_p05: time=2.616 calls=1
Execution Time: 5.965 msTimings: planning 0.206ms, execution 5.965ms, total 6.171ms.
datastore.getContainerManifestByDigestStmt
Summary: Plan matches the unique lookup: Index Scan over the unique digest index child (container_manifests_p57_..._dig_idx) with all three predicates in the Index Cond, one partition, 1 row in 3 buffer hits. The only shape delta from the raw statement is the added LIMIT $4 (bound to 1 by the builder), a no-op Limit node above a unique match. No anomalies.
Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=2, blob_storage_blobs=1, blob_storage_attachments=1, container_manifests=6000 (5000 in the queried image, of which 2500 are referrers of one subject digest with artifact_type alternating between two values, plus 1000 in a sibling image. Every row shares one namespace, so the whole seed lands in a single hash partition.)
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.digest = $3::bytea)
LIMIT $4;Bound args: [namespace_id=697543a3-27e8-4d9f-8958-7541e8a44298, container_image_id=8c68cff7-8b4c-4998-8e68-13d6e542c7a5, digest=0x00000000000000000000000000000000000000000000000000000000000009c4, limit=1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..8.30 rows=1 width=378) (actual time=0.011..0.011 rows=1 loops=1)
Buffers: shared hit=3
-> Index Scan using container_manifests_p57_namespace_id_container_image_id_dig_idx on container_manifests_p57 container_manifests (cost=0.28..8.30 rows=1 width=378) (actual time=0.010..0.010 rows=1 loops=1)
Index Cond: ((namespace_id = '697543a3-27e8-4d9f-8958-7541e8a44298'::uuid) AND (container_image_id = '8c68cff7-8b4c-4998-8e68-13d6e542c7a5'::uuid) AND (digest = '\x00000000000000000000000000000000000000000000000000000000000009c4'::bytea))
Buffers: shared hit=3
Planning:
Buffers: shared hit=469
Planning Time: 1.419 ms
Execution Time: 0.027 msTimings: planning 1.419ms, execution 0.027ms, total 1.446ms.
datastore.listContainerManifestsStmt.DefaultView_FirstPage
Summary: Reproduces the stacked base's plan on the narrowed 8-column projection: Index Scan Backward over the default view's partial index child (..._cre_idx, WHERE subject_digest IS NULL), no Sort node, no rows removed by any filter, one partition. The average row width the planner reports drops to 177 bytes, against 378 bytes for the 16-column projection in this same run's get and referrers plans, with annotations NULL in the seed (a stored annotations payload widens the full row much further, ~591 KB at the S12 cap). No anomalies.
Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=2, blob_storage_blobs=1, blob_storage_attachments=1, container_manifests=6000 (5000 in the queried image, of which 2500 are referrers of one subject digest with artifact_type alternating between two values, plus 1000 in a sibling image. Every row shares one namespace, so the whole seed lands in a single hash partition.)
Rendered SQL:
SELECT container_manifests.id AS "container_manifests.id",
container_manifests.digest AS "container_manifests.digest",
container_manifests.media_type AS "container_manifests.media_type",
container_manifests.artifact_type AS "container_manifests.artifact_type",
container_manifests.subject_digest AS "container_manifests.subject_digest",
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"
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: [namespace_id=bc6cd40f-65ee-401e-bb57-3d01dd22b71d, container_image_id=6db0d70d-3fbd-49e8-870c-d8b978b8066a, limit=11]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..2.68 rows=11 width=177) (actual time=0.010..0.012 rows=11 loops=1)
Buffers: shared hit=3
-> Index Scan Backward using container_manifests_p20_namespace_id_container_image_id_cre_idx on container_manifests_p20 container_manifests (cost=0.28..636.93 rows=2917 width=177) (actual time=0.009..0.011 rows=11 loops=1)
Index Cond: ((namespace_id = 'bc6cd40f-65ee-401e-bb57-3d01dd22b71d'::uuid) AND (container_image_id = '6db0d70d-3fbd-49e8-870c-d8b978b8066a'::uuid))
Buffers: shared hit=3
Planning:
Buffers: shared hit=266
Planning Time: 0.946 ms
Execution Time: 0.029 msTimings: planning 0.946ms, execution 0.029ms, total 0.975ms.
datastore.listContainerManifestsStmt.DefaultView_DeepPage
Summary: The deep page keeps the same shape with the keyset ROW(created_at, id) < ROW($3, $4) bound folded into the Index Cond of the partial index child, so the scan starts at the boundary instead of filtering from the top: still 3 buffer hits and no Sort at a boundary 1250 rows into the view. Narrow projection only. Predicates and order match the stacked base's statement. No anomalies.
Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=2, blob_storage_blobs=1, blob_storage_attachments=1, container_manifests=6000 (5000 in the queried image, of which 2500 are referrers of one subject digest with artifact_type alternating between two values, plus 1000 in a sibling image. Every row shares one namespace, so the whole seed lands in a single hash partition.)
Rendered SQL:
SELECT container_manifests.id AS "container_manifests.id",
container_manifests.digest AS "container_manifests.digest",
container_manifests.media_type AS "container_manifests.media_type",
container_manifests.artifact_type AS "container_manifests.artifact_type",
container_manifests.subject_digest AS "container_manifests.subject_digest",
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"
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: [namespace_id=d9e62b52-a667-47fc-bbe3-449918577cd5, container_image_id=3de37901-f1d7-4107-82cd-2dcd38a6d1cd, cursor=(created_at='2026-07-31 18:20:59.684194+00', id=1e21bc15-b3db-4655-b97f-6182c8a9318a) (row 1251 of the descending default-view walk), limit=11]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..4.45 rows=11 width=177) (actual time=0.015..0.019 rows=11 loops=1)
Buffers: shared hit=3
-> Index Scan Backward using container_manifests_p44_namespace_id_container_image_id_cre_idx on container_manifests_p44 container_manifests (cost=0.28..460.66 rows=1214 width=177) (actual time=0.015..0.018 rows=11 loops=1)
Index Cond: ((namespace_id = 'd9e62b52-a667-47fc-bbe3-449918577cd5'::uuid) AND (container_image_id = '3de37901-f1d7-4107-82cd-2dcd38a6d1cd'::uuid) AND (ROW(created_at, id) < ROW('2026-07-31 18:20:59.684194+00'::timestamp with time zone, '1e21bc15-b3db-4655-b97f-6182c8a9318a'::uuid)))
Buffers: shared hit=3
Planning:
Buffers: shared hit=15
Planning Time: 0.340 ms
Execution Time: 0.081 msTimings: planning 0.34ms, execution 0.081ms, total 0.421ms.
datastore.listContainerManifestsStmt.ReferrersView_FirstPage
Summary: The include_referrers view rides its own total index child (..._cr_idx1, same key tuple, no predicate): Index Scan Backward, no Sort, no filter, one partition, 4 buffer hits over the image's full 5000-row inventory. Narrow projection only. Predicates and order match the stacked base's statement. No anomalies.
Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=2, blob_storage_blobs=1, blob_storage_attachments=1, container_manifests=6000 (5000 in the queried image, of which 2500 are referrers of one subject digest with artifact_type alternating between two values, plus 1000 in a sibling image. Every row shares one namespace, so the whole seed lands in a single hash partition.)
Rendered SQL:
SELECT container_manifests.id AS "container_manifests.id",
container_manifests.digest AS "container_manifests.digest",
container_manifests.media_type AS "container_manifests.media_type",
container_manifests.artifact_type AS "container_manifests.artifact_type",
container_manifests.subject_digest AS "container_manifests.subject_digest",
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"
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: [namespace_id=9cdea800-e9dc-4ea3-a738-b766bcece45e, container_image_id=88edbf55-7243-4a95-8eb7-59043890462d, limit=11]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.41..3.92 rows=11 width=177) (actual time=0.011..0.014 rows=11 loops=1)
Buffers: shared hit=4
-> Index Scan Backward using container_manifests_p03_namespace_id_container_image_id_cr_idx1 on container_manifests_p03 container_manifests (cost=0.41..1596.47 rows=5000 width=177) (actual time=0.010..0.013 rows=11 loops=1)
Index Cond: ((namespace_id = '9cdea800-e9dc-4ea3-a738-b766bcece45e'::uuid) AND (container_image_id = '88edbf55-7243-4a95-8eb7-59043890462d'::uuid))
Buffers: shared hit=4
Planning:
Buffers: shared hit=266
Planning Time: 1.052 ms
Execution Time: 0.029 msTimings: planning 1.052ms, execution 0.029ms, total 1.081ms.
datastore.listContainerManifestsStmt.ReferrersView_DeepPage
Summary: Deep page of the total view: the keyset ROW bound folds into the Index Cond of the total index child, 3 buffer hits at a boundary 2500 rows in, no Sort. Narrow projection only. Predicates and order match the stacked base's statement. No anomalies.
Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=2, blob_storage_blobs=1, blob_storage_attachments=1, container_manifests=6000 (5000 in the queried image, of which 2500 are referrers of one subject digest with artifact_type alternating between two values, plus 1000 in a sibling image. Every row shares one namespace, so the whole seed lands in a single hash partition.)
Rendered SQL:
SELECT container_manifests.id AS "container_manifests.id",
container_manifests.digest AS "container_manifests.digest",
container_manifests.media_type AS "container_manifests.media_type",
container_manifests.artifact_type AS "container_manifests.artifact_type",
container_manifests.subject_digest AS "container_manifests.subject_digest",
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"
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: [namespace_id=ec77f3f6-63b6-47c8-a264-35a29719543c, container_image_id=36f78d7c-1a9b-4373-b71e-387a05e4da07, cursor=(created_at='2026-07-31 18:21:02.081818+00', id=6851bf88-cb03-4071-8deb-66c0dafdf598) (row 2501 of the descending total-view walk), limit=11]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..3.19 rows=11 width=177) (actual time=0.013..0.015 rows=11 loops=1)
Buffers: shared hit=3
-> Index Scan Backward using container_manifests_p42_namespace_id_container_image_id_cr_idx1 on container_manifests_p42 container_manifests (cost=0.28..550.82 rows=2082 width=177) (actual time=0.012..0.014 rows=11 loops=1)
Index Cond: ((namespace_id = 'ec77f3f6-63b6-47c8-a264-35a29719543c'::uuid) AND (container_image_id = '36f78d7c-1a9b-4373-b71e-387a05e4da07'::uuid) AND (ROW(created_at, id) < ROW('2026-07-31 18:21:02.081818+00'::timestamp with time zone, '6851bf88-cb03-4071-8deb-66c0dafdf598'::uuid)))
Buffers: shared hit=3
Planning:
Buffers: shared hit=18
Planning Time: 0.261 ms
Execution Time: 0.046 msTimings: planning 0.261ms, execution 0.046ms, total 0.307ms.
datastore.listReferrersPageStmt.TypeEmpty_CursorEmpty
Summary: First referrers page: Limit over an Index Scan of the referrers partial index child (..._sub_idx, WHERE subject_digest IS NOT NULL), all three predicates in the Index Cond, ORDER BY digest served by the index with no Sort, one partition. Same shape as the raw builder it replaces. No anomalies.
Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=2, blob_storage_blobs=1, blob_storage_attachments=1, container_manifests=6000 (5000 in the queried image, of which 2500 are referrers of one subject digest with artifact_type alternating between two values, plus 1000 in a sibling image. Every row shares one namespace, so the whole seed lands in a single hash partition.)
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 = $3::bytea)
ORDER BY container_manifests.digest ASC
LIMIT $4;Bound args: [namespace_id=0c029bcb-070b-45a9-8b95-bc648218ec53, container_image_id=aa840270-357c-4637-a7d0-ac66a3bd64d6, subject_digest=0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd, limit=10]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..2.92 rows=10 width=378) (actual time=0.016..0.019 rows=10 loops=1)
Buffers: shared hit=3
-> Index Scan using container_manifests_p15_namespace_id_container_image_id_sub_idx on container_manifests_p15 container_manifests (cost=0.28..550.84 rows=2083 width=378) (actual time=0.015..0.017 rows=10 loops=1)
Index Cond: ((namespace_id = '0c029bcb-070b-45a9-8b95-bc648218ec53'::uuid) AND (container_image_id = 'aa840270-357c-4637-a7d0-ac66a3bd64d6'::uuid) AND (subject_digest = '\xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd'::bytea))
Buffers: shared hit=3
Planning:
Buffers: shared hit=275
Planning Time: 0.963 ms
Execution Time: 0.035 msTimings: planning 0.963ms, execution 0.035ms, total 0.998ms.
datastore.listReferrersPageStmt.TypeSet_CursorEmpty
Summary: With artifact_type set, the predicate applies as a post-index Filter (9 rows removed at this seed's half-and-half type mix), exactly the raw statement's shape: the column is not in the partial index, and the filter walks the same subject-bounded index range. Index choice, ordering, and pruning are unchanged. No anomalies.
Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=2, blob_storage_blobs=1, blob_storage_attachments=1, container_manifests=6000 (5000 in the queried image, of which 2500 are referrers of one subject digest with artifact_type alternating between two values, plus 1000 in a sibling image. Every row shares one namespace, so the whole seed lands in a single hash partition.)
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 = $3::bytea)) AND (container_manifests.artifact_type = $4::text)
ORDER BY container_manifests.digest ASC
LIMIT $5;Bound args: [namespace_id=721e377a-71a7-4478-899d-b67900b89a43, container_image_id=436ea2e7-fea6-4815-953c-68a420842678, subject_digest=0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd, artifact_type='application/vnd.example.sbom', limit=10]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..13.09 rows=10 width=378) (actual time=0.014..0.018 rows=10 loops=1)
Buffers: shared hit=4
-> Index Scan using container_manifests_p40_namespace_id_container_image_id_sub_idx on container_manifests_p40 container_manifests (cost=0.28..556.04 rows=434 width=378) (actual time=0.013..0.017 rows=10 loops=1)
Index Cond: ((namespace_id = '721e377a-71a7-4478-899d-b67900b89a43'::uuid) AND (container_image_id = '436ea2e7-fea6-4815-953c-68a420842678'::uuid) AND (subject_digest = '\xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd'::bytea))
Filter: (artifact_type = 'application/vnd.example.sbom'::text)
Rows Removed by Filter: 9
Buffers: shared hit=4
Planning:
Buffers: shared hit=281
Planning Time: 1.009 ms
Execution Time: 0.037 msTimings: planning 1.009ms, execution 0.037ms, total 1.046ms.
datastore.listReferrersPageStmt.TypeEmpty_CursorSet
Summary: The keyset digest > $4 bound folds into the Index Cond as the fourth index key, so the scan resumes at the cursor with no re-read of prior pages: 3 buffer hits, no Sort, one partition. Same shape as the raw builder's cursor branch. No anomalies.
Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=2, blob_storage_blobs=1, blob_storage_attachments=1, container_manifests=6000 (5000 in the queried image, of which 2500 are referrers of one subject digest with artifact_type alternating between two values, plus 1000 in a sibling image. Every row shares one namespace, so the whole seed lands in a single hash partition.)
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 = $3::bytea)) AND (container_manifests.digest > $4::bytea)
ORDER BY container_manifests.digest ASC
LIMIT $5;Bound args: [namespace_id=ba0d5f51-4e5c-4121-b6fd-d998fe77fb88, container_image_id=fde11e91-046f-47a5-9c64-e4fefe739243, subject_digest=0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd, cursor=0x00000000000000000000000000000000000000000000000000000000000009c5, limit=10]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..4.06 rows=10 width=378) (actual time=0.016..0.019 rows=10 loops=1)
Buffers: shared hit=3
-> Index Scan using container_manifests_p25_namespace_id_container_image_id_sub_idx on container_manifests_p25 container_manifests (cost=0.28..459.72 rows=1215 width=378) (actual time=0.015..0.017 rows=10 loops=1)
Index Cond: ((namespace_id = 'ba0d5f51-4e5c-4121-b6fd-d998fe77fb88'::uuid) AND (container_image_id = 'fde11e91-046f-47a5-9c64-e4fefe739243'::uuid) AND (subject_digest = '\xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd'::bytea) AND (digest > '\x00000000000000000000000000000000000000000000000000000000000009c5'::bytea))
Buffers: shared hit=3
Planning:
Buffers: shared hit=277
Planning Time: 1.157 ms
Execution Time: 0.034 msTimings: planning 1.157ms, execution 0.034ms, total 1.191ms.
datastore.listReferrersPageStmt.TypeSet_CursorSet
Summary: Both conditional predicates compose as in the raw builder: the cursor rides the Index Cond, the artifact_type filter applies post-index (10 rows removed at this seed), and the LIMIT is now always bound because ListReferrersPage rejects non-positive limits before the build. Index choice, ordering, and pruning are unchanged. No anomalies.
Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=2, blob_storage_blobs=1, blob_storage_attachments=1, container_manifests=6000 (5000 in the queried image, of which 2500 are referrers of one subject digest with artifact_type alternating between two values, plus 1000 in a sibling image. Every row shares one namespace, so the whole seed lands in a single hash partition.)
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 = $3::bytea)) AND (container_manifests.artifact_type = $4::text)) AND (container_manifests.digest > $5::bytea)
ORDER BY container_manifests.digest ASC
LIMIT $6;Bound args: [namespace_id=51e099a7-69dc-4140-a05b-70d764b9aa11, container_image_id=69373438-d12d-49c1-8198-18c72c7eabe0, subject_digest=0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd, artifact_type='application/vnd.example.sbom', cursor=0x00000000000000000000000000000000000000000000000000000000000009c5, limit=10]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..18.56 rows=10 width=378) (actual time=0.017..0.021 rows=10 loops=1)
Buffers: shared hit=4
-> Index Scan using container_manifests_p27_namespace_id_container_image_id_sub_idx on container_manifests_p27 container_manifests (cost=0.28..462.75 rows=253 width=378) (actual time=0.016..0.019 rows=10 loops=1)
Index Cond: ((namespace_id = '51e099a7-69dc-4140-a05b-70d764b9aa11'::uuid) AND (container_image_id = '69373438-d12d-49c1-8198-18c72c7eabe0'::uuid) AND (subject_digest = '\xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd'::bytea) AND (digest > '\x00000000000000000000000000000000000000000000000000000000000009c5'::bytea))
Filter: (artifact_type = 'application/vnd.example.sbom'::text)
Rows Removed by Filter: 10
Buffers: shared hit=4
Planning:
Buffers: shared hit=284
Planning Time: 1.311 ms
Execution Time: 0.040 msTimings: planning 1.311ms, execution 0.04ms, total 1.351ms.