← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:dispatch-builders-by-group into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:dispatch-builders-by-group into launchpad:master with ~cjwatson/launchpad:bulk-logtail-update as a prerequisite.

Commit message:
Batch build candidate selection queries

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1866868 in Launchpad itself: "buildd-manager frequently gets stuck and stops gathering files from builders"
  https://bugs.launchpad.net/launchpad/+bug/1866868

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

We now select a batch of build candidates once per processor/virtualization combination per scan cycle, rather than once per idle builder per scan cycle.  This should allow buildd-manager to scale much better to large numbers of builders.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:dispatch-builders-by-group into launchpad:master.
diff --git a/lib/lp/buildmaster/interactor.py b/lib/lp/buildmaster/interactor.py
index d02249b..07dedda 100644
--- a/lib/lp/buildmaster/interactor.py
+++ b/lib/lp/buildmaster/interactor.py
@@ -337,8 +337,9 @@ class BuilderSlave(object):
 
 BuilderVitals = namedtuple(
     'BuilderVitals',
-    ('name', 'url', 'virtualized', 'vm_host', 'vm_reset_protocol',
-     'builderok', 'manual', 'build_queue', 'version', 'clean_status'))
+    ('name', 'url', 'processors', 'virtualized', 'vm_host',
+     'vm_reset_protocol', 'builderok', 'manual', 'build_queue', 'version',
+     'clean_status'))
 
 _BQ_UNSPECIFIED = object()
 
@@ -347,9 +348,10 @@ def extract_vitals_from_db(builder, build_queue=_BQ_UNSPECIFIED):
     if build_queue == _BQ_UNSPECIFIED:
         build_queue = builder.currentjob
     return BuilderVitals(
-        builder.name, builder.url, builder.virtualized, builder.vm_host,
-        builder.vm_reset_protocol, builder.builderok, builder.manual,
-        build_queue, builder.version, builder.clean_status)
+        builder.name, builder.url, removeSecurityProxy(builder.processors),
+        builder.virtualized, builder.vm_host, builder.vm_reset_protocol,
+        builder.builderok, builder.manual, build_queue, builder.version,
+        builder.clean_status)
 
 
 class BuilderInteractor(object):
@@ -500,20 +502,31 @@ class BuilderInteractor(object):
 
     @classmethod
     @defer.inlineCallbacks
-    def findAndStartJob(cls, vitals, builder, slave):
+    def findAndStartJob(cls, vitals, builder, slave, builder_factory):
         """Find a job to run and send it to the buildd slave.
 
         :return: A Deferred whose value is the `IBuildQueue` instance
             found or None if no job was found.
         """
         logger = cls._getSlaveScannerLogger()
-        # XXX This method should be removed in favour of two separately
-        # called methods that find and dispatch the job.  It will
-        # require a lot of test fixing.
-        candidate = builder.acquireBuildCandidate()
+
+        # Find a build candidate.  If we succeed, mark it as building
+        # immediately so that it is not dispatched by another builder in the
+        # build manager.
+        #
+        # We can consider this to be atomic, because although the build
+        # manager is a Twisted app and gives the appearance of doing lots of
+        # things at once, it's still single-threaded so no more than one
+        # builder scan can be in this code at the same time.
+        #
+        # If there's ever more than one build manager running at once, then
+        # this code will need some sort of mutex.
+        candidate = builder_factory.findBuildCandidate(vitals)
         if candidate is None:
             logger.debug("No build candidates available for builder.")
             defer.returnValue(None)
