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: aBEFORE INSERT ON manifeststrigger that resolves a new manifest's ownsubject_idfrom itssubject_digestwhen the subject already exists in the same repository. It runs inside theINSERT, so it sees a committed subject even if the application observed it as absent at lookup time.backfill_manifest_subject_id: anAFTER INSERT ON manifeststrigger that setssubject_id = NEW.idfor referrers in the same repository whosesubject_digest = NEW.digestandsubject_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-partitionCREATE INDEX CONCURRENTLYrollout (testing variant, four batches, then the parent attach), mirroring thesubject_idindex, to avoid a long write-blocking lock onmanifests.
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 INSERTtrigger resolvesNEW.subject_idfromNEW.subject_digest. This matters even though the handler also resolvessubject_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 withsubject_idNULL for good. Re-pushing the subject does not repair it either, because that hitsON CONFLICT DO NOTHINGand never fires theAFTER INSERTtrigger. Running the lookup inside the insert closes that deterministic window. - Subject inserted after the referrer. The
AFTER INSERTtrigger setssubject_id = NEW.idfor any orphan referrers that named this manifest as their subject.
Triggers (rather than application code or read-time resolution) are used because:
- 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'ssubject_idpoints at it). - They cover all manifest-creation paths (image, index, buildkit index) without duplicating the logic in each.
dbPutManifestV2currently 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_digestcolumn (!2928 (merged)) has merged; this MR targetsmaster. - The trigger migration sorts after the index migration (
...120200), so theAFTER INSERTUPDATEis 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_digestyet, 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.sqlregenerated 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 msThe 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.