← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/branch-delete-job into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/branch-delete-job into lp:launchpad with lp:~cjwatson/launchpad/code-delete-no-recipe-preload as a prerequisite.

Commit message:
Add a branch deletion job.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1793266 in Launchpad itself: "Unable to delete repository"
  https://bugs.launchpad.net/launchpad/+bug/1793266

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/branch-delete-job/+merge/364907

Nothing creates these jobs yet; that will come in a follow-up branch.

We'll need to get the branch-delete-job DB user created and .pgpass updated on the relevant machines before landing this.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/branch-delete-job into lp:launchpad.
=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg	2019-03-11 14:51:16 +0000
+++ database/schema/security.cfg	2019-03-21 15:55:38 +0000
@@ -2593,3 +2593,51 @@
 public.teamparticipation                = SELECT
 public.webhook                          = SELECT
 public.webhookjob                       = SELECT, INSERT
+
+[branch-delete-job]
+type=user
+groups=script
+public.accessartifact                   = SELECT, DELETE
+public.accessartifactgrant              = SELECT, DELETE
+public.accesspolicyartifact             = SELECT, DELETE
+public.archive                          = SELECT
+public.branch                           = SELECT, UPDATE, DELETE
+public.branchjob                        = SELECT, INSERT, DELETE
+public.branchmergeproposal              = SELECT, UPDATE, DELETE
+public.branchmergeproposaljob           = SELECT, DELETE
+public.branchsubscription               = SELECT, DELETE
+public.bug                              = SELECT
+public.bugbranch                        = SELECT, DELETE
+public.bugtask                          = SELECT
+public.buildfarmjob                     = SELECT, DELETE
+public.buildqueue                       = SELECT, DELETE
+public.codeimport                       = SELECT, DELETE
+public.codeimportjob                    = SELECT, DELETE
+public.codereviewinlinecomment          = SELECT, DELETE
+public.codereviewinlinecommentdraft     = SELECT, DELETE
+public.codereviewmessage                = SELECT, DELETE
+public.codereviewvote                   = SELECT, DELETE
+public.distribution                     = SELECT
+public.distributionsourcepackage        = SELECT, DELETE
+public.distroseries                     = SELECT
+public.emailaddress                     = SELECT
+public.job                              = SELECT, INSERT, UPDATE, DELETE
+public.person                           = SELECT
+public.previewdiff                      = SELECT, DELETE
+public.product                          = SELECT
+public.productseries                    = SELECT, UPDATE
+public.seriessourcepackagebranch        = SELECT, DELETE
+public.snap                             = SELECT, UPDATE
+public.sourcepackage                    = SELECT
+public.sourcepackagename                = SELECT
+public.sourcepackagepublishinghistory   = SELECT
+public.sourcepackagerecipe              = SELECT, DELETE
+public.sourcepackagerecipebuild         = SELECT, UPDATE
+public.sourcepackagerecipedata          = SELECT, DELETE
+public.sourcepackagerecipedatainstruction = SELECT, DELETE
+public.sourcepackagerecipedistroseries  = SELECT, DELETE
+public.sourcepackagerelease             = SELECT
+public.specificationbranch              = SELECT, DELETE
+public.translationtemplatesbuild        = SELECT, DELETE
+public.webhook                          = SELECT, DELETE
+public.webhookjob                       = SELECT, DELETE

=== modified file 'lib/lp/code/configure.zcml'
--- lib/lp/code/configure.zcml	2018-10-16 15:52:00 +0000
+++ lib/lp/code/configure.zcml	2019-03-21 15:55:38 +0000
@@ -1,4 +1,4 @@
-<!-- Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -602,6 +602,10 @@
     <allow interface="lp.code.interfaces.branchjob.IBranchModifiedMailJob"/>
     <allow interface="lp.code.interfaces.branchjob.IBranchJob"/>
   </class>
+  <class class="lp.code.model.branchjob.BranchDeleteJob">
+    <allow interface="lp.code.interfaces.branchjob.IBranchDeleteJob"/>
+    <allow interface="lp.code.interfaces.branchjob.IBranchJob"/>
+  </class>
   <securedutility
       component="lp.code.model.branchjob.RevisionMailJob"
       provides="lp.code.interfaces.branchjob.IRevisionMailJobSource">
@@ -627,6 +631,11 @@
       provides="lp.code.interfaces.branchjob.IBranchModifiedMailJobSource">
     <allow interface="lp.code.interfaces.branchjob.IBranchModifiedMailJobSource"/>
   </securedutility>
+  <securedutility
+      component="lp.code.model.branchjob.BranchDeleteJob"
+      provides="lp.code.interfaces.branchjob.IBranchDeleteJobSource">
+    <allow interface="lp.code.interfaces.branchjob.IBranchDeleteJobSource"/>
+  </securedutility>
 
   <!-- CodeImport -->
 

