Merge ~cjwatson/launchpad:more-relative-build-scores into launchpad:master

Proposed by Colin Watson
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)
Reviewer Review Type Date Requested Status
Clinton Fung Approve
Review via email: mp+437038@code.launchpad.net

Commit message

Add relative_build_score to CharmRecipe/GitRepository/Snap

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.Moderate` permission is already in use for something else), and I wanted to get this out the door without blocking on that.

To post a comment you must log in.
Revision history for this message
Clinton Fung (clinton-fung) wrote :

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.

review: Approve

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

Succeeded
[SUCCEEDED] docs:0 (build)
[SUCCEEDED] lint:0 (build)
[SUCCEEDED] mypy:0 (build)
13 of 3 results

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/charms/browser/charmrecipe.py b/lib/lp/charms/browser/charmrecipe.py
2index 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):
25diff --git a/lib/lp/charms/browser/tests/test_charmrecipe.py b/lib/lp/charms/browser/tests/test_charmrecipe.py
26index 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.
56diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
57index 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
79diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py
80index 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`."""
119diff --git a/lib/lp/charms/model/charmrecipebuild.py b/lib/lp/charms/model/charmrecipebuild.py
120index 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."""
134diff --git a/lib/lp/charms/tests/test_charmrecipe.py b/lib/lp/charms/tests/test_charmrecipe.py
135index 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()
156diff --git a/lib/lp/code/browser/configure.zcml b/lib/lp/code/browser/configure.zcml
157index 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"
186diff --git a/lib/lp/code/browser/gitrepository.py b/lib/lp/code/browser/gitrepository.py
187index 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
255diff --git a/lib/lp/code/browser/tests/test_gitrepository.py b/lib/lp/code/browser/tests/test_gitrepository.py
256index 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):
312diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py
313index 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
335diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py
336index 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."""
348diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
349index 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
388diff --git a/lib/lp/code/model/tests/test_cibuild.py b/lib/lp/code/model/tests/test_cibuild.py
389index 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()
412diff --git a/lib/lp/scripts/garbo.py b/lib/lp/scripts/garbo.py
413index 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,
542diff --git a/lib/lp/scripts/tests/test_garbo.py b/lib/lp/scripts/tests/test_garbo.py
543index 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
656diff --git a/lib/lp/snappy/browser/snap.py b/lib/lp/snappy/browser/snap.py
657index 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
676diff --git a/lib/lp/snappy/browser/tests/test_snap.py b/lib/lp/snappy/browser/tests/test_snap.py
677index 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.
714diff --git a/lib/lp/snappy/interfaces/snap.py b/lib/lp/snappy/interfaces/snap.py
715index 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
737diff --git a/lib/lp/snappy/model/snap.py b/lib/lp/snappy/model/snap.py
738index 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):
777diff --git a/lib/lp/snappy/model/snapbuild.py b/lib/lp/snappy/model/snapbuild.py
778index 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."""
794diff --git a/lib/lp/snappy/tests/test_snap.py b/lib/lp/snappy/tests/test_snap.py
795index 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.

Subscribers

People subscribed via source and target branches

to status/vote changes: