← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~twom/launchpad:stats-daemon into launchpad:master

 

Tom Wardill has proposed merging ~twom/launchpad:stats-daemon into launchpad:master with ~twom/launchpad:stats-actual-build-queues as a prerequisite.

Commit message:
Split timed stats into separate daemon

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1881598 in Launchpad itself: "ubuntutools.archive.UbuntuSourcePackage().pull() fails (take 2)"
  https://bugs.launchpad.net/launchpad/+bug/1881598

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

Ideally we want generation/updating of stats to not block actual work. Move the timed stats generation out of buildd-manager to it's own daemon.
Leave the event driven stats in buildd-manager.

The implementation of the daemon here was heavily based on (and imports part of) buildd-manager itself, as that seemed the simplest method to allow reuse of the existing vitals generation code.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~twom/launchpad:stats-daemon into launchpad:master.
diff --git a/daemons/numbercruncher.tac b/daemons/numbercruncher.tac
new file mode 100644
index 0000000..8435209
--- /dev/null
+++ b/daemons/numbercruncher.tac
@@ -0,0 +1,35 @@
+# Copyright 2009-202 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+# Twisted Application Configuration file.
+# Use with "twistd2.4 -y <file.tac>", e.g. "twistd -noy server.tac"
+
+
+from twisted.application import service
+from twisted.scripts.twistd import ServerOptions
+
+from lp.services.daemons import readyservice
+from lp.services.scripts import execute_zcml_for_scripts
+from lp.services.statsd.numbercruncher import NumberCruncher
+from lp.services.twistedsupport.features import setup_feature_controller
+from lp.services.twistedsupport.loggingsupport import RotatableFileLogObserver
+
+execute_zcml_for_scripts()
+
+options = ServerOptions()
+options.parseOptions()
+
+application = service.Application('BuilddManager')
+application.addComponent(
+    RotatableFileLogObserver(options.get('logfile')), ignoreClass=1)
+
+# Service that announces when the daemon is ready.
+readyservice.ReadyService().setServiceParent(application)
+
+
+# Service for scanning buildd slaves.
+service = NumberCruncher()
+service.setServiceParent(application)
+
+# Allow use of feature flags.
+setup_feature_controller('number-cruncher')
diff --git a/lib/lp/buildmaster/manager.py b/lib/lp/buildmaster/manager.py
index d2b56c3..66fbe80 100644
--- a/lib/lp/buildmaster/manager.py
+++ b/lib/lp/buildmaster/manager.py
@@ -8,6 +8,7 @@ __metaclass__ = type
 __all__ = [
     'BuilddManager',
     'BUILDD_MANAGER_LOG_NAME',
+    'PrefetchedBuilderFactory',
     'SlaveScanner',
     ]
 
@@ -701,9 +702,6 @@ class BuilddManager(service.Service):
     # How often to flush logtail updates, in seconds.
     FLUSH_LOGTAILS_INTERVAL = 15
 
-    # How often to update stats, in seconds
-    UPDATE_STATS_INTERVAL = 60
-
     def __init__(self, clock=None, builder_factory=None):
         # Use the clock if provided, it's so that tests can
         # advance it.  Use the reactor by default.
@@ -735,52 +733,6 @@ class BuilddManager(service.Service):
         logger.setLevel(level)
         return logger
 
