Merge lp:~cjwatson/launchpad/code-async-delete into lp:launchpad

Proposed by Colin Watson
Status: Rejected
Rejected by: Colin Watson
Proposed branch: lp:~cjwatson/launchpad/code-async-delete
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/git-repository-delete-job
Diff against target: 704 lines (+350/-25)
15 files modified
lib/lp/code/browser/branch.py (+8/-4)
lib/lp/code/browser/gitrepository.py (+6/-2)
lib/lp/code/browser/tests/test_gitrepository.py (+20/-0)
lib/lp/code/interfaces/branch.py (+17/-4)
lib/lp/code/interfaces/gitrepository.py (+18/-5)
lib/lp/code/model/branch.py (+21/-2)
lib/lp/code/model/gitrepository.py (+17/-6)
lib/lp/code/model/tests/test_branch.py (+30/-0)
lib/lp/code/model/tests/test_gitrepository.py (+25/-0)
lib/lp/code/stories/branches/xx-branch-deletion.txt (+14/-0)
lib/lp/code/stories/webservice/xx-branch.txt (+14/-1)
lib/lp/code/templates/branch-index.pt (+4/-0)
lib/lp/code/templates/gitrepository-index.pt (+7/-0)
lib/lp/scripts/garbo.py (+65/-0)
lib/lp/scripts/tests/test_garbo.py (+84/-1)
To merge this branch: bzr merge lp:~cjwatson/launchpad/code-async-delete
Reviewer Review Type Date Requested Status
Launchpad code reviewers Pending
Review via email: mp+364912@code.launchpad.net

Commit message

Delete branches and Git repositories asynchronously.

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) wrote :

This seems unnecessary since adding some more indexes has made deletion fast enough, so withdrawing this until we have a clear need.

Unmerged revisions

18914. By Colin Watson

Delete branches and Git repositories asynchronously.

18913. By Colin Watson

Add a Git repository deletion job.

18912. By Colin Watson

