Skip to content

Commit 7085c51

Browse files
authored
Merge pull request #2264 from aboutcode-org/load-factor
feat: show queue load factors on the pipeline dashboard
2 parents 7479d40 + 065d5a7 commit 7085c51

File tree

4 files changed

+151
-12
lines changed

4 files changed

+151
-12
lines changed

vulnerabilities/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2387,6 +2387,11 @@ def all_runs(self):
23872387
def latest_run(self):
23882388
return self.pipelineruns.first() if self.pipelineruns.exists() else None
23892389

2390+
@property
2391+
def latest_successful_run(self):
2392+
successful_runs = self.pipelineruns.filter(run_end_date__isnull=False, run_exitcode=0)
2393+
return successful_runs.first() if successful_runs.exists() else None
2394+
23902395
@property
23912396
def earliest_run(self):
23922397
return self.pipelineruns.earliest("run_start_date") if self.pipelineruns.exists() else None

vulnerabilities/tasks.py

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,23 @@
99

1010

1111
import logging
12+
from collections import Counter
13+
from contextlib import suppress
1214
from io import StringIO
1315
from traceback import format_exc as traceback_format_exc
1416

1517
import django_rq
18+
from redis.exceptions import ConnectionError
19+
from rq import Worker
1620

1721
from vulnerabilities import models
1822
from vulnerabilities.importer import Importer
1923
from vulnerabilities.improver import Improver
24+
from vulnerablecode.settings import RQ_QUEUES
2025

2126
logger = logging.getLogger(__name__)
2227

23-
default_queue = django_rq.get_queue("default")
24-
high_queue = django_rq.get_queue("high")
25-
26-
queues = {
27-
"default": django_rq.get_queue("default"),
28-
"high": django_rq.get_queue("high"),
29-
}
28+
queues = {queue: django_rq.get_queue(queue) for queue in RQ_QUEUES.keys()}
3029

3130

3231
def execute_pipeline(pipeline_id, run_id):
@@ -151,3 +150,61 @@ def dequeue_job(job_id):
151150
for queue in queues.values():
152151
if job_id in queue.jobs:
153152
queue.remove(job_id)
153+
154+
155+
def compute_queue_load_factor():
156+
"""
157+
Compute worker load per queue.
158+
159+
Load factor is the ratio of the total compute required to run all active pipelines
160+
in a queue to the available worker capacity for that queue over a 24-hour period.
161+
A value greater than 1 indicates that the number of workers is insufficient to
162+
run all pipelines within the schedule.
163+
164+
Also compute the additional workers needed to balance each queue
165+
"""
166+
field = models.PipelineSchedule._meta.get_field("run_priority")
167+
label_to_value = {label: value for value, label in field.choices}
168+
total_compute_seconds_per_queue = {}
169+
worker_per_queue = {}
170+
load_per_queue = {}
171+
seconds_in_24_hr = 86400
172+
173+
with suppress(ConnectionError):
174+
redis_conn = django_rq.get_connection()
175+
queue_names = [
176+
w.queue_names()[0] for w in Worker.all(connection=redis_conn) if w.queue_names()
177+
]
178+
worker_per_queue = dict(Counter(queue_names))
179+
180+
for queue in RQ_QUEUES.keys():
181+
total_compute_seconds_per_queue[queue] = sum(
182+
(p.latest_successful_run.runtime / (p.run_interval / 24))
183+
for p in models.PipelineSchedule.objects.filter(
184+
is_active=True, run_priority=label_to_value[queue]
185+
)
186+
if p.latest_successful_run
187+
)
188+
if queue not in worker_per_queue:
189+
worker_per_queue[queue] = 0
190+
191+
for queue_name, worker_count in worker_per_queue.items():
192+
net_load_on_queue = "no_worker"
193+
total_compute = total_compute_seconds_per_queue.get(queue_name, 0)
194+
if total_compute == 0:
195+
continue
196+
197+
unit_load_on_queue = total_compute / seconds_in_24_hr
198+
199+
num_of_worker_for_balanced_queue = round(unit_load_on_queue)
200+
addition_worker_needed = max(num_of_worker_for_balanced_queue - worker_count, 0)
201+
202+
if worker_count > 0:
203+
net_load_on_queue = unit_load_on_queue / worker_count
204+
205+
load_per_queue[queue_name] = {
206+
"load_factor": net_load_on_queue,
207+
"additional_worker": addition_worker_needed,
208+
}
209+
210+
return dict(sorted(load_per_queue.items(), key=lambda x: x[0], reverse=True))

vulnerabilities/templates/pipeline_dashboard.html

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
{% extends "base.html" %}
22

3+
{% load utils %}
4+
35
{% block title %}
46
Pipeline Dashboard
57
{% endblock %}
@@ -22,6 +24,18 @@
2224
.column {
2325
word-break: break-word;
2426
}
27+
28+
.has-text-orange {
29+
color: #ff8c42 !important;
30+
}
31+
32+
.has-tooltip-orange::before {
33+
background-color: #ff8c42 !important;
34+
}
35+
36+
.has-tooltip-orange::after {
37+
border-top-color: #ff8c42 !important;
38+
}
2539
</style>
2640
{% endblock %}
2741

@@ -48,11 +62,63 @@ <h1>Pipeline Dashboard</h1>
4862
</form>
4963