+        candidate.markAsBuilding(builder)
+        transaction.commit()
 
         new_behaviour = cls.getBuildBehaviour(candidate, builder, slave)
         needed_bfjb = type(removeSecurityProxy(
diff --git a/lib/lp/buildmaster/interfaces/builder.py b/lib/lp/buildmaster/interfaces/builder.py
index aefaf26..ac798a5 100644
--- a/lib/lp/buildmaster/interfaces/builder.py
+++ b/lib/lp/buildmaster/interfaces/builder.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2014 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Builder interfaces."""
@@ -208,22 +208,6 @@ class IBuilderView(IHasBuildRecords, IHasOwner):
     def failBuilder(reason):
         """Mark builder as failed for a given reason."""
 
-    def acquireBuildCandidate():
-        """Acquire a build candidate in an atomic fashion.
-
-        When retrieiving a candidate we need to mark it as building
-        immediately so that it is not dispatched by another builder in the
-        build manager.
-
-        We can consider this to be atomic because although the build manager
-        is a Twisted app and gives the appearance of doing lots of things at
-        once, it's still single-threaded so no more than one builder scan
-        can be in this code at the same time.
-
-        If there's ever more than one build manager running at once, then
-        this code will need some sort of mutex.
-        """
-
 
 class IBuilderEdit(Interface):
 
@@ -298,6 +282,9 @@ class IBuilderSet(IBuilderSetAdmin):
     def get(builder_id):
         """Return the IBuilder with the given builderid."""
 
+    def preloadProcessors(builders):
+        """Preload processors for a collection of `IBuilder`s."""
+
     @collection_default_content()
     def getBuilders():
         """Return all active configured builders."""
diff --git a/lib/lp/buildmaster/interfaces/buildfarmjob.py b/lib/lp/buildmaster/interfaces/buildfarmjob.py
index fef487d..79a948f 100644
--- a/lib/lp/buildmaster/interfaces/buildfarmjob.py
+++ b/lib/lp/buildmaster/interfaces/buildfarmjob.py
@@ -260,7 +260,7 @@ class ISpecificBuildFarmJobSource(Interface):
             job.
         """
 
-    def addCandidateSelectionCriteria(processor, virtualized):
+    def addCandidateSelectionCriteria():
         """Provide a sub-query to refine the candidate job selection.
 
         Return a sub-query to narrow down the list of candidate jobs.
@@ -268,10 +268,6 @@ class ISpecificBuildFarmJobSource(Interface):
         refer to the `BuildQueue` and `BuildFarmJob` tables already utilized
         in the latter.
 
-        :param processor: the type of processor that the candidate jobs are
-            expected to run on.
-        :param virtualized: whether the candidate jobs are expected to run on
-            the `processor` natively or inside a virtual machine.
         :return: a string containing a sub-query that narrows down the list of
             candidate jobs.
         """
diff --git a/lib/lp/buildmaster/interfaces/buildqueue.py b/lib/lp/buildmaster/interfaces/buildqueue.py
index 9bcf346..c067428 100644
--- a/lib/lp/buildmaster/interfaces/buildqueue.py
+++ b/lib/lp/buildmaster/interfaces/buildqueue.py
@@ -144,3 +144,11 @@ class IBuildQueueSet(Interface):
 
     def preloadForBuildFarmJobs(builds):
         """Preload buildqueue_record for the given IBuildFarmJobs."""
+
+    def findBuildCandidates(processor, virtualized, limit):
+        """Find candidate jobs for dispatch to idle builders.
+
+        :return: A sequence of up to `limit` `IBuildQueue` items with the
+            highest score that are for the given `processor` and that match
+            the given value of `virtualized`.
+        """
diff --git a/lib/lp/buildmaster/manager.py b/lib/lp/buildmaster/manager.py
index 8d9ffca..e4fb9f5 100644
--- a/lib/lp/buildmaster/manager.py
+++ b/lib/lp/buildmaster/manager.py
@@ -11,8 +11,10 @@ __all__ = [
     'SlaveScanner',
     ]
 
+from collections import defaultdict
 import datetime
 import functools
+from itertools import chain
 import logging
 
 import six
@@ -49,6 +51,7 @@ from lp.buildmaster.interfaces.builder import (
     CannotResumeHost,
     IBuilderSet,
     )
+from lp.buildmaster.interfaces.buildqueue import IBuildQueueSet
 from lp.buildmaster.model.builder import Builder
 from lp.buildmaster.model.buildqueue import BuildQueue
 from lp.services.database.bulk import dbify_value
@@ -72,6 +75,13 @@ JOB_RESET_THRESHOLD = 3
 BUILDER_FAILURE_THRESHOLD = 5
 
 
+def sort_build_candidates(candidates):
+    # Re-sort a combined list of candidates.  This must match the ordering
+    # used in BuildQueueSet.findBuildCandidates.
+    return sorted(
+        candidates, key=lambda candidate: (-candidate.lastscore, candidate.id))
+
+
 class BuilderFactory:
     """A dumb builder factory that just talks to the DB."""
 
@@ -109,6 +119,14 @@ class BuilderFactory:
             extract_vitals_from_db(b)
             for b in getUtility(IBuilderSet).__iter__())
 
+    def findBuildCandidate(self, vitals):
+        """Find the next build candidate for this `BuilderVitals`, or None."""
+        bq_set = getUtility(IBuildQueueSet)
+        candidates = sort_build_candidates(chain.from_iterable(
+            bq_set.findBuildCandidates(processor, vitals.virtualized, 1)
+            for processor in vitals.processors + [None]))
+        return candidates[0] if candidates else None
+
 
 class PrefetchedBuilderFactory:
     """A smart builder factory that does efficient bulk queries.
@@ -119,15 +137,28 @@ class PrefetchedBuilderFactory:
 
     date_updated = None
 
+    @staticmethod
+    def _getBuilderGroupKeys(vitals):
+        return [
+            (processor, vitals.virtualized)
+            for processor in vitals.processors + [None]]
+
     def update(self):
         """See `BuilderFactory`."""
         transaction.abort()
-        builders_and_bqs = IStore(Builder).using(
+        builders_and_bqs = list(IStore(Builder).using(
             Builder, LeftJoin(BuildQueue, BuildQueue.builderID == Builder.id)
-            ).find((Builder, BuildQueue))
+            ).find((Builder, BuildQueue)))
+        getUtility(IBuilderSet).preloadProcessors(
+            [b for b, _ in builders_and_bqs])
         self.vitals_map = dict(
             (b.name, extract_vitals_from_db(b, bq))
             for b, bq in builders_and_bqs)
+        self.builder_groups = defaultdict(list)
+        for vitals in self.vitals_map.values():
+            for builder_group_key in self._getBuilderGroupKeys(vitals):
+                self.builder_groups[builder_group_key].append(vitals)
+        self.candidates_map = {}
         transaction.abort()
         self.date_updated = datetime.datetime.utcnow()
 
@@ -151,6 +182,22 @@ class PrefetchedBuilderFactory:
         """See `BuilderFactory`."""
         return (b for n, b in sorted(six.iteritems(self.vitals_map)))
 
+    def findBuildCandidate(self, vitals):
+        """See `BuilderFactory`."""
+        bq_set = getUtility(IBuildQueueSet)
+        builder_group_keys = self._getBuilderGroupKeys(vitals)
+        for builder_group_key in builder_group_keys:
+            if builder_group_key not in self.candidates_map:
+                processor, virtualized = builder_group_key
+                self.candidates_map[builder_group_key] = (
+                    bq_set.findBuildCandidates(
+                        processor, virtualized,
+                        len(self.builder_groups[builder_group_key])))
+        candidates = sorted(chain.from_iterable(
+            self.candidates_map[builder_group_key]
+            for builder_group_key in builder_group_keys))
+        return candidates.pop(0) if candidates else None
+
 
 def judge_failure(builder_count, job_count, exc, retry=True):
     """Judge how to recover from a scan failure.
@@ -517,7 +564,8 @@ class SlaveScanner:
                 # attempt to just retry the scan; we need to reset
                 # the job so the dispatch will be reattempted.
                 builder = self.builder_factory[self.builder_name]
-                d = interactor.findAndStartJob(vitals, builder, slave)
+                d = interactor.findAndStartJob(
+                    vitals, builder, slave, self.builder_factory)
                 d.addErrback(functools.partial(self._scanFailed, False))
                 yield d
                 if builder.currentjob is not None:
diff --git a/lib/lp/buildmaster/model/builder.py b/lib/lp/buildmaster/model/builder.py
index a44e7fc..473b923 100644
--- a/lib/lp/buildmaster/model/builder.py
+++ b/lib/lp/buildmaster/model/builder.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -9,9 +9,6 @@ __all__ = [
     'BuilderSet',
     ]
 
-import logging
-
-import six
 from sqlobject import (
     BoolCol,
     ForeignKey,
@@ -20,23 +17,15 @@ from sqlobject import (
     StringCol,
     )
 from storm.expr import (
-    And,
     Coalesce,
     Count,
-    Desc,
-    Exists,
-    Or,
-    Select,
-    SQL,
     Sum,
     )
 from storm.properties import Int
 from storm.references import Reference
 from storm.store import Store
-import transaction
 from zope.component import getUtility
 from zope.interface import implementer
-from zope.security.proxy import removeSecurityProxy
 
 from lp.app.errors import (
     IncompatibleArguments,
@@ -53,11 +42,7 @@ from lp.buildmaster.interfaces.builder import (
     )
 from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJobSet
 from lp.buildmaster.interfaces.buildqueue import IBuildQueueSet
-from lp.buildmaster.model.buildfarmjob import BuildFarmJob
-from lp.buildmaster.model.buildqueue import (
-    BuildQueue,
-    specific_build_farm_job_sources,
-    )
+from lp.buildmaster.model.buildqueue import BuildQueue
 from lp.buildmaster.model.processor import Processor
 from lp.registry.interfaces.person import validate_public_person
 from lp.services.database.bulk import (
@@ -74,7 +59,6 @@ from lp.services.database.interfaces import (
     )
 from lp.services.database.sqlbase import SQLBase
 from lp.services.database.stormbase import StormBase
-from lp.services.features import getFeatureFlag
 from lp.services.propertycache import (
     cachedproperty,
     get_property_cache,
@@ -215,97 +199,6 @@ class Builder(SQLBase):
             return getUtility(IBuildFarmJobSet).getBuildsForBuilder(
                 self, status=build_state, user=user)
 
-    def _getSlaveScannerLogger(self):
-        """Return the logger instance from buildd-slave-scanner.py."""
-        # XXX cprov 20071120: Ideally the Launchpad logging system
-        # should be able to configure the root-logger instead of creating
-        # a new object, then the logger lookups won't require the specific
-        # name argument anymore. See bug 164203.
-        logger = logging.getLogger('slave-scanner')
-        return logger
-
-    def acquireBuildCandidate(self):
-        """See `IBuilder`."""
-        candidate = self._findBuildCandidate()
-        if candidate is not None:
-            candidate.markAsBuilding(self)
-            transaction.commit()
-        return candidate
-
-    def _findBuildCandidate(self):
-        """Find a candidate job for dispatch to an idle buildd slave.
-
-        The pending BuildQueue item with the highest score for this builder
-        or None if no candidate is available.
-
-        :return: A candidate job.
-        """
-        logger = self._getSlaveScannerLogger()
-
-        job_type_conditions = []
-        job_sources = specific_build_farm_job_sources()
-        for job_type, job_source in six.iteritems(job_sources):
-            query = job_source.addCandidateSelectionCriteria(
-                self.processor, self.virtualized)
-            if query:
-                job_type_conditions.append(
-                    Or(
-                        BuildFarmJob.job_type != job_type,
-                        Exists(SQL(query))))
-
-        def get_int_feature_flag(flag):
-            value_str = getFeatureFlag(flag)
-            if value_str is not None:
-                try:
-                    return int(value_str)
-                except ValueError:
-                    logger.error('invalid %s %r', flag, value_str)
-
-        score_conditions = []
-        minimum_scores = set()
-        for processor in self.processors:
-            minimum_scores.add(get_int_feature_flag(
-                'buildmaster.minimum_score.%s' % processor.name))
-        minimum_scores.add(get_int_feature_flag('buildmaster.minimum_score'))
-        minimum_scores.discard(None)
-        # If there are minimum scores set for any of the processors
-        # supported by this builder, use the highest of them.  This is a bit
-        # weird and not completely ideal, but it's a safe conservative
-        # option and avoids substantially complicating the candidate query.
-        if minimum_scores:
-            score_conditions.append(
-                BuildQueue.lastscore >= max(minimum_scores))
-
-        store = IStore(self.__class__)
-        candidate_jobs = store.using(BuildQueue, BuildFarmJob).find(
-            (BuildQueue.id,),
-            BuildFarmJob.id == BuildQueue._build_farm_job_id,
-            BuildQueue.status == BuildQueueStatus.WAITING,
-            Or(
-                BuildQueue.processorID.is_in(Select(
-                    BuilderProcessor.processor_id, tables=[BuilderProcessor],
-                    where=BuilderProcessor.builder == self)),
-                BuildQueue.processor == None),
-            BuildQueue.virtualized == self.virtualized,
-            BuildQueue.builder == None,
-            And(*(job_type_conditions + score_conditions))
-            ).order_by(Desc(BuildQueue.lastscore), BuildQueue.id)
-
-        # Only try the first handful of jobs. It's much easier on the
-        # database, the chance of a large prefix of the queue being
-        # bad candidates is negligible, and we want reasonably bounded
-        # per-cycle performance even if the prefix is large.
-        for (candidate_id,) in candidate_jobs[:10]:
-            candidate = getUtility(IBuildQueueSet).get(candidate_id)
-            job_source = job_sources[
-                removeSecurityProxy(candidate)._build_farm_job.job_type]
-            candidate_approved = job_source.postprocessCandidate(
-                candidate, logger)
-            if candidate_approved:
-                return candidate
-
-        return None
-
 
 class BuilderProcessor(StormBase):
     __storm_table__ = 'BuilderProcessor'
@@ -354,18 +247,20 @@ class BuilderSet(object):
         """See IBuilderSet."""
         return Builder.select().count()
 
-    def _preloadProcessors(self, rows):
+    def preloadProcessors(self, builders):
+        """See `IBuilderSet`."""
         # Grab (Builder.id, Processor.id) pairs and stuff them into the
         # Builders' processor caches.
         store = IStore(BuilderProcessor)
+        builder_ids = [b.id for b in builders]
         pairs = list(store.using(BuilderProcessor, Processor).find(
             (BuilderProcessor.builder_id, BuilderProcessor.processor_id),
             BuilderProcessor.processor_id == Processor.id,
-            BuilderProcessor.builder_id.is_in([b.id for b in rows])).order_by(
+            BuilderProcessor.builder_id.is_in(builder_ids)).order_by(
                 BuilderProcessor.builder_id, Processor.name))
         load(Processor, [pid for bid, pid in pairs])
-        for row in rows:
-            get_property_cache(row)._processors_cache = []
+        for builder in builders:
+            get_property_cache(builder)._processors_cache = []
         for bid, pid in pairs:
             cache = get_property_cache(store.get(Builder, bid))
             cache._processors_cache.append(store.get(Processor, pid))
@@ -378,7 +273,7 @@ class BuilderSet(object):
                 Builder.virtualized, Builder.name)
 
         def preload(rows):
-            self._preloadProcessors(rows)
+            self.preloadProcessors(rows)
             load_related(Person, rows, ['ownerID'])
             bqs = getUtility(IBuildQueueSet).preloadForBuilders(rows)
             BuildQueue.preloadSpecificBuild(bqs)
diff --git a/lib/lp/buildmaster/model/buildfarmjob.py b/lib/lp/buildmaster/model/buildfarmjob.py
index cd53136..e490204 100644
--- a/lib/lp/buildmaster/model/buildfarmjob.py
+++ b/lib/lp/buildmaster/model/buildfarmjob.py
@@ -251,7 +251,7 @@ class BuildFarmJobMixin:
 class SpecificBuildFarmJobSourceMixin:
 
     @staticmethod
-    def addCandidateSelectionCriteria(processor, virtualized):
+    def addCandidateSelectionCriteria():
         """See `ISpecificBuildFarmJobSource`."""
         return ('')
 
diff --git a/lib/lp/buildmaster/model/buildqueue.py b/lib/lp/buildmaster/model/buildqueue.py
index 6173a48..1e6bae3 100644
--- a/lib/lp/buildmaster/model/buildqueue.py
+++ b/lib/lp/buildmaster/model/buildqueue.py
@@ -11,9 +11,11 @@ __all__ = [
 
 from datetime import datetime
 from itertools import groupby
+import logging
 from operator import attrgetter
 
 import pytz
+import six
 from sqlobject import (
     BoolCol,
     ForeignKey,
@@ -21,6 +23,13 @@ from sqlobject import (
     IntervalCol,
     StringCol,
     )
+from storm.expr import (
+    And,
+    Desc,
+    Exists,
+    Or,
+    SQL,
+    )
 from storm.properties import (
     DateTime,
     Int,
@@ -55,6 +64,7 @@ from lp.services.database.constants import (
 from lp.services.database.enumcol import EnumCol
 from lp.services.database.interfaces import IStore
 from lp.services.database.sqlbase import SQLBase
+from lp.services.features import getFeatureFlag
 from lp.services.propertycache import (
     cachedproperty,
     get_property_cache,
@@ -273,3 +283,81 @@ class BuildQueueSet(object):
                 removeSecurityProxy(build).build_farm_job_id)
             get_property_cache(build).buildqueue_record = bq
         return bqs
+
+    def _getSlaveScannerLogger(self):
+        """Return the logger instance from buildd-slave-scanner.py."""
+        # XXX cprov 20071120: Ideally the Launchpad logging system
+        # should be able to configure the root-logger instead of creating
+        # a new object, then the logger lookups won't require the specific
+        # name argument anymore. See bug 164203.
+        logger = logging.getLogger('slave-scanner')
+        return logger
+
+    def findBuildCandidates(self, processor, virtualized, limit):
+        """See `IBuildQueueSet`."""
+        # Circular import.
+        from lp.buildmaster.model.buildfarmjob import BuildFarmJob
+
+        logger = self._getSlaveScannerLogger()
+
+        job_type_conditions = []
+        job_sources = specific_build_farm_job_sources()
+        for job_type, job_source in six.iteritems(job_sources):
+            query = job_source.addCandidateSelectionCriteria()
+            if query:
+                job_type_conditions.append(
+                    Or(
+                        BuildFarmJob.job_type != job_type,
+                        Exists(SQL(query))))
+
+        def get_int_feature_flag(flag):
+            value_str = getFeatureFlag(flag)
+            if value_str is not None:
+                try:
+                    return int(value_str)
+                except ValueError:
+                    logger.error('invalid %s %r', flag, value_str)
+
+        score_conditions = []
+        minimum_scores = set()
+        if processor is not None:
+            minimum_scores.add(get_int_feature_flag(
+                'buildmaster.minimum_score.%s' % processor.name))
+        minimum_scores.add(get_int_feature_flag('buildmaster.minimum_score'))
+        minimum_scores.discard(None)
+        # If there are minimum scores set for any of the processors
+        # supported by this builder, use the highest of them.  This is a bit
+        # weird and not completely ideal, but it's a safe conservative
+        # option and avoids substantially complicating the candidate query.
+        if minimum_scores:
+            score_conditions.append(
+                BuildQueue.lastscore >= max(minimum_scores))
+
+        store = IStore(BuildQueue)
+        candidate_jobs = store.using(BuildQueue, BuildFarmJob).find(
+            (BuildQueue.id,),
+            BuildFarmJob.id == BuildQueue._build_farm_job_id,
+            BuildQueue.status == BuildQueueStatus.WAITING,
+            BuildQueue.processor == processor,
+            BuildQueue.virtualized == virtualized,
+            BuildQueue.builder == None,
+            And(*(job_type_conditions + score_conditions))
+            ).order_by(Desc(BuildQueue.lastscore), BuildQueue.id)
+
+        # Only try a limited number of jobs. It's much easier on the
+        # database, the chance of a large prefix of the queue being
+        # bad candidates is negligible, and we want reasonably bounded
+        # per-cycle performance even if the prefix is large.
+        candidates = []
+        for (candidate_id,) in candidate_jobs[:max(limit * 2, 10)]:
+            candidate = getUtility(IBuildQueueSet).get(candidate_id)
+            job_source = job_sources[
+                removeSecurityProxy(candidate)._build_farm_job.job_type]
+            candidate_approved = job_source.postprocessCandidate(
+                candidate, logger)
+            if candidate_approved:
+                candidates.append(candidate)
+                if len(candidates) >= limit:
+                    break
+
+        return candidates
diff --git a/lib/lp/buildmaster/tests/mock_slaves.py b/lib/lp/buildmaster/tests/mock_slaves.py
index 32633ea..3a42f8c 100644
--- a/lib/lp/buildmaster/tests/mock_slaves.py
+++ b/lib/lp/buildmaster/tests/mock_slaves.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Mock Build objects for tests soyuz buildd-system."""
@@ -56,14 +56,16 @@ class MockBuilder:
     """Emulates a IBuilder class."""
 
     def __init__(self, name='mock-builder', builderok=True, manual=False,
-                 virtualized=True, vm_host=None, url='http://fake:0000',
-                 version=None, clean_status=BuilderCleanStatus.DIRTY,
+                 processors=None, virtualized=True, vm_host=None,
+                 url='http://fake:0000', version=None,
+                 clean_status=BuilderCleanStatus.DIRTY,
                  vm_reset_protocol=BuilderResetProtocol.PROTO_1_1):
         self.currentjob = None
         self.builderok = builderok
         self.manual = manual
         self.url = url
         self.name = name
+        self.processors = processors or []
         self.virtualized = virtualized
         self.vm_host = vm_host
         self.vm_reset_protocol = vm_reset_protocol
diff --git a/lib/lp/buildmaster/tests/test_builder.py b/lib/lp/buildmaster/tests/test_builder.py
index a97d3c9..4200c7a 100644
--- a/lib/lp/buildmaster/tests/test_builder.py
+++ b/lib/lp/buildmaster/tests/test_builder.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Test Builder features."""
@@ -11,13 +11,13 @@ from zope.security.proxy import removeSecurityProxy
 
 from lp.buildmaster.enums import (
     BuilderCleanStatus,
-    BuildQueueStatus,
     BuildStatus,
     )
 from lp.buildmaster.interfaces.builder import (
     IBuilder,
     IBuilderSet,
     )
+from lp.buildmaster.interfaces.buildqueue import IBuildQueueSet
 from lp.buildmaster.interfaces.processor import IProcessorSet
 from lp.buildmaster.model.buildqueue import BuildQueue
 from lp.buildmaster.tests.mock_slaves import make_publisher
@@ -98,16 +98,21 @@ class TestBuilder(TestCaseWithFactory):
         self.assertEqual([proc], builder.processors)
 
 
-class TestFindBuildCandidateBase(TestCaseWithFactory):
+# XXX cjwatson 2020-05-18: All these tests would now make more sense in
+# lp.buildmaster.tests.test_buildqueue, and should be moved there when
+# convenient.
+class TestFindBuildCandidatesBase(TestCaseWithFactory):
     """Setup the test publisher and some builders."""
 
     layer = LaunchpadZopelessLayer
 
     def setUp(self):
-        super(TestFindBuildCandidateBase, self).setUp()
+        super(TestFindBuildCandidatesBase, self).setUp()
         self.publisher = make_publisher()
         self.publisher.prepareBreezyAutotest()
 
+        self.proc_386 = getUtility(IProcessorSet).getByName('386')
+
         # Create some i386 builders ready to build PPA builds.  Two
         # already exist in sampledata so we'll use those first.
         self.builder1 = getUtility(IBuilderSet)['bob']
@@ -128,182 +133,167 @@ class TestFindBuildCandidateBase(TestCaseWithFactory):
             builder.builderok = True
             builder.manual = False
 
+        self.bq_set = getUtility(IBuildQueueSet)
+
 
-class TestFindBuildCandidateGeneralCases(TestFindBuildCandidateBase):
-    # Test usage of findBuildCandidate not specific to any archive type.
+class TestFindBuildCandidatesGeneralCases(TestFindBuildCandidatesBase):
+    # Test usage of findBuildCandidates not specific to any archive type.
 
-    def test_findBuildCandidate_matches_processor(self):
-        # Builder._findBuildCandidate returns the highest scored build
-        # for any of the builder's architectures.
+    def test_findBuildCandidates_matches_processor(self):
+        # BuildQueueSet.findBuildCandidates returns the highest scored build
+        # for the given processor and the given virtualization setting.
         bq1 = self.factory.makeBinaryPackageBuild().queueBuild()
         bq2 = self.factory.makeBinaryPackageBuild().queueBuild()
+        bq3 = self.factory.makeBinaryPackageBuild(
+            processor=bq2.processor).queueBuild()
 
-        # With no job for the builder's processor, no job is returned.
+        # No job is returned for a fresh processor.
         proc = self.factory.makeProcessor()
-        builder = removeSecurityProxy(
-            self.factory.makeBuilder(processors=[proc], virtualized=True))
-        self.assertIs(None, builder._findBuildCandidate())
+        self.assertEqual([], self.bq_set.findBuildCandidates(proc, True, 3))
 
-        # Once bq1's processor is added to the mix, it's the best
-        # candidate.
-        builder.processors = [proc, bq1.processor]
-        self.assertEqual(bq1, builder._findBuildCandidate())
+        # bq1 is the best candidate for its processor.
+        self.assertEqual(
+            [bq1], self.bq_set.findBuildCandidates(bq1.processor, True, 3))
 
-        # bq2's score doesn't matter, as its processor isn't suitable
-        # for our builder.
+        # bq2's score doesn't matter when finding candidates for bq1's
+        # processor.
         bq2.manualScore(3000)
-        self.assertEqual(bq1, builder._findBuildCandidate())
-
-        # But once we add bq2's processor, its higher score makes it win.
-        builder.processors = [bq1.processor, bq2.processor]
-        self.assertEqual(bq2, builder._findBuildCandidate())
-
-    def test_findBuildCandidate_supersedes_builds(self):
-        # IBuilder._findBuildCandidate identifies if there are builds
-        # for superseded source package releases in the queue and marks
-        # the corresponding build record as SUPERSEDED.
+        self.assertEqual([], self.bq_set.findBuildCandidates(proc, True, 3))
+        self.assertEqual(
+            [bq1], self.bq_set.findBuildCandidates(bq1.processor, True, 3))
+
+        # When looking at bq2's processor, the build with the higher score
+        # wins.
+        self.assertEqual(
+            [bq2, bq3],
+            self.bq_set.findBuildCandidates(bq2.processor, True, 3))
+        bq3.manualScore(4000)
+        self.assertEqual(
+            [bq3, bq2],
+            self.bq_set.findBuildCandidates(bq2.processor, True, 3))
+
+    def test_findBuildCandidates_supersedes_builds(self):
+        # BuildQueueSet.findBuildCandidates identifies if there are builds
+        # for superseded source package releases in the queue and marks the
+        # corresponding build record as SUPERSEDED.
         archive = self.factory.makeArchive()
         self.publisher.getPubSource(
             sourcename="gedit", status=PackagePublishingStatus.PUBLISHED,
             archive=archive).createMissingBuilds()
-        old_candidate = removeSecurityProxy(
-            self.frog_builder)._findBuildCandidate()
+        old_candidates = self.bq_set.findBuildCandidates(
+            self.proc_386, True, 2)
 
         # The candidate starts off as NEEDSBUILD:
         build = getUtility(IBinaryPackageBuildSet).getByQueueEntry(
-            old_candidate)
+            old_candidates[0])
         self.assertEqual(BuildStatus.NEEDSBUILD, build.status)
 
         # Now supersede the source package:
         publication = build.current_source_publication
         publication.status = PackagePublishingStatus.SUPERSEDED
 
-        # The candidate returned is now a different one:
-        new_candidate = removeSecurityProxy(
-            self.frog_builder)._findBuildCandidate()
-        self.assertNotEqual(new_candidate, old_candidate)
+        # The list of candidates returned is now different:
+        new_candidates = self.bq_set.findBuildCandidates(
+            self.proc_386, True, 2)
+        self.assertNotEqual(new_candidates, old_candidates)
 
         # And the old_candidate is superseded:
         self.assertEqual(BuildStatus.SUPERSEDED, build.status)
 
-    def test_findBuildCandidate_honours_minimum_score(self):
+    def test_findBuildCandidates_honours_limit(self):
+        # BuildQueueSet.findBuildCandidates returns no more than the number
+        # of candidates requested.
+        processor = self.factory.makeProcessor()
+        bqs = [
+            self.factory.makeBinaryPackageBuild(
+                processor=processor).queueBuild()
+            for _ in range(10)]
+
+        self.assertEqual(
+            bqs[:5], self.bq_set.findBuildCandidates(processor, True, 5))
+        self.assertEqual(
+            bqs, self.bq_set.findBuildCandidates(processor, True, 10))
+        self.assertEqual(
+            bqs, self.bq_set.findBuildCandidates(processor, True, 11))
+
+        build = getUtility(IBinaryPackageBuildSet).getByQueueEntry(bqs[0])
+        build.current_source_publication.status = (
+            PackagePublishingStatus.SUPERSEDED)
+
+        self.assertEqual(
+            bqs[1:6], self.bq_set.findBuildCandidates(processor, True, 5))
+
+    def test_findBuildCandidates_honours_minimum_score(self):
         # Sometimes there's an emergency that requires us to lock down the
         # build farm except for certain whitelisted builds.  We do this by
         # way of a feature flag to set a minimum score; if this is set,
-        # Builder._findBuildCandidate will ignore any build with a lower
-        # score.
-        bq1 = self.factory.makeBinaryPackageBuild().queueBuild()
-        bq1.manualScore(100000)
-        bq2 = self.factory.makeBinaryPackageBuild().queueBuild()
-        bq2.manualScore(99999)
-        builder1 = removeSecurityProxy(
-            self.factory.makeBuilder(
-                processors=[bq1.processor, self.factory.makeProcessor()],
-                virtualized=True))
-        builder2 = removeSecurityProxy(
-            self.factory.makeBuilder(
-                processors=[bq2.processor, self.factory.makeProcessor()],
-                virtualized=True))
-
-        # By default, each builder has the appropriate one of the two builds
-        # we just created as a candidate.
-        self.assertEqual(bq1, builder1._findBuildCandidate())
-        self.assertEqual(bq2, builder2._findBuildCandidate())
+        # BuildQueueSet.findBuildCandidates will ignore any build with a
+        # lower score.
+        processors = []
+        bqs = []
+        for _ in range(2):
+            processors.append(self.factory.makeProcessor())
+            bqs.append([])
+            for score in (100000, 99999):
+                bq = self.factory.makeBinaryPackageBuild(
+                    processor=processors[-1]).queueBuild()
+                bq.manualScore(score)
+                bqs[-1].append(bq)
+        processors.append(self.factory.makeProcessor())
+
+        # By default, each processor has the two builds we just created for
+        # it as candidates, with the highest score first.
+        self.assertEqual(
+            bqs[0], self.bq_set.findBuildCandidates(processors[0], True, 3))
+        self.assertEqual(
+            bqs[1], self.bq_set.findBuildCandidates(processors[1], True, 3))
 
         # If we set a minimum score, then only builds above that threshold
         # are candidates.
         with FeatureFixture({'buildmaster.minimum_score': '100000'}):
-            self.assertEqual(bq1, builder1._findBuildCandidate())
-            self.assertIsNone(builder2._findBuildCandidate())
+            self.assertEqual(
+                [bqs[0][0]],
+                self.bq_set.findBuildCandidates(processors[0], True, 3))
+            self.assertEqual(
+                [bqs[1][0]],
+                self.bq_set.findBuildCandidates(processors[1], True, 3))
 
         # We can similarly set a minimum score for individual processors.
-        # The maximum of these for any processor supported by the builder is
-        # used.
         cases = [
-            ({0: '99999'}, bq2),
-            ({1: '99999'}, bq2),
-            ({0: '100000'}, None),
-            ({1: '100000'}, None),
-            ({0: '99999', 1: '99999'}, bq2),
-            ({0: '99999', 1: '100000'}, None),
-            ({0: '100000', 1: '99999'}, None),
+            ({0: '99999'}, [bqs[0], bqs[1], []]),
+            ({1: '99999'}, [bqs[0], bqs[1], []]),
+            ({2: '99999'}, [bqs[0], bqs[1], []]),
+            ({0: '100000'}, [[bqs[0][0]], bqs[1], []]),
+            ({1: '100000'}, [bqs[0], [bqs[1][0]], []]),
+            ({2: '100000'}, [bqs[0], bqs[1], []]),
             ]
-        for feature_spec, expected_bq in cases:
+        for feature_spec, expected_bqs in cases:
             features = {
-                'buildmaster.minimum_score.%s' % builder2.processors[i].name:
-                    score
+                'buildmaster.minimum_score.%s' % processors[i].name: score
                 for i, score in feature_spec.items()}
             with FeatureFixture(features):
-                self.assertEqual(expected_bq, builder2._findBuildCandidate())
+                for i, processor in enumerate(processors):
+                    self.assertEqual(
+                        expected_bqs[i],
+                        self.bq_set.findBuildCandidates(processor, True, 3))
 
         # If we set an invalid minimum score, buildd-manager doesn't
         # explode.
         with FakeLogger() as logger:
             with FeatureFixture({'buildmaster.minimum_score': 'nonsense'}):
-                self.assertEqual(bq1, builder1._findBuildCandidate())
-                self.assertEqual(bq2, builder2._findBuildCandidate())
+                self.assertEqual(
+                    bqs[0],
+                    self.bq_set.findBuildCandidates(processors[0], True, 3))
+                self.assertEqual(
+                    bqs[1],
+                    self.bq_set.findBuildCandidates(processors[1], True, 3))
             self.assertEqual(
                 "invalid buildmaster.minimum_score u'nonsense'\n"
                 "invalid buildmaster.minimum_score u'nonsense'\n",
                 logger.output)
 
-    def test_acquireBuildCandidate_marks_building(self):
-        # acquireBuildCandidate() should call _findBuildCandidate and
-        # mark the build as building.
-        archive = self.factory.makeArchive()
-        self.publisher.getPubSource(
-            sourcename="gedit", status=PackagePublishingStatus.PUBLISHED,
-            archive=archive).createMissingBuilds()
-        candidate = removeSecurityProxy(
-            self.frog_builder).acquireBuildCandidate()
-        self.assertEqual(BuildQueueStatus.RUNNING, candidate.status)
-
-
-class TestFindBuildCandidatePPAWithSingleBuilder(TestCaseWithFactory):
-
-    layer = LaunchpadZopelessLayer
-
-    def setUp(self):
-        super(TestFindBuildCandidatePPAWithSingleBuilder, self).setUp()
-        self.publisher = make_publisher()
-        self.publisher.prepareBreezyAutotest()
-
-        self.bob_builder = getUtility(IBuilderSet)['bob']
-        self.frog_builder = getUtility(IBuilderSet)['frog']
 
-        # Disable bob so only frog is available.
-        self.bob_builder.manual = True
-        self.bob_builder.builderok = True
-        self.frog_builder.manual = False
-        self.frog_builder.builderok = True
-
-        # Make a new PPA and give it some builds.
-        self.ppa_joe = self.factory.makeArchive(name="joesppa")
-        self.publisher.getPubSource(
-            sourcename="gedit", status=PackagePublishingStatus.PUBLISHED,
-            archive=self.ppa_joe).createMissingBuilds()
-
-    def test_findBuildCandidate_first_build_started(self):
-        # The allocation rule for PPA dispatching doesn't apply when
-        # there's only one builder available.
-
-        # Asking frog to find a candidate should give us the joesppa build.
-        next_job = removeSecurityProxy(
-            self.frog_builder)._findBuildCandidate()
-        build = getUtility(IBinaryPackageBuildSet).getByQueueEntry(next_job)
-        self.assertEqual('joesppa', build.archive.name)
-
-        # If bob is in a failed state the joesppa build is still
-        # returned.
-        self.bob_builder.builderok = False
-        self.bob_builder.manual = False
-        next_job = removeSecurityProxy(
-            self.frog_builder)._findBuildCandidate()
-        build = getUtility(IBinaryPackageBuildSet).getByQueueEntry(next_job)
-        self.assertEqual('joesppa', build.archive.name)
-
-
-class TestFindBuildCandidatePPABase(TestFindBuildCandidateBase):
+class TestFindBuildCandidatesPPABase(TestFindBuildCandidatesBase):
 
     ppa_joe_private = False
     ppa_jim_private = False
@@ -324,7 +314,7 @@ class TestFindBuildCandidatePPABase(TestFindBuildCandidateBase):
 
     def setUp(self):
         """Publish some builds for the test archive."""
-        super(TestFindBuildCandidatePPABase, self).setUp()
+        super(TestFindBuildCandidatesPPABase, self).setUp()
 
         # Create two PPAs and add some builds to each.
         self.ppa_joe = self.factory.makeArchive(
@@ -374,32 +364,33 @@ class TestFindBuildCandidatePPABase(TestFindBuildCandidateBase):
         self.assertEqual(num_free_builders, 2)
 
 
-class TestFindBuildCandidatePPA(TestFindBuildCandidatePPABase):
+class TestFindBuildCandidatesPPA(TestFindBuildCandidatesPPABase):
 
     def test_findBuildCandidate(self):
         # joe's fourth i386 build will be the next build candidate.
-        next_job = removeSecurityProxy(self.builder4)._findBuildCandidate()
+        [next_job] = self.bq_set.findBuildCandidates(self.proc_386, True, 1)
         build = getUtility(IBinaryPackageBuildSet).getByQueueEntry(next_job)
         self.assertEqual('joesppa', build.archive.name)
 
     def test_findBuildCandidate_with_disabled_archive(self):
         # Disabled archives should not be considered for dispatching
         # builds.
-        disabled_job = removeSecurityProxy(self.builder4)._findBuildCandidate()
+        [disabled_job] = self.bq_set.findBuildCandidates(
+            self.proc_386, True, 1)
         build = getUtility(IBinaryPackageBuildSet).getByQueueEntry(
             disabled_job)
         build.archive.disable()
-        next_job = removeSecurityProxy(self.builder4)._findBuildCandidate()
+        [next_job] = self.bq_set.findBuildCandidates(self.proc_386, True, 1)
         self.assertNotEqual(disabled_job, next_job)
 
 
-class TestFindBuildCandidatePrivatePPA(TestFindBuildCandidatePPABase):
+class TestFindBuildCandidatesPrivatePPA(TestFindBuildCandidatesPPABase):
 
     ppa_joe_private = True
 
     def test_findBuildCandidate_for_private_ppa(self):
         # joe's fourth i386 build will be the next build candidate.
-        next_job = removeSecurityProxy(self.builder4)._findBuildCandidate()
+        [next_job] = self.bq_set.findBuildCandidates(self.proc_386, True, 1)
         build = getUtility(IBinaryPackageBuildSet).getByQueueEntry(next_job)
         self.assertEqual('joesppa', build.archive.name)
 
@@ -408,15 +399,15 @@ class TestFindBuildCandidatePrivatePPA(TestFindBuildCandidatePPABase):
         # from the (password protected) repo area, not the librarian.
         pub = build.current_source_publication
         pub.status = PackagePublishingStatus.PENDING
-        candidate = removeSecurityProxy(self.builder4)._findBuildCandidate()
+        [candidate] = self.bq_set.findBuildCandidates(self.proc_386, True, 1)
         self.assertNotEqual(next_job.id, candidate.id)
 
 
-class TestFindBuildCandidateDistroArchive(TestFindBuildCandidateBase):
+class TestFindBuildCandidatesDistroArchive(TestFindBuildCandidatesBase):
 
     def setUp(self):
         """Publish some builds for the test archive."""
-        super(TestFindBuildCandidateDistroArchive, self).setUp()
+        super(TestFindBuildCandidatesDistroArchive, self).setUp()
         # Create a primary archive and publish some builds for the
         # queue.
         self.non_ppa = self.factory.makeArchive(
@@ -432,29 +423,22 @@ class TestFindBuildCandidateDistroArchive(TestFindBuildCandidateBase):
     def test_findBuildCandidate_for_non_ppa(self):
         # Normal archives are not restricted to serial builds per
         # arch.
-
-        next_job = removeSecurityProxy(
-            self.frog_builder)._findBuildCandidate()
-        build = getUtility(IBinaryPackageBuildSet).getByQueueEntry(next_job)
-        self.assertEqual('primary', build.archive.name)
-        self.assertEqual('gedit', build.source_package_release.name)
+        self.assertEqual(
+            [self.gedit_build.buildqueue_record,
+             self.firefox_build.buildqueue_record],
+            self.bq_set.findBuildCandidates(self.proc_386, True, 3))
 
         # Now even if we set the build building, we'll still get the
         # second non-ppa build for the same archive as the next candidate.
-        build.updateStatus(BuildStatus.BUILDING, builder=self.frog_builder)
-        next_job = removeSecurityProxy(
-            self.frog_builder)._findBuildCandidate()
-        build = getUtility(IBinaryPackageBuildSet).getByQueueEntry(next_job)
-        self.assertEqual('primary', build.archive.name)
-        self.assertEqual('firefox', build.source_package_release.name)
+        self.gedit_build.updateStatus(
+            BuildStatus.BUILDING, builder=self.frog_builder)
+        self.assertEqual(
+            [self.firefox_build.buildqueue_record],
+            self.bq_set.findBuildCandidates(self.proc_386, True, 3))
 
     def test_findBuildCandidate_for_recipe_build(self):
         # Recipe builds with a higher score are selected first.
         # This test is run in a context with mixed recipe and binary builds.
-
-        self.assertIsNot(self.frog_builder.processor, None)
-        self.assertEqual(self.frog_builder.virtualized, True)
-
         self.assertEqual(self.gedit_build.buildqueue_record.lastscore, 2505)
         self.assertEqual(self.firefox_build.buildqueue_record.lastscore, 2505)
 
@@ -467,13 +451,14 @@ class TestFindBuildCandidateDistroArchive(TestFindBuildCandidateBase):
 
         self.assertEqual(recipe_build_job.lastscore, 9999)
 
-        next_job = removeSecurityProxy(
-            self.frog_builder)._findBuildCandidate()
-
-        self.assertEqual(recipe_build_job, next_job)
+        self.assertEqual(
+            [recipe_build_job,
+             self.gedit_build.buildqueue_record,
+             self.firefox_build.buildqueue_record],
+            self.bq_set.findBuildCandidates(self.proc_386, True, 3))
 
 
-class TestFindRecipeBuildCandidates(TestFindBuildCandidateBase):
+class TestFindRecipeBuildCandidates(TestFindBuildCandidatesBase):
     # These tests operate in a "recipe builds only" setting.
     # Please see also bug #507782.
 
@@ -504,11 +489,6 @@ class TestFindRecipeBuildCandidates(TestFindBuildCandidateBase):
     def test_findBuildCandidate_with_highest_score(self):
         # The recipe build with the highest score is selected first.
         # This test is run in a "recipe builds only" context.
-
-        self.assertIsNot(self.frog_builder.processor, None)
-        self.assertEqual(self.frog_builder.virtualized, True)
-
-        next_job = removeSecurityProxy(
-            self.frog_builder)._findBuildCandidate()
-
-        self.assertEqual(self.bq2, next_job)
+        self.assertEqual(
+            [self.bq2, self.bq1],
+            self.bq_set.findBuildCandidates(self.proc_386, True, 2))
diff --git a/lib/lp/buildmaster/tests/test_interactor.py b/lib/lp/buildmaster/tests/test_interactor.py
index 8514bb5..fa21e9e 100644
--- a/lib/lp/buildmaster/tests/test_interactor.py
+++ b/lib/lp/buildmaster/tests/test_interactor.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Test BuilderInteractor features."""
@@ -37,7 +37,6 @@ from twisted.internet import (
     )
 from twisted.internet.task import Clock
 from twisted.python.failure import Failure
-from zope.security.proxy import removeSecurityProxy
 
 from lp.buildmaster.enums import (
     BuilderCleanStatus,
@@ -408,28 +407,30 @@ class TestBuilderInteractorDB(TestCaseWithFactory):
         return builder, build
 
     def test_findAndStartJob_returns_candidate(self):
-        # findAndStartJob finds the next queued job using _findBuildCandidate.
+        # findAndStartJob finds the next queued job using findBuildCandidate.
         # We don't care about the type of build at all.
         builder, build = self._setupRecipeBuildAndBuilder()
         candidate = build.queueBuild()
-        # _findBuildCandidate is tested elsewhere, we just make sure that
+        builder_factory = MockBuilderFactory(builder, candidate)
+        # findBuildCandidate is tested elsewhere, we just make sure that
         # findAndStartJob delegates to it.
-        removeSecurityProxy(builder)._findBuildCandidate = FakeMethod(
-            result=candidate)
+        builder_factory.findBuildCandidate = FakeMethod(result=candidate)
         vitals = extract_vitals_from_db(builder)
-        d = BuilderInteractor.findAndStartJob(vitals, builder, OkSlave())
+        d = BuilderInteractor.findAndStartJob(
+            vitals, builder, OkSlave(), builder_factory)
         return d.addCallback(self.assertEqual, candidate)
 
     def test_findAndStartJob_starts_job(self):
-        # findAndStartJob finds the next queued job using _findBuildCandidate
+        # findAndStartJob finds the next queued job using findBuildCandidate
         # and then starts it.
         # We don't care about the type of build at all.
         builder, build = self._setupRecipeBuildAndBuilder()
         candidate = build.queueBuild()
-        removeSecurityProxy(builder)._findBuildCandidate = FakeMethod(
-            result=candidate)
+        builder_factory = MockBuilderFactory(builder, candidate)
+        builder_factory.findBuildCandidate = FakeMethod(result=candidate)
         vitals = extract_vitals_from_db(builder)
-        d = BuilderInteractor.findAndStartJob(vitals, builder, OkSlave())
+        d = BuilderInteractor.findAndStartJob(
+            vitals, builder, OkSlave(), builder_factory)
 
         def check_build_started(candidate):
             self.assertEqual(candidate.builder, builder)
@@ -443,23 +444,25 @@ class TestBuilderInteractorDB(TestCaseWithFactory):
         builder, build = self._setupBinaryBuildAndBuilder()
         builder.setCleanStatus(BuilderCleanStatus.DIRTY)
         candidate = build.queueBuild()
-        removeSecurityProxy(builder)._findBuildCandidate = FakeMethod(
-            result=candidate)
+        builder_factory = MockBuilderFactory(builder, candidate)
+        builder_factory.findBuildCandidate = FakeMethod(result=candidate)
         vitals = extract_vitals_from_db(builder)
         with ExpectedException(
                 BuildDaemonIsolationError,
                 "Attempted to start build on a dirty slave."):
-            yield BuilderInteractor.findAndStartJob(vitals, builder, OkSlave())
+            yield BuilderInteractor.findAndStartJob(
+                vitals, builder, OkSlave(), builder_factory)
 
     @defer.inlineCallbacks
     def test_findAndStartJob_dirties_slave(self):
         # findAndStartJob marks its builder DIRTY before dispatching.
         builder, build = self._setupBinaryBuildAndBuilder()
         candidate = build.queueBuild()
-        removeSecurityProxy(builder)._findBuildCandidate = FakeMethod(
-            result=candidate)
+        builder_factory = MockBuilderFactory(builder, candidate)
+        builder_factory.findBuildCandidate = FakeMethod(result=candidate)
         vitals = extract_vitals_from_db(builder)
-        yield BuilderInteractor.findAndStartJob(vitals, builder, OkSlave())
+        yield BuilderInteractor.findAndStartJob(
+            vitals, builder, OkSlave(), builder_factory)
         self.assertEqual(BuilderCleanStatus.DIRTY, builder.clean_status)
 
 
diff --git a/lib/lp/buildmaster/tests/test_manager.py b/lib/lp/buildmaster/tests/test_manager.py
index 9257ac8..6d16055 100644
--- a/lib/lp/buildmaster/tests/test_manager.py
+++ b/lib/lp/buildmaster/tests/test_manager.py
@@ -763,7 +763,8 @@ class TestPrefetchedBuilderFactory(TestCaseWithFactory):
 
     def test_update(self):
         # update grabs all of the Builders and their BuildQueues in a
-        # single query.
+        # single query, plus an additional two queries to grab all the
+        # associated Processors.
         builders = [self.factory.makeBuilder() for i in range(5)]
         for i in range(3):
             bq = self.factory.makeBinaryPackageBuild().queueBuild()
@@ -773,7 +774,7 @@ class TestPrefetchedBuilderFactory(TestCaseWithFactory):
         pbf.update()
         with StormStatementRecorder() as recorder:
             pbf.update()
-        self.assertThat(recorder, HasQueryCount(Equals(1)))
+        self.assertThat(recorder, HasQueryCount(Equals(3)))
 
     def test_getVitals(self):
         # PrefetchedBuilderFactory.getVitals looks up the BuilderVitals
diff --git a/lib/lp/soyuz/model/binarypackagebuild.py b/lib/lp/soyuz/model/binarypackagebuild.py
index 0551b82..998682a 100644
--- a/lib/lp/soyuz/model/binarypackagebuild.py
+++ b/lib/lp/soyuz/model/binarypackagebuild.py
@@ -1231,7 +1231,7 @@ class BinaryPackageBuildSet(SpecificBuildFarmJobSourceMixin):
             BinaryPackageBuild, build_farm_job_id=bfj_id).one()
 
     @staticmethod
-    def addCandidateSelectionCriteria(processor, virtualized):
+    def addCandidateSelectionCriteria():
         """See `ISpecificBuildFarmJobSource`."""
         private_statuses = (
             PackagePublishingStatus.PUBLISHED,