=== modified file 'lib/lp/code/enums.py'
--- lib/lp/code/enums.py	2018-10-19 23:10:41 +0000
+++ lib/lp/code/enums.py	2019-03-21 15:55:38 +0000
@@ -1,10 +1,11 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Enumerations used in the lp/code modules."""
 
 __metaclass__ = type
 __all__ = [
+    'BranchDeletionStatus',
     'BranchLifecycleStatus',
     'BranchLifecycleStatusFilter',
     'BranchListingSort',
@@ -116,6 +117,16 @@
         """)
 
 
+class BranchDeletionStatus(DBEnumeratedType):
+    """Branch Deletion Status
+
+    The deletion status of a branch is used to track asynchronous deletion.
+    """
+
+    ACTIVE = DBItem(0, "Active")
+    DELETING = DBItem(1, "Deleting")
+
+
 class GitRepositoryType(DBEnumeratedType):
     """Git Repository Type
 

=== modified file 'lib/lp/code/interfaces/branch.py'
--- lib/lp/code/interfaces/branch.py	2019-01-23 17:13:48 +0000
+++ lib/lp/code/interfaces/branch.py	2019-03-21 15:55:38 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Branch interfaces."""
@@ -75,6 +75,7 @@
     RepositoryFormat,
     )
 from lp.code.enums import (
+    BranchDeletionStatus,
     BranchLifecycleStatus,
     BranchMergeProposalStatus,
     BranchSubscriptionDiffSize,
@@ -293,6 +294,11 @@
             accepted).
         """
 
+    deletion_status = exported(Choice(
+        title=_("Deletion status"), required=True, readonly=True,
+        vocabulary=BranchDeletionStatus,
+        description=_("The deletion status of this branch.")))
+
     # People attributes
     registrant = exported(
         PublicPersonChoice(

=== modified file 'lib/lp/code/interfaces/branchjob.py'
--- lib/lp/code/interfaces/branchjob.py	2015-09-01 17:10:46 +0000
+++ lib/lp/code/interfaces/branchjob.py	2019-03-21 15:55:38 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """BranchJob interfaces."""
@@ -8,6 +8,8 @@
 
 
 __all__ = [
+    'IBranchDeleteJob',
+    'IBranchDeleteJobSource',
     'IBranchJob',
     'IBranchModifiedMailJob',
     'IBranchModifiedMailJobSource',
@@ -201,3 +203,19 @@
         :param user: The `IPerson` who modified the branch.
         :param branch_delta: An `IBranchDelta` describing the changes.
         """
+
+
+class IBranchDeleteJob(IRunnableJob):
+    """A Job that deletes a branch from the database."""
+
+    branch_id = Int(title=_('The id of the branch to delete.'))
+
+
+class IBranchDeleteJobSource(IJobSource):
+
+    def create(branch, requester):
+        """Delete a branch from the database.
+
+        :param branch: The `IBranch` to delete.
+        :param requester: The `IPerson` who requested the deletion.
+        """

=== modified file 'lib/lp/code/model/branch.py'
--- lib/lp/code/model/branch.py	2019-03-21 15:55:38 +0000
+++ lib/lp/code/model/branch.py	2019-03-21 15:55:38 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -82,6 +82,7 @@
     RepositoryFormat,
     )
 from lp.code.enums import (
+    BranchDeletionStatus,
     BranchLifecycleStatus,
     BranchMergeProposalStatus,
     BranchType,
@@ -304,6 +305,23 @@
         # such subscriptions.
         getUtility(IRemoveArtifactSubscriptionsJobSource).create(who, [self])
 
+    _deletion_status = EnumCol(
+        dbName='deletion_status', enum=BranchDeletionStatus,
+        default=BranchDeletionStatus.ACTIVE)
+
+    @property
+    def deletion_status(self):
+        # XXX cjwatson 2019-03-19: Remove once this column has been
+        # backfilled.
+        if self._deletion_status is None:
+            return BranchDeletionStatus.ACTIVE
+        else:
+            return self._deletion_status
+
+    @deletion_status.setter
+    def deletion_status(self, value):
+        self._deletion_status = value
+
     registrant = ForeignKey(
         dbName='registrant', foreignKey='Person',
         storm_validator=validate_public_person, notNull=True)

=== modified file 'lib/lp/code/model/branchjob.py'
--- lib/lp/code/model/branchjob.py	2018-03-30 20:42:14 +0000
+++ lib/lp/code/model/branchjob.py	2019-03-21 15:55:38 +0000
@@ -1,7 +1,8 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __all__ = [
+    'BranchDeleteJob',
     'BranchJob',
     'BranchModifiedMailJob',
     'BranchScanJob',
@@ -56,17 +57,22 @@
     implementer,
     provider,
     )
+from zope.security.proxy import removeSecurityProxy
 
 from lp.code.bzr import (
     branch_revision_history,
     get_branch_formats,
     )
 from lp.code.enums import (
+    BranchDeletionStatus,
     BranchMergeProposalStatus,
     BranchSubscriptionDiffSize,
     BranchSubscriptionNotificationLevel,
     )
+from lp.code.errors import CannotDeleteBranch
 from lp.code.interfaces.branchjob import (
+    IBranchDeleteJob,
+    IBranchDeleteJobSource,
     IBranchJob,
     IBranchModifiedMailJob,
     IBranchModifiedMailJobSource,
@@ -82,6 +88,7 @@
     IRosettaUploadJob,
     IRosettaUploadJobSource,
     )
+from lp.code.interfaces.branchlookup import IBranchLookup
 from lp.code.mail.branch import BranchMailer
 from lp.code.model.branch import Branch
 from lp.code.model.branchmergeproposal import BranchMergeProposal
@@ -121,6 +128,7 @@
     BaseRunnableJobSource,
     )
 from lp.services.mail.sendmail import format_address_for_person