5064
<div class="box">
51-
<div class="column has-text-right">
52-
<p class="has-text-weight-semibold">
53-
{{ active_pipeline_count|default:0 }} active pipeline{{ active_pipeline_count|default:0|pluralize }},
54-
{{ disabled_pipeline_count|default:0 }} disabled pipeline{{ disabled_pipeline_count|default:0|pluralize }}
55-
</p>
65+
<div class="columns is-multiline is-vcentered mb-0">
66+
<div class="column is-half has-text-left">
67+
{% if load_per_queue %}
68+
<p class="ml-3">
69+
<span class="has-text-weight-bold has-text-black is-size-6 has-tooltip-arrow has-tooltip-multiline"
70+
data-tooltip="Load factor is the ratio of the total compute required to run all active pipelines
71+
in a queue to the available worker capacity for that queue over a 24-hour period.
72+
A value greater than 1 indicates that the number of workers is insufficient to
73+
run all pipelines within the schedule.">
74+
Load Factor:
75+
</span>
76+
{% for queue_name, values in load_per_queue.items %}
77+
78+
<span class="has-text-weight-bold is-size-6 has-tooltip-arrow has-tooltip-multiline"
79+
data-tooltip="{{ queue_name|capfirst }} priority pipeline queue.">
80+
{{ queue_name| capfirst }}
81+
</span>
82+
{% with load_factor=values|get_item:"load_factor" additional=values|get_item:"additional_worker" %}
83+
{% if load_factor == "no_worker" %}
84+
<span class="has-text-weight-bold is-size-6 has-text-danger has-tooltip-arrow has-tooltip-multiline has-tooltip-danger"
85+
data-tooltip="All workers in the {{ queue_name }} queue are down. Please run {{ additional }}
86+
worker{{ additional|pluralize }} for the {{ queue_name }} queue.">
87+
<span class="icon"><i class="fa fa-exclamation-triangle"></i></span>
88+
</span>
89+
{% elif load_factor < 1 %}
90+
<span class="has-text-weight-bold is-size-6 has-text-success has-tooltip-arrow has-tooltip-multiline has-tooltip-success"
91+
data-tooltip="{{ queue_name|capfirst }} queue perfectly balanced.">
92+
{{ load_factor|floatformat:2 }}
93+
<span class="icon"><i class="fa fa-check-circle"></i></span>
94+
</span>
95+
{% elif load_factor < 1.6 %}
96+
<span class="has-text-weight-bold is-size-6 has-text-orange has-tooltip-arrow has-tooltip-multiline has-tooltip-orange"
97+
data-tooltip="Consider adding {{ additional }} additional worker{{ additional|pluralize }} to the {{ queue_name }} queue.">
98+
{{ load_factor|floatformat:2 }}
99+
<span class="icon"><i class="fa fa-info-circle"></i></span>
100+
</span>
101+
{% else %}
102+
<span class="has-text-weight-bold is-size-6 has-text-danger has-tooltip-arrow has-tooltip-multiline has-tooltip-danger"
103+
data-tooltip="Consider adding {{ additional }} additional worker{{ additional|pluralize }} to the {{ queue_name }} queue.">
104+
{{ load_factor|floatformat:2 }}
105+
<span class="icon"><i class="fa fa-exclamation-circle"></i></span>
106+
</span>
107+
{% endif %}
108+
{% endwith %}
109+
110+
{% if not forloop.last %} &bull; {% endif %}
111+
112+
{% endfor %}
113+
</p>
114+
{% endif %}
115+
</div>
116+
<div class="column is-half has-text-right">
117+
<p class="has-text-grey-dark has-text-weight-semibold mr-3">
118+
{{ active_pipeline_count|default:0 }} active pipeline{{ active_pipeline_count|default:0|pluralize }},
119+
{{ disabled_pipeline_count|default:0 }} disabled pipeline{{ disabled_pipeline_count|default:0|pluralize }}
120+
</p>
121+
</div>
56122
</div>
57123
<table class="table is-striped is-hoverable is-fullwidth">
58124
<thead>

vulnerabilities/views.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from cvss.exceptions import CVSS4MalformedError
1616
from django.contrib import messages
1717
from django.contrib.auth.views import LoginView
18+
from django.core.cache import cache
1819
from django.core.exceptions import ValidationError
1920
from django.core.mail import send_mail
2021
from django.db.models import Exists
@@ -48,6 +49,7 @@
4849
from vulnerabilities.pipelines.v2_importers.epss_importer_v2 import EPSSImporterPipeline
4950
from vulnerabilities.severity_systems import EPSS
5051
from vulnerabilities.severity_systems import SCORING_SYSTEMS
52+
from vulnerabilities.tasks import compute_queue_load_factor
5153
from vulnerabilities.throttling import AnonUserUIThrottle
5254
from vulnerabilities.utils import TYPES_WITH_MULTIPLE_IMPORTERS
5355
from vulnerabilities.utils import get_advisories_from_groups
@@ -57,6 +59,8 @@
5759

5860
PAGE_SIZE = 10
5961

62+
CACHE_TIMEOUT = 60 * 5
63+
6064

6165
class VulnerableCodeView(View):
6266
"""
@@ -961,6 +965,13 @@ def get_queryset(self):
961965

962966
def get_context_data(self, **kwargs):
963967
context = super().get_context_data(**kwargs)
968+
load_per_queue = cache.get("load_per_queue")
969+
970+
if load_per_queue is None:
971+
load_per_queue = compute_queue_load_factor()
972+
cache.set("load_per_queue", load_per_queue, CACHE_TIMEOUT)
973+
974+
context["load_per_queue"] = load_per_queue
964975
context["active_pipeline_count"] = PipelineSchedule.objects.filter(is_active=True).count()
965976
context["disabled_pipeline_count"] = PipelineSchedule.objects.filter(
966977
is_active=False

0 commit comments

Comments
 (0)