Skip to content

Commit 60efd91

Browse files
committed
Build: ability to cancel a running build from dashboard
We tried to implement this in another opportunities (see #7031) but the build process was really complex and we had to manage the exception in multiple places. After implementing Celery Handlers, we can just raise the exception when attending to the proper signal coming from `app.control.revoke` and handle it properly from `on_failure` task's method. All the initial local tests were great!
1 parent 1e9c724 commit 60efd91

File tree

7 files changed

+74
-2
lines changed

7 files changed

+74
-2
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 3.2.11 on 2022-01-26 20:10
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('builds', '0037_alter_build_cold_storage'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='build',
15+
name='task_id',
16+
field=models.CharField(blank=True, max_length=36, null=True, verbose_name='Celery task id'),
17+
),
18+
]

readthedocs/builds/models.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import os.path
66
import re
77
from functools import partial
8-
from shutil import rmtree
98

109
import regex
1110
from django.conf import settings
@@ -667,6 +666,13 @@ class Build(models.Model):
667666
help_text='Build steps stored outside the database.',
668667
)
669668

669+
task_id = models.CharField(
670+
_('Celery task id'),
671+
max_length=36,
672+
null=True,
673+
blank=True,
674+
)
675+
670676
# Managers
671677
objects = BuildQuerySet.as_manager()
672678
# Only include BRANCH, TAG, UNKNOWN type Version builds.

readthedocs/builds/views.py

+24
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Views for builds app."""
22

3+
import signal
4+
35
import structlog
46
import textwrap
57
from urllib.parse import urlparse
@@ -21,6 +23,12 @@
2123
from readthedocs.doc_builder.exceptions import BuildAppError
2224
from readthedocs.projects.models import Project
2325

26+
try:
27+
from readthedocsinc.worker import app
28+
except ImportError:
29+
from readthedocs.worker import app
30+
31+
2432
log = structlog.get_logger(__name__)
2533

2634

@@ -148,6 +156,22 @@ class BuildDetail(BuildBase, DetailView):
148156

149157
pk_url_kwarg = 'build_pk'
150158

159+
@method_decorator(login_required)
160+
def post(self, request, project_slug, build_pk):
161+
project = get_object_or_404(Project, slug=project_slug)
162+
build = get_object_or_404(Build, pk=build_pk)
163+
164+
if not AdminPermission.is_admin(request.user, project):
165+
return HttpResponseForbidden()
166+
167+
# NOTE: `terminate=True` is required for the child to attend our call
168+
# immediately. Otherwise, it finishes the task.
169+
app.control.revoke(build.task_id, signal=signal.SIGINT, terminate=True)
170+
171+
return HttpResponseRedirect(
172+
reverse('builds_detail', args=[project.slug, build.pk]),
173+
)
174+
151175
def get_context_data(self, **kwargs):
152176
context = super().get_context_data(**kwargs)
153177
context['project'] = self.project

readthedocs/core/utils/__init__.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,13 @@ def trigger_build(project, version=None, commit=None):
239239
# Build was skipped
240240
return (None, None)
241241

242-
return (update_docs_task.apply_async(), build)
242+
task = update_docs_task.apply_async()
243+
244+
# Store the task_id in the build object to be able to cancel it later.
245+
build.task_id = task.id
246+
build.save()
247+
248+
return task, build
243249

244250

245251
def send_email(

readthedocs/doc_builder/exceptions.py

+4
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ class DuplicatedBuildError(BuildUserError):
5959
status = BUILD_STATUS_DUPLICATED
6060

6161

62+
class BuildCancelled(BuildUserError):
63+
message = gettext_noop('Build cancelled by user.')
64+
65+
6266
class MkDocsYAMLParseError(BuildUserError):
6367
GENERIC_WITH_PARSE_EXCEPTION = gettext_noop(
6468
'Problem parsing MkDocs YAML configuration. {exception}',

readthedocs/projects/tasks/builds.py

+9
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
BuildUserError,
5252
BuildMaxConcurrencyError,
5353
DuplicatedBuildError,
54+
BuildCancelled,
5455
ProjectBuildsSkippedError,
5556
YAMLParseError,
5657
)
@@ -206,6 +207,7 @@ class UpdateDocsTask(SyncRepositoryMixin, Task):
206207
ProjectBuildsSkippedError,
207208
ConfigError,
208209
YAMLParseError,
210+
BuildCancelled,
209211
)
210212

211213
acks_late = True
@@ -221,10 +223,17 @@ def _setup_sigterm(self):
221223
def sigterm_received(*args, **kwargs):
222224
log.warning('SIGTERM received. Waiting for build to stop gracefully after it finishes.')
223225

226+
def sigint_received(*args, **kwargs):
227+
log.warning('SIGINT received. Cancelling the build running.')
228+
raise BuildCancelled
229+
224230
# Do not send the SIGTERM signal to children (pip is automatically killed when
225231
# receives SIGTERM and make the build to fail one command and stop build)
226232
signal.signal(signal.SIGTERM, sigterm_received)
227233

234+
235+
signal.signal(signal.SIGINT, sigint_received)
236+
228237
def _check_concurrency_limit(self):
229238
try:
230239
response = api_v2.build.concurrent.get(project__slug=self.project.slug)

readthedocs/templates/builds/build_detail.html

+5
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@
3030
{% block content %}
3131
<div class="build build-detail" id="build-detail">
3232

33+
<form method="post" action="{% url "builds_detail" build.version.project.slug build.pk %}">
34+
{% csrf_token %}
35+
<input type="submit" value="{% trans "Cancel build" %}">
36+
</form>
37+
3338
<!-- Build meta data -->
3439
<ul class="build-meta">
3540
<div data-bind="visible: finished()">

0 commit comments

Comments
 (0)