1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00
webvirtcloud/computes/test_cpu_graph.py
catborise 27f4845086 perf(computes): the host CPU graph no longer waits a second per poll
Each poll of a host's overview graph held a worker for one second between
two CPU samples. The answer now carries its sample, signed with the time
and the compute; the next poll sends it back and the usage is taken since
then, so a point is the average since the previous one (normally 5 s, at
most 15 s). Without a sample, or with one that is altered, older or from
another host, or when the host restarted, it is measured over one second
as before.

A sample is one reading; the busy share was mixed from separate readings,
counted guest time twice (it is part of user) and added half a percent.
A host that cannot be read leaves a gap in both graphs instead of a zero
CPU point and a broken memory axis.
2026-10-09 09:53:12 +03:00

171 lines
8.6 KiB
Python

"""The host CPU graph: usage is taken between the sample of the graph's
previous answer and now, without waiting, when that sample is recent and
unaltered; otherwise over one second as before."""
import json
import re
import shutil
import subprocess
import unittest
from unittest import TestCase as UnitTestCase
from unittest.mock import MagicMock, PropertyMock, patch
from django.contrib.auth import get_user_model
from django.core import signing
from django.test import TestCase
from django.urls import reverse
from libvirt import libvirtError
from computes.models import Compute
from computes.views import CPU_SAMPLE_MAX_AGE, CPU_SAMPLE_SIGNER
from vrtManager.hostdetails import cpu_percent, wvmHostDetails
def host(*readings):
"""A wvmHostDetails whose getCPUStats returns readings, (idle, busy) each."""
h = wvmHostDetails.__new__(wvmHostDetails)
h.wvm = MagicMock()
h.wvm.getCPUStats.side_effect = [
{"kernel": busy // 2, "user": busy - busy // 2, "idle": idle, "iowait": 0, "guest": busy // 3, "utilization": 99}
for idle, busy in readings
]
return h
class CpuPercentTestCase(UnitTestCase):
def test_busy_share_between_two_samples(self):
self.assertEqual(cpu_percent((100, 200), (130, 300)), 70.0)
self.assertEqual(cpu_percent((0, 0), (1, 3)), 66.7)
def test_samples_that_do_not_follow_on(self):
for before, after in (((100, 200), (100, 200)), ((100, 200), (50, 100)), ((100, 200), (200, 250))):
with self.subTest(before=before, after=after):
self.assertIsNone(cpu_percent(before, after))
@patch("vrtManager.hostdetails.time")
class CpuUsageTestCase(UnitTestCase):
def test_since_a_previous_sample_without_waiting(self, clock):
clock.time.return_value = 105.0
h = host((130, 70))
usage = h.get_cpu_usage(previous=(100, 100, 100.0))
clock.sleep.assert_not_called()
# total = kernel + user + idle + iowait; guest (inside user) and utilization are left out
self.assertEqual(usage, {"usage": 70.0, "sample": (130, 200, 105.0), "window": 5.0})
def test_over_one_second_without_a_sample(self, clock):
clock.time.side_effect = [10.0, 11.0]
h = host((100, 100), (110, 190))
self.assertEqual(h.get_cpu_usage(), {"usage": 90.0, "sample": (110, 300, 11.0), "window": 1.0})
clock.sleep.assert_called_once_with(1)
def test_over_one_second_after_the_host_restarted(self, clock):
clock.time.side_effect = [10.0, 11.0]
h = host((5, 5), (6, 9))
usage = h.get_cpu_usage(previous=(1000, 5000, 5.0))
clock.sleep.assert_called_once_with(1)
self.assertEqual((usage["usage"], usage["window"]), (80.0, 1.0))
def test_since_the_host_started(self, clock):
self.assertEqual(host((25, 75)).get_cpu_usage(diff=False), {"usage": 75.0})
clock.sleep.assert_not_called()
class GraphTestCase(TestCase):
def setUp(self):
self.compute = Compute.objects.create(name="graph", hostname="192.0.2.1", login="u", password="p", type=1)
self.other = Compute.objects.create(name="other", hostname="192.0.2.2", login="u", password="p", type=1)
admin = get_user_model().objects.create_superuser("graph-admin", "graph@example.com", "pw")
self.client.force_login(admin)
for target in ("computes.views.wvmConnect", "computes.views.wvmHostDetails"):
patcher = patch(target)
self.addCleanup(patcher.stop)
self.conn = patcher.start().return_value # the graph reads through wvmHostDetails
self.conn.get_memory_usage.return_value = {"total": 4, "usage": 1, "percent": 25}
self.conn.get_cpu_usage.return_value = {"usage": 12.5, "sample": (10, 20, 100.0), "window": 5.04}
def graph(self, compute=None, **params):
response = self.client.get(reverse("compute_graph", args=[(compute or self.compute).id]), params)
self.assertEqual(response.status_code, 200)
return json.loads(response.content)
def previous(self):
return self.conn.get_cpu_usage.call_args.kwargs["previous"]
def test_the_answer_carries_the_sample_for_the_next_poll(self):
data = self.graph()
self.assertIsNone(self.previous())
self.assertEqual((data["cpudata"], data["window"], data["memdata"]["percent"]), (12.5, 5.0, 25))
self.graph(cpu_sample=data["cpu_sample"])
self.assertEqual(self.previous(), (10, 20, 100.0))
def test_an_altered_old_or_foreign_sample_is_not_used(self):
token = self.graph()["cpu_sample"]
foreign = self.graph(self.other)["cpu_sample"]
altered = CPU_SAMPLE_SIGNER.sign_object({"compute": self.compute.id, "idle": 0, "total": 1, "time": 0})[:-2] + "xx"
for name, sample in (("altered", altered), ("garbage", "x"), ("other compute", foreign)):
with self.subTest(name):
self.graph(cpu_sample=sample)
self.assertIsNone(self.previous())
with patch("django.core.signing.time.time", return_value=signing.time.time() + CPU_SAMPLE_MAX_AGE + 1):
self.graph(cpu_sample=token)
self.assertIsNone(self.previous())
def test_an_unreachable_host_gives_no_cpu_value_and_no_sample(self):
self.conn.get_cpu_usage.side_effect = libvirtError("unreachable")
data = self.graph()
self.assertEqual((data["cpudata"], data["memdata"]["total"]), (None, None))
self.assertNotIn("cpu_sample", data)
@unittest.skipUnless(shutil.which("node"), "node is not installed")
class GraphScriptTestCase(TestCase):
"""The overview page's script sends each answer's sample with the next poll."""
def test_the_next_poll_sends_the_previous_sample(self):
self.client.force_login(get_user_model().objects.create_superuser("gs-admin", "gs@example.com", "pw"))
compute = Compute.objects.create(name="gs", hostname="192.0.2.1", login="u", password="p", type=1)
with patch("computes.views.wvmHostDetails") as details, patch.object(
Compute, "status", new_callable=PropertyMock, return_value=True
):
details.return_value.get_node_info.return_value = ("h", "x86_64", 1024, 2, "cpu", "qemu+tcp://h/system")
details.return_value.get_memory_usage.return_value = {"total": 1024, "usage": 512, "percent": 50}
page = self.client.get(reverse("overview", args=[compute.id])).content.decode()
script = next(s for s in re.findall(r"<script>(.*?)</script>", page, re.S) if "pollWhileVisible(" in s)
js = """
const vm = require("vm");
const charts = [], requests = [];
let poll;
const page = {
document: {getElementById: () => ({getContext: () => ({})})},
// Chart.js adds the lists a config leaves out
Chart: function (ctx, config) {
this.data = Object.assign({labels: []}, config.data);
this.data.datasets.forEach((set) => { set.data = set.data || []; });
this.options = config.options;
this.update = () => {};
charts.push(this);
},
$: {getJSON: (url, params) => { requests.push(params); return {}; }},
pollWhileVisible: (interval, request, onData) => { poll = {request, onData}; },
};
vm.runInNewContext(SCRIPT, vm.createContext(page));
const answer = (cpu, sample) => ({cpudata: cpu, window: 5.1, cpu_sample: sample, timeline: "t",
memdata: {total: 1048576, usage: 0}});
poll.request();
poll.onData(answer(3, "token-1"));
poll.request();
poll.onData({cpudata: null, memdata: {total: null, usage: null}, timeline: "t"});
poll.request();
const label = charts[0].options.tooltips.callbacks.label({datasetIndex: 0, index: 0, yLabel: 3}, charts[0].data);
console.log(JSON.stringify({requests, label, cpu: charts[0].data.datasets[0].data,
mem: charts[1].data.datasets[0].data, memMax: charts[1].options.scales.yAxes[0].ticks.max}));
""".replace("SCRIPT", json.dumps(script))
out = subprocess.run(["node", "-e", js], capture_output=True, text=True, timeout=30)
self.assertEqual(out.returncode, 0, out.stderr)
result = json.loads(out.stdout)
self.assertEqual(result["requests"], [{}, {"cpu_sample": "token-1"}, {}])
self.assertEqual(result["cpu"], [3, None])
# a host that could not be read leaves a gap and keeps the memory axis
self.assertEqual((result["mem"], result["memMax"]), ([0, None], 1))
self.assertTrue(result["label"].endswith(": 3 % (average over 5.1 s)"), result["label"])