Skip to content

Commit eba4025

Browse files
authored
Merge pull request #8850 from readthedocs/humitos/cancel-build
2 parents b220bc6 + 4eb5595 commit eba4025

File tree

7 files changed

+103
-2
lines changed

7 files changed

+103
-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', '0040_remove_old_jsonfields'),
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
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,13 @@ class Build(models.Model):
682682
help_text='Build steps stored outside the database.',
683683
)
684684

685+
task_id = models.CharField(
686+
_('Celery task id'),
687+
max_length=36,
688+
null=True,
689+
blank=True,
690+
)
691+
685692
# Managers
686693
objects = BuildQuerySet.as_manager()
687694
# Only include BRANCH, TAG, UNKNOWN type Version builds.

readthedocs/builds/views.py

+46-1
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
@@ -14,12 +16,15 @@
1416
from django.views.generic import DetailView, ListView
1517
from requests.utils import quote
1618

19+
from readthedocs.builds.constants import BUILD_STATE_TRIGGERED, BUILD_STATE_FINISHED
1720
from readthedocs.builds.filters import BuildListFilter
1821
from readthedocs.builds.models import Build, Version
1922
from readthedocs.core.permissions import AdminPermission
2023
from readthedocs.core.utils import trigger_build
21-
from readthedocs.doc_builder.exceptions import BuildAppError
24+
from readthedocs.doc_builder.exceptions import BuildAppError, BuildCancelled
2225
from readthedocs.projects.models import Project
26+
from readthedocs.worker import app
27+
2328

2429
log = structlog.get_logger(__name__)
2530

@@ -148,6 +153,46 @@ class BuildDetail(BuildBase, DetailView):
148153

149154
pk_url_kwarg = 'build_pk'
150155

156+
@method_decorator(login_required)
157+
def post(self, request, project_slug, build_pk):
158+
project = get_object_or_404(Project, slug=project_slug)
159+
build = get_object_or_404(Build, pk=build_pk)
160+
161+
if not AdminPermission.is_admin(request.user, project):
162+
return HttpResponseForbidden()
163+
164+
# NOTE: `terminate=True` is required for the child to attend our call
165+
# immediately when it's running the build. Otherwise, it finishes the
166+
# task. However, to revoke a task that has not started yet, we don't
167+
# need it.
168+
if build.state == BUILD_STATE_TRIGGERED:
169+
# Since the task won't be executed at all, we need to update the
170+
# Build object here.
171+
terminate = False
172+
build.state = BUILD_STATE_FINISHED
173+
build.success = False
174+
build.error = BuildCancelled.message
175+
build.length = 0
176+
build.save()
177+
else:
178+
# In this case, we left the update of the Build object to the task
179+
# itself to be executed in the `on_failure` handler.
180+
terminate = True
181+
182+
log.warning(
183+
'Canceling build.',
184+
project_slug=project.slug,
185+
version_slug=build.version.slug,
186+
build_id=build.pk,
187+
build_task_id=build.task_id,
188+
terminate=terminate,
189+
)
190+
app.control.revoke(build.task_id, signal=signal.SIGINT, terminate=terminate)
191+
192+
return HttpResponseRedirect(
193+
reverse('builds_detail', args=[project.slug, build.pk]),
194+
)
195+
151196
def get_context_data(self, **kwargs):
152197
context = super().get_context_data(**kwargs)
153198
context['project'] = self.project

readthedocs/core/utils/__init__.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,18 @@ def trigger_build(project, version=None, commit=None):
229229
# Build was skipped
230230
return (None, None)
231231

232-
return (update_docs_task.apply_async(), build)
232+
task = update_docs_task.apply_async()
233+
234+
# FIXME: I'm using `isinstance` here because I wasn't able to mock this
235+
# properly when running tests and it fails when trying to save a
236+
# `mock.Mock` object in the database.
237+
#
238+
# Store the task_id in the build object to be able to cancel it later.
239+
if isinstance(task.id, (str, int)):
240+
build.task_id = task.id
241+
build.save()
242+
243+
return task, build
233244

234245

235246
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
@@ -52,6 +52,7 @@
5252
BuildUserError,
5353
BuildMaxConcurrencyError,
5454
DuplicatedBuildError,
55+
BuildCancelled,
5556
ProjectBuildsSkippedError,
5657
YAMLParseError,
5758
MkDocsYAMLParseError,
@@ -237,6 +238,7 @@ class UpdateDocsTask(SyncRepositoryMixin, Task):
237238
ProjectBuildsSkippedError,
238239
ConfigError,
239240
YAMLParseError,
241+
BuildCancelled,
240242
BuildUserError,
241243
RepositoryError,
242244
MkDocsYAMLParseError,
@@ -245,6 +247,7 @@ class UpdateDocsTask(SyncRepositoryMixin, Task):
245247

246248
# Do not send notifications on failure builds for these exceptions.
247249
exceptions_without_notifications = (
250+
BuildCancelled,
248251
DuplicatedBuildError,
249252
ProjectBuildsSkippedError,
250253
)
@@ -262,10 +265,16 @@ def _setup_sigterm(self):
262265
def sigterm_received(*args, **kwargs):
263266
log.warning('SIGTERM received. Waiting for build to stop gracefully after it finishes.')
264267

268+
def sigint_received(*args, **kwargs):
269+
log.warning('SIGINT received. Canceling the build running.')
270+
raise BuildCancelled
271+
265272
# Do not send the SIGTERM signal to children (pip is automatically killed when
266273
# receives SIGTERM and make the build to fail one command and stop build)
267274
signal.signal(signal.SIGTERM, sigterm_received)
268275

276+
signal.signal(signal.SIGINT, sigint_received)
277+
269278
def _check_concurrency_limit(self):
270279
try:
271280
response = api_v2.build.concurrent.get(project__slug=self.data.project.slug)

readthedocs/templates/builds/build_detail.html

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

33+
{% if build.state != "finished" and request.user|is_admin:project %}
34+
<form method="post" action="{% url "builds_detail" build.version.project.slug build.pk %}">
35+
{% csrf_token %}
36+
<input type="submit" value="{% trans "Cancel build" %}">
37+
</form>
38+
{% endif %}
39+
3340
<!-- Build meta data -->
3441
<ul class="build-meta">
3542
<div data-bind="visible: finished()">

0 commit comments

Comments
 (0)