Repository: readthedocs/readthedocs.org

Issue:
Build: missing state cancelled
It seems like we may have done something to the `Build.state = "cancelled"`, I'm seeing these builds as `Build.state = "finished"` now instead.

To reproduce:

- Open beta dashboard
- Start a build
- In the build detail page, click cancel
- Build state is now "failed"
- Inspect API response, build state is "failed"

![image](https://github.com/readthedocs/readthedocs.org/assets/1140183/69e14539-5bf3-4a52-8422-6a0f71706a47)

![image](https://github.com/readthedocs/readthedocs.org/assets/1140183/747b7e5e-daf1-4122-bb80-c80f15dd8b39)



Discussion:

Resolving patch:
diff --git a/readthedocs/projects/tasks/builds.py b/readthedocs/projects/tasks/builds.py
--- a/readthedocs/projects/tasks/builds.py
+++ b/readthedocs/projects/tasks/builds.py
@@ -24,6 +24,7 @@
     ARTIFACT_TYPES_WITHOUT_MULTIPLE_FILES_SUPPORT,
     BUILD_FINAL_STATES,
     BUILD_STATE_BUILDING,
+    BUILD_STATE_CANCELLED,
     BUILD_STATE_CLONING,
     BUILD_STATE_FINISHED,
     BUILD_STATE_INSTALLING,
@@ -136,7 +137,7 @@ class SyncRepositoryTask(SyncRepositoryMixin, Task):
     in our database.
     """
 
-    name = __name__ + '.sync_repository_task'
+    name = __name__ + ".sync_repository_task"
     max_retries = 5
     default_retry_delay = 7 * 60
     throws = (
@@ -145,7 +146,7 @@ class SyncRepositoryTask(SyncRepositoryMixin, Task):
     )
 
     def before_start(self, task_id, args, kwargs):
-        log.info('Running task.', name=self.name)
+        log.info("Running task.", name=self.name)
 
         # Create the object to store all the task-related data
         self.data = TaskData()
@@ -168,7 +169,7 @@ def before_start(self, task_id, args, kwargs):
 
         # Also note there are builds that are triggered without a commit
         # because they just build the latest commit for that version
-        self.data.build_commit = kwargs.get('build_commit')
+        self.data.build_commit = kwargs.get("build_commit")
 
         log.bind(
             project_slug=self.data.project.slug,
@@ -179,7 +180,7 @@ def on_failure(self, exc, task_id, args, kwargs, einfo):
         # Do not log as error handled exceptions
         if isinstance(exc, RepositoryError):
             log.warning(
-                'There was an error with the repository.',
+                "There was an error with the repository.",
             )
         elif isinstance(exc, SyncRepositoryLocked):
             log.warning(
@@ -274,10 +275,8 @@ class UpdateDocsTask(SyncRepositoryMixin, Task):
     build all the documentation formats and upload them to the storage.
     """
 
-    name = __name__ + '.update_docs_task'
-    autoretry_for = (
-        BuildMaxConcurrencyError,
-    )
+    name = __name__ + ".update_docs_task"
+    autoretry_for = (BuildMaxConcurrencyError,)
     max_retries = settings.RTD_BUILDS_MAX_RETRIES
     default_retry_delay = settings.RTD_BUILDS_RETRY_DELAY
     retry_backoff = False
@@ -320,10 +319,12 @@ class UpdateDocsTask(SyncRepositoryMixin, Task):
 
     def _setup_sigterm(self):
         def sigterm_received(*args, **kwargs):
-            log.warning('SIGTERM received. Waiting for build to stop gracefully after it finishes.')
+            log.warning(
+                "SIGTERM received. Waiting for build to stop gracefully after it finishes."
+            )
 
         def sigint_received(*args, **kwargs):
-            log.warning('SIGINT received. Canceling the build running.')
+            log.warning("SIGINT received. Canceling the build running.")
 
             # Only allow to cancel the build if it's not already uploading the files.
             # This is to protect our users to end up with half of the documentation uploaded.
@@ -347,12 +348,12 @@ def _check_concurrency_limit(self):
             )
             concurrency_limit_reached = response.get("limit_reached", False)
             max_concurrent_builds = response.get(
-                'max_concurrent',
+                "max_concurrent",
                 settings.RTD_MAX_CONCURRENT_BUILDS,
             )
         except Exception:
             log.exception(
-                'Error while hitting/parsing API for concurrent limit checks from builder.',
+                "Error while hitting/parsing API for concurrent limit checks from builder.",
                 project_slug=self.data.project.slug,
                 version_slug=self.data.version.slug,
             )
@@ -375,7 +376,7 @@ def _check_concurrency_limit(self):
 
     def _check_project_disabled(self):
         if self.data.project.skip:
-            log.warning('Project build skipped.')
+            log.warning("Project build skipped.")
             raise BuildAppError(BuildAppError.BUILDS_DISABLED)
 
     def before_start(self, task_id, args, kwargs):
@@ -403,13 +404,13 @@ def before_start(self, task_id, args, kwargs):
         self.data.project = self.data.version.project
 
         # Save the builder instance's name into the build object
-        self.data.build['builder'] = socket.gethostname()
+        self.data.build["builder"] = socket.gethostname()
 
         # Reset any previous build error reported to the user
-        self.data.build['error'] = ''
+        self.data.build["error"] = ""
         # Also note there are builds that are triggered without a commit
         # because they just build the latest commit for that version
-        self.data.build_commit = kwargs.get('build_commit')
+        self.data.build_commit = kwargs.get("build_commit")
 
         self.data.build_director = BuildDirector(
             data=self.data,
@@ -417,7 +418,7 @@ def before_start(self, task_id, args, kwargs):
 
         log.bind(
             # NOTE: ``self.data.build`` is just a regular dict, not an APIBuild :'(
-            builder=self.data.build['builder'],
+            builder=self.data.build["builder"],
             commit=self.data.build_commit,
             project_slug=self.data.project.slug,
             version_slug=self.data.version.slug,
@@ -470,7 +471,7 @@ def on_failure(self, exc, task_id, args, kwargs, einfo):
             #
             # So, we create the `self.data.build` with the minimum required data.
             self.data.build = {
-                'id': self.data.build_pk,
+                "id": self.data.build_pk,
             }
 
         # 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):
             else:
                 message_id = BuildUserError.GENERIC
 
+            # Set build state as cancelled if the user cancelled the build
+            if isinstance(exc, BuildCancelled):
+                self.data.build["state"] = BUILD_STATE_CANCELLED
+
         else:
             # We don't know what happened in the build. Log the exception and
             # report a generic notification to the user.
@@ -513,7 +518,7 @@ def on_failure(self, exc, task_id, args, kwargs, einfo):
         if message_id not in self.exceptions_without_notifications:
             self.send_notifications(
                 self.data.version_pk,
-                self.data.build['id'],
+                self.data.build["id"],
                 event=WebHookEvent.BUILD_FAILED,
             )
 
@@ -541,7 +546,7 @@ def on_failure(self, exc, task_id, args, kwargs, einfo):
 
             send_external_build_status(
                 version_type=version_type,
-                build_pk=self.data.build['id'],
+                build_pk=self.data.build["id"],
                 commit=self.data.build_commit,
                 status=status,
             )
@@ -661,20 +666,20 @@ def on_success(self, retval, task_id, args, kwargs):
 
         self.send_notifications(
             self.data.version.pk,
-            self.data.build['id'],
+            self.data.build["id"],
             event=WebHookEvent.BUILD_PASSED,
         )
 
         if self.data.build_commit:
             send_external_build_status(
                 version_type=self.data.version.type,
-                build_pk=self.data.build['id'],
+                build_pk=self.data.build["id"],
                 commit=self.data.build_commit,
                 status=BUILD_STATUS_SUCCESS,
             )
 
         # Update build object
-        self.data.build['success'] = True
+        self.data.build["success"] = True
 
     def on_retry(self, exc, task_id, args, kwargs, einfo):
         """
@@ -686,11 +691,11 @@ def on_retry(self, exc, task_id, args, kwargs, einfo):
 
         See https://docs.celeryproject.org/en/master/userguide/tasks.html#retrying
         """
-        log.info('Retrying this task.')
+        log.info("Retrying this task.")
 
         if isinstance(exc, BuildMaxConcurrencyError):
             log.warning(
-                'Delaying tasks due to concurrency limit.',
+                "Delaying tasks due to concurrency limit.",
                 project_slug=self.data.project.slug,
                 version_slug=self.data.version.slug,
             )
@@ -713,7 +718,7 @@ def after_return(self, status, retval, task_id, args, kwargs, einfo):
            so some attributes from the `self.data` object may not be defined.
         """
         # Update build object
-        self.data.build['length'] = (timezone.now() - self.data.start_time).seconds
+        self.data.build["length"] = (timezone.now() - self.data.start_time).seconds
 
         build_state = None
         # The state key might not be defined
@@ -742,9 +747,9 @@ def after_return(self, status, retval, task_id, args, kwargs, einfo):
             )
 
         log.info(
-            'Build finished.',
-            length=self.data.build['length'],
-            success=self.data.build['success']
+            "Build finished.",
+            length=self.data.build["length"],
+            success=self.data.build["success"],
         )
 
     def update_build(self, state=None):
@@ -856,23 +861,20 @@ def get_build(self, build_pk):
         if build_pk:
             build = self.data.api_client.build(build_pk).get()
         private_keys = [
-            'project',
-            'version',
-            'resource_uri',
-            'absolute_uri',
+            "project",
+            "version",
+            "resource_uri",
+            "absolute_uri",
         ]
         # TODO: try to use the same technique than for ``APIProject``.
-        return {
-            key: val
-            for key, val in build.items() if key not in private_keys
-        }
+        return {key: val for key, val in build.items() if key not in private_keys}
 
     # NOTE: this can be just updated on `self.data.build['']` and sent once the
     # build has finished to reduce API calls.
     def set_valid_clone(self):
         """Mark on the project that it has been cloned properly."""
         self.data.api_client.project(self.data.project.pk).patch(
-            {'has_valid_clone': True}
+            {"has_valid_clone": True}
         )
         self.data.project.has_valid_clone = True
         self.data.version.project.has_valid_clone = True
@@ -887,7 +889,7 @@ def store_build_artifacts(self):
         Remove build artifacts of types not included in this build (PDF, ePub, zip only).
         """
         time_before_store_build_artifacts = timezone.now()
-        log.info('Writing build artifacts to media storage')
+        log.info("Writing build artifacts to media storage")
         self.update_build(state=BUILD_STATE_UPLOADING)
 
         valid_artifacts = self.get_valid_artifact_types()


Test patch:
diff --git a/readthedocs/projects/tests/test_build_tasks.py b/readthedocs/projects/tests/test_build_tasks.py
--- a/readthedocs/projects/tests/test_build_tasks.py
+++ b/readthedocs/projects/tests/test_build_tasks.py
@@ -17,7 +17,7 @@
 from readthedocs.config.config import BuildConfigV2
 from readthedocs.config.exceptions import ConfigError
 from readthedocs.config.tests.test_config import get_build_config
-from readthedocs.doc_builder.exceptions import BuildUserError
+from readthedocs.doc_builder.exceptions import BuildCancelled, BuildUserError
 from readthedocs.projects.exceptions import RepositoryError
 from readthedocs.projects.models import EnvironmentVariable, Project, WebHookEvent
 from readthedocs.projects.tasks.builds import sync_repository_task, update_docs_task
@@ -695,6 +695,55 @@ def test_failed_build(
         assert revoke_key_request._request.method == "POST"
         assert revoke_key_request.path == "/api/v2/revoke/"
 
+    @mock.patch("readthedocs.projects.tasks.builds.send_external_build_status")
+    @mock.patch("readthedocs.projects.tasks.builds.UpdateDocsTask.execute")
+    def test_cancelled_build(
+        self,
+        execute,
+        send_external_build_status,
+    ):
+        # Force an exception from the execution of the task. We don't really
+        # care "where" it was raised: setup, build, syncing directories, etc
+        execute.side_effect = BuildCancelled(
+            message_id=BuildCancelled.CANCELLED_BY_USER
+        )
+
+        self._trigger_update_docs_task()
+
+        send_external_build_status.assert_called_once_with(
+            version_type=self.version.type,
+            build_pk=self.build.pk,
+            commit=self.build.commit,
+            status=BUILD_STATUS_FAILURE,
+        )
+
+        notification_request = self.requests_mock.request_history[-3]
+        assert notification_request._request.method == "POST"
+        assert notification_request.path == "/api/v2/notifications/"
+        assert notification_request.json() == {
+            "attached_to": f"build/{self.build.pk}",
+            "message_id": BuildCancelled.CANCELLED_BY_USER,
+            "state": "unread",
+            "dismissable": False,
+            "news": False,
+            "format_values": {},
+        }
+
+        # Test we are updating the DB by calling the API with the updated build object
+        # The second last one should be the PATCH for the build
+        build_status_request = self.requests_mock.request_history[-2]
+        assert build_status_request._request.method == "PATCH"
+        assert build_status_request.path == "/api/v2/build/1/"
+        assert build_status_request.json() == {
+            "builder": mock.ANY,
+            "commit": self.build.commit,
+            "error": "",  # We are not sending ``error`` anymore
+            "id": self.build.pk,
+            "length": mock.ANY,
+            "state": "cancelled",
+            "success": False,
+        }
+
     @mock.patch("readthedocs.doc_builder.director.load_yaml_config")
     def test_build_commands_executed(
         self,