feat(oci): container_remote_manifest_relationships schema (S16 Step 2b)
What
Adds the container_remote_manifest_relationships table — the edge table
linking a cached manifest list or OCI index to the platform-specific manifests
it lists — as the last of Step 2's three MRs in the S16 container remote
vertical slice, in its 2a → 2c → 2b order. One goose migration, the
regenerated jet types and structure.sql dump, and two integration suites
asserting the schema shape and every constraint's accept and reject paths.
No Go production code and no behavior change: the table ships with no reader or writer, which is the intended state — a lazy cache fill stores the parent before any child has been fetched, so nothing links an index's children yet. Defining the mechanism that populates these rows is a spec follow-up tracked in #264.
Step 1 (container_remote_repositories, container_remote_images,
container_remote_manifests), 2a (container_remote_blobs), and 2c
(container_remote_tags, !1503 (merged)) are all merged, so the merge order this MR
requires is already satisfied — see ## Merge order.
Spec: docs/specs/S16-container-remote.md#container_remote_manifest_relationships.
Plan: docs/plans/2026-07-30-container-remote.md (Step 2, sub-MR 2b).
Schema
PARTITION BY HASH (namespace_id) × 64, PK (id, namespace_id). Five uuid
columns, all NOT NULL. Four FKs across three parents — namespaces, the
composite (id, namespace_id) into container_remote_images, and two
composites into container_remote_manifests for the parent and child ends of
the edge — the only table in the family whose FK set names one parent twice.
All four are MATCH SIMPLE and ON DELETE NO ACTION, so a manifest at
either end of a live edge cannot be hard-deleted, and a soft-deleted one
still blocks.
Three indexes, all non-partial: the unique (namespace_id, parent_container_remote_manifest_id, child_container_remote_manifest_id), a
child lookup (namespace_id, child_container_remote_manifest_id), and an
image-scoped (namespace_id, container_remote_image_id). Each leads with the
columns one composite FK's referential-integrity check has to descend, which
is why the table carries no separate FK-coverage index the way
container_remote_blobs does. The unique index is scoped to the pair rather
than to the grouping image, unlike the blobs and tags unique indexes: a
second edge between the same two manifests under a different image is a row
nothing can resolve, since a manifest is already reachable from exactly one
image through its own FK. Both directions of that inversion carry their own
pinning tests.
No CHECK constraints and no timestamp columns of any kind: all five columns
are uuids, so the type is the whole domain rule, and an edge is dated by the
parent manifest it derives from. Each absence has its own attributable test
rather than resting only on the exact-column-count assertion. A
self-referencing edge is insertable by design — hosted
container_manifest_relationships accepts one too.
Index and constraint names abbreviate the table to crmr: the table name
alone is 39 of an identifier's 63 characters, and the unique index spelled
out in full is 89. The hosted registry hits the same wall and answers it with
cmr, which this schema already uses, so crmr is that initialism with the
r for remote inserted.
Merge order
Depended on !1503 (merged) (2c), which has merged. A blocking-MR relationship on
!1503 (merged) is set on this MR, and Step 2's order is 2a → 2c → 2b. 2c took
20260812150300 and merged, which settled the half that
goose.WithAllowOutofOrder(false) makes hard: had 2b merged first, every
database that applied 2b's migration would afterwards refuse 2c (the deploy
provider reports detected 1 missing (out-of-order) migration lower than database version (<2b's version>)), and no later migration would apply until
2c was renumbered by hand. The plan's Step 2 section records the reservation
and the failure in both directions.
This migration is stamped 20260814150300, and that number is not stable
while the MR is open. lint:migration-ordering enforces a floor against the
target branch, so each time main takes a migration sorting above this file it
is re-stamped above the new floor — currently above main's
20260814131541_make_container_images_name_unique_index_partial.sql. Only
internal/datastore/migrations/migrations_checksum_test.go (the comment plus
knownHeadVersion) moves with it; structure.sql carries no version list, so
a re-stamp needs no re-dump.
Spec and plan contradictions surfaced while authoring
Two points where the documents disagree at something this MR touches; per the plan's convention both are recorded rather than silently resolved:
- The spec's Naming Conventions index-name pattern is arithmetically
impossible for this table — spelled out in full, the unique index is 89
characters against PostgreSQL's 63. The migration follows the hosted
precedent (
cmrthere,crmrhere) and documents the arithmetic at each renamed identifier. - The spec's acceptance-criterion numbering moved after the plan was written: the criterion the plan's ownership row calls 41 is 43 in the spec as merged (and 42 is 44). The criterion itself — caching a manifest list writes zero relationship rows — is owned by Step 8, and its only schema half is this table existing to be left empty.
Database Review Evidence
The table and its 64 partitions are created empty, so no phase touches
existing rows. Lock behavior was measured on PostgreSQL 17.10 against this
table rather than a stand-in, and the numbers live at the statements they
govern: each partition CREATE takes ShareRowExclusive on 131 relations
across the three FK targets (namespaces, plus 65 each for the two
hash-partitioned parents — declaring container_remote_manifests twice
widens neither the relation set nor the mode), a bounded write stall that
admits reads. The 64 partition DROPs in Down take no lock on any FK target;
the final parent DROP reaches the same 131 relations at ACCESS EXCLUSIVE,
which blocks reads too — plan a rollback as one brief stall across those
parents, not 65 of them. No migration in this repo sets lock_timeout; that
schema-wide decision is tracked in
#548 and
this file does not re-open it.
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.
| Migration | PG 16 | PG 17 | PG 18 |
|---|---|---|---|
20260814150300_create_container_remote_manifest_relationships.sql |
OK (765.93ms / 391.39ms) | OK (385.69ms / 369.45ms) | OK (868.9ms / 788.26ms) |
Migration notes:
- The spread across PG versions is run-to-run noise rather than a property of any one version, so the row is not a version comparison. This pipeline has PG 18 rolling back in 788.26ms against PG 16's 391.39ms, a ratio just past 2x. The previous pipeline on this branch (2755758420) ran the same DDL at 1.38s / 547.83ms on PG 18 and 653.37ms / 371.53ms on PG 16 — apply on PG 18 moved by more than 500ms between two runs of identical DDL, and the rollback ratio fell to 1.4x. Neither the 2x ratio here nor the 1.38s apply there reproduces.
- Every measured apply and rollback sits far inside the 5-minute per-migration boot budget, the slowest observation across both pipelines being 1.38s on an empty database. Apply costing more than rollback is the expected direction for this migration: Up creates the parent, 64 hash partitions, and their index clones, while Down drops them.
No EXPLAIN evidence ships in the suites: no store or query-layer code
queries this table yet. The schema suite's header says so and names where
that evidence belongs — the MR that adds the first real query against the
table.
On the size of this MR
Above the 500 LOC guideline in docs/dev/development-model.md, matching the
merged Step 1 and 2a MRs and the open 2c. Measured with the plan's counting
rule (added lines in hand-written .go and .sql files, blanks and
comment-only lines dropped, -- +goose directives kept):
| Part | LOC |
|---|---|
| Migration SQL | 213 |
| — of which mechanical 64-partition DDL | 192 |
| Integration suites (schema, constraints, fixtures) + token and checksum bumps | 668 |
| Total | 881 |
structure.sql (+1,822) and the go-jet artifacts (+111) are generated and
excluded; the small plan edits ride along. The novel hand-written DDL is ~21
lines once the partition statements come out (CONCURRENTLY is unavailable
on a partitioned parent, and batching the partitions into a DO $$ ... LOOP
would accumulate all the locks in one transaction, which the per-statement
layout avoids). The bulk is the two integration suites, and splitting further
would mean splitting one table's schema from its own tests; the plan's shape
is one table per MR, and this is that table.
Suggested reading order
By file, since the branch squashes on merge:
internal/datastore/migrations/sql/20260814150300_create_container_remote_manifest_relationships.sql— the table, with every column, constraint, index, and lock decision documented at the statement it governscontainer_remote_manifest_relationships_schema_integration_test.go— catalog shape, the named column-absence groups, all three index shapes, partition inheritance and routingcontainer_remote_manifest_relationships_constraints_integration_test.go— NOT NULL backstops, the unique pair index in all four of its directions, all four FKs including both cross-namespace halves of the manifest FKs, and delete blocks from either end of an edge and from the grouping imagecontainer_remote_test_helpers_test.goplus the one-linecontainerRemoteMigrationTokensaddition — the shared fixtures, and the token that folds this migration into the existing static Down-walk tests
End-to-end scenario catalogs
No scenario added or invalidated. This step ships schema only: nothing reads
or writes container_remote_manifest_relationships until later steps land,
so there is no observable client behavior for a scenario to cover. Both
container catalogs (docs/testing/e2e/oci.md, docs/testing/e2e/docker.md)
already list remote repositories as out of scope until the read paths ship.
Related to #288