Add a branch deletion job.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/lp/code/browser/branch.py'
--- lib/lp/code/browser/branch.py 2019-01-30 16:41:12 +0000
+++ lib/lp/code/browser/branch.py 2019-03-21 16:27:30 +0000
@@ -1,4 +1,4 @@
1# Copyright 2009-2018 Canonical Ltd. This software is licensed under the1# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Branch views."""4"""Branch views."""
@@ -87,7 +87,10 @@
87from lp.code.browser.decorations import DecoratedBranch87from lp.code.browser.decorations import DecoratedBranch
88from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin88from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
89from lp.code.browser.widgets.branchtarget import BranchTargetWidget89from lp.code.browser.widgets.branchtarget import BranchTargetWidget
90from lp.code.enums import BranchType90from lp.code.enums import (
91 BranchDeletionStatus,
92 BranchType,
93 )
91from lp.code.errors import (94from lp.code.errors import (
92 BranchCreationForbidden,95 BranchCreationForbidden,
93 BranchExists,96 BranchExists,
@@ -254,7 +257,8 @@
254 @enabled_with_permission('launchpad.Edit')257 @enabled_with_permission('launchpad.Edit')
255 def delete(self):258 def delete(self):
256 text = 'Delete branch'259 text = 'Delete branch'
257 return Link('+delete', text, icon='trash-icon')260 enabled = self.context.deletion_status != BranchDeletionStatus.DELETING
261 return Link('+delete', text, icon='trash-icon', enabled=enabled)
258262
259 @enabled_with_permission('launchpad.AnyPerson')263 @enabled_with_permission('launchpad.AnyPerson')
260 def edit_whiteboard(self):264 def edit_whiteboard(self):
@@ -992,7 +996,7 @@
992 # somewhere valid to send them next.996 # somewhere valid to send them next.
993 self.next_url = canonical_url(branch.target)997 self.next_url = canonical_url(branch.target)
994 message = "Branch %s deleted." % branch.unique_name998 message = "Branch %s deleted." % branch.unique_name
995 self.context.destroySelf(break_references=True)999 self.context.delete()
996 self.request.response.addNotification(message)1000 self.request.response.addNotification(message)
997 else:1001 else:
998 self.request.response.addNotification(1002 self.request.response.addNotification(
9991003
=== modified file 'lib/lp/code/browser/gitrepository.py'
--- lib/lp/code/browser/gitrepository.py 2019-01-30 16:41:12 +0000
+++ lib/lp/code/browser/gitrepository.py 2019-03-21 16:27:30 +0000
@@ -87,6 +87,7 @@
87 )87 )
88from lp.code.enums import (88from lp.code.enums import (
89 GitGranteeType,89 GitGranteeType,
90 GitRepositoryDeletionStatus,
90 GitRepositoryType,91 GitRepositoryType,
91 )92 )
92from lp.code.errors import (93from lp.code.errors import (
@@ -276,7 +277,10 @@
276 @enabled_with_permission("launchpad.Edit")277 @enabled_with_permission("launchpad.Edit")
277 def delete(self):278 def delete(self):
278 text = "Delete repository"279 text = "Delete repository"
279 return Link("+delete", text, icon="trash-icon")280 enabled = (
281 self.context.deletion_status !=
282 GitRepositoryDeletionStatus.DELETING)
283 return Link("+delete", text, icon="trash-icon", enabled=enabled)
280284
281285
282class GitRepositoryContextMenu(ContextMenu, HasRecipesMenuMixin):286class GitRepositoryContextMenu(ContextMenu, HasRecipesMenuMixin):
@@ -1294,7 +1298,7 @@
1294 # have somewhere valid to send them next.1298 # have somewhere valid to send them next.
1295 self.next_url = canonical_url(repository.target)1299 self.next_url = canonical_url(repository.target)
1296 message = "Repository %s deleted." % repository.unique_name1300 message = "Repository %s deleted." % repository.unique_name
1297 self.context.destroySelf(break_references=True)1301 self.context.delete()
1298 self.request.response.addNotification(message)1302 self.request.response.addNotification(message)
1299 else:1303 else:
1300 self.request.response.addNotification(1304 self.request.response.addNotification(
13011305
=== modified file 'lib/lp/code/browser/tests/test_gitrepository.py'
--- lib/lp/code/browser/tests/test_gitrepository.py 2019-01-30 17:24:15 +0000
+++ lib/lp/code/browser/tests/test_gitrepository.py 2019-03-21 16:27:30 +0000
@@ -16,6 +16,7 @@
16from textwrap import dedent16from textwrap import dedent
1717
18from fixtures import FakeLogger18from fixtures import FakeLogger
19from mechanize import LinkNotFoundError
19import pytz20import pytz
20import soupmatchers21import soupmatchers
21from storm.store import Store22from storm.store import Store
@@ -1829,6 +1830,25 @@
1829 ["Repository %s deleted." % name],1830 ["Repository %s deleted." % name],
1830 get_feedback_messages(browser.contents))1831 get_feedback_messages(browser.contents))
18311832
1833 def test_deleting_repository(self):
1834 # Repository deletion is asynchronous. If the user finds the
1835 # repository again before the deletion completes, then they see an
1836 # indication that it is being deleted, and there is no "Delete
1837 # repository" action.
1838 owner = self.factory.makePerson()
1839 repository = self.factory.makeGitRepository(owner=owner)
1840 with person_logged_in(repository.owner):
1841 repository.delete()
1842 self.assertEndsWith(repository.unique_name, "-deleting")
1843 browser = self.getViewBrowser(
1844 repository, "+index", rootsite="code", user=repository.owner)
1845 self.assertEqual(
1846 "This repository is being deleted.",
1847 extract_text(find_tag_by_id(
1848 browser.contents, "repository-deleting")))
1849 self.assertRaises(
1850 LinkNotFoundError, browser.getLink, "Delete repository")
1851
18321852
1833class TestGitRepositoryActivityView(BrowserTestCase):1853class TestGitRepositoryActivityView(BrowserTestCase):
18341854
18351855
=== modified file 'lib/lp/code/interfaces/branch.py'
--- lib/lp/code/interfaces/branch.py 2019-03-21 16:27:29 +0000
+++ lib/lp/code/interfaces/branch.py 2019-03-21 16:27:30 +0000
@@ -1228,9 +1228,6 @@
1228 branch.1228 branch.
1229 """1229 """
12301230
1231 @call_with(break_references=True)
1232 @export_destructor_operation()
1233 @operation_for_version('beta')
1234 def destroySelf(break_references=False):1231 def destroySelf(break_references=False):
1235 """Delete the specified branch.1232 """Delete the specified branch.
12361233
@@ -1239,7 +1236,23 @@
1239 :param break_references: If supplied, break any references to this1236 :param break_references: If supplied, break any references to this
1240 branch by deleting items with mandatory references and1237 branch by deleting items with mandatory references and
1241 NULLing other references.1238 NULLing other references.
1242 :raise: CannotDeleteBranch if the branch cannot be deleted.1239 :raises CannotDeleteBranch: if the branch cannot be deleted.
1240 """
1241
1242 @export_destructor_operation()
1243 @operation_for_version('beta')
1244 def delete():
1245 """Request deletion of the specified branch.
1246
1247 The branch will be deleted asynchronously.
1248
1249 This will delete any merge proposals that use this branch as their
1250 source or target, and any recipes that use this branch. It will
1251 clear the prerequisite from any merge proposals that use this branch
1252 as a prerequisite. It will detach the branch from any snap packages
1253 that build from it.
1254
1255 :raises CannotDeleteBranch: if the branch cannot be deleted.
1243 """1256 """
12441257
12451258
12461259
=== modified file 'lib/lp/code/interfaces/gitrepository.py'
--- lib/lp/code/interfaces/gitrepository.py 2019-03-21 16:27:29 +0000
+++ lib/lp/code/interfaces/gitrepository.py 2019-03-21 16:27:30 +0000
@@ -1,4 +1,4 @@
1# Copyright 2015-2018 Canonical Ltd. This software is licensed under the1# Copyright 2015-2019 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Git repository interfaces."""4"""Git repository interfaces."""
@@ -881,16 +881,29 @@
881 object needs to be touched.881 object needs to be touched.
882 """882 """
883883
884 @call_with(break_references=True)
885 @export_destructor_operation()
886 @operation_for_version("devel")
887 def destroySelf(break_references=False):884 def destroySelf(break_references=False):
888 """Delete the specified repository.885 """Delete the specified repository.
889886
890 :param break_references: If supplied, break any references to this887 :param break_references: If supplied, break any references to this
891 repository by deleting items with mandatory references and888 repository by deleting items with mandatory references and
892 NULLing other references.889 NULLing other references.
893 :raise: CannotDeleteGitRepository if the repository cannot be deleted.890 :raises CannotDeleteGitRepository: if the repository cannot be deleted.
891 """
892
893 @export_destructor_operation()
894 @operation_for_version("devel")
895 def delete():
896 """Request deletion of the specified repository.
897
898 The repository will be deleted asynchronously.
899
900 This will delete any merge proposals that use this repository as
901 their source or target, and any recipes that use this repository.
902 It will clear the prerequisite from any merge proposals that use
903 this repository as a prerequisite. It will detach the repository
904 from any snap packages that build from it.
905
906 :raises CannotDeleteGitRepository: if the repository cannot be deleted.
894 """907 """
895908
896909
897910
=== modified file 'lib/lp/code/model/branch.py'
--- lib/lp/code/model/branch.py 2019-03-21 16:27:29 +0000
+++ lib/lp/code/model/branch.py 2019-03-21 16:27:30 +0000
@@ -112,6 +112,11 @@
112 )112 )
113from lp.code.interfaces.branchcollection import IAllBranches113from lp.code.interfaces.branchcollection import IAllBranches
114from lp.code.interfaces.branchhosting import IBranchHostingClient114from lp.code.interfaces.branchhosting import IBranchHostingClient
115from lp.code.interfaces.branchjob import (
116 IBranchDeleteJobSource,
117 IBranchUpgradeJobSource,
118 IReclaimBranchSpaceJobSource,
119 )
115from lp.code.interfaces.branchlookup import IBranchLookup120from lp.code.interfaces.branchlookup import IBranchLookup
116from lp.code.interfaces.branchmergeproposal import (121from lp.code.interfaces.branchmergeproposal import (
117 BRANCH_MERGE_PROPOSAL_FINAL_STATES,122 BRANCH_MERGE_PROPOSAL_FINAL_STATES,
@@ -1455,7 +1460,6 @@
14551460
1456 def destroySelf(self, break_references=False):1461 def destroySelf(self, break_references=False):
1457 """See `IBranch`."""1462 """See `IBranch`."""
1458 from lp.code.interfaces.branchjob import IReclaimBranchSpaceJobSource
1459 if break_references:1463 if break_references:
1460 self._breakReferences()1464 self._breakReferences()
1461 if not self.canBeDeleted():1465 if not self.canBeDeleted():
@@ -1473,6 +1477,22 @@
1473 job = getUtility(IReclaimBranchSpaceJobSource).create(branch_id)1477 job = getUtility(IReclaimBranchSpaceJobSource).create(branch_id)
1474 job.celeryRunOnCommit()1478 job.celeryRunOnCommit()
14751479
1480 def delete(self):
1481 """See `IBranch`."""
1482 if self.deletion_status == BranchDeletionStatus.DELETING:
1483 raise CannotDeleteBranch(
1484 "This branch is already being deleted: %s" % self.unique_name)
1485 if not self.getStackedBranches().is_empty():
1486 # If there are branches stacked on this one, then we won't be
1487 # able to delete this branch even after breaking references.
1488 raise CannotDeleteBranch(
1489 "Cannot delete branch: %s" % self.unique_name)
1490 # Destructor operations can't take arguments.
1491 user = getUtility(ILaunchBag).user
1492 getUtility(IBranchDeleteJobSource).create(self, user)
1493 self.name = self.namespace.findUnusedName(self.name + "-deleting")
1494 self.deletion_status = BranchDeletionStatus.DELETING
1495
1476 def checkUpgrade(self):1496 def checkUpgrade(self):
1477 if self.branch_type is not BranchType.HOSTED:1497 if self.branch_type is not BranchType.HOSTED:
1478 raise CannotUpgradeNonHosted(self)1498 raise CannotUpgradeNonHosted(self)
@@ -1509,7 +1529,6 @@
15091529
1510 def requestUpgrade(self, requester):1530 def requestUpgrade(self, requester):
1511 """See `IBranch`."""1531 """See `IBranch`."""
1512 from lp.code.interfaces.branchjob import IBranchUpgradeJobSource
1513 job = getUtility(IBranchUpgradeJobSource).create(self, requester)1532 job = getUtility(IBranchUpgradeJobSource).create(self, requester)
1514 job.celeryRunOnCommit()1533 job.celeryRunOnCommit()
1515 return job1534 return job
15161535
=== modified file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py 2019-03-21 16:27:29 +0000
+++ lib/lp/code/model/gitrepository.py 2019-03-21 16:27:30 +0000
@@ -112,7 +112,11 @@
112 IGitCollection,112 IGitCollection,
113 )113 )
114from lp.code.interfaces.githosting import IGitHostingClient114from lp.code.interfaces.githosting import IGitHostingClient
115from lp.code.interfaces.gitjob import IGitRefScanJobSource115from lp.code.interfaces.gitjob import (
116 IGitRefScanJobSource,
117 IGitRepositoryDeleteJobSource,
118 IReclaimGitRepositorySpaceJobSource,
119 )
116from lp.code.interfaces.gitlookup import IGitLookup120from lp.code.interfaces.gitlookup import IGitLookup
117from lp.code.interfaces.gitnamespace import (121from lp.code.interfaces.gitnamespace import (
118 get_git_namespace,122 get_git_namespace,
@@ -1551,11 +1555,6 @@
15511555
1552 def destroySelf(self, break_references=False):1556 def destroySelf(self, break_references=False):
1553 """See `IGitRepository`."""1557 """See `IGitRepository`."""
1554 # Circular import.
1555 from lp.code.interfaces.gitjob import (
1556 IReclaimGitRepositorySpaceJobSource,
1557 )
1558
1559 if break_references:1558 if break_references:
1560 self._breakReferences()1559 self._breakReferences()
1561 if not self.canBeDeleted():1560 if not self.canBeDeleted():
@@ -1583,6 +1582,18 @@
1583 getUtility(IReclaimGitRepositorySpaceJobSource).create(1582 getUtility(IReclaimGitRepositorySpaceJobSource).create(
1584 repository_name, repository_path)1583 repository_name, repository_path)
15851584
1585 def delete(self):
1586 """See `IGitRepository`."""
1587 if self.deletion_status == GitRepositoryDeletionStatus.DELETING:
1588 raise CannotDeleteGitRepository(
1589 "This repository is already being deleted: %s" %
1590 self.unique_name)
1591 # Destructor operations can't take arguments.
1592 user = getUtility(ILaunchBag).user
1593 getUtility(IGitRepositoryDeleteJobSource).create(self, user)
1594 self.name = self.namespace.findUnusedName(self.name + "-deleting")
1595 self.deletion_status = GitRepositoryDeletionStatus.DELETING
1596
15861597
1587class DeletionOperation:1598class DeletionOperation:
1588 """Represent an operation to perform as part of branch deletion."""1599 """Represent an operation to perform as part of branch deletion."""
15891600
=== modified file 'lib/lp/code/model/tests/test_branch.py'
--- lib/lp/code/model/tests/test_branch.py 2019-03-21 16:27:29 +0000
+++ lib/lp/code/model/tests/test_branch.py 2019-03-21 16:27:30 +0000
@@ -53,6 +53,7 @@
53 RepositoryFormat,53 RepositoryFormat,
54 )54 )
55from lp.code.enums import (55from lp.code.enums import (
56 BranchDeletionStatus,
56 BranchLifecycleStatus,57 BranchLifecycleStatus,
57 BranchSubscriptionNotificationLevel,58 BranchSubscriptionNotificationLevel,
58 BranchType,59 BranchType,
@@ -74,6 +75,7 @@
74 IBranch,75 IBranch,
75 )76 )
76from lp.code.interfaces.branchjob import (77from lp.code.interfaces.branchjob import (
78 IBranchDeleteJobSource,
77 IBranchScanJobSource,79 IBranchScanJobSource,
78 IBranchUpgradeJobSource,80 IBranchUpgradeJobSource,
79 )81 )
@@ -1483,6 +1485,34 @@
1483 transaction.commit()1485 transaction.commit()
1484 self.assertRaises(LostObjectError, getattr, webhook, 'target')1486 self.assertRaises(LostObjectError, getattr, webhook, 'target')
14851487
1488 def test_delete_schedules_job(self):
1489 # Calling the delete method schedules a deletion job, which can be
1490 # processed.
1491 branch_id = self.branch.id
1492 branch_name = self.branch.unique_name
1493 self.branch.delete()
1494 update_trigger_modified_fields(self.branch)
1495 self.assertEqual(branch_name + "-deleting", self.branch.unique_name)
1496 self.assertEqual(
1497 BranchDeletionStatus.DELETING, self.branch.deletion_status)
1498 [job] = getUtility(IBranchDeleteJobSource).iterReady()
1499 self.assertEqual(branch_id, job.branch_id)
1500 self.assertEqual(self.user, job.requester)
1501 with dbuser(config.IBranchDeleteJobSource.dbuser):
1502 JobRunner([job]).runAll()
1503
1504 def test_delete_already_deleting(self):
1505 # Trying to delete a branch that is already being deleted raises
1506 # CannotDeleteBranch.
1507 self.branch.delete()
1508 self.assertRaises(CannotDeleteBranch, self.branch.delete)
1509
1510 def test_delete_stacked_branches(self):
1511 # Trying to delete a branch that is stacked upon raises
1512 # CannotDeleteBranch.
1513 self.factory.makeAnyBranch(stacked_on=self.branch)
1514 self.assertRaises(CannotDeleteBranch, self.branch.delete)
1515
14861516
1487class TestBranchDeletionConsequences(TestCase):1517class TestBranchDeletionConsequences(TestCase):
1488 """Test determination and application of branch deletion consequences."""1518 """Test determination and application of branch deletion consequences."""
14891519
=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
--- lib/lp/code/model/tests/test_gitrepository.py 2019-03-21 16:27:29 +0000
+++ lib/lp/code/model/tests/test_gitrepository.py 2019-03-21 16:27:30 +0000
@@ -52,6 +52,7 @@
52 CodeReviewNotificationLevel,52 CodeReviewNotificationLevel,
53 GitGranteeType,53 GitGranteeType,
54 GitObjectType,54 GitObjectType,
55 GitRepositoryDeletionStatus,
55 GitRepositoryType,56 GitRepositoryType,
56 TargetRevisionControlSystems,57 TargetRevisionControlSystems,
57 )58 )
@@ -71,6 +72,7 @@
71from lp.code.interfaces.defaultgit import ICanHasDefaultGitRepository72from lp.code.interfaces.defaultgit import ICanHasDefaultGitRepository
72from lp.code.interfaces.gitjob import (73from lp.code.interfaces.gitjob import (
73 IGitRefScanJobSource,74 IGitRefScanJobSource,
75 IGitRepositoryDeleteJobSource,
74 IGitRepositoryModifiedMailJobSource,76 IGitRepositoryModifiedMailJobSource,
75 )77 )
76from lp.code.interfaces.gitlookup import IGitLookup78from lp.code.interfaces.gitlookup import IGitLookup
@@ -818,6 +820,29 @@
818 GitActivity, GitActivity.repository_id == repository_id)820 GitActivity, GitActivity.repository_id == repository_id)
819 self.assertEqual([], list(activities))821 self.assertEqual([], list(activities))
820822
823 def test_delete_schedules_job(self):
824 # Calling the delete method schedules a deletion job, which can be
825 # processed.
826 repository_id = self.repository.id
827 repository_name = self.repository.unique_name
828 self.repository.delete()
829 self.assertEqual(
830 repository_name + "-deleting", self.repository.unique_name)
831 self.assertEqual(
832 GitRepositoryDeletionStatus.DELETING,
833 self.repository.deletion_status)
834 [job] = getUtility(IGitRepositoryDeleteJobSource).iterReady()
835 self.assertEqual(repository_id, job.repository_id)
836 self.assertEqual(self.user, job.requester)
837 with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
838 JobRunner([job]).runAll()
839
840 def test_delete_already_deleting(self):
841 # Trying to delete a repository that is already being deleted raises
842 # CannotDeleteGitRepository.
843 self.repository.delete()
844 self.assertRaises(CannotDeleteGitRepository, self.repository.delete)
845
821846
822class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):847class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
823 """Test determination and application of repository deletion848 """Test determination and application of repository deletion
824849
=== modified file 'lib/lp/code/stories/branches/xx-branch-deletion.txt'
--- lib/lp/code/stories/branches/xx-branch-deletion.txt 2018-05-13 10:35:52 +0000
+++ lib/lp/code/stories/branches/xx-branch-deletion.txt 2019-03-21 16:27:30 +0000
@@ -52,6 +52,20 @@
52 >>> print_feedback_messages(browser.contents)52 >>> print_feedback_messages(browser.contents)
53 Branch ~alice/earthlynx/to-delete deleted...53 Branch ~alice/earthlynx/to-delete deleted...
5454
55Branch deletion is asynchronous. If the user finds the branch again before
56the deletion completes, then they see an indication that it is being
57deleted, and there is no 'Delete branch' action.
58
59 >>> browser.open(
60 ... 'http://code.launchpad.dev/~alice/earthlynx/to-delete-deleting')
61 >>> print(extract_text(find_tag_by_id(
62 ... browser.contents, 'branch-deleting')))
63 This branch is being deleted.
64 >>> browser.getLink('Delete branch')
65 Traceback (most recent call last):
66 ...
67 LinkNotFoundError
68
55If the branch is junk, then the user is taken back to the code listing for69If the branch is junk, then the user is taken back to the code listing for
56the deleted branch's owner.70the deleted branch's owner.
5771
5872
=== modified file 'lib/lp/code/stories/webservice/xx-branch.txt'
--- lib/lp/code/stories/webservice/xx-branch.txt 2019-03-21 16:27:29 +0000
+++ lib/lp/code/stories/webservice/xx-branch.txt 2019-03-21 16:27:30 +0000
@@ -300,4 +300,17 @@
300300
301 >>> print_branches(webservice, '/widgets')301 >>> print_branches(webservice, '/widgets')
302 ~eric/fooix/trunk - Development302 ~eric/fooix/trunk - Development
303303 ~eric/fooix/feature-branch-deleting - Experimental
304 ~mary/blob/bar-deleting - Development
305
306 >>> from zope.component import getUtility
307 >>> from lp.code.interfaces.branchjob import IBranchDeleteJobSource
308 >>> from lp.services.config import config
309 >>> from lp.services.job.runner import JobRunner
310
311 >>> with permissive_security_policy(config.IBranchDeleteJobSource.dbuser):
312 ... job_source = getUtility(IBranchDeleteJobSource)
313 ... JobRunner.fromReady(job_source).runAll()
314
315 >>> print_branches(webservice, '/widgets')
316 ~eric/fooix/trunk - Development
304317
=== modified file 'lib/lp/code/templates/branch-index.pt'
--- lib/lp/code/templates/branch-index.pt 2019-01-23 17:52:34 +0000
+++ lib/lp/code/templates/branch-index.pt 2019-03-21 16:27:30 +0000
@@ -67,6 +67,10 @@
67<div metal:fill-slot="main">67<div metal:fill-slot="main">
6868
69 <div class="yui-g first">69 <div class="yui-g first">
70 <div tal:condition="python: context.deletion_status.name == 'DELETING'"
71 id="branch-deleting" class="warning message">
72 This branch is being deleted.
73 </div>
70 <tal:branch-errors tal:replace="structure context/@@+messages" />74 <tal:branch-errors tal:replace="structure context/@@+messages" />
71 </div>75 </div>
7276
7377
=== modified file 'lib/lp/code/templates/gitrepository-index.pt'
--- lib/lp/code/templates/gitrepository-index.pt 2019-01-28 15:36:56 +0000
+++ lib/lp/code/templates/gitrepository-index.pt 2019-03-21 16:27:30 +0000
@@ -33,6 +33,13 @@
3333
34<div metal:fill-slot="main">34<div metal:fill-slot="main">
3535
36 <div class="yui-g first">
37 <div tal:condition="python: context.deletion_status.name == 'DELETING'"
38 id="repository-deleting" class="warning message">
39 This repository is being deleted.
40 </div>
41 </div>
42
36 <div id="repository-description" tal:condition="context/description"43 <div id="repository-description" tal:condition="context/description"
37 class="summary"44 class="summary"
38 tal:content="structure context/description/fmt:text-to-html" />45 tal:content="structure context/description/fmt:text-to-html" />
3946
=== modified file 'lib/lp/scripts/garbo.py'
--- lib/lp/scripts/garbo.py 2019-01-30 11:23:33 +0000
+++ lib/lp/scripts/garbo.py 2019-03-21 16:27:30 +0000
@@ -40,6 +40,7 @@
40 Or,40 Or,
41 Row,41 Row,
42 SQL,42 SQL,
43 Update,
43 )44 )
44from storm.info import ClassAlias45from storm.info import ClassAlias
45from storm.store import EmptyResultSet46from storm.store import EmptyResultSet
@@ -57,13 +58,19 @@
57 BugWatchScheduler,58 BugWatchScheduler,
58 MAX_SAMPLE_SIZE,59 MAX_SAMPLE_SIZE,
59 )60 )
61from lp.code.enums import (
62 BranchDeletionStatus,
63 GitRepositoryDeletionStatus,
64 )
60from lp.code.interfaces.revision import IRevisionSet65from lp.code.interfaces.revision import IRevisionSet
66from lp.code.model.branch import Branch
61from lp.code.model.codeimportevent import CodeImportEvent67from lp.code.model.codeimportevent import CodeImportEvent
62from lp.code.model.codeimportresult import CodeImportResult68from lp.code.model.codeimportresult import CodeImportResult
63from lp.code.model.diff import (69from lp.code.model.diff import (
64 Diff,70 Diff,
65 PreviewDiff,71 PreviewDiff,
66 )72 )
73from lp.code.model.gitrepository import GitRepository
67from lp.code.model.revision import (74from lp.code.model.revision import (
68 RevisionAuthor,75 RevisionAuthor,
69 RevisionCache,76 RevisionCache,
@@ -1611,6 +1618,62 @@
1611 """ % (SnapBuildJobType.STORE_UPLOAD.value, JobStatus.COMPLETED.value)1618 """ % (SnapBuildJobType.STORE_UPLOAD.value, JobStatus.COMPLETED.value)
16121619
16131620
1621class BranchDeletionStatusPopulator(TunableLoop):
1622 """Populates Branch.deletion_status with ACTIVE."""
1623
1624 maximum_chunk_size = 5000
1625
1626 def __init__(self, log, abort_time=None):
1627 super(BranchDeletionStatusPopulator, self).__init__(log, abort_time)
1628 self.start_at = 1
1629 self.store = IMasterStore(Branch)
1630
1631 def findBranches(self):
1632 return self.store.find(
1633 Branch,
1634 Branch.id >= self.start_at,
1635 Branch._deletion_status == None).order_by(Branch.id)
1636
1637 def isDone(self):
1638 return self.findBranches().is_empty()
1639
1640 def __call__(self, chunk_size):
1641 ids = [branch.id for branch in self.findBranches()]
1642 self.store.execute(Update(
1643 {Branch._deletion_status: BranchDeletionStatus.ACTIVE.value},
1644 where=Branch.id.is_in(ids), table=Branch))
1645 transaction.commit()
1646
1647
1648class GitRepositoryDeletionStatusPopulator(TunableLoop):
1649 """Populates GitRepository.deletion_status with ACTIVE."""
1650
1651 maximum_chunk_size = 5000
1652
1653 def __init__(self, log, abort_time=None):
1654 super(GitRepositoryDeletionStatusPopulator, self).__init__(
1655 log, abort_time)
1656 self.start_at = 1
1657 self.store = IMasterStore(GitRepository)
1658
1659 def findRepositories(self):
1660 return self.store.find(
1661 GitRepository,
1662 GitRepository.id >= self.start_at,
1663 GitRepository._deletion_status == None).order_by(GitRepository.id)
1664
1665 def isDone(self):
1666 return self.findRepositories().is_empty()
1667
1668 def __call__(self, chunk_size):
1669 ids = [branch.id for branch in self.findRepositories()]
1670 self.store.execute(Update(
1671 {GitRepository._deletion_status:
1672 GitRepositoryDeletionStatus.ACTIVE.value},
1673 where=GitRepository.id.is_in(ids), table=GitRepository))
1674 transaction.commit()
1675
1676
1614class BaseDatabaseGarbageCollector(LaunchpadCronScript):1677class BaseDatabaseGarbageCollector(LaunchpadCronScript):
1615 """Abstract base class to run a collection of TunableLoops."""1678 """Abstract base class to run a collection of TunableLoops."""
1616 script_name = None # Script name for locking and database user. Override.1679 script_name = None # Script name for locking and database user. Override.
@@ -1884,6 +1947,7 @@
1884 script_name = 'garbo-daily'1947 script_name = 'garbo-daily'
1885 tunable_loops = [1948 tunable_loops = [
1886 AnswerContactPruner,1949 AnswerContactPruner,
1950 BranchDeletionStatusPopulator,
1887 BranchJobPruner,1951 BranchJobPruner,
1888 BugNotificationPruner,1952 BugNotificationPruner,
1889 BugWatchActivityPruner,1953 BugWatchActivityPruner,
@@ -1891,6 +1955,7 @@
1891 CodeImportResultPruner,1955 CodeImportResultPruner,
1892 DiffPruner,1956 DiffPruner,
1893 GitJobPruner,1957 GitJobPruner,
1958 GitRepositoryDeletionStatusPopulator,
1894 HWSubmissionEmailLinker,1959 HWSubmissionEmailLinker,
1895 LiveFSFilePruner,1960 LiveFSFilePruner,
1896 LoginTokenPruner,1961 LoginTokenPruner,
18971962
=== modified file 'lib/lp/scripts/tests/test_garbo.py'
--- lib/lp/scripts/tests/test_garbo.py 2019-01-30 11:23:33 +0000
+++ lib/lp/scripts/tests/test_garbo.py 2019-03-21 16:27:30 +0000
@@ -15,6 +15,7 @@
15from StringIO import StringIO15from StringIO import StringIO
16import time16import time
1717
18from psycopg2 import IntegrityError
18from pytz import UTC19from pytz import UTC
19from storm.exceptions import LostObjectError20from storm.exceptions import LostObjectError
20from storm.expr import (21from storm.expr import (
@@ -54,7 +55,11 @@
54 BranchFormat,55 BranchFormat,
55 RepositoryFormat,56 RepositoryFormat,
56 )57 )
57from lp.code.enums import CodeImportResultStatus58from lp.code.enums import (
59 BranchDeletionStatus,
60 CodeImportResultStatus,
61 GitRepositoryDeletionStatus,
62 )
58from lp.code.interfaces.codeimportevent import ICodeImportEventSet63from lp.code.interfaces.codeimportevent import ICodeImportEventSet
59from lp.code.interfaces.gitrepository import IGitRepositorySet64from lp.code.interfaces.gitrepository import IGitRepositorySet
60from lp.code.model.branchjob import (65from lp.code.model.branchjob import (
@@ -1606,6 +1611,84 @@
1606 # retained.1611 # retained.
1607 self._test_SnapFilePruner('foo.snap', None, 30, expected_count=1)1612 self._test_SnapFilePruner('foo.snap', None, 30, expected_count=1)
16081613
1614 def test_BranchDeletionStatusPopulator(self):
1615 switch_dbuser('testadmin')
1616 old_branches = [self.factory.makeAnyBranch() for _ in range(2)]
1617 for branch in old_branches:
1618 removeSecurityProxy(branch)._deletion_status = None
1619 try:
1620 Store.of(old_branches[0]).flush()
1621 except IntegrityError:
1622 # Now enforced by DB NOT NULL constraint; backfilling is no
1623 # longer necessary.
1624 return
1625 active_branches = [self.factory.makeAnyBranch() for _ in range(2)]
1626 for branch in active_branches:
1627 removeSecurityProxy(branch)._deletion_status = (
1628 BranchDeletionStatus.ACTIVE)
1629 deleting_branches = [self.factory.makeAnyBranch() for _ in range(2)]
1630 for branch in deleting_branches:
1631 removeSecurityProxy(branch)._deletion_status = (
1632 BranchDeletionStatus.DELETING)
1633 transaction.commit()
1634
1635 self.runDaily()
1636
1637 # Old branches are backfilled.
1638 for branch in old_branches:
1639 self.assertEqual(
1640 BranchDeletionStatus.ACTIVE,
1641 removeSecurityProxy(branch)._deletion_status)
1642 # Other branches are left alone.
1643 for branch in active_branches:
1644 self.assertEqual(
1645 BranchDeletionStatus.ACTIVE,
1646 removeSecurityProxy(branch)._deletion_status)
1647 for branch in deleting_branches:
1648 self.assertEqual(
1649 BranchDeletionStatus.DELETING,
1650 removeSecurityProxy(branch)._deletion_status)
1651
1652 def test_GitRepositoryDeletionStatusPopulator(self):
1653 switch_dbuser('testadmin')
1654 old_repositories = [self.factory.makeGitRepository() for _ in range(2)]
1655 for repository in old_repositories:
1656 removeSecurityProxy(repository)._deletion_status = None
1657 try:
1658 Store.of(old_repositories[0]).flush()
1659 except IntegrityError:
1660 # Now enforced by DB NOT NULL constraint; backfilling is no
1661 # longer necessary.
1662 return
1663 active_repositories = [
1664 self.factory.makeGitRepository() for _ in range(2)]
1665 for repository in active_repositories:
1666 removeSecurityProxy(repository)._deletion_status = (
1667 GitRepositoryDeletionStatus.ACTIVE)
1668 deleting_repositories = [
1669 self.factory.makeGitRepository() for _ in range(2)]
1670 for repository in deleting_repositories:
1671 removeSecurityProxy(repository)._deletion_status = (
1672 GitRepositoryDeletionStatus.DELETING)
1673 transaction.commit()
1674
1675 self.runDaily()
1676
1677 # Old repositories are backfilled.
1678 for repository in old_repositories:
1679 self.assertEqual(
1680 GitRepositoryDeletionStatus.ACTIVE,
1681 removeSecurityProxy(repository)._deletion_status)
1682 # Other repositories are left alone.
1683 for repository in active_repositories:
1684 self.assertEqual(
1685 GitRepositoryDeletionStatus.ACTIVE,
1686 removeSecurityProxy(repository)._deletion_status)
1687 for repository in deleting_repositories:
1688 self.assertEqual(
1689 GitRepositoryDeletionStatus.DELETING,
1690 removeSecurityProxy(repository)._deletion_status)
1691
16091692
1610class TestGarboTasks(TestCaseWithFactory):1693class TestGarboTasks(TestCaseWithFactory):
1611 layer = LaunchpadZopelessLayer1694 layer = LaunchpadZopelessLayer