← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Colin Watson has proposed merging ~cjwatson/launchpad:more-relative-build-scores into launchpad:master.

Commit message:
Add relative_build_score to CharmRecipe/GitRepository/Snap

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1974360 in Launchpad itself: "[Feature request] Relative build score for snap recipes"
  https://bugs.launchpad.net/launchpad/+bug/1974360

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/437038

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.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:more-relative-build-scores into launchpad:master.
diff --git a/lib/lp/charms/browser/charmrecipe.py b/lib/lp/charms/browser/charmrecipe.py
index f80aebb..3f5a304 100644
--- a/lib/lp/charms/browser/charmrecipe.py
+++ b/lib/lp/charms/browser/charmrecipe.py
@@ -281,6 +281,7 @@ class ICharmRecipeEditSchema(Interface):
             "auto_build",
             "auto_build_channels",
             "store_upload",
+            "relative_build_score",
         ],
     )
 
@@ -495,7 +496,10 @@ class CharmRecipeAdminView(BaseCharmRecipeEditView):
 
     page_title = "Administer"
 
-    field_names = ["require_virtualized"]
+    field_names = [
+        "require_virtualized",
+        "relative_build_score",
+    ]
 
 
 class CharmRecipeEditView(BaseCharmRecipeEditView):
diff --git a/lib/lp/charms/browser/tests/test_charmrecipe.py b/lib/lp/charms/browser/tests/test_charmrecipe.py
index 836fb56..c6568c7 100644
--- a/lib/lp/charms/browser/tests/test_charmrecipe.py
+++ b/lib/lp/charms/browser/tests/test_charmrecipe.py
@@ -565,7 +565,7 @@ class TestCharmRecipeAdminView(BaseTestCharmRecipeView):
         )
 
     def test_admin_recipe(self):
-        # Admins can change require_virtualized.
+        # Admins can change require_virtualized and relative_build_score.
         login("admin@xxxxxxxxxxxxx")
         admin = self.factory.makePerson(
             member_of=[getUtility(ILaunchpadCelebrities).admin]
@@ -573,14 +573,17 @@ class TestCharmRecipeAdminView(BaseTestCharmRecipeView):
         login_person(self.person)
         recipe = self.factory.makeCharmRecipe(registrant=self.person)
         self.assertTrue(recipe.require_virtualized)
+        self.assertEqual(0, recipe.relative_build_score)
 
         browser = self.getViewBrowser(recipe, user=admin)
         browser.getLink("Administer charm recipe").click()
         browser.getControl("Require virtualized builders").selected = False
+        browser.getControl("Relative build score").value = "50"
         browser.getControl("Update charm recipe").click()
 
         login_admin()
         self.assertFalse(recipe.require_virtualized)
+        self.assertEqual(50, recipe.relative_build_score)
 
     def test_admin_recipe_sets_date_last_modified(self):
         # Administering a charm recipe sets the date_last_modified property.
diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
index 6499998..83f0508 100644
--- a/lib/lp/charms/interfaces/charmrecipe.py
+++ b/lib/lp/charms/interfaces/charmrecipe.py
@@ -764,6 +764,18 @@ class ICharmRecipeAdminAttributes(Interface):
         )
     )
 
+    relative_build_score = exported(
+        Int(
+            title=_("Relative build score"),
+            required=True,
+            readonly=False,
+            description=_(
+                "A delta to apply to all build scores for the charm recipe.  "
+                "Builds with a higher score will build sooner."
+            ),
+        )
+    )
+
 
 # XXX cjwatson 2021-09-15 bug=760849: "beta" is a lie to get WADL
 # generation working.  Individual attributes must set their version to
diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py
index 566b61c..da5e668 100644
--- a/lib/lp/charms/model/charmrecipe.py
+++ b/lib/lp/charms/model/charmrecipe.py
@@ -299,6 +299,8 @@ class CharmRecipe(StormBase, WebhookTargetMixin):
 
     _store_channels = JSON("store_channels", allow_none=True)
 