-    def _updateBuilderCounts(self):
-        """Update statsd with the builder statuses."""
-        self.logger.debug("Updating builder stats.")
-        counts_by_processor = {}
-        for builder in self.builder_factory.iterVitals():
-            if not builder.active:
-                continue
-            for processor_name in builder.processor_names:
-                counts = counts_by_processor.setdefault(
-                    "{},virtualized={}".format(
-                        processor_name,
-                        builder.virtualized),
-                    {'cleaning': 0, 'idle': 0, 'disabled': 0, 'building': 0})
-                if not builder.builderok:
-                    counts['disabled'] += 1
-                elif builder.clean_status == BuilderCleanStatus.CLEANING:
-                    counts['cleaning'] += 1
-                elif (builder.build_queue and
-                      builder.build_queue.status == BuildQueueStatus.RUNNING):
-                    counts['building'] += 1
-                elif builder.clean_status == BuilderCleanStatus.CLEAN:
-                    counts['idle'] += 1
-        for processor, counts in counts_by_processor.items():
-            for count_name, count_value in counts.items():
-                gauge_name = "builders.{},arch={}".format(
-                    count_name, processor)
-                self.logger.debug("{}: {}".format(gauge_name, count_value))
-                self.statsd_client.gauge(gauge_name, count_value)
-        self.logger.debug("Builder stats update complete.")
-
-    def _updateBuilderQueues(self):
-        """Update statsd with the build queue lengths."""
-        self.logger.debug("Updating build queue stats.")
-        queue_details = getUtility(IBuilderSet).getBuildQueueSizes()
-        for queue_type, contents in queue_details.items():
-            virt = True if queue_type == 'virt' else False
-            for arch, value in contents.items():
-                gauge_name = "buildqueue,virtualized={},arch={}".format(
-                    virt, arch)
-                self.statsd_client.gauge(gauge_name, value[0])
-        self.logger.debug("Build queue stats update complete.")
-
-    def updateStats(self):
-        self._updateBuilderCounts()
-        self._updateBuilderQueues()
-
     def checkForNewBuilders(self):
         """Add and return any new builders."""
         new_builders = set(
@@ -850,9 +802,6 @@ class BuilddManager(service.Service):
         # Schedule bulk flushes for build queue logtail updates.
         self.flush_logtails_loop, self.flush_logtails_deferred = (
             self._startLoop(self.FLUSH_LOGTAILS_INTERVAL, self.flushLogTails))
-        # Schedule stats updates.
-        self.stats_update_loop, self.stats_update_deferred = (
-            self._startLoop(self.UPDATE_STATS_INTERVAL, self.updateStats))
 
     def stopService(self):
         """Callback for when we need to shut down."""
@@ -861,11 +810,9 @@ class BuilddManager(service.Service):
         deferreds = [slave.stopping_deferred for slave in self.builder_slaves]
         deferreds.append(self.scan_builders_deferred)
         deferreds.append(self.flush_logtails_deferred)
-        deferreds.append(self.stats_update_deferred)
 
         self.flush_logtails_loop.stop()
         self.scan_builders_loop.stop()
-        self.stats_update_loop.stop()
         for slave in self.builder_slaves:
             slave.stopCycle()
 
diff --git a/lib/lp/buildmaster/tests/test_manager.py b/lib/lp/buildmaster/tests/test_manager.py
index 5f5cc55..d9bbf9e 100644
--- a/lib/lp/buildmaster/tests/test_manager.py
+++ b/lib/lp/buildmaster/tests/test_manager.py
@@ -14,10 +14,7 @@ import signal
 import time
 
 from six.moves import xmlrpc_client
-from testtools.matchers import (
-    Equals,
-    MatchesListwise,
-    )
+from testtools.matchers import Equals
 from testtools.testcase import ExpectedException
 from testtools.twistedsupport import AsynchronousDeferredRunTest
 import transaction
@@ -48,7 +45,6 @@ from lp.buildmaster.interfaces.builder import (
     IBuilderSet,
     )
 from lp.buildmaster.interfaces.buildqueue import IBuildQueueSet
-from lp.buildmaster.interfaces.processor import IProcessorSet
 from lp.buildmaster.manager import (
     BuilddManager,
     BUILDER_FAILURE_THRESHOLD,
@@ -75,10 +71,9 @@ from lp.buildmaster.tests.test_interactor import (
     MockBuilderFactory,
     )
 from lp.registry.interfaces.distribution import IDistributionSet
-from lp.services.compat import mock
 from lp.services.config import config
 from lp.services.log.logger import BufferLogger
-from lp.services.statsd.interfaces.lp_statsd_client import ILPStatsdClient
+from lp.services.statsd.tests import StatsMixin
 from lp.soyuz.interfaces.binarypackagebuild import IBinaryPackageBuildSet
 from lp.soyuz.model.binarypackagebuildbehaviour import (
     BinaryPackageBuildBehaviour,
@@ -93,7 +88,6 @@ from lp.testing import (
 from lp.testing.dbuser import switch_dbuser
 from lp.testing.factory import LaunchpadObjectFactory
 from lp.testing.fakemethod import FakeMethod
-from lp.testing.fixture import ZopeUtilityFixture
 from lp.testing.layers import (
     LaunchpadScriptLayer,
     LaunchpadZopelessLayer,
@@ -103,18 +97,6 @@ from lp.testing.matchers import HasQueryCount
 from lp.testing.sampledata import BOB_THE_BUILDER_NAME
 
 
-class StatsMixin:
-
-    def setUpStats(self):
-        # Mock the utility class, then return a known value
-        # from getClient(), so we can assert against the call counts and args.
-        utility_class = mock.Mock()
-        self.stats_client = mock.Mock()
-        utility_class.getClient.return_value = self.stats_client
-        self.useFixture(
-            ZopeUtilityFixture(utility_class, ILPStatsdClient))
-
-
 class TestSlaveScannerScan(StatsMixin, TestCaseWithFactory):
     """Tests `SlaveScanner.scan` method.
 
@@ -1242,22 +1224,6 @@ class TestBuilddManager(TestCase):
         clock.advance(advance)
         self.assertNotEqual(0, manager.flushLogTails.call_count)
 
-    def test_startService_adds_updateStats_loop(self):
-        # When startService is called, the manager will start up a
-        # updateStats loop.
-        self._stub_out_scheduleNextScanCycle()
-        clock = task.Clock()
-        manager = BuilddManager(clock=clock)
-
-        # Replace updateStats() with FakeMethod so we can see if it was
-        # called.
-        manager.updateStats = FakeMethod()
-
-        manager.startService()
-        advance = BuilddManager.UPDATE_STATS_INTERVAL + 1
-        clock.advance(advance)
-        self.assertNotEqual(0, manager.updateStats.call_count)
-
 
 class TestFailureAssessments(TestCaseWithFactory):
 
@@ -1647,94 +1613,3 @@ class TestBuilddManagerScript(TestCaseWithFactory):
         self.assertFalse(
             os.access(rotated_logfilepath, os.F_OK),
             "Twistd's log file was rotated by twistd.")
-
-
-class TestStats(StatsMixin, TestCaseWithFactory):
-
-    layer = ZopelessDatabaseLayer
-    run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=20)
-
-    def setUp(self):
-        super(TestStats, self).setUp()
-        self.setUpStats()
-
-    def test_single_processor_counts(self):
-        builder = self.factory.makeBuilder()
-        builder.setCleanStatus(BuilderCleanStatus.CLEAN)
-        self.patch(BuilderSlave, 'makeBuilderSlave', FakeMethod(OkSlave()))
-        transaction.commit()
-        clock = task.Clock()
-        manager = BuilddManager(clock=clock)
-        manager._updateBuilderQueues = FakeMethod()
-        manager.builder_factory.update()
-        manager.updateStats()
-
-        self.assertEqual(8, self.stats_client.gauge.call_count)
-        for call in self.stats_client.mock.gauge.call_args_list:
-            self.assertIn('386', call[0][0])
-
-    def test_multiple_processor_counts(self):
-        builder = self.factory.makeBuilder(
-            processors=[getUtility(IProcessorSet).getByName('amd64')])
-        builder.setCleanStatus(BuilderCleanStatus.CLEAN)
-        self.patch(BuilderSlave, 'makeBuilderSlave', FakeMethod(OkSlave()))
-        transaction.commit()
-        clock = task.Clock()
-        manager = BuilddManager(clock=clock)
-        manager._updateBuilderQueues = FakeMethod()
-        manager.builder_factory.update()
-        manager.updateStats()
-
-        self.assertEqual(12, self.stats_client.gauge.call_count)
-        i386_calls = [c for c in self.stats_client.gauge.call_args_list
-                      if '386' in c[0][0]]
-        amd64_calls = [c for c in self.stats_client.gauge.call_args_list
-                       if 'amd64' in c[0][0]]
-        self.assertEqual(8, len(i386_calls))
-        self.assertEqual(4, len(amd64_calls))
-
-    def test_correct_values_counts(self):
-        builder = self.factory.makeBuilder(
-            processors=[getUtility(IProcessorSet).getByName('amd64')])
-        builder.setCleanStatus(BuilderCleanStatus.CLEANING)
-        self.patch(BuilderSlave, 'makeBuilderSlave', FakeMethod(OkSlave()))
-        transaction.commit()
-        clock = task.Clock()
-        manager = BuilddManager(clock=clock)
-        manager._updateBuilderQueues = FakeMethod()
-        manager.builder_factory.update()
-        manager.updateStats()
-
-        self.assertEqual(12, self.stats_client.gauge.call_count)
-        calls = [c[0] for c in self.stats_client.gauge.call_args_list
-                 if 'amd64' in c[0][0]]
-        self.assertThat(
-            calls, MatchesListwise(
-                [Equals(('builders.disabled,arch=amd64,virtualized=True', 0)),
-                 Equals(('builders.building,arch=amd64,virtualized=True', 0)),
-                 Equals(('builders.idle,arch=amd64,virtualized=True', 0)),
-                 Equals(('builders.cleaning,arch=amd64,virtualized=True', 1))
-                 ]))
-
-    def test_updateBuilderQueues(self):
-        builder = self.factory.makeBuilder(
-            processors=[getUtility(IProcessorSet).getByName('amd64')])
-        builder.setCleanStatus(BuilderCleanStatus.CLEANING)
-        build = self.factory.makeSnapBuild()
-        build.queueBuild()
-        self.patch(BuilderSlave, 'makeBuilderSlave', FakeMethod(OkSlave()))
-        transaction.commit()
-        clock = task.Clock()
-        manager = BuilddManager(clock=clock)
-        manager._updateBuilderCounts = FakeMethod()
-        manager.builder_factory.update()
-        manager.updateStats()
-
-        self.assertEqual(2, self.stats_client.gauge.call_count)
-        self.assertThat(
-            [x[0] for x in self.stats_client.gauge.call_args_list],
-            MatchesListwise(
-                [Equals(('buildqueue,virtualized=True,arch={}'.format(
-                    build.processor.name), 1)),
-                 Equals(('buildqueue,virtualized=False,arch=386', 1))
-                 ]))
diff --git a/lib/lp/services/statsd/numbercruncher.py b/lib/lp/services/statsd/numbercruncher.py
new file mode 100644
index 0000000..5ad1af0
--- /dev/null
+++ b/lib/lp/services/statsd/numbercruncher.py
@@ -0,0 +1,137 @@
+# Copyright 2020 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Out of process statsd reporting."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = ['NumberCruncher']
+
+import logging
+
+from twisted.application import service
+from twisted.internet import (
+    defer,
+    reactor,
+    )
+from twisted.internet.task import LoopingCall
+from twisted.python import log
+from zope.component import getUtility
+
+from lp.buildmaster.enums import (
+    BuilderCleanStatus,
+    BuildQueueStatus,
+    )
+from lp.buildmaster.interfaces.builder import IBuilderSet
+from lp.buildmaster.manager import PrefetchedBuilderFactory
+from lp.services.statsd.interfaces.lp_statsd_client import ILPStatsdClient
+
+NUMBER_CRUNCHER_LOG_NAME = "number-cruncher"
+
+
+class NumberCruncher(service.Service):
+    """Export statistics to statsd."""
+
+    QUEUE_INTERVAL = 60
+    BUILDER_INTERVAL = 60
+
+    def __init__(self, clock=None, builder_factory=None):
+        if clock is None:
+            clock = reactor
+        self._clock = clock
+        self.logger = self._setupLogger()
+        self.builder_factory = builder_factory or PrefetchedBuilderFactory()
+        self.statsd_client = getUtility(ILPStatsdClient).getClient()
+
+    def _setupLogger(self):
+        """Set up a 'number-cruncher' logger that redirects to twisted.
+        """
+        level = logging.INFO
+        logger = logging.getLogger(NUMBER_CRUNCHER_LOG_NAME)
+        logger.propagate = False
+
+        # Redirect the output to the twisted log module.
+        channel = logging.StreamHandler(log.StdioOnnaStick())
+        channel.setLevel(level)
+        channel.setFormatter(logging.Formatter('%(message)s'))
+
+        logger.addHandler(channel)
+        logger.setLevel(level)
+        return logger
+
+    def _startLoop(self, interval, callback):
+        """Schedule `callback` to run every `interval` seconds."""
+        loop = LoopingCall(callback)
+        loop.clock = self._clock
+        stopping_deferred = loop.start(interval)
+        return loop, stopping_deferred
+
+    def updateBuilderQueues(self):
+        """Update statsd with the build queue lengths."""
+        self.logger.debug("Updating build queue stats.")
+        queue_details = getUtility(IBuilderSet).getBuildQueueSizes()
+        for queue_type, contents in queue_details.items():
+            virt = True if queue_type == 'virt' else False
+            for arch, value in contents.items():
+                gauge_name = "buildqueue,virtualized={},arch={}".format(
+                    virt, arch)
+                self.logger.debug("{}: {}".format(gauge_name, value[0]))
+                self.statsd_client.gauge(gauge_name, value[0])
+        self.logger.debug("Build queue stats update complete.")
+
+    def _updateBuilderCounts(self):
+        """Update statsd with the builder statuses.
+
+        Requires the builder_factory to be updated.
+        """
+        self.logger.debug("Updating builder stats.")
+        counts_by_processor = {}
+        for builder in self.builder_factory.iterVitals():
+            if not builder.active:
+                continue
+            for processor_name in builder.processor_names:
+                counts = counts_by_processor.setdefault(
+                    "{},virtualized={}".format(
+                        processor_name,
+                        builder.virtualized),
+                    {'cleaning': 0, 'idle': 0, 'disabled': 0, 'building': 0})
+                if not builder.builderok:
+                    counts['disabled'] += 1
+                elif builder.clean_status == BuilderCleanStatus.CLEANING:
+                    counts['cleaning'] += 1
+                elif (builder.build_queue and
+                      builder.build_queue.status == BuildQueueStatus.RUNNING):
+                    counts['building'] += 1
+                elif builder.clean_status == BuilderCleanStatus.CLEAN:
+                    counts['idle'] += 1
+        for processor, counts in counts_by_processor.items():
+            for count_name, count_value in counts.items():
+                gauge_name = "builders.{},arch={}".format(
+                    count_name, processor)
+                self.logger.debug("{}: {}".format(gauge_name, count_value))
+                self.statsd_client.gauge(gauge_name, count_value)
+        self.logger.debug("Builder stats update complete.")
+
+    def updateBuilderStats(self):
+        """Statistics that require builder knowledge to be updated."""
+        self.builder_factory.update()
+        self._updateBuilderCounts()
+
+    def startService(self):
+        self.logger.debug("Starting number-cruncher service.")
+        self.update_queues_loop, self.update_queues_deferred = (
+            self._startLoop(self.QUEUE_INTERVAL, self.updateBuilderQueues))
+        self.update_builder_loop, self.update_builder_deferred = (
+            self._startLoop(self.BUILDER_INTERVAL, self.updateBuilderStats))
+
+    def stopService(self):
+        deferreds = []
+        deferreds.append(self.update_queues_deferred)
+        deferreds.append(self.update_builder_deferred)
+
+        self.update_queues_loop.stop()
+        self.update_builder_loop.stop()
+
+        d = defer.DeferredList(deferreds, consumeErrors=True)
+        return d
diff --git a/lib/lp/services/statsd/tests/__init__.py b/lib/lp/services/statsd/tests/__init__.py
index e69de29..5e56bf3 100644
--- a/lib/lp/services/statsd/tests/__init__.py
+++ b/lib/lp/services/statsd/tests/__init__.py
@@ -0,0 +1,25 @@
+# Copyright 2020 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Utility mixins for testing statsd handling"""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = ['StatsMixin']
+
+from lp.services.compat import mock
+from lp.services.statsd.interfaces.lp_statsd_client import ILPStatsdClient
+from lp.testing.fixture import ZopeUtilityFixture
+
+
+class StatsMixin:
+
+    def setUpStats(self):
+        # Mock the utility class, then return a known value
+        # from getClient(), so we can assert against the call counts and args.
+        utility_class = mock.Mock()
+        self.stats_client = mock.Mock()
+        utility_class.getClient.return_value = self.stats_client
+        self.useFixture(
+            ZopeUtilityFixture(utility_class, ILPStatsdClient))
diff --git a/lib/lp/services/statsd/tests/test_numbercruncher.py b/lib/lp/services/statsd/tests/test_numbercruncher.py
new file mode 100644
index 0000000..bbda6d5
--- /dev/null
+++ b/lib/lp/services/statsd/tests/test_numbercruncher.py
@@ -0,0 +1,115 @@
+# Copyright 2020 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for the stats number cruncher daemon."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from testtools.matchers import (
+    Equals,
+    MatchesListwise,
+    )
+from testtools.twistedsupport import AsynchronousDeferredRunTest
+import transaction
+from twisted.internet import task
+from zope.component import getUtility
+
+from lp.buildmaster.enums import BuilderCleanStatus
+from lp.buildmaster.interactor import BuilderSlave
+from lp.buildmaster.interfaces.processor import IProcessorSet
+from lp.buildmaster.tests.mock_slaves import OkSlave
+from lp.services.statsd.numbercruncher import NumberCruncher
+from lp.services.statsd.tests import StatsMixin
+from lp.testing import TestCaseWithFactory
+from lp.testing.fakemethod import FakeMethod
+from lp.testing.layers import ZopelessDatabaseLayer
+
+
+class TestStats(StatsMixin, TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+    run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=20)
+
+    def setUp(self):
+        super(TestStats, self).setUp()
+        self.setUpStats()
+
+    def test_single_processor_counts(self):
+        builder = self.factory.makeBuilder()
+        builder.setCleanStatus(BuilderCleanStatus.CLEAN)
+        self.patch(BuilderSlave, 'makeBuilderSlave', FakeMethod(OkSlave()))
+        transaction.commit()
+        clock = task.Clock()
+        manager = NumberCruncher(clock=clock)
+        manager.builder_factory.update()
+        manager.updateBuilderStats()
+
+        self.assertEqual(8, self.stats_client.gauge.call_count)
+        for call in self.stats_client.mock.gauge.call_args_list:
+            self.assertIn('386', call[0][0])
+
+    def test_multiple_processor_counts(self):
+        builder = self.factory.makeBuilder(
+            processors=[getUtility(IProcessorSet).getByName('amd64')])
+        builder.setCleanStatus(BuilderCleanStatus.CLEAN)
+        self.patch(BuilderSlave, 'makeBuilderSlave', FakeMethod(OkSlave()))
+        transaction.commit()
+        clock = task.Clock()
+        manager = NumberCruncher(clock=clock)
+        manager.builder_factory.update()
+        manager.updateBuilderStats()
+
+        self.assertEqual(12, self.stats_client.gauge.call_count)
+        i386_calls = [c for c in self.stats_client.gauge.call_args_list
+                      if '386' in c[0][0]]
+        amd64_calls = [c for c in self.stats_client.gauge.call_args_list
+                       if 'amd64' in c[0][0]]
+        self.assertEqual(8, len(i386_calls))
+        self.assertEqual(4, len(amd64_calls))
+
+    def test_correct_values_counts(self):
+        builder = self.factory.makeBuilder(
+            processors=[getUtility(IProcessorSet).getByName('amd64')])
+        builder.setCleanStatus(BuilderCleanStatus.CLEANING)
+        self.patch(BuilderSlave, 'makeBuilderSlave', FakeMethod(OkSlave()))
+        transaction.commit()
+        clock = task.Clock()
+        manager = NumberCruncher(clock=clock)
+        manager.builder_factory.update()
+        manager.updateBuilderStats()
+
+        self.assertEqual(12, self.stats_client.gauge.call_count)
+        calls = [c[0] for c in self.stats_client.gauge.call_args_list
+                 if 'amd64' in c[0][0]]
+        self.assertThat(
+            calls, MatchesListwise(
+                [Equals(('builders.disabled,arch=amd64,virtualized=True', 0)),
+                 Equals(('builders.building,arch=amd64,virtualized=True', 0)),
+                 Equals(('builders.idle,arch=amd64,virtualized=True', 0)),
+                 Equals(('builders.cleaning,arch=amd64,virtualized=True', 1))
+                 ]))
+
+    def test_updateBuilderQueues(self):
+        builder = self.factory.makeBuilder(
+            processors=[getUtility(IProcessorSet).getByName('amd64')])
+        builder.setCleanStatus(BuilderCleanStatus.CLEANING)
+        build = self.factory.makeSnapBuild()
+        build.queueBuild()
+        self.patch(BuilderSlave, 'makeBuilderSlave', FakeMethod(OkSlave()))
+        transaction.commit()
+        clock = task.Clock()
+        manager = NumberCruncher(clock=clock)
+        manager._updateBuilderCounts = FakeMethod()
+        manager.builder_factory.update()
+        manager.updateBuilderQueues()
+
+        self.assertEqual(2, self.stats_client.gauge.call_count)
+        self.assertThat(
+            [x[0] for x in self.stats_client.gauge.call_args_list],
+            MatchesListwise(
+                [Equals(('buildqueue,virtualized=True,arch={}'.format(
+                    build.processor.name), 1)),
+                 Equals(('buildqueue,virtualized=False,arch=386', 1))
+                 ]))
diff --git a/lib/lp/soyuz/model/archive.py b/lib/lp/soyuz/model/archive.py
index a37b737..30f7ae6 100644
--- a/lib/lp/soyuz/model/archive.py
+++ b/lib/lp/soyuz/model/archive.py
@@ -659,8 +659,12 @@ class Archive(SQLBase):
                     "The 'version' parameter can be used only together with"
                     " the 'name' parameter.")
             clauses.append(
+<<<<<<< lib/lp/soyuz/model/archive.py
                 Cast(SourcePackageRelease.version, "text") ==
                 six.ensure_text(version))
+=======
+                Cast(SourcePackageRelease.version, "text") == version)
+>>>>>>> lib/lp/soyuz/model/archive.py
         elif not order_by_date:
             order_by.insert(1, Desc(SourcePackageRelease.version))
 
@@ -859,8 +863,12 @@ class Archive(SQLBase):
                     " the 'name' parameter.")
 
             clauses.append(
+<<<<<<< lib/lp/soyuz/model/archive.py
                 Cast(BinaryPackageRelease.version, "text") ==
                 six.ensure_text(version))
+=======
+                Cast(BinaryPackageRelease.version, "text") == version)
+>>>>>>> lib/lp/soyuz/model/archive.py
         elif ordered:
             order_by.insert(1, Desc(BinaryPackageRelease.version))
 
diff --git a/utilities/start-dev-soyuz.sh b/utilities/start-dev-soyuz.sh
index c151c1c..efe4b0f 100755
--- a/utilities/start-dev-soyuz.sh
+++ b/utilities/start-dev-soyuz.sh
@@ -28,6 +28,7 @@ start_twistd_plugin() {
 
 start_twistd testkeyserver lib/lp/testing/keyserver/testkeyserver.tac
 start_twistd buildd-manager daemons/buildd-manager.tac
+start_twistd numbercruncher daemons/numbercruncher.tac
 mkdir -p /var/tmp/txpkgupload/incoming
 start_twistd_plugin txpkgupload pkgupload \
     --config-file configs/development/txpkgupload.yaml