Repository: getsentry/sentry
Pull request #91194: feat(uptime): Backfill dual fingerprints into grouphashes table.
Author: login: wedamija
Labels: name: Scope: Backend
description: Automatically applied to PRs that change backend components
name: Trigger: Revert
description: Add to a merged PR to revert it (skips CI)
Created: 2025-05-07T22:48:08Z | Merged: 2025-05-08T21:44:37Z

We started dual writing these in https://github.com/getsentry/sentry/pull/91087. This backfills both fingerprints into `GroupHash` so that we can stop relying on the old ones.

<!-- Describe your PR here. -->


Diff:
diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt
index e15ac9f1d08a77..0947058bb33229 100644
--- a/migrations_lockfile.txt
+++ b/migrations_lockfile.txt
@@ -27,6 +27,6 @@ social_auth: 0002_default_auto_field
 
 tempest: 0002_make_message_type_nullable
 
-uptime: 0040_uptime_backfill_detector_conditions
+uptime: 0041_uptime_backfill_detector_grouphash
 
 workflow_engine: 0059_fix_high_priority_condition_triggers
diff --git a/src/sentry/testutils/cases.py b/src/sentry/testutils/cases.py
index 791e9a1f2ed957..62c0777e870672 100644
--- a/src/sentry/testutils/cases.py
+++ b/src/sentry/testutils/cases.py
@@ -3140,12 +3140,6 @@ def tearDown(self):
         self.mock_resolve_rdap_provider_ctx.__exit__(None, None, None)
         self.mock_requests_get_ctx.__exit__(None, None, None)
 
