diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 56944e1f3f26..6117250245e5 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -43,4 +43,4 @@ tempest: 0001_squashed_0003_use_encrypted_char_field uptime: 0001_squashed_0055_backfill_2xx_status_assertion -workflow_engine: 0120_replace_boolean_deescalation_comparisons +workflow_engine: 0121_delete_malformed_anomaly_conditions diff --git a/src/sentry/workflow_engine/migrations/0121_delete_malformed_anomaly_conditions.py b/src/sentry/workflow_engine/migrations/0121_delete_malformed_anomaly_conditions.py new file mode 100644 index 000000000000..ddd4b1239c3a --- /dev/null +++ b/src/sentry/workflow_engine/migrations/0121_delete_malformed_anomaly_conditions.py @@ -0,0 +1,93 @@ +# Generated by Django 5.2.16 on 2026-09-17 21:56 + +from collections import defaultdict + +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.iterators import chunked +from sentry.utils.query import RangeQuerySetWrapper + +BATCH_SIZE = 1000 + + +def delete_malformed_anomaly_conditions( + apps: StateApps, schema_editor: BaseDatabaseSchemaEditor +) -> None: + AlertRuleTrigger = apps.get_model("sentry", "AlertRuleTrigger") + DataCondition = apps.get_model("workflow_engine", "DataCondition") + DataConditionAlertRuleTrigger = apps.get_model( + "workflow_engine", "DataConditionAlertRuleTrigger" + ) + anomaly_condition_ids = DataCondition.objects.filter( + type="anomaly_detection", comparison=0.0 + ).values_list("id", flat=True) + + for condition_ids in chunked( + RangeQuerySetWrapper( + anomaly_condition_ids, + step=BATCH_SIZE, + result_value_getter=lambda condition_id: condition_id, + ), + BATCH_SIZE, + ): + condition_ids_by_trigger_id = defaultdict(list) + for trigger_id, condition_id in DataConditionAlertRuleTrigger.objects.filter( + data_condition_id__in=condition_ids, + alert_rule_trigger_id__isnull=False, + ).values_list("alert_rule_trigger_id", "data_condition_id"): + condition_ids_by_trigger_id[trigger_id].append(condition_id) + + triggers = AlertRuleTrigger.objects.filter( + id__in=condition_ids_by_trigger_id + ).select_related("alert_rule") + + for trigger in triggers: + conditions = DataCondition.objects.filter( + id__in=condition_ids_by_trigger_id[trigger.id] + ) + condition_group_ids = conditions.values_list("condition_group_id", flat=True) + DataCondition.objects.filter(condition_group_id__in=condition_group_ids).exclude( + type="anomaly_detection" + ).delete() + + alert_rule = trigger.alert_rule + if ( + alert_rule.sensitivity is None + or alert_rule.seasonality is None + or alert_rule.threshold_type is None + ): + continue + + conditions.update( + comparison={ + "seasonality": alert_rule.seasonality, + "sensitivity": alert_rule.sensitivity, + "threshold_type": alert_rule.threshold_type, + } + ) + + +class Migration(CheckedMigration): + is_post_deployment = True + + dependencies = [ + ("workflow_engine", "0120_replace_boolean_deescalation_comparisons"), + ] + + operations = [ + migrations.RunPython( + delete_malformed_anomaly_conditions, + reverse_code=migrations.RunPython.noop, + hints={ + "tables": [ + "sentry_alertrule", + "sentry_alertruletrigger", + "workflow_engine_datacondition", + "workflow_engine_dataconditionalertruletrigger", + ] + }, + ), + ] diff --git a/tests/sentry/migrations/test_0121_delete_malformed_anomaly_conditions.py b/tests/sentry/migrations/test_0121_delete_malformed_anomaly_conditions.py new file mode 100644 index 000000000000..6cf7e9cefdf8 --- /dev/null +++ b/tests/sentry/migrations/test_0121_delete_malformed_anomaly_conditions.py @@ -0,0 +1,200 @@ +from sentry.testutils.cases import TestMigrations + + +class DeleteMalformedAnomalyConditionsTest(TestMigrations): + app = "workflow_engine" + migrate_from = "0120_replace_boolean_deescalation_comparisons" + migrate_to = "0121_delete_malformed_anomaly_conditions" + + def setup_before_migration(self, apps): + AlertRule = apps.get_model("sentry", "AlertRule") + AlertRuleTrigger = apps.get_model("sentry", "AlertRuleTrigger") + DataCondition = apps.get_model("workflow_engine", "DataCondition") + DataConditionAlertRuleTrigger = apps.get_model( + "workflow_engine", "DataConditionAlertRuleTrigger" + ) + DataConditionGroup = apps.get_model("workflow_engine", "DataConditionGroup") + SnubaQuery = apps.get_model("sentry", "SnubaQuery") + + snuba_query = SnubaQuery.objects.create( + type=0, + dataset="events", + query="", + aggregate="count()", + time_window=60, + resolution=60, + ) + alert_rule = AlertRule.objects_with_snapshots.create( + organization_id=self.organization.id, + snuba_query_id=snuba_query.id, + name="Malformed anomaly detector", + threshold_type=1, + threshold_period=1, + detection_type="dynamic", + sensitivity="medium", + seasonality="weekly", + ) + alert_rule_trigger = AlertRuleTrigger.objects.create( + alert_rule_id=alert_rule.id, + label="critical", + threshold_type=1, + alert_threshold=0, + ) + + malformed_group = DataConditionGroup.objects.create(organization_id=self.organization.id) + self.anomaly_condition = DataCondition.objects.create( + condition_group_id=malformed_group.id, + type="anomaly_detection", + comparison=0.0, + condition_result=75, + ) + self.malformed_condition = DataCondition.objects.create( + condition_group_id=malformed_group.id, + type="lte", + comparison=0, + condition_result=0, + ) + DataConditionAlertRuleTrigger.objects.create( + data_condition_id=self.anomaly_condition.id, + alert_rule_trigger_id=alert_rule_trigger.id, + ) + + second_malformed_group = DataConditionGroup.objects.create( + organization_id=self.organization.id + ) + self.second_anomaly_condition = DataCondition.objects.create( + condition_group_id=second_malformed_group.id, + type="anomaly_detection", + comparison=0.0, + condition_result=75, + ) + self.second_malformed_condition = DataCondition.objects.create( + condition_group_id=second_malformed_group.id, + type="lte", + comparison=0, + condition_result=0, + ) + DataConditionAlertRuleTrigger.objects.create( + data_condition_id=self.second_anomaly_condition.id, + alert_rule_trigger_id=alert_rule_trigger.id, + ) + + incomplete_snuba_query = SnubaQuery.objects.create( + type=0, + dataset="events", + query="", + aggregate="count()", + time_window=60, + resolution=60, + ) + incomplete_alert_rule = AlertRule.objects_with_snapshots.create( + organization_id=self.organization.id, + snuba_query_id=incomplete_snuba_query.id, + name="Incomplete anomaly detector", + threshold_type=1, + threshold_period=1, + detection_type="dynamic", + sensitivity=None, + seasonality="weekly", + ) + incomplete_alert_rule_trigger = AlertRuleTrigger.objects.create( + alert_rule_id=incomplete_alert_rule.id, + label="critical", + threshold_type=1, + alert_threshold=0, + ) + incomplete_group = DataConditionGroup.objects.create(organization_id=self.organization.id) + self.incomplete_anomaly_condition = DataCondition.objects.create( + condition_group_id=incomplete_group.id, + type="anomaly_detection", + comparison=0.0, + condition_result=75, + ) + self.incomplete_mixed_condition = DataCondition.objects.create( + condition_group_id=incomplete_group.id, + type="lte", + comparison=0, + condition_result=0, + ) + DataConditionAlertRuleTrigger.objects.create( + data_condition_id=self.incomplete_anomaly_condition.id, + alert_rule_trigger_id=incomplete_alert_rule_trigger.id, + ) + + orphaned_group = DataConditionGroup.objects.create(organization_id=self.organization.id) + self.orphaned_anomaly_condition = DataCondition.objects.create( + condition_group_id=orphaned_group.id, + type="anomaly_detection", + comparison=0.0, + condition_result=75, + ) + self.orphaned_mixed_condition = DataCondition.objects.create( + condition_group_id=orphaned_group.id, + type="lte", + comparison=0, + condition_result=0, + ) + + valid_anomaly_group = DataConditionGroup.objects.create( + organization_id=self.organization.id + ) + self.valid_anomaly_condition = DataCondition.objects.create( + condition_group_id=valid_anomaly_group.id, + type="anomaly_detection", + comparison={ + "sensitivity": "high", + "seasonality": "auto", + "threshold_type": 0, + }, + condition_result=75, + ) + self.valid_mixed_condition = DataCondition.objects.create( + condition_group_id=valid_anomaly_group.id, + type="lte", + comparison=0, + condition_result=0, + ) + + static_group = DataConditionGroup.objects.create(organization_id=self.organization.id) + self.valid_static_condition = DataCondition.objects.create( + condition_group_id=static_group.id, + type="lte", + comparison=0, + condition_result=0, + ) + + def test(self): + DataCondition = self.apps.get_model("workflow_engine", "DataCondition") + + anomaly_condition = DataCondition.objects.get(id=self.anomaly_condition.id) + assert anomaly_condition.comparison == { + "seasonality": "weekly", + "sensitivity": "medium", + "threshold_type": 1, + } + assert not DataCondition.objects.filter(id=self.malformed_condition.id).exists() + + second_anomaly_condition = DataCondition.objects.get(id=self.second_anomaly_condition.id) + assert second_anomaly_condition.comparison == anomaly_condition.comparison + assert not DataCondition.objects.filter(id=self.second_malformed_condition.id).exists() + + incomplete_anomaly_condition = DataCondition.objects.get( + id=self.incomplete_anomaly_condition.id + ) + assert incomplete_anomaly_condition.comparison == 0.0 + assert not DataCondition.objects.filter(id=self.incomplete_mixed_condition.id).exists() + + orphaned_anomaly_condition = DataCondition.objects.get( + id=self.orphaned_anomaly_condition.id + ) + assert orphaned_anomaly_condition.comparison == 0.0 + assert DataCondition.objects.filter(id=self.orphaned_mixed_condition.id).exists() + + valid_anomaly_condition = DataCondition.objects.get(id=self.valid_anomaly_condition.id) + assert valid_anomaly_condition.comparison == { + "sensitivity": "high", + "seasonality": "auto", + "threshold_type": 0, + } + assert DataCondition.objects.filter(id=self.valid_mixed_condition.id).exists() + assert DataCondition.objects.filter(id=self.valid_static_condition.id).exists()