Skip to content

Commit b9d9cf0

Browse files
authored
Build: set CANCELLED state when the build is cancelled (#11171)
* Build: set `CANCELLED` state when the build is cancelled This bug was introduced in https://github.com/readthedocs/readthedocs.org/pull/10922/files#diff-4eae6664ca2124d124dbfe04a8000483c7a23d426c3f390721d6e80fe5891064L504 by mistake. Closes #11170 * Lint * Test for `CANCELLED_BY_USER`
1 parent 695667e commit b9d9cf0

File tree

2 files changed

+91
-40
lines changed

2 files changed

+91
-40
lines changed

readthedocs/projects/tasks/builds.py

Lines changed: 41 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
ARTIFACT_TYPES_WITHOUT_MULTIPLE_FILES_SUPPORT,
2525
BUILD_FINAL_STATES,
2626
BUILD_STATE_BUILDING,
27+
BUILD_STATE_CANCELLED,
2728
BUILD_STATE_CLONING,
2829
BUILD_STATE_FINISHED,
2930
BUILD_STATE_INSTALLING,
@@ -136,7 +137,7 @@ class SyncRepositoryTask(SyncRepositoryMixin, Task):
136137
in our database.
137138
"""
138139

139-
name = __name__ + '.sync_repository_task'
140+
name = __name__ + ".sync_repository_task"
140141
max_retries = 5
141142
default_retry_delay = 7 * 60
142143
throws = (
@@ -145,7 +146,7 @@ class SyncRepositoryTask(SyncRepositoryMixin, Task):
145146
)
146147

147148
def before_start(self, task_id, args, kwargs):
148-
log.info('Running task.', name=self.name)
149+
log.info("Running task.", name=self.name)
149150

150151
# Create the object to store all the task-related data
151152
self.data = TaskData()
@@ -168,7 +169,7 @@ def before_start(self, task_id, args, kwargs):
168169

169170
# Also note there are builds that are triggered without a commit
170171
# because they just build the latest commit for that version
171-
self.data.build_commit = kwargs.get('build_commit')
172+
self.data.build_commit = kwargs.get("build_commit")
172173

173174
log.bind(
174175
project_slug=self.data.project.slug,
@@ -179,7 +180,7 @@ def on_failure(self, exc, task_id, args, kwargs, einfo):
179180
# Do not log as error handled exceptions
180181
if isinstance(exc, RepositoryError):
181182
log.warning(
182-
'There was an error with the repository.',
183+
"There was an error with the repository.",
183184
)
184185
elif isinstance(exc, SyncRepositoryLocked):
185186
log.warning(
@@ -274,10 +275,8 @@ class UpdateDocsTask(SyncRepositoryMixin, Task):
274275
build all the documentation formats and upload them to the storage.
275276
"""
276277

277-
name = __name__ + '.update_docs_task'
278-
autoretry_for = (
279-
BuildMaxConcurrencyError,
280-
)
278+
name = __name__ + ".update_docs_task"
279+
autoretry_for = (BuildMaxConcurrencyError,)
281280
max_retries = settings.RTD_BUILDS_MAX_RETRIES
282281
default_retry_delay = settings.RTD_BUILDS_RETRY_DELAY
283282
retry_backoff = False
@@ -320,10 +319,12 @@ class UpdateDocsTask(SyncRepositoryMixin, Task):
320319

321320
def _setup_sigterm(self):
322321
def sigterm_received(*args, **kwargs):
323-
log.warning('SIGTERM received. Waiting for build to stop gracefully after it finishes.')
322+
log.warning(
323+
"SIGTERM received. Waiting for build to stop gracefully after it finishes."
324+
)
324325

325326
def sigint_received(*args, **kwargs):
326-
log.warning('SIGINT received. Canceling the build running.')
327+
log.warning("SIGINT received. Canceling the build running.")
327328

328329
# Only allow to cancel the build if it's not already uploading the files.
329330
# This is to protect our users to end up with half of the documentation uploaded.
@@ -347,12 +348,12 @@ def _check_concurrency_limit(self):
347348
)
348349
concurrency_limit_reached = response.get("limit_reached", False)
349350
max_concurrent_builds = response.get(
350-
'max_concurrent',
351+
"max_concurrent",
351352
settings.RTD_MAX_CONCURRENT_BUILDS,
352353
)
353354
except Exception:
354355
log.exception(
355-
'Error while hitting/parsing API for concurrent limit checks from builder.',
356+
"Error while hitting/parsing API for concurrent limit checks from builder.",
356357
project_slug=self.data.project.slug,
357358
version_slug=self.data.version.slug,
358359
)
@@ -375,7 +376,7 @@ def _check_concurrency_limit(self):
375376