-
-class _OptionalCheckResult(TypedDict, total=False):
-    region: str
-
-
-class UptimeTestCase(UptimeTestCaseMixin, TestCase):
     def create_uptime_result(
         self,
         subscription_id: str | None = None,
@@ -3178,6 +3172,14 @@ def create_uptime_result(
         }
 
 
+class _OptionalCheckResult(TypedDict, total=False):
+    region: str
+
+
+class UptimeTestCase(UptimeTestCaseMixin, TestCase):
+    pass
+
+
 class IntegratedApiTestCase(BaseTestCase):
     def should_call_api_without_proxying(self) -> bool:
         return not IntegrationProxyClient.determine_whether_should_proxy_to_control()
diff --git a/src/sentry/uptime/migrations/0041_uptime_backfill_detector_grouphash.py b/src/sentry/uptime/migrations/0041_uptime_backfill_detector_grouphash.py
new file mode 100644
index 00000000000000..ae58792b082c43
--- /dev/null
+++ b/src/sentry/uptime/migrations/0041_uptime_backfill_detector_grouphash.py
@@ -0,0 +1,102 @@
+# Generated by Django 5.1.7 on 2025-05-06 22:16
+from hashlib import md5
+from typing import Any
+
+from django.db import migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+from django.db.migrations.state import StateApps
+
+from sentry.new_migrations.migrations import CheckedMigration
+from sentry.utils.query import RangeQuerySetWrapperWithProgressBar
+
+DATA_SOURCE_UPTIME_SUBSCRIPTION = "uptime_subscription"
+
+
+def get_project_subscription(ProjectUptimeSubscription: Any, detector: Any) -> Any:
+    data_source = detector.data_sources.first()
+    return ProjectUptimeSubscription.objects.get(uptime_subscription_id=int(data_source.source_id))
+
+
+def build_detector_fingerprint_component(detector: Any) -> str:
+    return f"uptime-detector:{detector.id}"
+
+
+def build_subscription_fingerprint_component(subscription_id: int) -> str:
+    return str(subscription_id)
+
+
+def hash_fingerprint(fingerprint: str) -> str:
+    return md5(fingerprint.encode("utf-8")).hexdigest()
+
+
+def update_auto_detected_active_interval_seconds(
+    apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
+) -> None:
+    GroupHash = apps.get_model("sentry", "GroupHash")
+    DataSource = apps.get_model("workflow_engine", "DataSource")
+    ProjectUptimeSubscription = apps.get_model("uptime", "ProjectUptimeSubscription")
+
+    for data_source in RangeQuerySetWrapperWithProgressBar(
+        DataSource.objects.filter(type=DATA_SOURCE_UPTIME_SUBSCRIPTION)
+    ):
+        uptime_subscription_id = int(data_source.source_id)
+        project_subscription_id = ProjectUptimeSubscription.objects.get(
+            uptime_subscription_id=uptime_subscription_id
+        ).id
+        detector = data_source.detectors.first()
+
+        detector_fingerprint_hash = hash_fingerprint(build_detector_fingerprint_component(detector))
+        sub_fingerprint_hash = hash_fingerprint(
+            build_subscription_fingerprint_component(project_subscription_id)
+        )
+        group_hashes = list(
+            GroupHash.objects.filter(
+                project=detector.project,
+                hash__in=[detector_fingerprint_hash, sub_fingerprint_hash],
+            )
+        )
+        # If we have 0 or 2 group hashes then we don't need to backfill anything. Either the group doesn't exist
+        # at all for this detector, or both hashes are already associated
+        if len(group_hashes) != 1:
+            continue
+        group_hash = group_hashes[0]
+        # If the only hash that exists is for the detector fingerprint then we don't have any work to do
+        if group_hash.hash == detector_fingerprint_hash:
+            continue
+
+        GroupHash.objects.get_or_create(
+            project=detector.project,
+            hash=detector_fingerprint_hash,
+            defaults={
+                "group": group_hash.group,
+            },
+        )
+
+
+class Migration(CheckedMigration):
+    # This flag is used to mark that a migration shouldn't be automatically run in production.
+    # This should only be used for operations where it's safe to run the migration after your
+    # code has deployed. So this should not be used for most operations that alter the schema
+    # of a table.
+    # Here are some things that make sense to mark as post deployment:
+    # - Large data migrations. Typically we want these to be run manually so that they can be
+    #   monitored and not block the deploy for a long period of time while they run.
+    # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to
+    #   run this outside deployments so that we don't block them. Note that while adding an index
+    #   is a schema change, it's completely safe to run the operation after the code has deployed.
+    # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment
+
+    is_post_deployment = True
+
+    dependencies = [
+        ("uptime", "0040_uptime_backfill_detector_conditions"),
+        ("workflow_engine", "0055_datasource_define_indexes"),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            update_auto_detected_active_interval_seconds,
+            migrations.RunPython.noop,
+            hints={"tables": ["sentry_grouphash"]},
+        )
+    ]
diff --git a/tests/sentry/uptime/migrations/test_0041_uptime_backfill_detector_grouphash.py b/tests/sentry/uptime/migrations/test_0041_uptime_backfill_detector_grouphash.py
new file mode 100644
index 00000000000000..698d71c0c98adc
--- /dev/null
+++ b/tests/sentry/uptime/migrations/test_0041_uptime_backfill_detector_grouphash.py
@@ -0,0 +1,100 @@
+from hashlib import md5
+
+import pytest
+
+from sentry.models.group import Group
+from sentry.models.grouphash import GroupHash
+from sentry.testutils.cases import TestMigrations, UptimeTestCaseMixin
+from sentry.uptime.grouptype import UptimeDomainCheckFailure
+from sentry.uptime.issue_platform import (
+    build_detector_fingerprint_component,
+    create_issue_platform_occurrence,
+)
+from sentry.uptime.types import DATA_SOURCE_UPTIME_SUBSCRIPTION, ProjectUptimeSubscriptionMode
+from sentry.workflow_engine.models import DataSource, DataSourceDetector
+
+
+@pytest.mark.skip(reason="Causes problems in pipeline")
+class TestUptimeBackfillDetectorGrouphash(TestMigrations, UptimeTestCaseMixin):
+    app = "uptime"
+    migrate_from = "0040_uptime_backfill_detector_conditions"
+    migrate_to = "0041_uptime_backfill_detector_grouphash"
+
+    def setup_before_migration(self, apps):
+        self.proj_sub_no_hash = self.create_project_uptime_subscription(project=self.project)
+        self.proj_sub_one_hash = self.create_project_uptime_subscription(project=self.project)
+        self.proj_sub_both_hash = self.create_project_uptime_subscription(project=self.project)
+
+        DataSource.objects.all().delete()
+        self.data_source_no_hash = self.create_data_source(
+            type=DATA_SOURCE_UPTIME_SUBSCRIPTION,
+            source_id=str(self.proj_sub_no_hash.uptime_subscription_id),
+        )
+        self.detector_no_hash = self.create_detector(
+            type=UptimeDomainCheckFailure.slug,
+            config={"mode": ProjectUptimeSubscriptionMode.MANUAL, "environment": None},
+            project=self.project,
+        )
+        DataSourceDetector.objects.create(
+            data_source=self.data_source_no_hash, detector=self.detector_no_hash
+        )
+
+        self.data_source_one_hash = self.create_data_source(
+            type=DATA_SOURCE_UPTIME_SUBSCRIPTION,
+            source_id=str(self.proj_sub_one_hash.uptime_subscription_id),
+        )
+        self.detector_one_hash = self.create_detector(
+            type=UptimeDomainCheckFailure.slug,
+            config={"mode": ProjectUptimeSubscriptionMode.MANUAL, "environment": None},
+            project=self.project,
+        )
+        DataSourceDetector.objects.create(
+            data_source=self.data_source_one_hash, detector=self.detector_one_hash
+        )
+
+        self.data_source_both_hash = self.create_data_source(
+            type=DATA_SOURCE_UPTIME_SUBSCRIPTION,
+            source_id=str(self.proj_sub_both_hash.uptime_subscription_id),
+        )
+        self.detector_both_hash = self.create_detector(
+            type=UptimeDomainCheckFailure.slug,
+            config={"mode": ProjectUptimeSubscriptionMode.MANUAL, "environment": None},
+            project=self.project,
+        )
+        DataSourceDetector.objects.create(
+            data_source=self.data_source_both_hash, detector=self.detector_both_hash
+        )
+
+        with self.tasks(), self.feature(UptimeDomainCheckFailure.build_ingest_feature_name()):
+            create_issue_platform_occurrence(self.create_uptime_result(), self.detector_both_hash)
+            create_issue_platform_occurrence(self.create_uptime_result(), self.detector_one_hash)
+
+        # Remove the hash for the detector
+        self.group_one_hash = Group.objects.last()
+        GroupHash.objects.filter(
+            group=self.group_one_hash,
+            hash=md5(
+                build_detector_fingerprint_component(self.detector_one_hash).encode("utf-8")
+            ).hexdigest(),
+        ).delete()
+
+    def test(self):
+        assert GroupHash.objects.filter(
+            group=self.group_one_hash,
+            hash=md5(
+                build_detector_fingerprint_component(self.detector_one_hash).encode("utf-8")
+            ).hexdigest(),
+        ).exists()
+        assert not GroupHash.objects.filter(
+            project=self.detector_no_hash.project,
+            hash=md5(
+                build_detector_fingerprint_component(self.detector_no_hash).encode("utf-8")
+            ).hexdigest(),
+        ).exists()
+        both_group = GroupHash.objects.get(
+            project=self.detector_both_hash.project,
+            hash=md5(
+                build_detector_fingerprint_component(self.detector_both_hash).encode("utf-8")
+            ).hexdigest(),
+        ).group
+        assert GroupHash.objects.filter(group=both_group).count() == 2


