feat(oci): container_remote_tags schema (S16 Step 2c)

What

Adds the container_remote_tags table — a cached tag under a remote-image entry, mapping the tag name to the manifest it resolved to and carrying the S13 freshness columns (upstream_checked_at, upstream_etag) that stale-tag revalidation reads — as the second 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: nothing reads or writes this table until the cache-lookup and serve steps later in S16.

That does not make it cheap to undo. The 64 partition DROPs in the Down path take ACCESS EXCLUSIVE on only the partition and its parent — a pg_locks sample quoted in the migration's Down comment shows zero locks on any FK target from a partition DROP — but the final parent DROP reaches namespaces, container_remote_images, and container_remote_manifests, plus each of the latter two's 64 partitions, with ACCESS EXCLUSIVE, which blocks reads as well as writes. Plan a rollback as a single brief stall across those parents, not 65 of them.

Step 1 (container_remote_repositories, container_remote_images, container_remote_manifests) and 2a (container_remote_blobs) are merged. This MR is scoped to container_remote_tags alone.

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

Schema

PARTITION BY HASH (namespace_id) × 64, PK (id, namespace_id), composite FKs to container_remote_images(id, namespace_id) and container_remote_manifests(id, namespace_id), and an FK to namespaces — all ON DELETE NO ACTION. name carries CHECK (char_length(name) >= 1 AND char_length(name) <= 255); upstream_etag is nullable with the same 255 cap plus a CHECK refusing every C0 control byte and DEL, because the stored ETag is replayed into the outbound If-None-Match header during revalidation, so a hostile upstream could otherwise inject headers into a later request; upstream_checked_at is NOT NULL DEFAULT NOW(), and the schema suite pins that default from the catalog. Exactly two indexes: the unique (namespace_id, container_remote_image_id, name)non-partial, unlike the blob and manifest partial uniques, because this table has no soft_deleted_at — and the (namespace_id, container_remote_manifest_id) FK-coverage index. The unique index doubles as image-FK coverage. Every column, constraint, and index decision is documented at the statement it governs in the migration.

Four columns the sibling tables carry are deliberately absent, across three absence tests — created_at/updated_at share one, soft_deleted_at and last_downloaded_at have their own — so each absence is attributable rather than resting only on the exact-column-count assertion.

Two of the CHECKs are corrections to ADR-007 as published: the name lower bound (the ADR gives NOT NULL, limit 255 alone) and the upstream_etag control-character CHECK (the ADR gives nullable, limit 255 with no CHECK). It refuses every C0 control and DEL, matching check_maven_remote_files_upstream_etag_no_ctl, because net/http refuses every byte below 0x20 except tab, plus 0x7f, on an outbound header value: a CR/LF-only bound would admit a stored ETag that then fails the If-None-Match round trip, pinning the tag into permanent revalidation failure. Both are recorded in the spec's ADR-007 amendment list and tracked in the CHECK-constraints item of #30; the ADR is synced from the handbook repo and is read-only here.

Merge order

Step 2's order is 2a → 2c → 2b, authored in that order too, not merely merged: a goose timestamp is fixed when the file is authored and goose.WithAllowOutofOrder(false) refuses a pending migration whose version sits below the applied head, so 2b's migration is deliberately not authored yet. This file is stamped 20260812150300. main's newest migration is 20260812150200 and lint:migration-ordering requires strictly greater, so the branch is rebased onto current main and the migration re-stamped from the 20260812140200 it was authored with: three migrations landed on main above 2a's 20260812140100 after this branch first opened. structure.sql is regenerated against the rebased migration set rather than left to Git's textual merge of two independently generated dumps, which is not necessarily the artifact mise run db:structure produces on the merged set and which neither db:structure-check nor lint:migration-ordering would catch.

2b's migration takes a version above this one when it is authored, which is what keeps the 2c-before-2b order enforced by goose rather than by convention.

Validation follow-ups applied

A five-category pre-push validation pass (/validate-step) surfaced two documentation defects, both fixed on this branch:

The plan's ADR-007 reconciliation bullet cited "#30, items 6–11" while the spec's amendment note — added on this same branch — directs citing the issue without item numbers, because the numbering moves as amendments land. The plan now cites the issue alone.

The spec described #30 as carrying "the seven amendments above as its items 6 to 12", but #30 had no entry for the container_remote_manifests.media_type lower bound the merged manifests migration ships. #30's CHECK-constraints item now records it (seven CHECKs, of which four are minimum-length bounds), and the spec's grouping sentence names the media_type bound alongside the name bounds, so the coordinated handbook MR built from #30 will carry the full set.

Both quotations above are the wording on main, so a reviewer diffing this branch against it can find them.

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.

Migration PG 16 PG 17 PG 18
20260812150300_create_container_remote_tags.sql OK (906.13ms / 414.9ms) OK (392.42ms / 391.32ms) OK (985.66ms / 664.93ms)

Migration notes:

  • Every apply and rollback lands under a second on all three versions, none crossing the 1s empty-DB threshold. The work is 64 empty-partition CREATEs touching no data, so a production apply matches the empty-DB shape modulo lock waits — well inside the 5-minute boot budget.

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

No EXPLAIN evidence ships in the suites, unlike the blob MR's reverse-lookup index: no store or query-layer code queries this table yet. The schema suite's header comment gives the reason and says plan evidence follows the first real query against the table rather than the schema that anticipates it. The reason is not that pruning differs here — namespace_id is the only pruning key any index on a hash-partitioned table has, this table's two included. It is that the sibling tests pin an ordered keyset plan for an ORDER BY ... LIMIT, and both indexes here serve equality lookups, so there is no plan shape for an EXPLAIN to hold in place.

On the size of this MR

Several times the 500 LOC guideline in docs/dev/development-model.md, matching the merged Step 1 and 2a MRs. Measured with one rule across both halves (added lines, blanks and comment-only lines stripped):

Part LOC
Migration SQL 216
— of which mechanical 64-partition DDL 193
Integration suites (schema, constraints, fixtures) + token and checksum bumps 804
Total 1,020

The plan's dependency-summary table carries the same 1,020 measurement for this MR. structure.sql (+1,886) and the go-jet artifacts (+121) are generated and excluded; the small plan and spec edits ride along.

The novel hand-written DDL is ~23 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 65 locks in one transaction, which the per-statement layout avoids). The bulk is the two integration suites. 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

The commits are ordered for review and the split is meaningful:

  1. test(oci): container_remote_tags schema tests (S16 Step 2c) — the suites, written first
  2. feat(oci): create container_remote_tags — the migration they constrain
  3. refactor: simplify container_remote_tags test suites — code-simplifier pass
  4. docs(specs): record the name lower bounds as an ADR-007 amendment
  5. test(oci): pin container_remote_tags.upstream_checked_at DEFAULT value
  6. the final docs: commits — the ADR-007 tracking corrections and the #30 citation fix described in ## Validation follow-ups applied

End-to-end scenario catalogs

No scenario added or invalidated. This step ships schema only: nothing reads or writes container_remote_tags until the cache-lookup and 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 Radamanthus Batnag

Merge request reports

Loading
Loading