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
1=== modified file 'lib/lp/code/browser/branch.py'
2--- lib/lp/code/browser/branch.py 2019-01-30 16:41:12 +0000
3+++ lib/lp/code/browser/branch.py 2019-03-21 16:27:30 +0000
4@@ -1,4 +1,4 @@
5-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
6+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
7 # GNU Affero General Public License version 3 (see the file LICENSE).
8
9 """Branch views."""
10@@ -87,7 +87,10 @@
11 from lp.code.browser.decorations import DecoratedBranch
12 from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
13 from lp.code.browser.widgets.branchtarget import BranchTargetWidget
14-from lp.code.enums import BranchType
15+from lp.code.enums import (
16+ BranchDeletionStatus,
17+ BranchType,
18+ )
19 from lp.code.errors import (
20 BranchCreationForbidden,
21 BranchExists,
22@@ -254,7 +257,8 @@
23 @enabled_with_permission('launchpad.Edit')
24 def delete(self):
25 text = 'Delete branch'
26- return Link('+delete', text, icon='trash-icon')
27+ enabled = self.context.deletion_status != BranchDeletionStatus.DELETING
28+ return Link('+delete', text, icon='trash-icon', enabled=enabled)
29
30 @enabled_with_permission('launchpad.AnyPerson')
31 def edit_whiteboard(self):
32@@ -992,7 +996,7 @@
33 # somewhere valid to send them next.
34 self.next_url = canonical_url(branch.target)
35 message = "Branch %s deleted." % branch.unique_name
36- self.context.destroySelf(break_references=True)
37+ self.context.delete()
38 self.request.response.addNotification(message)
39 else:
40 self.request.response.addNotification(
41
42=== modified file 'lib/lp/code/browser/gitrepository.py'
43--- lib/lp/code/browser/gitrepository.py 2019-01-30 16:41:12 +0000
44+++ lib/lp/code/browser/gitrepository.py 2019-03-21 16:27:30 +0000
45@@ -87,6 +87,7 @@
46 )
47 from lp.code.enums import (
48 GitGranteeType,
49+ GitRepositoryDeletionStatus,
50 GitRepositoryType,
51 )
52 from lp.code.errors import (
53@@ -276,7 +277,10 @@
54 @enabled_with_permission("launchpad.Edit")
55 def delete(self):
56 text = "Delete repository"
57- return Link("+delete", text, icon="trash-icon")
58+ enabled = (
59+ self.context.deletion_status !=
60+ GitRepositoryDeletionStatus.DELETING)
61+ return Link("+delete", text, icon="trash-icon", enabled=enabled)
62
63
64 class GitRepositoryContextMenu(ContextMenu, HasRecipesMenuMixin):
65@@ -1294,7 +1298,7 @@
66 # have somewhere valid to send them next.
67 self.next_url = canonical_url(repository.target)
68 message = "Repository %s deleted." % repository.unique_name
69- self.context.destroySelf(break_references=True)
70+ self.context.delete()
71 self.request.response.addNotification(message)
72 else:
73 self.request.response.addNotification(
74
75=== modified file 'lib/lp/code/browser/tests/test_gitrepository.py'
76--- lib/lp/code/browser/tests/test_gitrepository.py 2019-01-30 17:24:15 +0000
77+++ lib/lp/code/browser/tests/test_gitrepository.py 2019-03-21 16:27:30 +0000
78@@ -16,6 +16,7 @@
79 from textwrap import dedent
80
81 from fixtures import FakeLogger
82+from mechanize import LinkNotFoundError
83 import pytz
84 import soupmatchers
85 from storm.store import Store
86@@ -1829,6 +1830,25 @@
87 ["Repository %s deleted." % name],
88 get_feedback_messages(browser.contents))
89
90+ def test_deleting_repository(self):
91+ # Repository deletion is asynchronous. If the user finds the
92+ # repository again before the deletion completes, then they see an
93+ # indication that it is being deleted, and there is no "Delete
94+ # repository" action.
95+ owner = self.factory.makePerson()
96+ repository = self.factory.makeGitRepository(owner=owner)
97+ with person_logged_in(repository.owner):
98+ repository.delete()
99+ self.assertEndsWith(repository.unique_name, "-deleting")
100+ browser = self.getViewBrowser(
101+ repository, "+index", rootsite="code", user=repository.owner)
102+ self.assertEqual(
103+ "This repository is being deleted.",
104+ extract_text(find_tag_by_id(
105+ browser.contents, "repository-deleting")))
106+ self.assertRaises(
107+ LinkNotFoundError, browser.getLink, "Delete repository")
108+
109
110 class TestGitRepositoryActivityView(BrowserTestCase):
111
112
113=== modified file 'lib/lp/code/interfaces/branch.py'
114--- lib/lp/code/interfaces/branch.py 2019-03-21 16:27:29 +0000
115+++ lib/lp/code/interfaces/branch.py 2019-03-21 16:27:30 +0000
116@@ -1228,9 +1228,6 @@
117 branch.
118 """
119
120- @call_with(break_references=True)
121- @export_destructor_operation()
122- @operation_for_version('beta')
123 def destroySelf(break_references=False):
124 """Delete the specified branch.
125
126@@ -1239,7 +1236,23 @@
127 :param break_references: If supplied, break any references to this
128 branch by deleting items with mandatory references and
129 NULLing other references.
130- :raise: CannotDeleteBranch if the branch cannot be deleted.
131+ :raises CannotDeleteBranch: if the branch cannot be deleted.
132+ """
133+
134+ @export_destructor_operation()
135+ @operation_for_version('beta')
136+ def delete():
137+ """Request deletion of the specified branch.
138+
139+ The branch will be deleted asynchronously.
140+
141+ This will delete any merge proposals that use this branch as their
142+ source or target, and any recipes that use this branch. It will
143+ clear the prerequisite from any merge proposals that use this branch
144+ as a prerequisite. It will detach the branch from any snap packages
145+ that build from it.
146+
147+ :raises CannotDeleteBranch: if the branch cannot be deleted.
148 """
149
150
151
152=== modified file 'lib/lp/code/interfaces/gitrepository.py'
153--- lib/lp/code/interfaces/gitrepository.py 2019-03-21 16:27:29 +0000
154+++ lib/lp/code/interfaces/gitrepository.py 2019-03-21 16:27:30 +0000
155@@ -1,4 +1,4 @@
156-# Copyright 2015-2018 Canonical Ltd. This software is licensed under the
157+# Copyright 2015-2019 Canonical Ltd. This software is licensed under the
158 # GNU Affero General Public License version 3 (see the file LICENSE).
159
160 """Git repository interfaces."""
161@@ -881,16 +881,29 @@
162 object needs to be touched.
163 """
164
165- @call_with(break_references=True)
166- @export_destructor_operation()
167- @operation_for_version("devel")
168 def destroySelf(break_references=False):
169 """Delete the specified repository.
170
171 :param break_references: If supplied, break any references to this
172 repository by deleting items with mandatory references and
173 NULLing other references.
174- :raise: CannotDeleteGitRepository if the repository cannot be deleted.
175+ :raises CannotDeleteGitRepository: if the repository cannot be deleted.
176+ """
177+
178+ @export_destructor_operation()
179+ @operation_for_version("devel")
180+ def delete():
181+ """Request deletion of the specified repository.
182+
183+ The repository will be deleted asynchronously.
184+
185+ This will delete any merge proposals that use this repository as
186+ their source or target, and any recipes that use this repository.
187+ It will clear the prerequisite from any merge proposals that use
188+ this repository as a prerequisite. It will detach the repository
189+ from any snap packages that build from it.
190+
191+ :raises CannotDeleteGitRepository: if the repository cannot be deleted.
192 """
193
194
195
196=== modified file 'lib/lp/code/model/branch.py'
197--- lib/lp/code/model/branch.py 2019-03-21 16:27:29 +0000
198+++ lib/lp/code/model/branch.py 2019-03-21 16:27:30 +0000
199@@ -112,6 +112,11 @@
200 )
201 from lp.code.interfaces.branchcollection import IAllBranches
202 from lp.code.interfaces.branchhosting import IBranchHostingClient
203+from lp.code.interfaces.branchjob import (
204+ IBranchDeleteJobSource,
205+ IBranchUpgradeJobSource,
206+ IReclaimBranchSpaceJobSource,
207+ )
208 from lp.code.interfaces.branchlookup import IBranchLookup
209 from lp.code.interfaces.branchmergeproposal import (
210 BRANCH_MERGE_PROPOSAL_FINAL_STATES,
211@@ -1455,7 +1460,6 @@
212
213 def destroySelf(self, break_references=False):
214 """See `IBranch`."""
215- from lp.code.interfaces.branchjob import IReclaimBranchSpaceJobSource
216 if break_references:
217 self._breakReferences()
218 if not self.canBeDeleted():
219@@ -1473,6 +1477,22 @@
220 job = getUtility(IReclaimBranchSpaceJobSource).create(branch_id)
221 job.celeryRunOnCommit()
222
223+ def delete(self):
224+ """See `IBranch`."""
225+ if self.deletion_status == BranchDeletionStatus.DELETING:
226+ raise CannotDeleteBranch(
227+ "This branch is already being deleted: %s" % self.unique_name)
228+ if not self.getStackedBranches().is_empty():
229+ # If there are branches stacked on this one, then we won't be
230+ # able to delete this branch even after breaking references.
231+ raise CannotDeleteBranch(
232+ "Cannot delete branch: %s" % self.unique_name)
233+ # Destructor operations can't take arguments.
234+ user = getUtility(ILaunchBag).user
235+ getUtility(IBranchDeleteJobSource).create(self, user)
236+ self.name = self.namespace.findUnusedName(self.name + "-deleting")
237+ self.deletion_status = BranchDeletionStatus.DELETING
238+
239 def checkUpgrade(self):
240 if self.branch_type is not BranchType.HOSTED:
241 raise CannotUpgradeNonHosted(self)
242@@ -1509,7 +1529,6 @@
243
244 def requestUpgrade(self, requester):
245 """See `IBranch`."""
246- from lp.code.interfaces.branchjob import IBranchUpgradeJobSource
247 job = getUtility(IBranchUpgradeJobSource).create(self, requester)
248 job.celeryRunOnCommit()
249 return job
250
251=== modified file 'lib/lp/code/model/gitrepository.py'
252--- lib/lp/code/model/gitrepository.py 2019-03-21 16:27:29 +0000
253+++ lib/lp/code/model/gitrepository.py 2019-03-21 16:27:30 +0000
254@@ -112,7 +112,11 @@
255 IGitCollection,
256 )
257 from lp.code.interfaces.githosting import IGitHostingClient
258-from lp.code.interfaces.gitjob import IGitRefScanJobSource
259+from lp.code.interfaces.gitjob import (
260+ IGitRefScanJobSource,
261+ IGitRepositoryDeleteJobSource,
262+ IReclaimGitRepositorySpaceJobSource,
263+ )
264 from lp.code.interfaces.gitlookup import IGitLookup
265 from lp.code.interfaces.gitnamespace import (
266 get_git_namespace,
267@@ -1551,11 +1555,6 @@
268
269 def destroySelf(self, break_references=False):
270 """See `IGitRepository`."""
271- # Circular import.
272- from lp.code.interfaces.gitjob import (
273- IReclaimGitRepositorySpaceJobSource,
274- )
275-
276 if break_references:
277 self._breakReferences()
278 if not self.canBeDeleted():
279@@ -1583,6 +1582,18 @@
280 getUtility(IReclaimGitRepositorySpaceJobSource).create(
281 repository_name, repository_path)
282
283+ def delete(self):
284+ """See `IGitRepository`."""
285+ if self.deletion_status == GitRepositoryDeletionStatus.DELETING:
286+ raise CannotDeleteGitRepository(
287+ "This repository is already being deleted: %s" %
288+ self.unique_name)
289+ # Destructor operations can't take arguments.
290+ user = getUtility(ILaunchBag).user
291+ getUtility(IGitRepositoryDeleteJobSource).create(self, user)
292+ self.name = self.namespace.findUnusedName(self.name + "-deleting")
293+ self.deletion_status = GitRepositoryDeletionStatus.DELETING
294+
295
296 class DeletionOperation:
297 """Represent an operation to perform as part of branch deletion."""
298
299=== modified file 'lib/lp/code/model/tests/test_branch.py'
300--- lib/lp/code/model/tests/test_branch.py 2019-03-21 16:27:29 +0000
301+++ lib/lp/code/model/tests/test_branch.py 2019-03-21 16:27:30 +0000
302@@ -53,6 +53,7 @@
303 RepositoryFormat,
304 )
305 from lp.code.enums import (
306+ BranchDeletionStatus,
307 BranchLifecycleStatus,
308 BranchSubscriptionNotificationLevel,
309 BranchType,
310@@ -74,6 +75,7 @@
311 IBranch,
312 )
313 from lp.code.interfaces.branchjob import (
314+ IBranchDeleteJobSource,
315 IBranchScanJobSource,
316 IBranchUpgradeJobSource,
317 )
318@@ -1483,6 +1485,34 @@
319 transaction.commit()
320 self.assertRaises(LostObjectError, getattr, webhook, 'target')
321
322+ def test_delete_schedules_job(self):
323+ # Calling the delete method schedules a deletion job, which can be
324+ # processed.
325+ branch_id = self.branch.id
326+ branch_name = self.branch.unique_name
327+ self.branch.delete()
328+ update_trigger_modified_fields(self.branch)
329+ self.assertEqual(branch_name + "-deleting", self.branch.unique_name)
330+ self.assertEqual(
331+ BranchDeletionStatus.DELETING, self.branch.deletion_status)
332+ [job] = getUtility(IBranchDeleteJobSource).iterReady()
333+ self.assertEqual(branch_id, job.branch_id)
334+ self.assertEqual(self.user, job.requester)
335+ with dbuser(config.IBranchDeleteJobSource.dbuser):
336+ JobRunner([job]).runAll()
337+
338+ def test_delete_already_deleting(self):
339+ # Trying to delete a branch that is already being deleted raises
340+ # CannotDeleteBranch.
341+ self.branch.delete()
342+ self.assertRaises(CannotDeleteBranch, self.branch.delete)
343+
344+ def test_delete_stacked_branches(self):
345+ # Trying to delete a branch that is stacked upon raises
346+ # CannotDeleteBranch.
347+ self.factory.makeAnyBranch(stacked_on=self.branch)
348+ self.assertRaises(CannotDeleteBranch, self.branch.delete)
349+
350
351 class TestBranchDeletionConsequences(TestCase):
352 """Test determination and application of branch deletion consequences."""
353
354=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
355--- lib/lp/code/model/tests/test_gitrepository.py 2019-03-21 16:27:29 +0000
356+++ lib/lp/code/model/tests/test_gitrepository.py 2019-03-21 16:27:30 +0000
357@@ -52,6 +52,7 @@
358 CodeReviewNotificationLevel,
359 GitGranteeType,
360 GitObjectType,
361+ GitRepositoryDeletionStatus,
362 GitRepositoryType,
363 TargetRevisionControlSystems,
364 )
365@@ -71,6 +72,7 @@
366 from lp.code.interfaces.defaultgit import ICanHasDefaultGitRepository
367 from lp.code.interfaces.gitjob import (
368 IGitRefScanJobSource,
369+ IGitRepositoryDeleteJobSource,
370 IGitRepositoryModifiedMailJobSource,
371 )
372 from lp.code.interfaces.gitlookup import IGitLookup
373@@ -818,6 +820,29 @@
374 GitActivity, GitActivity.repository_id == repository_id)
375 self.assertEqual([], list(activities))
376
377+ def test_delete_schedules_job(self):
378+ # Calling the delete method schedules a deletion job, which can be
379+ # processed.
380+ repository_id = self.repository.id
381+ repository_name = self.repository.unique_name
382+ self.repository.delete()
383+ self.assertEqual(
384+ repository_name + "-deleting", self.repository.unique_name)
385+ self.assertEqual(
386+ GitRepositoryDeletionStatus.DELETING,
387+ self.repository.deletion_status)
388+ [job] = getUtility(IGitRepositoryDeleteJobSource).iterReady()
389+ self.assertEqual(repository_id, job.repository_id)
390+ self.assertEqual(self.user, job.requester)
391+ with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
392+ JobRunner([job]).runAll()
393+
394+ def test_delete_already_deleting(self):
395+ # Trying to delete a repository that is already being deleted raises
396+ # CannotDeleteGitRepository.
397+ self.repository.delete()
398+ self.assertRaises(CannotDeleteGitRepository, self.repository.delete)
399+
400
401 class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
402 """Test determination and application of repository deletion
403
404=== modified file 'lib/lp/code/stories/branches/xx-branch-deletion.txt'
405--- lib/lp/code/stories/branches/xx-branch-deletion.txt 2018-05-13 10:35:52 +0000
406+++ lib/lp/code/stories/branches/xx-branch-deletion.txt 2019-03-21 16:27:30 +0000
407@@ -52,6 +52,20 @@
408 >>> print_feedback_messages(browser.contents)
409 Branch ~alice/earthlynx/to-delete deleted...
410
411+Branch deletion is asynchronous. If the user finds the branch again before
412+the deletion completes, then they see an indication that it is being
413+deleted, and there is no 'Delete branch' action.
414+
415+ >>> browser.open(
416+ ... 'http://code.launchpad.dev/~alice/earthlynx/to-delete-deleting')
417+ >>> print(extract_text(find_tag_by_id(
418+ ... browser.contents, 'branch-deleting')))
419+ This branch is being deleted.
420+ >>> browser.getLink('Delete branch')
421+ Traceback (most recent call last):
422+ ...
423+ LinkNotFoundError
424+
425 If the branch is junk, then the user is taken back to the code listing for
426 the deleted branch's owner.
427
428
429=== modified file 'lib/lp/code/stories/webservice/xx-branch.txt'
430--- lib/lp/code/stories/webservice/xx-branch.txt 2019-03-21 16:27:29 +0000
431+++ lib/lp/code/stories/webservice/xx-branch.txt 2019-03-21 16:27:30 +0000
432@@ -300,4 +300,17 @@
433
434 >>> print_branches(webservice, '/widgets')
435 ~eric/fooix/trunk - Development
436-
437+ ~eric/fooix/feature-branch-deleting - Experimental
438+ ~mary/blob/bar-deleting - Development
439+
440+ >>> from zope.component import getUtility
441+ >>> from lp.code.interfaces.branchjob import IBranchDeleteJobSource
442+ >>> from lp.services.config import config
443+ >>> from lp.services.job.runner import JobRunner
444+
445+ >>> with permissive_security_policy(config.IBranchDeleteJobSource.dbuser):
446+ ... job_source = getUtility(IBranchDeleteJobSource)
447+ ... JobRunner.fromReady(job_source).runAll()
448+
449+ >>> print_branches(webservice, '/widgets')
450+ ~eric/fooix/trunk - Development
451
452=== modified file 'lib/lp/code/templates/branch-index.pt'
453--- lib/lp/code/templates/branch-index.pt 2019-01-23 17:52:34 +0000
454+++ lib/lp/code/templates/branch-index.pt 2019-03-21 16:27:30 +0000
455@@ -67,6 +67,10 @@
456 <div metal:fill-slot="main">
457
458 <div class="yui-g first">
459+ <div tal:condition="python: context.deletion_status.name == 'DELETING'"
460+ id="branch-deleting" class="warning message">
461+ This branch is being deleted.
462+ </div>
463 <tal:branch-errors tal:replace="structure context/@@+messages" />
464 </div>
465
466
467=== modified file 'lib/lp/code/templates/gitrepository-index.pt'
468--- lib/lp/code/templates/gitrepository-index.pt 2019-01-28 15:36:56 +0000
469+++ lib/lp/code/templates/gitrepository-index.pt 2019-03-21 16:27:30 +0000
470@@ -33,6 +33,13 @@
471
472 <div metal:fill-slot="main">
473
474+ <div class="yui-g first">
475+ <div tal:condition="python: context.deletion_status.name == 'DELETING'"
476+ id="repository-deleting" class="warning message">
477+ This repository is being deleted.
478+ </div>
479+ </div>
480+
481 <div id="repository-description" tal:condition="context/description"
482 class="summary"
483 tal:content="structure context/description/fmt:text-to-html" />
484
485=== modified file 'lib/lp/scripts/garbo.py'
486--- lib/lp/scripts/garbo.py 2019-01-30 11:23:33 +0000
487+++ lib/lp/scripts/garbo.py 2019-03-21 16:27:30 +0000
488@@ -40,6 +40,7 @@
489 Or,
490 Row,
491 SQL,
492+ Update,
493 )
494 from storm.info import ClassAlias
495 from storm.store import EmptyResultSet
496@@ -57,13 +58,19 @@
497 BugWatchScheduler,
498 MAX_SAMPLE_SIZE,
499 )
500+from lp.code.enums import (
501+ BranchDeletionStatus,
502+ GitRepositoryDeletionStatus,
503+ )
504 from lp.code.interfaces.revision import IRevisionSet
505+from lp.code.model.branch import Branch
506 from lp.code.model.codeimportevent import CodeImportEvent
507 from lp.code.model.codeimportresult import CodeImportResult
508 from lp.code.model.diff import (
509 Diff,
510 PreviewDiff,
511 )
512+from lp.code.model.gitrepository import GitRepository
513 from lp.code.model.revision import (
514 RevisionAuthor,
515 RevisionCache,
516@@ -1611,6 +1618,62 @@
517 """ % (SnapBuildJobType.STORE_UPLOAD.value, JobStatus.COMPLETED.value)
518
519
520+class BranchDeletionStatusPopulator(TunableLoop):
521+ """Populates Branch.deletion_status with ACTIVE."""
522+
523+ maximum_chunk_size = 5000
524+
525+ def __init__(self, log, abort_time=None):
526+ super(BranchDeletionStatusPopulator, self).__init__(log, abort_time)
527+ self.start_at = 1
528+ self.store = IMasterStore(Branch)
529+
530+ def findBranches(self):
531+ return self.store.find(
532+ Branch,
533+ Branch.id >= self.start_at,
534+ Branch._deletion_status == None).order_by(Branch.id)
535+
536+ def isDone(self):
537+ return self.findBranches().is_empty()
538+
539+ def __call__(self, chunk_size):
540+ ids = [branch.id for branch in self.findBranches()]
541+ self.store.execute(Update(
542+ {Branch._deletion_status: BranchDeletionStatus.ACTIVE.value},
543+ where=Branch.id.is_in(ids), table=Branch))
544+ transaction.commit()
545+
546+
547+class GitRepositoryDeletionStatusPopulator(TunableLoop):
548+ """Populates GitRepository.deletion_status with ACTIVE."""
549+
550+ maximum_chunk_size = 5000
551+
552+ def __init__(self, log, abort_time=None):
553+ super(GitRepositoryDeletionStatusPopulator, self).__init__(
554+ log, abort_time)
555+ self.start_at = 1
556+ self.store = IMasterStore(GitRepository)
557+
558+ def findRepositories(self):
559+ return self.store.find(
560+ GitRepository,
561+ GitRepository.id >= self.start_at,
562+ GitRepository._deletion_status == None).order_by(GitRepository.id)
563+
564+ def isDone(self):
565+ return self.findRepositories().is_empty()
566+
567+ def __call__(self, chunk_size):
568+ ids = [branch.id for branch in self.findRepositories()]
569+ self.store.execute(Update(
570+ {GitRepository._deletion_status:
571+ GitRepositoryDeletionStatus.ACTIVE.value},
572+ where=GitRepository.id.is_in(ids), table=GitRepository))
573+ transaction.commit()
574+
575+
576 class BaseDatabaseGarbageCollector(LaunchpadCronScript):
577 """Abstract base class to run a collection of TunableLoops."""
578 script_name = None # Script name for locking and database user. Override.
579@@ -1884,6 +1947,7 @@
580 script_name = 'garbo-daily'
581 tunable_loops = [
582 AnswerContactPruner,
583+ BranchDeletionStatusPopulator,
584 BranchJobPruner,
585 BugNotificationPruner,
586 BugWatchActivityPruner,
587@@ -1891,6 +1955,7 @@
588 CodeImportResultPruner,
589 DiffPruner,
590 GitJobPruner,
591+ GitRepositoryDeletionStatusPopulator,
592 HWSubmissionEmailLinker,
593 LiveFSFilePruner,
594 LoginTokenPruner,
595
596=== modified file 'lib/lp/scripts/tests/test_garbo.py'
597--- lib/lp/scripts/tests/test_garbo.py 2019-01-30 11:23:33 +0000
598+++ lib/lp/scripts/tests/test_garbo.py 2019-03-21 16:27:30 +0000
599@@ -15,6 +15,7 @@
600 from StringIO import StringIO
601 import time
602
603+from psycopg2 import IntegrityError
604 from pytz import UTC
605 from storm.exceptions import LostObjectError
606 from storm.expr import (
607@@ -54,7 +55,11 @@
608 BranchFormat,
609 RepositoryFormat,
610 )
611-from lp.code.enums import CodeImportResultStatus
612+from lp.code.enums import (
613+ BranchDeletionStatus,
614+ CodeImportResultStatus,
615+ GitRepositoryDeletionStatus,
616+ )
617 from lp.code.interfaces.codeimportevent import ICodeImportEventSet
618 from lp.code.interfaces.gitrepository import IGitRepositorySet
619 from lp.code.model.branchjob import (
620@@ -1606,6 +1611,84 @@
621 # retained.
622 self._test_SnapFilePruner('foo.snap', None, 30, expected_count=1)
623
624+ def test_BranchDeletionStatusPopulator(self):
625+ switch_dbuser('testadmin')
626+ old_branches = [self.factory.makeAnyBranch() for _ in range(2)]
627+ for branch in old_branches:
628+ removeSecurityProxy(branch)._deletion_status = None
629+ try:
630+ Store.of(old_branches[0]).flush()
631+ except IntegrityError:
632+ # Now enforced by DB NOT NULL constraint; backfilling is no
633+ # longer necessary.
634+ return
635+ active_branches = [self.factory.makeAnyBranch() for _ in range(2)]
636+ for branch in active_branches:
637+ removeSecurityProxy(branch)._deletion_status = (
638+ BranchDeletionStatus.ACTIVE)
639+ deleting_branches = [self.factory.makeAnyBranch() for _ in range(2)]
640+ for branch in deleting_branches:
641+ removeSecurityProxy(branch)._deletion_status = (
642+ BranchDeletionStatus.DELETING)
643+ transaction.commit()
644+
645+ self.runDaily()
646+
647+ # Old branches are backfilled.
648+ for branch in old_branches:
649+ self.assertEqual(
650+ BranchDeletionStatus.ACTIVE,
651+ removeSecurityProxy(branch)._deletion_status)
652+ # Other branches are left alone.
653+ for branch in active_branches:
654+ self.assertEqual(
655+ BranchDeletionStatus.ACTIVE,
656+ removeSecurityProxy(branch)._deletion_status)
657+ for branch in deleting_branches:
658+ self.assertEqual(
659+ BranchDeletionStatus.DELETING,
660+ removeSecurityProxy(branch)._deletion_status)
661+
662+ def test_GitRepositoryDeletionStatusPopulator(self):
663+ switch_dbuser('testadmin')
664+ old_repositories = [self.factory.makeGitRepository() for _ in range(2)]
665+ for repository in old_repositories:
666+ removeSecurityProxy(repository)._deletion_status = None
667+ try:
668+ Store.of(old_repositories[0]).flush()
669+ except IntegrityError:
670+ # Now enforced by DB NOT NULL constraint; backfilling is no
671+ # longer necessary.
672+ return
673+ active_repositories = [
674+ self.factory.makeGitRepository() for _ in range(2)]
675+ for repository in active_repositories:
676+ removeSecurityProxy(repository)._deletion_status = (
677+ GitRepositoryDeletionStatus.ACTIVE)
678+ deleting_repositories = [
679+ self.factory.makeGitRepository() for _ in range(2)]
680+ for repository in deleting_repositories:
681+ removeSecurityProxy(repository)._deletion_status = (
682+ GitRepositoryDeletionStatus.DELETING)
683+ transaction.commit()
684+
685+ self.runDaily()
686+
687+ # Old repositories are backfilled.
688+ for repository in old_repositories:
689+ self.assertEqual(
690+ GitRepositoryDeletionStatus.ACTIVE,
691+ removeSecurityProxy(repository)._deletion_status)
692+ # Other repositories are left alone.
693+ for repository in active_repositories:
694+ self.assertEqual(
695+ GitRepositoryDeletionStatus.ACTIVE,
696+ removeSecurityProxy(repository)._deletion_status)
697+ for repository in deleting_repositories:
698+ self.assertEqual(
699+ GitRepositoryDeletionStatus.DELETING,
700+ removeSecurityProxy(repository)._deletion_status)
701+
702
703 class TestGarboTasks(TestCaseWithFactory):
704 layer = LaunchpadZopelessLayer