Reviews:
user: login: evanpurkhiser
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T01:07:33Z
user: login: wedamija
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T21:11:27Z
user: login: evanpurkhiser
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T21:42:22Z
user: login: evanpurkhiser
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T21:44:11Z
user: login: evanpurkhiser
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-08T21:44:36Z
user: login: evanpurkhiser
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T21:52:05Z

Review comments:
diff_hunk: @@ -0,0 +1,100 @@
+# Generated by Django 5.1.7 on 2025-05-06 22:16
+from hashlib import md5
+
+from django.db import migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+from django.db.migrations.state import StateApps
+
+from sentry.new_migrations.migrations import CheckedMigration
+from sentry.utils.query import RangeQuerySetWrapperWithProgressBar
+
+DATA_SOURCE_UPTIME_SUBSCRIPTION = "uptime_subscription"
+
+
+def get_project_subscription(ProjectUptimeSubscription, detector):
+    data_source = detector.data_sources.first()
+    return ProjectUptimeSubscription.objects.get(uptime_subscription_id=int(data_source.source_id))
+
+
+def build_detector_fingerprint_component(detector) -> str:
+    return f"uptime-detector:{detector.id}"
+
+
+def build_subscription_fingerprint_component(subscription_id) -> str:
+    return str(subscription_id)
+
+
+def hash_fingerprint(fingerprint: str) -> str:
+    return md5(fingerprint.encode("utf-8")).hexdigest()
+
+
+def update_auto_detected_active_interval_seconds(
+    apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
+) -> None:
+    GroupHash = apps.get_model("sentry", "GroupHash")
+    DataSource = apps.get_model("workflow_engine", "DataSource")
+    ProjectUptimeSubscription = apps.get_model("uptime", "ProjectUptimeSubscription")
+
+    for data_source in RangeQuerySetWrapperWithProgressBar(
+        DataSource.objects.filter(type=DATA_SOURCE_UPTIME_SUBSCRIPTION)
+    ):
+        uptime_subscription_id = int(data_source.source_id)
+        project_subscription_id = ProjectUptimeSubscription.objects.get(
+            uptime_subscription_id=uptime_subscription_id
+        ).id
+        detector = data_source.detectors.first()
+
+        detector_fingerprint_hash = hash_fingerprint(build_detector_fingerprint_component(detector))
+        sub_fingerprint_hash = hash_fingerprint(
+            build_subscription_fingerprint_component(project_subscription_id)
+        )
+        group_hashes = list(
+            GroupHash.objects.filter(
+                project=detector.project,
+                hash__in=[detector_fingerprint_hash, sub_fingerprint_hash],
+            )
+        )
+        # If we have 0 or 2 group hashes then we don't need to backfill anything. Either the group doesn't exist
+        # at all for this detector, or both hashes are already associated
+        if len(group_hashes) != 1:
+            continue
+        group_hash = group_hashes[0]
+        # If the only hash that exists is for the detector fingerprint then we don't have any work to do
+        if group_hash.hash == detector_fingerprint_hash:
+            continue
+
+        GroupHash.objects.get_or_create(
+            project=detector.project,
+            hash=detector_fingerprint_hash,
+            defaults={
+                "group": group_hash.group,
+            },
+        )
+
+
+class Migration(CheckedMigration):
+    # This flag is used to mark that a migration shouldn't be automatically run in production.
+    # This should only be used for operations where it's safe to run the migration after your
+    # code has deployed. So this should not be used for most operations that alter the schema
+    # of a table.
+    # Here are some things that make sense to mark as post deployment:
+    # - Large data migrations. Typically we want these to be run manually so that they can be
+    #   monitored and not block the deploy for a long period of time while they run.
+    # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to
+    #   run this outside deployments so that we don't block them. Note that while adding an index
+    #   is a schema change, it's completely safe to run the operation after the code has deployed.
+    # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment
+
+    is_post_deployment = True
+
+    dependencies = [
path: src/sentry/uptime/migrations/0040_uptime_backfill_detector_grouphash.py
user: login: evanpurkhiser
body: I think you’ll need to depend on the workflow engine migrations too. Otherwise the app state might not be right when this migration runs?
created_at: 2025-05-08T01:07:32Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,100 @@
+# Generated by Django 5.1.7 on 2025-05-06 22:16
+from hashlib import md5
+
+from django.db import migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+from django.db.migrations.state import StateApps
+
+from sentry.new_migrations.migrations import CheckedMigration
+from sentry.utils.query import RangeQuerySetWrapperWithProgressBar
+
+DATA_SOURCE_UPTIME_SUBSCRIPTION = "uptime_subscription"
+
+
+def get_project_subscription(ProjectUptimeSubscription, detector):
+    data_source = detector.data_sources.first()
+    return ProjectUptimeSubscription.objects.get(uptime_subscription_id=int(data_source.source_id))
+
+
+def build_detector_fingerprint_component(detector) -> str:
+    return f"uptime-detector:{detector.id}"
+
+
+def build_subscription_fingerprint_component(subscription_id) -> str:
+    return str(subscription_id)
+
+
+def hash_fingerprint(fingerprint: str) -> str:
+    return md5(fingerprint.encode("utf-8")).hexdigest()
+
+
+def update_auto_detected_active_interval_seconds(
+    apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
+) -> None:
+    GroupHash = apps.get_model("sentry", "GroupHash")
+    DataSource = apps.get_model("workflow_engine", "DataSource")
+    ProjectUptimeSubscription = apps.get_model("uptime", "ProjectUptimeSubscription")
+
+    for data_source in RangeQuerySetWrapperWithProgressBar(
+        DataSource.objects.filter(type=DATA_SOURCE_UPTIME_SUBSCRIPTION)
+    ):
+        uptime_subscription_id = int(data_source.source_id)
+        project_subscription_id = ProjectUptimeSubscription.objects.get(
+            uptime_subscription_id=uptime_subscription_id
+        ).id
+        detector = data_source.detectors.first()
+
+        detector_fingerprint_hash = hash_fingerprint(build_detector_fingerprint_component(detector))
+        sub_fingerprint_hash = hash_fingerprint(
+            build_subscription_fingerprint_component(project_subscription_id)
+        )
+        group_hashes = list(
+            GroupHash.objects.filter(
+                project=detector.project,
+                hash__in=[detector_fingerprint_hash, sub_fingerprint_hash],
+            )
+        )
+        # If we have 0 or 2 group hashes then we don't need to backfill anything. Either the group doesn't exist
+        # at all for this detector, or both hashes are already associated
+        if len(group_hashes) != 1:
+            continue
+        group_hash = group_hashes[0]
+        # If the only hash that exists is for the detector fingerprint then we don't have any work to do
+        if group_hash.hash == detector_fingerprint_hash:
+            continue
+
+        GroupHash.objects.get_or_create(
+            project=detector.project,
+            hash=detector_fingerprint_hash,
+            defaults={
+                "group": group_hash.group,
+            },
+        )
+
+
+class Migration(CheckedMigration):
+    # This flag is used to mark that a migration shouldn't be automatically run in production.
+    # This should only be used for operations where it's safe to run the migration after your
+    # code has deployed. So this should not be used for most operations that alter the schema
+    # of a table.
+    # Here are some things that make sense to mark as post deployment:
+    # - Large data migrations. Typically we want these to be run manually so that they can be
+    #   monitored and not block the deploy for a long period of time while they run.
+    # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to
+    #   run this outside deployments so that we don't block them. Note that while adding an index
+    #   is a schema change, it's completely safe to run the operation after the code has deployed.
+    # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment
+
+    is_post_deployment = True
+
+    dependencies = [
path: src/sentry/uptime/migrations/0040_uptime_backfill_detector_grouphash.py
user: login: wedamija
body: I think that previous uptime migrations are depending on this, so it's probably fine. But added a dependency on the latest anyway
created_at: 2025-05-08T21:11:27Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,102 @@
+# Generated by Django 5.1.7 on 2025-05-06 22:16
+from hashlib import md5
+from typing import Any
+
+from django.db import migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+from django.db.migrations.state import StateApps
+
+from sentry.new_migrations.migrations import CheckedMigration
+from sentry.utils.query import RangeQuerySetWrapperWithProgressBar
+
+DATA_SOURCE_UPTIME_SUBSCRIPTION = "uptime_subscription"
+
+
+def get_project_subscription(ProjectUptimeSubscription: Any, detector: Any) -> Any:
+    data_source = detector.data_sources.first()
+    return ProjectUptimeSubscription.objects.get(uptime_subscription_id=int(data_source.source_id))
+
+
+def build_detector_fingerprint_component(detector: Any) -> str:
+    return f"uptime-detector:{detector.id}"
+
+
+def build_subscription_fingerprint_component(subscription_id: int) -> str:
+    return str(subscription_id)
+
+
+def hash_fingerprint(fingerprint: str) -> str:
+    return md5(fingerprint.encode("utf-8")).hexdigest()
+
+
+def update_auto_detected_active_interval_seconds(
+    apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
+) -> None:
+    GroupHash = apps.get_model("sentry", "GroupHash")
+    DataSource = apps.get_model("workflow_engine", "DataSource")
+    ProjectUptimeSubscription = apps.get_model("uptime", "ProjectUptimeSubscription")
+
+    for data_source in RangeQuerySetWrapperWithProgressBar(
+        DataSource.objects.filter(type=DATA_SOURCE_UPTIME_SUBSCRIPTION)
+    ):
+        uptime_subscription_id = int(data_source.source_id)
+        project_subscription_id = ProjectUptimeSubscription.objects.get(
+            uptime_subscription_id=uptime_subscription_id
+        ).id
+        detector = data_source.detectors.first()
+
+        detector_fingerprint_hash = hash_fingerprint(build_detector_fingerprint_component(detector))
+        sub_fingerprint_hash = hash_fingerprint(
+            build_subscription_fingerprint_component(project_subscription_id)
+        )
+        group_hashes = list(
+            GroupHash.objects.filter(
+                project=detector.project,
+                hash__in=[detector_fingerprint_hash, sub_fingerprint_hash],
path: src/sentry/uptime/migrations/0041_uptime_backfill_detector_grouphash.py
user: login: evanpurkhiser
body: Why even lookup the detector fingerprints? Don’t we only care about when it’s exactly just the sub hash?
created_at: 2025-05-08T21:42:21Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,102 @@
+# Generated by Django 5.1.7 on 2025-05-06 22:16
+from hashlib import md5
+from typing import Any
+
+from django.db import migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+from django.db.migrations.state import StateApps
+
+from sentry.new_migrations.migrations import CheckedMigration
+from sentry.utils.query import RangeQuerySetWrapperWithProgressBar
+
+DATA_SOURCE_UPTIME_SUBSCRIPTION = "uptime_subscription"
+
+
+def get_project_subscription(ProjectUptimeSubscription: Any, detector: Any) -> Any:
+    data_source = detector.data_sources.first()
+    return ProjectUptimeSubscription.objects.get(uptime_subscription_id=int(data_source.source_id))
+
+
+def build_detector_fingerprint_component(detector: Any) -> str:
+    return f"uptime-detector:{detector.id}"
+
+
+def build_subscription_fingerprint_component(subscription_id: int) -> str:
+    return str(subscription_id)
+
+
+def hash_fingerprint(fingerprint: str) -> str:
+    return md5(fingerprint.encode("utf-8")).hexdigest()
+
+
+def update_auto_detected_active_interval_seconds(
+    apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
+) -> None:
+    GroupHash = apps.get_model("sentry", "GroupHash")
+    DataSource = apps.get_model("workflow_engine", "DataSource")
+    ProjectUptimeSubscription = apps.get_model("uptime", "ProjectUptimeSubscription")
+
+    for data_source in RangeQuerySetWrapperWithProgressBar(
+        DataSource.objects.filter(type=DATA_SOURCE_UPTIME_SUBSCRIPTION)
+    ):
+        uptime_subscription_id = int(data_source.source_id)
+        project_subscription_id = ProjectUptimeSubscription.objects.get(
+            uptime_subscription_id=uptime_subscription_id
+        ).id
+        detector = data_source.detectors.first()
+
+        detector_fingerprint_hash = hash_fingerprint(build_detector_fingerprint_component(detector))
+        sub_fingerprint_hash = hash_fingerprint(
+            build_subscription_fingerprint_component(project_subscription_id)
+        )
+        group_hashes = list(
+            GroupHash.objects.filter(
+                project=detector.project,
+                hash__in=[detector_fingerprint_hash, sub_fingerprint_hash],
path: src/sentry/uptime/migrations/0041_uptime_backfill_detector_grouphash.py
user: login: evanpurkhiser
body: Oh I see, each group hash per group hash. Never mind 
created_at: 2025-05-08T21:44:11Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,100 @@
+# Generated by Django 5.1.7 on 2025-05-06 22:16
+from hashlib import md5
+
+from django.db import migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+from django.db.migrations.state import StateApps
+
+from sentry.new_migrations.migrations import CheckedMigration
+from sentry.utils.query import RangeQuerySetWrapperWithProgressBar
+
+DATA_SOURCE_UPTIME_SUBSCRIPTION = "uptime_subscription"
+
+
+def get_project_subscription(ProjectUptimeSubscription, detector):
+    data_source = detector.data_sources.first()
+    return ProjectUptimeSubscription.objects.get(uptime_subscription_id=int(data_source.source_id))
+
+
+def build_detector_fingerprint_component(detector) -> str:
+    return f"uptime-detector:{detector.id}"
+
+
+def build_subscription_fingerprint_component(subscription_id) -> str:
+    return str(subscription_id)
+
+
+def hash_fingerprint(fingerprint: str) -> str:
+    return md5(fingerprint.encode("utf-8")).hexdigest()
+
+
+def update_auto_detected_active_interval_seconds(
+    apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
+) -> None:
+    GroupHash = apps.get_model("sentry", "GroupHash")
+    DataSource = apps.get_model("workflow_engine", "DataSource")
+    ProjectUptimeSubscription = apps.get_model("uptime", "ProjectUptimeSubscription")
+
+    for data_source in RangeQuerySetWrapperWithProgressBar(
+        DataSource.objects.filter(type=DATA_SOURCE_UPTIME_SUBSCRIPTION)
+    ):
+        uptime_subscription_id = int(data_source.source_id)
+        project_subscription_id = ProjectUptimeSubscription.objects.get(
+            uptime_subscription_id=uptime_subscription_id
+        ).id
+        detector = data_source.detectors.first()
+
+        detector_fingerprint_hash = hash_fingerprint(build_detector_fingerprint_component(detector))
+        sub_fingerprint_hash = hash_fingerprint(
+            build_subscription_fingerprint_component(project_subscription_id)
+        )
+        group_hashes = list(
+            GroupHash.objects.filter(
+                project=detector.project,
+                hash__in=[detector_fingerprint_hash, sub_fingerprint_hash],
+            )
+        )
+        # If we have 0 or 2 group hashes then we don't need to backfill anything. Either the group doesn't exist
+        # at all for this detector, or both hashes are already associated
+        if len(group_hashes) != 1:
+            continue
+        group_hash = group_hashes[0]
+        # If the only hash that exists is for the detector fingerprint then we don't have any work to do
+        if group_hash.hash == detector_fingerprint_hash:
+            continue
+
+        GroupHash.objects.get_or_create(
+            project=detector.project,
+            hash=detector_fingerprint_hash,
+            defaults={
+                "group": group_hash.group,
+            },
+        )
+
+
+class Migration(CheckedMigration):
+    # This flag is used to mark that a migration shouldn't be automatically run in production.
+    # This should only be used for operations where it's safe to run the migration after your
+    # code has deployed. So this should not be used for most operations that alter the schema
+    # of a table.
+    # Here are some things that make sense to mark as post deployment:
+    # - Large data migrations. Typically we want these to be run manually so that they can be
+    #   monitored and not block the deploy for a long period of time while they run.
+    # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to
+    #   run this outside deployments so that we don't block them. Note that while adding an index
+    #   is a schema change, it's completely safe to run the operation after the code has deployed.
+    # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment
+
+    is_post_deployment = True
+
+    dependencies = [
path: src/sentry/uptime/migrations/0040_uptime_backfill_detector_grouphash.py
user: login: evanpurkhiser
body: Oh yeah you’re right. But I think it’s probably worth it to be specific? Not sure lol
created_at: 2025-05-08T21:52:04Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-07T22:52:18Z
body: This PR has a migration; here is the generated SQL for  `src/sentry/uptime/migrations/0040_uptime_backfill_detector_grouphash.py`

**for `0040_uptime_backfill_detector_grouphash` in `uptime`**

~~~sql
--
-- Raw Python operation
--
-- THIS OPERATION CANNOT BE WRITTEN AS SQL
~~~
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-05-07T23:02:34Z
body: ## [Codecov](https://app.codecov.io/gh/getsentry/sentry/pull/91194?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) Report
Attention: Patch coverage is `51.25000%` with `39 lines` in your changes missing coverage. Please review.

:white_check_mark: All tests successful. No failed tests found.

| [Files with missing lines](https://app.codecov.io/gh/getsentry/sentry/pull/91194?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) | Patch % | Lines |
|---|---|---|
| [...ns/test\_0041\_uptime\_backfill\_detector\_grouphash.py](https://app.codecov.io/gh/getsentry/sentry/pull/91194?src=pr&el=tree&filepath=tests%2Fsentry%2Fuptime%2Fmigrations%2Ftest_0041_uptime_backfill_detector_grouphash.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry#diff-dGVzdHMvc2VudHJ5L3VwdGltZS9taWdyYXRpb25zL3Rlc3RfMDA0MV91cHRpbWVfYmFja2ZpbGxfZGV0ZWN0b3JfZ3JvdXBoYXNoLnB5) | 42.10% | [22 Missing :warning: ](https://app.codecov.io/gh/getsentry/sentry/pull/91194?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) |
| [...rations/0041\_uptime\_backfill\_detector\_grouphash.py](https://app.codecov.io/gh/getsentry/sentry/pull/91194?src=pr&el=tree&filepath=src%2Fsentry%2Fuptime%2Fmigrations%2F0041_uptime_backfill_detector_grouphash.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry#diff-c3JjL3NlbnRyeS91cHRpbWUvbWlncmF0aW9ucy8wMDQxX3VwdGltZV9iYWNrZmlsbF9kZXRlY3Rvcl9ncm91cGhhc2gucHk=) | 55.26% | [17 Missing :warning: ](https://app.codecov.io/gh/getsentry/sentry/pull/91194?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master   #91194      +/-   ##
==========================================
+ Coverage   82.85%   87.64%   +4.79%     
==========================================
  Files       10320    10322       +2     
  Lines      585628   585705      +77     
  Branches    22613    22613              
==========================================
+ Hits       485231   513362   +28131     
+ Misses      99969    71915   -28054     
  Partials      428      428              
```

</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-08T21:18:52Z
body: This PR has a migration; here is the generated SQL for  `src/sentry/uptime/migrations/0041_uptime_backfill_detector_grouphash.py`

**for `0041_uptime_backfill_detector_grouphash` in `uptime`**

~~~sql
--
-- Raw Python operation
--
-- THIS OPERATION CANNOT BE WRITTEN AS SQL
~~~
author_association: CONTRIBUTOR
user: login: wedamija
created_at: 2025-05-08T22:59:00Z
body: There's a problem with the migration runner, reverting to unblock deploys
author_association: MEMBER
user: login: getsentry-bot
created_at: 2025-05-08T22:59:04Z
body: PR reverted: 30212b347c8179ca464052ddb62db9430a4101b8
author_association: CONTRIBUTOR