← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~twom/launchpad:stats-in-buildd-manager into launchpad:master

 

Tom Wardill has proposed merging ~twom/launchpad:stats-in-buildd-manager into launchpad:master.

Commit message:
Add statsd totals for builder stats by processor

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

It'd be useful to graph the builder states over time, by architecture. Create a statsd client that is configured from settings (with a dummy one to prevent network traffic if not).
Use this in builddmanager in an extra (tunable) loop to prevent impingement on other manager work.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~twom/launchpad:stats-in-buildd-manager into launchpad:master.
diff --git a/configs/development/launchpad-lazr.conf b/configs/development/launchpad-lazr.conf
index b8f2c55..4cc4106 100644
--- a/configs/development/launchpad-lazr.conf
+++ b/configs/development/launchpad-lazr.conf
@@ -233,3 +233,8 @@ hostname: feeds.launchpad.test
 # so disable that here. note that the testrunner config inherits
 # this setting from us.
 send_email: false
+
+[statsd]
+host:
+port:
+prefix:
diff --git a/constraints.txt b/constraints.txt
index 706f7da..adf37e2 100644
--- a/constraints.txt
+++ b/constraints.txt
@@ -298,6 +298,7 @@ SimpleTAL==4.3; python_version < "3"
 SimpleTAL==5.2; python_version >= "3"
 soupmatchers==0.4
 soupsieve==1.9
+statsd==3.3.0
 # lp:~launchpad-committers/storm/lp
 storm==0.24+lp416
 subprocess32==3.2.6
diff --git a/lib/lp/buildmaster/manager.py b/lib/lp/buildmaster/manager.py
index a4b96e3..418fe6a 100644
--- a/lib/lp/buildmaster/manager.py
+++ b/lib/lp/buildmaster/manager.py
@@ -61,6 +61,7 @@ from lp.services.database.stormexpr import (
     Values,
     )
 from lp.services.propertycache import get_property_cache
+from lp.services.statsd import getStatsdClient
 
 
 BUILDD_MANAGER_LOG_NAME = "slave-scanner"
@@ -691,6 +692,9 @@ 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.
@@ -702,6 +706,7 @@ class BuilddManager(service.Service):
         self.logger = self._setupLogger()
         self.current_builders = []
         self.pending_logtails = {}
+        self.statsd_client = getStatsdClient()
 
     def _setupLogger(self):
         """Set up a 'slave-scanner' logger that redirects to twisted.
@@ -721,6 +726,27 @@ class BuilddManager(service.Service):
         logger.setLevel(level)
         return logger
 
+    def updateStats(self):
+        """Update statsd with the builder statuses."""
+        counts_by_processor = {}
+        for builder in self.builder_factory.iterVitals():
+            for processor_name in builder.processor_names:
+                counts = counts_by_processor.get(
+                    processor_name,
+                    {'cleaning': 0, 'ok': 0, 'disabled': 0})
+                if builder.clean_status == BuilderCleanStatus.CLEANING:
+                    counts['cleaning'] += 1
+                elif builder.builderok:
+                    counts['ok'] += 1
+                else:
+                    counts['disabled'] += 1
+                counts_by_processor[processor_name] = counts
+        for processor, counts in counts_by_processor.items():
+            for count_name, count_value in counts.items():
+                gauge_name = "builders.{}.{}".format(processor, count_name)
+                self.logger.debug("{}: {}".format(gauge_name, count_name))
+                self.statsd_client.gauge(gauge_name, count_value)
+
     def checkForNewBuilders(self):
         """Add and return any new builders."""
         new_builders = set(
@@ -790,6 +816,9 @@ 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."""
@@ -798,9 +827,11 @@ 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 b0d56ed..01acedf 100644
--- a/lib/lp/buildmaster/tests/test_manager.py
+++ b/lib/lp/buildmaster/tests/test_manager.py
@@ -13,8 +13,12 @@ import os
 import signal
 import time
 
+from fixtures import MockPatch
 from six.moves import xmlrpc_client
-from testtools.matchers import Equals
+from testtools.matchers import (
+    Equals,
+    MatchesListwise,
+    )
 from testtools.testcase import ExpectedException
 from testtools.twistedsupport import AsynchronousDeferredRunTest
 import transaction
@@ -45,6 +49,7 @@ 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,
@@ -1596,3 +1601,69 @@ class TestBuilddManagerScript(TestCaseWithFactory):
         self.assertFalse(
             os.access(rotated_logfilepath, os.F_OK),
             "Twistd's log file was rotated by twistd.")