+from lp.services.scripts import log
 from lp.services.utils import text_delta
 from lp.services.webapp import canonical_url
 from lp.translations.interfaces.translationimportqueue import (
@@ -195,6 +203,12 @@
         This job runs against a branch to send emails about modifications.
         """)
 
+    DELETE_BRANCH = DBItem(9, """
+        Delete branch
+
+        This job deletes a branch from the database.
+        """)
+
 
 @implementer(IBranchJob)
 class BranchJob(SQLBase):
@@ -295,7 +309,9 @@
         vars.extend([
             ('branch_job_id', self.context.id),
             ('branch_job_type', self.context.job_type.title)])
-        if self.context.branch is not None:
+        if 'branch_name' in self.metadata:
+            vars.append(('branch_name', self.metadata['branch_name']))
+        elif self.context.branch is not None:
             vars.append(('branch_name', self.context.branch.unique_name))
         return vars
 
@@ -347,7 +363,6 @@
 
     def run(self):
         """See `IBranchScanJob`."""
-        from lp.services.scripts import log
         with server(get_ro_server(), no_replace=True):
             try:
                 with try_advisory_lock(
@@ -1056,3 +1071,69 @@
     def run(self):
         """See `IBranchModifiedMailJob`."""
         self.getMailer().sendAll()
+
+
+@implementer(IBranchDeleteJob)
+@provider(IBranchDeleteJobSource)
+class BranchDeleteJob(BranchJobDerived):
+    """A Job that deletes a branch from the database."""
+
+    class_job_type = BranchJobType.DELETE_BRANCH
+
+    user_error_types = (CannotDeleteBranch,)
+
+    config = config.IBranchDeleteJobSource
+
+    def __repr__(self):
+        return '<DELETE_BRANCH branch job (%(id)s) for %(branch)s>' % {
+            'id': self.context.id,
+            'branch': self.branch_name,
+            }
+
+    def getOperationDescription(self):
+        return 'deleting a branch'
+
+    @classmethod
+    def create(cls, branch, requester):
+        """See `IBranchDeleteJobSource`."""
+        metadata = {'branch_id': branch.id, 'branch_name': branch.unique_name}
+        # The BranchJob has a branch of None, because we don't want to
+        # delete this job while trying to delete the branch.
+        branch_job = BranchJob(
+            None, cls.class_job_type, metadata, requester=requester)
+        job = cls(branch_job)
+        job.celeryRunOnCommit()
+        return job
+
+    @property
+    def branch_id(self):
+        return self.metadata['branch_id']
+
+    @property
+    def branch_name(self):
+        return self.metadata['branch_name']
+
+    def run(self):
+        """See `IBranchDeleteJob`."""
+        branch = getUtility(IBranchLookup).get(self.branch_id)
+        if branch is None:
+            log.info(
+                'Skipping branch %s because it has already been deleted.' %
+                self.branch_name)
+        elif branch.deletion_status != BranchDeletionStatus.DELETING:
+            log.warning(
+                'Skipping branch %s because its deletion status is not '
+                'DELETING.' % self.branch_name)
+        else:
+            try:
+                branch.destroySelf(break_references=True)
+            except CannotDeleteBranch:
+                # Set the deletion status back to ACTIVE so that it's
+                # possible to try again.  We don't attempt to undo the
+                # renaming at the moment.  Do this in its own transaction
+                # since the job runner will abort the transaction.
+                transaction.abort()
+                removeSecurityProxy(branch).deletion_status = (
+                    BranchDeletionStatus.ACTIVE)
+                transaction.commit()
+                raise

=== modified file 'lib/lp/code/model/tests/test_branch.py'
--- lib/lp/code/model/tests/test_branch.py	2019-01-28 18:09:21 +0000
+++ lib/lp/code/model/tests/test_branch.py	2019-03-21 15:55:38 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for Branches."""
@@ -1247,7 +1247,8 @@
                          "deleted.")
         branch_id = self.branch.id
         branch_set = getUtility(IBranchLookup)
