Skip to content

Commit 81346c5

Browse files
authored
Merge pull request #2451 from aboutcode-org/2450-autoclean-runs
Add config and job to clean up old pipeline runs
2 parents d422368 + fb00657 commit 81346c5

9 files changed

Lines changed: 205 additions & 24 deletions

File tree

‎vulnerabilities/models.py‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import xml.etree.ElementTree as ET
1515
from contextlib import suppress
1616
from functools import cached_property
17+
from inspect import cleandoc
1718
from itertools import groupby
1819
from operator import attrgetter
1920
from traceback import format_exc as traceback_format_exc
@@ -2073,7 +2074,7 @@ def pipeline_class(self):
20732074

20742075
@property
20752076
def job(self):
2076-
with suppress(NoSuchJobError):
2077+
with suppress(redis.exceptions.ConnectionError, NoSuchJobError):
20772078
return Job.fetch(
20782079
str(self.run_id),
20792080
connection=django_rq.get_connection(),
@@ -2218,21 +2219,21 @@ def stop_run(self):
22182219
job_id=str(self.run_id),
22192220
)
22202221
self.set_run_stopped()
2222+
self.delete_run_job()
22212223

2222-
def delete_run(self, delete_self=True):
2224+
def delete_run_job(self):
22232225
if job := self.job:
22242226
job.delete()
22252227

2226-
if delete_self:
2227-
self.delete()
2228-
22292228
def delete(self, *args, **kwargs):
22302229
"""
22312230
Before deletion of the run instance, try to stop the run execution.
22322231
"""
22332232
with suppress(redis.exceptions.ConnectionError, AttributeError):
22342233
self.stop_run()
22352234

2235+
self.delete_run_job()
2236+
22362237
return super().delete(*args, **kwargs)
22372238

22382239
def append_to_log(self, message, is_multiline=False):
@@ -2366,16 +2367,20 @@ def pipeline_class(self):
23662367
from vulnerabilities.importers import IMPORTERS_REGISTRY
23672368
from vulnerabilities.improvers import IMPROVERS_REGISTRY
23682369
from vulnerabilities.pipelines.exporters import EXPORTERS_REGISTRY
2370+
from vulnerabilities.pipelines.management import MANAGEMENT_REGISTRY
23692371

2370-
pipeline_registry = IMPORTERS_REGISTRY | IMPROVERS_REGISTRY | EXPORTERS_REGISTRY
2372+
pipeline_registry = (
2373+
IMPORTERS_REGISTRY | IMPROVERS_REGISTRY | EXPORTERS_REGISTRY | MANAGEMENT_REGISTRY
2374+
)
23712375

23722376
return pipeline_registry[self.pipeline_id]
23732377

23742378
@property
23752379
def description(self):
23762380
"""Return the pipeline class."""
2381+
23772382
if self.pipeline_class:
2378-
return self.pipeline_class.__doc__
2383+
return cleandoc(self.pipeline_class.__doc__ or "")
23792384

