refactor(datastore): tie a relationship edge to its manifests' image
Why
The table's four foreign keys reach namespaces once, container_images once,
and container_manifests twice, none of them tied to the container_image_id
the row carries. A row naming one image while its child manifest sits under
another satisfies all four. No production writer creates that row and the reads
do not re-check, so a future writer, a backfill, or a repair script could put
one in front of the API. Three integration tests recorded the gap rather than
asserting it away. Raised in
review on !2581.
What
This replaces the two narrow manifest keys, it does not add to them. All
four columns are NOT NULL, so MATCH SIMPLE never escapes and the narrow pair
is fully implied. Keeping both doubles the insert's referential-integrity work:
20,000 inserts measured 1185 to 1363 ms with the narrow pair, 2756 to 2899 ms
with both, and 1389 ms with the composite pair.
NOT VALID is refused on a partitioned table on PostgreSQL 16.15 and 17.10
with SQLSTATE 42809, and accepted only on 18.4. All three sit in the
db:migrate matrix, so the keys add validating in one statement, deviating from
docs/dev/database-migrations.md's two-migration recipe. The scan is linear in
the edge count, 14 to 24 microseconds per edge over a 470 ms lock floor, so the
pair holds ShareRowExclusive on both tables and all 128 partitions for 14.3 s
at 254,256 edges.
The referenced index is invalid until the 64th ATTACH, and an invalid
index silently backs no foreign key, so both constraint statements come after
the attach phase. The schema test asserts indisvalid.
The rename moves mapCreateCMRError's needles to parent_cm_id and
child_cm_id. No name keeping the full child_container_manifest_id token and
also naming the image column fits 63 bytes, measured at 64 and 65. Create has
no production caller, so the deploy window where a new binary meets the old
schema is reachable only from tests.
The equal-digest tiebreak becomes unreachable. Two manifests can share a
digest only across images, so once both keys hold every child of a parent has a
distinct digest. The ORDER BY trailing key stays as a deterministic cursor,
and the flipped test says so.
container_remote_manifest_relationships holds the same gap on purpose and
nothing here touches it.
Size
14 files, 2,639 raw lines, and 1,991 reviewable, past the 500 that
docs/dev/development-model.md asks a split or a justification for. Reviewable
by group: the new schema integration test 1,130, migration SQL 252 of its 636
with 384 mechanical 64-times lines, three flipped test files 373, Go production
185, three dev docs 47, the run recipe and knownHeadVersion 4, and regenerated
structure.sql 0 of its 264. The bulk is that one test file, and the only split
available separates it from the migration it covers, which guardrail 6 forbids.
Merge order
migrations_checksum_test.go moves knownHeadVersion to this migration's
version, and the same line moves to a different version on
fix(datastore): serve maven package-id FK check... (!2762 - merged) • Moaz Khalifa • 19.5. Whichever lands second re-derives the
constant from the new lexical head, and no pipeline on either MR reports the
collision.
No e2e scenario is added or affected. The catalogued scenarios reaching this
table are e2e.docker.publish.push-multiarch,
e2e.docker.publish.buildx-default-push,
e2e.docker.discover.manifest-detail-via-api, and its
e2e.oci.discover.manifest-detail-via-api twin, and all exercise the push path
and the read, both unchanged.
The S12 spec and ADR-007 text amendments ship separately, as a docs(specs) MR
and a handbook MR.
The attach-phase abort window this migration layout carries predates the branch and is tracked in Three container migrations cannot replay after ... (#1403 - closed) • Hayley Swimelar.
Database Review Evidence
Migrations
Note
Timings are from CI (db:migrate matrix, goose verbose) against an
empty database, in apply / rollback order per PG version.
Production-scale validation via Database Lab is not yet available. See
Database review evidence
for the matrix rationale and how to read the numbers.
Source: pipeline 2859818380, green on merge commit 8455887d4. The branch was rebased onto main after that run, and every SQL statement in this migration is unchanged across both heads, with only the two validating-add pragma comments edited since.
| Migration | PG 16 | PG 17 | PG 18 |
|---|---|---|---|
20260921135818_add_container_manifest_relationships_image_composite_fks.sql |
OK (893.32ms / 549.64ms) | OK (759.68ms / 535.26ms) | OK (942.57ms / 538.56ms) |
Migration notes:
- No anomaly fires: every phase reports
OK, the spread across versions is 1.06x on apply and 1.02x on rollback, and apply stays under 1 s on all three. The idempotency re-apply reaches 1.02 s on PG 18. - Apply runs 1.4x to 1.8x rollback, the benign direction. The Up builds 64 partition indexes and adds two validating keys, and the Down drops the parent index in one cascade.
- CI lands below the 0.96 s to 1.76 s the file's header records for an empty 64-partition schema, at 0.76 s to 0.94 s here. Different hardware, and below rather than above the documented cost.
- CI says nothing about the header's 14 to 24 microseconds per edge, because every matrix instance applies against an empty schema and both validating adds scan 0 edges. The boot budget is set by the header's populated measurement (15.45 s at 513,710 manifests and 254,256 edges, 14.3 s of it the two adds), not by this table.
Queries
No query evidence to collect. All three changed non-test Go files dispatch SQL,
but every statement chain is byte-identical to the merge-base.
container_manifest_relationship_children.go and lifecycle_reap_container.go
changed comments only, and the one code change, in mapCreateCMRError, inspects
a driver error and dispatches nothing.
Context for LLM agents
Rationale
- Leaf-to-leaf foreign keys, partition ordinal to partition ordinal. Both
tables hash
namespace_idon the same modulus, so a per-leaf key naming the matching leaf costs 19 locks against 1103, measured. Rejected because the constraint would not appear on the parent table at all, so every catalog read filteringconrelid = 'container_manifest_relationships'::regclass AND conparentid = 0shows it missing, which is the query the existing remote constraints suite uses. It also hard-codes that both tables keep the same modulus forever, and the lock saving buys nothing because 1103 of 6400 slots already fits. - A CHECK constraint or a row-level trigger. PostgreSQL cannot express a
CHECK over another table's rows, so this reduces to two lookups per inserted
row in PL/pgSQL. It escapes
pg_constraint, so no schema test can assert its form the way this repository's suites assert foreign keys.docs/dev/database.mdsettles the general question. - Keep both pairs. Roughly 70 microseconds per row for a guarantee the composite pair already makes, so a 200-child Image Index push pays about 14 ms for nothing.
- Two migrations, the index then the constraints. The index exists only to back the constraints, so the first migration would leave an index nothing references and that adds no uniqueness the primary key does not already give, and the second is unapplyable until the first lands.
- Keep the existing constraint names and widen their column lists. The names
are 54 and 55 bytes, so both fit, and
mapCreateCMRErrorwould need no change. Rejected becausefk_cmr_child_container_manifest_id_container_manifestswould then name one of three columns, against thefk_<table>_<columns>_<referenced table>convention indocs/dev/database.md. A same-named constraint with a different definition is also the kind of change astructure.sqldiff shows quietly, and the same reasoning drove the rename in feat(maven): enforce maven_files version↔packag... (!566 - merged) • Moaz Khalifa • 19.2. - Purpose-name the constraints.
fk_cmr_child_belongs_to_imagefits easily and states the invariant. Rejected becausedocs/dev/database.md's pattern is the column list, the maven pair is the only deviation in the tree, and a column-list name reads as the successor of the constraint it replaces.
Non-goals
- Any read-side change.
ListChildrenByParentIDsandListParentDigestsByChildkeep the predicates their review settled in docs(plans): add the S17 container manifest rea... (!2361 - merged) • Hayley Swimelar • 19.4. - Dropping
fk_cmr_container_image_id_container_images, which is transitively implied throughcontainer_manifestsbut is not a local statement a reader of this table can check. - Amending the S12 spec or ADR-007 here.
docs/dev/conventions.mdpairsdocs/specs/with a spec MR, anddocs/adr/is synced from the handbook by CI. container_tags, tracked in Follow-up from "feat(managementapi): serve the ... (#725) • Unassigned.- Relationship-row test cleanup ordering, tracked in Follow-up from "refactor(datastore): return a m... (#927) • Unassigned.
- Deferred keys into
blob_storage_blobs, tracked in Eight deferred foreign keys were handed to S06 ... (#615) • Pawel Rozlach. container_remote_manifest_relationships, which is S16's and another owner's.
Spec coverage
Spec: docs/specs/S12-container-oci-hosted.md,
#### container_manifest_relationships. Criteria numbered as in the working
plan's Acceptance criteria section.
| # | Criterion | Tests |
|---|---|---|
| AC-1 | An edge whose child manifest belongs to a second image is refused with SQLSTATE 23503 naming fk_cmr_ns_id_and_ci_id_and_child_cm_id_container_manifests |
TestContainerManifestRelationshipStore_ForeignImageChildEdgeRefused, TestContainerReaper_CrossImageRelationshipRefused, TestCMRImageCompositeFK_RefusesAnEdgeWhoseManifestSitsElsewhere/the_child_manifest_sits_under_a_second_image |
| AC-2 | An edge whose parent manifest belongs to a second image is refused with SQLSTATE 23503 naming fk_cmr_ns_id_and_ci_id_and_parent_cm_id_container_manifests |
TestContainerManifestRelationshipStore_EqualDigestsAcrossImagesRefused, TestCMRImageCompositeFK_RefusesAnEdgeWhoseManifestSitsElsewhere/the_parent_manifest_sits_under_a_second_image |
| AC-3 | unique_container_manifests_ns_id_ci_id_id exists at the parent, keys (namespace_id, container_image_id, id) in order, is unique and total, and is indisvalid |
TestCMRImageCompositeFK_ReferencedIndexKeysThreeColumnsInOrder |
| AC-4 | All 64 partition indexes exist under their written-out names and all 64 are attached, and the partitions sweep reports no unattached child |
TestCMRImageCompositeFK_EveryPartitionCarriesItByName, TestCMRImageCompositeFK_OrphanSweepSeesAnUnattachedChild |
| AC-5 | Every identifier the migration introduces is at most 63 bytes | TestCMRImageCompositeFK_NamesFitIdentifierLimit |
| AC-6 | Both new constraints exist with conparentid = 0, confdeltype = 'a', confupdtype = 'a', and the exact pg_get_constraintdef text |
TestCMRImageCompositeFK_BothKeysCarryTheirDefinitions (one subtest per key) |
| AC-7 | Both narrow manifest keys are absent from the table, counted without the conparentid filter |
TestCMRImageCompositeFK_NarrowManifestKeysAreGone (one subtest per key) |
| AC-8 | Per-referenced-parent multiplicity over conparentid = 0 is namespaces: 1, container_images: 1, container_manifests: 2 |
TestCMRImageCompositeFK_EveryKeyPointsAtItsDeclaredParent (one subtest per parent) |
| AC-9 | fk_cmr_container_image_id_container_images and fk_container_manifest_relationships_namespace_id_namespaces survive unchanged |
TestCMRImageCompositeFK_ImageAndNamespaceKeysSurvive (one subtest per key) |
| AC-10 | The Down, by static parse: NO TRANSACTION, no DO $$, both composite drops before DROP INDEX IF EXISTS, each re-add guarded by a preceding DROP CONSTRAINT IF EXISTS, SET/RESET lock_timeout bracketing its statements |
TestCMRImageCompositeFK_DownRestoresTheNarrowKeysBounded |
| AC-11 | The migration's version prefix equals the 14 digits of its filename | TestCMRImageCompositeFK_VersionMatchesItsFilename |
| AC-12 | The foreign-image child test asserts the refusal naming the child-side key, and its read-assertion body is gone | TestContainerManifestRelationshipStore_ForeignImageChildEdgeRefused, renamed from TestContainerManifestRelationshipStore_ListChildrenByParentIDs_ForeignImageChildServed |
| AC-13 | The equal-digest test asserts the refusal naming the parent-side key, and its doc comment states the schema refuses the only form that reached the ordering's trailing key, which stays as a deterministic cursor | TestContainerManifestRelationshipStore_EqualDigestsAcrossImagesRefused, renamed from TestContainerManifestRelationshipStore_ListChildrenByParentIDs_EqualDigestsBreakOnTheChildID |
| AC-14 | The reaper test and its raw-insert helper assert the refusal, and the helper's comment no longer claims nothing holds the three ids together | TestContainerReaper_CrossImageRelationshipRefused, renamed from TestContainerReaper_RootManifestPage_ChildOfAnotherImage, through assertCrossImageEdgeRefused, which replaced seedCrossImageRelationship and the branch's own assertRawChildEdgeRefused. TestContainerReaper_RootManifestPage_ExcludesAReferencedChild is new and keeps the direct assertion on the page's own output, which the cross-image premise no longer carries |
| AC-15 | The Up, by static parse: one ON ONLY parent build, 64 CREATE UNIQUE INDEX CONCURRENTLY leaf builds each preceded by its own guard, every build before the first ATTACH PARTITION, and both ADD CONSTRAINT after the last |
TestCMRImageCompositeFK_UpLayoutIsRecoverable |
| AC-16 | mapCreateCMRError still maps a parent-side violation to ErrParentManifestMissing and a child-side one to ErrChildManifestMissing, and the two existing subtests pass without edits |
TestContainerManifestRelationshipStore_Create/missing_parent_surfaces_ErrParentManifestMissing, TestContainerManifestRelationshipStore_Create/missing_child_surfaces_ErrChildManifestMissing, both unedited |
| n/a | Not an acceptance criterion. rootManifestPage returns the image's manifests that no relationship row names as a child, which this change does not alter. Restored coverage: the flip above removed the suite's only direct call to the method |
TestContainerReaper_RootManifestPage_ExcludesAReferencedChild, new, and green at this phase by design |
| n/a | Not an acceptance criterion. mapCreateCMRError matches its two needles as substrings, so it needs exactly one key per needle and no key carrying both. The comment above the switch claimed that and nothing asserted it |
TestCMRImageCompositeFK_OneKeyPerErrorNeedle (one subtest per needle), new |
Related to #1339 (closed)