-        self.branch.destroySelf()
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            self.branch.destroySelf()
         self.assertIsNone(
             branch_set.get(branch_id), "The branch has not been deleted.")
 
@@ -1280,7 +1281,8 @@
         bug.linkBranch(self.branch, self.user)
         self.assertEqual(self.branch.canBeDeleted(), False,
                          "A branch linked to a bug is not deletable.")
-        self.assertRaises(CannotDeleteBranch, self.branch.destroySelf)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            self.assertRaises(CannotDeleteBranch, self.branch.destroySelf)
 
     def test_specBranchLinkDisablesDeletion(self):
         """A branch linked to a spec cannot be deleted."""
@@ -1291,7 +1293,8 @@
         spec.linkBranch(self.branch, self.user)
         self.assertEqual(self.branch.canBeDeleted(), False,
                          "A branch linked to a spec is not deletable.")
-        self.assertRaises(CannotDeleteBranch, self.branch.destroySelf)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            self.assertRaises(CannotDeleteBranch, self.branch.destroySelf)
 
     def test_associatedProductSeriesBranchDisablesDeletion(self):
         """A branch linked as a branch to a product series cannot be
@@ -1301,14 +1304,16 @@
         self.assertEqual(self.branch.canBeDeleted(), False,
                          "A branch that is a user branch for a product series"
                          " is not deletable.")
-        self.assertRaises(CannotDeleteBranch, self.branch.destroySelf)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            self.assertRaises(CannotDeleteBranch, self.branch.destroySelf)
 
     def test_productSeriesTranslationsBranchDisablesDeletion(self):
         self.product.development_focus.translations_branch = self.branch
         self.assertEqual(self.branch.canBeDeleted(), False,
                          "A branch that is a translations branch for a "
                          "product series is not deletable.")
-        self.assertRaises(CannotDeleteBranch, self.branch.destroySelf)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            self.assertRaises(CannotDeleteBranch, self.branch.destroySelf)
 
     def test_revisionsDeletable(self):
         """A branch that has some revisions can be deleted."""
@@ -1321,7 +1326,8 @@
         self.assertEqual(self.branch.canBeDeleted(), True,
                          "A branch that has a revision is deletable.")
         unique_name = self.branch.unique_name
-        self.branch.destroySelf()
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            self.branch.destroySelf()
         # Commit again to trigger the deferred indices.
         transaction.commit()
         branch_lookup = getUtility(IBranchLookup)
@@ -1335,7 +1341,8 @@
         self.branch.addLandingTarget(self.user, target_branch)
         self.assertEqual(self.branch.canBeDeleted(), False,
                          "A branch with a landing target is not deletable.")
-        self.assertRaises(CannotDeleteBranch, self.branch.destroySelf)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            self.assertRaises(CannotDeleteBranch, self.branch.destroySelf)
 
     def test_landingCandidateDisablesDeletion(self):
         """A branch with a landing candidate cannot be deleted."""
@@ -1345,7 +1352,8 @@
         self.assertEqual(self.branch.canBeDeleted(), False,
                          "A branch with a landing candidate is not"
                          " deletable.")
-        self.assertRaises(CannotDeleteBranch, self.branch.destroySelf)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            self.assertRaises(CannotDeleteBranch, self.branch.destroySelf)
 
     def test_prerequisiteBranchDisablesDeletion(self):
         """A branch that is a prerequisite branch cannot be deleted."""
@@ -1357,14 +1365,16 @@
         self.assertEqual(self.branch.canBeDeleted(), False,
                          "A branch with a prerequisite target is not "
                          "deletable.")
-        self.assertRaises(CannotDeleteBranch, self.branch.destroySelf)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            self.assertRaises(CannotDeleteBranch, self.branch.destroySelf)
 
     def test_relatedBranchJobsDeleted(self):
         # A branch with an associated branch job will delete those jobs.
         branch = self.factory.makeBranch(
             branch_format=BranchFormat.BZR_BRANCH_6)
         removeSecurityProxy(branch).requestUpgrade(branch.owner)
-        branch.destroySelf()
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            branch.destroySelf()
         # Need to commit the transaction to fire off the constraint checks.
         transaction.commit()
 
@@ -1373,7 +1383,8 @@
         # should be cleared.
         dev_focus = self.branch.product.development_focus
         dev_focus.translations_branch = self.branch
-        self.branch.destroySelf(break_references=True)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            self.branch.destroySelf(break_references=True)
 
     def test_related_TranslationTemplatesBuild_cleaned_out(self):
         # A TranslationTemplatesBuild may come with a BuildQueue entry.
@@ -1381,7 +1392,8 @@
         # remove the TTB.
         build = self.factory.makeTranslationTemplatesBuild()
         build.queueBuild()
-        build.branch.destroySelf(break_references=True)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            build.branch.destroySelf(break_references=True)
 
     def test_unrelated_TranslationTemplatesBuild_intact(self):
         # No innocent BuildQueue entries are harmed in deleting a
@@ -1391,7 +1403,8 @@
         other_build = self.factory.makeTranslationTemplatesBuild()
         other_bq = other_build.queueBuild()
 
-        build.branch.destroySelf(break_references=True)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            build.branch.destroySelf(break_references=True)
 
         store = Store.of(build)
         # The BuildQueue for the job whose branch we deleted is gone.
@@ -1406,7 +1419,8 @@
         branch = self.factory.makeAnyBranch()
         branch_id = branch.id
         store = Store.of(branch)
-        branch.destroySelf()
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            branch.destroySelf()
         jobs = store.find(
             BranchJob,
             BranchJob.job_type == BranchJobType.RECLAIM_BRANCH_SPACE)
@@ -1417,7 +1431,8 @@
     def test_destroySelf_with_SourcePackageRecipe(self):
         """If branch is a base_branch in a recipe, it is deleted."""
         recipe = self.factory.makeSourcePackageRecipe()
-        recipe.base_branch.destroySelf(break_references=True)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            recipe.base_branch.destroySelf(break_references=True)
 
     def test_destroySelf_with_SourcePackageRecipe_as_non_base(self):
         """If branch is referred to by a recipe, it is deleted."""
@@ -1425,7 +1440,8 @@
         branch2 = self.factory.makeAnyBranch()
         self.factory.makeSourcePackageRecipe(
             branches=[branch1, branch2])
-        branch2.destroySelf(break_references=True)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            branch2.destroySelf(break_references=True)
 
     def test_destroySelf_with_inline_comments_draft(self):
         # Draft inline comments related to a deleted branch (source
@@ -1439,7 +1455,8 @@
             previewdiff_id=preview_diff.id,
             person=self.user,
             comments={'1': 'Should vanish.'})
-        self.branch.destroySelf(break_references=True)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            self.branch.destroySelf(break_references=True)
 
     def test_destroySelf_with_inline_comments_published(self):
         # Published inline comments related to a deleted branch (source
@@ -1455,12 +1472,14 @@
             previewdiff_id=preview_diff.id,
             inline_comments={'1': 'Must disappear.'},
         )
-        self.branch.destroySelf(break_references=True)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            self.branch.destroySelf(break_references=True)
 
     def test_related_webhooks_deleted(self):
         webhook = self.factory.makeWebhook(target=self.branch)
         webhook.ping()
-        self.branch.destroySelf()
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            self.branch.destroySelf()
         transaction.commit()
         self.assertRaises(LostObjectError, getattr, webhook, 'target')
 
@@ -1541,7 +1560,8 @@
         merge_proposal1, merge_proposal2 = self.makeMergeProposals()
         merge_proposal1_id = merge_proposal1.id
         BranchMergeProposal.get(merge_proposal1_id)
-        self.branch.destroySelf(break_references=True)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            self.branch.destroySelf(break_references=True)
         self.assertRaises(SQLObjectNotFound,
             BranchMergeProposal.get, merge_proposal1_id)
 
@@ -1550,14 +1570,17 @@
         merge_proposal1, merge_proposal2 = self.makeMergeProposals()
         merge_proposal1_id = merge_proposal1.id
         BranchMergeProposal.get(merge_proposal1_id)
-        merge_proposal1.target_branch.destroySelf(break_references=True)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            merge_proposal1.target_branch.destroySelf(break_references=True)
         self.assertRaises(SQLObjectNotFound,
             BranchMergeProposal.get, merge_proposal1_id)
 
     def test_deleteMergeProposalDependent(self):
         """break_links enables deleting merge proposal dependant branches."""
         merge_proposal1, merge_proposal2 = self.makeMergeProposals()
-        merge_proposal1.prerequisite_branch.destroySelf(break_references=True)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            merge_proposal1.prerequisite_branch.destroySelf(
+                break_references=True)
         self.assertEqual(None, merge_proposal1.prerequisite_branch)
 
     def test_deleteSourceCodeReviewComment(self):
@@ -1565,7 +1588,8 @@
         comment = self.factory.makeCodeReviewComment()
         comment_id = comment.id
         branch = comment.branch_merge_proposal.source_branch
-        branch.destroySelf(break_references=True)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            branch.destroySelf(break_references=True)
         self.assertRaises(
             SQLObjectNotFound, CodeReviewComment.get, comment_id)
 
@@ -1574,7 +1598,8 @@
         comment = self.factory.makeCodeReviewComment()
         comment_id = comment.id
         branch = comment.branch_merge_proposal.target_branch
-        branch.destroySelf(break_references=True)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            branch.destroySelf(break_references=True)
         self.assertRaises(
             SQLObjectNotFound, CodeReviewComment.get, comment_id)
 
@@ -1592,7 +1617,8 @@
         bug1.linkBranch(self.branch, self.branch.owner)
         bug_branch1 = bug1.linked_bugbranches[0]
         bug_branch1_id = removeSecurityProxy(bug_branch1).id
-        self.branch.destroySelf(break_references=True)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            self.branch.destroySelf(break_references=True)
         self.assertRaises(SQLObjectNotFound, BugBranch.get, bug_branch1_id)
 
     def test_branchWithSpecRequirements(self):
@@ -1612,7 +1638,8 @@
         spec2 = self.factory.makeSpecification()
         spec2.linkBranch(self.branch, self.branch.owner)
         spec2_branch_id = self.branch.spec_links[1].id
-        self.branch.destroySelf(break_references=True)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            self.branch.destroySelf(break_references=True)
         self.assertRaises(
             SQLObjectNotFound, SpecificationBranch.get, spec1_branch_id)
         self.assertRaises(
@@ -1630,7 +1657,8 @@
         """break_links allows deleting a series' branch."""
         series1 = self.factory.makeProductSeries(branch=self.branch)
         series2 = self.factory.makeProductSeries(branch=self.branch)