23802385
@property
23812386
def all_runs(self):
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Copyright (c) nexB Inc. and others. All rights reserved.
2+
# VulnerableCode is a trademark of nexB Inc.
3+
# SPDX-License-Identifier: Apache-2.0
4+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
5+
# See https://github.com/aboutcode-org/vulnerablecode for support or download.
6+
# See https://aboutcode.org for more information about nexB OSS projects.
7+
#
8+
9+
from vulnerabilities.pipelines.management import cleanup_pipeline_runs
10+
from vulnerabilities.utils import create_registry
11+
12+
MANAGEMENT_REGISTRY = create_registry(
13+
[
14+
cleanup_pipeline_runs.CleanupPipelineRuns,
15+
]
16+
)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Copyright (c) nexB Inc. and others. All rights reserved.
2+
# VulnerableCode is a trademark of nexB Inc.
3+
# SPDX-License-Identifier: Apache-2.0
4+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
5+
# See https://github.com/aboutcode-org/vulnerablecode for support or download.
6+
# See https://aboutcode.org for more information about nexB OSS projects.
7+
#
8+
9+
10+
from datetime import timedelta
11+
12+
from django.utils import timezone
13+
14+
from vulnerabilities.models import PipelineSchedule
15+
from vulnerabilities.pipelines import LoopProgress
16+
from vulnerabilities.pipelines import VulnerableCodePipeline
17+
from vulnerablecode.settings import (
18+
VULNERABLECODE_MINIMUM_PIPELINE_RUNS_TO_RETAIN as minimum_number_of_runs_to_keep,
19+
)
20+
from vulnerablecode.settings import VULNERABLECODE_PIPELINE_RUN_RETENTION_DAYS as retention_days
21+
22+
23+
class CleanupPipelineRuns(VulnerableCodePipeline):
24+
"""Remove pipeline runs older than the retention period while preserving the configured minimum number of runs."""
25+
26+
pipeline_id = "cleanup_pipeline_runs"
27+
28+
run_interval = 1440
29+
run_priority = PipelineSchedule.ExecutionPriority.DEFAULT
30+
31+
@classmethod
32+
def steps(cls):
33+
return (cls.cleanup_old_pipeline_runs,)
34+
35+
def cleanup_old_pipeline_runs(self):
36+
"""Remove pipeline runs older than the retention period while preserving the configured minimum number of runs."""
37+
38+
pipelines = PipelineSchedule.objects.all()
39+
pipeline_count = 0
40+
deleted_run_count = 0
41+
42+
progress = LoopProgress(
43+
total_iterations=pipelines.count(), progress_step=5, logger=self.log
44+
)
45+
for pipeline in progress.iter(pipelines):
46+
cutoff = timezone.now() - timedelta(days=retention_days)
47+
runs = pipeline.pipelineruns.filter(run_exitcode__isnull=False).order_by(
48+
"-created_date"
49+
)
50+
runs_to_keep = runs[:minimum_number_of_runs_to_keep]
51+
runs_to_delete = runs.filter(created_date__lt=cutoff).exclude(
52+
run_id__in=runs_to_keep.values("run_id")
53+
)
54+
55+
if runs_to_delete.exists():
56+
pipeline_count += 1
57+
58+
for run in runs_to_delete.iterator():
59+
run.delete()
60+
deleted_run_count += 1
61+
62+
self.log(
63+
f"Successfully removed {deleted_run_count:,d} runs from {pipeline_count} pipelines that were older than {retention_days} days."
64+
)

‎vulnerabilities/schedules.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,9 @@ def update_pipeline_schedule():
9191
from vulnerabilities.improvers import IMPROVERS_REGISTRY
9292
from vulnerabilities.models import PipelineSchedule
9393
from vulnerabilities.pipelines.exporters import EXPORTERS_REGISTRY
94+
from vulnerabilities.pipelines.management import MANAGEMENT_REGISTRY
9495

95-
pipelines = IMPORTERS_REGISTRY | IMPROVERS_REGISTRY | EXPORTERS_REGISTRY
96+
pipelines = IMPORTERS_REGISTRY | IMPROVERS_REGISTRY | EXPORTERS_REGISTRY | MANAGEMENT_REGISTRY
9697

9798
PipelineSchedule.objects.exclude(pipeline_id__in=pipelines.keys()).delete()
9899
for id, pipeline_class in pipelines.items():
@@ -130,7 +131,7 @@ def mark_stale_runs():
130131
stale_jobs_count = stale_jobs.count()
131132

132133
for job in stale_jobs.iterator(chunk_size=1000):
133-
job.set_run_staled()
134+
job.stop_run()
134135

135136
log.info(f"Marked {stale_jobs_count} unfinished jobs as stale.")
136137

@@ -150,7 +151,7 @@ def requeue_missing_jobs():
150151
enqueue_run(run=job)
151152
missing_jobs_count += 1
152153
else:
153-
job.set_run_staled()
154+
job.stop_run()
154155

155156
log.info(f"Requeued {missing_jobs_count} missing jobs.")
156157

