feat(oci): container_remote_manifests schema (S16 Step 1c)

What

Adds the container_remote_manifests table — a cached manifest under a remote-image entry — as the third and last of Step 1's three MRs in the S16 container remote vertical slice. 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: nothing reads or writes this table until the cache-fill and cache-hit serve steps later in S16.

That does not make it cheap to undo. The parent DROP in the Down path takes ACCESS EXCLUSIVE on namespaces, container_remote_images, blob_storage_attachments, blob_storage_blobs and the 64 partitions of each of the latter three, and ACCESS EXCLUSIVE blocks reads as well as writes. The 64 partition DROPs that precede it take no lock on any FK target (see the Down comment in the migration), so plan a rollback as a single brief stall across the blob-storage layer, not a no-op.

container_remote_repositories (1a) and container_remote_images (1b) are already merged. This MR is scoped to container_remote_manifests alone.

Spec: docs/specs/S16-container-remote.md#container_remote_manifests. Plan: docs/plans/2026-07-30-container-remote.md (Step 1, sub-MR 1c).

Schema

PARTITION BY HASH (namespace_id) × 64, PK (id, namespace_id) on application-generated UUIDv7, a composite FK to container_remote_images(id, namespace_id) and two composite FKs into blob_storage_attachments/blob_storage_blobs (content-addressed storage), all ON DELETE NO ACTION. Six indexes: a partial unique digest index (WHERE soft_deleted_at IS NULL), a retention index, an FK-coverage index each for the two blob-storage references, and two namespace-wide scan indexes (trash listing, chronological). Every column, constraint, and index decision is documented at the statement it governs in the migration.

Review follow-ups applied

All six columns the schema guards with a CHECK or a composite FK now carry an insert-based not_null_violation test: digest, blob_sha256, media_type and size, because a CHECK is not false for NULL, plus container_remote_image_id and blob_storage_attachment_id, because neither composite FK declares MATCH FULL and a MATCH SIMPLE reference with a NULL column is not checked at all.

The two namespace-wide scan indexes (trash listing, chronological) now carry EXPLAIN (ANALYZE, BUFFERS) evidence. Both pruning tests seed 5,000 rows, a quarter of them soft-deleted, and assert the plan rides the intended index in a forward scan with no post-scan Sort, resolving child index names from the catalog. At one seeded row the planner chose a sequential scan and sorted afterwards, so the earlier versions of those tests passed with their CREATE INDEX statements deleted.

Database Review Evidence

Collected from pipeline 2746717209 on PostgreSQL 17 against an empty database, all three matrix jobs green:

Phase Duration
Apply 340.6 ms
Rollback 247.1 ms
Re-apply 449.3 ms

Full output is in the migration-review.log artifacts of jobs 15804256181, 15804256182, and 15804256183.

The table and its 64 partitions are created empty, so no phase touches existing rows. The lock exposure, which the timings do not show, is the one in ## What above.

The retention index still has no such evidence, because no store/query-layer code queries this table yet. That evidence belongs to the MR that adds the first real query against it.

On the size of this MR

This MR is several times the 500 LOC guideline in docs/dev/development-model.md, matching the shape of the already-merged Step 1b MR (container_remote_images, !1323 (merged)). About half the migration is the mechanical 64 CREATE TABLE ... PARTITION OF / 65 DROP TABLE statements (CONCURRENTLY is unavailable on a partitioned parent, and batching them into a DO $$ ... LOOP would accumulate all 65 locks in one transaction, which the per-statement layout avoids). structure.sql and the three jet files are generated and excluded from the reviewable count. Most of the rest is the constraints and schema-shape integration suites, doc-commented by the conventions this package already follows.

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.

End-to-end scenario catalogs

No scenario added or invalidated. This step ships schema only: nothing reads or writes container_remote_manifests until the cache-fill and cache-hit serve 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

Edited by Sylvia Shen

Merge request reports

Loading
Loading