-        self.branch.destroySelf(break_references=True)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            self.branch.destroySelf(break_references=True)
         self.assertEqual(None, series1.branch)
         self.assertEqual(None, series2.branch)
 
@@ -1661,7 +1689,8 @@
             package.development_version.setBranch,
             pocket, branch, package.distribution.owner)
         self.assertEqual(False, branch.canBeDeleted())
-        branch.destroySelf(break_references=True)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            branch.destroySelf(break_references=True)
         self.assertIs(None, package.getBranch(pocket))
 
     def test_branchWithCodeImportRequirements(self):
@@ -1676,7 +1705,8 @@
         """break_references allows deleting a code import branch."""
         code_import = self.factory.makeCodeImport()
         code_import_id = code_import.id
-        code_import.branch.destroySelf(break_references=True)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            code_import.branch.destroySelf(break_references=True)
         self.assertRaises(
             NotFoundError, getUtility(ICodeImportSet).get, code_import_id)
 
@@ -1685,14 +1715,16 @@
         merge_proposal = self.factory.makeBranchMergeProposal()
         merge_proposal.nominateReviewer(self.factory.makePerson(),
                                         self.factory.makePerson())
-        merge_proposal.source_branch.destroySelf(break_references=True)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            merge_proposal.source_branch.destroySelf(break_references=True)
 
     def test_targetBranchWithCodeReviewVoteReference(self):
         """Break_references handles CodeReviewVoteReference target branch."""
         merge_proposal = self.factory.makeBranchMergeProposal()
         merge_proposal.nominateReviewer(self.factory.makePerson(),
                                         self.factory.makePerson())