‎vulnerabilities/tasks.py‎

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ def compute_queue_load_factor():
178178
"""
179179
field = models.PipelineSchedule._meta.get_field("run_priority")
180180
label_to_value = {label: value for value, label in field.choices}
181-
total_compute_seconds_per_queue = {}
181+
total_compute_seconds_per_queue_in_24hr_cycle = {}
182182
worker_per_queue = {}
183183
load_per_queue = {}
184184
seconds_in_24_hr = 86400
@@ -191,26 +191,39 @@ def compute_queue_load_factor():
191191
worker_per_queue = dict(Counter(queue_names))
192192

193193
for queue in RQ_QUEUES.keys():
194-
total_compute_seconds_per_queue[queue] = sum(
195-
(p.latest_successful_run.runtime / (p.run_interval / (24 * 60)))
196-
for p in models.PipelineSchedule.objects.filter(
197-
is_active=True, run_priority=label_to_value[queue]
198-
)
199-
if p.latest_successful_run
194+
total_compute_seconds = 0
195+
active_schedules = models.PipelineSchedule.objects.filter(
196+
is_active=True,
197+
run_priority=label_to_value[queue],
200198
)
199+
for schedule in active_schedules:
200+
if not schedule.latest_successful_run:
201+
continue
202+
203+
runs_per_day = (24 * 60) / schedule.run_interval
204+
compute_seconds_per_day = schedule.latest_successful_run.runtime * runs_per_day
205+
total_compute_seconds += compute_seconds_per_day
206+
207+
total_compute_seconds_per_queue_in_24hr_cycle[queue] = total_compute_seconds
208+
201209
if queue not in worker_per_queue:
202210
worker_per_queue[queue] = 0
203211

204212
for queue_name, worker_count in worker_per_queue.items():
205213
net_load_on_queue = "no_worker"
206-
total_compute = total_compute_seconds_per_queue.get(queue_name, 0)
214+
total_compute = total_compute_seconds_per_queue_in_24hr_cycle.get(queue_name, 0)
215+
total_pipeline_for_queue = models.PipelineSchedule.objects.filter(
216+
is_active=True, run_priority=label_to_value[queue_name]
217+
).count()
207218
if total_compute == 0:
208219
continue
209220

210221
unit_load_on_queue = total_compute / seconds_in_24_hr
211222

212223
num_of_worker_for_balanced_queue = round(unit_load_on_queue)
213-
addition_worker_needed = max(num_of_worker_for_balanced_queue - worker_count, 0)
224+
addition_worker_needed = min(
225+
max(num_of_worker_for_balanced_queue - worker_count, 0), total_pipeline_for_queue
226+
)
214227

215228
if worker_count > 0:
216229
net_load_on_queue = unit_load_on_queue / worker_count

‎vulnerabilities/templates/pipeline_run_list.html‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,22 @@ <h1>{{ pipeline_name }} Runs</h1>
4141
</div>
4242

4343
{% if pipeline_description %}
44-
<div class="notification is-info is-light">
45-
<p class="is-size-5 has-text-grey-dark has-text-centered">
46-
{{ pipeline_description }}
47-
</p>
48-
</div>
44+
<div class="notification is-info is-light">
45+
<div class="is-size-5 has-text-grey-dark">
46+
{{ pipeline_description|linebreaks }}
47+
</div>
48+
</div>
4949
{% endif %}
5050

5151
<div class="box">
52+
<div class="notification has-text-centered" style="background-color: #f8edb2;">
53+
<p class="is-size-6 has-text-grey-dark">
54+
<i class="fa fa-exclamation-triangle mr-1"></i>
55+
Pipeline runs older than {{ pipeline_run_retention_days }} days are automatically removed.
56+
At least the latest {{ minimum_pipeline_runs_retain }} pipeline runs are always retained.
57+
</p>
58+
</div>
59+
5260
<table class="table is-striped is-hoverable is-fullwidth">
5361
<thead>
5462
<tr>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# VulnerableCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: Apache-2.0
5+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
6+
# See https://github.com/aboutcode-org/vulnerablecode for support or download.
7+
# See https://aboutcode.org for more information about nexB OSS projects.
8+
#
9+
10+
11+
from datetime import timedelta
12+
13+
from django.test import TestCase
14+
from django.utils import timezone
15+
16+
from vulnerabilities import models
17+
from vulnerabilities.pipelines.management.cleanup_pipeline_runs import CleanupPipelineRuns
18+
from vulnerabilities.tests.pipelines import TestLogger
19+
20+
21+
class TestCleanupPipelineRuns(TestCase):
22+
def setUp(self):
23+
self.logger = TestLogger()
24+
25+
self.schedule1 = models.PipelineSchedule.objects.create(pipeline_id="test_pipeline")
26+
for _ in range(70):
27+
models.PipelineRun.objects.create(
28+
pipeline=self.schedule1,
29+
run_exitcode=0,
30+
)
31+
32+
def test_pipelines_management_cleanup_pipeline_runs_with_old_and_new_runs(self):
33+
cutoff = timezone.now() - timedelta(days=100)
34+
old_run_ids = models.PipelineRun.objects.all()[:60].values("run_id")
35+
models.PipelineRun.objects.filter(run_id__in=old_run_ids).update(created_date=cutoff)
36+
37+
self.assertEqual(models.PipelineRun.objects.count(), 70)
38+
pipeline = CleanupPipelineRuns()
39+
pipeline.log = self.logger.write
40+
exit_code, _ = pipeline.execute()
41+
42+
self.assertEqual(exit_code, 0)
43+
self.assertEqual(models.PipelineRun.objects.count(), 60)
44+
self.assertIn(
45+
"Successfully removed 10 runs from 1 pipelines that were older than 60 days",
46+
self.logger.getvalue(),
47+
)
48+
49+
def test_pipelines_management_cleanup_pipeline_runs_with_new_runs_only(self):
50+
self.assertEqual(models.PipelineRun.objects.count(), 70)
51+
pipeline = CleanupPipelineRuns()
52+
pipeline.log = self.logger.write
53+
exit_code, _ = pipeline.execute()
54+
55+
self.assertEqual(exit_code, 0)
56+
self.assertEqual(models.PipelineRun.objects.count(), 70)
57+
self.assertIn(
58+
"Successfully removed 0 runs from 0 pipelines that were older than 60 days",
59+
self.logger.getvalue(),
60+
)

‎vulnerabilities/views.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@
6666
from vulnerabilities.utils import safe_altcha_redirect
6767
from vulnerablecode import __version__ as VULNERABLECODE_VERSION
6868
from vulnerablecode.settings import VULNERABLECODE_ALTCHA_SESSION_TIMEOUT
69+
from vulnerablecode.settings import VULNERABLECODE_MINIMUM_PIPELINE_RUNS_TO_RETAIN
70+
from vulnerablecode.settings import VULNERABLECODE_PIPELINE_RUN_RETENTION_DAYS
6971
from vulnerablecode.settings import env
7072

7173
PAGE_SIZE = 10
@@ -1048,6 +1050,8 @@ def get_context_data(self, **kwargs):
10481050
)
10491051
context["pipeline_name"] = pipeline.pipeline_class.__name__
10501052
context["pipeline_description"] = pipeline.description
1053+
context["pipeline_run_retention_days"] = VULNERABLECODE_PIPELINE_RUN_RETENTION_DAYS
1054+
context["minimum_pipeline_runs_retain"] = VULNERABLECODE_MINIMUM_PIPELINE_RUNS_TO_RETAIN
10511055
return context
10521056

10531057

‎vulnerablecode/settings.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@
4444

4545
VULNERABLECODE_ALTCHA_SESSION_TIMEOUT = env.int("VULNERABLECODE_ALTCHA_SESSION_TIMEOUT", None)
4646

47+
# Retain pipeline runs for the specified number of days, cannot be less than 10 days.
48+
VULNERABLECODE_PIPELINE_RUN_RETENTION_DAYS = max(
49+
env.int("VULNERABLECODE_PIPELINE_RUN_RETENTION_DAYS", 60), 10
50+
)
51+
52+
# Minimum number of pipeline runs to retain regardless of the retention period, cannot be less than 15.
53+
VULNERABLECODE_MINIMUM_PIPELINE_RUNS_TO_RETAIN = max(
54+
env.int("VULNERABLECODE_MINIMUM_PIPELINE_RUNS_TO_RETAIN", 60), 15
55+
)
56+
4757
# SECURITY WARNING: do not run with debug turned on in production
4858
DEBUG = env.bool("VULNERABLECODE_DEBUG", default=False)
4959

0 commit comments

Comments
 (0)