From 27f484508617f139ab511c6775618dab5bae7b9f Mon Sep 17 00:00:00 2001 From: catborise Date: Fri, 9 Oct 2026 09:36:03 +0300 Subject: [PATCH] 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. --- README.md | 1 + computes/templates/overview.html | 25 ++++- computes/test_cpu_graph.py | 171 +++++++++++++++++++++++++++++++ computes/views.py | 50 ++++++--- vrtManager/hostdetails.py | 69 ++++++++----- 5 files changed, 271 insertions(+), 45 deletions(-) create mode 100644 computes/test_cpu_graph.py diff --git a/README.md b/README.md index e95a4656..3c55774b 100644 --- a/README.md +++ b/README.md @@ -534,6 +534,7 @@ A rollback leaves the clone on a detached HEAD at the restored commit; the updat - A VM that vanishes from its compute keeps its owners for `INSTANCE_OWNERSHIP_RETENTION_DAYS` (30) in case the same UUID comes back; superusers list and remove these records on the "Removed VMs" admin page. - The log rotates (10 MB × 5) in `data/webvirtcloud.log`. - A disk can be grown while its VM runs or is paused, not only when it is shut off. +- A host's CPU graph shows the average since its previous point (normally 5 s) instead of a 1 s sample taken while a worker waits. - A running VM without a guest agent shows the IPv4 address the host learned by ARP (the lookup always failed before). - After a live migration the VM's NVRAM file is deleted on the source, when it is in libvirt's `/var/lib/libvirt/qemu/nvram` and the destination does not share that directory (a VM created there later with the same name would start with those UEFI variables). - gstfsd finds the guest's operating system the way libguestfs inspects it, so a root on LVM works, and it always answers (an error when it finds no or several operating systems). Update `/usr/local/bin/gstfsd` on every compute (see [Adding a Compute Node](#adding-a-compute-node), step 3). diff --git a/computes/templates/overview.html b/computes/templates/overview.html index ecadbce4..c7deca72 100644 --- a/computes/templates/overview.html +++ b/computes/templates/overview.html @@ -120,6 +120,9 @@ ", 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"]) diff --git a/computes/views.py b/computes/views.py index 4bd6801f..00dade42 100644 --- a/computes/views.py +++ b/computes/views.py @@ -1,6 +1,7 @@ import json from admin.decorators import superuser_only +from django.core import signing from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse @@ -20,6 +21,11 @@ from computes.models import Compute from . import utils +# The host CPU graph sends back the CPU sample of its previous answer; usage +# is taken since then when it is this recent, otherwise over one second. +CPU_SAMPLE_MAX_AGE = 15 +CPU_SAMPLE_SIGNER = signing.TimestampSigner(salt="computes.cpu-sample") + @superuser_only def computes(request): @@ -130,7 +136,7 @@ def compute_graph(request, compute_id): :return: """ comp_mgr = ComputeManager(compute_id) - data = comp_mgr.compute_graph() + data = comp_mgr.compute_graph(request.GET.get("cpu_sample")) return HttpResponse(data, content_type="application/json") @@ -219,7 +225,21 @@ class ComputeManager: return json.dumps(data) - def compute_graph(self): + def _cpu_sample(self, token): + """The (idle, total, time) sample in a token of this compute's graph, + or None when there is none, it is altered or too old.""" + if not token: + return None + try: + data = CPU_SAMPLE_SIGNER.unsign_object(token, max_age=CPU_SAMPLE_MAX_AGE) + except signing.BadSignature: + return None + if data.get("compute") != self.compute.id: + return None + return data["idle"], data["total"], data["time"] + + def compute_graph(self, cpu_sample=None): + result = {"cpudata": None, "memdata": {"total": None, "usage": None}, "timeline": timezone.now().strftime("%H:%M:%S")} try: conn = wvmHostDetails( self.compute.hostname, @@ -227,19 +247,19 @@ class ComputeManager: self.compute.password, self.compute.type, ) - current_time = timezone.now().strftime("%H:%M:%S") - cpu_usage = conn.get_cpu_usage() - mem_usage = conn.get_memory_usage() + cpu = conn.get_cpu_usage(previous=self._cpu_sample(cpu_sample)) + idle, total, taken = cpu["sample"] + result = { + "cpudata": cpu["usage"], + "memdata": conn.get_memory_usage(), + "timeline": timezone.now().strftime("%H:%M:%S"), + "window": round(cpu["window"], 1), + "cpu_sample": CPU_SAMPLE_SIGNER.sign_object( + {"compute": self.compute.id, "idle": idle, "total": total, "time": taken} + ), + } conn.close() except libvirtError: - cpu_usage = {"usage": 0} - mem_usage = {"usage": 0} - current_time = 0 + pass - return json.dumps( - { - "cpudata": cpu_usage["usage"], - "memdata": mem_usage, - "timeline": current_time, - } - ) + return json.dumps(result) diff --git a/vrtManager/hostdetails.py b/vrtManager/hostdetails.py index 31aca86d..cc155364 100644 --- a/vrtManager/hostdetails.py +++ b/vrtManager/hostdetails.py @@ -1,9 +1,26 @@ import time +from libvirt import VIR_NODE_CPU_STATS_ALL_CPUS + from vrtManager.connection import wvmConnect from vrtManager.util import get_xml_path +# the times of virNodeGetCPUStats that add up to the elapsed CPU time; guest is +# already part of user and utilization is a percentage +CPU_TIMES = ("kernel", "user", "idle", "iowait") + + +def cpu_percent(before, after): + """Busy share in percent between two (idle, total) samples, or None when + the second does not follow the first.""" + idle = after[0] - before[0] + total = after[1] - before[1] + if total <= 0 or not 0 <= idle <= total: + return None + return round(100 * (total - idle) / total, 1) + + def cpu_version(doc): for info in doc.xpath("/sysinfo/processor/entry"): elem = info.xpath("@name")[0] @@ -27,32 +44,34 @@ class wvmHostDetails(wvmConnect): else: return {"total": None, "usage": None, "percent": None} - def get_cpu_usage(self, diff=True): - """ - Function return cpu usage on node. - """ - prev_idle = 0 - prev_total = 0 - cpu = self.wvm.getCPUStats(-1, 0) - if not isinstance(cpu, dict): - return {"usage": None} + def _cpu_times(self): + """(idle, total) nanoseconds of all host CPUs, from one reading.""" + stats = self.wvm.getCPUStats(VIR_NODE_CPU_STATS_ALL_CPUS, 0) + return stats["idle"], sum(stats.get(name, 0) for name in CPU_TIMES) - for num in range(2): - idle = self.wvm.getCPUStats(-1, 0)["idle"] - total = sum(self.wvm.getCPUStats(-1, 0).values()) - diff_idle = idle - prev_idle - diff_total = total - prev_total - diff_usage = (1000 * (diff_total - diff_idle) / diff_total + 5) / 10 - if not diff: - return {"usage": diff_usage} - prev_total = total - prev_idle = idle - if num == 0: - time.sleep(1) - else: - diff_usage = max(diff_usage, 0) - - return {"usage": diff_usage} + def get_cpu_usage(self, diff=True, previous=None): + """ + Busy share of all host CPUs in percent. diff=False: since the host + started. Otherwise since previous, an (idle, total, time) sample this + method returned, or over one second when there is none or it does not + lead to this one (the host restarted). Also returns the closing sample + and the seconds the usage covers. + """ + times = self._cpu_times() + if not diff: + return {"usage": cpu_percent((0, 0), times)} + now = time.time() + usage = None + if previous is not None: + usage = cpu_percent(previous[:2], times) + start = previous[2] + if usage is None: + before, start = times, now + time.sleep(1) + times = self._cpu_times() + now = time.time() + usage = cpu_percent(before, times) + return {"usage": usage, "sample": (*times, now), "window": now - start} def get_node_info(self): """