-        merge_proposal.target_branch.destroySelf(break_references=True)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            merge_proposal.target_branch.destroySelf(break_references=True)
 
     def test_snap_requirements(self):
         # If a branch is used by a snap package, the deletion requirements
@@ -1706,7 +1738,8 @@
         # break_references allows deleting a branch used by a snap package.
         snap1 = self.factory.makeSnap(branch=self.branch)
         snap2 = self.factory.makeSnap(branch=self.branch)
-        self.branch.destroySelf(break_references=True)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            self.branch.destroySelf(break_references=True)
         transaction.commit()
         self.assertIsNone(snap1.branch)
         self.assertIsNone(snap2.branch)
@@ -1715,7 +1748,8 @@
         """ClearDependent.__call__ must clear the prerequisite branch."""
         merge_proposal = removeSecurityProxy(self.makeMergeProposals()[0])
         with person_logged_in(merge_proposal.prerequisite_branch.owner):
-            ClearDependentBranch(merge_proposal)()
+            with dbuser(config.IBranchDeleteJobSource.dbuser):
+                ClearDependentBranch(merge_proposal)()
         self.assertEqual(None, merge_proposal.prerequisite_branch)
 
     def test_ClearOfficialPackageBranch(self):
@@ -1730,14 +1764,16 @@
             pocket, branch, package.distribution.owner)
         series_set = getUtility(IFindOfficialBranchLinks)
         [link] = list(series_set.findForBranch(branch))
