diff --git a/src/sentry/preprod/api/endpoints/project_preprod_artifact_update.py b/src/sentry/preprod/api/endpoints/project_preprod_artifact_update.py index c3b1443a88ff..ab72f1200764 100644 --- a/src/sentry/preprod/api/endpoints/project_preprod_artifact_update.py +++ b/src/sentry/preprod/api/endpoints/project_preprod_artifact_update.py @@ -6,6 +6,7 @@ import jsonschema import orjson +from django.utils import timezone from rest_framework.request import Request from rest_framework.response import Response @@ -415,6 +416,24 @@ def put( can_run_size, size_skip_reason = should_run_size(head_artifact) if can_run_size: + preprocessing_succeeded = head_artifact.state == PreprodArtifact.ArtifactState.PROCESSED + if preprocessing_succeeded: + # Launchpad will run size analysis when it receives this response, + # so mark the main metric as PROCESSING. + PreprodArtifactSizeMetrics.objects.filter( + preprod_artifact=head_artifact, + metrics_artifact_type=PreprodArtifactSizeMetrics.MetricsArtifactType.MAIN_ARTIFACT, + state__in=[ + PreprodArtifactSizeMetrics.SizeAnalysisState.PENDING, + PreprodArtifactSizeMetrics.SizeAnalysisState.PROCESSING, + PreprodArtifactSizeMetrics.SizeAnalysisState.FAILED, + ], + ).update( + state=PreprodArtifactSizeMetrics.SizeAnalysisState.PROCESSING, + error_code=None, + error_message=None, + date_updated=timezone.now(), + ) requested_features.append(PreprodFeature.SIZE_ANALYSIS) else: # Update size metrics record to NOT_RAN with appropriate error code diff --git a/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_update.py b/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_update.py index 74337abf3662..a0754963ed22 100644 --- a/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_update.py +++ b/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_update.py @@ -1,8 +1,10 @@ +from datetime import timedelta from typing import Any, cast from unittest.mock import MagicMock, patch import orjson from django.test import override_settings +from django.utils import timezone from sentry.preprod.api.endpoints.project_preprod_artifact_update import find_or_create_release from sentry.preprod.models import ( @@ -567,6 +569,97 @@ def test_update_preprod_artifact_build_number_raw_merges_into_existing_extras(se "build_number_raw": "1.2.3", } + @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=["test-secret-key"]) + def test_preprocessing_starts_size_analysis_and_refreshes_retries(self) -> None: + metrics = self.create_preprod_artifact_size_metrics(self.preprod_artifact) + component = self.create_preprod_artifact_size_metrics( + self.preprod_artifact, + metrics_type=PreprodArtifactSizeMetrics.MetricsArtifactType.WATCH_ARTIFACT, + identifier="com.example.watch", + state=PreprodArtifactSizeMetrics.SizeAnalysisState.PENDING, + ) + for state in [ + PreprodArtifactSizeMetrics.SizeAnalysisState.PENDING, + PreprodArtifactSizeMetrics.SizeAnalysisState.PROCESSING, + PreprodArtifactSizeMetrics.SizeAnalysisState.FAILED, + ]: + with self.subTest(state=state): + old_time = timezone.now() - timedelta(minutes=35) + PreprodArtifactSizeMetrics.objects.filter(id=metrics.id).update( + state=state, + date_updated=old_time, + error_code=PreprodArtifactSizeMetrics.ErrorCode.TIMEOUT, + error_message="Previous attempt timed out", + ) + + attempt_started = timezone.now() + response = self._make_request({"artifact_type": 1}) + + assert response.status_code == 200 + assert "size_analysis" in response.json()["requestedFeatures"] + metrics.refresh_from_db() + assert metrics.state == PreprodArtifactSizeMetrics.SizeAnalysisState.PROCESSING + assert metrics.date_updated >= attempt_started + assert metrics.error_code is None + assert metrics.error_message is None + component.refresh_from_db() + assert component.state == PreprodArtifactSizeMetrics.SizeAnalysisState.PENDING + + @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=["test-secret-key"]) + def test_preprocessing_preserves_finished_size_analysis(self) -> None: + metrics = self.create_preprod_artifact_size_metrics(self.preprod_artifact) + for state in [ + PreprodArtifactSizeMetrics.SizeAnalysisState.COMPLETED, + PreprodArtifactSizeMetrics.SizeAnalysisState.NOT_RAN, + ]: + with self.subTest(state=state): + old_time = timezone.now() - timedelta(minutes=35) + PreprodArtifactSizeMetrics.objects.filter(id=metrics.id).update( + state=state, date_updated=old_time + ) + + response = self._make_request({"artifact_type": 1}) + + assert response.status_code == 200 + metrics.refresh_from_db() + assert metrics.state == state + assert metrics.date_updated == old_time + + @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=["test-secret-key"]) + def test_artifact_error_reports_do_not_restart_size_analysis(self) -> None: + metrics = self.create_preprod_artifact_size_metrics( + self.preprod_artifact, state=PreprodArtifactSizeMetrics.SizeAnalysisState.FAILED + ) + original_updated = metrics.date_updated + for data in [ + {"error_code": 1}, + {"error_message": "Preprocessing failed"}, + ]: + with self.subTest(data=data): + self.preprod_artifact.update(state=PreprodArtifact.ArtifactState.PROCESSED) + response = self._make_request(data) + + assert response.status_code == 200 + metrics.refresh_from_db() + assert metrics.state == PreprodArtifactSizeMetrics.SizeAnalysisState.FAILED + assert metrics.date_updated == original_updated + + @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=["test-secret-key"]) + def test_preprocessing_does_not_restart_analysis_for_failed_artifact(self) -> None: + self.preprod_artifact.update(state=PreprodArtifact.ArtifactState.FAILED) + metrics = self.create_preprod_artifact_size_metrics( + self.preprod_artifact, state=PreprodArtifactSizeMetrics.SizeAnalysisState.FAILED + ) + original_updated = metrics.date_updated + + response = self._make_request({"artifact_type": 1}) + + assert response.status_code == 200 + assert "size_analysis" in response.json()["requestedFeatures"] + metrics.refresh_from_db() + assert metrics.state == PreprodArtifactSizeMetrics.SizeAnalysisState.FAILED + assert metrics.date_updated == original_updated + @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=["test-secret-key"]) def test_update_preprod_artifact_returns_requested_features(self) -> None: """Test that the response includes requestedFeatures with both features by default.""" @@ -683,6 +776,9 @@ def test_update_preprod_artifact_filters_size_when_no_quota( ) -> None: """Test that SIZE_ANALYSIS is filtered out when organization has no quota.""" mock_has_size_quota.return_value = False + metrics = self.create_preprod_artifact_size_metrics( + self.preprod_artifact, state=PreprodArtifactSizeMetrics.SizeAnalysisState.PENDING + ) data = {"artifact_type": 1} response = self._make_request(data) @@ -692,6 +788,9 @@ def test_update_preprod_artifact_filters_size_when_no_quota( assert "requestedFeatures" in resp_data assert "size_analysis" not in resp_data["requestedFeatures"] assert "build_distribution" in resp_data["requestedFeatures"] + metrics.refresh_from_db() + assert metrics.state == PreprodArtifactSizeMetrics.SizeAnalysisState.NOT_RAN + assert metrics.error_code == PreprodArtifactSizeMetrics.ErrorCode.NO_QUOTA @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=["test-secret-key"]) @patch("sentry.preprod.quotas.has_installable_quota")