376377
def _check_project_disabled(self):
377378
if self.data.project.skip:
378-
log.warning('Project build skipped.')
379+
log.warning("Project build skipped.")
379380
raise BuildAppError(BuildAppError.BUILDS_DISABLED)
380381

381382
def before_start(self, task_id, args, kwargs):
@@ -403,21 +404,21 @@ def before_start(self, task_id, args, kwargs):
403404
self.data.project = self.data.version.project
404405

405406
# Save the builder instance's name into the build object
406-
self.data.build['builder'] = socket.gethostname()
407+
self.data.build["builder"] = socket.gethostname()
407408

408409
# Reset any previous build error reported to the user
409-
self.data.build['error'] = ''
410+
self.data.build["error"] = ""
410411
# Also note there are builds that are triggered without a commit
411412
# because they just build the latest commit for that version
412-
self.data.build_commit = kwargs.get('build_commit')
413+
self.data.build_commit = kwargs.get("build_commit")
413414

414415
self.data.build_director = BuildDirector(
415416
data=self.data,
416417
)
417418

418419
log.bind(
419420
# NOTE: ``self.data.build`` is just a regular dict, not an APIBuild :'(
420-
builder=self.data.build['builder'],
421+
builder=self.data.build["builder"],
421422
commit=self.data.build_commit,
422423
project_slug=self.data.project.slug,
423424
version_slug=self.data.version.slug,
@@ -470,7 +471,7 @@ def on_failure(self, exc, task_id, args, kwargs, einfo):
470471
#
471472
# So, we create the `self.data.build` with the minimum required data.
472473
self.data.build = {
473-
'id': self.data.build_pk,
474+
"id": self.data.build_pk,
474475
}
475476

476477
# Known errors in our application code (e.g. we couldn't connect to
@@ -488,6 +489,10 @@ def on_failure(self, exc, task_id, args, kwargs, einfo):
488489
else:
489490
message_id = BuildUserError.GENERIC
490491

492+
# Set build state as cancelled if the user cancelled the build
493+
if isinstance(exc, BuildCancelled):
494+
self.data.build["state"] = BUILD_STATE_CANCELLED
495+
491496
else:
492497
# We don't know what happened in the build. Log the exception and
493498
# report a generic notification to the user.
@@ -513,7 +518,7 @@ def on_failure(self, exc, task_id, args, kwargs, einfo):
513518
if message_id not in self.exceptions_without_notifications:
514519
self.send_notifications(
515520
self.data.version_pk,
516-
self.data.build['id'],
521+
self.data.build["id"],
517522
event=WebHookEvent.BUILD_FAILED,
518523
)
519524

@@ -541,7 +546,7 @@ def on_failure(self, exc, task_id, args, kwargs, einfo):
541546

542547
send_external_build_status(
543548
version_type=version_type,
544-
build_pk=self.data.build['id'],
549+
build_pk=self.data.build["id"],
545550
commit=self.data.build_commit,
546551
status=status,
547552
)
@@ -661,20 +666,20 @@ def on_success(self, retval, task_id, args, kwargs):
661666

662667
self.send_notifications(
663668
self.data.version.pk,
664-
self.data.build['id'],
669+
self.data.build["id"],
665670
event=WebHookEvent.BUILD_PASSED,
666671
)
667672

668673
if self.data.build_commit:
669674
send_external_build_status(
670675
version_type=self.data.version.type,
671-
build_pk=self.data.build['id'],
676+
build_pk=self.data.build["id"],
672677
commit=self.data.build_commit,
673678
status=BUILD_STATUS_SUCCESS,
674679
)
675680

676681
# Update build object
677-
self.data.build['success'] = True
682+
self.data.build["success"] = True
678683

679684
def on_retry(self, exc, task_id, args, kwargs, einfo):
680685
"""
@@ -686,11 +691,11 @@ def on_retry(self, exc, task_id, args, kwargs, einfo):
686691
687692
See https://docs.celeryproject.org/en/master/userguide/tasks.html#retrying
688693
"""
689-
log.info('Retrying this task.')
694+
log.info("Retrying this task.")
690695

691696
if isinstance(exc, BuildMaxConcurrencyError):
692697
log.warning(
693-
'Delaying tasks due to concurrency limit.',
698+
"Delaying tasks due to concurrency limit.",
694699
project_slug=self.data.project.slug,
695700
version_slug=self.data.version.slug,
696701
)
@@ -713,7 +718,7 @@ def after_return(self, status, retval, task_id, args, kwargs, einfo):
713718
so some attributes from the `self.data` object may not be defined.
714719
"""
715720
# Update build object
716-
self.data.build['length'] = (timezone.now() - self.data.start_time).seconds
721+
self.data.build["length"] = (timezone.now() - self.data.start_time).seconds
717722

718723
build_state = None
719724
# The state key might not be defined
@@ -742,9 +747,9 @@ def after_return(self, status, retval, task_id, args, kwargs, einfo):
742747
)
743748

744749
log.info(
745-
'Build finished.',
746-
length=self.data.build['length'],
747-
success=self.data.build['success']
750+
"Build finished.",
751+
length=self.data.build["length"],
752+
success=self.data.build["success"],
748753
)
749754

750755
def update_build(self, state=None):
@@ -856,23 +861,20 @@ def get_build(self, build_pk):
856861
if build_pk:
857862
build = self.data.api_client.build(build_pk).get()
858863
private_keys = [
859-
'project',
860-
'version',
861-
'resource_uri',
862-
'absolute_uri',
864+
"project",
865+
"version",
866+
"resource_uri",
867+
"absolute_uri",
863868
]
864869
# TODO: try to use the same technique than for ``APIProject``.
865-
return {
866-
key: val
867-
for key, val in build.items() if key not in private_keys
868-
}
870+
return {key: val for key, val in build.items() if key not in private_keys}
869871

870872
# NOTE: this can be just updated on `self.data.build['']` and sent once the
871873
# build has finished to reduce API calls.
872874
def set_valid_clone(self):
873875
"""Mark on the project that it has been cloned properly."""
874876
self.data.api_client.project(self.data.project.pk).patch(
875-
{'has_valid_clone': True}
877+
{"has_valid_clone": True}
876878
)
877879
self.data.project.has_valid_clone = True
878880
self.data.version.project.has_valid_clone = True
@@ -887,7 +889,7 @@ def store_build_artifacts(self):
887889
Remove build artifacts of types not included in this build (PDF, ePub, zip only).
888890
"""
889891
time_before_store_build_artifacts = timezone.now()
890-
log.info('Writing build artifacts to media storage')
892+
log.info("Writing build artifacts to media storage")
891893
self.update_build(state=BUILD_STATE_UPLOADING)
892894

893895
valid_artifacts = self.get_valid_artifact_types()

readthedocs/projects/tests/test_build_tasks.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from readthedocs.config.config import BuildConfigV2
1818
from readthedocs.config.exceptions import ConfigError
1919
from readthedocs.config.tests.test_config import get_build_config
20-
from readthedocs.doc_builder.exceptions import BuildUserError
20+
from readthedocs.doc_builder.exceptions import BuildCancelled, BuildUserError
2121
from readthedocs.projects.exceptions import RepositoryError
2222
from readthedocs.projects.models import EnvironmentVariable, Project, WebHookEvent
2323
from readthedocs.projects.tasks.builds import sync_repository_task, update_docs_task
@@ -695,6 +695,55 @@ def test_failed_build(
695695
assert revoke_key_request._request.method == "POST"
696696
assert revoke_key_request.path == "/api/v2/revoke/"
697697

698+
@mock.patch("readthedocs.projects.tasks.builds.send_external_build_status")
699+
@mock.patch("readthedocs.projects.tasks.builds.UpdateDocsTask.execute")
700+
def test_cancelled_build(
701+
self,
702+
execute,
703+
send_external_build_status,
704+
):
705+
# Force an exception from the execution of the task. We don't really
706+
# care "where" it was raised: setup, build, syncing directories, etc
707+
execute.side_effect = BuildCancelled(
708+
message_id=BuildCancelled.CANCELLED_BY_USER
709+
)
710+
711+
self._trigger_update_docs_task()
712+
713+
send_external_build_status.assert_called_once_with(
714+
version_type=self.version.type,
715+
build_pk=self.build.pk,
716+
commit=self.build.commit,
717+
status=BUILD_STATUS_FAILURE,
718+
)
719+
720+
notification_request = self.requests_mock.request_history[-3]
721+
assert notification_request._request.method == "POST"
722+
assert notification_request.path == "/api/v2/notifications/"
723+
assert notification_request.json() == {
724+
"attached_to": f"build/{self.build.pk}",
725+
"message_id": BuildCancelled.CANCELLED_BY_USER,
726+
"state": "unread",
727+
"dismissable": False,
728+
"news": False,
729+
"format_values": {},
730+
}
731+
732+
# Test we are updating the DB by calling the API with the updated build object
733+
# The second last one should be the PATCH for the build
734+
build_status_request = self.requests_mock.request_history[-2]
735+
assert build_status_request._request.method == "PATCH"
736+
assert build_status_request.path == "/api/v2/build/1/"
737+
assert build_status_request.json() == {
738+
"builder": mock.ANY,
739+
"commit": self.build.commit,
740+
"error": "", # We are not sending ``error`` anymore
741+
"id": self.build.pk,
742+
"length": mock.ANY,
743+
"state": "cancelled",
744+
"success": False,
745+
}
746+
698747
@mock.patch("readthedocs.doc_builder.director.load_yaml_config")
699748
def test_build_commands_executed(
700749
self,

0 commit comments

Comments
 (0)