Browse Source

fix(substatus): Add migration to fix substatuses (#77741)

Fixes substatus inconsistencies by removing substatus for groups that
aren't unresolved or ignored and recalculating substatus for
unresolved/ignored groups that have an incompatible substatus.

From [this query](https://redash.getsentry.net/queries/6949), there
should be around 3k groups to fix.
Snigdha Sharma 5 months ago
parent
commit
ad84c9edaa

+ 1 - 1
migrations_lockfile.txt

@@ -10,7 +10,7 @@ hybridcloud: 0016_add_control_cacheversion
 nodestore: 0002_nodestore_no_dictfield
 remote_subscriptions: 0003_drop_remote_subscription
 replays: 0004_index_together
-sentry: 0763_add_created_by_to_broadcasts
+sentry: 0764_migrate_bad_status_substatus_rows
 social_auth: 0002_default_auto_field
 uptime: 0013_uptime_subscription_new_unique
 workflow_engine: 0005_data_source_detector

+ 170 - 0
src/sentry/migrations/0764_migrate_bad_status_substatus_rows.py

@@ -0,0 +1,170 @@
+# Generated by Django 5.1.1 on 2024-09-17 21:16
+
+from datetime import timedelta
+
+from django.apps.registry import Apps
+from django.db import migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+from django.utils import timezone
+
+from sentry.new_migrations.migrations import CheckedMigration
+from sentry.utils.query import RangeQuerySetWrapperWithProgressBarApprox
+
+
+class ActivityType:
+    SET_IGNORED = 3
+
+
+class GroupHistoryStatus:
+    REGRESSED = 7
+    ARCHIVED_UNTIL_ESCALATING = 15
+    ARCHIVED_FOREVER = 16
+    ARCHIVED_UNTIL_CONDITION_MET = 17
+
+
+class GroupSubStatus:
+    # GroupStatus.IGNORED
+    UNTIL_ESCALATING = 1
+    # Group is ignored/archived for a count/user count/duration
+    UNTIL_CONDITION_MET = 4
+    # Group is ignored/archived forever
+    FOREVER = 5
+
+    # GroupStatus.UNRESOLVED
+    ESCALATING = 2
+    ONGOING = 3
+    REGRESSED = 6
+    NEW = 7
+
+
+class GroupStatus:
+    UNRESOLVED = 0
+    RESOLVED = 1
+    IGNORED = 2
+    PENDING_DELETION = 3
+    DELETION_IN_PROGRESS = 4
+    PENDING_MERGE = 5
+
+    # The group's events are being re-processed and after that the group will
+    # be deleted. In this state no new events shall be added to the group.
+    REPROCESSING = 6
+
+    # TODO(dcramer): remove in 9.0
+    MUTED = IGNORED
+
+
+UNRESOLVED_SUBSTATUS_CHOICES = {
+    GroupSubStatus.ONGOING,
+    GroupSubStatus.ESCALATING,
+    GroupSubStatus.REGRESSED,
+    GroupSubStatus.NEW,
+}
+
+IGNORED_SUBSTATUS_CHOICES = {
+    GroupSubStatus.UNTIL_ESCALATING,
+    GroupSubStatus.FOREVER,
+    GroupSubStatus.UNTIL_CONDITION_MET,
+}
+
+# End copy
+
+ACTIVITY_DATA_FIELDS = {
+    "ignoreCount",
+    "ignoreDuration",
+    "ignoreUntil",
+    "ignoreUserCount",
+    "ignoreUserWindow",
+    "ignoreWindow",
+}
+
+
+def fix_substatus_for_groups(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+    Group = apps.get_model("sentry", "Group")
+    Activity = apps.get_model("sentry", "Activity")
+    GroupSnooze = apps.get_model("sentry", "GroupSnooze")
+    GroupHistory = apps.get_model("sentry", "GroupHistory")
+
+    seven_days_ago = timezone.now() - timedelta(days=7)
+    group_history = GroupHistory.objects.filter(
+        date_added__gt=seven_days_ago, status=GroupHistoryStatus.REGRESSED
+    )
+    activity = Activity.objects.filter(type=ActivityType.SET_IGNORED)
+    for group in RangeQuerySetWrapperWithProgressBarApprox(Group.objects.all()):
+        if (
+            group.status not in [GroupStatus.UNRESOLVED, GroupStatus.IGNORED]
+            and group.substatus is None
+        ):
+            # These groups are correct
+            continue
+
+        new_substatus = None
+
+        if group.status == GroupStatus.IGNORED:
+            if group.substatus in IGNORED_SUBSTATUS_CHOICES:
+                # These groups are correct
+                continue
+
+            group_activity = activity.filter(group_id=group.id).order_by("-datetime").first()
+            if group_activity:
+                # If ignoreUntilEscalating is set, we should set the substatus to UNTIL_ESCALATING
+                if group_activity.data.get("ignoreUntilEscalating", False):
+                    new_substatus = GroupSubStatus.UNTIL_ESCALATING
+                # If any other field in the activity data is set, we should set the substatus to UNTIL_CONDITION_MET
+                elif any(group_activity.data.get(field) for field in ACTIVITY_DATA_FIELDS):
+                    new_substatus = GroupSubStatus.UNTIL_CONDITION_MET
+
+            # If no activity is found or the activity data is not set, check the group snooze table
+            if not new_substatus:
+                snooze = GroupSnooze.objects.filter(group=group)
+                if snooze.exists():
+                    # If snooze exists, we should set the substatus to UNTIL_CONDITION_MET
+                    new_substatus = GroupSubStatus.UNTIL_CONDITION_MET
+                else:
+                    # If we have no other information stored about the group's status conditions, the group is ignored forever
+                    new_substatus = GroupSubStatus.FOREVER
+
+        elif group.status == GroupStatus.UNRESOLVED:
+            if group.substatus in UNRESOLVED_SUBSTATUS_CHOICES:
+                # These groups are correct
+                continue
+
+            if group.first_seen > seven_days_ago:
+                new_substatus = GroupSubStatus.NEW
+            else:
+                histories = group_history.filter(group=group)
+                if histories.exists():
+                    new_substatus = GroupSubStatus.REGRESSED
+
+            if new_substatus is None:
+                new_substatus = GroupSubStatus.ONGOING
+
+        group.substatus = new_substatus
+        group.save(update_fields=["substatus"])
+
+
+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 = [
+        ("sentry", "0763_add_created_by_to_broadcasts"),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            fix_substatus_for_groups,
+            migrations.RunPython.noop,
+            hints={"tables": ["sentry_groupedmessage", "sentry_grouphistory"]},
+        ),
+    ]

+ 159 - 0
tests/sentry/migrations/test_0764_migrate_bad_status_substatus_rows.py

@@ -0,0 +1,159 @@
+from datetime import timedelta
+
+from django.utils import timezone
+
+from sentry.models.activity import Activity
+from sentry.models.group import Group, GroupStatus
+from sentry.models.grouphistory import GroupHistory, GroupHistoryStatus
+from sentry.models.groupsnooze import GroupSnooze
+from sentry.models.organization import Organization
+from sentry.testutils.cases import TestMigrations
+from sentry.types.activity import ActivityType
+from sentry.types.group import GroupSubStatus
+
+
+class BackfillMissingUnresolvedSubstatusTest(TestMigrations):
+    migrate_from = "0763_add_created_by_to_broadcasts"
+    migrate_to = "0764_migrate_bad_status_substatus_rows"
+
+    def setup_before_migration(self, app):
+        self.organization = Organization.objects.create(name="test", slug="test")
+        self.project = self.create_project(organization=self.organization)
+        self.do_not_update = Group.objects.create(
+            project=self.project,
+            status=GroupStatus.UNRESOLVED,
+            substatus=GroupSubStatus.NEW,
+        )
+
+        self.ongoing_group = Group.objects.create(
+            project=self.project,
+            status=GroupStatus.UNRESOLVED,
+        )
+        # .update() skips calling the pre_save checks which add a substatus
+        self.ongoing_group.update(
+            substatus=GroupSubStatus.UNTIL_ESCALATING,
+            first_seen=timezone.now() - timedelta(days=8),
+        )
+
+        self.regressed_group = Group.objects.create(
+            project=self.project,
+            status=GroupStatus.UNRESOLVED,
+            first_seen=timezone.now() - timedelta(days=8),
+        )
+        self.regressed_group.update(substatus=GroupSubStatus.FOREVER)
+        GroupHistory.objects.create(
+            group=self.regressed_group,
+            date_added=timezone.now() - timedelta(days=1),
+            organization_id=self.organization.id,
+            project_id=self.project.id,
+            status=GroupHistoryStatus.REGRESSED,
+        )
+
+        self.new_group = Group.objects.create(
+            project=self.project,
+            status=GroupStatus.UNRESOLVED,
+            first_seen=timezone.now(),
+        )
+        self.new_group.update(substatus=GroupSubStatus.UNTIL_CONDITION_MET)
+
+        self.do_not_update_2 = Group.objects.create(
+            project=self.project,
+            status=GroupStatus.IGNORED,
+            substatus=GroupSubStatus.UNTIL_ESCALATING,
+        )
+
+        self.ignored_until_condition_met = Group.objects.create(
+            project=self.project,
+            status=GroupStatus.IGNORED,
+        )
+        # .update() skips calling the pre_save checks which requires a substatus
+        self.ignored_until_condition_met.update(substatus=GroupSubStatus.ONGOING)
+        Activity.objects.create(
+            group=self.ignored_until_condition_met,
+            project=self.project,
+            type=ActivityType.SET_IGNORED.value,
+            data={"ignoreCount": 10},
+        )
+
+        self.ignored_until_condition_met_no_activity = Group.objects.create(
+            project=self.project,
+            status=GroupStatus.IGNORED,
+        )
+        self.ignored_until_condition_met_no_activity.update(substatus=GroupSubStatus.REGRESSED)
+        Activity.objects.create(
+            group=self.ignored_until_condition_met_no_activity,
+            project=self.project,
+            type=ActivityType.SET_IGNORED.value,
+            data={
+                "ignoreCount": None,
+                "ignoreDuration": None,
+                "ignoreUntil": None,
+                "ignoreUserCount": None,
+                "ignoreUserWindow": None,
+                "ignoreWindow": None,
+                "ignoreUntilEscalating": None,
+            },
+        )
+        GroupSnooze.objects.create(
+            group=self.ignored_until_condition_met_no_activity,
+            count=10,
+        )
+
+        self.ignored_until_escalating = Group.objects.create(
+            project=self.project,
+            status=GroupStatus.IGNORED,
+        )
+        # .update() skips calling the pre_save checks which requires a substatus
+        self.ignored_until_escalating.update(substatus=GroupSubStatus.NEW)
+        Activity.objects.create(
+            group=self.ignored_until_escalating,
+            project=self.project,
+            type=ActivityType.SET_IGNORED.value,
+            data={"ignoreUntilEscalating": True},
+        )
+
+        self.ignored_forever = Group.objects.create(
+            project=self.project,
+            status=GroupStatus.IGNORED,
+        )
+        self.ignored_forever.update(substatus=GroupSubStatus.ONGOING)
+
+        self.pending_merge = Group.objects.create(
+            project=self.project,
+            status=GroupStatus.PENDING_MERGE,
+        )
+        self.pending_merge.update(substatus=GroupSubStatus.NEW)
+
+    def test(self):
+        self.do_not_update.refresh_from_db()
+        assert self.do_not_update.substatus == GroupSubStatus.NEW
+
+        self.ongoing_group.refresh_from_db()
+        assert self.ongoing_group.substatus == GroupSubStatus.ONGOING
+
+        self.regressed_group.refresh_from_db()
+        assert self.regressed_group.substatus == GroupSubStatus.REGRESSED
+
+        self.new_group.refresh_from_db()
+        assert self.new_group.substatus == GroupSubStatus.NEW
+
+        self.do_not_update_2.refresh_from_db()
+        assert self.do_not_update_2.substatus == GroupSubStatus.UNTIL_ESCALATING
+
+        self.ignored_until_condition_met.refresh_from_db()
+        assert self.ignored_until_condition_met.substatus == GroupSubStatus.UNTIL_CONDITION_MET
+
+        self.ignored_until_condition_met_no_activity.refresh_from_db()
+        assert (
+            self.ignored_until_condition_met_no_activity.substatus
+            == GroupSubStatus.UNTIL_CONDITION_MET
+        )
+
+        self.ignored_until_escalating.refresh_from_db()
+        assert self.ignored_until_escalating.substatus == GroupSubStatus.UNTIL_ESCALATING
+
+        self.ignored_forever.refresh_from_db()
+        assert self.ignored_forever.substatus == GroupSubStatus.FOREVER
+
+        self.pending_merge.refresh_from_db()
+        assert self.pending_merge.substatus is None