diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index d9d7ad3647eb..fda0ac55974c 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -43,4 +43,4 @@ tempest: 0001_squashed_0003_use_encrypted_char_field uptime: 0056_encrypt_uptime_subscription_headers_body -workflow_engine: 0121_add_activation_id_to_detector_state +workflow_engine: 0122_cleanup_invalid_data_condition_comparisons diff --git a/src/sentry/workflow_engine/migrations/0122_cleanup_invalid_data_condition_comparisons.py b/src/sentry/workflow_engine/migrations/0122_cleanup_invalid_data_condition_comparisons.py new file mode 100644 index 000000000000..615dd5123213 --- /dev/null +++ b/src/sentry/workflow_engine/migrations/0122_cleanup_invalid_data_condition_comparisons.py @@ -0,0 +1,132 @@ +import logging +from decimal import Decimal, InvalidOperation +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 django.db.models import Q + +from sentry.new_migrations.migrations import CheckedMigration +from sentry.utils.query import RangeQuerySetWrapperWithProgressBar + +logger = logging.getLogger(__name__) + +EVENT_SEEN_COUNT = "event_seen_count" +EVENT_CREATED_BY_DETECTOR = "event_created_by_detector" +ISSUE_RESOLUTION_CHANGE = "issue_resolution_change" +ISSUE_PRIORITY_DEESCALATING = "issue_priority_deescalating" +ISSUE_PRIORITY_EQUALS = "issue_priority_equals" +ISSUE_PRIORITY_GREATER_OR_EQUAL = "issue_priority_greater_or_equal" + +CONDITION_TYPES = ( + EVENT_SEEN_COUNT, + EVENT_CREATED_BY_DETECTOR, + ISSUE_RESOLUTION_CHANGE, + ISSUE_PRIORITY_DEESCALATING, +) +PRIORITY_CONDITION_TYPES = ( + ISSUE_PRIORITY_EQUALS, + ISSUE_PRIORITY_GREATER_OR_EQUAL, +) +PRIORITY_VALUES = {25, 50, 75} +PRIORITY_NAMES = {"low", "medium", "high"} +RESOLVED = 1 + + +def positive_integer(value: object) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value if value >= 1 else None + if not isinstance(value, (float, str)): + return None + + try: + decimal = Decimal(str(value)) + except InvalidOperation: + return None + if not decimal.is_finite() or decimal < 1 or decimal != decimal.to_integral_value(): + return None + return int(decimal) + + +def priority_integer(value: object) -> int | None: + comparison = positive_integer(value) + return comparison if comparison in PRIORITY_VALUES else None + + +def get_replacement(condition: Any) -> int | None: + comparison = condition.comparison + if condition.type == EVENT_SEEN_COUNT: + return RESOLVED if comparison is True else positive_integer(comparison) + + if condition.type == EVENT_CREATED_BY_DETECTOR: + return positive_integer(comparison) + + if condition.type == ISSUE_RESOLUTION_CHANGE: + if comparison is True or comparison == "resolved": + return RESOLVED + resolved = positive_integer(comparison) + return resolved if resolved == RESOLVED else None + + if condition.type in PRIORITY_CONDITION_TYPES: + return priority_integer(comparison) + + if condition.type == ISSUE_PRIORITY_DEESCALATING: + return priority_integer(comparison) + + return None + + +def cleanup_invalid_data_condition_comparisons( + apps: StateApps, schema_editor: BaseDatabaseSchemaEditor +) -> None: + DataCondition = apps.get_model("workflow_engine", "DataCondition") + conditions = DataCondition.objects.filter( + Q(type__in=CONDITION_TYPES) + | Q(type__in=PRIORITY_CONDITION_TYPES) & ~Q(comparison__in=PRIORITY_NAMES) + ) + + for condition in RangeQuerySetWrapperWithProgressBar(conditions): + old_comparison = condition.comparison + replacement = get_replacement(condition) + logging_extra = { + "data_condition_id": condition.id, + "data_condition_type": condition.type, + "condition_group_id": condition.condition_group_id, + "old_comparison": old_comparison, + } + + if replacement is None: + logger.warning( + "Data condition comparison could not be safely converted and was left unchanged", + extra=logging_extra, + ) + continue + + if type(old_comparison) is int and old_comparison == replacement: + continue + + condition.comparison = replacement + condition.save(update_fields=["comparison"]) + logger.info( + "Data condition comparison converted", + extra={**logging_extra, "new_comparison": replacement}, + ) + + +class Migration(CheckedMigration): + is_post_deployment = True + + dependencies = [ + ("workflow_engine", "0121_add_activation_id_to_detector_state"), + ] + + operations = [ + migrations.RunPython( + cleanup_invalid_data_condition_comparisons, + reverse_code=migrations.RunPython.noop, + hints={"tables": ["workflow_engine_datacondition"]}, + ), + ] diff --git a/tests/sentry/migrations/test_0122_cleanup_invalid_data_condition_comparisons.py b/tests/sentry/migrations/test_0122_cleanup_invalid_data_condition_comparisons.py new file mode 100644 index 000000000000..1fc7869e43be --- /dev/null +++ b/tests/sentry/migrations/test_0122_cleanup_invalid_data_condition_comparisons.py @@ -0,0 +1,65 @@ +from sentry.silo.safety import unguarded_write +from sentry.testutils.cases import TestMigrations + + +class CleanupInvalidDataConditionComparisonsTest(TestMigrations): + app = "workflow_engine" + migrate_from = "0121_add_activation_id_to_detector_state" + migrate_to = "0122_cleanup_invalid_data_condition_comparisons" + + def setup_before_migration(self, apps): + Organization = apps.get_model("sentry", "Organization") + DataCondition = apps.get_model("workflow_engine", "DataCondition") + DataConditionGroup = apps.get_model("workflow_engine", "DataConditionGroup") + + with unguarded_write(using="default"): + organization = Organization.objects.create( + slug="cleanup-data-condition-comparisons", + name="Cleanup Data Condition Comparisons", + ) + default_group = DataConditionGroup.objects.create(organization_id=organization.id) + values: dict[str, tuple[str, object, int]] = { + "seen-string": ("event_seen_count", "2", default_group.id), + "seen-boolean": ("event_seen_count", True, default_group.id), + "detector-string": ("event_created_by_detector", "42", default_group.id), + "resolution-string": ("issue_resolution_change", "resolved", default_group.id), + "priority-string": ("issue_priority_equals", "25", default_group.id), + "deescalation-string": ( + "issue_priority_deescalating", + "50", + default_group.id, + ), + "invalid-detector": ( + "event_created_by_detector", + "detector", + default_group.id, + ), + "existing-priority-name": ("issue_priority_equals", "high", default_group.id), + } + self.condition_ids = {} + for name, (condition_type, comparison, group_id) in values.items(): + condition = DataCondition.objects.create( + condition_group_id=group_id, + type=condition_type, + comparison=comparison, + condition_result=True, + ) + self.condition_ids[name] = condition.id + + def test(self): + DataCondition = self.apps.get_model("workflow_engine", "DataCondition") + comparisons = { + name: DataCondition.objects.get(id=condition_id).comparison + for name, condition_id in self.condition_ids.items() + } + + assert comparisons == { + "seen-string": 2, + "seen-boolean": 1, + "detector-string": 42, + "resolution-string": 1, + "priority-string": 25, + "deescalation-string": 50, + "invalid-detector": "detector", + "existing-priority-name": "high", + }