Merge lp:~cjwatson/launchpad/code-async-delete into lp:launchpad
- code-async-delete
- Merge into devel
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 | ||||
Related bugs: |
|
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.
Description of the change
To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) wrote : | # |
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 |
This seems unnecessary since adding some more indexes has made deletion fast enough, so withdrawing this until we have a clear need.