diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 56944e1f3f26..b249c8d3366a 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_repair_mixed_anomaly_detection_condition_groups diff --git a/src/sentry/workflow_engine/migrations/0121_repair_mixed_anomaly_detection_condition_groups.py b/src/sentry/workflow_engine/migrations/0121_repair_mixed_anomaly_detection_condition_groups.py new file mode 100644 index 000000000000..d218316dd5b5 --- /dev/null +++ b/src/sentry/workflow_engine/migrations/0121_repair_mixed_anomaly_detection_condition_groups.py @@ -0,0 +1,70 @@ +# Generated by Django 5.2.16 on 2026-09-17 20:53 + +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor +from django.db.migrations.state import StateApps +from django.db.models import Count, Q + +from sentry.new_migrations.migrations import CheckedMigration +from sentry.utils.query import RangeQuerySetWrapperWithProgressBar + +ANOMALY_DETECTION = "anomaly_detection" +DEFAULT_ANOMALY_COMPARISON = { + "seasonality": "auto", + "sensitivity": "low", + "threshold_type": 2, +} + + +def repair_mixed_anomaly_detection_condition_groups( + apps: StateApps, schema_editor: BaseDatabaseSchemaEditor +) -> None: + DataCondition = apps.get_model("workflow_engine", "DataCondition") + DataConditionGroup = apps.get_model("workflow_engine", "DataConditionGroup") + + condition_groups = DataConditionGroup.objects.annotate( + condition_count=Count("conditions"), + anomaly_count=Count( + "conditions", + filter=Q(conditions__type=ANOMALY_DETECTION), + ), + ).filter(condition_count__gt=1, anomaly_count__gt=0) + + for condition_group in RangeQuerySetWrapperWithProgressBar(condition_groups): + conditions = DataCondition.objects.filter(condition_group_id=condition_group.id) + conditions.filter(type=ANOMALY_DETECTION).update(comparison=DEFAULT_ANOMALY_COMPARISON) + conditions.exclude(type=ANOMALY_DETECTION).delete() + + +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 = [ + ("workflow_engine", "0120_replace_boolean_deescalation_comparisons"), + ] + + operations = [ + migrations.RunPython( + repair_mixed_anomaly_detection_condition_groups, + reverse_code=migrations.RunPython.noop, + hints={ + "tables": [ + "workflow_engine_datacondition", + "workflow_engine_dataconditiongroup", + "workflow_engine_dataconditionalertruletrigger", + ] + }, + ), + ] diff --git a/tests/sentry/migrations/test_0121_repair_mixed_anomaly_detection_condition_groups.py b/tests/sentry/migrations/test_0121_repair_mixed_anomaly_detection_condition_groups.py new file mode 100644 index 000000000000..c23f0d3b2ccd --- /dev/null +++ b/tests/sentry/migrations/test_0121_repair_mixed_anomaly_detection_condition_groups.py @@ -0,0 +1,104 @@ +from sentry.testutils.cases import TestMigrations + + +class RepairMixedAnomalyDetectionConditionGroupsTest(TestMigrations): + app = "workflow_engine" + migrate_from = "0120_replace_boolean_deescalation_comparisons" # pyright: ignore[reportAssignmentType] + migrate_to = "0121_repair_mixed_anomaly_detection_condition_groups" # pyright: ignore[reportAssignmentType] + + def setup_before_migration(self, apps): + DataCondition = apps.get_model("workflow_engine", "DataCondition") + DataConditionGroup = apps.get_model("workflow_engine", "DataConditionGroup") + + mixed_group = DataConditionGroup.objects.create(organization_id=self.organization.id) + self.mixed_anomaly_id = DataCondition.objects.create( + condition_group_id=mixed_group.id, + type="anomaly_detection", + comparison=0.0, + condition_result=2, + ).id + self.mixed_static_ids = [ + DataCondition.objects.create( + condition_group_id=mixed_group.id, + type=condition_type, + comparison=comparison, + condition_result=condition_result, + ).id + for condition_type, comparison, condition_result in ( + ("lte", 0.0, 0), + ("gt", 100.0, 2), + ) + ] + + anomalies_only_group = DataConditionGroup.objects.create( + organization_id=self.organization.id + ) + self.anomalies_only_ids = [ + DataCondition.objects.create( + condition_group_id=anomalies_only_group.id, + type="anomaly_detection", + comparison={ + "seasonality": "weekly", + "sensitivity": "high", + "threshold_type": 0, + }, + condition_result=condition_result, + ).id + for condition_result in (1, 2) + ] + + single_anomaly_group = DataConditionGroup.objects.create( + organization_id=self.organization.id + ) + self.single_anomaly_id = DataCondition.objects.create( + condition_group_id=single_anomaly_group.id, + type="anomaly_detection", + comparison={ + "seasonality": "daily", + "sensitivity": "medium", + "threshold_type": 1, + }, + condition_result=2, + ).id + + static_group = DataConditionGroup.objects.create(organization_id=self.organization.id) + self.static_condition_ids = [ + DataCondition.objects.create( + condition_group_id=static_group.id, + type=condition_type, + comparison=comparison, + condition_result=condition_result, + ).id + for condition_type, comparison, condition_result in ( + ("gt", 10.0, 2), + ("lte", 10.0, 0), + ) + ] + + def test_repair_mixed_anomaly_detection_condition_groups(self): + DataCondition = self.apps.get_model("workflow_engine", "DataCondition") + default_comparison = { + "seasonality": "auto", + "sensitivity": "low", + "threshold_type": 2, + } + + mixed_anomaly = DataCondition.objects.get(id=self.mixed_anomaly_id) + assert mixed_anomaly.comparison == default_comparison + assert not DataCondition.objects.filter(id__in=self.mixed_static_ids).exists() + + anomalies_only = DataCondition.objects.filter(id__in=self.anomalies_only_ids).order_by("id") + assert anomalies_only.count() == 2 + assert list(anomalies_only.values_list("comparison", flat=True)) == [ + default_comparison, + default_comparison, + ] + + single_anomaly = DataCondition.objects.get(id=self.single_anomaly_id) + assert single_anomaly.comparison == { + "seasonality": "daily", + "sensitivity": "medium", + "threshold_type": 1, + } + + assert DataCondition.objects.filter(id__in=self.static_condition_ids).count() == 2