From 304893a1317a77cd7577c274790e8ee7f2a62b29 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:17:51 +0000 Subject: [PATCH 1/3] fix(workflows): clean invalid condition comparisons Co-Authored-By: Kyle Consalus --- ...anup_invalid_data_condition_comparisons.py | 142 ++++++++++++++++++ ...anup_invalid_data_condition_comparisons.py | 94 ++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 src/sentry/workflow_engine/migrations/0120_cleanup_invalid_data_condition_comparisons.py create mode 100644 tests/sentry/migrations/test_0120_cleanup_invalid_data_condition_comparisons.py diff --git a/src/sentry/workflow_engine/migrations/0120_cleanup_invalid_data_condition_comparisons.py b/src/sentry/workflow_engine/migrations/0120_cleanup_invalid_data_condition_comparisons.py new file mode 100644 index 000000000000..f823de544185 --- /dev/null +++ b/src/sentry/workflow_engine/migrations/0120_cleanup_invalid_data_condition_comparisons.py @@ -0,0 +1,142 @@ +import logging +from decimal import Decimal, InvalidOperation + +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, DataCondition) -> 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: + if comparison is True: + sibling_comparisons = DataCondition.objects.filter( + condition_group_id=condition.condition_group_id, + type=ISSUE_PRIORITY_GREATER_OR_EQUAL, + ).values_list("comparison", flat=True) + thresholds = { + threshold + for sibling in sibling_comparisons + if (threshold := priority_integer(sibling)) is not None + } + return thresholds.pop() if len(thresholds) == 1 else None + 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, DataCondition) + 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", "0119_add_index_for_all_project_detectors"), + ] + + 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_0120_cleanup_invalid_data_condition_comparisons.py b/tests/sentry/migrations/test_0120_cleanup_invalid_data_condition_comparisons.py new file mode 100644 index 000000000000..e87554329cf6 --- /dev/null +++ b/tests/sentry/migrations/test_0120_cleanup_invalid_data_condition_comparisons.py @@ -0,0 +1,94 @@ +from sentry.silo.safety import unguarded_write +from sentry.testutils.cases import TestMigrations + + +class CleanupInvalidDataConditionComparisonsTest(TestMigrations): + app = "workflow_engine" + migrate_from = "0119_add_index_for_all_project_detectors" + migrate_to = "0120_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) + deescalation_group = DataConditionGroup.objects.create(organization_id=organization.id) + ambiguous_group = DataConditionGroup.objects.create(organization_id=organization.id) + + DataCondition.objects.create( + condition_group_id=deescalation_group.id, + type="issue_priority_greater_or_equal", + comparison=75, + condition_result=True, + ) + for comparison in (50, 75): + DataCondition.objects.create( + condition_group_id=ambiguous_group.id, + type="issue_priority_greater_or_equal", + comparison=comparison, + condition_result=True, + ) + + values = { + "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, + ), + "deescalation-boolean": ( + "issue_priority_deescalating", + True, + deescalation_group.id, + ), + "invalid-detector": ( + "event_created_by_detector", + "detector", + default_group.id, + ), + "ambiguous-deescalation": ( + "issue_priority_deescalating", + True, + ambiguous_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, + "deescalation-boolean": 75, + "invalid-detector": "detector", + "ambiguous-deescalation": True, + "existing-priority-name": "high", + } From b02fb3f1a5ca5cb7197953ece17b48ac88586ee0 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:28:14 +0000 Subject: [PATCH 2/3] fix(workflows): update migration metadata --- migrations_lockfile.txt | 2 +- .../0120_cleanup_invalid_data_condition_comparisons.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index b93ffa69a339..cd9c18e21639 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: 0119_add_index_for_all_project_detectors +workflow_engine: 0120_cleanup_invalid_data_condition_comparisons diff --git a/src/sentry/workflow_engine/migrations/0120_cleanup_invalid_data_condition_comparisons.py b/src/sentry/workflow_engine/migrations/0120_cleanup_invalid_data_condition_comparisons.py index f823de544185..499389d9caf8 100644 --- a/src/sentry/workflow_engine/migrations/0120_cleanup_invalid_data_condition_comparisons.py +++ b/src/sentry/workflow_engine/migrations/0120_cleanup_invalid_data_condition_comparisons.py @@ -1,5 +1,6 @@ import logging from decimal import Decimal, InvalidOperation +from typing import Any from django.db import migrations from django.db.backends.base.schema import BaseDatabaseSchemaEditor @@ -55,7 +56,7 @@ def priority_integer(value: object) -> int | None: return comparison if comparison in PRIORITY_VALUES else None -def get_replacement(condition, DataCondition) -> int | None: +def get_replacement(condition: Any, DataCondition: Any) -> int | None: comparison = condition.comparison if condition.type == EVENT_SEEN_COUNT: return RESOLVED if comparison is True else positive_integer(comparison) From 74c0e45dfda98dc163f9360f9dc28a36223ecdaa Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:35:46 +0000 Subject: [PATCH 3/3] fix(workflows): type migration test values --- .../test_0120_cleanup_invalid_data_condition_comparisons.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/sentry/migrations/test_0120_cleanup_invalid_data_condition_comparisons.py b/tests/sentry/migrations/test_0120_cleanup_invalid_data_condition_comparisons.py index e87554329cf6..5082b0a52f55 100644 --- a/tests/sentry/migrations/test_0120_cleanup_invalid_data_condition_comparisons.py +++ b/tests/sentry/migrations/test_0120_cleanup_invalid_data_condition_comparisons.py @@ -27,15 +27,15 @@ def setup_before_migration(self, apps): comparison=75, condition_result=True, ) - for comparison in (50, 75): + for threshold in (50, 75): DataCondition.objects.create( condition_group_id=ambiguous_group.id, type="issue_priority_greater_or_equal", - comparison=comparison, + comparison=threshold, condition_result=True, ) - values = { + 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),