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:
test(oci): container_remote_tags schema tests (S16 Step 2c)— the suites, written firstfeat(oci): create container_remote_tags— the migration they constrainrefactor: simplify container_remote_tags test suites— code-simplifier passdocs(specs): record the name lower bounds as an ADR-007 amendmenttest(oci): pin container_remote_tags.upstream_checked_at DEFAULT value- 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