Summary
buildpack_lifecycle_data and cnb_lifecycle_data rows can be shared between a build and a droplet: during staging a single row ends up with both build_guid and droplet_guid set, pointing at two live parents. Both parents declare an app-level :destroy dependency on that row.
This document describes the issue, inventories the current delete associations and DB foreign keys, and lays out two distinct ways forward.
The issue
How a row becomes shared
The buildpack/CNB staging path creates one lifecycle_data row and attaches it to both parents:
- A build is created; its lifecycle_data row is created owned by the build (
build_guid set).
DropletCreate#find_or_create_buildpack_droplet creates the droplet and attaches the build's same row to the droplet by setting droplet_guid on it.
The result is one row with build_guid and droplet_guid both set, both referencing live parents.
Aside: requested vs. detected buildpacks
The build's lifecycle_data holds the requested buildpacks (what the user asked for at build time; empty = auto-detect). When staging finishes, StagingCompletionHandler#save_staging_result writes the detected buildpacks (what staging actually resolved) via droplet.buildpack_lifecycle_data.buildpacks = …. Because build and droplet currently share one row, that write overwrites the requested value - after staging, the (shared) row holds detected, not requested buildpacks. This is a subtle semantic difference, not treated as a bug today.
Why deletion leaves a dangling reference
Both parents remove the row on their own destruction, unconditionally:
BuildModel: add_association_dependencies buildpack_lifecycle_data: :destroy, cnb_lifecycle_data: :destroy
DropletModel: add_association_dependencies buildpack_lifecycle_data: :destroy, cnb_lifecycle_data: :destroy
add_association_dependencies … :destroy is an app-level hook (Sequel), not a DB constraint. When the droplet is destroyed it destroys the shared row - even though the build still references it - so the build is left with a dangling reference (build.buildpack_lifecycle_data → nil). The same happens in reverse: destroying the build destroys the shared row and leaves the still-live droplet with a dangling reference.
All deletion paths for a shared row
A shared row (build_guid and droplet_guid both set, both live) can be removed by any of the following. A live parent is left with a dangling reference whenever the row is destroyed while that parent still exists - the parent survives but its lifecycle_data is gone.
| Trigger |
Mechanism |
Effect on the shared row |
Dangling reference on a live parent? |
Delete the build (BuildDelete) |
DB ON DELETE CASCADE via build_guid FK and app-level :destroy on BuildModel |
Row destroyed (DB cascade fires regardless of the Ruby hook) |
Yes - the still-live droplet dangles (droplet.buildpack_lifecycle_data → nil) |
Delete the droplet (DropletDelete) |
App-level :destroy on DropletModel |
Row destroyed |
Yes - the still-live build dangles (build.buildpack_lifecycle_data → nil) |
Delete the app (AppDelete#delete_subresources) |
Deletes builds then droplets in order (BuildDelete, then DropletDelete) |
Row destroyed when the build is deleted (first) |
No lasting dangle - the droplet is deleted immediately after; but the build-delete-dangles-droplet mechanism is exercised here mid-transaction |
| Delete the app's own lifecycle_data |
App owns a separate app_guid-keyed row, not the shared one |
Shared row untouched |
N/A - app-owned rows are single-parent and not involved in sharing |
The critical asymmetry: both the build-delete and the droplet-delete paths leave the other live parent dangling. The droplet side has no DB FK (relies on the Ruby :destroy hook); the build side has an ON DELETE CASCADE FK, so it destroys the row at the database level - which no app-level fix can intercept. Any solution that keeps rows shared must reconcile both directions.
Inventory: delete associations
add_association_dependencies is app-level (fires in Ruby on Model#destroy); it is not enforced by the database and is bypassed by raw dataset deletes (Model.where(...).delete).
| Parent model |
Association |
Target row |
Dependency (app-level) |
BuildModel |
buildpack_lifecycle_data |
buildpack_lifecycle_data |
:destroy |
BuildModel |
cnb_lifecycle_data |
cnb_lifecycle_data |
:destroy |
DropletModel |
buildpack_lifecycle_data |
buildpack_lifecycle_data |
:destroy |
DropletModel |
cnb_lifecycle_data |
cnb_lifecycle_data |
:destroy |
*LifecycleDataModel |
buildpack_lifecycle_buildpacks |
child buildpack rows |
:destroy |
Back-navigation (lifecycle_data.build / .droplet / .app) exists as many_to_one readers but is not used by production code to drive deletes.
The lifecycle_data readers on both parents already tolerate a missing row (buildpack_lifecycle_data || BuildpackLifecycleDataModel.new.freeze), with the comment "The lifecycle_data row can be destroyed independently of this" - i.e. the codebase already assumes the row's lifetime is decoupled from any single parent.
Inventory: DB foreign keys on the lifecycle_data tables
Owner columns are app_guid, build_guid, droplet_guid on both tables.
| Table |
Column |
SQL foreign key? |
on_delete |
buildpack_lifecycle_data |
build_guid |
Yes - fk_buildpack_lifecycle_build_guid → builds.guid |
cascade |
buildpack_lifecycle_data |
droplet_guid |
No - index only |
- |
buildpack_lifecycle_data |
app_guid |
No - index only |
- |
cnb_lifecycle_data |
build_guid |
Yes - fk_cnb_lifecycle_build_guid → builds.guid |
cascade |
cnb_lifecycle_data |
droplet_guid |
No - index only |
- |
cnb_lifecycle_data |
app_guid |
No - index only |
- |
buildpack_lifecycle_buildpacks |
buildpack_lifecycle_data_guid |
Yes - fk_blbuildpack_bldata_guid → buildpack_lifecycle_data.guid |
cascade |
buildpack_lifecycle_buildpacks |
cnb_lifecycle_data_guid |
Yes - fk_blcnb_bldata_guid → cnb_lifecycle_data.guid |
cascade |
Key asymmetry: build_guid is DB-enforced with cascade on both tables, but droplet_guid is not - only an index. So at the DB level, deleting a build cascades to a build-owned row, but deleting a droplet relies entirely on the app-level :destroy hook.
Two ways forward
Both keep both delete orders safe (the hard requirement). They differ in whether they eliminate row-sharing or merely tolerate it, and in how much migration/backfill they require.
Option A - Stop sharing: droplet gets its own copy + cascade FK
Each parent owns its own single-parent row. The staging path copies the build's lifecycle_data onto a new droplet-owned row instead of attaching the build's row. A cascade FK on droplet_guid becomes the DB-level backstop.
Changes
- Forward fix (
app/actions/droplet_create.rb) - find_or_create_buildpack_droplet copies stack, buildpacks (rebuilding the child buildpack_lifecycle_buildpacks rows), and - for CNB - the encrypted registry_credentials_json, onto a new droplet-owned row. Uses the create(..., droplet:) association-arg form to avoid a MySQL concurrent-insert deadlock (mirrors DropletCopy).
- Model
validate - reject any row with more than one owner column set ([app_guid, build_guid, droplet_guid].compact.length > 1). Single-parent becomes an invariant the model refuses to violate.
- Migration - add
on_delete: :cascade FK on droplet_guid for both tables, with MySQL collation alignment; delete rows whose droplet_guid points at a now-deleted droplet (NULL-guarded so app/build-owned rows survive).
- Backfill - for every pre-existing shared row (
build_guid and droplet_guid both set on a live pair), split it: create a droplet-owned copy and clear droplet_guid on the build's row.
Order within the migration: backfill (split shared rows) → delete rows with a dangling droplet_guid → MySQL collation alignment → add the droplet_guid cascade FK. The FK must be added last, once every row is single-owned.
Pros
- Eliminates the shared-row design entirely; every row has exactly one owner.
- DB-enforced backstop: a raw dataset delete or future migration cannot silently reintroduce a dangling reference - the FK guarantees droplet-owned rows die with their droplet and nothing else.
- Side-effect: because the droplet now has its own row, the staging-completion write lands on the droplet's copy - build's row keeps requested buildpacks, droplet's row holds detected. No production reader depends on this either way; it's a semantic tidy-up, not a requirement.
Cons
- Largest change. Copy logic + validate + migration + a data backfill of existing shared rows, in a specific order.
- Backfill risk. Copying inside a migration bypasses the model layer; CNB's encrypted
registry_credentials_json must be copied as raw ciphertext + salt (not re-encrypted). Backfill cost scales with the number of existing shared rows in production CCDBs.
- Multi-step, order-dependent migration; must be idempotent.
Option B - Tolerate sharing: set null FKs + destroy only when no parent remains
Keep rows shareable. Instead of unconditionally destroying the row when a parent is deleted, let the database null out the departing parent's guid and only destroy the row once no owner is left. No copy, no backfill.
Changes
- FKs to
set null - change the existing build_guid FKs (both tables) from on_delete: :cascade to on_delete: :set null, and add a droplet_guid FK also with on_delete: :set null. Deleting either parent then clears only its own guid column at the DB level; the other owner and the row survive.
- Remove the unconditional
:destroy dependencies on BuildModel / DropletModel for buildpack_lifecycle_data / cnb_lifecycle_data - the set null FK now handles detaching this parent. No model hooks are added.
- Reap ownerless rows with a background job. With the
:destroy hooks gone, a row whose last parent is deleted is left with all owner columns null and would leak. Add a periodic cleanup job (same pattern as pending_build_cleanup / orphaned_blobs_cleanup, registered in the clock scheduler) that deletes *_lifecycle_data rows where app_guid, build_guid, and droplet_guid are all null.
- Migration - flip the two
build_guid FKs to set null, add the droplet_guid set null FK (with MySQL collation alignment). No shared-row split needed - set null tolerates the existing two-owner rows.
Pros
- No data backfill. Existing shared rows keep working;
set null handles them on delete. Migration only alters FK definitions.
- No model hooks / no delete-path changes. Detaching is DB-enforced; the only new code is a standalone cleanup job, independent of the delete flows.
- Symmetric by construction - build-delete and droplet-delete both just clear their own guid at the DB level.
- Both delete orders safe.
Cons
- Ownerless rows exist transiently. Between a parent's deletion and the next cleanup-job run, a fully-detached row sits in the table. Harmless (no live parent references it) but not immediately reclaimed.
- Still a schema change (FK redefinition on three columns), though no data migration.
Side-effect: shared rows persist, so the staging-completion write still lands on the shared row - build.lifecycle_data.buildpacks continues to reflect detected rather than requested (same as today). Unlike Option A, the requested/detected separation is not gained. No production reader depends on it.
Comparison
| Dimension |
Option A (copy + cascade FK) |
Option B (set null FKs) |
| Eliminates row sharing |
Yes |
No (tolerates it) |
| Schema change / migration |
Yes |
Yes (FK redefinition only) |
| Data backfill of existing rows |
Required |
Not needed |
| Detach-on-parent-delete enforced by |
DB cascade FK |
DB set null FK |
| Ownerless-row cleanup |
N/A (single owner) |
Background cleanup job |
| Fixes existing shared rows |
Via backfill |
Via set null on delete |
| Change size / steps |
Large, multi-step |
Moderate |
| Requested vs. detected buildpacks separated |
Yes |
No |
| Model hooks / delete-path changes |
Forward-copy in staging |
None (job is standalone) |
Recommendation
- If DB-enforced correctness and eliminating the shared-row design are the priority, Option A - accept the migration + backfill cost.
- If avoiding the data backfill is the priority and keeping shared rows is acceptable, Option B - the
set null FKs make both delete directions safe at the DB level with no delete-path or model-hook changes, at the cost of a periodic background job that reaps ownerless rows.
Summary
buildpack_lifecycle_dataandcnb_lifecycle_datarows can be shared between a build and a droplet: during staging a single row ends up with bothbuild_guidanddroplet_guidset, pointing at two live parents. Both parents declare an app-level:destroydependency on that row.This document describes the issue, inventories the current delete associations and DB foreign keys, and lays out two distinct ways forward.
The issue
How a row becomes shared
The buildpack/CNB staging path creates one lifecycle_data row and attaches it to both parents:
build_guidset).DropletCreate#find_or_create_buildpack_dropletcreates the droplet and attaches the build's same row to the droplet by settingdroplet_guidon it.The result is one row with
build_guidanddroplet_guidboth set, both referencing live parents.Aside: requested vs. detected buildpacks
The build's lifecycle_data holds the requested buildpacks (what the user asked for at build time; empty = auto-detect). When staging finishes,
StagingCompletionHandler#save_staging_resultwrites the detected buildpacks (what staging actually resolved) viadroplet.buildpack_lifecycle_data.buildpacks = …. Because build and droplet currently share one row, that write overwrites the requested value - after staging, the (shared) row holds detected, not requested buildpacks. This is a subtle semantic difference, not treated as a bug today.Why deletion leaves a dangling reference
Both parents remove the row on their own destruction, unconditionally:
BuildModel:add_association_dependencies buildpack_lifecycle_data: :destroy, cnb_lifecycle_data: :destroyDropletModel:add_association_dependencies buildpack_lifecycle_data: :destroy, cnb_lifecycle_data: :destroyadd_association_dependencies … :destroyis an app-level hook (Sequel), not a DB constraint. When the droplet is destroyed it destroys the shared row - even though the build still references it - so the build is left with a dangling reference (build.buildpack_lifecycle_data→nil). The same happens in reverse: destroying the build destroys the shared row and leaves the still-live droplet with a dangling reference.All deletion paths for a shared row
A shared row (
build_guidanddroplet_guidboth set, both live) can be removed by any of the following. A live parent is left with a dangling reference whenever the row is destroyed while that parent still exists - the parent survives but its lifecycle_data is gone.BuildDelete)ON DELETE CASCADEviabuild_guidFK and app-level:destroyonBuildModeldroplet.buildpack_lifecycle_data→nil)DropletDelete):destroyonDropletModelbuild.buildpack_lifecycle_data→nil)AppDelete#delete_subresources)BuildDelete, thenDropletDelete)app_guid-keyed row, not the shared oneThe critical asymmetry: both the build-delete and the droplet-delete paths leave the other live parent dangling. The droplet side has no DB FK (relies on the Ruby
:destroyhook); the build side has anON DELETE CASCADEFK, so it destroys the row at the database level - which no app-level fix can intercept. Any solution that keeps rows shared must reconcile both directions.Inventory: delete associations
add_association_dependenciesis app-level (fires in Ruby onModel#destroy); it is not enforced by the database and is bypassed by raw dataset deletes (Model.where(...).delete).BuildModelbuildpack_lifecycle_databuildpack_lifecycle_data:destroyBuildModelcnb_lifecycle_datacnb_lifecycle_data:destroyDropletModelbuildpack_lifecycle_databuildpack_lifecycle_data:destroyDropletModelcnb_lifecycle_datacnb_lifecycle_data:destroy*LifecycleDataModelbuildpack_lifecycle_buildpacks:destroyBack-navigation (
lifecycle_data.build/.droplet/.app) exists asmany_to_onereaders but is not used by production code to drive deletes.The
lifecycle_datareaders on both parents already tolerate a missing row (buildpack_lifecycle_data || BuildpackLifecycleDataModel.new.freeze), with the comment "The lifecycle_data row can be destroyed independently of this" - i.e. the codebase already assumes the row's lifetime is decoupled from any single parent.Inventory: DB foreign keys on the lifecycle_data tables
Owner columns are
app_guid,build_guid,droplet_guidon both tables.on_deletebuildpack_lifecycle_databuild_guidfk_buildpack_lifecycle_build_guid→builds.guidcascadebuildpack_lifecycle_datadroplet_guidbuildpack_lifecycle_dataapp_guidcnb_lifecycle_databuild_guidfk_cnb_lifecycle_build_guid→builds.guidcascadecnb_lifecycle_datadroplet_guidcnb_lifecycle_dataapp_guidbuildpack_lifecycle_buildpacksbuildpack_lifecycle_data_guidfk_blbuildpack_bldata_guid→buildpack_lifecycle_data.guidcascadebuildpack_lifecycle_buildpackscnb_lifecycle_data_guidfk_blcnb_bldata_guid→cnb_lifecycle_data.guidcascadeKey asymmetry:
build_guidis DB-enforced with cascade on both tables, butdroplet_guidis not - only an index. So at the DB level, deleting a build cascades to a build-owned row, but deleting a droplet relies entirely on the app-level:destroyhook.Two ways forward
Both keep both delete orders safe (the hard requirement). They differ in whether they eliminate row-sharing or merely tolerate it, and in how much migration/backfill they require.
Option A - Stop sharing: droplet gets its own copy + cascade FK
Each parent owns its own single-parent row. The staging path copies the build's lifecycle_data onto a new droplet-owned row instead of attaching the build's row. A cascade FK on
droplet_guidbecomes the DB-level backstop.Changes
app/actions/droplet_create.rb) -find_or_create_buildpack_dropletcopies stack, buildpacks (rebuilding the childbuildpack_lifecycle_buildpacksrows), and - for CNB - the encryptedregistry_credentials_json, onto a new droplet-owned row. Uses thecreate(..., droplet:)association-arg form to avoid a MySQL concurrent-insert deadlock (mirrorsDropletCopy).validate- reject any row with more than one owner column set ([app_guid, build_guid, droplet_guid].compact.length > 1). Single-parent becomes an invariant the model refuses to violate.on_delete: :cascadeFK ondroplet_guidfor both tables, with MySQL collation alignment; delete rows whosedroplet_guidpoints at a now-deleted droplet (NULL-guarded so app/build-owned rows survive).build_guidanddroplet_guidboth set on a live pair), split it: create a droplet-owned copy and cleardroplet_guidon the build's row.Order within the migration: backfill (split shared rows) → delete rows with a dangling
droplet_guid→ MySQL collation alignment → add thedroplet_guidcascade FK. The FK must be added last, once every row is single-owned.Pros
Cons
registry_credentials_jsonmust be copied as raw ciphertext + salt (not re-encrypted). Backfill cost scales with the number of existing shared rows in production CCDBs.Option B - Tolerate sharing:
set nullFKs + destroy only when no parent remainsKeep rows shareable. Instead of unconditionally destroying the row when a parent is deleted, let the database null out the departing parent's guid and only destroy the row once no owner is left. No copy, no backfill.
Changes
set null- change the existingbuild_guidFKs (both tables) fromon_delete: :cascadetoon_delete: :set null, and add adroplet_guidFK also withon_delete: :set null. Deleting either parent then clears only its own guid column at the DB level; the other owner and the row survive.:destroydependencies onBuildModel/DropletModelforbuildpack_lifecycle_data/cnb_lifecycle_data- theset nullFK now handles detaching this parent. No model hooks are added.:destroyhooks gone, a row whose last parent is deleted is left with all owner columns null and would leak. Add a periodic cleanup job (same pattern aspending_build_cleanup/orphaned_blobs_cleanup, registered in the clock scheduler) that deletes*_lifecycle_datarows whereapp_guid,build_guid, anddroplet_guidare all null.build_guidFKs toset null, add thedroplet_guidset nullFK (with MySQL collation alignment). No shared-row split needed -set nulltolerates the existing two-owner rows.Pros
set nullhandles them on delete. Migration only alters FK definitions.Cons
Side-effect: shared rows persist, so the staging-completion write still lands on the shared row -
build.lifecycle_data.buildpackscontinues to reflect detected rather than requested (same as today). Unlike Option A, the requested/detected separation is not gained. No production reader depends on it.Comparison
set nullFKs)set nullFKset nullon deleteRecommendation
set nullFKs make both delete directions safe at the DB level with no delete-path or model-hook changes, at the cost of a periodic background job that reaps ownerless rows.