+    _relative_build_score = Int(name="relative_build_score", allow_none=True)
+
     def __init__(
         self,
         registrant,
@@ -342,6 +344,7 @@ class CharmRecipe(StormBase, WebhookTargetMixin):
         self.store_name = store_name
         self.store_secrets = store_secrets
         self.store_channels = store_channels
+        self.relative_build_score = 0
 
     def __repr__(self):
         return "<CharmRecipe ~%s/%s/+charm/%s>" % (
@@ -393,6 +396,18 @@ class CharmRecipe(StormBase, WebhookTargetMixin):
         """See `ICharmRecipe`."""
         self._store_channels = value or None
 
+    # XXX cjwatson 2023-02-08: Remove this once existing rows have been
+    # backfilled.
+    @property
+    def relative_build_score(self):
+        """See `ICharmRecipe`."""
+        return self._relative_build_score or 0
+
+    @relative_build_score.setter
+    def relative_build_score(self, value):
+        """See `ICharmRecipe`."""
+        self._relative_build_score = value
+
     @cachedproperty
     def _default_distribution(self):
         """See `ICharmRecipe`."""
diff --git a/lib/lp/charms/model/charmrecipebuild.py b/lib/lp/charms/model/charmrecipebuild.py
index d0656ce..352ccff 100644
--- a/lib/lp/charms/model/charmrecipebuild.py
+++ b/lib/lp/charms/model/charmrecipebuild.py
@@ -237,9 +237,7 @@ class CharmRecipeBuild(PackageBuildMixin, StormBase):
 
     def calculateScore(self):
         """See `IBuildFarmJob`."""
-        # XXX cjwatson 2021-05-28: We'll probably need something like
-        # CharmRecipe.relative_build_score at some point.
-        return 2510
+        return 2510 + self.recipe.relative_build_score
 
     def getMedianBuildDuration(self):
         """Return the median duration of our successful builds."""
diff --git a/lib/lp/charms/tests/test_charmrecipe.py b/lib/lp/charms/tests/test_charmrecipe.py
index 0855d7e..195de06 100644
--- a/lib/lp/charms/tests/test_charmrecipe.py
+++ b/lib/lp/charms/tests/test_charmrecipe.py
@@ -344,6 +344,17 @@ class TestCharmRecipe(TestCaseWithFactory):
         queue_record.score()
         self.assertEqual(2510, queue_record.lastscore)
 
+    def test_requestBuild_relative_build_score(self):
+        # Offsets for charm recipes are respected.
+        recipe = self.factory.makeCharmRecipe()
+        removeSecurityProxy(recipe).relative_build_score = 50
+        das = self.makeBuildableDistroArchSeries()
+        build_request = self.factory.makeCharmRecipeBuildRequest(recipe=recipe)
+        build = recipe.requestBuild(build_request, das)
+        queue_record = build.buildqueue_record
+        queue_record.score()
+        self.assertEqual(2560, queue_record.lastscore)
+
     def test_requestBuild_channels(self):
         # requestBuild can select non-default channels.
         recipe = self.factory.makeCharmRecipe()
diff --git a/lib/lp/code/browser/configure.zcml b/lib/lp/code/browser/configure.zcml
index 788c7f2..6978fcb 100644
--- a/lib/lp/code/browser/configure.zcml
+++ b/lib/lp/code/browser/configure.zcml
@@ -865,6 +865,12 @@
         template="../templates/git-macros.pt"/>
     <browser:page
         for="lp.code.interfaces.gitrepository.IGitRepository"
+        class="lp.code.browser.gitrepository.GitRepositoryAdminView"
+        permission="launchpad.Admin"
+        name="+admin"
+        template="../../app/templates/generic-edit.pt"/>
+    <browser:page
+        for="lp.code.interfaces.gitrepository.IGitRepository"
         class="lp.code.browser.gitrepository.GitRepositoryEditInformationTypeView"
         permission="launchpad.Edit"
         name="+edit-information-type"
@@ -877,12 +883,6 @@
         template="../../app/templates/generic-edit.pt"/>
     <browser:page
         for="lp.code.interfaces.gitrepository.IGitRepository"
-        class="lp.code.browser.gitrepository.GitRepositoryEditBuilderConstraintsView"
-        permission="launchpad.Admin"
-        name="+builder-constraints"
-        template="../../app/templates/generic-edit.pt"/>
-    <browser:page
-        for="lp.code.interfaces.gitrepository.IGitRepository"
         class="lp.code.browser.gitrepository.GitRepositoryEditView"
         permission="launchpad.Edit"
         name="+edit"
diff --git a/lib/lp/code/browser/gitrepository.py b/lib/lp/code/browser/gitrepository.py
index c3724f0..886260c 100644
--- a/lib/lp/code/browser/gitrepository.py
+++ b/lib/lp/code/browser/gitrepository.py
@@ -266,16 +266,21 @@ class GitRepositoryEditMenu(NavigationMenu):
     facet = "branches"
     title = "Edit Git repository"
     links = [
+        "admin",
         "edit",
         "reviewer",
         "permissions",
         "activity",
         "access_tokens",
         "webhooks",
-        "builder_constraints",
         "delete",
     ]
 
+    @enabled_with_permission("launchpad.Admin")
+    def admin(self):
+        text = "Administer repository"
+        return Link("+admin", text, icon="edit")
+
     @enabled_with_permission("launchpad.Edit")
     def edit(self):
         text = "Change repository details"
@@ -306,11 +311,6 @@ class GitRepositoryEditMenu(NavigationMenu):
         text = "Manage webhooks"
         return Link("+webhooks", text, icon="edit")
 
-    @enabled_with_permission("launchpad.Admin")
-    def builder_constraints(self):
-        text = "Set builder constraints"
-        return Link("+builder-constraints", text, icon="edit")
-
     @enabled_with_permission("launchpad.Edit")
     def delete(self):
         text = "Delete repository"
@@ -634,7 +634,11 @@ class GitRepositoryEditFormView(LaunchpadEditFormView):
 
             use_template(
                 IGitRepository,
-                include=["builder_constraints", "default_branch"],
+                include=[
+                    "builder_constraints",
+                    "default_branch",
+                    "relative_build_score",
+                ],
             )
             information_type = copy_field(
                 IGitRepository["information_type"],
@@ -785,10 +789,13 @@ class GitRepositoryEditReviewerView(GitRepositoryEditFormView):
         return {"reviewer": self.context.code_reviewer}
 
 
-class GitRepositoryEditBuilderConstraintsView(GitRepositoryEditFormView):
-    """A view to set builder constraints."""
+class GitRepositoryAdminView(GitRepositoryEditFormView):
+    """A view for administrative settings on repositories."""
 
-    field_names = ["builder_constraints"]
+    field_names = [
+        "builder_constraints",
+        "relative_build_score",
+    ]
     custom_widget_builder_constraints = LabeledMultiCheckBoxWidget
 
 
diff --git a/lib/lp/code/browser/tests/test_gitrepository.py b/lib/lp/code/browser/tests/test_gitrepository.py
index 51bf64b..ae0c853 100644
--- a/lib/lp/code/browser/tests/test_gitrepository.py
+++ b/lib/lp/code/browser/tests/test_gitrepository.py
@@ -1072,7 +1072,7 @@ class TestGitRepositoryEditReviewerView(TestCaseWithFactory):
         self.assertEqual(modified_date, repository.date_last_modified)
 
 
-class TestGitRepositoryEditBuilderConstraintsView(BrowserTestCase):
+class TestGitRepositoryAdminView(BrowserTestCase):
 
     layer = DatabaseFunctionalLayer
 
@@ -1084,24 +1084,23 @@ class TestGitRepositoryEditBuilderConstraintsView(BrowserTestCase):
         repository_url = canonical_url(repository, rootsite="code")
         browser = self.getViewBrowser(repository, user=owner)
         self.assertRaises(
-            LinkNotFoundError, browser.getLink, "Set builder constraints"
+            LinkNotFoundError, browser.getLink, "Administer repository"
         )
         self.assertRaises(
             Unauthorized,
             self.getUserBrowser,
-            repository_url + "/+builder-constraints",
+            repository_url + "/+admin",
             user=owner,
         )
 
     def test_commercial_admin(self):
-        # A commercial admin can set builder constraints on a Git
-        # repository.
+        # A commercial admin can administer a Git repository.
         self.factory.makeBuilder(open_resources=["gpu", "large", "another"])
         owner = self.factory.makePerson()
         repository = self.factory.makeGitRepository(owner=owner)
         commercial_admin = login_celebrity("commercial_admin")
         browser = self.getViewBrowser(repository, user=commercial_admin)
-        browser.getLink("Set builder constraints").click()
+        browser.getLink("Administer repository").click()
         builder_constraints = browser.getControl(
             name="field.builder_constraints"
         )
@@ -1116,10 +1115,14 @@ class TestGitRepositoryEditBuilderConstraintsView(BrowserTestCase):
             "gpu",
             "large",
         ]
+        relative_build_score = browser.getControl("Relative build score")
+        self.assertEqual("0", relative_build_score.value)
+        relative_build_score.value = "50"
         browser.getControl("Change Git Repository").click()
 
         login_person(owner)
         self.assertEqual(("gpu", "large"), repository.builder_constraints)
+        self.assertEqual(50, repository.relative_build_score)
 
 
 class TestGitRepositoryEditView(TestCaseWithFactory):
diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py
index 453954f..933e824 100644
--- a/lib/lp/code/interfaces/gitrepository.py
+++ b/lib/lp/code/interfaces/gitrepository.py
@@ -1286,6 +1286,18 @@ class IGitRepositoryAdminAttributes(Interface):
         as_of="devel",
     )
 
+    relative_build_score = exported(
+        Int(
+            title=_("Relative build score"),
+            required=True,
+            readonly=False,
+            description=_(
+                "A delta to apply to all build scores of this repository.  "
+                "Builds with a higher score will build sooner."
+            ),
+        )
+    )
+
 
 # XXX cjwatson 2015-01-19 bug=760849: "beta" is a lie to get WADL
 # generation working.  Individual attributes must set their version to
diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py
index 0bfc773..f70592d 100644
--- a/lib/lp/code/model/cibuild.py
+++ b/lib/lp/code/model/cibuild.py
@@ -322,7 +322,7 @@ class CIBuild(PackageBuildMixin, StormBase):
         # above bulky things like live filesystem builds, but below
         # important things like builds of proposed Ubuntu stable updates.
         # See https://help.launchpad.net/Packaging/BuildScores.
-        return 2600
+        return 2600 + self.git_repository.relative_build_score
 
     def getMedianBuildDuration(self):
         """Return the median duration of our recent successful builds."""
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index f78c20c..8369224 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -328,6 +328,8 @@ class GitRepository(
         name="builder_constraints", allow_none=True
     )
 
+    _relative_build_score = Int(name="relative_build_score", allow_none=True)
+
     def __init__(
         self,
         repository_type,
@@ -378,6 +380,7 @@ class GitRepository(
         self.date_last_repacked = date_last_repacked
         self.date_last_scanned = date_last_scanned
         self.builder_constraints = builder_constraints
+        self.relative_build_score = 0
 
     def _createOnHostingService(
         self, clone_from_repository=None, async_create=False
@@ -465,6 +468,18 @@ class GitRepository(
         else:
             return self.owner
 
+    # XXX cjwatson 2023-02-08: Remove this once existing rows have been
+    # backfilled.
+    @property
+    def relative_build_score(self):
+        """See `IGitRepository`."""
+        return self._relative_build_score or 0
+
+    @relative_build_score.setter
+    def relative_build_score(self, value):
+        """See `IGitRepository`."""
+        self._relative_build_score = value
+
     def _checkPersonalPrivateOwnership(self, new_owner):
         if self.information_type in PRIVATE_INFORMATION_TYPES and (
             not new_owner.is_team
diff --git a/lib/lp/code/model/tests/test_cibuild.py b/lib/lp/code/model/tests/test_cibuild.py
index d5b4405..dd2a0d1 100644
--- a/lib/lp/code/model/tests/test_cibuild.py
+++ b/lib/lp/code/model/tests/test_cibuild.py
@@ -702,6 +702,19 @@ class TestCIBuildSet(TestCaseWithFactory):
         queue_record.score()
         self.assertEqual(2600, queue_record.lastscore)
 
+    def test_requestBuild_relative_build_score(self):
+        # Offsets for Git repositories are respected.
+        repository = self.factory.makeGitRepository()
+        removeSecurityProxy(repository).relative_build_score = 50
+        commit_sha1 = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest()
+        das = self.factory.makeBuildableDistroArchSeries()
+        build = getUtility(ICIBuildSet).requestBuild(
+            repository, commit_sha1, das, [[("test", 0)]]
+        )
+        queue_record = build.buildqueue_record
+        queue_record.score()
+        self.assertEqual(2650, queue_record.lastscore)
+
     def test_requestBuild_rejects_repeats(self):
         # requestBuild refuses if an identical build was already requested.
         repository = self.factory.makeGitRepository()
diff --git a/lib/lp/scripts/garbo.py b/lib/lp/scripts/garbo.py
index e798587..bcd8da6 100644
--- a/lib/lp/scripts/garbo.py
+++ b/lib/lp/scripts/garbo.py
@@ -57,6 +57,7 @@ from lp.bugs.scripts.checkwatches.scheduler import (
     MAX_SAMPLE_SIZE,
     BugWatchScheduler,
 )
+from lp.charms.model.charmrecipe import CharmRecipe
 from lp.code.enums import GitRepositoryStatus, RevisionStatusArtifactType
 from lp.code.interfaces.revision import IRevisionSet
 from lp.code.model.codeimportevent import CodeImportEvent
@@ -117,6 +118,7 @@ from lp.services.verification.model.logintoken import LoginToken
 from lp.services.webapp.publisher import canonical_url
 from lp.services.webhooks.interfaces import IWebhookJobSource
 from lp.services.webhooks.model import WebhookJob
+from lp.snappy.model.snap import Snap
 from lp.snappy.model.snapbuild import SnapFile
 from lp.snappy.model.snapbuildjob import SnapBuildJobType
 from lp.soyuz.enums import (
@@ -2243,6 +2245,88 @@ class ArchiveFileDatePopulator(TunableLoop):
         transaction.commit()
 
 
+class CharmRecipeRelativeBuildScorePopulator(TunableLoop):
+    """Populates CharmRecipe.relative_build_score."""
+
+    maximum_chunk_size = 5000
+
+    def __init__(self, log, abort_time=None):
+        super().__init__(log, abort_time)
+        self.start_at = 1
+        self.store = IPrimaryStore(CharmRecipe)
+
+    def findRecipes(self):
+        return self.store.find(
+            CharmRecipe,
+            CharmRecipe.id >= self.start_at,
+            CharmRecipe._relative_build_score == None,
+        ).order_by(CharmRecipe.id)
+
+    def isDone(self):
+        return self.findRecipes().is_empty()
+
+    def __call__(self, chunk_size):
+        recipes = list(self.findRecipes()[:chunk_size])
+        for recipe in recipes:
+            recipe.relative_build_score = 0
+        self.start_at = recipes[-1].id + 1
+        transaction.commit()
+
+
+class GitRepositoryRelativeBuildScorePopulator(TunableLoop):
+    """Populates GitRepository.relative_build_score."""
+
+    maximum_chunk_size = 5000
+
+    def __init__(self, log, abort_time=None):
+        super().__init__(log, abort_time)
+        self.start_at = 1
+        self.store = IPrimaryStore(GitRepository)
+
+    def findRepositories(self):
+        return self.store.find(
+            GitRepository,
+            GitRepository.id >= self.start_at,
+            GitRepository._relative_build_score == None,
+        ).order_by(GitRepository.id)
+
+    def isDone(self):
+        return self.findRepositories().is_empty()
+
+    def __call__(self, chunk_size):
+        repositories = list(self.findRepositories()[:chunk_size])
+        for repository in repositories:
+            repository.relative_build_score = 0
+        self.start_at = repositories[-1].id + 1
+        transaction.commit()
+
+
+class SnapRelativeBuildScorePopulator(TunableLoop):
+    """Populates Snap.relative_build_score."""
+
+    maximum_chunk_size = 5000
+
+    def __init__(self, log, abort_time=None):
+        super().__init__(log, abort_time)
+        self.start_at = 1
+        self.store = IPrimaryStore(Snap)
+
+    def findRecipes(self):
+        return self.store.find(
+            Snap, Snap.id >= self.start_at, Snap._relative_build_score == None
+        ).order_by(Snap.id)
+
+    def isDone(self):
+        return self.findRecipes().is_empty()
+
+    def __call__(self, chunk_size):
+        recipes = list(self.findRecipes()[:chunk_size])
+        for recipe in recipes:
+            recipe.relative_build_score = 0
+        self.start_at = recipes[-1].id + 1
+        transaction.commit()
+
+
 class BaseDatabaseGarbageCollector(LaunchpadCronScript):
     """Abstract base class to run a collection of TunableLoops."""
 
@@ -2562,10 +2646,12 @@ class DailyDatabaseGarbageCollector(BaseDatabaseGarbageCollector):
         BranchJobPruner,
         BugNotificationPruner,
         BugWatchActivityPruner,
+        CharmRecipeRelativeBuildScorePopulator,
         CodeImportEventPruner,
         CodeImportResultPruner,
         DiffPruner,
         GitJobPruner,
+        GitRepositoryRelativeBuildScorePopulator,
         LiveFSFilePruner,
         LoginTokenPruner,
         OCIFilePruner,
@@ -2579,6 +2665,7 @@ class DailyDatabaseGarbageCollector(BaseDatabaseGarbageCollector):
         ScrubPOFileTranslator,
         SnapBuildJobPruner,
         SnapFilePruner,
+        SnapRelativeBuildScorePopulator,
         SourcePackagePublishingHistoryFormatPopulator,
         SuggestiveTemplatesCacheUpdater,
         TeamMembershipPruner,
diff --git a/lib/lp/scripts/tests/test_garbo.py b/lib/lp/scripts/tests/test_garbo.py
index eae9163..6d531c2 100644
--- a/lib/lp/scripts/tests/test_garbo.py
+++ b/lib/lp/scripts/tests/test_garbo.py
@@ -42,6 +42,7 @@ from lp.bugs.model.bugnotification import (
     BugNotificationRecipient,
 )
 from lp.buildmaster.enums import BuildStatus
+from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
 from lp.code.bzr import BranchFormat, RepositoryFormat
 from lp.code.enums import (
     CodeImportResultStatus,
@@ -2545,6 +2546,101 @@ class TestGarbo(FakeAdapterMixin, TestCaseWithFactory):
         self.assertEqual(1, rs.count())
         self.assertEqual(archive_files[1], rs.one())
 
+    def test_CharmRecipeRelativeBuildScorePopulator(self):
+        self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+        switch_dbuser("testadmin")
+        old_recipes = [self.factory.makeCharmRecipe() for _ in range(2)]
+        for recipe in old_recipes:
+            removeSecurityProxy(recipe)._relative_build_score = None
+        try:
+            Store.of(old_recipes[0]).flush()
+        except IntegrityError:
+            # Now enforced by DB NOT NULL constraint; backfilling is no
+            # longer necessary.
+            return
+        new_recipes = [self.factory.makeCharmRecipe() for _ in range(2)]
+        self.assertEqual(0, new_recipes[0].relative_build_score)
+        new_recipes[1].relative_build_score = 100
+        transaction.commit()
+
+        self.runDaily()
+
+        # Old recipes are backfilled.
+        for recipe in old_recipes:
+            self.assertEqual(
+                0, removeSecurityProxy(recipe)._relative_build_score
+            )
+        # Other recipes are left alone.
+        self.assertEqual(
+            0, removeSecurityProxy(new_recipes[0])._relative_build_score
+        )
+        self.assertEqual(
+            100, removeSecurityProxy(new_recipes[1])._relative_build_score
+        )
+
+    def test_GitRepositoryRelativeBuildScorePopulator(self):
+        switch_dbuser("testadmin")
+        old_repositories = [self.factory.makeGitRepository() for _ in range(2)]
+        for repository in old_repositories:
+            removeSecurityProxy(repository)._relative_build_score = None
+        try:
+            Store.of(old_repositories[0]).flush()
+        except IntegrityError:
+            # Now enforced by DB NOT NULL constraint; backfilling is no
+            # longer necessary.
+            return
+        new_repositories = [self.factory.makeGitRepository() for _ in range(2)]
+        self.assertEqual(0, new_repositories[0].relative_build_score)
+        new_repositories[1].relative_build_score = 100
+        transaction.commit()
+
+        self.runDaily()
+
+        # Old repositories are backfilled.
+        for repository in old_repositories:
+            self.assertEqual(
+                0, removeSecurityProxy(repository)._relative_build_score
+            )
+        # Other repositories are left alone.
+        self.assertEqual(
+            0, removeSecurityProxy(new_repositories[0])._relative_build_score
+        )
+        self.assertEqual(
+            100,
+            removeSecurityProxy(new_repositories[1])._relative_build_score,
+        )
+
+    def test_SnapRelativeBuildScorePopulator(self):
+        switch_dbuser("testadmin")
+        old_recipes = [self.factory.makeSnap() for _ in range(2)]
+        for recipe in old_recipes:
+            removeSecurityProxy(recipe)._relative_build_score = None
+        try:
+            Store.of(old_recipes[0]).flush()
+        except IntegrityError:
+            # Now enforced by DB NOT NULL constraint; backfilling is no
+            # longer necessary.
+            return
+        new_recipes = [self.factory.makeSnap() for _ in range(2)]
+        self.assertEqual(0, new_recipes[0].relative_build_score)
+        new_recipes[1].relative_build_score = 100
+        transaction.commit()
+
+        self.runDaily()
+
+        # Old recipes are backfilled.
+        for recipe in old_recipes:
+            self.assertEqual(
+                0, removeSecurityProxy(recipe)._relative_build_score
+            )
+        # Other recipes are left alone.
+        self.assertEqual(
+            0, removeSecurityProxy(new_recipes[0])._relative_build_score
+        )
+        self.assertEqual(
+            100, removeSecurityProxy(new_recipes[1])._relative_build_score
+        )
+
 
 class TestGarboTasks(TestCaseWithFactory):
     layer = LaunchpadZopelessLayer
diff --git a/lib/lp/snappy/browser/snap.py b/lib/lp/snappy/browser/snap.py
index 80819c0..71602fe 100644
--- a/lib/lp/snappy/browser/snap.py
+++ b/lib/lp/snappy/browser/snap.py
@@ -496,6 +496,7 @@ class ISnapEditSchema(Interface):
             "auto_build",
             "auto_build_channels",
             "store_upload",
+            "relative_build_score",
         ],
     )
 
@@ -915,6 +916,7 @@ class SnapAdminView(BaseSnapEditView):
         "information_type",
         "require_virtualized",
         "allow_internet",
+        "relative_build_score",
     ]
 
     @property
diff --git a/lib/lp/snappy/browser/tests/test_snap.py b/lib/lp/snappy/browser/tests/test_snap.py
index 4c2fbdd..2cbfa05 100644
--- a/lib/lp/snappy/browser/tests/test_snap.py
+++ b/lib/lp/snappy/browser/tests/test_snap.py
@@ -849,7 +849,8 @@ class TestSnapAdminView(BaseTestSnapView):
         )
 
     def test_admin_snap(self):
-        # Admins can change require_virtualized, privacy, and allow_internet.
+        # Admins can change require_virtualized, privacy, allow_internet,
+        # and relative_build_score.
         login("admin@xxxxxxxxxxxxx")
         admin = self.factory.makePerson(
             member_of=[getUtility(ILaunchpadCelebrities).admin]
@@ -863,6 +864,7 @@ class TestSnapAdminView(BaseTestSnapView):
         self.assertIsNone(snap.project)
         self.assertFalse(snap.private)
         self.assertTrue(snap.allow_internet)
+        self.assertEqual(0, snap.relative_build_score)
 
         self.factory.makeAccessPolicy(
             pillar=project, type=InformationType.PRIVATESECURITY
@@ -874,6 +876,7 @@ class TestSnapAdminView(BaseTestSnapView):
         browser.getControl("Require virtualized builders").selected = False
         browser.getControl(name="field.information_type").value = private
         browser.getControl("Allow external network access").selected = False
+        browser.getControl("Relative build score").value = "50"
         browser.getControl("Update snap package").click()
 
         login_admin()
@@ -881,6 +884,7 @@ class TestSnapAdminView(BaseTestSnapView):
         self.assertFalse(snap.require_virtualized)
         self.assertTrue(snap.private)
         self.assertFalse(snap.allow_internet)
+        self.assertEqual(50, snap.relative_build_score)
 
     def test_admin_snap_private_without_project(self):
         # Cannot make snap private if it doesn't have a project associated.
diff --git a/lib/lp/snappy/interfaces/snap.py b/lib/lp/snappy/interfaces/snap.py
index a86919c..55ba182 100644
--- a/lib/lp/snappy/interfaces/snap.py
+++ b/lib/lp/snappy/interfaces/snap.py
@@ -1198,6 +1198,18 @@ class ISnapAdminAttributes(Interface):
         )
     )
 
+    relative_build_score = exported(
+        Int(
+            title=_("Relative build score"),
+            required=True,
+            readonly=False,
+            description=_(
+                "A delta to apply to all build scores for the snap recipe.  "
+                "Builds with a higher score will build sooner."
+            ),
+        )
+    )
+
     def subscribe(person, subscribed_by):
         """Subscribe a person to this snap recipe."""
 
diff --git a/lib/lp/snappy/model/snap.py b/lib/lp/snappy/model/snap.py
index de5e86e..0b7d0f8 100644
--- a/lib/lp/snappy/model/snap.py
+++ b/lib/lp/snappy/model/snap.py
@@ -394,6 +394,8 @@ class Snap(StormBase, WebhookTargetMixin):
 
     _store_channels = JSON("store_channels", allow_none=True)
 
+    _relative_build_score = Int(name="relative_build_score", allow_none=True)
+
     def __init__(
         self,
         registrant,
@@ -452,6 +454,7 @@ class Snap(StormBase, WebhookTargetMixin):
         self.store_name = store_name
         self.store_secrets = store_secrets
         self.store_channels = store_channels
+        self.relative_build_score = 0
 
     def __repr__(self):
         return "<Snap ~%s/+snap/%s>" % (self.owner.name, self.name)
@@ -679,6 +682,18 @@ class Snap(StormBase, WebhookTargetMixin):
     def store_channels(self, value):
         self._store_channels = value or None
 
+    # XXX cjwatson 2023-02-08: Remove this once existing rows have been
+    # backfilled.
+    @property
+    def relative_build_score(self):
+        """See `ISnap`."""
+        return self._relative_build_score or 0
+
+    @relative_build_score.setter
+    def relative_build_score(self, value):
+        """See `ISnap`."""
+        self._relative_build_score = value
+
     def getAllowedInformationTypes(self, user):
         """See `ISnap`."""
         if user_has_special_snap_access(user):
diff --git a/lib/lp/snappy/model/snapbuild.py b/lib/lp/snappy/model/snapbuild.py
index 410942b..e6678c0 100644
--- a/lib/lp/snappy/model/snapbuild.py
+++ b/lib/lp/snappy/model/snapbuild.py
@@ -304,7 +304,11 @@ class SnapBuild(PackageBuildMixin, StormBase):
         return super().can_be_retried
 
     def calculateScore(self):
-        return 2510 + self.archive.relative_build_score
+        return (
+            2510
+            + self.archive.relative_build_score
+            + self.snap.relative_build_score
+        )
 
     def getMedianBuildDuration(self):
         """Return the median duration of our successful builds."""
diff --git a/lib/lp/snappy/tests/test_snap.py b/lib/lp/snappy/tests/test_snap.py
index 93fe69d..f2ef159 100644
--- a/lib/lp/snappy/tests/test_snap.py
+++ b/lib/lp/snappy/tests/test_snap.py
@@ -278,7 +278,7 @@ class TestSnap(TestCaseWithFactory):
         self.assertEqual(2510, queue_record.lastscore)
 
     def test_requestBuild_relative_build_score(self):
-        # Offsets for archives are respected.
+        # Offsets for archives and snap recipes are respected.
         processor = self.factory.makeProcessor(supports_virtualized=True)
         distroarchseries = self.makeBuildableDistroArchSeries(
             processor=processor
@@ -286,6 +286,7 @@ class TestSnap(TestCaseWithFactory):
         snap = self.factory.makeSnap(
             distroseries=distroarchseries.distroseries, processors=[processor]
         )
+        removeSecurityProxy(snap).relative_build_score = 50
         archive = self.factory.makeArchive(owner=snap.owner)
         removeSecurityProxy(archive).relative_build_score = 100
         build = snap.requestBuild(
@@ -296,7 +297,7 @@ class TestSnap(TestCaseWithFactory):
         )
         queue_record = build.buildqueue_record
         queue_record.score()
-        self.assertEqual(2610, queue_record.lastscore)
+        self.assertEqual(2660, queue_record.lastscore)
 
     def test_requestBuild_snap_base(self):
         # requestBuild can select a snap base.