From f26b43438d78cbd2ea3569926adbbf8c5d1c49ea Mon Sep 17 00:00:00 2001 From: Ryan Brooks Date: Wed, 8 Apr 2026 08:54:18 -0700 Subject: [PATCH 1/2] Run snapshot comparisons when uploads received out-of-order --- .../endpoints/preprod_artifact_snapshot.py | 51 ++++++++++- src/sentry/preprod/snapshots/utils.py | 35 ++++++++ .../test_preprod_artifact_snapshot.py | 84 ++++++++++++++++++- 3 files changed, 168 insertions(+), 2 deletions(-) diff --git a/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py b/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py index c5703b8cc59641..6b3235c96b3c29 100644 --- a/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py +++ b/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py @@ -54,7 +54,10 @@ PreprodSnapshotMetrics, ) from sentry.preprod.snapshots.tasks import compare_snapshots -from sentry.preprod.snapshots.utils import find_base_snapshot_artifact +from sentry.preprod.snapshots.utils import ( + find_base_snapshot_artifact, + find_head_snapshot_artifacts_awaiting_base, +) from sentry.preprod.url_utils import get_preprod_artifact_url from sentry.preprod.vcs.status_checks.snapshots.tasks import ( create_preprod_snapshot_status_check_task, @@ -639,6 +642,52 @@ def post(self, request: Request, project: Project) -> Response: }, ) + # Trigger comparisons for any head artifacts that were uploaded before this base. + # Handles possible out-of-order uploads where heads arrive before their base build. + if commit_comparison is not None: + try: + waiting_heads = find_head_snapshot_artifacts_awaiting_base( + organization_id=project.organization_id, + base_sha=commit_comparison.head_sha, + base_repo_name=commit_comparison.head_repo_name, + project_id=project.id, + app_id=artifact.app_id, + artifact_type=artifact.artifact_type, + build_configuration=artifact.build_configuration, + ) + for head_artifact in waiting_heads: + head_metrics = head_artifact.preprodsnapshotmetrics + logger.info( + "Found head artifact awaiting base, triggering snapshot comparison", + extra={ + "head_artifact_id": head_artifact.id, + "base_artifact_id": artifact.id, + "base_sha": commit_comparison.head_sha, + }, + ) + try: + PreprodSnapshotComparison.objects.get_or_create( + head_snapshot_metrics=head_metrics, + base_snapshot_metrics=snapshot_metrics, + defaults={"state": PreprodSnapshotComparison.State.PENDING}, + ) + except IntegrityError: + pass + + compare_snapshots.apply_async( + kwargs={ + "project_id": project.id, + "org_id": project.organization_id, + "head_artifact_id": head_artifact.id, + "base_artifact_id": artifact.id, + }, + ) + except Exception: + logger.exception( + "Failed to trigger comparisons for head artifacts awaiting base", + extra={"base_artifact_id": artifact.id}, + ) + return Response( { "artifactId": str(artifact.id), diff --git a/src/sentry/preprod/snapshots/utils.py b/src/sentry/preprod/snapshots/utils.py index 3163e0acb17075..4c74e3f955bdea 100644 --- a/src/sentry/preprod/snapshots/utils.py +++ b/src/sentry/preprod/snapshots/utils.py @@ -1,6 +1,7 @@ from __future__ import annotations from sentry.preprod.models import PreprodArtifact, PreprodBuildConfiguration +from sentry.preprod.snapshots.models import PreprodSnapshotComparison def find_base_snapshot_artifact( @@ -26,3 +27,37 @@ def find_base_snapshot_artifact( .order_by("-date_added") .first() ) + + +def find_head_snapshot_artifacts_awaiting_base( + organization_id: int, + base_sha: str, + base_repo_name: str, + project_id: int, + app_id: str | None, + artifact_type: str | None, + build_configuration: PreprodBuildConfiguration | None, +) -> list[PreprodArtifact]: + """Find head snapshot artifacts that were uploaded before their base was available. + + When a base artifact is uploaded, its commit_comparison.head_sha is the SHA that waiting + head artifacts have as their commit_comparison.base_sha. This finds those heads so + comparisons can be triggered retroactively. + """ + return list( + PreprodArtifact.objects.filter( + commit_comparison__organization_id=organization_id, + commit_comparison__base_sha=base_sha, + commit_comparison__base_repo_name=base_repo_name, + project_id=project_id, + preprodsnapshotmetrics__isnull=False, + app_id=app_id, + artifact_type=artifact_type, + build_configuration=build_configuration, + ) + .exclude( + preprodsnapshotmetrics__snapshot_comparisons_head_metrics__state=PreprodSnapshotComparison.State.SUCCESS, + ) + .select_related("preprodsnapshotmetrics") + .order_by("-date_added") + ) diff --git a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py index f2b71da9039464..81b909c4a9985b 100644 --- a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py +++ b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py @@ -5,7 +5,7 @@ from sentry.models.commitcomparison import CommitComparison from sentry.preprod.models import PreprodArtifact -from sentry.preprod.snapshots.models import PreprodSnapshotMetrics +from sentry.preprod.snapshots.models import PreprodSnapshotComparison, PreprodSnapshotMetrics from sentry.testutils.cases import APITestCase @@ -250,6 +250,88 @@ def test_snapshot_invalid_sha_format(self) -> None: assert response.status_code == 400 + @patch("sentry.preprod.api.endpoints.preprod_artifact_snapshot.get_preprod_session") + @patch("sentry.preprod.api.endpoints.preprod_artifact_snapshot.compare_snapshots") + def test_base_upload_triggers_comparison_for_waiting_head( + self, mock_compare_snapshots, mock_get_session + ) -> None: + """ + When a head snapshot is uploaded before its base, uploading the base should + retroactively trigger a comparison for the waiting head. + """ + head_sha = "a" * 40 + base_sha = "b" * 40 + repo_name = "owner/repo" + app_id = "com.example.app" + + # Simulate a head artifact that was uploaded before its base was available. + # It has a commit_comparison with base_sha pointing to the not-yet-uploaded base. + head_commit_comparison = CommitComparison.objects.create( + organization_id=self.org.id, + head_repo_name=repo_name, + head_sha=head_sha, + base_sha=base_sha, + provider="github", + head_ref="feature-branch", + base_repo_name=repo_name, + ) + head_artifact = PreprodArtifact.objects.create( + project=self.project, + state=PreprodArtifact.ArtifactState.UPLOADED, + app_id=app_id, + commit_comparison=head_commit_comparison, + ) + head_metrics = PreprodSnapshotMetrics.objects.create( + preprod_artifact=head_artifact, + image_count=1, + extras={ + "manifest_key": f"{self.org.id}/{self.project.id}/{head_artifact.id}/manifest.json" + }, + ) + + # No comparison exists yet — the base was missing when the head was uploaded. + assert not PreprodSnapshotComparison.objects.filter( + head_snapshot_metrics=head_metrics + ).exists() + + # Upload the base snapshot. Its head_sha matches the head artifact's base_sha. + url = self._get_create_url() + data = { + "app_id": app_id, + "head_sha": base_sha, + "provider": "github", + "head_repo_name": repo_name, + "head_ref": "main", + "images": { + "img1": {"display_name": "Screen 1", "width": 375, "height": 812}, + }, + } + + with self.feature("organizations:preprod-snapshots"): + response = self.client.post(url, data, format="json") + + assert response.status_code == 200 + + base_artifact = PreprodArtifact.objects.get(id=response.data["artifactId"]) + base_metrics = PreprodSnapshotMetrics.objects.get(preprod_artifact=base_artifact) + + # A pending comparison record should have been created linking head to base. + comparison = PreprodSnapshotComparison.objects.get( + head_snapshot_metrics=head_metrics, + base_snapshot_metrics=base_metrics, + ) + assert comparison.state == PreprodSnapshotComparison.State.PENDING + + # The comparison task should have been queued for the waiting head. + mock_compare_snapshots.apply_async.assert_called_once_with( + kwargs={ + "project_id": self.project.id, + "org_id": self.org.id, + "head_artifact_id": head_artifact.id, + "base_artifact_id": base_artifact.id, + } + ) + class ProjectPreprodSnapshotGetTest(APITestCase): def setUp(self) -> None: From d2e185e2418d0a2fa75bf4c96833ab23689b48f7 Mon Sep 17 00:00:00 2001 From: Ryan Brooks Date: Wed, 8 Apr 2026 10:07:28 -0700 Subject: [PATCH 2/2] Remove type --- src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py | 1 - src/sentry/preprod/snapshots/utils.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py b/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py index 6b3235c96b3c29..78c8cc36c96e22 100644 --- a/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py +++ b/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py @@ -652,7 +652,6 @@ def post(self, request: Request, project: Project) -> Response: base_repo_name=commit_comparison.head_repo_name, project_id=project.id, app_id=artifact.app_id, - artifact_type=artifact.artifact_type, build_configuration=artifact.build_configuration, ) for head_artifact in waiting_heads: diff --git a/src/sentry/preprod/snapshots/utils.py b/src/sentry/preprod/snapshots/utils.py index 4c74e3f955bdea..707e714b841902 100644 --- a/src/sentry/preprod/snapshots/utils.py +++ b/src/sentry/preprod/snapshots/utils.py @@ -35,7 +35,6 @@ def find_head_snapshot_artifacts_awaiting_base( base_repo_name: str, project_id: int, app_id: str | None, - artifact_type: str | None, build_configuration: PreprodBuildConfiguration | None, ) -> list[PreprodArtifact]: """Find head snapshot artifacts that were uploaded before their base was available. @@ -52,7 +51,6 @@ def find_head_snapshot_artifacts_awaiting_base( project_id=project_id, preprodsnapshotmetrics__isnull=False, app_id=app_id, - artifact_type=artifact_type, build_configuration=build_configuration, ) .exclude(