-        ClearOfficialPackageBranch(link)()
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            ClearOfficialPackageBranch(link)()
         self.assertIs(None, package.getBranch(pocket))
 
     def test_ClearSeriesBranch(self):
         """ClearSeriesBranch.__call__ must clear the user branch."""
         series = removeSecurityProxy(self.factory.makeProductSeries(
             branch=self.branch))
-        ClearSeriesBranch(series, self.branch)()
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            ClearSeriesBranch(series, self.branch)()
         self.assertEqual(None, series.branch)
 
     def test_DeletionOperation(self):
@@ -1749,7 +1785,8 @@
         spec = self.factory.makeSpecification()
         spec_link = spec.linkBranch(self.branch, self.branch.owner)
         spec_link_id = spec_link.id
-        DeletionCallable(spec, 'blah', spec_link.destroySelf)()
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            DeletionCallable(spec, 'blah', spec_link.destroySelf)()
         self.assertRaises(SQLObjectNotFound, SpecificationBranch.get,
                           spec_link_id)
 
@@ -1757,7 +1794,8 @@
         """DeleteCodeImport.__call__ must delete the CodeImport."""
         code_import = self.factory.makeCodeImport()
         code_import_id = code_import.id
-        DeleteCodeImport(code_import)()
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            DeleteCodeImport(code_import)()
         self.assertRaises(
             NotFoundError, getUtility(ICodeImportSet).get, code_import_id)
 

