From b3cd378ebb43a9e5622ceb7e32e3f58d107972b9 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 4 Nov 2025 11:39:28 -0800 Subject: [PATCH 01/32] add build number sorting --- .../api/endpoints/organization_releases.py | 4 ++-- .../organization_trace_item_attributes.py | 19 +++++++++------ src/sentry/api/helpers/group_index/update.py | 1 + src/sentry/models/release.py | 16 ++++++++++++- src/sentry/models/releases/util.py | 24 +++++++++++++++++-- src/sentry/tagstore/snuba/backend.py | 19 +++++++++------ 6 files changed, 64 insertions(+), 19 deletions(-) diff --git a/src/sentry/api/endpoints/organization_releases.py b/src/sentry/api/endpoints/organization_releases.py index bde8654f36301d..5bcaabbc54147e 100644 --- a/src/sentry/api/endpoints/organization_releases.py +++ b/src/sentry/api/endpoints/organization_releases.py @@ -381,7 +381,7 @@ def __get_new(self, request: Request, organization: Organization) -> Response: queryset = queryset.filter(build_number__isnull=False).order_by("-build_number") paginator_kwargs["order_by"] = "-build_number" elif sort == "semver": - queryset = queryset.annotate_prerelease_column() + queryset = queryset.annotate_prerelease_column().annotate_build_number_column() order_by = [F(col).desc(nulls_last=True) for col in Release.SEMVER_COLS] # TODO: Adding this extra sort order breaks index usage. Index usage is already broken @@ -553,7 +553,7 @@ def __get_old(self, request: Request, organization: Organization) -> Response: queryset = queryset.filter(build_number__isnull=False).order_by("-build_number") paginator_kwargs["order_by"] = "-build_number" elif sort == "semver": - queryset = queryset.annotate_prerelease_column() + queryset = queryset.annotate_prerelease_column().annotate_build_number_column() order_by = [F(col).desc(nulls_last=True) for col in Release.SEMVER_COLS] # TODO: Adding this extra sort order breaks index usage. Index usage is already broken diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index 91d79147e18537..914c34683a19e1 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -470,6 +470,7 @@ def semver_autocomplete_function(self): order_by = map(_flip_field_sort, Release.SEMVER_COLS + ["package"]) versions = versions.filter_to_semver() # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet versions = versions.annotate_prerelease_column() + versions = versions.annotate_build_number_column() versions = versions.order_by(*order_by) seen = set() @@ -547,13 +548,17 @@ def semver_package_autocomplete_function(self): .distinct() ) - versions = Release.objects.filter( - organization_id=self.snuba_params.organization_id, - package__in=packages, - id__in=ReleaseProject.objects.filter( - project_id__in=self.snuba_params.project_ids - ).values_list("release_id", flat=True), - ).annotate_prerelease_column() # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet + versions = ( + Release.objects.filter( + organization_id=self.snuba_params.organization_id, + package__in=packages, + id__in=ReleaseProject.objects.filter( + project_id__in=self.snuba_params.project_ids + ).values_list("release_id", flat=True), + ) + .annotate_prerelease_column() + .annotate_build_number_column() + ) # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet environment_ids = self.snuba_params.environment_ids if environment_ids: diff --git a/src/sentry/api/helpers/group_index/update.py b/src/sentry/api/helpers/group_index/update.py index 359c3cf275dce5..459d4d22c1bd62 100644 --- a/src/sentry/api/helpers/group_index/update.py +++ b/src/sentry/api/helpers/group_index/update.py @@ -854,6 +854,7 @@ def get_semver_releases(project: Project) -> QuerySet[Release]: Release.objects.filter(projects=project, organization_id=project.organization_id) .filter_to_semver() # type: ignore[attr-defined] .annotate_prerelease_column() + .annotate_build_number_column() .order_by(*[f"-{col}" for col in Release.SEMVER_COLS]) ) diff --git a/src/sentry/models/release.py b/src/sentry/models/release.py index 23deab547d01f2..0da27c4fb99abc 100644 --- a/src/sentry/models/release.py +++ b/src/sentry/models/release.py @@ -101,6 +101,9 @@ def get_queryset(self) -> ReleaseQuerySet: def annotate_prerelease_column(self): return self.get_queryset().annotate_prerelease_column() + def annotate_build_number_column(self): + return self.get_queryset().annotate_build_number_column() + def filter_to_semver(self) -> ReleaseQuerySet: return self.get_queryset().filter_to_semver() @@ -299,7 +302,16 @@ class Meta: __repr__ = sane_repr("organization_id", "version") - SEMVER_COLS = ["major", "minor", "patch", "revision", "prerelease_case", "prerelease"] + SEMVER_COLS = [ + "major", + "minor", + "patch", + "revision", + "prerelease_case", + "prerelease", + "build_number_case", + "build_number", + ] def __eq__(self, other: object) -> bool: """Make sure that specialized releases are only comparable to the same @@ -398,6 +410,8 @@ def semver_tuple(self) -> SemverVersion: self.revision, 1 if self.prerelease == "" else 0, self.prerelease, + 1 if self.build_number is None else 0, + self.build_number, ) @classmethod diff --git a/src/sentry/models/releases/util.py b/src/sentry/models/releases/util.py index 66fa5b2829f5fe..5c9b9039c27b51 100644 --- a/src/sentry/models/releases/util.py +++ b/src/sentry/models/releases/util.py @@ -24,7 +24,10 @@ class SemverVersion( - namedtuple("SemverVersion", "major minor patch revision prerelease_case prerelease") + namedtuple( + "SemverVersion", + "major minor patch revision prerelease_case prerelease build_number_case build_number", + ) ): pass @@ -50,6 +53,19 @@ def annotate_prerelease_column(self): ) ) + def annotate_build_number_column(self): + """ + Adds a `build_number` column to the queryset which is used to properly sort + by build number. We treat a null build number as higher than any other value. + """ + return self.annotate( + build_number_case=Case( + When(build_number__isnull=True, then=1), + default=0, + output_field=models.IntegerField(), + ) + ) + def filter_to_semver(self) -> Self: """ Filters the queryset to only include semver compatible rows @@ -107,7 +123,11 @@ def filter_by_semver( Typically we build a `SemverFilter` via `sentry.search.events.filter.parse_semver` """ - qs = self.filter(organization_id=organization_id).annotate_prerelease_column() + qs = ( + self.filter(organization_id=organization_id) + .annotate_prerelease_column() + .annotate_build_number_column() + ) query_func = "exclude" if semver_filter.negated else "filter" if semver_filter.package: diff --git a/src/sentry/tagstore/snuba/backend.py b/src/sentry/tagstore/snuba/backend.py index 38e9f6a91bc9bb..7da47503f87c0f 100644 --- a/src/sentry/tagstore/snuba/backend.py +++ b/src/sentry/tagstore/snuba/backend.py @@ -1043,13 +1043,17 @@ def _get_semver_versions_for_package(self, projects, organization_id, package): .distinct() ) - return Release.objects.filter( - organization_id=organization_id, - package__in=packages, - id__in=ReleaseProject.objects.filter(project_id__in=projects).values_list( - "release_id", flat=True - ), - ).annotate_prerelease_column() # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet + return ( + Release.objects.filter( + organization_id=organization_id, + package__in=packages, + id__in=ReleaseProject.objects.filter(project_id__in=projects).values_list( + "release_id", flat=True + ), + ) + .annotate_prerelease_column() + .annotate_build_number_column() + ) # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet def _get_tag_values_for_semver( self, @@ -1093,6 +1097,7 @@ def _get_tag_values_for_semver( versions = ( versions.filter_to_semver() .annotate_prerelease_column() + .annotate_build_number_column() .order_by(*order_by) .values_list("version", flat=True)[:1000] ) From 0eb0eca8687964305a653e3ef0013e62a8829d09 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 4 Nov 2025 11:52:42 -0800 Subject: [PATCH 02/32] ensure semver order test fails without build number ordering --- tests/sentry/api/endpoints/test_organization_releases.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/sentry/api/endpoints/test_organization_releases.py b/tests/sentry/api/endpoints/test_organization_releases.py index ec8720a1974318..078a39da1a987b 100644 --- a/tests/sentry/api/endpoints/test_organization_releases.py +++ b/tests/sentry/api/endpoints/test_organization_releases.py @@ -249,12 +249,12 @@ def test_release_list_order_by_build_number(self) -> None: def test_release_list_order_by_semver(self) -> None: self.login_as(user=self.user) release_1 = self.create_release(version="test@2.2") - release_2 = self.create_release(version="test@10.0+122") + release_7 = self.create_release(version="test@10.0+123") release_3 = self.create_release(version="test@2.2-alpha") release_4 = self.create_release(version="test@2.2.3") release_5 = self.create_release(version="test@2.20.3") release_6 = self.create_release(version="test@2.20.3.3") - release_7 = self.create_release(version="test@10.0+123") + release_2 = self.create_release(version="test@10.0+122") release_8 = self.create_release(version="test@some_thing") release_9 = self.create_release(version="random_junk") From afac5b0c445544861e6decada81cd761174b3618 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 4 Nov 2025 15:53:55 -0800 Subject: [PATCH 03/32] add compare_version test --- tests/sentry/models/test_release.py | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/sentry/models/test_release.py b/tests/sentry/models/test_release.py index 8c7763d737f8c6..0c30163613ab62 100644 --- a/tests/sentry/models/test_release.py +++ b/tests/sentry/models/test_release.py @@ -4,6 +4,7 @@ import pytest from django.core.exceptions import ValidationError from django.utils import timezone +from sentry_relay.processing import compare_version, parse_release from sentry.api.exceptions import InvalidRepository from sentry.api.release_search import INVALID_SEMVER_MESSAGE @@ -1903,3 +1904,33 @@ def test_get_unused_filter_allows_cleanup_with_old_group_resolutions(self): unused_filter = Release.get_unused_filter(self.cutoff_date) unused_releases = Release.objects.filter(unused_filter) assert old_release in unused_releases + + +class ReleaseOrderBySemverTestCase(TestCase): + def test_compare_version_sorts_by_build_number(self): + """Test that compare_version correctly sorts releases by build number with various formats""" + + # numeric builds (numerical comparison) + v1 = parse_release("app@1.0.0+999")["version_raw"] + v2 = parse_release("app@1.0.0+1000")["version_raw"] + assert compare_version(v1, v2) == -1 + assert compare_version(v2, v1) == 1 + + # alphanumeric builds (lexicographic comparison) + v1 = parse_release("app@1.0.0+abc")["version_raw"] + v2 = parse_release("app@1.0.0+xyz")["version_raw"] + assert compare_version(v1, v2) == -1 + assert compare_version(v2, v1) == 1 + v1 = parse_release("app@1.0.0+z1b2c3d")["version_raw"] + v2 = parse_release("app@1.0.0+z9y8x7w")["version_raw"] + assert compare_version(v1, v2) == -1 + assert compare_version(v2, v1) == 1 + v1 = parse_release("app@1.0.0+build.45")["version_raw"] + v2 = parse_release("app@1.0.0+build.123")["version_raw"] + assert compare_version(v1, v2) == 1 + assert compare_version(v2, v1) == -1 + + # no build vs. with build (with build > no build) + v_no_build = parse_release("app@1.0.0")["version_raw"] + v_with_build = parse_release("app@1.0.0+123")["version_raw"] + assert compare_version(v_no_build, v_with_build) == -1 From e4797205e241958251008b926d1c3acdf4506b66 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 4 Nov 2025 15:56:38 -0800 Subject: [PATCH 04/32] clean up test --- tests/sentry/api/endpoints/test_organization_releases.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/sentry/api/endpoints/test_organization_releases.py b/tests/sentry/api/endpoints/test_organization_releases.py index 078a39da1a987b..42ffae0fa0f929 100644 --- a/tests/sentry/api/endpoints/test_organization_releases.py +++ b/tests/sentry/api/endpoints/test_organization_releases.py @@ -249,12 +249,12 @@ def test_release_list_order_by_build_number(self) -> None: def test_release_list_order_by_semver(self) -> None: self.login_as(user=self.user) release_1 = self.create_release(version="test@2.2") - release_7 = self.create_release(version="test@10.0+123") + release_2 = self.create_release(version="test@10.0+123") release_3 = self.create_release(version="test@2.2-alpha") release_4 = self.create_release(version="test@2.2.3") release_5 = self.create_release(version="test@2.20.3") release_6 = self.create_release(version="test@2.20.3.3") - release_2 = self.create_release(version="test@10.0+122") + release_7 = self.create_release(version="test@10.0+122") release_8 = self.create_release(version="test@some_thing") release_9 = self.create_release(version="random_junk") @@ -262,8 +262,8 @@ def test_release_list_order_by_semver(self) -> None: self.assert_expected_versions( response, [ - release_7, release_2, + release_7, release_6, release_5, release_4, From 185a8a1074a2a93211413762f9fd8a28ec826c63 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 4 Nov 2025 16:14:19 -0800 Subject: [PATCH 05/32] remove annotation --- .../api/endpoints/organization_releases.py | 4 ++-- .../organization_trace_item_attributes.py | 19 +++++++---------- src/sentry/api/helpers/group_index/update.py | 1 - src/sentry/models/release.py | 5 ----- src/sentry/models/releases/util.py | 21 ++----------------- src/sentry/tagstore/snuba/backend.py | 19 +++++++---------- 6 files changed, 18 insertions(+), 51 deletions(-) diff --git a/src/sentry/api/endpoints/organization_releases.py b/src/sentry/api/endpoints/organization_releases.py index 5bcaabbc54147e..bde8654f36301d 100644 --- a/src/sentry/api/endpoints/organization_releases.py +++ b/src/sentry/api/endpoints/organization_releases.py @@ -381,7 +381,7 @@ def __get_new(self, request: Request, organization: Organization) -> Response: queryset = queryset.filter(build_number__isnull=False).order_by("-build_number") paginator_kwargs["order_by"] = "-build_number" elif sort == "semver": - queryset = queryset.annotate_prerelease_column().annotate_build_number_column() + queryset = queryset.annotate_prerelease_column() order_by = [F(col).desc(nulls_last=True) for col in Release.SEMVER_COLS] # TODO: Adding this extra sort order breaks index usage. Index usage is already broken @@ -553,7 +553,7 @@ def __get_old(self, request: Request, organization: Organization) -> Response: queryset = queryset.filter(build_number__isnull=False).order_by("-build_number") paginator_kwargs["order_by"] = "-build_number" elif sort == "semver": - queryset = queryset.annotate_prerelease_column().annotate_build_number_column() + queryset = queryset.annotate_prerelease_column() order_by = [F(col).desc(nulls_last=True) for col in Release.SEMVER_COLS] # TODO: Adding this extra sort order breaks index usage. Index usage is already broken diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index 914c34683a19e1..91d79147e18537 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -470,7 +470,6 @@ def semver_autocomplete_function(self): order_by = map(_flip_field_sort, Release.SEMVER_COLS + ["package"]) versions = versions.filter_to_semver() # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet versions = versions.annotate_prerelease_column() - versions = versions.annotate_build_number_column() versions = versions.order_by(*order_by) seen = set() @@ -548,17 +547,13 @@ def semver_package_autocomplete_function(self): .distinct() ) - versions = ( - Release.objects.filter( - organization_id=self.snuba_params.organization_id, - package__in=packages, - id__in=ReleaseProject.objects.filter( - project_id__in=self.snuba_params.project_ids - ).values_list("release_id", flat=True), - ) - .annotate_prerelease_column() - .annotate_build_number_column() - ) # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet + versions = Release.objects.filter( + organization_id=self.snuba_params.organization_id, + package__in=packages, + id__in=ReleaseProject.objects.filter( + project_id__in=self.snuba_params.project_ids + ).values_list("release_id", flat=True), + ).annotate_prerelease_column() # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet environment_ids = self.snuba_params.environment_ids if environment_ids: diff --git a/src/sentry/api/helpers/group_index/update.py b/src/sentry/api/helpers/group_index/update.py index 459d4d22c1bd62..359c3cf275dce5 100644 --- a/src/sentry/api/helpers/group_index/update.py +++ b/src/sentry/api/helpers/group_index/update.py @@ -854,7 +854,6 @@ def get_semver_releases(project: Project) -> QuerySet[Release]: Release.objects.filter(projects=project, organization_id=project.organization_id) .filter_to_semver() # type: ignore[attr-defined] .annotate_prerelease_column() - .annotate_build_number_column() .order_by(*[f"-{col}" for col in Release.SEMVER_COLS]) ) diff --git a/src/sentry/models/release.py b/src/sentry/models/release.py index 0da27c4fb99abc..d66a6ad7265a23 100644 --- a/src/sentry/models/release.py +++ b/src/sentry/models/release.py @@ -101,9 +101,6 @@ def get_queryset(self) -> ReleaseQuerySet: def annotate_prerelease_column(self): return self.get_queryset().annotate_prerelease_column() - def annotate_build_number_column(self): - return self.get_queryset().annotate_build_number_column() - def filter_to_semver(self) -> ReleaseQuerySet: return self.get_queryset().filter_to_semver() @@ -309,7 +306,6 @@ class Meta: "revision", "prerelease_case", "prerelease", - "build_number_case", "build_number", ] @@ -410,7 +406,6 @@ def semver_tuple(self) -> SemverVersion: self.revision, 1 if self.prerelease == "" else 0, self.prerelease, - 1 if self.build_number is None else 0, self.build_number, ) diff --git a/src/sentry/models/releases/util.py b/src/sentry/models/releases/util.py index 5c9b9039c27b51..635ada6c9cd352 100644 --- a/src/sentry/models/releases/util.py +++ b/src/sentry/models/releases/util.py @@ -26,7 +26,7 @@ class SemverVersion( namedtuple( "SemverVersion", - "major minor patch revision prerelease_case prerelease build_number_case build_number", + "major minor patch revision prerelease_case prerelease build_number", ) ): pass @@ -53,19 +53,6 @@ def annotate_prerelease_column(self): ) ) - def annotate_build_number_column(self): - """ - Adds a `build_number` column to the queryset which is used to properly sort - by build number. We treat a null build number as higher than any other value. - """ - return self.annotate( - build_number_case=Case( - When(build_number__isnull=True, then=1), - default=0, - output_field=models.IntegerField(), - ) - ) - def filter_to_semver(self) -> Self: """ Filters the queryset to only include semver compatible rows @@ -123,11 +110,7 @@ def filter_by_semver( Typically we build a `SemverFilter` via `sentry.search.events.filter.parse_semver` """ - qs = ( - self.filter(organization_id=organization_id) - .annotate_prerelease_column() - .annotate_build_number_column() - ) + qs = self.filter(organization_id=organization_id).annotate_prerelease_column() query_func = "exclude" if semver_filter.negated else "filter" if semver_filter.package: diff --git a/src/sentry/tagstore/snuba/backend.py b/src/sentry/tagstore/snuba/backend.py index 7da47503f87c0f..38e9f6a91bc9bb 100644 --- a/src/sentry/tagstore/snuba/backend.py +++ b/src/sentry/tagstore/snuba/backend.py @@ -1043,17 +1043,13 @@ def _get_semver_versions_for_package(self, projects, organization_id, package): .distinct() ) - return ( - Release.objects.filter( - organization_id=organization_id, - package__in=packages, - id__in=ReleaseProject.objects.filter(project_id__in=projects).values_list( - "release_id", flat=True - ), - ) - .annotate_prerelease_column() - .annotate_build_number_column() - ) # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet + return Release.objects.filter( + organization_id=organization_id, + package__in=packages, + id__in=ReleaseProject.objects.filter(project_id__in=projects).values_list( + "release_id", flat=True + ), + ).annotate_prerelease_column() # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet def _get_tag_values_for_semver( self, @@ -1097,7 +1093,6 @@ def _get_tag_values_for_semver( versions = ( versions.filter_to_semver() .annotate_prerelease_column() - .annotate_build_number_column() .order_by(*order_by) .values_list("version", flat=True)[:1000] ) From b78407a24a4177bf0a8ebce90e742a1cdc2b26e4 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 4 Nov 2025 16:19:55 -0800 Subject: [PATCH 06/32] add lexicographic sort on build_code --- src/sentry/models/release.py | 2 ++ src/sentry/models/releases/util.py | 2 +- tests/sentry/api/endpoints/test_organization_releases.py | 4 ++++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/sentry/models/release.py b/src/sentry/models/release.py index d66a6ad7265a23..77460843d981fb 100644 --- a/src/sentry/models/release.py +++ b/src/sentry/models/release.py @@ -307,6 +307,7 @@ class Meta: "prerelease_case", "prerelease", "build_number", + "build_code", ] def __eq__(self, other: object) -> bool: @@ -407,6 +408,7 @@ def semver_tuple(self) -> SemverVersion: 1 if self.prerelease == "" else 0, self.prerelease, self.build_number, + self.build_code, ) @classmethod diff --git a/src/sentry/models/releases/util.py b/src/sentry/models/releases/util.py index 635ada6c9cd352..5f7b1c9dfb7863 100644 --- a/src/sentry/models/releases/util.py +++ b/src/sentry/models/releases/util.py @@ -26,7 +26,7 @@ class SemverVersion( namedtuple( "SemverVersion", - "major minor patch revision prerelease_case prerelease build_number", + "major minor patch revision prerelease_case prerelease build_number build_code", ) ): pass diff --git a/tests/sentry/api/endpoints/test_organization_releases.py b/tests/sentry/api/endpoints/test_organization_releases.py index 42ffae0fa0f929..0fddae3c9b3f22 100644 --- a/tests/sentry/api/endpoints/test_organization_releases.py +++ b/tests/sentry/api/endpoints/test_organization_releases.py @@ -257,6 +257,8 @@ def test_release_list_order_by_semver(self) -> None: release_7 = self.create_release(version="test@10.0+122") release_8 = self.create_release(version="test@some_thing") release_9 = self.create_release(version="random_junk") + release_10 = self.create_release(version="test@10.0+abc") + release_11 = self.create_release(version="test@10.0+xyz") response = self.get_success_response(self.organization.slug, sort="semver") self.assert_expected_versions( @@ -264,6 +266,8 @@ def test_release_list_order_by_semver(self) -> None: [ release_2, release_7, + release_11, + release_10, release_6, release_5, release_4, From 3096e533f9eed5012a2c571c5cbc41bb73a3c22a Mon Sep 17 00:00:00 2001 From: srest2021 Date: Wed, 5 Nov 2025 10:29:28 -0800 Subject: [PATCH 07/32] make sure build code test doesnt pass by coincidence --- tests/sentry/api/endpoints/test_organization_releases.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/sentry/api/endpoints/test_organization_releases.py b/tests/sentry/api/endpoints/test_organization_releases.py index 0fddae3c9b3f22..aa7e750a582352 100644 --- a/tests/sentry/api/endpoints/test_organization_releases.py +++ b/tests/sentry/api/endpoints/test_organization_releases.py @@ -257,8 +257,8 @@ def test_release_list_order_by_semver(self) -> None: release_7 = self.create_release(version="test@10.0+122") release_8 = self.create_release(version="test@some_thing") release_9 = self.create_release(version="random_junk") - release_10 = self.create_release(version="test@10.0+abc") - release_11 = self.create_release(version="test@10.0+xyz") + release_10 = self.create_release(version="test@10.0+xyz") + release_11 = self.create_release(version="test@10.0+abc") response = self.get_success_response(self.organization.slug, sort="semver") self.assert_expected_versions( @@ -266,8 +266,8 @@ def test_release_list_order_by_semver(self) -> None: [ release_2, release_7, - release_11, release_10, + release_11, release_6, release_5, release_4, From e3a53a7bf8d3eddd397970f179185069cdfab0a6 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Wed, 5 Nov 2025 12:34:09 -0800 Subject: [PATCH 08/32] add build_number_case annotation --- .../api/endpoints/organization_releases.py | 4 +-- .../organization_trace_item_attributes.py | 19 +++++++----- src/sentry/api/helpers/group_index/update.py | 1 + src/sentry/models/release.py | 12 ++++++++ src/sentry/models/releases/util.py | 25 ++++++++++++++-- src/sentry/tagstore/snuba/backend.py | 19 +++++++----- .../endpoints/test_organization_releases.py | 30 +++++++++++-------- tests/sentry/models/test_release.py | 21 ++++++++++--- 8 files changed, 96 insertions(+), 35 deletions(-) diff --git a/src/sentry/api/endpoints/organization_releases.py b/src/sentry/api/endpoints/organization_releases.py index bde8654f36301d..5bcaabbc54147e 100644 --- a/src/sentry/api/endpoints/organization_releases.py +++ b/src/sentry/api/endpoints/organization_releases.py @@ -381,7 +381,7 @@ def __get_new(self, request: Request, organization: Organization) -> Response: queryset = queryset.filter(build_number__isnull=False).order_by("-build_number") paginator_kwargs["order_by"] = "-build_number" elif sort == "semver": - queryset = queryset.annotate_prerelease_column() + queryset = queryset.annotate_prerelease_column().annotate_build_number_column() order_by = [F(col).desc(nulls_last=True) for col in Release.SEMVER_COLS] # TODO: Adding this extra sort order breaks index usage. Index usage is already broken @@ -553,7 +553,7 @@ def __get_old(self, request: Request, organization: Organization) -> Response: queryset = queryset.filter(build_number__isnull=False).order_by("-build_number") paginator_kwargs["order_by"] = "-build_number" elif sort == "semver": - queryset = queryset.annotate_prerelease_column() + queryset = queryset.annotate_prerelease_column().annotate_build_number_column() order_by = [F(col).desc(nulls_last=True) for col in Release.SEMVER_COLS] # TODO: Adding this extra sort order breaks index usage. Index usage is already broken diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index 91d79147e18537..914c34683a19e1 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -470,6 +470,7 @@ def semver_autocomplete_function(self): order_by = map(_flip_field_sort, Release.SEMVER_COLS + ["package"]) versions = versions.filter_to_semver() # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet versions = versions.annotate_prerelease_column() + versions = versions.annotate_build_number_column() versions = versions.order_by(*order_by) seen = set() @@ -547,13 +548,17 @@ def semver_package_autocomplete_function(self): .distinct() ) - versions = Release.objects.filter( - organization_id=self.snuba_params.organization_id, - package__in=packages, - id__in=ReleaseProject.objects.filter( - project_id__in=self.snuba_params.project_ids - ).values_list("release_id", flat=True), - ).annotate_prerelease_column() # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet + versions = ( + Release.objects.filter( + organization_id=self.snuba_params.organization_id, + package__in=packages, + id__in=ReleaseProject.objects.filter( + project_id__in=self.snuba_params.project_ids + ).values_list("release_id", flat=True), + ) + .annotate_prerelease_column() + .annotate_build_number_column() + ) # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet environment_ids = self.snuba_params.environment_ids if environment_ids: diff --git a/src/sentry/api/helpers/group_index/update.py b/src/sentry/api/helpers/group_index/update.py index 359c3cf275dce5..459d4d22c1bd62 100644 --- a/src/sentry/api/helpers/group_index/update.py +++ b/src/sentry/api/helpers/group_index/update.py @@ -854,6 +854,7 @@ def get_semver_releases(project: Project) -> QuerySet[Release]: Release.objects.filter(projects=project, organization_id=project.organization_id) .filter_to_semver() # type: ignore[attr-defined] .annotate_prerelease_column() + .annotate_build_number_column() .order_by(*[f"-{col}" for col in Release.SEMVER_COLS]) ) diff --git a/src/sentry/models/release.py b/src/sentry/models/release.py index 77460843d981fb..4c12bdc5edc0d8 100644 --- a/src/sentry/models/release.py +++ b/src/sentry/models/release.py @@ -101,6 +101,9 @@ def get_queryset(self) -> ReleaseQuerySet: def annotate_prerelease_column(self): return self.get_queryset().annotate_prerelease_column() + def annotate_build_number_column(self): + return self.get_queryset().annotate_build_number_column() + def filter_to_semver(self) -> ReleaseQuerySet: return self.get_queryset().filter_to_semver() @@ -306,6 +309,7 @@ class Meta: "revision", "prerelease_case", "prerelease", + "build_number_case", "build_number", "build_code", ] @@ -400,6 +404,13 @@ def is_release_newer_or_equal(org_id, release, other_release): @property def semver_tuple(self) -> SemverVersion: + if self.build_number is None and self.build_code is not None: + build_number_case = 2 + elif self.build_number is not None: + build_number_case = 1 + else: + build_number_case = 0 + return SemverVersion( self.major, self.minor, @@ -407,6 +418,7 @@ def semver_tuple(self) -> SemverVersion: self.revision, 1 if self.prerelease == "" else 0, self.prerelease, + build_number_case, self.build_number, self.build_code, ) diff --git a/src/sentry/models/releases/util.py b/src/sentry/models/releases/util.py index 5f7b1c9dfb7863..4fa3908836ac76 100644 --- a/src/sentry/models/releases/util.py +++ b/src/sentry/models/releases/util.py @@ -26,7 +26,7 @@ class SemverVersion( namedtuple( "SemverVersion", - "major minor patch revision prerelease_case prerelease build_number build_code", + "major minor patch revision prerelease_case prerelease build_number_case build_number build_code", ) ): pass @@ -53,6 +53,23 @@ def annotate_prerelease_column(self): ) ) + def annotate_build_number_column(self): + """ + Adds a `build_number_case` column to the queryset which is used to properly sort + by build number to match compare_version behavior: + - Alphanumeric builds (NULL build_number, non-NULL build_code): case=2 (highest) + - Numeric builds (non-NULL build_number): case=1 (middle) + - No build metadata (NULL build_number, NULL build_code): case=0 (lowest) + """ + return self.annotate( + build_number_case=Case( + When(build_number__isnull=True, build_code__isnull=False, then=2), + When(build_number__isnull=False, then=1), + default=0, + output_field=models.IntegerField(), + ) + ) + def filter_to_semver(self) -> Self: """ Filters the queryset to only include semver compatible rows @@ -110,7 +127,11 @@ def filter_by_semver( Typically we build a `SemverFilter` via `sentry.search.events.filter.parse_semver` """ - qs = self.filter(organization_id=organization_id).annotate_prerelease_column() + qs = ( + self.filter(organization_id=organization_id) + .annotate_prerelease_column() + .annotate_build_number_column() + ) query_func = "exclude" if semver_filter.negated else "filter" if semver_filter.package: diff --git a/src/sentry/tagstore/snuba/backend.py b/src/sentry/tagstore/snuba/backend.py index 38e9f6a91bc9bb..7da47503f87c0f 100644 --- a/src/sentry/tagstore/snuba/backend.py +++ b/src/sentry/tagstore/snuba/backend.py @@ -1043,13 +1043,17 @@ def _get_semver_versions_for_package(self, projects, organization_id, package): .distinct() ) - return Release.objects.filter( - organization_id=organization_id, - package__in=packages, - id__in=ReleaseProject.objects.filter(project_id__in=projects).values_list( - "release_id", flat=True - ), - ).annotate_prerelease_column() # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet + return ( + Release.objects.filter( + organization_id=organization_id, + package__in=packages, + id__in=ReleaseProject.objects.filter(project_id__in=projects).values_list( + "release_id", flat=True + ), + ) + .annotate_prerelease_column() + .annotate_build_number_column() + ) # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet def _get_tag_values_for_semver( self, @@ -1093,6 +1097,7 @@ def _get_tag_values_for_semver( versions = ( versions.filter_to_semver() .annotate_prerelease_column() + .annotate_build_number_column() .order_by(*order_by) .values_list("version", flat=True)[:1000] ) diff --git a/tests/sentry/api/endpoints/test_organization_releases.py b/tests/sentry/api/endpoints/test_organization_releases.py index aa7e750a582352..1302e0752541c5 100644 --- a/tests/sentry/api/endpoints/test_organization_releases.py +++ b/tests/sentry/api/endpoints/test_organization_releases.py @@ -257,24 +257,28 @@ def test_release_list_order_by_semver(self) -> None: release_7 = self.create_release(version="test@10.0+122") release_8 = self.create_release(version="test@some_thing") release_9 = self.create_release(version="random_junk") - release_10 = self.create_release(version="test@10.0+xyz") - release_11 = self.create_release(version="test@10.0+abc") + release_10 = self.create_release(version="test@10.0+x22") + release_11 = self.create_release(version="test@10.0+a23") + release_12 = self.create_release(version="test@10.0") + release_13 = self.create_release(version="test@10.0-abc") response = self.get_success_response(self.organization.slug, sort="semver") self.assert_expected_versions( response, [ - release_2, - release_7, - release_10, - release_11, - release_6, - release_5, - release_4, - release_1, - release_3, - release_9, - release_8, + release_10, # test@10.0+x22 + release_11, # test@10.0+a23 + release_2, # test@10.0+123 + release_7, # test@10.0+122 + release_12, # test@10.0 + release_13, # test@10.0-abc + release_6, # test@2.20.3.3 + release_5, # test@2.20.3 + release_4, # test@2.2.3 + release_1, # test@2.2 + release_3, # test@2.2-alpha + release_9, # random_junk + release_8, # test@some_thing ], ) diff --git a/tests/sentry/models/test_release.py b/tests/sentry/models/test_release.py index 0c30163613ab62..26e342d5aca680 100644 --- a/tests/sentry/models/test_release.py +++ b/tests/sentry/models/test_release.py @@ -1908,15 +1908,21 @@ def test_get_unused_filter_allows_cleanup_with_old_group_resolutions(self): class ReleaseOrderBySemverTestCase(TestCase): def test_compare_version_sorts_by_build_number(self): - """Test that compare_version correctly sorts releases by build number with various formats""" + """ + Test that compare_version correctly sorts releases by build number with various formats. - # numeric builds (numerical comparison) + DESC (largest to smallest): + 1. alphanumeric builds (compared lexicographically) + 2. numeric builds (compared numerically) + 3. no build + """ + # numeric builds use numerical comparison v1 = parse_release("app@1.0.0+999")["version_raw"] v2 = parse_release("app@1.0.0+1000")["version_raw"] assert compare_version(v1, v2) == -1 assert compare_version(v2, v1) == 1 - # alphanumeric builds (lexicographic comparison) + # alphanumeric builds use lexicographic comparison v1 = parse_release("app@1.0.0+abc")["version_raw"] v2 = parse_release("app@1.0.0+xyz")["version_raw"] assert compare_version(v1, v2) == -1 @@ -1930,7 +1936,14 @@ def test_compare_version_sorts_by_build_number(self): assert compare_version(v1, v2) == 1 assert compare_version(v2, v1) == -1 - # no build vs. with build (with build > no build) + # numeric < alphanumeric + v1 = parse_release("app@1.0.0+999")["version_raw"] + v2 = parse_release("app@1.0.0+abc")["version_raw"] + assert compare_version(v1, v2) == -1 + assert compare_version(v2, v1) == 1 + + # no build < with build v_no_build = parse_release("app@1.0.0")["version_raw"] v_with_build = parse_release("app@1.0.0+123")["version_raw"] assert compare_version(v_no_build, v_with_build) == -1 + assert compare_version(v_with_build, v_no_build) == 1 From 83288820647e80db4d4ab54c8116c5ce562ae43f Mon Sep 17 00:00:00 2001 From: srest2021 Date: Thu, 6 Nov 2025 09:54:02 -0800 Subject: [PATCH 09/32] rename case column --- src/sentry/api/endpoints/organization_releases.py | 4 ++-- .../endpoints/organization_trace_item_attributes.py | 4 ++-- src/sentry/api/helpers/group_index/update.py | 2 +- src/sentry/features/temporary.py | 2 ++ src/sentry/models/release.py | 4 ++-- src/sentry/models/releases/util.py | 13 +++++-------- src/sentry/tagstore/snuba/backend.py | 4 ++-- 7 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/sentry/api/endpoints/organization_releases.py b/src/sentry/api/endpoints/organization_releases.py index 5bcaabbc54147e..825cd9d80e964b 100644 --- a/src/sentry/api/endpoints/organization_releases.py +++ b/src/sentry/api/endpoints/organization_releases.py @@ -381,7 +381,7 @@ def __get_new(self, request: Request, organization: Organization) -> Response: queryset = queryset.filter(build_number__isnull=False).order_by("-build_number") paginator_kwargs["order_by"] = "-build_number" elif sort == "semver": - queryset = queryset.annotate_prerelease_column().annotate_build_number_column() + queryset = queryset.annotate_prerelease_column().annotate_build_case_column() order_by = [F(col).desc(nulls_last=True) for col in Release.SEMVER_COLS] # TODO: Adding this extra sort order breaks index usage. Index usage is already broken @@ -553,7 +553,7 @@ def __get_old(self, request: Request, organization: Organization) -> Response: queryset = queryset.filter(build_number__isnull=False).order_by("-build_number") paginator_kwargs["order_by"] = "-build_number" elif sort == "semver": - queryset = queryset.annotate_prerelease_column().annotate_build_number_column() + queryset = queryset.annotate_prerelease_column().annotate_build_case_column() order_by = [F(col).desc(nulls_last=True) for col in Release.SEMVER_COLS] # TODO: Adding this extra sort order breaks index usage. Index usage is already broken diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index 914c34683a19e1..a44b320a9eeac8 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -470,7 +470,7 @@ def semver_autocomplete_function(self): order_by = map(_flip_field_sort, Release.SEMVER_COLS + ["package"]) versions = versions.filter_to_semver() # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet versions = versions.annotate_prerelease_column() - versions = versions.annotate_build_number_column() + versions = versions.annotate_build_code_column() versions = versions.order_by(*order_by) seen = set() @@ -557,7 +557,7 @@ def semver_package_autocomplete_function(self): ).values_list("release_id", flat=True), ) .annotate_prerelease_column() - .annotate_build_number_column() + .annotate_build_case_column() ) # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet environment_ids = self.snuba_params.environment_ids diff --git a/src/sentry/api/helpers/group_index/update.py b/src/sentry/api/helpers/group_index/update.py index 459d4d22c1bd62..b6cb6de5b81b38 100644 --- a/src/sentry/api/helpers/group_index/update.py +++ b/src/sentry/api/helpers/group_index/update.py @@ -854,7 +854,7 @@ def get_semver_releases(project: Project) -> QuerySet[Release]: Release.objects.filter(projects=project, organization_id=project.organization_id) .filter_to_semver() # type: ignore[attr-defined] .annotate_prerelease_column() - .annotate_build_number_column() + .annotate_build_case_column() .order_by(*[f"-{col}" for col in Release.SEMVER_COLS]) ) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 41948ae7fe698a..4f46261f66205a 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -363,6 +363,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:reprocessing-v2", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False) # Enable issue resolve in current semver release manager.add("organizations:resolve-in-semver-release", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Add build code and build number to semver ordering + manager.add("organizations:semver-ordering-with-build-code", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable revocation of org auth keys when a user renames an org slug manager.add("organizations:revoke-org-auth-on-slug-rename", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable detecting SDK crashes during event processing diff --git a/src/sentry/models/release.py b/src/sentry/models/release.py index 4c12bdc5edc0d8..9e7a94e76f4fb9 100644 --- a/src/sentry/models/release.py +++ b/src/sentry/models/release.py @@ -101,8 +101,8 @@ def get_queryset(self) -> ReleaseQuerySet: def annotate_prerelease_column(self): return self.get_queryset().annotate_prerelease_column() - def annotate_build_number_column(self): - return self.get_queryset().annotate_build_number_column() + def annotate_build_code_column(self): + return self.get_queryset().annotate_build_code_column() def filter_to_semver(self) -> ReleaseQuerySet: return self.get_queryset().filter_to_semver() diff --git a/src/sentry/models/releases/util.py b/src/sentry/models/releases/util.py index 4fa3908836ac76..9fd1d509ec34ec 100644 --- a/src/sentry/models/releases/util.py +++ b/src/sentry/models/releases/util.py @@ -53,16 +53,16 @@ def annotate_prerelease_column(self): ) ) - def annotate_build_number_column(self): + def annotate_build_code_column(self): """ - Adds a `build_number_case` column to the queryset which is used to properly sort + Adds a `build_code_case` column to the queryset which is used to properly sort by build number to match compare_version behavior: - Alphanumeric builds (NULL build_number, non-NULL build_code): case=2 (highest) - Numeric builds (non-NULL build_number): case=1 (middle) - No build metadata (NULL build_number, NULL build_code): case=0 (lowest) """ return self.annotate( - build_number_case=Case( + build_code_case=Case( When(build_number__isnull=True, build_code__isnull=False, then=2), When(build_number__isnull=False, then=1), default=0, @@ -127,11 +127,8 @@ def filter_by_semver( Typically we build a `SemverFilter` via `sentry.search.events.filter.parse_semver` """ - qs = ( - self.filter(organization_id=organization_id) - .annotate_prerelease_column() - .annotate_build_number_column() - ) + qs = self.filter(organization_id=organization_id).annotate_prerelease_column().annotate_build_code_column() # type: ignore[attr-defined] + query_func = "exclude" if semver_filter.negated else "filter" if semver_filter.package: diff --git a/src/sentry/tagstore/snuba/backend.py b/src/sentry/tagstore/snuba/backend.py index 7da47503f87c0f..92fa96a175ed06 100644 --- a/src/sentry/tagstore/snuba/backend.py +++ b/src/sentry/tagstore/snuba/backend.py @@ -1052,7 +1052,7 @@ def _get_semver_versions_for_package(self, projects, organization_id, package): ), ) .annotate_prerelease_column() - .annotate_build_number_column() + .annotate_build_code_column() ) # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet def _get_tag_values_for_semver( @@ -1097,7 +1097,7 @@ def _get_tag_values_for_semver( versions = ( versions.filter_to_semver() .annotate_prerelease_column() - .annotate_build_number_column() + .annotate_build_code_column() .order_by(*order_by) .values_list("version", flat=True)[:1000] ) From d3b19a0cd0954d44c7c98e03fc11f1393132ebb4 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Thu, 6 Nov 2025 09:55:48 -0800 Subject: [PATCH 10/32] rename case column --- src/sentry/api/endpoints/organization_releases.py | 4 ++-- .../api/endpoints/organization_trace_item_attributes.py | 2 +- src/sentry/api/helpers/group_index/update.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/sentry/api/endpoints/organization_releases.py b/src/sentry/api/endpoints/organization_releases.py index 825cd9d80e964b..dc636ce45af100 100644 --- a/src/sentry/api/endpoints/organization_releases.py +++ b/src/sentry/api/endpoints/organization_releases.py @@ -381,7 +381,7 @@ def __get_new(self, request: Request, organization: Organization) -> Response: queryset = queryset.filter(build_number__isnull=False).order_by("-build_number") paginator_kwargs["order_by"] = "-build_number" elif sort == "semver": - queryset = queryset.annotate_prerelease_column().annotate_build_case_column() + queryset = queryset.annotate_prerelease_column().annotate_build_code_column() order_by = [F(col).desc(nulls_last=True) for col in Release.SEMVER_COLS] # TODO: Adding this extra sort order breaks index usage. Index usage is already broken @@ -553,7 +553,7 @@ def __get_old(self, request: Request, organization: Organization) -> Response: queryset = queryset.filter(build_number__isnull=False).order_by("-build_number") paginator_kwargs["order_by"] = "-build_number" elif sort == "semver": - queryset = queryset.annotate_prerelease_column().annotate_build_case_column() + queryset = queryset.annotate_prerelease_column().annotate_build_code_column() order_by = [F(col).desc(nulls_last=True) for col in Release.SEMVER_COLS] # TODO: Adding this extra sort order breaks index usage. Index usage is already broken diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index a44b320a9eeac8..985e7b73fa8691 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -557,7 +557,7 @@ def semver_package_autocomplete_function(self): ).values_list("release_id", flat=True), ) .annotate_prerelease_column() - .annotate_build_case_column() + .annotate_build_code_column() ) # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet environment_ids = self.snuba_params.environment_ids diff --git a/src/sentry/api/helpers/group_index/update.py b/src/sentry/api/helpers/group_index/update.py index b6cb6de5b81b38..07c30b74b50b01 100644 --- a/src/sentry/api/helpers/group_index/update.py +++ b/src/sentry/api/helpers/group_index/update.py @@ -854,7 +854,7 @@ def get_semver_releases(project: Project) -> QuerySet[Release]: Release.objects.filter(projects=project, organization_id=project.organization_id) .filter_to_semver() # type: ignore[attr-defined] .annotate_prerelease_column() - .annotate_build_case_column() + .annotate_build_code_column() .order_by(*[f"-{col}" for col in Release.SEMVER_COLS]) ) From 4f987e828237ba2124a419d65947e16e53070c4c Mon Sep 17 00:00:00 2001 From: srest2021 Date: Thu, 6 Nov 2025 10:14:03 -0800 Subject: [PATCH 11/32] fix --- src/sentry/models/release.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/models/release.py b/src/sentry/models/release.py index 9e7a94e76f4fb9..6382de12ca8024 100644 --- a/src/sentry/models/release.py +++ b/src/sentry/models/release.py @@ -309,7 +309,7 @@ class Meta: "revision", "prerelease_case", "prerelease", - "build_number_case", + "build_code_case", "build_number", "build_code", ] From ce127dc33463ac5b3d0ccfc7d5c87cd6fdb4958b Mon Sep 17 00:00:00 2001 From: srest2021 Date: Thu, 6 Nov 2025 10:26:23 -0800 Subject: [PATCH 12/32] missed some renaming --- src/sentry/models/release.py | 8 ++++---- src/sentry/models/releases/util.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/sentry/models/release.py b/src/sentry/models/release.py index 6382de12ca8024..f161937f8b7a98 100644 --- a/src/sentry/models/release.py +++ b/src/sentry/models/release.py @@ -405,11 +405,11 @@ def is_release_newer_or_equal(org_id, release, other_release): @property def semver_tuple(self) -> SemverVersion: if self.build_number is None and self.build_code is not None: - build_number_case = 2 + build_code_case = 2 elif self.build_number is not None: - build_number_case = 1 + build_code_case = 1 else: - build_number_case = 0 + build_code_case = 0 return SemverVersion( self.major, @@ -418,7 +418,7 @@ def semver_tuple(self) -> SemverVersion: self.revision, 1 if self.prerelease == "" else 0, self.prerelease, - build_number_case, + build_code_case, self.build_number, self.build_code, ) diff --git a/src/sentry/models/releases/util.py b/src/sentry/models/releases/util.py index 9fd1d509ec34ec..f1f3c6fa4ef5ef 100644 --- a/src/sentry/models/releases/util.py +++ b/src/sentry/models/releases/util.py @@ -26,7 +26,7 @@ class SemverVersion( namedtuple( "SemverVersion", - "major minor patch revision prerelease_case prerelease build_number_case build_number build_code", + "major minor patch revision prerelease_case prerelease build_code_case build_number build_code", ) ): pass From 8d2366002150c3abd6d90f7593bb2b1a5eace199 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Thu, 6 Nov 2025 13:02:49 -0800 Subject: [PATCH 13/32] fix typing --- .../endpoints/organization_trace_item_attributes.py | 4 ++-- src/sentry/models/release.py | 4 ++-- src/sentry/models/releases/util.py | 10 +++++++--- src/sentry/tagstore/snuba/backend.py | 4 ++-- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index 985e7b73fa8691..9f3519d95f1f69 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -556,9 +556,9 @@ def semver_package_autocomplete_function(self): project_id__in=self.snuba_params.project_ids ).values_list("release_id", flat=True), ) - .annotate_prerelease_column() + .annotate_prerelease_column() # type: ignore[attr-defined] .annotate_build_code_column() - ) # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet + ) environment_ids = self.snuba_params.environment_ids if environment_ids: diff --git a/src/sentry/models/release.py b/src/sentry/models/release.py index 22d9542b42c2a8..a81121caeb1dd6 100644 --- a/src/sentry/models/release.py +++ b/src/sentry/models/release.py @@ -98,10 +98,10 @@ class ReleaseModelManager(BaseManager["Release"]): def get_queryset(self) -> ReleaseQuerySet: return ReleaseQuerySet(self.model, using=self._db) - def annotate_prerelease_column(self): + def annotate_prerelease_column(self) -> ReleaseQuerySet: return self.get_queryset().annotate_prerelease_column() - def annotate_build_code_column(self): + def annotate_build_code_column(self) -> ReleaseQuerySet: return self.get_queryset().annotate_build_code_column() def filter_to_semver(self) -> ReleaseQuerySet: diff --git a/src/sentry/models/releases/util.py b/src/sentry/models/releases/util.py index f1f3c6fa4ef5ef..863f29a7a1ece4 100644 --- a/src/sentry/models/releases/util.py +++ b/src/sentry/models/releases/util.py @@ -41,7 +41,7 @@ class SemverFilter: class ReleaseQuerySet(BaseQuerySet["Release"]): - def annotate_prerelease_column(self): + def annotate_prerelease_column(self) -> Self: """ Adds a `prerelease_case` column to the queryset which is used to properly sort by prerelease. We treat an empty (but not null) prerelease as higher than any @@ -53,7 +53,7 @@ def annotate_prerelease_column(self): ) ) - def annotate_build_code_column(self): + def annotate_build_code_column(self) -> Self: """ Adds a `build_code_case` column to the queryset which is used to properly sort by build number to match compare_version behavior: @@ -127,7 +127,11 @@ def filter_by_semver( Typically we build a `SemverFilter` via `sentry.search.events.filter.parse_semver` """ - qs = self.filter(organization_id=organization_id).annotate_prerelease_column().annotate_build_code_column() # type: ignore[attr-defined] + qs = ( + self.filter(organization_id=organization_id) + .annotate_prerelease_column() + .annotate_build_code_column() + ) query_func = "exclude" if semver_filter.negated else "filter" diff --git a/src/sentry/tagstore/snuba/backend.py b/src/sentry/tagstore/snuba/backend.py index 92fa96a175ed06..b56fdb5f556850 100644 --- a/src/sentry/tagstore/snuba/backend.py +++ b/src/sentry/tagstore/snuba/backend.py @@ -1051,9 +1051,9 @@ def _get_semver_versions_for_package(self, projects, organization_id, package): "release_id", flat=True ), ) - .annotate_prerelease_column() + .annotate_prerelease_column() # type: ignore[attr-defined] .annotate_build_code_column() - ) # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet + ) def _get_tag_values_for_semver( self, From 354b7f2c0eabf97a0be28802c2148cee3ae7fb53 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Fri, 7 Nov 2025 14:46:21 -0800 Subject: [PATCH 14/32] add feature flag --- .../api/endpoints/organization_releases.py | 16 +++++-- .../organization_trace_item_attributes.py | 28 +++++++------ src/sentry/api/helpers/group_index/update.py | 9 ++-- src/sentry/models/release.py | 34 +++++++++------ src/sentry/models/releases/util.py | 17 +++++--- .../filters/latest_adopted_release_filter.py | 11 ++++- src/sentry/search/eap/spans/filter_aliases.py | 7 +++- .../search/events/datasets/filter_aliases.py | 9 +++- src/sentry/search/events/filter.py | 9 +++- src/sentry/tagstore/snuba/backend.py | 42 ++++++++++--------- .../endpoints/test_organization_releases.py | 39 +++++++++++++---- 11 files changed, 152 insertions(+), 69 deletions(-) diff --git a/src/sentry/api/endpoints/organization_releases.py b/src/sentry/api/endpoints/organization_releases.py index dc636ce45af100..9cb2034913adf1 100644 --- a/src/sentry/api/endpoints/organization_releases.py +++ b/src/sentry/api/endpoints/organization_releases.py @@ -381,9 +381,13 @@ def __get_new(self, request: Request, organization: Organization) -> Response: queryset = queryset.filter(build_number__isnull=False).order_by("-build_number") paginator_kwargs["order_by"] = "-build_number" elif sort == "semver": - queryset = queryset.annotate_prerelease_column().annotate_build_code_column() + queryset = queryset.annotate_prerelease_column() + semver_cols = Release.SEMVER_COLS + if features.has("organizations:semver-ordering-with-build-code", organization): + queryset = queryset.annotate_build_code_column() + semver_cols = Release.SEMVER_COLS_WITH_BUILD_CODE - order_by = [F(col).desc(nulls_last=True) for col in Release.SEMVER_COLS] + order_by = [F(col).desc(nulls_last=True) for col in semver_cols] # TODO: Adding this extra sort order breaks index usage. Index usage is already broken # when we filter by status, so when we fix that we should also consider the best way to # make this work as expected. @@ -553,9 +557,13 @@ def __get_old(self, request: Request, organization: Organization) -> Response: queryset = queryset.filter(build_number__isnull=False).order_by("-build_number") paginator_kwargs["order_by"] = "-build_number" elif sort == "semver": - queryset = queryset.annotate_prerelease_column().annotate_build_code_column() + queryset = queryset.annotate_prerelease_column() + semver_cols = Release.SEMVER_COLS + if features.has("organizations:semver-ordering-with-build-code", organization): + queryset = queryset.annotate_build_code_column() + semver_cols = Release.SEMVER_COLS_WITH_BUILD_CODE - order_by = [F(col).desc(nulls_last=True) for col in Release.SEMVER_COLS] + order_by = [F(col).desc(nulls_last=True) for col in semver_cols] # TODO: Adding this extra sort order breaks index usage. Index usage is already broken # when we filter by status, so when we fix that we should also consider the best way to # make this work as expected. diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index 9f3519d95f1f69..eabeb67dabcec4 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -467,10 +467,13 @@ def semver_autocomplete_function(self): id__in=release_environments.values_list("release_id", flat=True) ) - order_by = map(_flip_field_sort, Release.SEMVER_COLS + ["package"]) versions = versions.filter_to_semver() # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet versions = versions.annotate_prerelease_column() - versions = versions.annotate_build_code_column() + semver_cols = Release.SEMVER_COLS + if features.has("organizations:semver-ordering-with-build-code", self.organization): + versions = versions.annotate_build_code_column() + semver_cols = Release.SEMVER_COLS_WITH_BUILD_CODE + order_by = map(_flip_field_sort, semver_cols + ["package"]) versions = versions.order_by(*order_by) seen = set() @@ -548,17 +551,16 @@ def semver_package_autocomplete_function(self): .distinct() ) - versions = ( - Release.objects.filter( - organization_id=self.snuba_params.organization_id, - package__in=packages, - id__in=ReleaseProject.objects.filter( - project_id__in=self.snuba_params.project_ids - ).values_list("release_id", flat=True), - ) - .annotate_prerelease_column() # type: ignore[attr-defined] - .annotate_build_code_column() - ) + versions = Release.objects.filter( + organization_id=self.snuba_params.organization_id, + package__in=packages, + id__in=ReleaseProject.objects.filter( + project_id__in=self.snuba_params.project_ids + ).values_list("release_id", flat=True), + ).annotate_prerelease_column() # type: ignore[attr-defined] + + if features.has("organizations:semver-ordering-with-build-code", self.organization): + versions = versions.annotate_build_code_column() environment_ids = self.snuba_params.environment_ids if environment_ids: diff --git a/src/sentry/api/helpers/group_index/update.py b/src/sentry/api/helpers/group_index/update.py index 07c30b74b50b01..cff8b6e4457aac 100644 --- a/src/sentry/api/helpers/group_index/update.py +++ b/src/sentry/api/helpers/group_index/update.py @@ -850,13 +850,16 @@ def greatest_semver_release(project: Project) -> Release | None: def get_semver_releases(project: Project) -> QuerySet[Release]: - return ( + qs = ( Release.objects.filter(projects=project, organization_id=project.organization_id) .filter_to_semver() # type: ignore[attr-defined] .annotate_prerelease_column() - .annotate_build_code_column() - .order_by(*[f"-{col}" for col in Release.SEMVER_COLS]) ) + semver_cols = Release.SEMVER_COLS + if features.has("organizations:semver-ordering-with-build-code", project.organization): + qs = qs.annotate_build_code_column() + semver_cols = Release.SEMVER_COLS_WITH_BUILD_CODE + return qs.order_by(*[f"-{col}" for col in semver_cols]) def handle_is_subscribed( diff --git a/src/sentry/models/release.py b/src/sentry/models/release.py index a81121caeb1dd6..6ee43458c38361 100644 --- a/src/sentry/models/release.py +++ b/src/sentry/models/release.py @@ -41,7 +41,12 @@ ) from sentry.models.releases.exceptions import UnsafeReleaseDeletion from sentry.models.releases.release_project import ReleaseProject -from sentry.models.releases.util import ReleaseQuerySet, SemverFilter, SemverVersion +from sentry.models.releases.util import ( + ReleaseQuerySet, + SemverFilter, + SemverVersion, + SemverVersionWithBuildCode, +) from sentry.utils import metrics from sentry.utils.cache import cache from sentry.utils.db import atomic_transaction @@ -302,17 +307,9 @@ class Meta: __repr__ = sane_repr("organization_id", "version") - SEMVER_COLS = [ - "major", - "minor", - "patch", - "revision", - "prerelease_case", - "prerelease", - "build_code_case", - "build_number", - "build_code", - ] + SEMVER_COLS = ["major", "minor", "patch", "revision", "prerelease_case", "prerelease"] + + SEMVER_COLS_WITH_BUILD_CODE = SEMVER_COLS + ["build_code_case", "build_number", "build_code"] def __eq__(self, other: object) -> bool: """Make sure that specialized releases are only comparable to the same @@ -404,6 +401,17 @@ def is_release_newer_or_equal(org_id, release, other_release): @property def semver_tuple(self) -> SemverVersion: + return SemverVersion( + self.major, + self.minor, + self.patch, + self.revision, + 1 if self.prerelease == "" else 0, + self.prerelease, + ) + + @property + def semver_tuple_with_build_code(self) -> SemverVersionWithBuildCode: if self.build_number is None and self.build_code is not None: build_code_case = 2 elif self.build_number is not None: @@ -411,7 +419,7 @@ def semver_tuple(self) -> SemverVersion: else: build_code_case = 0 - return SemverVersion( + return SemverVersionWithBuildCode( self.major, self.minor, self.patch, diff --git a/src/sentry/models/releases/util.py b/src/sentry/models/releases/util.py index 863f29a7a1ece4..823e3ac446ebf7 100644 --- a/src/sentry/models/releases/util.py +++ b/src/sentry/models/releases/util.py @@ -26,6 +26,15 @@ class SemverVersion( namedtuple( "SemverVersion", + "major minor patch revision prerelease_case prerelease", + ) +): + pass + + +class SemverVersionWithBuildCode( + namedtuple( + "SemverVersionWithBuildCode", "major minor patch revision prerelease_case prerelease build_code_case build_number build_code", ) ): @@ -85,7 +94,7 @@ def filter_by_semver_build( negated: bool = False, ) -> Self: """ - Filters released by build. If the passed `build` is a numeric string, we'll filter on + Filters releases by build. If the passed `build` is a numeric string, we'll filter on `build_number` and make use of the passed operator. If it is a non-numeric string, then we'll filter on `build_code` instead. We support a wildcard only at the end of this string, so that we can filter efficiently via the index. @@ -127,11 +136,7 @@ def filter_by_semver( Typically we build a `SemverFilter` via `sentry.search.events.filter.parse_semver` """ - qs = ( - self.filter(organization_id=organization_id) - .annotate_prerelease_column() - .annotate_build_code_column() - ) + qs = self.filter(organization_id=organization_id).annotate_prerelease_column() query_func = "exclude" if semver_filter.negated else "filter" diff --git a/src/sentry/rules/filters/latest_adopted_release_filter.py b/src/sentry/rules/filters/latest_adopted_release_filter.py index e78fc5b5eb33c3..f20ae1a6c4470d 100644 --- a/src/sentry/rules/filters/latest_adopted_release_filter.py +++ b/src/sentry/rules/filters/latest_adopted_release_filter.py @@ -7,8 +7,10 @@ from django import forms from django.db.models.signals import post_delete, post_save +from sentry import features from sentry.models.environment import Environment from sentry.models.grouprelease import GroupRelease +from sentry.models.organization import Organization from sentry.models.release import Release, follows_semver_versioning_scheme from sentry.models.releaseenvironment import ReleaseEnvironment from sentry.rules import EventState @@ -156,7 +158,14 @@ def is_newer_release( and release.is_semver_release and comparison_release.is_semver_release ): - return release.semver_tuple > comparison_release.semver_tuple + organization = Organization.objects.get_from_cache(id=release.organization_id) + if features.has("organizations:semver-ordering-with-build-code", organization): + return ( + release.semver_tuple_with_build_code + > comparison_release.semver_tuple_with_build_code + ) + else: + return release.semver_tuple > comparison_release.semver_tuple else: release_date = release.date_released if release.date_released else release.date_added comparison_date = ( diff --git a/src/sentry/search/eap/spans/filter_aliases.py b/src/sentry/search/eap/spans/filter_aliases.py index 7c6bff10435e41..04ec356adff1eb 100644 --- a/src/sentry/search/eap/spans/filter_aliases.py +++ b/src/sentry/search/eap/spans/filter_aliases.py @@ -1,6 +1,7 @@ import string from typing import Literal +from sentry import features from sentry.api.event_search import SearchFilter, SearchKey, SearchValue from sentry.exceptions import InvalidSearchQuery from sentry.models.release import Release @@ -95,7 +96,11 @@ def semver_filter_converter(params: SnubaParams, search_filter: SearchFilter) -> # Note that we sort this such that if we end up fetching more than # MAX_SEMVER_SEARCH_RELEASES, we will return the releases that are closest to # the passed filter. - order_by = Release.SEMVER_COLS + order_by = ( + Release.SEMVER_COLS_WITH_BUILD_CODE + if features.has("organizations:semver-ordering-with-build-code", params.organization) + else Release.SEMVER_COLS + ) if operator.startswith("<"): order_by = list(map(_flip_field_sort, order_by)) qs = ( diff --git a/src/sentry/search/events/datasets/filter_aliases.py b/src/sentry/search/events/datasets/filter_aliases.py index f68ffaeff7776f..9e96c2f99b56f4 100644 --- a/src/sentry/search/events/datasets/filter_aliases.py +++ b/src/sentry/search/events/datasets/filter_aliases.py @@ -5,6 +5,7 @@ import sentry_sdk from snuba_sdk import Column, Condition, Function, Op +from sentry import features from sentry.api.event_search import SearchFilter, SearchKey, SearchValue from sentry.exceptions import InvalidSearchQuery from sentry.models.release import Release @@ -212,7 +213,13 @@ def semver_filter_converter( # Note that we sort this such that if we end up fetching more than # MAX_SEMVER_SEARCH_RELEASES, we will return the releases that are closest to # the passed filter. - order_by = Release.SEMVER_COLS + order_by = ( + Release.SEMVER_COLS_WITH_BUILD_CODE + if features.has( + "organizations:semver-ordering-with-build-code", builder.params.organization + ) + else Release.SEMVER_COLS + ) if operator.startswith("<"): order_by = list(map(_flip_field_sort, order_by)) qs = ( diff --git a/src/sentry/search/events/filter.py b/src/sentry/search/events/filter.py index bb5f7792e2bd6f..2d8a75877f6238 100644 --- a/src/sentry/search/events/filter.py +++ b/src/sentry/search/events/filter.py @@ -8,6 +8,7 @@ from sentry_relay.consts import SPAN_STATUS_NAME_TO_CODE from sentry_relay.processing import parse_release as parse_release_relay +from sentry import features from sentry.api.event_search import ( AggregateFilter, ParenExpression, @@ -19,6 +20,7 @@ from sentry.constants import SEMVER_FAKE_PACKAGE from sentry.exceptions import InvalidSearchQuery from sentry.models.group import Group +from sentry.models.organization import Organization from sentry.models.project import Project from sentry.models.release import Release from sentry.models.releases.util import SemverFilter @@ -331,7 +333,12 @@ def _semver_filter_converter( # Note that we sort this such that if we end up fetching more than # MAX_SEMVER_SEARCH_RELEASES, we will return the releases that are closest to # the passed filter. - order_by = Release.SEMVER_COLS + organization = Organization.objects.get_from_cache(id=organization_id) + order_by = ( + Release.SEMVER_COLS_WITH_BUILD_CODE + if features.has("organizations:semver-ordering-with-build-code", organization) + else Release.SEMVER_COLS + ) if operator.startswith("<"): order_by = list(map(_flip_field_sort, order_by)) qs = ( diff --git a/src/sentry/tagstore/snuba/backend.py b/src/sentry/tagstore/snuba/backend.py index b56fdb5f556850..ee8430770a4e48 100644 --- a/src/sentry/tagstore/snuba/backend.py +++ b/src/sentry/tagstore/snuba/backend.py @@ -1043,17 +1043,19 @@ def _get_semver_versions_for_package(self, projects, organization_id, package): .distinct() ) - return ( - Release.objects.filter( - organization_id=organization_id, - package__in=packages, - id__in=ReleaseProject.objects.filter(project_id__in=projects).values_list( - "release_id", flat=True - ), - ) - .annotate_prerelease_column() # type: ignore[attr-defined] - .annotate_build_code_column() - ) + qs = Release.objects.filter( + organization_id=organization_id, + package__in=packages, + id__in=ReleaseProject.objects.filter(project_id__in=projects).values_list( + "release_id", flat=True + ), + ).annotate_prerelease_column() # type: ignore[attr-defined] + + organization = Organization.objects.get_from_cache(id=organization_id) + if features.has("organizations:semver-ordering-with-build-code", organization): + qs = qs.annotate_build_code_column() + + return qs def _get_tag_values_for_semver( self, @@ -1093,14 +1095,16 @@ def _get_tag_values_for_semver( ).values_list("release_id", flat=True) ) - order_by = map(_flip_field_sort, Release.SEMVER_COLS + ["package"]) - versions = ( - versions.filter_to_semver() - .annotate_prerelease_column() - .annotate_build_code_column() - .order_by(*order_by) - .values_list("version", flat=True)[:1000] - ) + versions = versions.filter_to_semver().annotate_prerelease_column() + + order_by = Release.SEMVER_COLS + organization = Organization.objects.get_from_cache(id=organization_id) + if features.has("organizations:semver-ordering-with-build-code", organization): + versions = versions.annotate_build_code_column() + order_by = Release.SEMVER_COLS_WITH_BUILD_CODE + + order_by = map(_flip_field_sort, order_by + ["package"]) + versions = versions.order_by(*order_by).values_list("version", flat=True)[:1000] seen = set() formatted_versions = [] diff --git a/tests/sentry/api/endpoints/test_organization_releases.py b/tests/sentry/api/endpoints/test_organization_releases.py index 1302e0752541c5..72bfe4b8daebd7 100644 --- a/tests/sentry/api/endpoints/test_organization_releases.py +++ b/tests/sentry/api/endpoints/test_organization_releases.py @@ -246,8 +246,7 @@ def test_release_list_order_by_build_number(self) -> None: response = self.get_success_response(self.organization.slug, sort="build") self.assert_expected_versions(response, [release_1, release_3, release_2]) - def test_release_list_order_by_semver(self) -> None: - self.login_as(user=self.user) + def _assert_semver_order(self, with_build_code: bool) -> None: release_1 = self.create_release(version="test@2.2") release_2 = self.create_release(version="test@10.0+123") release_3 = self.create_release(version="test@2.2-alpha") @@ -263,9 +262,9 @@ def test_release_list_order_by_semver(self) -> None: release_13 = self.create_release(version="test@10.0-abc") response = self.get_success_response(self.organization.slug, sort="semver") - self.assert_expected_versions( - response, - [ + + if with_build_code: + expected_order = [ release_10, # test@10.0+x22 release_11, # test@10.0+a23 release_2, # test@10.0+123 @@ -279,8 +278,34 @@ def test_release_list_order_by_semver(self) -> None: release_3, # test@2.2-alpha release_9, # random_junk release_8, # test@some_thing - ], - ) + ] + else: + expected_order = [ + release_12, # test@10.0 + release_11, # test@10.0+a23 + release_10, # test@10.0+x22 + release_7, # test@10.0+122 + release_2, # test@10.0+123 + release_13, # test@10.0-abc + release_6, # test@2.20.3.3 + release_5, # test@2.20.3 + release_4, # test@2.2.3 + release_1, # test@2.2 + release_3, # test@2.2-alpha + release_9, # random_junk + release_8, # test@some_thing + ] + + self.assert_expected_versions(response, expected_order) + + def test_release_list_order_by_semver(self) -> None: + self.login_as(user=self.user) + self._assert_semver_order(with_build_code=False) + + def test_release_list_order_by_semver_with_build_code(self) -> None: + self.login_as(user=self.user) + with self.feature("organizations:semver-ordering-with-build-code"): + self._assert_semver_order(with_build_code=True) def test_query_filter(self) -> None: user = self.create_user(is_staff=False, is_superuser=False) From 63b9dab42bfea95267f0db6705c9e42f247ca6eb Mon Sep 17 00:00:00 2001 From: srest2021 Date: Mon, 10 Nov 2025 10:31:31 -0800 Subject: [PATCH 15/32] dont need to update ordering in this file --- .../api/endpoints/organization_trace_item_attributes.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index eabeb67dabcec4..7195bcec99f338 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -467,13 +467,9 @@ def semver_autocomplete_function(self): id__in=release_environments.values_list("release_id", flat=True) ) + order_by = map(_flip_field_sort, Release.SEMVER_COLS + ["package"]) versions = versions.filter_to_semver() # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet versions = versions.annotate_prerelease_column() - semver_cols = Release.SEMVER_COLS - if features.has("organizations:semver-ordering-with-build-code", self.organization): - versions = versions.annotate_build_code_column() - semver_cols = Release.SEMVER_COLS_WITH_BUILD_CODE - order_by = map(_flip_field_sort, semver_cols + ["package"]) versions = versions.order_by(*order_by) seen = set() @@ -559,9 +555,6 @@ def semver_package_autocomplete_function(self): ).values_list("release_id", flat=True), ).annotate_prerelease_column() # type: ignore[attr-defined] - if features.has("organizations:semver-ordering-with-build-code", self.organization): - versions = versions.annotate_build_code_column() - environment_ids = self.snuba_params.environment_ids if environment_ids: release_environments = ReleaseEnvironment.objects.filter( From b38147159af826018ea73cc5db32e6da3d8c3900 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Mon, 10 Nov 2025 11:10:57 -0800 Subject: [PATCH 16/32] more tests --- .../endpoints/test_organization_releases.py | 22 +++-- tests/sentry/api/helpers/test_group_index.py | 91 +++++++++++++++++++ 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/tests/sentry/api/endpoints/test_organization_releases.py b/tests/sentry/api/endpoints/test_organization_releases.py index 72bfe4b8daebd7..5282c822faf062 100644 --- a/tests/sentry/api/endpoints/test_organization_releases.py +++ b/tests/sentry/api/endpoints/test_organization_releases.py @@ -246,20 +246,21 @@ def test_release_list_order_by_build_number(self) -> None: response = self.get_success_response(self.organization.slug, sort="build") self.assert_expected_versions(response, [release_1, release_3, release_2]) - def _assert_semver_order(self, with_build_code: bool) -> None: + def _test_release_list_order_by_semver_helper(self, with_build_code: bool) -> None: release_1 = self.create_release(version="test@2.2") - release_2 = self.create_release(version="test@10.0+123") + release_2 = self.create_release(version="test@10.0+1000") release_3 = self.create_release(version="test@2.2-alpha") release_4 = self.create_release(version="test@2.2.3") release_5 = self.create_release(version="test@2.20.3") release_6 = self.create_release(version="test@2.20.3.3") - release_7 = self.create_release(version="test@10.0+122") + release_7 = self.create_release(version="test@10.0+998") release_8 = self.create_release(version="test@some_thing") release_9 = self.create_release(version="random_junk") release_10 = self.create_release(version="test@10.0+x22") release_11 = self.create_release(version="test@10.0+a23") release_12 = self.create_release(version="test@10.0") release_13 = self.create_release(version="test@10.0-abc") + release_14 = self.create_release(version="test@10.0+999") response = self.get_success_response(self.organization.slug, sort="semver") @@ -267,8 +268,9 @@ def _assert_semver_order(self, with_build_code: bool) -> None: expected_order = [ release_10, # test@10.0+x22 release_11, # test@10.0+a23 - release_2, # test@10.0+123 - release_7, # test@10.0+122 + release_2, # test@10.0+1000 + release_14, # test@10.0+999 + release_7, # test@10.0+998 release_12, # test@10.0 release_13, # test@10.0-abc release_6, # test@2.20.3.3 @@ -280,12 +282,14 @@ def _assert_semver_order(self, with_build_code: bool) -> None: release_8, # test@some_thing ] else: + # without build code ordering, tiebreaker is date_added expected_order = [ + release_14, # test@10.0+999 release_12, # test@10.0 release_11, # test@10.0+a23 release_10, # test@10.0+x22 - release_7, # test@10.0+122 - release_2, # test@10.0+123 + release_7, # test@10.0+998 + release_2, # test@10.0+1000 release_13, # test@10.0-abc release_6, # test@2.20.3.3 release_5, # test@2.20.3 @@ -300,12 +304,12 @@ def _assert_semver_order(self, with_build_code: bool) -> None: def test_release_list_order_by_semver(self) -> None: self.login_as(user=self.user) - self._assert_semver_order(with_build_code=False) + self._test_release_list_order_by_semver_helper(with_build_code=False) def test_release_list_order_by_semver_with_build_code(self) -> None: self.login_as(user=self.user) with self.feature("organizations:semver-ordering-with-build-code"): - self._assert_semver_order(with_build_code=True) + self._test_release_list_order_by_semver_helper(with_build_code=True) def test_query_filter(self) -> None: user = self.create_user(is_staff=False, is_superuser=False) diff --git a/tests/sentry/api/helpers/test_group_index.py b/tests/sentry/api/helpers/test_group_index.py index 7004f6d68d0a61..5dbf4c846040c7 100644 --- a/tests/sentry/api/helpers/test_group_index.py +++ b/tests/sentry/api/helpers/test_group_index.py @@ -11,6 +11,8 @@ from sentry.api.helpers.group_index.delete import schedule_tasks_to_delete_groups from sentry.api.helpers.group_index.update import ( get_group_list, + get_semver_releases, + greatest_semver_release, handle_assigned_to, handle_has_seen, handle_is_bookmarked, @@ -1141,3 +1143,92 @@ def test_delete_groups_deletes_seer_records_by_hash( mock_delete_seer_grouping_records_by_hash.assert_called_with( args=[self.project.id, hashes, 0] ) + + +class GetSemverReleasesTest(TestCase): + def _test_get_semver_releases_helper(self, with_build_code: bool) -> None: + """Test that get_semver_releases returns releases in correct semver order.""" + release_1 = self.create_release(version="test@2.2", project=self.project) + release_2 = self.create_release(version="test@10.0+1000", project=self.project) + release_3 = self.create_release(version="test@2.2-alpha", project=self.project) + release_4 = self.create_release(version="test@2.2.3", project=self.project) + release_5 = self.create_release(version="test@2.20.3", project=self.project) + release_6 = self.create_release(version="test@2.20.3.3", project=self.project) + release_7 = self.create_release(version="test@10.0+998", project=self.project) + release_8 = self.create_release(version="test@10.0+x22", project=self.project) + release_9 = self.create_release(version="test@10.0+a23", project=self.project) + release_10 = self.create_release(version="test@10.0", project=self.project) + release_11 = self.create_release(version="test@10.0-abc", project=self.project) + release_12 = self.create_release(version="test@10.0+999", project=self.project) + # Non-semver releases that will be filtered out by filter_to_semver() + self.create_release(version="test@some_thing", project=self.project) + self.create_release(version="random_junk", project=self.project) + + releases = list(get_semver_releases(self.project)) + + if with_build_code: + expected_order = [ + release_8, # test@10.0+x22 + release_9, # test@10.0+a23 + release_2, # test@10.0+1000 + release_12, # test@10.0+999 + release_7, # test@10.0+998 + release_10, # test@10.0 + release_11, # test@10.0-abc + release_6, # test@2.20.3.3 + release_5, # test@2.20.3 + release_4, # test@2.2.3 + release_1, # test@2.2 + release_3, # test@2.2-alpha + ] + else: + expected_order = [ + release_12, # test@10.0+999 + release_2, # test@10.0+1000 + release_7, # test@10.0+998 + release_8, # test@10.0+x22 + release_9, # test@10.0+a23 + release_10, # test@10.0 + release_11, # test@10.0-abc + release_6, # test@2.20.3.3 + release_5, # test@2.20.3 + release_4, # test@2.2.3 + release_1, # test@2.2 + release_3, # test@2.2-alpha + ] + + assert len(releases) == len(expected_order) + assert [r.id for r in releases] == [r.id for r in expected_order] + + def test_get_semver_releases(self) -> None: + """Test get_semver_releases orders releases by semver.""" + self._test_get_semver_releases_helper(with_build_code=False) + + def test_get_semver_releases_with_build_code(self) -> None: + """Test get_semver_releases orders releases by semver and build code.""" + with self.feature("organizations:semver-ordering-with-build-code"): + self._test_get_semver_releases_helper(with_build_code=True) + + def test_greatest_semver_release(self) -> None: + """Test that greatest_semver_release returns the highest version release.""" + self.create_release(version="test@1.0", project=self.project) + release_2 = self.create_release(version="test@2.0", project=self.project) + self.create_release(version="test@1.5", project=self.project) + + greatest = greatest_semver_release(self.project) + assert greatest is not None + assert greatest.id == release_2.id + assert greatest.version == "test@2.0" + + def test_greatest_semver_release_with_build_codes(self) -> None: + """Test that greatest_semver_release returns the highest version release with the highest build code.""" + release_with_highest_build = self.create_release( + version="test@1.0+100", project=self.project + ) + self.create_release(version="test@1.0+99", project=self.project) + + with self.feature("organizations:semver-ordering-with-build-code"): + greatest = greatest_semver_release(self.project) + assert greatest is not None + assert greatest.id == release_with_highest_build.id + assert greatest.version == "test@1.0+100" From f35d7b0b465b47b14b087fbd5082fdf082456a64 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Mon, 10 Nov 2025 11:30:41 -0800 Subject: [PATCH 17/32] more tests --- src/sentry/models/releases/util.py | 3 +- .../filters/test_latest_adopted_release.py | 69 ++++++++++++++++++- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/sentry/models/releases/util.py b/src/sentry/models/releases/util.py index 823e3ac446ebf7..e6694ae9559029 100644 --- a/src/sentry/models/releases/util.py +++ b/src/sentry/models/releases/util.py @@ -65,7 +65,7 @@ def annotate_prerelease_column(self) -> Self: def annotate_build_code_column(self) -> Self: """ Adds a `build_code_case` column to the queryset which is used to properly sort - by build number to match compare_version behavior: + by build code to match compare_version behavior: - Alphanumeric builds (NULL build_number, non-NULL build_code): case=2 (highest) - Numeric builds (non-NULL build_number): case=1 (middle) - No build metadata (NULL build_number, NULL build_code): case=0 (lowest) @@ -137,7 +137,6 @@ def filter_by_semver( Typically we build a `SemverFilter` via `sentry.search.events.filter.parse_semver` """ qs = self.filter(organization_id=organization_id).annotate_prerelease_column() - query_func = "exclude" if semver_filter.negated else "filter" if semver_filter.package: diff --git a/tests/sentry/rules/filters/test_latest_adopted_release.py b/tests/sentry/rules/filters/test_latest_adopted_release.py index eca8d1295d75af..18f1980ecaf04d 100644 --- a/tests/sentry/rules/filters/test_latest_adopted_release.py +++ b/tests/sentry/rules/filters/test_latest_adopted_release.py @@ -1,7 +1,11 @@ from datetime import UTC, datetime, timedelta -from sentry.rules.filters.latest_adopted_release_filter import LatestAdoptedReleaseFilter -from sentry.testutils.cases import RuleTestCase +from sentry.rules.filters.latest_adopted_release_filter import ( + LatestAdoptedReleaseFilter, + LatestReleaseOrders, + is_newer_release, +) +from sentry.testutils.cases import RuleTestCase, TestCase class LatestAdoptedReleaseFilterTest(RuleTestCase): @@ -196,3 +200,64 @@ def test_date(self) -> None: self.create_group_release(group=group_3, release=middle_release) self.assertDoesNotPass(rule, event_3) + + +class IsNewerReleaseTest(TestCase): + def _test_is_newer_release_semver_helper(self, with_build_code: bool) -> None: + """Test that is_newer_release correctly compares releases with semver and build code.""" + older_release = self.create_release(version="test@1.0", project=self.project) + newer_release = self.create_release(version="test@2.0", project=self.project) + newer_release_older_numeric = self.create_release( + version="test@2.0+100", project=self.project + ) + newer_release_newer_numeric = self.create_release( + version="test@2.0+200", project=self.project + ) + newer_release_alpha = self.create_release(version="test@2.0+zzz", project=self.project) + + if with_build_code: + assert is_newer_release(newer_release, older_release, LatestReleaseOrders.SEMVER) + + # numeric build codes are compared numerically + assert is_newer_release( + newer_release_newer_numeric, newer_release_older_numeric, LatestReleaseOrders.SEMVER + ) + + # alphanumeric builds are always newer than numeric builds + assert is_newer_release( + newer_release_alpha, newer_release_older_numeric, LatestReleaseOrders.SEMVER + ) + assert is_newer_release( + newer_release_alpha, newer_release_newer_numeric, LatestReleaseOrders.SEMVER + ) + + # releases without build are always older than releases with build + assert is_newer_release( + newer_release_older_numeric, newer_release, LatestReleaseOrders.SEMVER + ) + assert is_newer_release(newer_release_alpha, newer_release, LatestReleaseOrders.SEMVER) + else: + assert is_newer_release(newer_release, older_release, LatestReleaseOrders.SEMVER) + + # all releases with version 1.0 are considered equal + assert not is_newer_release( + newer_release_newer_numeric, newer_release_older_numeric, LatestReleaseOrders.SEMVER + ) + assert not is_newer_release( + newer_release_older_numeric, newer_release_newer_numeric, LatestReleaseOrders.SEMVER + ) + assert not is_newer_release( + newer_release_alpha, newer_release_older_numeric, LatestReleaseOrders.SEMVER + ) + assert not is_newer_release( + newer_release, newer_release_older_numeric, LatestReleaseOrders.SEMVER + ) + + def test_is_newer_release_semver(self) -> None: + """Test is_newer_release without build code ordering.""" + self._test_is_newer_release_semver_helper(with_build_code=False) + + def test_is_newer_release_semver_with_build_code(self) -> None: + """Test is_newer_release with build code ordering feature flag.""" + with self.feature("organizations:semver-ordering-with-build-code"): + self._test_is_newer_release_semver_helper(with_build_code=True) From 83a2701ceba972750f96dccb4c74d9398b18e0e6 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Mon, 10 Nov 2025 11:37:23 -0800 Subject: [PATCH 18/32] cleaning up --- .../endpoints/organization_trace_item_attributes.py | 2 +- tests/sentry/api/helpers/test_group_index.py | 10 +++++----- .../rules/filters/test_latest_adopted_release.py | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index 7195bcec99f338..91d79147e18537 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -553,7 +553,7 @@ def semver_package_autocomplete_function(self): id__in=ReleaseProject.objects.filter( project_id__in=self.snuba_params.project_ids ).values_list("release_id", flat=True), - ).annotate_prerelease_column() # type: ignore[attr-defined] + ).annotate_prerelease_column() # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet environment_ids = self.snuba_params.environment_ids if environment_ids: diff --git a/tests/sentry/api/helpers/test_group_index.py b/tests/sentry/api/helpers/test_group_index.py index 5dbf4c846040c7..53b6af07597f80 100644 --- a/tests/sentry/api/helpers/test_group_index.py +++ b/tests/sentry/api/helpers/test_group_index.py @@ -1146,7 +1146,7 @@ def test_delete_groups_deletes_seer_records_by_hash( class GetSemverReleasesTest(TestCase): - def _test_get_semver_releases_helper(self, with_build_code: bool) -> None: + def _test_greatest_semver_releases_helper(self, with_build_code: bool) -> None: """Test that get_semver_releases returns releases in correct semver order.""" release_1 = self.create_release(version="test@2.2", project=self.project) release_2 = self.create_release(version="test@10.0+1000", project=self.project) @@ -1200,14 +1200,14 @@ def _test_get_semver_releases_helper(self, with_build_code: bool) -> None: assert len(releases) == len(expected_order) assert [r.id for r in releases] == [r.id for r in expected_order] - def test_get_semver_releases(self) -> None: + def test_greatest_semver_releases(self) -> None: """Test get_semver_releases orders releases by semver.""" - self._test_get_semver_releases_helper(with_build_code=False) + self._test_greatest_semver_releases_helper(with_build_code=False) - def test_get_semver_releases_with_build_code(self) -> None: + def test_greatest_semver_releases_with_build_code(self) -> None: """Test get_semver_releases orders releases by semver and build code.""" with self.feature("organizations:semver-ordering-with-build-code"): - self._test_get_semver_releases_helper(with_build_code=True) + self._test_greatest_semver_releases_helper(with_build_code=True) def test_greatest_semver_release(self) -> None: """Test that greatest_semver_release returns the highest version release.""" diff --git a/tests/sentry/rules/filters/test_latest_adopted_release.py b/tests/sentry/rules/filters/test_latest_adopted_release.py index 18f1980ecaf04d..14f94e3b204b04 100644 --- a/tests/sentry/rules/filters/test_latest_adopted_release.py +++ b/tests/sentry/rules/filters/test_latest_adopted_release.py @@ -254,10 +254,10 @@ def _test_is_newer_release_semver_helper(self, with_build_code: bool) -> None: ) def test_is_newer_release_semver(self) -> None: - """Test is_newer_release without build code ordering.""" + """Test is_newer_release compares releases by semver.""" self._test_is_newer_release_semver_helper(with_build_code=False) def test_is_newer_release_semver_with_build_code(self) -> None: - """Test is_newer_release with build code ordering feature flag.""" + """Test is_newer_release compares releases by semver and build code.""" with self.feature("organizations:semver-ordering-with-build-code"): self._test_is_newer_release_semver_helper(with_build_code=True) From cc73245d0f89ae640412f63d3c563145ab394363 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Mon, 10 Nov 2025 13:07:55 -0800 Subject: [PATCH 19/32] more tests & clean up feature flag checks --- .../api/endpoints/organization_releases.py | 24 +++++++++--- src/sentry/api/helpers/group_index/update.py | 12 ++++-- .../filters/latest_adopted_release_filter.py | 5 ++- src/sentry/search/events/filter.py | 38 ++++++++++--------- tests/sentry/search/events/test_filter.py | 20 ++++++++++ 5 files changed, 71 insertions(+), 28 deletions(-) diff --git a/src/sentry/api/endpoints/organization_releases.py b/src/sentry/api/endpoints/organization_releases.py index 9cb2034913adf1..19a354bd28597d 100644 --- a/src/sentry/api/endpoints/organization_releases.py +++ b/src/sentry/api/endpoints/organization_releases.py @@ -381,13 +381,19 @@ def __get_new(self, request: Request, organization: Organization) -> Response: queryset = queryset.filter(build_number__isnull=False).order_by("-build_number") paginator_kwargs["order_by"] = "-build_number" elif sort == "semver": + order_by_build_code = features.has( + "organizations:semver-ordering-with-build-code", organization + ) + queryset = queryset.annotate_prerelease_column() - semver_cols = Release.SEMVER_COLS - if features.has("organizations:semver-ordering-with-build-code", organization): + if order_by_build_code: queryset = queryset.annotate_build_code_column() - semver_cols = Release.SEMVER_COLS_WITH_BUILD_CODE + semver_cols = ( + Release.SEMVER_COLS_WITH_BUILD_CODE if order_by_build_code else Release.SEMVER_COLS + ) order_by = [F(col).desc(nulls_last=True) for col in semver_cols] + # TODO: Adding this extra sort order breaks index usage. Index usage is already broken # when we filter by status, so when we fix that we should also consider the best way to # make this work as expected. @@ -557,13 +563,19 @@ def __get_old(self, request: Request, organization: Organization) -> Response: queryset = queryset.filter(build_number__isnull=False).order_by("-build_number") paginator_kwargs["order_by"] = "-build_number" elif sort == "semver": + order_by_build_code = features.has( + "organizations:semver-ordering-with-build-code", organization + ) + queryset = queryset.annotate_prerelease_column() - semver_cols = Release.SEMVER_COLS - if features.has("organizations:semver-ordering-with-build-code", organization): + if order_by_build_code: queryset = queryset.annotate_build_code_column() - semver_cols = Release.SEMVER_COLS_WITH_BUILD_CODE + semver_cols = ( + Release.SEMVER_COLS_WITH_BUILD_CODE if order_by_build_code else Release.SEMVER_COLS + ) order_by = [F(col).desc(nulls_last=True) for col in semver_cols] + # TODO: Adding this extra sort order breaks index usage. Index usage is already broken # when we filter by status, so when we fix that we should also consider the best way to # make this work as expected. diff --git a/src/sentry/api/helpers/group_index/update.py b/src/sentry/api/helpers/group_index/update.py index cff8b6e4457aac..59c097801a4e83 100644 --- a/src/sentry/api/helpers/group_index/update.py +++ b/src/sentry/api/helpers/group_index/update.py @@ -850,15 +850,21 @@ def greatest_semver_release(project: Project) -> Release | None: def get_semver_releases(project: Project) -> QuerySet[Release]: + order_by_build_code = features.has( + "organizations:semver-ordering-with-build-code", project.organization + ) + + semver_cols = ( + Release.SEMVER_COLS_WITH_BUILD_CODE if order_by_build_code else Release.SEMVER_COLS + ) + qs = ( Release.objects.filter(projects=project, organization_id=project.organization_id) .filter_to_semver() # type: ignore[attr-defined] .annotate_prerelease_column() ) - semver_cols = Release.SEMVER_COLS - if features.has("organizations:semver-ordering-with-build-code", project.organization): + if order_by_build_code: qs = qs.annotate_build_code_column() - semver_cols = Release.SEMVER_COLS_WITH_BUILD_CODE return qs.order_by(*[f"-{col}" for col in semver_cols]) diff --git a/src/sentry/rules/filters/latest_adopted_release_filter.py b/src/sentry/rules/filters/latest_adopted_release_filter.py index f20ae1a6c4470d..585c6f3987f8d7 100644 --- a/src/sentry/rules/filters/latest_adopted_release_filter.py +++ b/src/sentry/rules/filters/latest_adopted_release_filter.py @@ -159,7 +159,10 @@ def is_newer_release( and comparison_release.is_semver_release ): organization = Organization.objects.get_from_cache(id=release.organization_id) - if features.has("organizations:semver-ordering-with-build-code", organization): + order_by_build_code = features.has( + "organizations:semver-ordering-with-build-code", organization + ) + if order_by_build_code: return ( release.semver_tuple_with_build_code > comparison_release.semver_tuple_with_build_code diff --git a/src/sentry/search/events/filter.py b/src/sentry/search/events/filter.py index 2d8a75877f6238..1b0b3296ab2d3e 100644 --- a/src/sentry/search/events/filter.py +++ b/src/sentry/search/events/filter.py @@ -330,26 +330,25 @@ def _semver_filter_converter( version: str = search_filter.value.raw_value operator: str = search_filter.operator + organization = Organization.objects.get_from_cache(id=organization_id) + order_by_build_code = features.has( + "organizations:semver-ordering-with-build-code", organization + ) + # Note that we sort this such that if we end up fetching more than # MAX_SEMVER_SEARCH_RELEASES, we will return the releases that are closest to # the passed filter. - organization = Organization.objects.get_from_cache(id=organization_id) - order_by = ( - Release.SEMVER_COLS_WITH_BUILD_CODE - if features.has("organizations:semver-ordering-with-build-code", organization) - else Release.SEMVER_COLS - ) + order_by = Release.SEMVER_COLS_WITH_BUILD_CODE if order_by_build_code else Release.SEMVER_COLS if operator.startswith("<"): order_by = list(map(_flip_field_sort, order_by)) - qs = ( - Release.objects.filter_by_semver( - organization_id, - parse_semver(version, operator), - project_ids=project_ids, - ) - .values_list("version", flat=True) - .order_by(*order_by)[:MAX_SEARCH_RELEASES] + qs = Release.objects.filter_by_semver( + organization_id, + parse_semver(version, operator), + project_ids=project_ids, ) + if order_by_build_code: + qs = qs.annotate_build_code_column() + qs = qs.values_list("version", flat=True).order_by(*order_by)[:MAX_SEARCH_RELEASES] versions = list(qs) final_operator = "IN" if len(versions) == MAX_SEARCH_RELEASES: @@ -361,11 +360,14 @@ def _semver_filter_converter( # Note that the `order_by` here is important for index usage. Postgres seems # to seq scan with this query if the `order_by` isn't included, so we # include it even though we don't really care about order for this query - qs_flipped = ( - Release.objects.filter_by_semver(organization_id, parse_semver(version, operator)) - .order_by(*map(_flip_field_sort, order_by)) - .values_list("version", flat=True)[:MAX_SEARCH_RELEASES] + qs_flipped = Release.objects.filter_by_semver( + organization_id, parse_semver(version, operator) ) + if order_by_build_code: + qs_flipped = qs_flipped.annotate_build_code_column() + qs_flipped = qs_flipped.order_by(*map(_flip_field_sort, order_by)).values_list( + "version", flat=True + )[:MAX_SEARCH_RELEASES] exclude_versions = list(qs_flipped) if exclude_versions and len(exclude_versions) < len(versions): diff --git a/tests/sentry/search/events/test_filter.py b/tests/sentry/search/events/test_filter.py index 591ee8fe03542c..b208ad95848ff9 100644 --- a/tests/sentry/search/events/test_filter.py +++ b/tests/sentry/search/events/test_filter.py @@ -334,6 +334,26 @@ def test_projects(self) -> None: project_id=[project_2.id], ) + def test_build_code_ordering_with_limit(self) -> None: + """ + Test that build code ordering affects which releases are returned when + MAX_SEARCH_RELEASES limit is hit and feature flag is enabled. + """ + release_1 = self.create_release(version="test@1.0.0+zzz") + release_2 = self.create_release(version="test@1.0.0") + release_3 = self.create_release(version="test@1.0.0+100") + self.create_release(version="test@1.0.0+200") + self.create_release(version="test@1.0.0+300") + + # should return the 2 releases closest to 1.0.0 (lowest/no build codes) + with self.feature("organizations:semver-ordering-with-build-code"): + with patch("sentry.search.events.filter.MAX_SEARCH_RELEASES", 2): + self.run_test(">=", "1.0.0", "IN", [release_2.version, release_3.version]) + + # build codes ignored -> tiebreaker is insertion order (asc) + with patch("sentry.search.events.filter.MAX_SEARCH_RELEASES", 2): + self.run_test(">=", "1.0.0", "IN", [release_1.version, release_2.version]) + class SemverPackageFilterConverterTest(BaseSemverConverterTest): key = SEMVER_PACKAGE_ALIAS From ffdaf0de4211cfe228b0a69aaf721eb2a87e2a9d Mon Sep 17 00:00:00 2001 From: srest2021 Date: Mon, 10 Nov 2025 13:48:52 -0800 Subject: [PATCH 20/32] remove unnecessary sorting in backend.py; update filter aliases --- src/sentry/search/eap/spans/filter_aliases.py | 36 +++++++++--------- .../search/events/datasets/filter_aliases.py | 38 +++++++++---------- src/sentry/tagstore/snuba/backend.py | 17 ++++----- 3 files changed, 45 insertions(+), 46 deletions(-) diff --git a/src/sentry/search/eap/spans/filter_aliases.py b/src/sentry/search/eap/spans/filter_aliases.py index 04ec356adff1eb..766221d01641a7 100644 --- a/src/sentry/search/eap/spans/filter_aliases.py +++ b/src/sentry/search/eap/spans/filter_aliases.py @@ -93,25 +93,24 @@ def semver_filter_converter(params: SnubaParams, search_filter: SearchFilter) -> version: str = search_filter.value.raw_value operator: str = search_filter.operator + order_by_build_code = features.has( + "organizations:semver-ordering-with-build-code", params.organization + ) + # Note that we sort this such that if we end up fetching more than # MAX_SEMVER_SEARCH_RELEASES, we will return the releases that are closest to # the passed filter. - order_by = ( - Release.SEMVER_COLS_WITH_BUILD_CODE - if features.has("organizations:semver-ordering-with-build-code", params.organization) - else Release.SEMVER_COLS - ) + order_by = Release.SEMVER_COLS_WITH_BUILD_CODE if order_by_build_code else Release.SEMVER_COLS if operator.startswith("<"): order_by = list(map(_flip_field_sort, order_by)) - qs = ( - Release.objects.filter_by_semver( - organization_id, - parse_semver(version, operator), - project_ids=params.project_ids, - ) - .values_list("version", flat=True) - .order_by(*order_by)[: constants.MAX_SEARCH_RELEASES] + qs = Release.objects.filter_by_semver( + organization_id, + parse_semver(version, operator), + project_ids=params.project_ids, ) + if order_by_build_code: + qs = qs.annotate_build_code_column() + qs = qs.values_list("version", flat=True).order_by(*order_by)[: constants.MAX_SEARCH_RELEASES] versions = list(qs) final_operator: Literal["IN", "NOT IN"] = "IN" if len(versions) == constants.MAX_SEARCH_RELEASES: @@ -123,11 +122,14 @@ def semver_filter_converter(params: SnubaParams, search_filter: SearchFilter) -> # Note that the `order_by` here is important for index usage. Postgres seems # to seq scan with this query if the `order_by` isn't included, so we # include it even though we don't really care about order for this query - qs_flipped = ( - Release.objects.filter_by_semver(organization_id, parse_semver(version, operator)) - .order_by(*map(_flip_field_sort, order_by)) - .values_list("version", flat=True)[: constants.MAX_SEARCH_RELEASES] + qs_flipped = Release.objects.filter_by_semver( + organization_id, parse_semver(version, operator) ) + if order_by_build_code: + qs_flipped = qs_flipped.annotate_build_code_column() + qs_flipped = qs_flipped.order_by(*map(_flip_field_sort, order_by)).values_list( + "version", flat=True + )[: constants.MAX_SEARCH_RELEASES] exclude_versions = list(qs_flipped) if exclude_versions and len(exclude_versions) < len(versions): diff --git a/src/sentry/search/events/datasets/filter_aliases.py b/src/sentry/search/events/datasets/filter_aliases.py index 9e96c2f99b56f4..9f12b0fa4dea4b 100644 --- a/src/sentry/search/events/datasets/filter_aliases.py +++ b/src/sentry/search/events/datasets/filter_aliases.py @@ -210,27 +210,24 @@ def semver_filter_converter( version: str = search_filter.value.raw_value operator: str = search_filter.operator + order_by_build_code = features.has( + "organizations:semver-ordering-with-build-code", builder.params.organization + ) + # Note that we sort this such that if we end up fetching more than # MAX_SEMVER_SEARCH_RELEASES, we will return the releases that are closest to # the passed filter. - order_by = ( - Release.SEMVER_COLS_WITH_BUILD_CODE - if features.has( - "organizations:semver-ordering-with-build-code", builder.params.organization - ) - else Release.SEMVER_COLS - ) + order_by = Release.SEMVER_COLS_WITH_BUILD_CODE if order_by_build_code else Release.SEMVER_COLS if operator.startswith("<"): order_by = list(map(_flip_field_sort, order_by)) - qs = ( - Release.objects.filter_by_semver( - organization_id, - parse_semver(version, operator), - project_ids=builder.params.project_ids, - ) - .values_list("version", flat=True) - .order_by(*order_by)[: constants.MAX_SEARCH_RELEASES] + qs = Release.objects.filter_by_semver( + organization_id, + parse_semver(version, operator), + project_ids=builder.params.project_ids, ) + if order_by_build_code: + qs = qs.annotate_build_code_column() + qs = qs.values_list("version", flat=True).order_by(*order_by)[: constants.MAX_SEARCH_RELEASES] versions = list(qs) final_operator = Op.IN if len(versions) == constants.MAX_SEARCH_RELEASES: @@ -242,11 +239,14 @@ def semver_filter_converter( # Note that the `order_by` here is important for index usage. Postgres seems # to seq scan with this query if the `order_by` isn't included, so we # include it even though we don't really care about order for this query - qs_flipped = ( - Release.objects.filter_by_semver(organization_id, parse_semver(version, operator)) - .order_by(*map(_flip_field_sort, order_by)) - .values_list("version", flat=True)[: constants.MAX_SEARCH_RELEASES] + qs_flipped = Release.objects.filter_by_semver( + organization_id, parse_semver(version, operator) ) + if order_by_build_code: + qs_flipped = qs_flipped.annotate_build_code_column() + qs_flipped = qs_flipped.order_by(*map(_flip_field_sort, order_by)).values_list( + "version", flat=True + )[: constants.MAX_SEARCH_RELEASES] exclude_versions = list(qs_flipped) if exclude_versions and len(exclude_versions) < len(versions): diff --git a/src/sentry/tagstore/snuba/backend.py b/src/sentry/tagstore/snuba/backend.py index ee8430770a4e48..4ea7ca8f4eb9e4 100644 --- a/src/sentry/tagstore/snuba/backend.py +++ b/src/sentry/tagstore/snuba/backend.py @@ -1095,16 +1095,13 @@ def _get_tag_values_for_semver( ).values_list("release_id", flat=True) ) - versions = versions.filter_to_semver().annotate_prerelease_column() - - order_by = Release.SEMVER_COLS - organization = Organization.objects.get_from_cache(id=organization_id) - if features.has("organizations:semver-ordering-with-build-code", organization): - versions = versions.annotate_build_code_column() - order_by = Release.SEMVER_COLS_WITH_BUILD_CODE - - order_by = map(_flip_field_sort, order_by + ["package"]) - versions = versions.order_by(*order_by).values_list("version", flat=True)[:1000] + order_by = map(_flip_field_sort, Release.SEMVER_COLS + ["package"]) + versions = ( + versions.filter_to_semver() + .annotate_prerelease_column() + .order_by(*order_by) + .values_list("version", flat=True)[:1000] + ) seen = set() formatted_versions = [] From 22ae3bf6eb72d89185f9471d052b729b8ace5ceb Mon Sep 17 00:00:00 2001 From: srest2021 Date: Mon, 10 Nov 2025 14:10:38 -0800 Subject: [PATCH 21/32] fix flaky test --- tests/sentry/api/helpers/test_group_index.py | 39 ++++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/tests/sentry/api/helpers/test_group_index.py b/tests/sentry/api/helpers/test_group_index.py index 53b6af07597f80..c0783efccfbb24 100644 --- a/tests/sentry/api/helpers/test_group_index.py +++ b/tests/sentry/api/helpers/test_group_index.py @@ -1181,25 +1181,32 @@ def _test_greatest_semver_releases_helper(self, with_build_code: bool) -> None: release_1, # test@2.2 release_3, # test@2.2-alpha ] + + assert len(releases) == len(expected_order) + assert [r.id for r in releases] == [r.id for r in expected_order] else: - expected_order = [ - release_12, # test@10.0+999 - release_2, # test@10.0+1000 - release_7, # test@10.0+998 - release_8, # test@10.0+x22 - release_9, # test@10.0+a23 - release_10, # test@10.0 - release_11, # test@10.0-abc - release_6, # test@2.20.3.3 - release_5, # test@2.20.3 - release_4, # test@2.2.3 - release_1, # test@2.2 - release_3, # test@2.2-alpha + # Without build code ordering, 10.0 releases (same semver, different build codes) + # are not in deterministic order. Just verify they're grouped at the top. + all_10_releases = { + release_2, + release_7, + release_8, + release_9, + release_10, + release_11, + release_12, + } + + assert len(releases) == 12 + assert set(releases[:7]) == all_10_releases + assert releases[7:] == [ + release_6, + release_5, + release_4, + release_1, + release_3, ] - assert len(releases) == len(expected_order) - assert [r.id for r in releases] == [r.id for r in expected_order] - def test_greatest_semver_releases(self) -> None: """Test get_semver_releases orders releases by semver.""" self._test_greatest_semver_releases_helper(with_build_code=False) From 30cc2d998efdd9cc98faf1c97dade2d9d9a383ab Mon Sep 17 00:00:00 2001 From: srest2021 Date: Mon, 10 Nov 2025 14:33:55 -0800 Subject: [PATCH 22/32] fix typing --- src/sentry/search/eap/spans/filter_aliases.py | 4 ++-- tests/sentry/rules/filters/test_latest_adopted_release.py | 2 +- tests/sentry/search/events/test_filter.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/sentry/search/eap/spans/filter_aliases.py b/src/sentry/search/eap/spans/filter_aliases.py index 766221d01641a7..8ab3e340b90aaf 100644 --- a/src/sentry/search/eap/spans/filter_aliases.py +++ b/src/sentry/search/eap/spans/filter_aliases.py @@ -109,7 +109,7 @@ def semver_filter_converter(params: SnubaParams, search_filter: SearchFilter) -> project_ids=params.project_ids, ) if order_by_build_code: - qs = qs.annotate_build_code_column() + qs = qs.annotate_build_code_column() # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet qs = qs.values_list("version", flat=True).order_by(*order_by)[: constants.MAX_SEARCH_RELEASES] versions = list(qs) final_operator: Literal["IN", "NOT IN"] = "IN" @@ -126,7 +126,7 @@ def semver_filter_converter(params: SnubaParams, search_filter: SearchFilter) -> organization_id, parse_semver(version, operator) ) if order_by_build_code: - qs_flipped = qs_flipped.annotate_build_code_column() + qs_flipped = qs_flipped.annotate_build_code_column() # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet qs_flipped = qs_flipped.order_by(*map(_flip_field_sort, order_by)).values_list( "version", flat=True )[: constants.MAX_SEARCH_RELEASES] diff --git a/tests/sentry/rules/filters/test_latest_adopted_release.py b/tests/sentry/rules/filters/test_latest_adopted_release.py index 14f94e3b204b04..6f7b37c3d3180b 100644 --- a/tests/sentry/rules/filters/test_latest_adopted_release.py +++ b/tests/sentry/rules/filters/test_latest_adopted_release.py @@ -2,9 +2,9 @@ from sentry.rules.filters.latest_adopted_release_filter import ( LatestAdoptedReleaseFilter, - LatestReleaseOrders, is_newer_release, ) +from sentry.search.utils import LatestReleaseOrders from sentry.testutils.cases import RuleTestCase, TestCase diff --git a/tests/sentry/search/events/test_filter.py b/tests/sentry/search/events/test_filter.py index b208ad95848ff9..6ba75f5d8666cc 100644 --- a/tests/sentry/search/events/test_filter.py +++ b/tests/sentry/search/events/test_filter.py @@ -334,7 +334,7 @@ def test_projects(self) -> None: project_id=[project_2.id], ) - def test_build_code_ordering_with_limit(self) -> None: + def test_build_code_ordering(self) -> None: """ Test that build code ordering affects which releases are returned when MAX_SEARCH_RELEASES limit is hit and feature flag is enabled. From d07da244e58f1c6ccefe1277f827e93e701024f6 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Mon, 10 Nov 2025 14:41:13 -0800 Subject: [PATCH 23/32] small naming change --- tests/sentry/api/helpers/test_group_index.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/sentry/api/helpers/test_group_index.py b/tests/sentry/api/helpers/test_group_index.py index c0783efccfbb24..175cec33e8a647 100644 --- a/tests/sentry/api/helpers/test_group_index.py +++ b/tests/sentry/api/helpers/test_group_index.py @@ -1227,7 +1227,7 @@ def test_greatest_semver_release(self) -> None: assert greatest.id == release_2.id assert greatest.version == "test@2.0" - def test_greatest_semver_release_with_build_codes(self) -> None: + def test_greatest_semver_release_with_build_code(self) -> None: """Test that greatest_semver_release returns the highest version release with the highest build code.""" release_with_highest_build = self.create_release( version="test@1.0+100", project=self.project From 960111ebef21cc190d90d72b12969282d6cdf6c2 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Mon, 10 Nov 2025 15:13:33 -0800 Subject: [PATCH 24/32] update utils --- src/sentry/search/utils.py | 24 +++++++++++++++--- tests/sentry/search/test_utils.py | 42 +++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/sentry/search/utils.py b/src/sentry/search/utils.py index 9146ca6fb99885..f1d252ce78a203 100644 --- a/src/sentry/search/utils.py +++ b/src/sentry/search/utils.py @@ -10,8 +10,10 @@ from django.db import DataError, connections, router from django.utils import timezone as django_timezone +from sentry import features from sentry.models.environment import Environment from sentry.models.group import STATUS_QUERY_CHOICES, Group +from sentry.models.organization import Organization from sentry.models.organizationmember import OrganizationMember from sentry.models.organizationmemberteam import OrganizationMemberTeam from sentry.models.project import Project @@ -413,11 +415,21 @@ def get_latest_release( return sorted(versions) -def _get_release_query_type_sql(query_type: LatestReleaseOrders, last: bool) -> tuple[str, str]: +def _get_release_query_type_sql( + query_type: LatestReleaseOrders, last: bool, organization_id: int +) -> tuple[str, str]: direction = "DESC" if last else "ASC" extra_conditions = "" if query_type == LatestReleaseOrders.SEMVER: - rank_order_by = f"major {direction}, minor {direction}, patch {direction}, revision {direction}, CASE WHEN (prerelease = '') THEN 1 ELSE 0 END {direction}, prerelease {direction}, sr.id {direction}" + organization = Organization.objects.get_from_cache(id=organization_id) + order_by_build_code = features.has( + "organizations:semver-ordering-with-build-code", organization + ) + + if order_by_build_code: + rank_order_by = f"major {direction}, minor {direction}, patch {direction}, revision {direction}, CASE WHEN (prerelease = '') THEN 1 ELSE 0 END {direction}, prerelease {direction}, CASE WHEN (build_number IS NULL AND build_code IS NOT NULL) THEN 2 WHEN (build_number IS NOT NULL) THEN 1 ELSE 0 END {direction}, build_number {direction}, build_code {direction}, sr.id {direction}" + else: + rank_order_by = f"major {direction}, minor {direction}, patch {direction}, revision {direction}, CASE WHEN (prerelease = '') THEN 1 ELSE 0 END {direction}, prerelease {direction}, sr.id {direction}" extra_conditions += " AND sr.major IS NOT NULL" else: rank_order_by = f"COALESCE(date_released, date_added) {direction}" @@ -446,7 +458,9 @@ def _run_latest_release_query( if adopted: extra_conditions += " AND jt.adopted IS NOT NULL AND jt.unadopted IS NULL " - rank_order_by, query_type_conditions = _get_release_query_type_sql(query_type, True) + rank_order_by, query_type_conditions = _get_release_query_type_sql( + query_type, True, organization_id + ) extra_conditions += query_type_conditions # XXX: This query can be very inefficient for projects with a large (100k+) @@ -500,7 +514,9 @@ def get_first_last_release_for_group( ordering to order the releases. """ direction = "DESC" if last else "ASC" - rank_order_by, extra_conditions = _get_release_query_type_sql(query_type, last) + rank_order_by, extra_conditions = _get_release_query_type_sql( + query_type, last, group.project.organization_id + ) query = f""" SELECT sr.* diff --git a/tests/sentry/search/test_utils.py b/tests/sentry/search/test_utils.py index 1e6f2b99e8d0a5..86a11cf686b723 100644 --- a/tests/sentry/search/test_utils.py +++ b/tests/sentry/search/test_utils.py @@ -831,6 +831,23 @@ def test_multiple_projects_mixed_versions(self) -> None: release_1.version, ] + def test_semver_with_build_code(self) -> None: + """Test that get_latest_release handles build code ordering.""" + self.create_release(version="test@1.0.0+1000") + self.create_release(version="test@1.0.0+999") + release_highest_build = self.create_release(version="test@1.0.0+zzz") + self.create_release(version="test@1.0.0") + release_latest_created = self.create_release(version="test@1.0.0+aaa") + + # no build code ordering -> tiebreaker is sr.id + result = get_latest_release([self.project], None) + assert result == [release_latest_created.version] + + # with build code ordering -> alphanumeric builds are highest + with self.feature("organizations:semver-ordering-with-build-code"): + result = get_latest_release([self.project], None) + assert result == [release_highest_build.version] + class GetFirstLastReleaseForGroupTest(TestCase): def test_date(self) -> None: @@ -894,6 +911,31 @@ def test_semver(self) -> None: group_2, LatestReleaseOrders.SEMVER, False ) + def test_semver_with_build_code(self) -> None: + """Test that get_first_last_release_for_group handles build code ordering.""" + release_numeric_low = self.create_release(version="test@1.0.0+999") + release_numeric_high = self.create_release(version="test@1.0.0+1000") + release_alpha = self.create_release(version="test@1.0.0+zzz") + release_no_build = self.create_release(version="test@1.0.0") + + self.create_group_release(group=self.group, release=release_numeric_low) + self.create_group_release(group=self.group, release=release_numeric_high) + self.create_group_release(group=self.group, release=release_alpha) + self.create_group_release(group=self.group, release=release_no_build) + + # no build code ordering -> tiebreaker is sr.id + result = get_first_last_release_for_group(self.group, LatestReleaseOrders.SEMVER, True) + assert result.version == release_no_build.version # latest created + result = get_first_last_release_for_group(self.group, LatestReleaseOrders.SEMVER, False) + assert result.version == release_numeric_low.version # oldest created + + # with build code ordering -> alphanumeric builds are highest, no build is lowest + with self.feature("organizations:semver-ordering-with-build-code"): + result = get_first_last_release_for_group(self.group, LatestReleaseOrders.SEMVER, True) + assert result.version == release_alpha.version + result = get_first_last_release_for_group(self.group, LatestReleaseOrders.SEMVER, False) + assert result.version == release_no_build.version + @control_silo_test class ConvertUserTagTest(TestCase): From ba16113a411d4619b5061768a15f456907b6ec13 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Mon, 10 Nov 2025 15:20:28 -0800 Subject: [PATCH 25/32] fix failing test --- tests/sentry/search/events/test_filter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/sentry/search/events/test_filter.py b/tests/sentry/search/events/test_filter.py index 6ba75f5d8666cc..677ea43fb5a19b 100644 --- a/tests/sentry/search/events/test_filter.py +++ b/tests/sentry/search/events/test_filter.py @@ -415,15 +415,15 @@ def test_invalid_params(self) -> None: key = SEMVER_BUILD_ALIAS filter = SearchFilter(SearchKey(key), "=", SearchValue("sentry")) with pytest.raises(ValueError, match="organization_id is a required param"): - _semver_filter_converter(filter, key, None) + self.converter(filter, key, None) with pytest.raises(ValueError, match="organization_id is a required param"): - _semver_filter_converter(filter, key, {"something": 1}) # type: ignore[arg-type] # intentionally bad data + self.converter(filter, key, {"something": 1}) # type: ignore[arg-type] # intentionally bad data filter = SearchFilter(SearchKey(key), "IN", SearchValue("sentry")) with pytest.raises( InvalidSearchQuery, match="Invalid operation 'IN' for semantic version filter." ): - _semver_filter_converter(filter, key, {"organization_id": 1}) + self.converter(filter, key, {"organization_id": 1}) def test_empty(self) -> None: self.run_test("=", "test", "IN", [SEMVER_EMPTY_RELEASE]) From 3486a98f914d8b481f40a40008d2f959eabea7f8 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Mon, 10 Nov 2025 15:38:08 -0800 Subject: [PATCH 26/32] fix typing again --- tests/sentry/search/events/test_filter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/sentry/search/events/test_filter.py b/tests/sentry/search/events/test_filter.py index 677ea43fb5a19b..6a4ab6f48fdadb 100644 --- a/tests/sentry/search/events/test_filter.py +++ b/tests/sentry/search/events/test_filter.py @@ -417,7 +417,7 @@ def test_invalid_params(self) -> None: with pytest.raises(ValueError, match="organization_id is a required param"): self.converter(filter, key, None) with pytest.raises(ValueError, match="organization_id is a required param"): - self.converter(filter, key, {"something": 1}) # type: ignore[arg-type] # intentionally bad data + self.converter(filter, key, {"something": 1}) # intentionally bad data filter = SearchFilter(SearchKey(key), "IN", SearchValue("sentry")) with pytest.raises( From 205504e2c7e8b716b30f25e36254a35e472206b7 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 11 Nov 2025 14:12:20 -0800 Subject: [PATCH 27/32] revert filter changes --- src/sentry/search/eap/spans/filter_aliases.py | 33 +++++++--------- .../search/events/datasets/filter_aliases.py | 38 +++++++------------ src/sentry/search/events/filter.py | 35 +++++++---------- tests/sentry/search/events/test_filter.py | 26 ++----------- 4 files changed, 42 insertions(+), 90 deletions(-) diff --git a/src/sentry/search/eap/spans/filter_aliases.py b/src/sentry/search/eap/spans/filter_aliases.py index 8ab3e340b90aaf..7c6bff10435e41 100644 --- a/src/sentry/search/eap/spans/filter_aliases.py +++ b/src/sentry/search/eap/spans/filter_aliases.py @@ -1,7 +1,6 @@ import string from typing import Literal -from sentry import features from sentry.api.event_search import SearchFilter, SearchKey, SearchValue from sentry.exceptions import InvalidSearchQuery from sentry.models.release import Release @@ -93,24 +92,21 @@ def semver_filter_converter(params: SnubaParams, search_filter: SearchFilter) -> version: str = search_filter.value.raw_value operator: str = search_filter.operator - order_by_build_code = features.has( - "organizations:semver-ordering-with-build-code", params.organization - ) - # Note that we sort this such that if we end up fetching more than # MAX_SEMVER_SEARCH_RELEASES, we will return the releases that are closest to # the passed filter. - order_by = Release.SEMVER_COLS_WITH_BUILD_CODE if order_by_build_code else Release.SEMVER_COLS + order_by = Release.SEMVER_COLS if operator.startswith("<"): order_by = list(map(_flip_field_sort, order_by)) - qs = Release.objects.filter_by_semver( - organization_id, - parse_semver(version, operator), - project_ids=params.project_ids, + qs = ( + Release.objects.filter_by_semver( + organization_id, + parse_semver(version, operator), + project_ids=params.project_ids, + ) + .values_list("version", flat=True) + .order_by(*order_by)[: constants.MAX_SEARCH_RELEASES] ) - if order_by_build_code: - qs = qs.annotate_build_code_column() # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet - qs = qs.values_list("version", flat=True).order_by(*order_by)[: constants.MAX_SEARCH_RELEASES] versions = list(qs) final_operator: Literal["IN", "NOT IN"] = "IN" if len(versions) == constants.MAX_SEARCH_RELEASES: @@ -122,14 +118,11 @@ def semver_filter_converter(params: SnubaParams, search_filter: SearchFilter) -> # Note that the `order_by` here is important for index usage. Postgres seems # to seq scan with this query if the `order_by` isn't included, so we # include it even though we don't really care about order for this query - qs_flipped = Release.objects.filter_by_semver( - organization_id, parse_semver(version, operator) + qs_flipped = ( + Release.objects.filter_by_semver(organization_id, parse_semver(version, operator)) + .order_by(*map(_flip_field_sort, order_by)) + .values_list("version", flat=True)[: constants.MAX_SEARCH_RELEASES] ) - if order_by_build_code: - qs_flipped = qs_flipped.annotate_build_code_column() # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet - qs_flipped = qs_flipped.order_by(*map(_flip_field_sort, order_by)).values_list( - "version", flat=True - )[: constants.MAX_SEARCH_RELEASES] exclude_versions = list(qs_flipped) if exclude_versions and len(exclude_versions) < len(versions): diff --git a/src/sentry/search/events/datasets/filter_aliases.py b/src/sentry/search/events/datasets/filter_aliases.py index 9f12b0fa4dea4b..42b582da8b3bbf 100644 --- a/src/sentry/search/events/datasets/filter_aliases.py +++ b/src/sentry/search/events/datasets/filter_aliases.py @@ -5,7 +5,6 @@ import sentry_sdk from snuba_sdk import Column, Condition, Function, Op -from sentry import features from sentry.api.event_search import SearchFilter, SearchKey, SearchValue from sentry.exceptions import InvalidSearchQuery from sentry.models.release import Release @@ -210,43 +209,32 @@ def semver_filter_converter( version: str = search_filter.value.raw_value operator: str = search_filter.operator - order_by_build_code = features.has( - "organizations:semver-ordering-with-build-code", builder.params.organization - ) - # Note that we sort this such that if we end up fetching more than # MAX_SEMVER_SEARCH_RELEASES, we will return the releases that are closest to # the passed filter. - order_by = Release.SEMVER_COLS_WITH_BUILD_CODE if order_by_build_code else Release.SEMVER_COLS + order_by = Release.SEMVER_COLS if operator.startswith("<"): order_by = list(map(_flip_field_sort, order_by)) - qs = Release.objects.filter_by_semver( - organization_id, - parse_semver(version, operator), - project_ids=builder.params.project_ids, + qs = ( + Release.objects.filter_by_semver( + organization_id, + parse_semver(version, operator), + project_ids=builder.params.project_ids, + ) + .values_list("version", flat=True) + .order_by(*order_by)[: constants.MAX_SEARCH_RELEASES] ) - if order_by_build_code: - qs = qs.annotate_build_code_column() - qs = qs.values_list("version", flat=True).order_by(*order_by)[: constants.MAX_SEARCH_RELEASES] versions = list(qs) final_operator = Op.IN if len(versions) == constants.MAX_SEARCH_RELEASES: - # We want to limit how many versions we pass through to Snuba. If we've hit - # the limit, make an extra query and see whether the inverse has fewer ids. - # If so, we can do a NOT IN query with these ids instead. Otherwise, we just - # do our best. - operator = constants.OPERATOR_NEGATION_MAP[operator] # Note that the `order_by` here is important for index usage. Postgres seems # to seq scan with this query if the `order_by` isn't included, so we # include it even though we don't really care about order for this query - qs_flipped = Release.objects.filter_by_semver( - organization_id, parse_semver(version, operator) + qs_flipped = ( + Release.objects.filter_by_semver(organization_id, parse_semver(version, operator)) + .order_by(*map(_flip_field_sort, order_by)) + .values_list("version", flat=True)[: constants.MAX_SEARCH_RELEASES] ) - if order_by_build_code: - qs_flipped = qs_flipped.annotate_build_code_column() - qs_flipped = qs_flipped.order_by(*map(_flip_field_sort, order_by)).values_list( - "version", flat=True - )[: constants.MAX_SEARCH_RELEASES] exclude_versions = list(qs_flipped) if exclude_versions and len(exclude_versions) < len(versions): diff --git a/src/sentry/search/events/filter.py b/src/sentry/search/events/filter.py index 1b0b3296ab2d3e..bb5f7792e2bd6f 100644 --- a/src/sentry/search/events/filter.py +++ b/src/sentry/search/events/filter.py @@ -8,7 +8,6 @@ from sentry_relay.consts import SPAN_STATUS_NAME_TO_CODE from sentry_relay.processing import parse_release as parse_release_relay -from sentry import features from sentry.api.event_search import ( AggregateFilter, ParenExpression, @@ -20,7 +19,6 @@ from sentry.constants import SEMVER_FAKE_PACKAGE from sentry.exceptions import InvalidSearchQuery from sentry.models.group import Group -from sentry.models.organization import Organization from sentry.models.project import Project from sentry.models.release import Release from sentry.models.releases.util import SemverFilter @@ -330,25 +328,21 @@ def _semver_filter_converter( version: str = search_filter.value.raw_value operator: str = search_filter.operator - organization = Organization.objects.get_from_cache(id=organization_id) - order_by_build_code = features.has( - "organizations:semver-ordering-with-build-code", organization - ) - # Note that we sort this such that if we end up fetching more than # MAX_SEMVER_SEARCH_RELEASES, we will return the releases that are closest to # the passed filter. - order_by = Release.SEMVER_COLS_WITH_BUILD_CODE if order_by_build_code else Release.SEMVER_COLS + order_by = Release.SEMVER_COLS if operator.startswith("<"): order_by = list(map(_flip_field_sort, order_by)) - qs = Release.objects.filter_by_semver( - organization_id, - parse_semver(version, operator), - project_ids=project_ids, + qs = ( + Release.objects.filter_by_semver( + organization_id, + parse_semver(version, operator), + project_ids=project_ids, + ) + .values_list("version", flat=True) + .order_by(*order_by)[:MAX_SEARCH_RELEASES] ) - if order_by_build_code: - qs = qs.annotate_build_code_column() - qs = qs.values_list("version", flat=True).order_by(*order_by)[:MAX_SEARCH_RELEASES] versions = list(qs) final_operator = "IN" if len(versions) == MAX_SEARCH_RELEASES: @@ -360,14 +354,11 @@ def _semver_filter_converter( # Note that the `order_by` here is important for index usage. Postgres seems # to seq scan with this query if the `order_by` isn't included, so we # include it even though we don't really care about order for this query - qs_flipped = Release.objects.filter_by_semver( - organization_id, parse_semver(version, operator) + qs_flipped = ( + Release.objects.filter_by_semver(organization_id, parse_semver(version, operator)) + .order_by(*map(_flip_field_sort, order_by)) + .values_list("version", flat=True)[:MAX_SEARCH_RELEASES] ) - if order_by_build_code: - qs_flipped = qs_flipped.annotate_build_code_column() - qs_flipped = qs_flipped.order_by(*map(_flip_field_sort, order_by)).values_list( - "version", flat=True - )[:MAX_SEARCH_RELEASES] exclude_versions = list(qs_flipped) if exclude_versions and len(exclude_versions) < len(versions): diff --git a/tests/sentry/search/events/test_filter.py b/tests/sentry/search/events/test_filter.py index 6a4ab6f48fdadb..591ee8fe03542c 100644 --- a/tests/sentry/search/events/test_filter.py +++ b/tests/sentry/search/events/test_filter.py @@ -334,26 +334,6 @@ def test_projects(self) -> None: project_id=[project_2.id], ) - def test_build_code_ordering(self) -> None: - """ - Test that build code ordering affects which releases are returned when - MAX_SEARCH_RELEASES limit is hit and feature flag is enabled. - """ - release_1 = self.create_release(version="test@1.0.0+zzz") - release_2 = self.create_release(version="test@1.0.0") - release_3 = self.create_release(version="test@1.0.0+100") - self.create_release(version="test@1.0.0+200") - self.create_release(version="test@1.0.0+300") - - # should return the 2 releases closest to 1.0.0 (lowest/no build codes) - with self.feature("organizations:semver-ordering-with-build-code"): - with patch("sentry.search.events.filter.MAX_SEARCH_RELEASES", 2): - self.run_test(">=", "1.0.0", "IN", [release_2.version, release_3.version]) - - # build codes ignored -> tiebreaker is insertion order (asc) - with patch("sentry.search.events.filter.MAX_SEARCH_RELEASES", 2): - self.run_test(">=", "1.0.0", "IN", [release_1.version, release_2.version]) - class SemverPackageFilterConverterTest(BaseSemverConverterTest): key = SEMVER_PACKAGE_ALIAS @@ -415,15 +395,15 @@ def test_invalid_params(self) -> None: key = SEMVER_BUILD_ALIAS filter = SearchFilter(SearchKey(key), "=", SearchValue("sentry")) with pytest.raises(ValueError, match="organization_id is a required param"): - self.converter(filter, key, None) + _semver_filter_converter(filter, key, None) with pytest.raises(ValueError, match="organization_id is a required param"): - self.converter(filter, key, {"something": 1}) # intentionally bad data + _semver_filter_converter(filter, key, {"something": 1}) # type: ignore[arg-type] # intentionally bad data filter = SearchFilter(SearchKey(key), "IN", SearchValue("sentry")) with pytest.raises( InvalidSearchQuery, match="Invalid operation 'IN' for semantic version filter." ): - self.converter(filter, key, {"organization_id": 1}) + _semver_filter_converter(filter, key, {"organization_id": 1}) def test_empty(self) -> None: self.run_test("=", "test", "IN", [SEMVER_EMPTY_RELEASE]) From 22984f161cb8dc2bcd2da1c0b74b79bc327aecd8 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 11 Nov 2025 14:14:06 -0800 Subject: [PATCH 28/32] oops --- src/sentry/search/events/datasets/filter_aliases.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/sentry/search/events/datasets/filter_aliases.py b/src/sentry/search/events/datasets/filter_aliases.py index 42b582da8b3bbf..f68ffaeff7776f 100644 --- a/src/sentry/search/events/datasets/filter_aliases.py +++ b/src/sentry/search/events/datasets/filter_aliases.py @@ -227,6 +227,11 @@ def semver_filter_converter( versions = list(qs) final_operator = Op.IN if len(versions) == constants.MAX_SEARCH_RELEASES: + # We want to limit how many versions we pass through to Snuba. If we've hit + # the limit, make an extra query and see whether the inverse has fewer ids. + # If so, we can do a NOT IN query with these ids instead. Otherwise, we just + # do our best. + operator = constants.OPERATOR_NEGATION_MAP[operator] # Note that the `order_by` here is important for index usage. Postgres seems # to seq scan with this query if the `order_by` isn't included, so we # include it even though we don't really care about order for this query From c77a8563f11155a38fdcdaf7dafafe735aa73759 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 11 Nov 2025 15:33:22 -0800 Subject: [PATCH 29/32] revert backend.py changes --- src/sentry/tagstore/snuba/backend.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/sentry/tagstore/snuba/backend.py b/src/sentry/tagstore/snuba/backend.py index 4ea7ca8f4eb9e4..38e9f6a91bc9bb 100644 --- a/src/sentry/tagstore/snuba/backend.py +++ b/src/sentry/tagstore/snuba/backend.py @@ -1043,19 +1043,13 @@ def _get_semver_versions_for_package(self, projects, organization_id, package): .distinct() ) - qs = Release.objects.filter( + return Release.objects.filter( organization_id=organization_id, package__in=packages, id__in=ReleaseProject.objects.filter(project_id__in=projects).values_list( "release_id", flat=True ), - ).annotate_prerelease_column() # type: ignore[attr-defined] - - organization = Organization.objects.get_from_cache(id=organization_id) - if features.has("organizations:semver-ordering-with-build-code", organization): - qs = qs.annotate_build_code_column() - - return qs + ).annotate_prerelease_column() # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet def _get_tag_values_for_semver( self, From 0c479bce1690e9bd364dd6e67152cdb279de0b19 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 11 Nov 2025 16:41:31 -0800 Subject: [PATCH 30/32] latest adopted release test --- .../filters/test_latest_adopted_release.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/tests/sentry/rules/filters/test_latest_adopted_release.py b/tests/sentry/rules/filters/test_latest_adopted_release.py index 6f7b37c3d3180b..07cbda4c0890b2 100644 --- a/tests/sentry/rules/filters/test_latest_adopted_release.py +++ b/tests/sentry/rules/filters/test_latest_adopted_release.py @@ -6,6 +6,7 @@ ) from sentry.search.utils import LatestReleaseOrders from sentry.testutils.cases import RuleTestCase, TestCase +from sentry.testutils.helpers.features import with_feature class LatestAdoptedReleaseFilterTest(RuleTestCase): @@ -130,6 +131,101 @@ def test_semver_with_release_without_adoption(self) -> None: # Oldest release for group is 1.9, latest adopted release for environment is 1.0 self.assertPasses(rule, event_2) + @with_feature("organizations:semver-ordering-with-build-code") + def test_semver_with_build_code(self) -> None: + """ + Test that the rule uses build number when ordering releases by semver. + + Without build code ordering, whichever release is created last will get + picked as the latest adopted release for the env. Ie, the behavior that + we are changing is the fallback to insertion order (id), NOT date_added. + In prod, id and date_added are closely linked, but we specify here for + clarity. + """ + event = self.get_event() + now = datetime.now(UTC) + prod = self.create_environment(name="prod") + test = self.create_environment(name="test") + + release_9 = self.create_release( + project=event.group.project, + version="test@2.0+9", + environments=[test], + date_added=now - timedelta(days=2), + adopted=now - timedelta(days=2), + ) + self.create_group_release(group=self.event.group, release=release_9) + + release_11 = self.create_release( + project=event.group.project, + version="test@2.0+11", + environments=[test], + date_added=now - timedelta(days=2), + adopted=now - timedelta(days=2), + ) + + self.create_release( + project=event.group.project, + version="test@2.0+10", + environments=[prod], + date_added=now - timedelta(days=1), + adopted=now - timedelta(days=1), + ) + + self.create_release( + project=event.group.project, + version="test@2.0+8", + environments=[prod], + date_added=now, + adopted=now, + ) + + # The newest adopted release associated with the event's issue (2.0+9) + # is older than the latest adopted release in prod (2.0+10) + data = {"oldest_or_newest": "newest", "older_or_newer": "newer", "environment": prod.name} + rule = self.get_rule(data=data) + self.assertDoesNotPass(rule, event) + data = {"oldest_or_newest": "newest", "older_or_newer": "older", "environment": prod.name} + rule = self.get_rule(data=data) + self.assertPasses(rule, event) + + self.create_group_release(group=self.event.group, release=release_11) + + # The newest adopted release associated with the event's issue (2.0+11) + # is newer than the latest adopted release in prod (2.0+10) + data = {"oldest_or_newest": "newest", "older_or_newer": "newer", "environment": prod.name} + rule = self.get_rule(data=data) + self.assertPasses(rule, event) + data = {"oldest_or_newest": "newest", "older_or_newer": "older", "environment": prod.name} + rule = self.get_rule(data=data) + self.assertDoesNotPass(rule, event) + + # The oldest adopted release associated with the event's issue (2.0+9) + # is older than the latest adopted release in prod (2.0+10) + data = {"oldest_or_newest": "oldest", "older_or_newer": "newer", "environment": prod.name} + rule = self.get_rule(data=data) + self.assertDoesNotPass(rule, event) + data = {"oldest_or_newest": "oldest", "older_or_newer": "older", "environment": prod.name} + rule = self.get_rule(data=data) + self.assertPasses(rule, event) + + self.create_release( + project=event.group.project, + version="test@2.0+a", + environments=[prod], + date_added=now - timedelta(days=3), + adopted=now - timedelta(days=3), + ) + + # The newest adopted release associated with the event's issue (2.0+11) + # is older than the latest adopted release in prod (2.0+a) + data = {"oldest_or_newest": "newest", "older_or_newer": "newer", "environment": prod.name} + rule = self.get_rule(data=data) + self.assertDoesNotPass(rule, event) + data = {"oldest_or_newest": "newest", "older_or_newer": "older", "environment": prod.name} + rule = self.get_rule(data=data) + self.assertPasses(rule, event) + def test_no_adopted_release(self) -> None: event = self.get_event() now = datetime.now(UTC) From 275191056d9e41b77321363aa58a604992e20c76 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 11 Nov 2025 19:59:37 -0800 Subject: [PATCH 31/32] fix comment --- tests/sentry/search/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/sentry/search/test_utils.py b/tests/sentry/search/test_utils.py index 86a11cf686b723..15742329c6b398 100644 --- a/tests/sentry/search/test_utils.py +++ b/tests/sentry/search/test_utils.py @@ -839,7 +839,7 @@ def test_semver_with_build_code(self) -> None: self.create_release(version="test@1.0.0") release_latest_created = self.create_release(version="test@1.0.0+aaa") - # no build code ordering -> tiebreaker is sr.id + # no build code ordering -> tiebreaker is insertion order (id) result = get_latest_release([self.project], None) assert result == [release_latest_created.version] From f7cb5dced23d1dbdc4ac72c1addbc166a2943fc6 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Wed, 12 Nov 2025 11:10:02 -0800 Subject: [PATCH 32/32] more test coverage for latest adopted release --- .../rules/filters/test_latest_adopted_release.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/sentry/rules/filters/test_latest_adopted_release.py b/tests/sentry/rules/filters/test_latest_adopted_release.py index 07cbda4c0890b2..c969aecff84e1c 100644 --- a/tests/sentry/rules/filters/test_latest_adopted_release.py +++ b/tests/sentry/rules/filters/test_latest_adopted_release.py @@ -336,18 +336,34 @@ def _test_is_newer_release_semver_helper(self, with_build_code: bool) -> None: assert is_newer_release(newer_release, older_release, LatestReleaseOrders.SEMVER) # all releases with version 1.0 are considered equal + assert not is_newer_release( newer_release_newer_numeric, newer_release_older_numeric, LatestReleaseOrders.SEMVER ) + assert not is_newer_release( + newer_release_older_numeric, newer_release_newer_numeric, LatestReleaseOrders.SEMVER + ) + assert not is_newer_release( newer_release_older_numeric, newer_release_newer_numeric, LatestReleaseOrders.SEMVER ) + assert not is_newer_release( + newer_release_newer_numeric, newer_release_older_numeric, LatestReleaseOrders.SEMVER + ) + assert not is_newer_release( newer_release_alpha, newer_release_older_numeric, LatestReleaseOrders.SEMVER ) + assert not is_newer_release( + newer_release_older_numeric, newer_release_alpha, LatestReleaseOrders.SEMVER + ) + assert not is_newer_release( newer_release, newer_release_older_numeric, LatestReleaseOrders.SEMVER ) + assert not is_newer_release( + newer_release_older_numeric, newer_release, LatestReleaseOrders.SEMVER + ) def test_is_newer_release_semver(self) -> None: """Test is_newer_release compares releases by semver."""