fix(models): cascade one-to-one child rows on application purge

Purging an app from the Recycle Bin crashed with IntegrityError
(NOT NULL constraint failed: container_scale_policies.application_id)
when the app had an auto-scale policy: with no delete cascade,
SQLAlchemy nulls the child FK on parent delete, and these one-to-one
rows declare it NOT NULL. Scale policy, sleep policy and wp_site all
share the shape; all three now ride the purge.

Reported on Discord (vaultwarden/hedgedoc delete-reinstall flow).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Juan DenisandClaude Fable 5 committed 2026-08-31 19:22:43 -04:00
1 parent b966f57533
commit c1d4c0af12
4 files changed
+42 -3

No files matched your search

+4 -1
View File
@@ -23,7 +23,10 @@ class ContainerScalePolicy(db.Model):
last_scaled_at = db.Column(db.DateTime)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
application = db.relationship('Application', backref=db.backref('scale_policy', uselist=False))
# cascade: application_id is NOT NULL, so a real delete of the app (Recycle
# Bin purge) must take this row with it — the default null-out IntegrityErrors.
application = db.relationship('Application', backref=db.backref(
'scale_policy', uselist=False, cascade='all, delete-orphan'))
def to_dict(self):
return {
+4 -1
View File
@@ -17,7 +17,10 @@ class ContainerSleepPolicy(db.Model):
asleep = db.Column(db.Boolean, default=False, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
application = db.relationship('Application', backref=db.backref('sleep_policy', uselist=False))
# cascade: application_id is NOT NULL, so a real delete of the app (Recycle
# Bin purge) must take this row with it — the default null-out IntegrityErrors.
application = db.relationship('Application', backref=db.backref(
'sleep_policy', uselist=False, cascade='all, delete-orphan'))
def to_dict(self):
return {
+4 -1
View File
@@ -100,7 +100,10 @@ class WordPressSite(JsonColumnMixin, TimestampMixin, db.Model):
# Timestamps
# Relationships
application = db.relationship('Application', backref=db.backref('wp_site', uselist=False))
# cascade: application_id is NOT NULL, so a real delete of the app (Recycle
# Bin purge) must take this row with it — the default null-out IntegrityErrors.
application = db.relationship('Application', backref=db.backref(
'wp_site', uselist=False, cascade='all, delete-orphan'))
environments = db.relationship(
'WordPressSite',
backref=db.backref('production_site', remote_side=[id]),
@@ -157,6 +157,36 @@ def test_purge_is_what_removes_the_volumes(app, dead_app, monkeypatch):
assert calls and calls[0].get('volumes') is True
def test_purge_takes_the_one_to_one_policy_rows_with_it(app, dead_app, monkeypatch):
"""Field report: purging an app that had an auto-scale policy died with
`IntegrityError: NOT NULL constraint failed: container_scale_policies
.application_id`. Without a delete cascade SQLAlchemy's default on parent
delete is to NULL the child FK — and these one-to-one rows declare the FK
NOT NULL, so the purge itself crashed. The policy rows must ride along."""
from app.services import docker_service
monkeypatch.setattr(docker_service.DockerService, 'compose_down',
classmethod(lambda cls, path, **kw: {'success': True}))
monkeypatch.setattr(application_restore.CronService, 'clear_application',
classmethod(lambda cls, _id: 0))
from app.models.container_scale_policy import ContainerScalePolicy
from app.models.container_sleep_policy import ContainerSleepPolicy
with app.app_context():
db.session.add(ContainerScalePolicy(application_id=dead_app.id))
db.session.add(ContainerSleepPolicy(application_id=dead_app.id))
db.session.commit()
ok, warning = recycle_bin_service.purge('application', dead_app.id)
assert ok
assert warning is None, warning
assert Application.query.get(dead_app.id) is None
assert ContainerScalePolicy.query.filter_by(
application_id=dead_app.id).first() is None
assert ContainerSleepPolicy.query.filter_by(
application_id=dead_app.id).first() is None
def test_purge_honours_an_explicit_remove_data_false(app, dead_app, monkeypatch):
"""Database engines default to preserving their volumes — losing the data is
the only irreversible part of that uninstall."""