=== modified file 'lib/lp/code/model/tests/test_branchjob.py'
--- lib/lp/code/model/tests/test_branchjob.py	2018-03-30 20:42:14 +0000
+++ lib/lp/code/model/tests/test_branchjob.py	2019-03-21 15:55:38 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for BranchJobs."""
@@ -16,7 +16,10 @@
 from bzrlib.bzrdir import BzrDir
 from bzrlib.revision import NULL_REVISION
 from bzrlib.transport import get_transport
-from fixtures import MockPatch
+from fixtures import (
+    FakeLogger,
+    MockPatch,
+    )
 import pytz
 from sqlobject import SQLObjectNotFound
 from storm.locals import Store
@@ -31,6 +34,7 @@
     RepositoryFormat,
     )
 from lp.code.enums import (
+    BranchDeletionStatus,
     BranchMergeProposalStatus,
     BranchSubscriptionDiffSize,
     BranchSubscriptionNotificationLevel,
@@ -38,6 +42,8 @@
     )
 from lp.code.errors import AlreadyLatestFormat
 from lp.code.interfaces.branchjob import (
+    IBranchDeleteJob,
+    IBranchDeleteJobSource,
     IBranchJob,
     IBranchScanJob,
     IBranchUpgradeJob,
@@ -46,6 +52,7 @@
     IRevisionMailJob,
     IRosettaUploadJob,
     )
+from lp.code.interfaces.branchlookup import IBranchLookup
 from lp.code.model.branchjob import (
     BranchJob,
     BranchJobDerived,
@@ -72,6 +79,7 @@
 from lp.services.job.model.job import Job
 from lp.services.job.runner import JobRunner
 from lp.services.job.tests import block_on_job
+from lp.services.mail.sendmail import format_address_for_person
 from lp.services.osutils import override_environ
 from lp.services.webapp import canonical_url
 from lp.testing import (
@@ -86,6 +94,7 @@
     CeleryBzrsyncdJobLayer,
     DatabaseFunctionalLayer,
     LaunchpadZopelessLayer,
+    ZopelessDatabaseLayer,
     )
 from lp.testing.librarianhelpers import get_newest_librarian_file
 from lp.testing.mail_helpers import pop_notifications
@@ -1390,3 +1399,87 @@
         os.makedirs(branch_path)
         self.runReadyJobs()
         self.assertFalse(os.path.exists(branch_path))
+
+
+class TestBranchDeleteJob(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def test_providesInterface(self):
+        branch = self.factory.makeAnyBranch()
+        requester = branch.registrant
+        job = getUtility(IBranchDeleteJobSource).create(branch, requester)
+        self.assertProvides(job, IBranchDeleteJob)
+
+    def test_run(self):
+        branch = self.factory.makeAnyBranch()
+        branch_id = branch.id
+        requester = branch.registrant
+        job = getUtility(IBranchDeleteJobSource).create(branch, requester)
+        removeSecurityProxy(branch).deletion_status = (
+            BranchDeletionStatus.DELETING)
+        logger = self.useFixture(FakeLogger())
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            job.run()
+        self.assertEqual('', logger.output)
+        self.assertIsNone(getUtility(IBranchLookup).get(branch_id))
+
+    def test_already_deleted(self):
+        branch = self.factory.makeAnyBranch()
+        branch_name = branch.unique_name
+        requester = branch.registrant
+        job = getUtility(IBranchDeleteJobSource).create(branch, requester)
+        branch.destroySelf()
+        logger = self.useFixture(FakeLogger())
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            job.run()
+        self.assertEqual(
+            'Skipping branch %s because it has already been '
+            'deleted.\n' % branch_name,
+            logger.output)
+
+    def test_not_deleting(self):
+        # The job skips branches that aren't DELETING.  This shouldn't be
+        # possible in practice, but is a guard against accidents.
+        branch = self.factory.makeAnyBranch()
+        branch_id = branch.id
+        branch_name = branch.unique_name
+        self.assertNotEqual(
+            BranchDeletionStatus.DELETING, branch.deletion_status)
+        requester = branch.registrant
+        job = getUtility(IBranchDeleteJobSource).create(branch, requester)
+        logger = self.useFixture(FakeLogger())
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            job.run()
+        self.assertEqual(
+            'Skipping branch %s because its deletion status is not '
+            'DELETING.\n' % branch_name,
+            logger.output)
+        self.assertEqual(branch, getUtility(IBranchLookup).get(branch_id))
+
+    def test_error(self):
+        # If deleting the branch fails, an error message is sent to the
+        # requester and the deletion status is set back to ACTIVE.  Most
+        # cases where this can happen are caught earlier, but this can still
+        # happen if a branch stacked on the to-be-deleted branch is created
+        # between the deletion job being created and being run.
+        branch = self.factory.makeAnyBranch()
+        branch_id = branch.id
+        requester = branch.registrant
+        job = getUtility(IBranchDeleteJobSource).create(branch, requester)
+        removeSecurityProxy(branch).deletion_status = (
+            BranchDeletionStatus.DELETING)
+        self.factory.makeAnyBranch(stacked_on=branch)
+        logger = self.useFixture(FakeLogger())
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            JobRunner([job]).runJobHandleError(job)
+        self.assertIn(
+            'failed with user error CannotDeleteBranch', logger.output)
+        self.assertEqual(branch, getUtility(IBranchLookup).get(branch_id))
+        self.assertEqual(BranchDeletionStatus.ACTIVE, branch.deletion_status)
+        self.assertEqual([], self.oopses)
+        [mail] = pop_notifications()
+        self.assertEqual(format_address_for_person(requester), mail['to'])
+        self.assertEqual(
+            'Launchpad error while deleting a branch', mail['subject'])
+        self.assertIn('Cannot delete branch', mail.get_payload(decode=True))

=== modified file 'lib/lp/code/stories/webservice/xx-branch.txt'
--- lib/lp/code/stories/webservice/xx-branch.txt	2018-05-13 10:35:52 +0000
+++ lib/lp/code/stories/webservice/xx-branch.txt	2019-03-21 15:55:38 +0000
@@ -112,6 +112,7 @@
     control_format: None
     date_created: u'2009-01-01T00:00:00+00:00'
     date_last_modified: u'2009-01-01T00:00:00+00:00'
+    deletion_status: u'Active'
     dependent_branches_collection_link: u'.../~eric/fooix/trunk/dependent_branches'
     description: None
     display_name: u'lp://dev/~eric/fooix/trunk'

=== modified file 'lib/lp/services/config/schema-lazr.conf'
--- lib/lp/services/config/schema-lazr.conf	2019-03-18 12:22:55 +0000
+++ lib/lp/services/config/schema-lazr.conf	2019-03-21 15:55:38 +0000
@@ -1867,6 +1867,7 @@
 # dbuser, the crontab_group, and the module that the job source class
 # can be loaded from.
 job_sources:
+    IBranchDeleteJobSource,
     IBranchModifiedMailJobSource,
     ICommercialExpiredJobSource,
     IExpiringMembershipNotificationJobSource,
@@ -1888,6 +1889,11 @@
     ITeamJoinNotificationJobSource,
     IThirtyDayCommercialExpirationJobSource
 
+[IBranchDeleteJobSource]
+module: lp.code.interfaces.branchjob
+dbuser: branch-delete-job
+crontab_group: MAIN
+
 [IBranchMergeProposalJobSource]
 module: lp.code.interfaces.branchmergeproposal
 dbuser: merge-proposal-jobs


Follow ups