From ad84c9edaa85d7cf0147a3e86d29328b4db56b8c Mon Sep 17 00:00:00 2001 From: Snigdha Sharma Date: Fri, 20 Sep 2024 15:59:29 -0700 Subject: [PATCH] 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. --- migrations_lockfile.txt | 2 +- .../0764_migrate_bad_status_substatus_rows.py | 170 ++++++++++++++++++ ..._0764_migrate_bad_status_substatus_rows.py | 159 ++++++++++++++++ 3 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 src/sentry/migrations/0764_migrate_bad_status_substatus_rows.py create mode 100644 tests/sentry/migrations/test_0764_migrate_bad_status_substatus_rows.py diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 8560a91e4d917d..5b94065e474a37 100644 --- a/migrations_lockfile.txt +++ b/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 diff --git a/src/sentry/migrations/0764_migrate_bad_status_substatus_rows.py b/src/sentry/migrations/0764_migrate_bad_status_substatus_rows.py new file mode 100644 index 00000000000000..3c9dac93b025c2 --- /dev/null +++ b/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"]}, + ), + ] diff --git a/tests/sentry/migrations/test_0764_migrate_bad_status_substatus_rows.py b/tests/sentry/migrations/test_0764_migrate_bad_status_substatus_rows.py new file mode 100644 index 00000000000000..06d0a6cf457299 --- /dev/null +++ b/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