diff --git a/src/sentry/replays/usecases/query/__init__.py b/src/sentry/replays/usecases/query/__init__.py index dcab9e74dadf80..fa9f469fa08e76 100644 --- a/src/sentry/replays/usecases/query/__init__.py +++ b/src/sentry/replays/usecases/query/__init__.py @@ -344,7 +344,11 @@ def _query_using_scalar_strategy( try: where = handle_search_filters(scalar_search_config, search_filters) - orderby = handle_ordering(agg_sort_config, sort or "-" + DEFAULT_SORT_FIELD) + orderby = handle_ordering( + agg_sort_config, + sort or "-" + DEFAULT_SORT_FIELD, + tiebreaker="replay_id", # Ensure stable sort when ordering by column with duplicates + ) except RetryAggregated: return _query_using_aggregated_strategy( search_filters, @@ -378,7 +382,11 @@ def _query_using_aggregated_strategy( period_start: datetime, period_stop: datetime, ): - orderby = handle_ordering(agg_sort_config, sort or "-" + DEFAULT_SORT_FIELD) + orderby = handle_ordering( + agg_sort_config, + sort or "-" + DEFAULT_SORT_FIELD, + tiebreaker="replay_id", # Ensure stable sort when ordering by column with duplicates + ) having: list[Condition] = handle_search_filters(agg_search_config, search_filters) having.append(Condition(Function("min", parameters=[Column("segment_id")]), Op.EQ, 0)) @@ -459,11 +467,16 @@ def execute_query(query: Query, tenant_id: dict[str, int], referrer: str) -> Map raise -def handle_ordering(config: dict[str, Expression], sort: str) -> list[OrderBy]: - if sort.startswith("-"): - return [OrderBy(_get_sort_column(config, sort[1:]), Direction.DESC)] - else: - return [OrderBy(_get_sort_column(config, sort), Direction.ASC)] +def handle_ordering( + config: dict[str, Expression], sort: str, tiebreaker: str | None = None +) -> list[OrderBy]: + direction = Direction.DESC if sort.startswith("-") else Direction.ASC + bare_sort = sort[1:] if sort.startswith("-") else sort + + orderby = [OrderBy(_get_sort_column(config, bare_sort), direction)] + if tiebreaker: + orderby.append(OrderBy(Column(tiebreaker), direction)) + return orderby def _get_sort_column(config: dict[str, Expression], column_name: str) -> Function: diff --git a/tests/sentry/replays/endpoints/test_organization_replay_index.py b/tests/sentry/replays/endpoints/test_organization_replay_index.py index 05e308ee574ede..1966c3686d92dc 100644 --- a/tests/sentry/replays/endpoints/test_organization_replay_index.py +++ b/tests/sentry/replays/endpoints/test_organization_replay_index.py @@ -485,6 +485,30 @@ def test_get_replays_duration_sorted(self) -> None: assert response_data["data"][0]["id"] == replay2_id assert response_data["data"][1]["id"] == replay1_id + def test_get_replays_duration_sorted_tiebreaker(self) -> None: + """Test that replays with identical durations have deterministic order when ordering by duration.""" + project = self.create_project(teams=[self.team]) + replay_ids = [uuid.uuid4().hex for _ in range(5)] + + timestamp_start = datetime.datetime.now() - datetime.timedelta(seconds=10) + timestamp_end = datetime.datetime.now() - datetime.timedelta(seconds=5) + + for replay_id in replay_ids: + self.store_replays(mock_replay(timestamp_start, project.id, replay_id, segment_id=0)) + self.store_replays(mock_replay(timestamp_end, project.id, replay_id, segment_id=1)) + + with self.feature(self.features): + response = self.client.get(self.url + "?orderBy=duration") + assert response.status_code == 200, response + asc_order = [r["id"] for r in response.json()["data"]] + + response = self.client.get(self.url + "?orderBy=-duration") + assert response.status_code == 200, response + desc_order = [r["id"] for r in response.json()["data"]] + + assert desc_order == list(reversed(asc_order)) + assert set(asc_order) == set(replay_ids) + def test_get_replays_pagination(self) -> None: """Test replays can be paginated.""" project = self.create_project(teams=[self.team])