+
+
+class TestStats(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+    run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=20)
+
+    def setUp(self):
+        super(TestStats, self).setUp()
+        self.stats_client = self.useFixture(
+            MockPatch(
+                'lp.buildmaster.manager.getStatsdClient'
+            )).mock()
+
+    def test_single_processor(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.builder_factory.update()
+        manager.updateStats()
+
+        self.assertEqual(3, 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(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.builder_factory.update()
+        manager.updateStats()
+
+        self.assertEqual(6, 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(3, len(i386_calls))
+        self.assertEqual(3, len(amd64_calls))
+
+    def test_correct_values(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.builder_factory.update()
+        manager.updateStats()
+
+        self.assertEqual(6, 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.amd64.disabled', 0)),
+                 Equals(('builders.amd64.ok', 0)),
+                 Equals(('builders.amd64.cleaning', 1))]))
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index 80bc7e2..bb8afcd 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -113,7 +113,6 @@ authentication_endpoint: none
 # authserver.
 authentication_timeout: 15
 
-
 [canonical]
 # datatype: boolean
 show_tracebacks: False
@@ -2022,3 +2021,9 @@ concurrency: 1
 timeout: 86400
 fallback_queue:
 concurrency: 1
+
+# Statsd connection details
+[statsd]
+host: none
+port: none
+prefix: none
diff --git a/lib/lp/services/statsd/__init__.py b/lib/lp/services/statsd/__init__.py
new file mode 100644
index 0000000..41acbf2
--- /dev/null
+++ b/lib/lp/services/statsd/__init__.py
@@ -0,0 +1,35 @@
+# Copyright 2020 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Statsd client wrapper with Launchpad configuration"""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = ['getStatsdClient']
+
+
+from statsd import StatsClient
+
+from lp.services.config import config
+
+
+class UnconfiguredStatsdClient:
+    """Dummy client for if statsd is not configured in the environment.
+
+    This client will be used if the statsd settings are not available to
+    Launchpad. Prevents unnecessary network traffic.
+    """
+
+    def __getattr__(self, name):
+        return lambda *args, **kwargs: None
+
+
+def getStatsdClient():
+    if getattr(config, 'statsd', None) and config.statsd.host:
+        return StatsClient(
+            host=config.statsd.host,
+            port=config.statsd.port,
+            prefix=config.statsd.prefix)
+    else:
+        return UnconfiguredStatsdClient()
diff --git a/lib/lp/services/statsd/tests/__init__.py b/lib/lp/services/statsd/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/services/statsd/tests/__init__.py
diff --git a/lib/lp/services/statsd/tests/test_lp_statsd.py b/lib/lp/services/statsd/tests/test_lp_statsd.py
new file mode 100644
index 0000000..6a244aa
--- /dev/null
+++ b/lib/lp/services/statsd/tests/test_lp_statsd.py
@@ -0,0 +1,40 @@
+# Copyright 2020 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for the Launchpad Statsd Client"""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from statsd import StatsClient
+
+from lp.services.config import config
+from lp.services.statsd import (
+    getStatsdClient,
+    UnconfiguredStatsdClient,
+    )
+from lp.testing import TestCase
+from lp.testing.layers import BaseLayer
+
+
+class TestClientConfiguration(TestCase):
+
+    layer = BaseLayer
+
+    def test_get_correct_instance_unconfigured(self):
+        """Test that we get the correct client, depending on config values."""
+        config.push(
+            'statsd_test',
+            "[statsd]\nhost:")
+        client = getStatsdClient()
+        self.assertEqual(
+            type(client), UnconfiguredStatsdClient)
+
+    def test_get_correct_instance_configured(self):
+        config.push(
+            'statsd_test',
+            "[statsd]\nhost: 127.0.01\nport: 9999\nprefix: test\n")
+        client = getStatsdClient()
+        self.assertEqual(
+            type(client), StatsClient)
diff --git a/setup.py b/setup.py
index b724650..49e5306 100644
--- a/setup.py
+++ b/setup.py
@@ -232,6 +232,7 @@ setup(
         'six',
         'soupmatchers',
         'Sphinx',
+        'statsd',
         'storm',
         # XXX cjwatson 2020-08-07: Temporarily dropped on Python 3 until
         # codeimport can be ported to Breezy.