Merge ~cjwatson/launchpad:more-relative-build-scores into launchpad:master
- Git
- lp:~cjwatson/launchpad
- more-relative-build-scores
- Merge into master
Status: | Needs review |
---|---|
Proposed branch: | ~cjwatson/launchpad:more-relative-build-scores |
Merge into: | launchpad:master |
Diff against target: |
823 lines (+346/-32) 21 files modified
lib/lp/charms/browser/charmrecipe.py (+5/-1) lib/lp/charms/browser/tests/test_charmrecipe.py (+4/-1) lib/lp/charms/interfaces/charmrecipe.py (+12/-0) lib/lp/charms/model/charmrecipe.py (+15/-0) lib/lp/charms/model/charmrecipebuild.py (+1/-3) lib/lp/charms/tests/test_charmrecipe.py (+11/-0) lib/lp/code/browser/configure.zcml (+6/-6) lib/lp/code/browser/gitrepository.py (+17/-10) lib/lp/code/browser/tests/test_gitrepository.py (+9/-6) lib/lp/code/interfaces/gitrepository.py (+12/-0) lib/lp/code/model/cibuild.py (+1/-1) lib/lp/code/model/gitrepository.py (+15/-0) lib/lp/code/model/tests/test_cibuild.py (+13/-0) lib/lp/scripts/garbo.py (+87/-0) lib/lp/scripts/tests/test_garbo.py (+96/-0) lib/lp/snappy/browser/snap.py (+2/-0) lib/lp/snappy/browser/tests/test_snap.py (+5/-1) lib/lp/snappy/interfaces/snap.py (+12/-0) lib/lp/snappy/model/snap.py (+15/-0) lib/lp/snappy/model/snapbuild.py (+5/-1) lib/lp/snappy/tests/test_snap.py (+3/-2) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Clinton Fung | Approve | ||
Review via email: mp+437038@code.launchpad.net |
Commit message
Add relative_
Description of the change
This lets us adjust the priority of all builds for a given recipe or repository, which is occasionally useful.
The new attributes are currently only editable by commercial admins and people with similar privilege, which isn't perfect; ideally these would also be editable by Launchpad staff, since they don't allow privilege escalation. However, that would have involved a fair bit more rearrangement (particularly in `GitRepository`, where the `launchpad.
Unmerged commits
- c5e4a46... by Colin Watson
-
Add relative_
build_score to CharmRecipe/ GitRepository/ Snap This lets us adjust the priority of all builds for a given recipe or
repository, which is occasionally useful.The new attributes are currently only editable by commercial admins and
people with similar privilege, which isn't perfect; ideally these would
also be editable by Launchpad staff, since they don't allow privilege
escalation. However, that would have involved a fair bit more
rearrangement (particularly in `GitRepository`, where the
`launchpad.Moderate` permission is already in use for something else),
and I wanted to get this out the door without blocking on that.LP: #1974360
-
docs:0 (build) lint:0 (build) mypy:0 (build) 1 → 3 of 3 results First • Previous • Next • Last
Preview Diff
1 | diff --git a/lib/lp/charms/browser/charmrecipe.py b/lib/lp/charms/browser/charmrecipe.py |
2 | index f80aebb..3f5a304 100644 |
3 | --- a/lib/lp/charms/browser/charmrecipe.py |
4 | +++ b/lib/lp/charms/browser/charmrecipe.py |
5 | @@ -281,6 +281,7 @@ class ICharmRecipeEditSchema(Interface): |
6 | "auto_build", |
7 | "auto_build_channels", |
8 | "store_upload", |
9 | + "relative_build_score", |
10 | ], |
11 | ) |
12 | |
13 | @@ -495,7 +496,10 @@ class CharmRecipeAdminView(BaseCharmRecipeEditView): |
14 | |
15 | page_title = "Administer" |
16 | |
17 | - field_names = ["require_virtualized"] |
18 | + field_names = [ |
19 | + "require_virtualized", |
20 | + "relative_build_score", |
21 | + ] |
22 | |
23 | |
24 | class CharmRecipeEditView(BaseCharmRecipeEditView): |
25 | diff --git a/lib/lp/charms/browser/tests/test_charmrecipe.py b/lib/lp/charms/browser/tests/test_charmrecipe.py |
26 | index 836fb56..c6568c7 100644 |
27 | --- a/lib/lp/charms/browser/tests/test_charmrecipe.py |
28 | +++ b/lib/lp/charms/browser/tests/test_charmrecipe.py |
29 | @@ -565,7 +565,7 @@ class TestCharmRecipeAdminView(BaseTestCharmRecipeView): |
30 | ) |
31 | |
32 | def test_admin_recipe(self): |
33 | - # Admins can change require_virtualized. |
34 | + # Admins can change require_virtualized and relative_build_score. |
35 | login("admin@canonical.com") |
36 | admin = self.factory.makePerson( |
37 | member_of=[getUtility(ILaunchpadCelebrities).admin] |
38 | @@ -573,14 +573,17 @@ class TestCharmRecipeAdminView(BaseTestCharmRecipeView): |
39 | login_person(self.person) |
40 | recipe = self.factory.makeCharmRecipe(registrant=self.person) |
41 | self.assertTrue(recipe.require_virtualized) |
42 | + self.assertEqual(0, recipe.relative_build_score) |
43 | |
44 | browser = self.getViewBrowser(recipe, user=admin) |
45 | browser.getLink("Administer charm recipe").click() |
46 | browser.getControl("Require virtualized builders").selected = False |
47 | + browser.getControl("Relative build score").value = "50" |
48 | browser.getControl("Update charm recipe").click() |
49 | |
50 | login_admin() |
51 | self.assertFalse(recipe.require_virtualized) |
52 | + self.assertEqual(50, recipe.relative_build_score) |
53 | |
54 | def test_admin_recipe_sets_date_last_modified(self): |
55 | # Administering a charm recipe sets the date_last_modified property. |
56 | diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py |
57 | index 6499998..83f0508 100644 |
58 | --- a/lib/lp/charms/interfaces/charmrecipe.py |
59 | +++ b/lib/lp/charms/interfaces/charmrecipe.py |
60 | @@ -764,6 +764,18 @@ class ICharmRecipeAdminAttributes(Interface): |
61 | ) |
62 | ) |
63 | |
64 | + relative_build_score = exported( |
65 | + Int( |
66 | + title=_("Relative build score"), |
67 | + required=True, |
68 | + readonly=False, |
69 | + description=_( |
70 | + "A delta to apply to all build scores for the charm recipe. " |
71 | + "Builds with a higher score will build sooner." |
72 | + ), |
73 | + ) |
74 | + ) |
75 | + |
76 | |
77 | # XXX cjwatson 2021-09-15 bug=760849: "beta" is a lie to get WADL |
78 | # generation working. Individual attributes must set their version to |
79 | diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py |
80 | index 566b61c..da5e668 100644 |
81 | --- a/lib/lp/charms/model/charmrecipe.py |
82 | +++ b/lib/lp/charms/model/charmrecipe.py |
83 | @@ -299,6 +299,8 @@ class CharmRecipe(StormBase, WebhookTargetMixin): |
84 | |
85 | _store_channels = JSON("store_channels", allow_none=True) |
86 | |
87 | + _relative_build_score = Int(name="relative_build_score", allow_none=True) |
88 | + |
89 | def __init__( |
90 | self, |
91 | registrant, |
92 | @@ -342,6 +344,7 @@ class CharmRecipe(StormBase, WebhookTargetMixin): |
93 | self.store_name = store_name |
94 | self.store_secrets = store_secrets |
95 | self.store_channels = store_channels |
96 | + self.relative_build_score = 0 |
97 | |
98 | def __repr__(self): |
99 | return "<CharmRecipe ~%s/%s/+charm/%s>" % ( |
100 | @@ -393,6 +396,18 @@ class CharmRecipe(StormBase, WebhookTargetMixin): |
101 | """See `ICharmRecipe`.""" |
102 | self._store_channels = value or None |
103 | |
104 | + # XXX cjwatson 2023-02-08: Remove this once existing rows have been |
105 | + # backfilled. |
106 | + @property |
107 | + def relative_build_score(self): |
108 | + """See `ICharmRecipe`.""" |
109 | + return self._relative_build_score or 0 |
110 | + |
111 | + @relative_build_score.setter |
112 | + def relative_build_score(self, value): |
113 | + """See `ICharmRecipe`.""" |
114 | + self._relative_build_score = value |
115 | + |
116 | @cachedproperty |
117 | def _default_distribution(self): |
118 | """See `ICharmRecipe`.""" |
119 | diff --git a/lib/lp/charms/model/charmrecipebuild.py b/lib/lp/charms/model/charmrecipebuild.py |
120 | index d0656ce..352ccff 100644 |
121 | --- a/lib/lp/charms/model/charmrecipebuild.py |
122 | +++ b/lib/lp/charms/model/charmrecipebuild.py |
123 | @@ -237,9 +237,7 @@ class CharmRecipeBuild(PackageBuildMixin, StormBase): |
124 | |
125 | def calculateScore(self): |
126 | """See `IBuildFarmJob`.""" |
127 | - # XXX cjwatson 2021-05-28: We'll probably need something like |
128 | - # CharmRecipe.relative_build_score at some point. |
129 | - return 2510 |
130 | + return 2510 + self.recipe.relative_build_score |
131 | |
132 | def getMedianBuildDuration(self): |
133 | """Return the median duration of our successful builds.""" |
134 | diff --git a/lib/lp/charms/tests/test_charmrecipe.py b/lib/lp/charms/tests/test_charmrecipe.py |
135 | index 0855d7e..195de06 100644 |
136 | --- a/lib/lp/charms/tests/test_charmrecipe.py |
137 | +++ b/lib/lp/charms/tests/test_charmrecipe.py |
138 | @@ -344,6 +344,17 @@ class TestCharmRecipe(TestCaseWithFactory): |
139 | queue_record.score() |
140 | self.assertEqual(2510, queue_record.lastscore) |
141 | |
142 | + def test_requestBuild_relative_build_score(self): |
143 | + # Offsets for charm recipes are respected. |
144 | + recipe = self.factory.makeCharmRecipe() |
145 | + removeSecurityProxy(recipe).relative_build_score = 50 |
146 | + das = self.makeBuildableDistroArchSeries() |
147 | + build_request = self.factory.makeCharmRecipeBuildRequest(recipe=recipe) |
148 | + build = recipe.requestBuild(build_request, das) |
149 | + queue_record = build.buildqueue_record |
150 | + queue_record.score() |
151 | + self.assertEqual(2560, queue_record.lastscore) |
152 | + |
153 | def test_requestBuild_channels(self): |
154 | # requestBuild can select non-default channels. |
155 | recipe = self.factory.makeCharmRecipe() |
156 | diff --git a/lib/lp/code/browser/configure.zcml b/lib/lp/code/browser/configure.zcml |
157 | index 788c7f2..6978fcb 100644 |
158 | --- a/lib/lp/code/browser/configure.zcml |
159 | +++ b/lib/lp/code/browser/configure.zcml |
160 | @@ -865,6 +865,12 @@ |
161 | template="../templates/git-macros.pt"/> |
162 | <browser:page |
163 | for="lp.code.interfaces.gitrepository.IGitRepository" |
164 | + class="lp.code.browser.gitrepository.GitRepositoryAdminView" |
165 | + permission="launchpad.Admin" |
166 | + name="+admin" |
167 | + template="../../app/templates/generic-edit.pt"/> |
168 | + <browser:page |
169 | + for="lp.code.interfaces.gitrepository.IGitRepository" |
170 | class="lp.code.browser.gitrepository.GitRepositoryEditInformationTypeView" |
171 | permission="launchpad.Edit" |
172 | name="+edit-information-type" |
173 | @@ -877,12 +883,6 @@ |
174 | template="../../app/templates/generic-edit.pt"/> |
175 | <browser:page |
176 | for="lp.code.interfaces.gitrepository.IGitRepository" |
177 | - class="lp.code.browser.gitrepository.GitRepositoryEditBuilderConstraintsView" |
178 | - permission="launchpad.Admin" |
179 | - name="+builder-constraints" |
180 | - template="../../app/templates/generic-edit.pt"/> |
181 | - <browser:page |
182 | - for="lp.code.interfaces.gitrepository.IGitRepository" |
183 | class="lp.code.browser.gitrepository.GitRepositoryEditView" |
184 | permission="launchpad.Edit" |
185 | name="+edit" |
186 | diff --git a/lib/lp/code/browser/gitrepository.py b/lib/lp/code/browser/gitrepository.py |
187 | index c3724f0..886260c 100644 |
188 | --- a/lib/lp/code/browser/gitrepository.py |
189 | +++ b/lib/lp/code/browser/gitrepository.py |
190 | @@ -266,16 +266,21 @@ class GitRepositoryEditMenu(NavigationMenu): |
191 | facet = "branches" |
192 | title = "Edit Git repository" |
193 | links = [ |
194 | + "admin", |
195 | "edit", |
196 | "reviewer", |
197 | "permissions", |
198 | "activity", |
199 | "access_tokens", |
200 | "webhooks", |
201 | - "builder_constraints", |
202 | "delete", |
203 | ] |
204 | |
205 | + @enabled_with_permission("launchpad.Admin") |
206 | + def admin(self): |
207 | + text = "Administer repository" |
208 | + return Link("+admin", text, icon="edit") |
209 | + |
210 | @enabled_with_permission("launchpad.Edit") |
211 | def edit(self): |
212 | text = "Change repository details" |
213 | @@ -306,11 +311,6 @@ class GitRepositoryEditMenu(NavigationMenu): |
214 | text = "Manage webhooks" |
215 | return Link("+webhooks", text, icon="edit") |
216 | |
217 | - @enabled_with_permission("launchpad.Admin") |
218 | - def builder_constraints(self): |
219 | - text = "Set builder constraints" |
220 | - return Link("+builder-constraints", text, icon="edit") |
221 | - |
222 | @enabled_with_permission("launchpad.Edit") |
223 | def delete(self): |
224 | text = "Delete repository" |
225 | @@ -634,7 +634,11 @@ class GitRepositoryEditFormView(LaunchpadEditFormView): |
226 | |
227 | use_template( |
228 | IGitRepository, |
229 | - include=["builder_constraints", "default_branch"], |
230 | + include=[ |
231 | + "builder_constraints", |
232 | + "default_branch", |
233 | + "relative_build_score", |
234 | + ], |
235 | ) |
236 | information_type = copy_field( |
237 | IGitRepository["information_type"], |
238 | @@ -785,10 +789,13 @@ class GitRepositoryEditReviewerView(GitRepositoryEditFormView): |
239 | return {"reviewer": self.context.code_reviewer} |
240 | |
241 | |
242 | -class GitRepositoryEditBuilderConstraintsView(GitRepositoryEditFormView): |
243 | - """A view to set builder constraints.""" |
244 | +class GitRepositoryAdminView(GitRepositoryEditFormView): |
245 | + """A view for administrative settings on repositories.""" |
246 | |
247 | - field_names = ["builder_constraints"] |
248 | + field_names = [ |
249 | + "builder_constraints", |
250 | + "relative_build_score", |
251 | + ] |
252 | custom_widget_builder_constraints = LabeledMultiCheckBoxWidget |
253 | |
254 | |
255 | diff --git a/lib/lp/code/browser/tests/test_gitrepository.py b/lib/lp/code/browser/tests/test_gitrepository.py |
256 | index 51bf64b..ae0c853 100644 |
257 | --- a/lib/lp/code/browser/tests/test_gitrepository.py |
258 | +++ b/lib/lp/code/browser/tests/test_gitrepository.py |
259 | @@ -1072,7 +1072,7 @@ class TestGitRepositoryEditReviewerView(TestCaseWithFactory): |
260 | self.assertEqual(modified_date, repository.date_last_modified) |
261 | |
262 | |
263 | -class TestGitRepositoryEditBuilderConstraintsView(BrowserTestCase): |
264 | +class TestGitRepositoryAdminView(BrowserTestCase): |
265 | |
266 | layer = DatabaseFunctionalLayer |
267 | |
268 | @@ -1084,24 +1084,23 @@ class TestGitRepositoryEditBuilderConstraintsView(BrowserTestCase): |
269 | repository_url = canonical_url(repository, rootsite="code") |
270 | browser = self.getViewBrowser(repository, user=owner) |
271 | self.assertRaises( |
272 | - LinkNotFoundError, browser.getLink, "Set builder constraints" |
273 | + LinkNotFoundError, browser.getLink, "Administer repository" |
274 | ) |
275 | self.assertRaises( |
276 | Unauthorized, |
277 | self.getUserBrowser, |
278 | - repository_url + "/+builder-constraints", |
279 | + repository_url + "/+admin", |
280 | user=owner, |
281 | ) |
282 | |
283 | def test_commercial_admin(self): |
284 | - # A commercial admin can set builder constraints on a Git |
285 | - # repository. |
286 | + # A commercial admin can administer a Git repository. |
287 | self.factory.makeBuilder(open_resources=["gpu", "large", "another"]) |
288 | owner = self.factory.makePerson() |
289 | repository = self.factory.makeGitRepository(owner=owner) |
290 | commercial_admin = login_celebrity("commercial_admin") |
291 | browser = self.getViewBrowser(repository, user=commercial_admin) |
292 | - browser.getLink("Set builder constraints").click() |
293 | + browser.getLink("Administer repository").click() |
294 | builder_constraints = browser.getControl( |
295 | name="field.builder_constraints" |
296 | ) |
297 | @@ -1116,10 +1115,14 @@ class TestGitRepositoryEditBuilderConstraintsView(BrowserTestCase): |
298 | "gpu", |
299 | "large", |
300 | ] |
301 | + relative_build_score = browser.getControl("Relative build score") |
302 | + self.assertEqual("0", relative_build_score.value) |
303 | + relative_build_score.value = "50" |
304 | browser.getControl("Change Git Repository").click() |
305 | |
306 | login_person(owner) |
307 | self.assertEqual(("gpu", "large"), repository.builder_constraints) |
308 | + self.assertEqual(50, repository.relative_build_score) |
309 | |
310 | |
311 | class TestGitRepositoryEditView(TestCaseWithFactory): |
312 | diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py |
313 | index 453954f..933e824 100644 |
314 | --- a/lib/lp/code/interfaces/gitrepository.py |
315 | +++ b/lib/lp/code/interfaces/gitrepository.py |
316 | @@ -1286,6 +1286,18 @@ class IGitRepositoryAdminAttributes(Interface): |
317 | as_of="devel", |
318 | ) |
319 | |
320 | + relative_build_score = exported( |
321 | + Int( |
322 | + title=_("Relative build score"), |
323 | + required=True, |
324 | + readonly=False, |
325 | + description=_( |
326 | + "A delta to apply to all build scores of this repository. " |
327 | + "Builds with a higher score will build sooner." |
328 | + ), |
329 | + ) |
330 | + ) |
331 | + |
332 | |
333 | # XXX cjwatson 2015-01-19 bug=760849: "beta" is a lie to get WADL |
334 | # generation working. Individual attributes must set their version to |
335 | diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py |
336 | index 0bfc773..f70592d 100644 |
337 | --- a/lib/lp/code/model/cibuild.py |
338 | +++ b/lib/lp/code/model/cibuild.py |
339 | @@ -322,7 +322,7 @@ class CIBuild(PackageBuildMixin, StormBase): |
340 | # above bulky things like live filesystem builds, but below |
341 | # important things like builds of proposed Ubuntu stable updates. |
342 | # See https://help.launchpad.net/Packaging/BuildScores. |
343 | - return 2600 |
344 | + return 2600 + self.git_repository.relative_build_score |
345 | |
346 | def getMedianBuildDuration(self): |
347 | """Return the median duration of our recent successful builds.""" |
348 | diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py |
349 | index f78c20c..8369224 100644 |
350 | --- a/lib/lp/code/model/gitrepository.py |
351 | +++ b/lib/lp/code/model/gitrepository.py |
352 | @@ -328,6 +328,8 @@ class GitRepository( |
353 | name="builder_constraints", allow_none=True |
354 | ) |
355 | |
356 | + _relative_build_score = Int(name="relative_build_score", allow_none=True) |
357 | + |
358 | def __init__( |
359 | self, |
360 | repository_type, |
361 | @@ -378,6 +380,7 @@ class GitRepository( |
362 | self.date_last_repacked = date_last_repacked |
363 | self.date_last_scanned = date_last_scanned |
364 | self.builder_constraints = builder_constraints |
365 | + self.relative_build_score = 0 |
366 | |
367 | def _createOnHostingService( |
368 | self, clone_from_repository=None, async_create=False |
369 | @@ -465,6 +468,18 @@ class GitRepository( |
370 | else: |
371 | return self.owner |
372 | |
373 | + # XXX cjwatson 2023-02-08: Remove this once existing rows have been |
374 | + # backfilled. |
375 | + @property |
376 | + def relative_build_score(self): |
377 | + """See `IGitRepository`.""" |
378 | + return self._relative_build_score or 0 |
379 | + |
380 | + @relative_build_score.setter |
381 | + def relative_build_score(self, value): |
382 | + """See `IGitRepository`.""" |
383 | + self._relative_build_score = value |
384 | + |
385 | def _checkPersonalPrivateOwnership(self, new_owner): |
386 | if self.information_type in PRIVATE_INFORMATION_TYPES and ( |
387 | not new_owner.is_team |
388 | diff --git a/lib/lp/code/model/tests/test_cibuild.py b/lib/lp/code/model/tests/test_cibuild.py |
389 | index d5b4405..dd2a0d1 100644 |
390 | --- a/lib/lp/code/model/tests/test_cibuild.py |
391 | +++ b/lib/lp/code/model/tests/test_cibuild.py |
392 | @@ -702,6 +702,19 @@ class TestCIBuildSet(TestCaseWithFactory): |
393 | queue_record.score() |
394 | self.assertEqual(2600, queue_record.lastscore) |
395 | |
396 | + def test_requestBuild_relative_build_score(self): |
397 | + # Offsets for Git repositories are respected. |
398 | + repository = self.factory.makeGitRepository() |
399 | + removeSecurityProxy(repository).relative_build_score = 50 |
400 | + commit_sha1 = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest() |
401 | + das = self.factory.makeBuildableDistroArchSeries() |
402 | + build = getUtility(ICIBuildSet).requestBuild( |
403 | + repository, commit_sha1, das, [[("test", 0)]] |
404 | + ) |
405 | + queue_record = build.buildqueue_record |
406 | + queue_record.score() |
407 | + self.assertEqual(2650, queue_record.lastscore) |
408 | + |
409 | def test_requestBuild_rejects_repeats(self): |
410 | # requestBuild refuses if an identical build was already requested. |
411 | repository = self.factory.makeGitRepository() |
412 | diff --git a/lib/lp/scripts/garbo.py b/lib/lp/scripts/garbo.py |
413 | index e798587..bcd8da6 100644 |
414 | --- a/lib/lp/scripts/garbo.py |
415 | +++ b/lib/lp/scripts/garbo.py |
416 | @@ -57,6 +57,7 @@ from lp.bugs.scripts.checkwatches.scheduler import ( |
417 | MAX_SAMPLE_SIZE, |
418 | BugWatchScheduler, |
419 | ) |
420 | +from lp.charms.model.charmrecipe import CharmRecipe |
421 | from lp.code.enums import GitRepositoryStatus, RevisionStatusArtifactType |
422 | from lp.code.interfaces.revision import IRevisionSet |
423 | from lp.code.model.codeimportevent import CodeImportEvent |
424 | @@ -117,6 +118,7 @@ from lp.services.verification.model.logintoken import LoginToken |
425 | from lp.services.webapp.publisher import canonical_url |
426 | from lp.services.webhooks.interfaces import IWebhookJobSource |
427 | from lp.services.webhooks.model import WebhookJob |
428 | +from lp.snappy.model.snap import Snap |
429 | from lp.snappy.model.snapbuild import SnapFile |
430 | from lp.snappy.model.snapbuildjob import SnapBuildJobType |
431 | from lp.soyuz.enums import ( |
432 | @@ -2243,6 +2245,88 @@ class ArchiveFileDatePopulator(TunableLoop): |
433 | transaction.commit() |
434 | |
435 | |
436 | +class CharmRecipeRelativeBuildScorePopulator(TunableLoop): |
437 | + """Populates CharmRecipe.relative_build_score.""" |
438 | + |
439 | + maximum_chunk_size = 5000 |
440 | + |
441 | + def __init__(self, log, abort_time=None): |
442 | + super().__init__(log, abort_time) |
443 | + self.start_at = 1 |
444 | + self.store = IPrimaryStore(CharmRecipe) |
445 | + |
446 | + def findRecipes(self): |
447 | + return self.store.find( |
448 | + CharmRecipe, |
449 | + CharmRecipe.id >= self.start_at, |
450 | + CharmRecipe._relative_build_score == None, |
451 | + ).order_by(CharmRecipe.id) |
452 | + |
453 | + def isDone(self): |
454 | + return self.findRecipes().is_empty() |
455 | + |
456 | + def __call__(self, chunk_size): |
457 | + recipes = list(self.findRecipes()[:chunk_size]) |
458 | + for recipe in recipes: |
459 | + recipe.relative_build_score = 0 |
460 | + self.start_at = recipes[-1].id + 1 |
461 | + transaction.commit() |
462 | + |
463 | + |
464 | +class GitRepositoryRelativeBuildScorePopulator(TunableLoop): |
465 | + """Populates GitRepository.relative_build_score.""" |
466 | + |
467 | + maximum_chunk_size = 5000 |
468 | + |
469 | + def __init__(self, log, abort_time=None): |
470 | + super().__init__(log, abort_time) |
471 | + self.start_at = 1 |
472 | + self.store = IPrimaryStore(GitRepository) |
473 | + |
474 | + def findRepositories(self): |
475 | + return self.store.find( |
476 | + GitRepository, |
477 | + GitRepository.id >= self.start_at, |
478 | + GitRepository._relative_build_score == None, |
479 | + ).order_by(GitRepository.id) |
480 | + |
481 | + def isDone(self): |
482 | + return self.findRepositories().is_empty() |
483 | + |
484 | + def __call__(self, chunk_size): |
485 | + repositories = list(self.findRepositories()[:chunk_size]) |
486 | + for repository in repositories: |
487 | + repository.relative_build_score = 0 |
488 | + self.start_at = repositories[-1].id + 1 |
489 | + transaction.commit() |
490 | + |
491 | + |
492 | +class SnapRelativeBuildScorePopulator(TunableLoop): |
493 | + """Populates Snap.relative_build_score.""" |
494 | + |
495 | + maximum_chunk_size = 5000 |
496 | + |
497 | + def __init__(self, log, abort_time=None): |
498 | + super().__init__(log, abort_time) |
499 | + self.start_at = 1 |
500 | + self.store = IPrimaryStore(Snap) |
501 | + |
502 | + def findRecipes(self): |
503 | + return self.store.find( |
504 | + Snap, Snap.id >= self.start_at, Snap._relative_build_score == None |
505 | + ).order_by(Snap.id) |
506 | + |
507 | + def isDone(self): |
508 | + return self.findRecipes().is_empty() |
509 | + |
510 | + def __call__(self, chunk_size): |
511 | + recipes = list(self.findRecipes()[:chunk_size]) |
512 | + for recipe in recipes: |
513 | + recipe.relative_build_score = 0 |
514 | + self.start_at = recipes[-1].id + 1 |
515 | + transaction.commit() |
516 | + |
517 | + |
518 | class BaseDatabaseGarbageCollector(LaunchpadCronScript): |
519 | """Abstract base class to run a collection of TunableLoops.""" |
520 | |
521 | @@ -2562,10 +2646,12 @@ class DailyDatabaseGarbageCollector(BaseDatabaseGarbageCollector): |
522 | BranchJobPruner, |
523 | BugNotificationPruner, |
524 | BugWatchActivityPruner, |
525 | + CharmRecipeRelativeBuildScorePopulator, |
526 | CodeImportEventPruner, |
527 | CodeImportResultPruner, |
528 | DiffPruner, |
529 | GitJobPruner, |
530 | + GitRepositoryRelativeBuildScorePopulator, |
531 | LiveFSFilePruner, |
532 | LoginTokenPruner, |
533 | OCIFilePruner, |
534 | @@ -2579,6 +2665,7 @@ class DailyDatabaseGarbageCollector(BaseDatabaseGarbageCollector): |
535 | ScrubPOFileTranslator, |
536 | SnapBuildJobPruner, |
537 | SnapFilePruner, |
538 | + SnapRelativeBuildScorePopulator, |
539 | SourcePackagePublishingHistoryFormatPopulator, |
540 | SuggestiveTemplatesCacheUpdater, |
541 | TeamMembershipPruner, |
542 | diff --git a/lib/lp/scripts/tests/test_garbo.py b/lib/lp/scripts/tests/test_garbo.py |
543 | index eae9163..6d531c2 100644 |
544 | --- a/lib/lp/scripts/tests/test_garbo.py |
545 | +++ b/lib/lp/scripts/tests/test_garbo.py |
546 | @@ -42,6 +42,7 @@ from lp.bugs.model.bugnotification import ( |
547 | BugNotificationRecipient, |
548 | ) |
549 | from lp.buildmaster.enums import BuildStatus |
550 | +from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE |
551 | from lp.code.bzr import BranchFormat, RepositoryFormat |
552 | from lp.code.enums import ( |
553 | CodeImportResultStatus, |
554 | @@ -2545,6 +2546,101 @@ class TestGarbo(FakeAdapterMixin, TestCaseWithFactory): |
555 | self.assertEqual(1, rs.count()) |
556 | self.assertEqual(archive_files[1], rs.one()) |
557 | |
558 | + def test_CharmRecipeRelativeBuildScorePopulator(self): |
559 | + self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"})) |
560 | + switch_dbuser("testadmin") |
561 | + old_recipes = [self.factory.makeCharmRecipe() for _ in range(2)] |
562 | + for recipe in old_recipes: |
563 | + removeSecurityProxy(recipe)._relative_build_score = None |
564 | + try: |
565 | + Store.of(old_recipes[0]).flush() |
566 | + except IntegrityError: |
567 | + # Now enforced by DB NOT NULL constraint; backfilling is no |
568 | + # longer necessary. |
569 | + return |
570 | + new_recipes = [self.factory.makeCharmRecipe() for _ in range(2)] |
571 | + self.assertEqual(0, new_recipes[0].relative_build_score) |
572 | + new_recipes[1].relative_build_score = 100 |
573 | + transaction.commit() |
574 | + |
575 | + self.runDaily() |
576 | + |
577 | + # Old recipes are backfilled. |
578 | + for recipe in old_recipes: |
579 | + self.assertEqual( |
580 | + 0, removeSecurityProxy(recipe)._relative_build_score |
581 | + ) |
582 | + # Other recipes are left alone. |
583 | + self.assertEqual( |
584 | + 0, removeSecurityProxy(new_recipes[0])._relative_build_score |
585 | + ) |
586 | + self.assertEqual( |
587 | + 100, removeSecurityProxy(new_recipes[1])._relative_build_score |
588 | + ) |
589 | + |
590 | + def test_GitRepositoryRelativeBuildScorePopulator(self): |
591 | + switch_dbuser("testadmin") |
592 | + old_repositories = [self.factory.makeGitRepository() for _ in range(2)] |
593 | + for repository in old_repositories: |
594 | + removeSecurityProxy(repository)._relative_build_score = None |
595 | + try: |
596 | + Store.of(old_repositories[0]).flush() |
597 | + except IntegrityError: |
598 | + # Now enforced by DB NOT NULL constraint; backfilling is no |
599 | + # longer necessary. |
600 | + return |
601 | + new_repositories = [self.factory.makeGitRepository() for _ in range(2)] |
602 | + self.assertEqual(0, new_repositories[0].relative_build_score) |
603 | + new_repositories[1].relative_build_score = 100 |
604 | + transaction.commit() |
605 | + |
606 | + self.runDaily() |
607 | + |
608 | + # Old repositories are backfilled. |
609 | + for repository in old_repositories: |
610 | + self.assertEqual( |
611 | + 0, removeSecurityProxy(repository)._relative_build_score |
612 | + ) |
613 | + # Other repositories are left alone. |
614 | + self.assertEqual( |
615 | + 0, removeSecurityProxy(new_repositories[0])._relative_build_score |
616 | + ) |
617 | + self.assertEqual( |
618 | + 100, |
619 | + removeSecurityProxy(new_repositories[1])._relative_build_score, |
620 | + ) |
621 | + |
622 | + def test_SnapRelativeBuildScorePopulator(self): |
623 | + switch_dbuser("testadmin") |
624 | + old_recipes = [self.factory.makeSnap() for _ in range(2)] |
625 | + for recipe in old_recipes: |
626 | + removeSecurityProxy(recipe)._relative_build_score = None |
627 | + try: |
628 | + Store.of(old_recipes[0]).flush() |
629 | + except IntegrityError: |
630 | + # Now enforced by DB NOT NULL constraint; backfilling is no |
631 | + # longer necessary. |
632 | + return |
633 | + new_recipes = [self.factory.makeSnap() for _ in range(2)] |
634 | + self.assertEqual(0, new_recipes[0].relative_build_score) |
635 | + new_recipes[1].relative_build_score = 100 |
636 | + transaction.commit() |
637 | + |
638 | + self.runDaily() |
639 | + |
640 | + # Old recipes are backfilled. |
641 | + for recipe in old_recipes: |
642 | + self.assertEqual( |
643 | + 0, removeSecurityProxy(recipe)._relative_build_score |
644 | + ) |
645 | + # Other recipes are left alone. |
646 | + self.assertEqual( |
647 | + 0, removeSecurityProxy(new_recipes[0])._relative_build_score |
648 | + ) |
649 | + self.assertEqual( |
650 | + 100, removeSecurityProxy(new_recipes[1])._relative_build_score |
651 | + ) |
652 | + |
653 | |
654 | class TestGarboTasks(TestCaseWithFactory): |
655 | layer = LaunchpadZopelessLayer |
656 | diff --git a/lib/lp/snappy/browser/snap.py b/lib/lp/snappy/browser/snap.py |
657 | index 80819c0..71602fe 100644 |
658 | --- a/lib/lp/snappy/browser/snap.py |
659 | +++ b/lib/lp/snappy/browser/snap.py |
660 | @@ -496,6 +496,7 @@ class ISnapEditSchema(Interface): |
661 | "auto_build", |
662 | "auto_build_channels", |
663 | "store_upload", |
664 | + "relative_build_score", |
665 | ], |
666 | ) |
667 | |
668 | @@ -915,6 +916,7 @@ class SnapAdminView(BaseSnapEditView): |
669 | "information_type", |
670 | "require_virtualized", |
671 | "allow_internet", |
672 | + "relative_build_score", |
673 | ] |
674 | |
675 | @property |
676 | diff --git a/lib/lp/snappy/browser/tests/test_snap.py b/lib/lp/snappy/browser/tests/test_snap.py |
677 | index 4c2fbdd..2cbfa05 100644 |
678 | --- a/lib/lp/snappy/browser/tests/test_snap.py |
679 | +++ b/lib/lp/snappy/browser/tests/test_snap.py |
680 | @@ -849,7 +849,8 @@ class TestSnapAdminView(BaseTestSnapView): |
681 | ) |
682 | |
683 | def test_admin_snap(self): |
684 | - # Admins can change require_virtualized, privacy, and allow_internet. |
685 | + # Admins can change require_virtualized, privacy, allow_internet, |
686 | + # and relative_build_score. |
687 | login("admin@canonical.com") |
688 | admin = self.factory.makePerson( |
689 | member_of=[getUtility(ILaunchpadCelebrities).admin] |
690 | @@ -863,6 +864,7 @@ class TestSnapAdminView(BaseTestSnapView): |
691 | self.assertIsNone(snap.project) |
692 | self.assertFalse(snap.private) |
693 | self.assertTrue(snap.allow_internet) |
694 | + self.assertEqual(0, snap.relative_build_score) |
695 | |
696 | self.factory.makeAccessPolicy( |
697 | pillar=project, type=InformationType.PRIVATESECURITY |
698 | @@ -874,6 +876,7 @@ class TestSnapAdminView(BaseTestSnapView): |
699 | browser.getControl("Require virtualized builders").selected = False |
700 | browser.getControl(name="field.information_type").value = private |
701 | browser.getControl("Allow external network access").selected = False |
702 | + browser.getControl("Relative build score").value = "50" |
703 | browser.getControl("Update snap package").click() |
704 | |
705 | login_admin() |
706 | @@ -881,6 +884,7 @@ class TestSnapAdminView(BaseTestSnapView): |
707 | self.assertFalse(snap.require_virtualized) |
708 | self.assertTrue(snap.private) |
709 | self.assertFalse(snap.allow_internet) |
710 | + self.assertEqual(50, snap.relative_build_score) |
711 | |
712 | def test_admin_snap_private_without_project(self): |
713 | # Cannot make snap private if it doesn't have a project associated. |
714 | diff --git a/lib/lp/snappy/interfaces/snap.py b/lib/lp/snappy/interfaces/snap.py |
715 | index a86919c..55ba182 100644 |
716 | --- a/lib/lp/snappy/interfaces/snap.py |
717 | +++ b/lib/lp/snappy/interfaces/snap.py |
718 | @@ -1198,6 +1198,18 @@ class ISnapAdminAttributes(Interface): |
719 | ) |
720 | ) |
721 | |
722 | + relative_build_score = exported( |
723 | + Int( |
724 | + title=_("Relative build score"), |
725 | + required=True, |
726 | + readonly=False, |
727 | + description=_( |
728 | + "A delta to apply to all build scores for the snap recipe. " |
729 | + "Builds with a higher score will build sooner." |
730 | + ), |
731 | + ) |
732 | + ) |
733 | + |
734 | def subscribe(person, subscribed_by): |
735 | """Subscribe a person to this snap recipe.""" |
736 | |
737 | diff --git a/lib/lp/snappy/model/snap.py b/lib/lp/snappy/model/snap.py |
738 | index de5e86e..0b7d0f8 100644 |
739 | --- a/lib/lp/snappy/model/snap.py |
740 | +++ b/lib/lp/snappy/model/snap.py |
741 | @@ -394,6 +394,8 @@ class Snap(StormBase, WebhookTargetMixin): |
742 | |
743 | _store_channels = JSON("store_channels", allow_none=True) |
744 | |
745 | + _relative_build_score = Int(name="relative_build_score", allow_none=True) |
746 | + |
747 | def __init__( |
748 | self, |
749 | registrant, |
750 | @@ -452,6 +454,7 @@ class Snap(StormBase, WebhookTargetMixin): |
751 | self.store_name = store_name |
752 | self.store_secrets = store_secrets |
753 | self.store_channels = store_channels |
754 | + self.relative_build_score = 0 |
755 | |
756 | def __repr__(self): |
757 | return "<Snap ~%s/+snap/%s>" % (self.owner.name, self.name) |
758 | @@ -679,6 +682,18 @@ class Snap(StormBase, WebhookTargetMixin): |
759 | def store_channels(self, value): |
760 | self._store_channels = value or None |
761 | |
762 | + # XXX cjwatson 2023-02-08: Remove this once existing rows have been |
763 | + # backfilled. |
764 | + @property |
765 | + def relative_build_score(self): |
766 | + """See `ISnap`.""" |
767 | + return self._relative_build_score or 0 |
768 | + |
769 | + @relative_build_score.setter |
770 | + def relative_build_score(self, value): |
771 | + """See `ISnap`.""" |
772 | + self._relative_build_score = value |
773 | + |
774 | def getAllowedInformationTypes(self, user): |
775 | """See `ISnap`.""" |
776 | if user_has_special_snap_access(user): |
777 | diff --git a/lib/lp/snappy/model/snapbuild.py b/lib/lp/snappy/model/snapbuild.py |
778 | index 410942b..e6678c0 100644 |
779 | --- a/lib/lp/snappy/model/snapbuild.py |
780 | +++ b/lib/lp/snappy/model/snapbuild.py |
781 | @@ -304,7 +304,11 @@ class SnapBuild(PackageBuildMixin, StormBase): |
782 | return super().can_be_retried |
783 | |
784 | def calculateScore(self): |
785 | - return 2510 + self.archive.relative_build_score |
786 | + return ( |
787 | + 2510 |
788 | + + self.archive.relative_build_score |
789 | + + self.snap.relative_build_score |
790 | + ) |
791 | |
792 | def getMedianBuildDuration(self): |
793 | """Return the median duration of our successful builds.""" |
794 | diff --git a/lib/lp/snappy/tests/test_snap.py b/lib/lp/snappy/tests/test_snap.py |
795 | index 93fe69d..f2ef159 100644 |
796 | --- a/lib/lp/snappy/tests/test_snap.py |
797 | +++ b/lib/lp/snappy/tests/test_snap.py |
798 | @@ -278,7 +278,7 @@ class TestSnap(TestCaseWithFactory): |
799 | self.assertEqual(2510, queue_record.lastscore) |
800 | |
801 | def test_requestBuild_relative_build_score(self): |
802 | - # Offsets for archives are respected. |
803 | + # Offsets for archives and snap recipes are respected. |
804 | processor = self.factory.makeProcessor(supports_virtualized=True) |
805 | distroarchseries = self.makeBuildableDistroArchSeries( |
806 | processor=processor |
807 | @@ -286,6 +286,7 @@ class TestSnap(TestCaseWithFactory): |
808 | snap = self.factory.makeSnap( |
809 | distroseries=distroarchseries.distroseries, processors=[processor] |
810 | ) |
811 | + removeSecurityProxy(snap).relative_build_score = 50 |
812 | archive = self.factory.makeArchive(owner=snap.owner) |
813 | removeSecurityProxy(archive).relative_build_score = 100 |
814 | build = snap.requestBuild( |
815 | @@ -296,7 +297,7 @@ class TestSnap(TestCaseWithFactory): |
816 | ) |
817 | queue_record = build.buildqueue_record |
818 | queue_record.score() |
819 | - self.assertEqual(2610, queue_record.lastscore) |
820 | + self.assertEqual(2660, queue_record.lastscore) |
821 | |
822 | def test_requestBuild_snap_base(self): |
823 | # requestBuild can select a snap base. |
I would want to refactor things like the "Hard-code score" of 2510 (and others, especially where there is implicit arithmetic done) to not be a magic number.
It may not be for this specific merge, but it's something that I can see being a good idea as people start to need to be able to reason about what order things will run in given certain build scores.