From 6d5265de5d3dabaaa83ce03a0c21a211b00f9d00 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 07:39:24 +0000 Subject: [PATCH 1/6] builds: track queued time separately from build duration The build duration (`length`) is measured from when the task actually starts running on a builder, so time spent waiting in the queue no longer inflates it. Expose the queued time via a new `Build.queue_time` property and a `queued` field in the v3 API, and base the API `finished` timestamp on the execution start (`task_executed_at`) instead of the trigger time. --- readthedocs/api/v3/serializers.py | 12 +++++- .../responses/projects-builds-detail.json | 1 + .../tests/responses/projects-builds-list.json | 1 + .../projects-versions-builds-list_POST.json | 1 + readthedocs/builds/models.py | 17 ++++++++ readthedocs/builds/tests/test_models.py | 40 +++++++++++++++++++ readthedocs/projects/tasks/builds.py | 3 ++ 7 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 readthedocs/builds/tests/test_models.py diff --git a/readthedocs/api/v3/serializers.py b/readthedocs/api/v3/serializers.py index 69c98472f3e..71db1d06342 100644 --- a/readthedocs/api/v3/serializers.py +++ b/readthedocs/api/v3/serializers.py @@ -173,6 +173,7 @@ class BuildSerializer(FlexFieldsModelSerializer): finished = serializers.SerializerMethodField() success = serializers.SerializerMethodField() duration = serializers.IntegerField(source="length") + queued = serializers.IntegerField(source="queue_time") state = BuildStateSerializer(source="*") _links = BuildLinksSerializer(source="*") urls = BuildURLsSerializer(source="*") @@ -189,6 +190,7 @@ class Meta: "created", "finished", "duration", + "queued", "state", "success", "error", @@ -203,8 +205,14 @@ def get_error(self, obj): return "" def get_finished(self, obj): - if obj.date and obj.length: - return obj.date + datetime.timedelta(seconds=obj.length) + if obj.length: + # ``length`` is the build duration, measured from when the build + # actually started running (``task_executed_at``), so it excludes + # the time the build spent queued. Fall back to ``date`` for builds + # created before ``task_executed_at`` was tracked. + started = obj.task_executed_at or obj.date + if started: + return started + datetime.timedelta(seconds=obj.length) def get_success(self, obj): """ diff --git a/readthedocs/api/v3/tests/responses/projects-builds-detail.json b/readthedocs/api/v3/tests/responses/projects-builds-detail.json index 996bcef6e61..db489a5d53c 100644 --- a/readthedocs/api/v3/tests/responses/projects-builds-detail.json +++ b/readthedocs/api/v3/tests/responses/projects-builds-detail.json @@ -2,6 +2,7 @@ "commit": "a1b2c3", "created": "2019-04-29T10:00:00Z", "duration": 60, + "queued": null, "error": "", "finished": "2019-04-29T10:01:00Z", "id": 1, diff --git a/readthedocs/api/v3/tests/responses/projects-builds-list.json b/readthedocs/api/v3/tests/responses/projects-builds-list.json index df60e9912fe..99e54c115bc 100644 --- a/readthedocs/api/v3/tests/responses/projects-builds-list.json +++ b/readthedocs/api/v3/tests/responses/projects-builds-list.json @@ -7,6 +7,7 @@ "commit": "a1b2c3", "created": "2019-04-29T10:00:00Z", "duration": 60, + "queued": null, "error": "", "finished": "2019-04-29T10:01:00Z", "id": 1, diff --git a/readthedocs/api/v3/tests/responses/projects-versions-builds-list_POST.json b/readthedocs/api/v3/tests/responses/projects-versions-builds-list_POST.json index fbda2e3a255..3c59094649b 100644 --- a/readthedocs/api/v3/tests/responses/projects-versions-builds-list_POST.json +++ b/readthedocs/api/v3/tests/responses/projects-versions-builds-list_POST.json @@ -3,6 +3,7 @@ "commit": null, "created": "2019-04-29T14:00:00Z", "duration": null, + "queued": null, "error": "", "finished": null, "id": 3, diff --git a/readthedocs/builds/models.py b/readthedocs/builds/models.py index 219bf68fdeb..d2055e35226 100644 --- a/readthedocs/builds/models.py +++ b/readthedocs/builds/models.py @@ -1061,6 +1061,23 @@ def finished(self): """Return if build has an end state.""" return self.state in BUILD_FINAL_STATES + @property + def queue_time(self): + """ + Time the build spent queued before it started running, in seconds. + + This is the time between when the build was triggered (``date``) and + when the build task actually started running on a builder + (``task_executed_at``). + + It is tracked separately from ``length`` (the build duration) so that + time spent waiting in the queue does not show up as part of the build + duration. + """ + if self.task_executed_at: + return int((self.task_executed_at - self.date).total_seconds()) + return None + @property def is_stale(self): """Return if build state is triggered & date more than 5m ago.""" diff --git a/readthedocs/builds/tests/test_models.py b/readthedocs/builds/tests/test_models.py new file mode 100644 index 00000000000..cbcd1613160 --- /dev/null +++ b/readthedocs/builds/tests/test_models.py @@ -0,0 +1,40 @@ +import datetime + +from django.test import TestCase +from django_dynamic_fixture import get + +from readthedocs.builds.models import Build +from readthedocs.builds.models import Version +from readthedocs.projects.models import Project + + +class BuildQueueTimeTests(TestCase): + def setUp(self): + self.project = get(Project) + self.version = get(Version, project=self.project) + + def test_queue_time_is_none_when_task_not_executed(self): + build = get( + Build, + project=self.project, + version=self.version, + task_executed_at=None, + ) + assert build.queue_time is None + + def test_queue_time_is_time_between_trigger_and_execution(self): + build = get(Build, project=self.project, version=self.version) + build.task_executed_at = build.date + datetime.timedelta(seconds=120) + assert build.queue_time == 120 + + def test_queue_time_is_independent_from_duration(self): + # Time spent queued should not be counted as part of the build duration. + build = get( + Build, + project=self.project, + version=self.version, + length=42, + ) + build.task_executed_at = build.date + datetime.timedelta(seconds=300) + assert build.queue_time == 300 + assert build.length == 42 diff --git a/readthedocs/projects/tasks/builds.py b/readthedocs/projects/tasks/builds.py index 30793ed5606..776c54f0fd5 100644 --- a/readthedocs/projects/tasks/builds.py +++ b/readthedocs/projects/tasks/builds.py @@ -389,6 +389,9 @@ def before_start(self, task_id, args, kwargs): structlog.contextvars.bind_contextvars(build_id=self.data.build_pk) log.info("Running task.", name=self.name) + # Track when the build task starts running on the builder. This is used + # to compute the build duration (``length``), so it does not include the + # time the build spent waiting in the queue (see ``Build.queue_time``). self.data.start_time = timezone.now() self.data.environment_class = DockerBuildEnvironment if not settings.DOCKER_ENABLE: From b668437494cb9e596d9f8aec4e671c90cb9477d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 05:07:12 +0000 Subject: [PATCH 2/6] api: name build queue field queue_time for clarity Rename the v3 API field from `queued` to `queue_time` so it reads unambiguously as a duration in seconds rather than a build status/state, matching the `Build.queue_time` property. Also make the docstring state the unit (seconds) explicitly. --- readthedocs/api/v3/serializers.py | 6 ++++-- .../api/v3/tests/responses/projects-builds-detail.json | 2 +- .../api/v3/tests/responses/projects-builds-list.json | 2 +- .../responses/projects-versions-builds-list_POST.json | 2 +- readthedocs/builds/models.py | 8 ++++---- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/readthedocs/api/v3/serializers.py b/readthedocs/api/v3/serializers.py index 71db1d06342..8c86ef30beb 100644 --- a/readthedocs/api/v3/serializers.py +++ b/readthedocs/api/v3/serializers.py @@ -173,7 +173,9 @@ class BuildSerializer(FlexFieldsModelSerializer): finished = serializers.SerializerMethodField() success = serializers.SerializerMethodField() duration = serializers.IntegerField(source="length") - queued = serializers.IntegerField(source="queue_time") + # Time the build spent queued before it started running, in seconds. + # Named explicitly so it's not confused with a build status/state. + queue_time = serializers.IntegerField() state = BuildStateSerializer(source="*") _links = BuildLinksSerializer(source="*") urls = BuildURLsSerializer(source="*") @@ -190,7 +192,7 @@ class Meta: "created", "finished", "duration", - "queued", + "queue_time", "state", "success", "error", diff --git a/readthedocs/api/v3/tests/responses/projects-builds-detail.json b/readthedocs/api/v3/tests/responses/projects-builds-detail.json index db489a5d53c..f749cee844b 100644 --- a/readthedocs/api/v3/tests/responses/projects-builds-detail.json +++ b/readthedocs/api/v3/tests/responses/projects-builds-detail.json @@ -2,7 +2,7 @@ "commit": "a1b2c3", "created": "2019-04-29T10:00:00Z", "duration": 60, - "queued": null, + "queue_time": null, "error": "", "finished": "2019-04-29T10:01:00Z", "id": 1, diff --git a/readthedocs/api/v3/tests/responses/projects-builds-list.json b/readthedocs/api/v3/tests/responses/projects-builds-list.json index 99e54c115bc..dd4dbb85b95 100644 --- a/readthedocs/api/v3/tests/responses/projects-builds-list.json +++ b/readthedocs/api/v3/tests/responses/projects-builds-list.json @@ -7,7 +7,7 @@ "commit": "a1b2c3", "created": "2019-04-29T10:00:00Z", "duration": 60, - "queued": null, + "queue_time": null, "error": "", "finished": "2019-04-29T10:01:00Z", "id": 1, diff --git a/readthedocs/api/v3/tests/responses/projects-versions-builds-list_POST.json b/readthedocs/api/v3/tests/responses/projects-versions-builds-list_POST.json index 3c59094649b..5a88021aa29 100644 --- a/readthedocs/api/v3/tests/responses/projects-versions-builds-list_POST.json +++ b/readthedocs/api/v3/tests/responses/projects-versions-builds-list_POST.json @@ -3,7 +3,7 @@ "commit": null, "created": "2019-04-29T14:00:00Z", "duration": null, - "queued": null, + "queue_time": null, "error": "", "finished": null, "id": 3, diff --git a/readthedocs/builds/models.py b/readthedocs/builds/models.py index d2055e35226..c5c9d475112 100644 --- a/readthedocs/builds/models.py +++ b/readthedocs/builds/models.py @@ -1064,15 +1064,15 @@ def finished(self): @property def queue_time(self): """ - Time the build spent queued before it started running, in seconds. + Number of seconds the build spent queued before it started running. This is the time between when the build was triggered (``date``) and when the build task actually started running on a builder (``task_executed_at``). - It is tracked separately from ``length`` (the build duration) so that - time spent waiting in the queue does not show up as part of the build - duration. + It is the *queue wait time*, not the build duration: it is tracked + separately from ``length`` (the build duration) so that time spent + waiting in the queue does not show up as part of the build duration. """ if self.task_executed_at: return int((self.task_executed_at - self.date).total_seconds()) From be020edce48c94e8a5f83719d6d8290fa94c3383 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 05:11:50 +0000 Subject: [PATCH 3/6] api: simplify build finished computation and cover queue time Drop a redundant guard in the v3 `finished` computation (``date`` is always set, so the fallback is never empty). Add an API test asserting queue time is reported separately and that ``finished`` is based on the execution start, not the trigger time. --- readthedocs/api/v3/serializers.py | 3 +-- readthedocs/api/v3/tests/test_builds.py | 26 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/readthedocs/api/v3/serializers.py b/readthedocs/api/v3/serializers.py index 8c86ef30beb..e973699c7c1 100644 --- a/readthedocs/api/v3/serializers.py +++ b/readthedocs/api/v3/serializers.py @@ -213,8 +213,7 @@ def get_finished(self, obj): # the time the build spent queued. Fall back to ``date`` for builds # created before ``task_executed_at`` was tracked. started = obj.task_executed_at or obj.date - if started: - return started + datetime.timedelta(seconds=obj.length) + return started + datetime.timedelta(seconds=obj.length) def get_success(self, obj): """ diff --git a/readthedocs/api/v3/tests/test_builds.py b/readthedocs/api/v3/tests/test_builds.py index 37d451133fb..fe62d7aa576 100644 --- a/readthedocs/api/v3/tests/test_builds.py +++ b/readthedocs/api/v3/tests/test_builds.py @@ -1,3 +1,4 @@ +import datetime from unittest import mock from django.test import override_settings @@ -239,6 +240,31 @@ def test_projects_builds_detail(self): self.assertEqual(response.status_code, 200) self.assertDictEqual(response.json(), expected_response) + def test_projects_builds_detail_queue_time(self): + # The build was queued for 30s before it started running, then took + # ``length`` (60s) to build. + self.build.task_executed_at = self.created + datetime.timedelta(seconds=30) + self.build.save() + + url = reverse( + "projects-builds-detail", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + "build_pk": self.build.pk, + }, + ) + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + data = response.json() + # Queue time is reported separately and does not inflate the duration. + self.assertEqual(data["queue_time"], 30) + self.assertEqual(data["duration"], 60) + # ``finished`` is execution start (task_executed_at) + duration, so the + # queue wait is not counted towards it. + self.assertEqual(data["finished"], "2019-04-29T10:01:30Z") + def test_projects_builds_detail_other_user(self): url = reverse( "projects-builds-detail", From 29b1b0314803bc730a4223522a9d3e325801ac37 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 09:20:09 +0000 Subject: [PATCH 4/6] builds: store queue time as a field measured from original trigger Replace the computed `Build.queue_time` property with a stored `IntegerField` so the total queue wait is queryable for historical builds and cheap to aggregate. It's set in `before_start` from the original trigger date (`date`) to when the task starts running, so it captures the full wait even across retries (`date` is never reset). Add a migration and update the v2 build payload mock to include `date`. --- readthedocs/api/v3/serializers.py | 4 +- readthedocs/api/v3/tests/test_builds.py | 1 + .../migrations/0074_build_queue_time.py | 21 ++++++++ readthedocs/builds/models.py | 24 +++------ readthedocs/builds/tests/test_models.py | 31 +++++------- readthedocs/projects/tasks/builds.py | 11 ++++- readthedocs/projects/tests/mockers.py | 1 + .../projects/tests/test_build_tasks.py | 49 +++++++++++++++++++ 8 files changed, 102 insertions(+), 40 deletions(-) create mode 100644 readthedocs/builds/migrations/0074_build_queue_time.py diff --git a/readthedocs/api/v3/serializers.py b/readthedocs/api/v3/serializers.py index e973699c7c1..966d13d980d 100644 --- a/readthedocs/api/v3/serializers.py +++ b/readthedocs/api/v3/serializers.py @@ -173,8 +173,8 @@ class BuildSerializer(FlexFieldsModelSerializer): finished = serializers.SerializerMethodField() success = serializers.SerializerMethodField() duration = serializers.IntegerField(source="length") - # Time the build spent queued before it started running, in seconds. - # Named explicitly so it's not confused with a build status/state. + # Seconds the build spent queued before it started running. Declared + # explicitly so the name reads as a duration, not a build status/state. queue_time = serializers.IntegerField() state = BuildStateSerializer(source="*") _links = BuildLinksSerializer(source="*") diff --git a/readthedocs/api/v3/tests/test_builds.py b/readthedocs/api/v3/tests/test_builds.py index fe62d7aa576..a96ddd3c588 100644 --- a/readthedocs/api/v3/tests/test_builds.py +++ b/readthedocs/api/v3/tests/test_builds.py @@ -243,6 +243,7 @@ def test_projects_builds_detail(self): def test_projects_builds_detail_queue_time(self): # The build was queued for 30s before it started running, then took # ``length`` (60s) to build. + self.build.queue_time = 30 self.build.task_executed_at = self.created + datetime.timedelta(seconds=30) self.build.save() diff --git a/readthedocs/builds/migrations/0074_build_queue_time.py b/readthedocs/builds/migrations/0074_build_queue_time.py new file mode 100644 index 00000000000..fa08d9670b2 --- /dev/null +++ b/readthedocs/builds/migrations/0074_build_queue_time.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.13 on 2026-06-01 09:11 + +from django.db import migrations +from django.db import models +from django_safemigrate import Safe + + +class Migration(migrations.Migration): + safe = Safe.before_deploy() + + dependencies = [ + ("builds", "0073_remove_deprecated_build_fields"), + ] + + operations = [ + migrations.AddField( + model_name="build", + name="queue_time", + field=models.IntegerField(blank=True, null=True, verbose_name="Queue time"), + ), + ] diff --git a/readthedocs/builds/models.py b/readthedocs/builds/models.py index c5c9d475112..473b4af33a2 100644 --- a/readthedocs/builds/models.py +++ b/readthedocs/builds/models.py @@ -792,6 +792,13 @@ class Build(models.Model): length = models.IntegerField(_("Build Length"), null=True, blank=True) + # Number of seconds the build spent queued before it started running, + # measured from when the build was originally triggered (``date``) to when + # the task started running on a builder (``task_executed_at``). Stored + # separately from ``length`` so queue wait time does not inflate the build + # duration. Null for builds triggered before this was tracked. + queue_time = models.IntegerField(_("Queue time"), null=True, blank=True) + builder = models.CharField( _("Builder"), max_length=255, @@ -1061,23 +1068,6 @@ def finished(self): """Return if build has an end state.""" return self.state in BUILD_FINAL_STATES - @property - def queue_time(self): - """ - Number of seconds the build spent queued before it started running. - - This is the time between when the build was triggered (``date``) and - when the build task actually started running on a builder - (``task_executed_at``). - - It is the *queue wait time*, not the build duration: it is tracked - separately from ``length`` (the build duration) so that time spent - waiting in the queue does not show up as part of the build duration. - """ - if self.task_executed_at: - return int((self.task_executed_at - self.date).total_seconds()) - return None - @property def is_stale(self): """Return if build state is triggered & date more than 5m ago.""" diff --git a/readthedocs/builds/tests/test_models.py b/readthedocs/builds/tests/test_models.py index cbcd1613160..9cd6bcc627d 100644 --- a/readthedocs/builds/tests/test_models.py +++ b/readthedocs/builds/tests/test_models.py @@ -1,5 +1,3 @@ -import datetime - from django.test import TestCase from django_dynamic_fixture import get @@ -13,28 +11,21 @@ def setUp(self): self.project = get(Project) self.version = get(Version, project=self.project) - def test_queue_time_is_none_when_task_not_executed(self): - build = get( - Build, - project=self.project, - version=self.version, - task_executed_at=None, - ) + def test_queue_time_defaults_to_none(self): + # Builds triggered before queue time was tracked have no value. + build = get(Build, project=self.project, version=self.version, queue_time=None) assert build.queue_time is None - def test_queue_time_is_time_between_trigger_and_execution(self): - build = get(Build, project=self.project, version=self.version) - build.task_executed_at = build.date + datetime.timedelta(seconds=120) - assert build.queue_time == 120 - - def test_queue_time_is_independent_from_duration(self): - # Time spent queued should not be counted as part of the build duration. + def test_queue_time_is_stored_independently_from_duration(self): + # Queue wait time is stored separately so it does not inflate the + # build duration (``length``). build = get( Build, project=self.project, version=self.version, - length=42, + queue_time=30, + length=60, ) - build.task_executed_at = build.date + datetime.timedelta(seconds=300) - assert build.queue_time == 300 - assert build.length == 42 + build.refresh_from_db() + assert build.queue_time == 30 + assert build.length == 60 diff --git a/readthedocs/projects/tasks/builds.py b/readthedocs/projects/tasks/builds.py index 776c54f0fd5..a46d9646a16 100644 --- a/readthedocs/projects/tasks/builds.py +++ b/readthedocs/projects/tasks/builds.py @@ -435,7 +435,16 @@ def before_start(self, task_id, args, kwargs): ) # Save when the task was executed by a builder - self.data.build["task_executed_at"] = timezone.now() + task_executed_at = timezone.now() + self.data.build["task_executed_at"] = task_executed_at + + # Store the total time the build spent queued, measured from when it was + # originally triggered (``date``) to now. ``date`` is set once at + # creation and is not reset on retries, so this captures the full wait + # even across retries. It's stored separately from ``length`` so queue + # time does not inflate the build duration. + triggered_at = datetime.datetime.fromisoformat(self.data.build["date"]) + self.data.build["queue_time"] = int((task_executed_at - triggered_at).total_seconds()) # Enable scale-in protection on this instance # diff --git a/readthedocs/projects/tests/mockers.py b/readthedocs/projects/tests/mockers.py index da83061c40c..edc37298f3f 100644 --- a/readthedocs/projects/tests/mockers.py +++ b/readthedocs/projects/tests/mockers.py @@ -186,6 +186,7 @@ def _mock_api(self): "id": self.build.pk, "state": BUILD_STATE_TRIGGERED, "commit": self.build.commit, + "date": self.build.date.isoformat(), "task_executed_at": self.build.task_executed_at, }, headers=headers, diff --git a/readthedocs/projects/tests/test_build_tasks.py b/readthedocs/projects/tests/test_build_tasks.py index 7350324b064..435e5a1ac67 100644 --- a/readthedocs/projects/tests/test_build_tasks.py +++ b/readthedocs/projects/tests/test_build_tasks.py @@ -1,3 +1,4 @@ +import datetime import os import pathlib import textwrap @@ -10,6 +11,7 @@ import pytest from django.conf import settings from django.test.utils import override_settings +from django.utils import timezone from readthedocs.allauth.providers.githubapp.provider import GitHubAppProvider from readthedocs.builds.constants import ( @@ -556,6 +558,8 @@ def test_successful_build( "commit": "a1b2c3", "builder": mock.ANY, "task_executed_at": mock.ANY, + "queue_time": mock.ANY, + "date": mock.ANY, } # Update build state: installing @@ -565,6 +569,8 @@ def test_successful_build( "commit": "a1b2c3", "builder": mock.ANY, "task_executed_at": mock.ANY, + "queue_time": mock.ANY, + "date": mock.ANY, "readthedocs_yaml_path": None, # We update the `config` field at the same time we send the # `installing` state, to reduce one API call @@ -646,6 +652,8 @@ def test_successful_build( "config": mock.ANY, "builder": mock.ANY, "task_executed_at": mock.ANY, + "queue_time": mock.ANY, + "date": mock.ANY, } # Update build state: uploading assert self.requests_mock.request_history[8].json() == { @@ -656,6 +664,8 @@ def test_successful_build( "config": mock.ANY, "builder": mock.ANY, "task_executed_at": mock.ANY, + "queue_time": mock.ANY, + "date": mock.ANY, } # Get temporary credentials @@ -692,6 +702,8 @@ def test_successful_build( "config": mock.ANY, "builder": mock.ANY, "task_executed_at": mock.ANY, + "queue_time": mock.ANY, + "date": mock.ANY, "length": mock.ANY, "success": True, } @@ -711,6 +723,37 @@ def test_successful_build( ] ) + @mock.patch("readthedocs.projects.tasks.builds.build_complete") + @mock.patch("readthedocs.projects.tasks.builds.send_external_build_status") + @mock.patch("readthedocs.projects.tasks.builds.UpdateDocsTask.execute") + @mock.patch("readthedocs.projects.tasks.builds.UpdateDocsTask.send_notifications") + @mock.patch("readthedocs.projects.tasks.builds.clean_build") + def test_build_stores_queue_time( + self, + clean_build, + send_notifications, + execute, + send_external_build_status, + build_complete, + ): + # The build was triggered a while ago and is only now picked up by a + # builder. The queued time is measured from the original trigger date. + self.build.date = timezone.now() - datetime.timedelta(seconds=45) + self.build.save() + + # The queue time is computed in ``before_start``, so the actual build + # outcome doesn't matter; force a clean stop to reach the final PATCH. + execute.side_effect = BuildUserError(message_id=BuildUserError.GENERIC) + + self._trigger_update_docs_task() + + # The last PATCH to the build stores the queued time, measured from the + # original trigger date to when the task started running. + 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()["queue_time"] >= 45 + @mock.patch("readthedocs.projects.tasks.builds.build_complete") @mock.patch("readthedocs.projects.tasks.builds.send_external_build_status") @mock.patch("readthedocs.projects.tasks.builds.UpdateDocsTask.execute") @@ -779,6 +822,8 @@ def test_failed_build( assert build_status_request.json() == { "builder": mock.ANY, "task_executed_at": mock.ANY, + "queue_time": mock.ANY, + "date": mock.ANY, "commit": self.build.commit, "id": self.build.pk, "length": mock.ANY, @@ -833,6 +878,8 @@ def test_cancelled_build( assert build_status_request.json() == { "builder": mock.ANY, "task_executed_at": mock.ANY, + "queue_time": mock.ANY, + "date": mock.ANY, "commit": self.build.commit, "id": self.build.pk, "length": mock.ANY, @@ -2986,6 +3033,8 @@ def test_config_file_exception(self, load_yaml_config): "success": False, "builder": mock.ANY, "task_executed_at": mock.ANY, + "queue_time": mock.ANY, + "date": mock.ANY, "length": 0, } From 9a96126bcb2d48a215bd94361671a7fc0af72629 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 09:40:05 +0000 Subject: [PATCH 5/6] api: auto-generate queue_time serializer field `queue_time` is now a real model field, so the explicit IntegerField declaration is redundant; ModelSerializer generates it from Meta.fields. --- readthedocs/api/v3/serializers.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/readthedocs/api/v3/serializers.py b/readthedocs/api/v3/serializers.py index 966d13d980d..514262630ca 100644 --- a/readthedocs/api/v3/serializers.py +++ b/readthedocs/api/v3/serializers.py @@ -173,9 +173,6 @@ class BuildSerializer(FlexFieldsModelSerializer): finished = serializers.SerializerMethodField() success = serializers.SerializerMethodField() duration = serializers.IntegerField(source="length") - # Seconds the build spent queued before it started running. Declared - # explicitly so the name reads as a duration, not a build status/state. - queue_time = serializers.IntegerField() state = BuildStateSerializer(source="*") _links = BuildLinksSerializer(source="*") urls = BuildURLsSerializer(source="*") From d1128c58fd3b92e56bee42db07d700676e0009f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 09:58:23 +0000 Subject: [PATCH 6/6] proxito: add queue_time to hosting API response fixture The addons hosting (v1) response embeds the build serializer, which now includes the `queue_time` field. Update the expected-response fixture. --- readthedocs/proxito/tests/responses/v1.json | 1 + 1 file changed, 1 insertion(+) diff --git a/readthedocs/proxito/tests/responses/v1.json b/readthedocs/proxito/tests/responses/v1.json index fee8417131a..325827dcfe0 100644 --- a/readthedocs/proxito/tests/responses/v1.json +++ b/readthedocs/proxito/tests/responses/v1.json @@ -94,6 +94,7 @@ "finished": "2019-04-29T10:01:00Z", "id": 1, "project": "project", + "queue_time": null, "state": { "code": "finished", "name": "Finished"