From 2e14b57e44cdc77743a26d493026faaa1a4dc150 Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Tue, 14 Apr 2026 15:10:11 -0700 Subject: [PATCH 1/6] Implement EAP group search for issue feed queries --- .../search/eap/occurrences/aggregates.py | 18 +++ .../search/eap/occurrences/search_executor.py | 114 ++++++++++++++++ .../sentry/search/eap/test_search_executor.py | 126 +++++++++++++++++- 3 files changed, 256 insertions(+), 2 deletions(-) diff --git a/src/sentry/search/eap/occurrences/aggregates.py b/src/sentry/search/eap/occurrences/aggregates.py index fca83112b34712..f75ae1bced4faf 100644 --- a/src/sentry/search/eap/occurrences/aggregates.py +++ b/src/sentry/search/eap/occurrences/aggregates.py @@ -113,6 +113,24 @@ ], ), "count_unique": count_unique_aggregate_definition(default_arg="group_id"), + "first_seen": AggregateDefinition( + internal_function=Function.FUNCTION_MIN, + default_search_type="integer", + infer_search_type_from_arguments=False, + arguments=[ + AttributeArgumentDefinition( + attribute_types={ + "duration", + "number", + "integer", + "string", + *constants.SIZE_TYPE, + *constants.DURATION_TYPE, + }, + default_arg="timestamp", + ) + ], + ), "last_seen": AggregateDefinition( internal_function=Function.FUNCTION_MAX, default_search_type="integer", diff --git a/src/sentry/search/eap/occurrences/search_executor.py b/src/sentry/search/eap/occurrences/search_executor.py index b4debe3dd79695..20b578177780ff 100644 --- a/src/sentry/search/eap/occurrences/search_executor.py +++ b/src/sentry/search/eap/occurrences/search_executor.py @@ -1,8 +1,16 @@ import logging from collections.abc import Sequence from datetime import datetime +from typing import Any from sentry.api.event_search import SearchFilter +from sentry.models.environment import Environment +from sentry.models.organization import Organization +from sentry.models.project import Project +from sentry.search.eap.occurrences.query_utils import build_group_id_in_filter +from sentry.search.eap.types import SearchResolverConfig +from sentry.search.events.types import SnubaParams +from sentry.snuba.occurrences_rpc import Occurrences from sentry.utils import metrics logger = logging.getLogger(__name__) @@ -129,6 +137,112 @@ def _convert_error_unhandled(sf: SearchFilter) -> str | None: return "error.handled:1" +# Maps legacy sort_field names (from PostgresSnubaQueryExecutor.sort_strategies values) +# to (selected_columns, orderby) for EAP queries. +# +# Reference — legacy sort_strategies in executors.py: +# "date" → "last_seen" → max(timestamp) * 1000 +# "freq" → "times_seen" → count() +# "new" → "first_seen" → min(coalesce(group_first_seen, timestamp)) * 1000 +# "user" → "user_count" → uniq(tags[sentry:user]) +# "trends" → "trends" → complex ClickHouse expression (not supported) +# "recommended" → "recommended" → complex ClickHouse expression (not supported) +# "inbox" → "" → Postgres only (not supported) +EAP_SORT_STRATEGIES: dict[str, tuple[list[str], list[str]]] = { + "last_seen": (["group_id", "last_seen()"], ["-last_seen()"]), + "times_seen": (["group_id", "count()"], ["-count()"]), +} + + +def run_eap_group_search( + start: datetime, + end: datetime, + project_ids: Sequence[int], + environment_ids: Sequence[int] | None, + sort_field: str, + organization: Organization, + group_ids: Sequence[int] | None = None, + limit: int | None = None, + offset: int = 0, + search_filters: Sequence[SearchFilter] | None = None, + referrer: str = "", +) -> tuple[list[tuple[int, Any]], int]: + """EAP equivalent of PostgresSnubaQueryExecutor.snuba_search(). + + Returns a tuple of: + * a list of (group_id, sort_score) tuples, + * total count (0 during double-reading; legacy provides the real total). + + This matches the return signature of snuba_search() so it can be used + as the experimental branch in check_and_choose(). + """ + if sort_field not in EAP_SORT_STRATEGIES: + return ([], 0) + + selected_columns, orderby = EAP_SORT_STRATEGIES[sort_field] + score_column = selected_columns[1] # e.g. "last_seen()" or "count()" + + projects = list(Project.objects.filter(id__in=project_ids)) + if not projects: + return ([], 0) + + environments: list[Environment] = [] + if environment_ids: + environments = list( + Environment.objects.filter(organization_id=organization.id, id__in=environment_ids) + ) + + snuba_params = SnubaParams( + start=start, + end=end, + organization=organization, + projects=projects, + environments=environments, + ) + + query_string = search_filters_to_query_string(search_filters or []) + + extra_conditions = None + if group_ids: + extra_conditions = build_group_id_in_filter(group_ids) + + try: + result = Occurrences.run_table_query( + params=snuba_params, + query_string=query_string, + selected_columns=selected_columns, + orderby=orderby, + offset=offset, + limit=limit or 100, + referrer=referrer, + config=SearchResolverConfig(), + extra_conditions=extra_conditions, + ) + except Exception: + logger.exception( + "eap.search_executor.run_table_query_failed", + extra={ + "organization_id": organization.id, + "project_ids": project_ids, + "sort_field": sort_field, + "referrer": referrer, + }, + ) + return ([], 0) + + tuples: list[tuple[int, Any]] = [] + for row in result.get("data", []): + group_id = row.get("group_id") + score = row.get(score_column) + if group_id is not None: + tuples.append((int(group_id), score)) + + # The EAP RPC TraceItemTableResponse does not include a total count + # (unlike Snuba's totals=True). During double-reading the legacy result + # provides the real total, so we return 0 here. + return (tuples, 0) + + def _format_value( raw_value: str | int | float | datetime | Sequence[str] | Sequence[float], ) -> str: diff --git a/tests/sentry/search/eap/test_search_executor.py b/tests/sentry/search/eap/test_search_executor.py index 786f9e90a5b0c3..71e6ba796b4cf2 100644 --- a/tests/sentry/search/eap/test_search_executor.py +++ b/tests/sentry/search/eap/test_search_executor.py @@ -1,7 +1,11 @@ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from sentry.api.event_search import SearchFilter, SearchKey, SearchValue -from sentry.search.eap.occurrences.search_executor import search_filters_to_query_string +from sentry.search.eap.occurrences.search_executor import ( + run_eap_group_search, + search_filters_to_query_string, +) +from sentry.testutils.cases import OccurrenceTestCase, SnubaTestCase, TestCase class TestSearchFiltersToQueryString: @@ -264,3 +268,121 @@ def test_negated_tag_filter(self): def test_wildcard_in_tag(self): filters = [SearchFilter(SearchKey("tags[url]"), "=", SearchValue("*example*"))] assert search_filters_to_query_string(filters) == "tags[url]:*example*" + + +class TestRunEapGroupSearch(TestCase, SnubaTestCase, OccurrenceTestCase): + def setUp(self) -> None: + super().setUp() + self.now = datetime.now(timezone.utc) + self.start = self.now - timedelta(hours=1) + self.end = self.now + timedelta(hours=1) + + self.group1 = self.create_group(project=self.project) + self.group2 = self.create_group(project=self.project) + + # Store 3 error occurrences for group1, 1 warning for group2 + for _ in range(3): + occ = self.create_eap_occurrence( + group_id=self.group1.id, + level="error", + timestamp=self.now - timedelta(minutes=5), + ) + self.store_eap_items([occ]) + + occ = self.create_eap_occurrence( + group_id=self.group2.id, + level="warning", + timestamp=self.now - timedelta(minutes=10), + ) + self.store_eap_items([occ]) + + def test_sort_and_filter(self) -> None: + """Freq sort returns groups ordered by count, and level filter narrows results.""" + # Freq sort — group1 (3 events) should come before group2 (1 event) + result, _ = run_eap_group_search( + start=self.start, + end=self.end, + project_ids=[self.project.id], + environment_ids=None, + sort_field="times_seen", + organization=self.organization, + referrer="test", + ) + group_ids = [gid for gid, _ in result] + assert group_ids[0] == self.group1.id + assert self.group2.id in group_ids + + # Adding a level filter should exclude group2 + result, _ = run_eap_group_search( + start=self.start, + end=self.end, + project_ids=[self.project.id], + environment_ids=None, + sort_field="last_seen", + organization=self.organization, + search_filters=[SearchFilter(SearchKey("level"), "=", SearchValue("error"))], + referrer="test", + ) + group_ids = {gid for gid, _ in result} + assert group_ids == {self.group1.id} + + def test_group_id_pre_filter(self) -> None: + """Pre-filtered group_ids are passed as extra_conditions, narrowing results.""" + result, _ = run_eap_group_search( + start=self.start, + end=self.end, + project_ids=[self.project.id], + environment_ids=None, + sort_field="last_seen", + organization=self.organization, + group_ids=[self.group1.id], + referrer="test", + ) + assert {gid for gid, _ in result} == {self.group1.id} + + def test_environment_filter(self) -> None: + """Environment IDs are applied via SnubaParams to narrow results.""" + env = self.create_environment(project=self.project, name="production") + occ = self.create_eap_occurrence( + group_id=self.group1.id, + level="error", + environment="production", + timestamp=self.now - timedelta(minutes=2), + ) + self.store_eap_items([occ]) + + occ2 = self.create_eap_occurrence( + group_id=self.group2.id, + level="warning", + environment="staging", + timestamp=self.now - timedelta(minutes=2), + ) + self.store_eap_items([occ2]) + + result, _ = run_eap_group_search( + start=self.start, + end=self.end, + project_ids=[self.project.id], + environment_ids=[env.id], + sort_field="last_seen", + organization=self.organization, + referrer="test", + ) + group_ids = {gid for gid, _ in result} + assert self.group1.id in group_ids + assert self.group2.id not in group_ids + + def test_unsupported_sort_returns_empty(self) -> None: + """Unsupported sort strategies (trends, recommended) return empty + so the caller can fall back to the legacy result.""" + result, total = run_eap_group_search( + start=self.start, + end=self.end, + project_ids=[self.project.id], + environment_ids=None, + sort_field="trends", + organization=self.organization, + referrer="test", + ) + assert result == [] + assert total == 0 From 632b750b7bbabac161e317b5e6eb7c87fbcd5020 Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Tue, 14 Apr 2026 15:38:17 -0700 Subject: [PATCH 2/6] Implement double read from EAP in issue feed search flow --- src/sentry/search/snuba/executors.py | 92 +++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/src/sentry/search/snuba/executors.py b/src/sentry/search/snuba/executors.py index 32ca5b06617737..f39e2dad7382de 100644 --- a/src/sentry/search/snuba/executors.py +++ b/src/sentry/search/snuba/executors.py @@ -37,6 +37,8 @@ from sentry.models.group import Group from sentry.models.organization import Organization from sentry.models.project import Project +from sentry.search.eap.occurrences.rollout_utils import EAPOccurrencesComparator +from sentry.search.eap.occurrences.search_executor import EAP_SORT_STRATEGIES, run_eap_group_search from sentry.search.events.filter import convert_search_filter_to_snuba_query, format_search_filter from sentry.snuba.dataset import Dataset from sentry.users.models.user import User @@ -45,6 +47,8 @@ from sentry.utils.cursors import Cursor, CursorResult from sentry.utils.snuba import SnubaQueryParams, aliased_query_params, bulk_raw_query +logger = logging.getLogger(__name__) + FIRST_RELEASE_FILTERS = ["first_release", "firstRelease"] @@ -90,6 +94,19 @@ class Clauses(Enum): ENTITY_SEARCH_ISSUES = "search_issues" +def _reasonable_search_result_match( + control: tuple[list[tuple[int, Any]], int], + experimental: tuple[list[tuple[int, Any]], int], +) -> bool: + control_group_ids = {gid for gid, _ in control[0]} + experimental_group_ids = {gid for gid, _ in experimental[0]} + + if not experimental_group_ids: + return True + + return experimental_group_ids.issubset(control_group_ids) + + @dataclass class TrendsParams: # (event or issue age_hours) / (event or issue halflife hours) @@ -509,7 +526,80 @@ def snuba_search( if get_sample: sort_field = "sample" - return [(row["group_id"], row[sort_field]) for row in rows], total # type: ignore[literal-required] + result = [(row["group_id"], row[sort_field]) for row in rows], total # type: ignore[literal-required] + + # Double-read from EAP for supported sort strategies + if not get_sample and sort_field in EAP_SORT_STRATEGIES: + result = self._maybe_double_read_eap( + result, + start=start, + end=end, + project_ids=project_ids, + environment_ids=environment_ids, + sort_field=sort_field, + organization=organization, + group_ids=group_ids, + limit=limit, + offset=offset, + search_filters=snuba_search_filters, + referrer=referrer, + ) + + return result + + @staticmethod + def _maybe_double_read_eap( + legacy_result: tuple[list[tuple[int, Any]], int], + *, + start: datetime, + end: datetime, + project_ids: Sequence[int], + environment_ids: Sequence[int] | None, + sort_field: str, + organization: Organization, + group_ids: Sequence[int] | None, + limit: int | None, + offset: int, + search_filters: Sequence[SearchFilter], + referrer: str, + ) -> tuple[list[tuple[int, Any]], int]: + callsite = "PostgresSnubaQueryExecutor.snuba_search" + if not EAPOccurrencesComparator.should_check_experiment(callsite): + return legacy_result + + try: + eap_result = run_eap_group_search( + start=start, + end=end, + project_ids=project_ids, + environment_ids=environment_ids, + sort_field=sort_field, + organization=organization, + group_ids=group_ids, + limit=limit, + offset=offset, + search_filters=search_filters, + referrer=referrer, + ) + return EAPOccurrencesComparator.check_and_choose( + control_data=legacy_result, + experimental_data=eap_result, + callsite=callsite, + is_experimental_data_a_null_result=len(eap_result[0]) == 0, + reasonable_match_comparator=_reasonable_search_result_match, + debug_context={ + "sort_field": sort_field, + "organization_id": organization.id, + "num_group_ids": len(group_ids) if group_ids else 0, + "num_filters": len(search_filters), + }, + ) + except Exception: + logger.exception( + "eap.double_read.snuba_search_failed", + extra={"sort_field": sort_field}, + ) + return legacy_result def has_sort_strategy(self, sort_by: str) -> bool: return sort_by in self.sort_strategies.keys() From 7e4cca477eb63f962a28f05dc66d09e3cb7ea9b7 Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Tue, 14 Apr 2026 15:42:48 -0700 Subject: [PATCH 3/6] Fix typing error --- tests/sentry/search/eap/test_search_executor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/sentry/search/eap/test_search_executor.py b/tests/sentry/search/eap/test_search_executor.py index d69a68117c8e3d..633715f665ca65 100644 --- a/tests/sentry/search/eap/test_search_executor.py +++ b/tests/sentry/search/eap/test_search_executor.py @@ -140,7 +140,7 @@ def test_realistic_mixed_query(self): ) -class TestRunEapGroupSearch(TestCase, SnubaTestCase, OccurrenceTestCase): +class TestRunEAPGroupSearch(TestCase, SnubaTestCase, OccurrenceTestCase): def setUp(self) -> None: super().setUp() self.now = datetime.now(timezone.utc) @@ -193,8 +193,8 @@ def test_sort_and_filter(self) -> None: search_filters=[SearchFilter(SearchKey("level"), "=", SearchValue("error"))], referrer="test", ) - group_ids = {gid for gid, _ in result} - assert group_ids == {self.group1.id} + result_group_ids = {gid for gid, _ in result} + assert result_group_ids == {self.group1.id} def test_group_id_pre_filter(self) -> None: """Pre-filtered group_ids are passed as extra_conditions, narrowing results.""" From a363fb7d92c9db8e929ec8fb6b4759fda1d0bbee Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Wed, 15 Apr 2026 11:07:32 -0700 Subject: [PATCH 4/6] Support more sort strategies --- .../search/eap/occurrences/search_executor.py | 2 + .../sentry/search/eap/test_search_executor.py | 121 ++++++++++++++++-- 2 files changed, 109 insertions(+), 14 deletions(-) diff --git a/src/sentry/search/eap/occurrences/search_executor.py b/src/sentry/search/eap/occurrences/search_executor.py index 9f88204bd6e9e1..10119cdfdfdb5a 100644 --- a/src/sentry/search/eap/occurrences/search_executor.py +++ b/src/sentry/search/eap/occurrences/search_executor.py @@ -180,6 +180,8 @@ def _convert_aggregation_filter(sf: SearchFilter) -> str | None: EAP_SORT_STRATEGIES: dict[str, tuple[list[str], list[str]]] = { "last_seen": (["group_id", "last_seen()"], ["-last_seen()"]), "times_seen": (["group_id", "count()"], ["-count()"]), + "first_seen": (["group_id", "first_seen()"], ["-first_seen()"]), + "user_count": (["group_id", "count_unique(user)"], ["-count_unique(user)"]), } diff --git a/tests/sentry/search/eap/test_search_executor.py b/tests/sentry/search/eap/test_search_executor.py index 7330f795706539..66fb8ee6981ac8 100644 --- a/tests/sentry/search/eap/test_search_executor.py +++ b/tests/sentry/search/eap/test_search_executor.py @@ -191,9 +191,22 @@ def setUp(self) -> None: ) self.store_eap_items([occ]) - def test_sort_and_filter(self) -> None: - """Freq sort returns groups ordered by count, and level filter narrows results.""" - # Freq sort — group1 (3 events) should come before group2 (1 event) + def test_last_seen_sort(self) -> None: + result, _ = run_eap_group_search( + start=self.start, + end=self.end, + project_ids=[self.project.id], + environment_ids=None, + sort_field="last_seen", + organization=self.organization, + referrer="test", + ) + group_ids = [gid for gid, _ in result] + assert len(group_ids) == 2 + assert group_ids[0] == self.group1.id + assert group_ids[1] == self.group2.id + + def test_times_seen_sort(self) -> None: result, _ = run_eap_group_search( start=self.start, end=self.end, @@ -204,10 +217,75 @@ def test_sort_and_filter(self) -> None: referrer="test", ) group_ids = [gid for gid, _ in result] + assert len(group_ids) == 2 + assert group_ids[0] == self.group1.id + assert group_ids[1] == self.group2.id + + def test_first_seen_sort(self) -> None: + result, _ = run_eap_group_search( + start=self.start, + end=self.end, + project_ids=[self.project.id], + environment_ids=None, + sort_field="first_seen", + organization=self.organization, + referrer="test", + ) + group_ids = [gid for gid, _ in result] + assert len(group_ids) == 2 assert group_ids[0] == self.group1.id - assert self.group2.id in group_ids + assert group_ids[1] == self.group2.id + + def test_user_count_sort(self) -> None: + group3 = self.create_group(project=self.project) + for i in range(3): + occ = self.create_eap_occurrence( + group_id=group3.id, + level="error", + timestamp=self.now - timedelta(minutes=3), + tags={"sentry:user": f"user-{i}@example.com"}, + ) + self.store_eap_items([occ]) + + occ = self.create_eap_occurrence( + group_id=self.group1.id, + level="error", + timestamp=self.now - timedelta(minutes=3), + tags={"sentry:user": "only-user@example.com"}, + ) + self.store_eap_items([occ]) - # Adding a level filter should exclude group2 + result, _ = run_eap_group_search( + start=self.start, + end=self.end, + project_ids=[self.project.id], + environment_ids=None, + sort_field="user_count", + organization=self.organization, + referrer="test", + ) + group_ids = [gid for gid, _ in result] + assert len(group_ids) == 2 + assert group_ids[0] == group3.id + assert group_ids[1] == self.group1.id + + def test_unsupported_sort_returns_empty(self) -> None: + """Unsupported sort strategies (trends, recommended) return empty + so the caller can fall back to the legacy result.""" + result, total = run_eap_group_search( + start=self.start, + end=self.end, + project_ids=[self.project.id], + environment_ids=None, + sort_field="trends", + organization=self.organization, + referrer="test", + ) + assert result == [] + assert total == 0 + + def test_filter_narrows_results(self) -> None: + """A level filter excludes groups that don't match.""" result, _ = run_eap_group_search( start=self.start, end=self.end, @@ -218,8 +296,8 @@ def test_sort_and_filter(self) -> None: search_filters=[SearchFilter(SearchKey("level"), "=", SearchValue("error"))], referrer="test", ) - result_group_ids = {gid for gid, _ in result} - assert result_group_ids == {self.group1.id} + group_ids = {gid for gid, _ in result} + assert group_ids == {self.group1.id} def test_group_id_pre_filter(self) -> None: """Pre-filtered group_ids are passed as extra_conditions, narrowing results.""" @@ -267,17 +345,32 @@ def test_environment_filter(self) -> None: assert self.group1.id in group_ids assert self.group2.id not in group_ids - def test_unsupported_sort_returns_empty(self) -> None: - """Unsupported sort strategies (trends, recommended) return empty - so the caller can fall back to the legacy result.""" - result, total = run_eap_group_search( + def test_sort_and_filter(self) -> None: + """Combines sorting, filtering, and group_id pre-filtering in one query. + Creates 3 groups across 2 levels, pre-filters to 2 of them, filters by + level, and verifies the remaining group is returned with correct sort order.""" + group3 = self.create_group(project=self.project) + for i in range(5): + occ = self.create_eap_occurrence( + group_id=group3.id, + level="error", + timestamp=self.now - timedelta(minutes=1 + i), + ) + self.store_eap_items([occ]) + + result, _ = run_eap_group_search( start=self.start, end=self.end, project_ids=[self.project.id], environment_ids=None, - sort_field="trends", + sort_field="times_seen", organization=self.organization, + group_ids=[self.group1.id, group3.id], + search_filters=[SearchFilter(SearchKey("level"), "=", SearchValue("error"))], referrer="test", ) - assert result == [] - assert total == 0 + group_ids = [gid for gid, _ in result] + assert len(group_ids) == 2 + assert group_ids[0] == group3.id + assert group_ids[1] == self.group1.id + assert self.group2.id not in group_ids From 5de66af03d0fe92e86ba0ffc25e36ed8c4ad4870 Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Wed, 15 Apr 2026 11:19:18 -0700 Subject: [PATCH 5/6] Minor changes --- src/sentry/search/eap/occurrences/search_executor.py | 5 +++-- tests/sentry/search/eap/test_search_executor.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/sentry/search/eap/occurrences/search_executor.py b/src/sentry/search/eap/occurrences/search_executor.py index 10119cdfdfdb5a..e596363365889f 100644 --- a/src/sentry/search/eap/occurrences/search_executor.py +++ b/src/sentry/search/eap/occurrences/search_executor.py @@ -19,6 +19,7 @@ # Filters that must be skipped because they have no EAP equivalent. # These would silently become dynamic tag lookups in the EAP SearchResolver # (resolver.py:1026-1060) and produce incorrect results. +# TODO: these are potentially gaps between existing issue feed search behavior and EAP search behavior. May need to adddress. SKIP_FILTERS: frozenset[str] = frozenset( { # event.type is added internally by _query_params_for_error(), not from user filters. @@ -52,7 +53,7 @@ AGGREGATION_FIELD_TO_EAP_FUNCTION: dict[str, str] = { "times_seen": "count()", "last_seen": "last_seen()", - "user_count": "count_unique(user.id)", + "user_count": "count_unique(user)", } @@ -151,7 +152,7 @@ def _convert_aggregation_filter(sf: SearchFilter) -> str | None: e.g. times_seen:>100 → count():>100 last_seen:>2024-01-01 → last_seen():>2024-01-01T00:00:00+00:00 - user_count:>5 → count_unique(user.id):>5 + user_count:>5 → count_unique(user):>5 """ eap_function = AGGREGATION_FIELD_TO_EAP_FUNCTION[sf.key.name] formatted_value = _format_value(sf.value.raw_value) diff --git a/tests/sentry/search/eap/test_search_executor.py b/tests/sentry/search/eap/test_search_executor.py index 66fb8ee6981ac8..273e8b55e0e2c9 100644 --- a/tests/sentry/search/eap/test_search_executor.py +++ b/tests/sentry/search/eap/test_search_executor.py @@ -109,7 +109,7 @@ def test_aggregation_filters_translated(self): ), ( SearchFilter(SearchKey("user_count"), ">", SearchValue("5")), - "count_unique(user.id):>5", + "count_unique(user):>5", ), ] for sf, expected in cases: From 385c3e318971974a20e71154b2560ebd71a12ae4 Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Wed, 15 Apr 2026 12:57:32 -0700 Subject: [PATCH 6/6] Cleanup --- src/sentry/search/snuba/executors.py | 110 ++++++++++----------------- 1 file changed, 40 insertions(+), 70 deletions(-) diff --git a/src/sentry/search/snuba/executors.py b/src/sentry/search/snuba/executors.py index 1151ee1381b3ac..85b28f406c6425 100644 --- a/src/sentry/search/snuba/executors.py +++ b/src/sentry/search/snuba/executors.py @@ -527,80 +527,50 @@ def snuba_search( if get_sample: sort_field = "sample" - result = [(row["group_id"], row[sort_field]) for row in rows], total # type: ignore[literal-required] + snuba_result = [(row["group_id"], row[sort_field]) for row in rows], total # type: ignore[literal-required] + result = snuba_result # Double-read from EAP for supported sort strategies - if not get_sample and sort_field in EAP_SORT_STRATEGIES: - result = self._maybe_double_read_eap( - result, - start=start, - end=end, - project_ids=project_ids, - environment_ids=environment_ids, - sort_field=sort_field, - organization=organization, - group_ids=group_ids, - limit=limit, - offset=offset, - search_filters=snuba_search_filters, - referrer=referrer, - ) - - return result - - @staticmethod - def _maybe_double_read_eap( - legacy_result: tuple[list[tuple[int, Any]], int], - *, - start: datetime, - end: datetime, - project_ids: Sequence[int], - environment_ids: Sequence[int] | None, - sort_field: str, - organization: Organization, - group_ids: Sequence[int] | None, - limit: int | None, - offset: int, - search_filters: Sequence[SearchFilter], - referrer: str, - ) -> tuple[list[tuple[int, Any]], int]: callsite = "PostgresSnubaQueryExecutor.snuba_search" - if not EAPOccurrencesComparator.should_check_experiment(callsite): - return legacy_result + if ( + not get_sample + and sort_field in EAP_SORT_STRATEGIES + and EAPOccurrencesComparator.should_check_experiment(callsite) + ): + try: + eap_result = run_eap_group_search( + start=start, + end=end, + project_ids=project_ids, + environment_ids=environment_ids, + sort_field=sort_field, + organization=organization, + group_ids=group_ids, + limit=limit, + offset=offset, + search_filters=snuba_search_filters, + referrer=referrer, + ) + result = EAPOccurrencesComparator.check_and_choose( + snuba_result, + eap_result, + callsite, + is_experimental_data_a_null_result=len(eap_result[0]) == 0, + reasonable_match_comparator=_reasonable_search_result_match, + debug_context={ + "sort_field": sort_field, + "organization_id": organization.id, + "num_group_ids": len(group_ids) if group_ids else 0, + "num_filters": len(snuba_search_filters), + }, + ) + except Exception: + logger.exception( + "eap.double_read.snuba_search_failed", + extra={"callsite": callsite, "sort_field": sort_field}, + ) - try: - eap_result = run_eap_group_search( - start=start, - end=end, - project_ids=project_ids, - environment_ids=environment_ids, - sort_field=sort_field, - organization=organization, - group_ids=group_ids, - limit=limit, - offset=offset, - search_filters=search_filters, - referrer=referrer, - ) - return EAPOccurrencesComparator.check_and_choose( - control_data=legacy_result, - experimental_data=eap_result, - callsite=callsite, - is_experimental_data_a_null_result=len(eap_result[0]) == 0, - reasonable_match_comparator=_reasonable_search_result_match, - debug_context={ - "sort_field": sort_field, - "organization_id": organization.id, - "num_group_ids": len(group_ids) if group_ids else 0, - "num_filters": len(search_filters), - }, - ) - except Exception: - logger.exception( - "eap.double_read.snuba_search_failed", - extra={"sort_field": sort_field}, - ) - return legacy_result + return result def has_sort_strategy(self, sort_by: str) -> bool: return sort_by in self.sort_strategies.keys()