feat(datastore): add subject_id backfill trigger and index

What

Adds the schema that keeps subject_id linked no matter the order in which a referrer and its subject are pushed (#2376, the migrations half of the split-out from the original combined MR):

  • resolve_manifest_subject_id: a BEFORE INSERT ON manifests trigger that resolves a new manifest's own subject_id from its subject_digest when the subject already exists in the same repository. It runs inside the INSERT, so it sees a committed subject even if the application observed it as absent at lookup time.
  • backfill_manifest_subject_id: an AFTER INSERT ON manifests trigger that sets subject_id = NEW.id for referrers in the same repository whose subject_digest = NEW.digest and subject_id IS NULL. Atomic with the insert; covers all creation paths.
  • A partial index (WHERE subject_digest IS NOT NULL AND subject_id IS NULL) on (top_level_namespace_id, repository_id, subject_digest), built with the batched per-partition CREATE INDEX CONCURRENTLY rollout (testing variant, four batches, then the parent attach), mirroring the subject_id index, to avoid a long write-blocking lock on manifests.

Why two triggers

Tolerating a missing subject lets a referrer be pushed before its subject (buildkit --attest pushes them concurrently). The link can then fall on either side of the push order, so both directions need handling:

  • Subject already present when the referrer is inserted. The BEFORE INSERT trigger resolves NEW.subject_id from NEW.subject_digest. This matters even though the handler also resolves subject_id: the handler's lookup and the referrer's insert are separate autocommit statements, so a subject inserted in between would otherwise be missed and the referrer stored with subject_id NULL for good. Re-pushing the subject does not repair it either, because that hits ON CONFLICT DO NOTHING and never fires the AFTER INSERT trigger. Running the lookup inside the insert closes that deterministic window.
  • Subject inserted after the referrer. The AFTER INSERT trigger sets subject_id = NEW.id for any orphan referrers that named this manifest as their subject.

Triggers (rather than application code or read-time resolution) are used because:

  1. They are atomic with the insert, so there is no window where a just-created subject looks dangling before its referrers are linked (relevant to online GC's IsDangling, which treats a manifest as referenced when some row's subject_id points at it).
  2. They cover all manifest-creation paths (image, index, buildkit index) without duplicating the logic in each.
  3. dbPutManifestV2 currently creates the manifest on the primary without a wrapping transaction, so an application-layer version would first require restructuring that path.

A truly simultaneous push, where both transactions are still uncommitted when each runs its trigger, is not covered here. That residual window needs read-time resolution or a reconciliation pass. It is acceptable while REGISTRY_FF_TOLERATE_MISSING_SUBJECT stays off, and should be settled before the flag is enabled.

Historical backfill still not needed

With REGISTRY_FF_TOLERATE_MISSING_SUBJECT off until the column and write-path deploy, every referrer already in the DB has its subject_id resolved at push time. Nothing historical needs repair; orphan referrers only appear once the flag is on.

Notes

  • The subject_digest column (!2928 (merged)) has merged; this MR targets master.
  • The trigger migration sorts after the index migration (...120200), so the AFTER INSERT UPDATE is served by the partial index from the moment it starts firing rather than heap-scanning the repository during the concurrent index build.
  • Nothing writes subject_digest yet, so the triggers are inert until the write-path lands. Their behavior is covered by an integration test in the handling MR (which adds the write-path/plumbing needed to exercise them).
  • structure.sql regenerated with pg_dump 17 + pgFormatter 5.0; migrations applied cleanly on Postgres 17.

Index verification

Verified on PostgreSQL 17 with 100,500 manifests in one repository: 100,000 regular manifests (no subject) plus 500 orphan referrers (subject_digest set, subject_id NULL). Both the trigger's backfill lookup and the equivalent UPDATE use the partial index, not a sequential scan:

Index Scan using index_manifests_p_56_on_ns_id_and_repo_id_and_subject_digest
  Index Cond: (top_level_namespace_id = 1 AND repository_id = 1 AND subject_digest = '\x…')
  actual time ~0.05 ms

The partial predicate (WHERE subject_digest IS NOT NULL AND subject_id IS NULL) keeps the index small no matter how many manifests have a NULL subject_id (which is most of them, since regular images carry no subject):

  • partition rows: 100,500
  • index entries: ~500 (the orphan referrers only), 56 kB

A control query over the regular rows (subject_digest IS NULL) sequentially scans, confirming those rows are correctly excluded from the index.

Part of #2376.

Edited by Stan Hu

Merge request reports

Loading
Loading