Merge lp:~jtv/launchpad/bug-602333 into lp:launchpad

Proposed by Jeroen T. Vermeulen
Status: Merged
Approved by: Robert Collins
Approved revision: no longer in the source branch.
Merged at revision: 11161
Proposed branch: lp:~jtv/launchpad/bug-602333
Merge into: lp:launchpad
Diff against target: 321 lines (+116/-50)
4 files modified
lib/lp/code/model/branch.py (+41/-24)
lib/lp/code/model/tests/test_branch.py (+56/-20)
lib/lp/translations/interfaces/translationtemplatesbuildjob.py (+3/-0)
lib/lp/translations/model/translationtemplatesbuildjob.py (+16/-6)
To merge this branch: bzr merge lp:~jtv/launchpad/bug-602333
Reviewer Review Type Date Requested Status
Robert Collins (community) Approve
Launchpad code reviewers code Pending
Review via email: mp+30195@code.launchpad.net

Commit message

Delete BuildQueue entries for BranchJobs on any Branch being deleted.

Description of the change

= Bug 602333 =

Deleting branches can fail because of foreign-key constraints in the buildfarm classes. It may be best to read the bug comments before reviewing this.

A branch can have BranchJobs attached. The references look like this:

    Branch <- BranchJob -> Job

Branch deletion breaks the foreign-key constraint by deleting the Job; the schema is set up to let that deletion cascade to BranchJob.

One type of BranchJob is TranslationTemplatesBuildJob. This is a job for the build farm, so it comes with a BuildQueue entry attached (which goes away when the job completes). Looking at the object-oriented side, there is no direct reference between the BuildQueue and the Job; they are linked only in that they both refer to Job.

    Branch <- BranchJob -> Job <- BuildQueue

The reference from BuildQueue to Job does not cascade deletions, so the Job cannot be deleted as long as the BuildQueue exists. Nor would it be reasonable to make it cascade, since BuildQueue in the general case has no connection to any BranchJob that might be deleted in this way. It's a peculiarity of TranslationTemplatesBuildJob.

After discussion with Aaron and Jono, I fixed this in a manner that's consistent with how the tie between Branch and BranchJob is broken: delete any BuildQueue entries straight from the database before deleting the Jobs.

Test:
{{{
./bin/test -vvc -m lp.code.tests.test_branch -t TestBranchDeletion
}}}

You'll also see some drive-by lint cleanups. And I moved two tests that were clearly in the wrong test case.

Jeroen

