fix(models): cascade ALL not-null children on application purge

Field follow-up to c1d4c0af: the live box then crashed on
deployments.app_id — dynamic relationships get the same FK null-out
on parent delete as the one-to-ones. Cascade every one-to-many from
Application whose child FK is NOT NULL (deployments, config
snapshots, image scans, SBOMs, image update checks, git deployments),
and pin the invariant with a mapper-inspection sweep so a future
NOT NULL child cannot reintroduce the purge crash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Juan DenisandClaude Fable 5 committed 2026-08-31 19:28:35 -04:00
1 parent c1d4c0af12
commit 1704d60b05
6 files changed
+49 -6

No files matched your search

+4 -1
View File
@@ -59,7 +59,10 @@ class Deployment(JsonColumnMixin, db.Model):
extra_data = db.Column(db.Text, default='{}')
# Relationships
app = db.relationship('Application', backref=db.backref('deployments', lazy='dynamic'))
# cascade: app_id is NOT NULL, so a real delete of the app (Recycle Bin
# purge) must take the history with it — the default null-out IntegrityErrors.
app = db.relationship('Application', backref=db.backref(
'deployments', lazy='dynamic', cascade='all, delete-orphan'))
deployer = db.relationship('User', backref=db.backref('user_deployments', lazy='dynamic'))
def get_metadata(self):
+6 -1
View File
@@ -58,8 +58,13 @@ class DeploymentSnapshot(JsonColumnMixin, db.Model):
# deployment.py (another agent owns those). The Application gains a
# ``config_snapshots`` collection; the Deployment gains ``config_snapshots``
# too via a distinct backref.
# cascade: application_id is NOT NULL, so a real delete of the app (Recycle
# Bin purge) must take the snapshots too — the default null-out IntegrityErrors.
# (deployment_id below is nullable, so the Deployment side needs no cascade.)
application = db.relationship(
'Application', backref=db.backref('config_snapshots', lazy='dynamic')
'Application',
backref=db.backref('config_snapshots', lazy='dynamic',
cascade='all, delete-orphan')
)
deployment = db.relationship(
'Deployment', backref=db.backref('config_snapshots', lazy='dynamic')
+8 -2
View File
@@ -20,7 +20,10 @@ class ImageVulnerabilityScan(JsonColumnMixin, db.Model):
started_at = db.Column(db.DateTime, default=datetime.utcnow)
completed_at = db.Column(db.DateTime, nullable=True)
application = db.relationship('Application', backref=db.backref('image_scans', lazy='dynamic', order_by='ImageVulnerabilityScan.started_at.desc()'))
# cascade: application_id is NOT NULL — purge must delete, not null out.
application = db.relationship('Application', backref=db.backref(
'image_scans', lazy='dynamic', cascade='all, delete-orphan',
order_by='ImageVulnerabilityScan.started_at.desc()'))
def set_counts(self, counts):
self.severity_counts = json.dumps(counts)
@@ -72,7 +75,10 @@ class SbomArtifact(JsonColumnMixin, db.Model):
sbom_json = db.Column(db.Text) # SPDX 2.3 JSON
created_at = db.Column(db.DateTime, default=datetime.utcnow)
application = db.relationship('Application', backref=db.backref('sboms', lazy='dynamic', order_by='SbomArtifact.created_at.desc()'))
# cascade: application_id is NOT NULL — purge must delete, not null out.
application = db.relationship('Application', backref=db.backref(
'sboms', lazy='dynamic', cascade='all, delete-orphan',
order_by='SbomArtifact.created_at.desc()'))
def to_dict(self, include_sbom=False):
return {
+2 -1
View File
@@ -18,7 +18,8 @@ class ImageUpdateCheck(SerializableMixin, db.Model):
error_message = db.Column(db.Text)
checked_at = db.Column(db.DateTime, default=datetime.utcnow)
# cascade: application_id is NOT NULL — purge must delete, not null out.
application = db.relationship('Application', backref=db.backref(
'image_update_checks', lazy='dynamic',
'image_update_checks', lazy='dynamic', cascade='all, delete-orphan',
order_by='ImageUpdateCheck.checked_at.desc()'))
+4 -1
View File
@@ -172,7 +172,10 @@ class GitDeployment(db.Model):
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Relationships
app = db.relationship('Application', backref=db.backref('git_deployments', lazy='dynamic'))
# cascade: app_id is NOT NULL — purge must delete, not null out. (GitWebhook
# above keeps a plain backref: its app_id is nullable.)
app = db.relationship('Application', backref=db.backref(
'git_deployments', lazy='dynamic', cascade='all, delete-orphan'))
webhook = db.relationship('GitWebhook', backref=db.backref('deployments', lazy='dynamic'))
def to_dict(self):
@@ -171,10 +171,12 @@ def test_purge_takes_the_one_to_one_policy_rows_with_it(app, dead_app, monkeypat
from app.models.container_scale_policy import ContainerScalePolicy
from app.models.container_sleep_policy import ContainerSleepPolicy
from app.models.deployment import Deployment
with app.app_context():
db.session.add(ContainerScalePolicy(application_id=dead_app.id))
db.session.add(ContainerSleepPolicy(application_id=dead_app.id))
db.session.add(Deployment(app_id=dead_app.id, version=1))
db.session.commit()
ok, warning = recycle_bin_service.purge('application', dead_app.id)
@@ -185,6 +187,29 @@ def test_purge_takes_the_one_to_one_policy_rows_with_it(app, dead_app, monkeypat
application_id=dead_app.id).first() is None
assert ContainerSleepPolicy.query.filter_by(
application_id=dead_app.id).first() is None
assert Deployment.query.filter_by(app_id=dead_app.id).first() is None
def test_every_not_null_child_of_application_cascades_deletes(app):
"""The invariant behind the purge crash, pinned for FUTURE models too: any
one-to-many from Application whose child FK is NOT NULL must carry a delete
cascade, because SQLAlchemy's default on parent delete is to NULL the child
FK — which the NOT NULL constraint turns into an IntegrityError at purge."""
from sqlalchemy import inspect as sa_inspect
with app.app_context():
offenders = []
for rel in sa_inspect(Application).relationships:
if rel.direction.name != 'ONETOMANY' or rel.viewonly:
continue
fk_cols = [c for c in rel.remote_side if c.foreign_keys]
if not fk_cols or all(c.nullable for c in fk_cols):
continue
if 'delete' not in rel.cascade:
offenders.append(str(rel))
assert not offenders, (
'NOT NULL children of Application without a delete cascade '
f'(purge would IntegrityError): {offenders}')
def test_purge_honours_an_explicit_remove_data_false(app, dead_app, monkeypatch):