To post a comment you must log in.
Revision history for this message
Robert Collins (lifeless) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/code/model/branch.py'
2--- lib/lp/code/model/branch.py 2010-07-09 10:22:32 +0000
3+++ lib/lp/code/model/branch.py 2010-07-17 23:18:49 +0000
4@@ -46,6 +46,7 @@
5 IStoreSelector, MAIN_STORE, SLAVE_FLAVOR)
6
7 from lp.app.errors import UserCannotUnsubscribePerson
8+from lp.buildmaster.model.buildqueue import BuildQueue
9 from lp.code.bzr import (
10 BranchFormat, ControlFormat, CURRENT_BRANCH_FORMATS,
11 CURRENT_REPOSITORY_FORMATS, RepositoryFormat)
12@@ -450,7 +451,7 @@
13 @property
14 def code_is_browseable(self):
15 """See `IBranch`."""
16- return (self.revision_count > 0 or self.last_mirrored != None)
17+ return (self.revision_count > 0 or self.last_mirrored != None)
18
19 def codebrowse_url(self, *extras):
20 """See `IBranch`."""
21@@ -890,19 +891,18 @@
22 prefix = config.launchpad.bzr_imports_root_url
23 return urlappend(prefix, '%08x' % self.id)
24 else:
25- raise AssertionError("No pull URL for %r" % (self,))
26+ raise AssertionError("No pull URL for %r" % (self, ))
27
28 def requestMirror(self):
29 """See `IBranch`."""
30 if self.branch_type == BranchType.REMOTE:
31 raise BranchTypeError(self.unique_name)
32- from canonical.launchpad.interfaces import IStore
33- IStore(self).find(
34+ branch = Store.of(self).find(
35 Branch,
36 Branch.id == self.id,
37 Or(Branch.next_mirror_time > UTC_NOW,
38- Branch.next_mirror_time == None)
39- ).set(next_mirror_time=UTC_NOW)
40+ Branch.next_mirror_time == None))
41+ branch.set(next_mirror_time=UTC_NOW)
42 self.next_mirror_time = AutoReload
43 return self.next_mirror_time
44
45@@ -964,31 +964,45 @@
46 """See `IBranch`."""
47 return self.destroySelf(break_references=True)
48
49+ def _deleteBranchSubscriptions(self):
50+ """Delete subscriptions for this branch prior to deleting branch."""
51+ subscriptions = Store.of(self).find(
52+ BranchSubscription, BranchSubscription.branch == self)
53+ subscriptions.remove()
54+
55+ def _deleteJobs(self):
56+ """Delete jobs for this branch prior to deleting branch.
57+
58+ This deletion includes `BranchJob`s associated with the branch,
59+ as well as `BuildQueue` entries for `TranslationTemplateBuildJob`s.
60+ """
61+ # Avoid circular imports.
62+ from lp.code.model.branchjob import BranchJob
63+
64+ store = Store.of(self)
65+ affected_jobs = Select(
66+ [BranchJob.jobID],
67+ And(BranchJob.job == Job.id, BranchJob.branch == self))
68+
69+ # Delete BuildQueue entries for affected Jobs. They would pin
70+ # the affected Jobs in the database otherwise.
71+ store.find(BuildQueue, BuildQueue.jobID.is_in(affected_jobs)).remove()
72+
73+ # Delete Jobs. Their BranchJobs cascade along in the database.
74+ store.find(Job, Job.id.is_in(affected_jobs)).remove()
75+
76 def destroySelf(self, break_references=False):
77 """See `IBranch`."""
78- from lp.code.model.branchjob import BranchJob
79 from lp.code.interfaces.branchjob import IReclaimBranchSpaceJobSource
80 if break_references:
81 self._breakReferences()
82 if not self.canBeDeleted():
83 raise CannotDeleteBranch(
84 "Cannot delete branch: %s" % self.unique_name)
85- # BranchRevisions are taken care of a cascading delete
86- # in the database.
87- store = Store.of(self)
88- # Delete the branch subscriptions.
89- subscriptions = store.find(
90- BranchSubscription, BranchSubscription.branch == self)
91- subscriptions.remove()
92- # Delete any linked jobs.
93- # Using a sub-select here as joins in delete statements is not
94- # valid standard sql.
95- jobs = store.find(
96- Job,
97- Job.id.is_in(Select([BranchJob.jobID],
98- And(BranchJob.job == Job.id,
99- BranchJob.branch == self))))
100- jobs.remove()
101+
102+ self._deleteBranchSubscriptions()
103+ self._deleteJobs()
104+
105 # Now destroy the branch.
106 branch_id = self.id
107 SQLBase.destroySelf(self)
108@@ -997,8 +1011,10 @@
109
110 def commitsForDays(self, since):
111 """See `IBranch`."""
112+
113 class DateTrunc(NamedFunc):
114 name = "date_trunc"
115+
116 results = Store.of(self).find(
117 (DateTrunc('day', Revision.revision_date), Count(Revision.id)),
118 Revision.id == BranchRevision.revisionID,
119@@ -1080,6 +1096,7 @@
120 def __init__(self, affected_object, rationale):
121 self.affected_object = ProxyFactory(affected_object)
122 self.rationale = rationale
123+
124 def __call__(self):
125 """Perform the deletion operation."""
126 raise NotImplementedError(DeletionOperation.__call__)
127@@ -1161,7 +1178,7 @@
128
129 def __init__(self, code_import):
130 DeletionOperation.__init__(
131- self, code_import, _( 'This is the import data for this branch.'))
132+ self, code_import, _('This is the import data for this branch.'))
133
134 def __call__(self):
135 from lp.code.model.codeimport import CodeImportSet
136
137=== modified file 'lib/lp/code/model/tests/test_branch.py'
138--- lib/lp/code/model/tests/test_branch.py 2010-05-27 01:34:14 +0000
139+++ lib/lp/code/model/tests/test_branch.py 2010-07-17 23:18:49 +0000
140@@ -1,4 +1,4 @@
141-# Copyright 2009 Canonical Ltd. This software is licensed under the
142+# Copyright 2009-2010 Canonical Ltd. This software is licensed under the
143 # GNU Affero General Public License version 3 (see the file LICENSE).
144
145 # pylint: disable-msg=F0401,E1002
146@@ -34,6 +34,7 @@
147 from canonical.launchpad.webapp.interfaces import IOpenLaunchBag
148 from canonical.testing import DatabaseFunctionalLayer, LaunchpadZopelessLayer
149
150+from lp.buildmaster.model.buildqueue import BuildQueue
151 from lp.blueprints.interfaces.specification import (
152 ISpecificationSet, SpecificationDefinitionStatus)
153 from lp.blueprints.model.specificationbranch import (
154@@ -78,6 +79,8 @@
155 person_logged_in, run_with_login, TestCase, TestCaseWithFactory,
156 time_counter)
157 from lp.testing.factory import LaunchpadObjectFactory
158+from lp.translations.model.translationtemplatesbuildjob import (
159+ ITranslationTemplatesBuildJobSource)
160
161
162 class TestCodeImport(TestCase):
163@@ -1039,6 +1042,39 @@
164 # Need to commit the transaction to fire off the constraint checks.
165 transaction.commit()
166
167+ def test_related_TranslationTemplatesBuildJob_cleaned_out(self):
168+ # A TranslationTemplatesBuildJob is a type of BranchJob that
169+ # comes with a BuildQueue entry referring to the same Job.
170+ # Deleting the branch cleans up the BuildQueue before it can
171+ # remove the Job and BranchJob.
172+ branch = self.factory.makeAnyBranch()
173+ getUtility(ITranslationTemplatesBuildJobSource).create(branch)
174+
175+ branch.destroySelf(break_references=True)
176+
177+ Store.of(branch).flush()
178+
179+ def test_unrelated_TranslationTemplatesBuildJob_intact(self):
180+ # No innocent BuildQueue entries are harmed in deleting a
181+ # branch.
182+ branch = self.factory.makeAnyBranch()
183+ other_branch = self.factory.makeAnyBranch()
184+ source = getUtility(ITranslationTemplatesBuildJobSource)
185+ job = source.create(branch)
186+ other_job = source.create(other_branch)
187+ store = Store.of(branch)
188+
189+ branch.destroySelf(break_references=True)
190+
191+ # The BuildQueue for the job whose branch we deleted is gone.
192+ buildqueue = store.find(BuildQueue, BuildQueue.job == job.job)
193+ self.assertEqual([], list(buildqueue))
194+
195+ # The other job's BuildQueue entry is still there.
196+ other_buildqueue = store.find(
197+ BuildQueue, BuildQueue.job == other_job.job)
198+ self.assertNotEqual([], list(other_buildqueue))
199+
200 def test_createsJobToReclaimSpace(self):
201 # When a branch is deleted from the database, a job to remove the
202 # branch from disk as well.
203@@ -1053,6 +1089,25 @@
204 [branch_id],
205 [ReclaimBranchSpaceJob(job).branch_id for job in jobs])
206
207+ def test_destroySelf_with_SourcePackageRecipe(self):
208+ """If branch is a base_branch in a recipe, it is deleted."""
209+ recipe = self.factory.makeSourcePackageRecipe()
210+ store = Store.of(recipe)
211+ recipe.base_branch.destroySelf(break_references=True)
212+ # show no DB constraints have been violated
213+ store.flush()
214+
215+ def test_destroySelf_with_SourcePackageRecipe_as_non_base(self):
216+ """If branch is referred to by a recipe, it is deleted."""
217+ branch1 = self.factory.makeAnyBranch()
218+ branch2 = self.factory.makeAnyBranch()
219+ recipe = self.factory.makeSourcePackageRecipe(
220+ branches=[branch1, branch2])
221+ store = Store.of(recipe)
222+ branch2.destroySelf(break_references=True)
223+ # show no DB constraints have been violated
224+ store.flush()
225+
226
227 class TestBranchDeletionConsequences(TestCase):
228 """Test determination and application of branch deletion consequences."""
229@@ -1348,25 +1403,6 @@
230 {recipe: ('delete', 'This recipe uses this branch.')},
231 recipe.base_branch.deletionRequirements())
232
233- def test_destroySelf_with_SourcePackageRecipe(self):
234- """If branch is a base_branch in a recipe, it is deleted."""
235- recipe = self.factory.makeSourcePackageRecipe()
236- store = Store.of(recipe)
237- recipe.base_branch.destroySelf(break_references=True)
238- # show no DB constraints have been violated
239- store.flush()
240-
241- def test_destroySelf_with_SourcePackageRecipe_as_non_base(self):
242- """If branch is referred to by a recipe, it is deleted."""
243- branch1 = self.factory.makeAnyBranch()
244- branch2 = self.factory.makeAnyBranch()
245- recipe = self.factory.makeSourcePackageRecipe(
246- branches=[branch1, branch2])
247- store = Store.of(recipe)
248- branch2.destroySelf(break_references=True)
249- # show no DB constraints have been violated
250- store.flush()
251-
252
253 class StackedBranches(TestCaseWithFactory):
254 """Tests for showing branches stacked on another."""
255
256=== modified file 'lib/lp/translations/interfaces/translationtemplatesbuildjob.py'
257--- lib/lp/translations/interfaces/translationtemplatesbuildjob.py 2010-02-17 12:58:53 +0000
258+++ lib/lp/translations/interfaces/translationtemplatesbuildjob.py 2010-07-17 23:18:49 +0000
259@@ -34,3 +34,6 @@
260
261 def scheduleTranslationTemplatesBuild(branch):
262 """Schedule a translation templates build job, if appropriate."""
263+
264+ def getByBranch(branch):
265+ """Find `TranslationTemplatesBuildJob` for given `Branch`."""
266
267=== modified file 'lib/lp/translations/model/translationtemplatesbuildjob.py'
268--- lib/lp/translations/model/translationtemplatesbuildjob.py 2010-05-26 08:53:13 +0000
269+++ lib/lp/translations/model/translationtemplatesbuildjob.py 2010-07-17 23:18:49 +0000
270@@ -18,8 +18,7 @@
271 from canonical.config import config
272
273 from canonical.launchpad.interfaces import ILaunchpadCelebrities
274-from canonical.launchpad.webapp.interfaces import (
275- DEFAULT_FLAVOR, IStoreSelector, MAIN_STORE, MASTER_FLAVOR)
276+from canonical.launchpad.interfaces.lpstorm import IMasterStore, IStore
277
278 from lp.buildmaster.interfaces.buildfarmjob import BuildFarmJobType
279 from lp.buildmaster.interfaces.buildqueue import IBuildQueueSet
280@@ -118,7 +117,7 @@
281 @classmethod
282 def create(cls, branch):
283 """See `ITranslationTemplatesBuildJobSource`."""
284- store = getUtility(IStoreSelector).get(MAIN_STORE, MASTER_FLAVOR)
285+ store = IMasterStore(BranchJob)
286
287 # Pass public HTTP URL for the branch.
288 metadata = {'branch_url': branch.composePublicURL()}
289@@ -128,8 +127,9 @@
290 specific_job = TranslationTemplatesBuildJob(branch_job)
291 duration_estimate = cls.duration_estimate
292
293- # XXX Danilo Segan: we hard-code processor to the Ubuntu
294- # default processor architecture. See bug 580429.
295+ # XXX Danilo Segan bug=580429: we hard-code processor to the Ubuntu
296+ # default processor architecture. This stops the buildfarm from
297+ # accidentally dispatching the jobs to private builders.
298 build_queue_entry = BuildQueue(
299 estimated_duration=duration_estimate,
300 job_type=BuildFarmJobType.TRANSLATIONTEMPLATESBUILD,
301@@ -163,9 +163,19 @@
302
303 Overridden here to search via a BranchJob, rather than a Job.
304 """
305- store = getUtility(IStoreSelector).get(MAIN_STORE, DEFAULT_FLAVOR)
306+ store = IStore(BranchJob)
307 branch_job = store.find(BranchJob, BranchJob.job == job).one()
308 if branch_job is None:
309 return None
310 else:
311 return cls(branch_job)
312+
313+ @classmethod
314+ def getByBranch(cls, branch):
315+ """See `ITranslationTemplatesBuildJobSource`."""
316+ store = IStore(BranchJob)
317+ branch_job = store.find(BranchJob, BranchJob.branch == branch).one()
318+ if branch_job is None:
319+ return None
320+ else:
321+ return cls(branch_job)