Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
bc5b993
feat(eap): add semver sort key for sentry.release ORDER BY in EAP
claude Jun 25, 2026
f08a5cd
fix(eap): fix mypy error and GROUP BY semver tuple mismatch
claude Jun 25, 2026
4be96cd
[getsentry/action-github-commit] Auto commit
getsantry[bot] Jun 25, 2026
1e2d24b
fix(eap): apply semver sort key to flextime pagination boundary compa…
claude Jun 25, 2026
62fcc84
fix(eap): handle Nullable(String) in semver_sort_key
claude Jun 25, 2026
0dc485b
fix(test): handle tied semver sort keys in test_desc_is_reverse_of_asc
claude Jun 25, 2026
6f24de6
Merge branch 'master' into claude/semver-sorting-eap-clickhouse-lpg3gs
claude Jun 26, 2026
6628441
[getsentry/action-github-commit] Auto commit
getsantry[bot] Jun 26, 2026
6891fa8
fix(lint): add explicit strict=True to zip() in pagination.get_filters
claude Jun 26, 2026
750bfcf
Merge branch 'master' into claude/semver-sorting-eap-clickhouse-lpg3gs
claude Jun 28, 2026
b576c6c
feat(eap): also semver-sort sentry.sdk.version
claude Jun 29, 2026
a67628a
[getsentry/action-github-commit] Auto commit
getsantry[bot] Jun 29, 2026
b989fe9
Revert sentry.sdk.version from SEMVER_SORT_ATTRIBUTES
claude Jun 29, 2026
85e4223
fix(eap): add raw-string tiebreaker to semver sort key
claude Jun 29, 2026
a75e224
Merge branch 'master' into claude/semver-sorting-eap-clickhouse-lpg3gs
claude Jun 29, 2026
94eea20
Remove test_uniq_with_default_value_double_casts_to_float64
claude Jun 29, 2026
18d4f49
Merge branch 'master' into claude/semver-sorting-eap-clickhouse-lpg3gs
claude Jul 3, 2026
d3210eb
feat(eap): respect SORT_NATURAL on attribute values endpoint
claude Jul 3, 2026
f8741ab
test: write natural-sort fixtures per-test, not in shared fixture
claude Jul 3, 2026
a9dbc5a
feat(eap): make SORT_NATURAL release-aware for release attributes
claude Jul 3, 2026
7a45d04
test: make release-sort test robust to default sentry.release
claude Jul 3, 2026
75edf1d
fix(eap): treat PEP 440 dot-dev builds as prereleases in semver key
claude Jul 3, 2026
bc680ba
test: update semver structure assertion to match() stability flag
claude Jul 3, 2026
267d3b5
feat(eap): drive semver ordering via SORT_NATURAL on all endpoints
claude Jul 3, 2026
56fac6c
fix: guard _semver_sort_key_py against non-ASCII Unicode digits
claude Jul 3, 2026
d55cf19
[getsentry/action-github-commit] Auto commit
getsantry[bot] Jul 3, 2026
8ecbb25
fix(mypy): type _name_key with Mapping[str, Any] -> Any
claude Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions snuba/web/rpc/common/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,31 @@ def attribute_key_to_expression(attr_key: AttributeKey) -> Expression:
raise BadSnubaRPCRequestException(str(e)) from e


_SEMVER_COMPONENT_COUNT = 4 # major.minor.patch.build


def semver_sort_key(expr: Expression, alias: str | None = None) -> Expression:
"""Return a Tuple(Array(UInt32), UInt8) sort key for semantic-version ORDER BY.

Strips a leading 'package@' prefix, isolates the release part (before '-'),
maps each dot-component to UInt32, pads to 4 elements so "1.2" == "1.2.0",
and adds a stability flag (0=prerelease, 1=stable) so prerelease versions sort
before their corresponding stable release. Works on Altinity 25.3/25.8.
"""
x = Argument(None, "x")
version_no_prefix = f.arrayElement(f.splitByChar(literal("@"), expr), literal(-1))
release_part = f.arrayElement(f.splitByChar(literal("-"), version_no_prefix), literal(1))
numeric_key = f.arrayResize(
f.arrayMap(
Lambda(None, ("x",), f.toUInt32OrZero(x)),
f.splitByChar(literal("."), release_part),
),
literal(_SEMVER_COMPONENT_COUNT),
)
is_stable = f.equals(f.position(version_no_prefix, literal("-")), literal(0))
return FunctionCall(alias, "tuple", (numeric_key, is_stable))


def _trace_item_filter_key_expression(
attr_to_key_expression_callable: Callable[[AttributeKey], Expression], key: AttributeKey
) -> Expression:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
attribute_key_to_expression,
base_conditions_and,
get_field_existence_expression,
semver_sort_key,
timestamp_in_range_condition,
trace_item_filters_to_expression,
treeify_or_and_conditions,
Expand Down Expand Up @@ -88,6 +89,8 @@

_DEFAULT_ROW_LIMIT = 10_000

_SEMVER_SORT_ATTRIBUTES: frozenset[str] = frozenset({"sentry.release"})


OP_TO_EXPR = {
Column.BinaryFormula.OP_ADD: f.plus,
Expand Down Expand Up @@ -268,7 +271,7 @@ def aggregation_filter_to_expression(
raise BadSnubaRPCRequestException(f"Unsupported aggregation filter type: {default}")


def _groupby_order_by_expression(attr_key: AttributeKey) -> Expression:
def _groupby_order_by_expression(attr_key: AttributeKey, for_order_by: bool = False) -> Expression:
"""
Maps an attribute key used in GROUP BY / ORDER BY to its expression.

Expand All @@ -280,10 +283,17 @@ def _groupby_order_by_expression(attr_key: AttributeKey) -> Expression:
valid (it is a function of the grouped column) while letting ORDER BY sort on
the raw column. If the two diverged, ClickHouse would reject the query with
"Column `timestamp` is not under aggregate function and not in GROUP BY".

For `_SEMVER_SORT_ATTRIBUTES` (e.g. `sentry.release`), the ORDER BY uses
a semver tuple key while GROUP BY keeps the raw expression. ClickHouse
accepts ORDER BY on a function of a GROUP BY key, so the two can differ here.
"""
if attr_key.name == "sentry.timestamp":
return snuba_column("timestamp")
return attribute_key_to_expression(attr_key)
base = attribute_key_to_expression(attr_key)
if for_order_by and attr_key.name in _SEMVER_SORT_ATTRIBUTES:
return semver_sort_key(base)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
return base


def _convert_order_by(
Expand Down Expand Up @@ -335,7 +345,7 @@ def _convert_order_by(
# covers `sentry.timestamp` ordering anywhere else.) The GROUP BY uses the
# same expression (see `_groupby_order_by_expression`) so an aggregation
# query that orders by `sentry.timestamp` stays valid.
expression = _groupby_order_by_expression(x.column.key)
expression = _groupby_order_by_expression(x.column.key, for_order_by=True)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
res.append(
OrderBy(
direction=direction,
Expand Down
29 changes: 29 additions & 0 deletions tests/web/rpc/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
dedupe_and_conditions,
next_monday,
prev_monday,
semver_sort_key,
trace_item_filters_to_expression,
treeify_or_and_conditions,
use_sampling_factor,
Expand Down Expand Up @@ -1082,3 +1083,31 @@ def test_like_wildcard_matches_present_not_absent(self) -> None:
def test_not_like_wildcard_matches_only_absent(self) -> None:
# Present rows all `like '%'`, so only the absent key survives NOT LIKE.
assert self._execute(ComparisonFilter.OP_NOT_LIKE, value="%") == ["green"]


class TestSemverSortKey:
def test_expression_structure(self) -> None:
from snuba.query.dsl import column as snuba_column

expr = semver_sort_key(snuba_column("release"))
assert isinstance(expr, FunctionCall)
assert expr.function_name == "tuple"
assert len(expr.parameters) == 2
numeric_key_expr, is_stable_expr = expr.parameters
assert isinstance(numeric_key_expr, FunctionCall)
assert numeric_key_expr.function_name == "arrayResize"
assert isinstance(is_stable_expr, FunctionCall)
assert is_stable_expr.function_name == "equals"
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

def test_alias_is_forwarded(self) -> None:
from snuba.query.dsl import column as snuba_column

expr = semver_sort_key(snuba_column("release"), alias="semver_key")
assert isinstance(expr, FunctionCall)
assert expr.alias == "semver_key"

def test_no_alias_by_default(self) -> None:
from snuba.query.dsl import column as snuba_column

expr = semver_sort_key(snuba_column("release"))
assert expr.alias is None
Original file line number Diff line number Diff line change
Expand Up @@ -4402,3 +4402,100 @@ def test_order_by_bug() -> None:
)
with pytest.raises(BadSnubaRPCRequestException, match=error_message):
_validate_order_by(message)


@pytest.mark.clickhouse_db
@pytest.mark.redis_db
class TestSemverSorting:
"""ORDER BY sentry.release uses the tuple(arrayResize(…), is_stable) key so
versions sort numerically (1.2.9 before 1.2.10) with pre-releases before
their corresponding stable release.
"""

_RELEASES = [
"1.2.9",
"1.2.10",
"1.2.3",
"1.2.3-beta.1",
"1.2.2",
"my-pkg@2.0.0",
"1.2",
"1.2.0",
]

@pytest.fixture(autouse=True)
def _setup(self, clickhouse_db: None, redis_db: None) -> None:
for i, release in enumerate(self._RELEASES):
write_eap_item(
start_timestamp=BASE_TIME + timedelta(minutes=i),
raw_attributes={"sentry.release": release, "semver_test_marker": "1"},
)

def _query_releases(self, descending: bool = False) -> list[str]:
message = TraceItemTableRequest(
meta=RequestMeta(
project_ids=[1],
organization_id=1,
cogs_category="something",
referrer="something",
start_timestamp=START_TIMESTAMP,
end_timestamp=END_TIMESTAMP,
trace_item_type=TraceItemType.TRACE_ITEM_TYPE_SPAN,
),
filter=TraceItemFilter(
exists_filter=ExistsFilter(
key=AttributeKey(type=AttributeKey.TYPE_STRING, name="semver_test_marker")
)
),
columns=[
Column(key=AttributeKey(type=AttributeKey.TYPE_STRING, name="sentry.release"))
],
order_by=[
TraceItemTableRequest.OrderBy(
column=Column(
key=AttributeKey(type=AttributeKey.TYPE_STRING, name="sentry.release")
),
descending=descending,
)
],
limit=len(self._RELEASES) + 10,
)
response = EndpointTraceItemTable().execute(message)
return [v.val_str for v in response.column_values[0].results]

def test_numeric_ordering(self) -> None:
releases = self._query_releases()
assert releases.index("1.2.9") < releases.index("1.2.10"), (
"1.2.9 must sort before 1.2.10 (numeric, not lexicographic)"
)

def test_prerelease_before_stable(self) -> None:
releases = self._query_releases()
assert releases.index("1.2.3-beta.1") < releases.index("1.2.3"), (
"prerelease 1.2.3-beta.1 must sort before stable 1.2.3"
)

def test_prerelease_of_newer_after_older_stable(self) -> None:
releases = self._query_releases()
assert releases.index("1.2.3-beta.1") > releases.index("1.2.2"), (
"prerelease 1.2.3-beta.1 must sort after older stable 1.2.2"
)

def test_package_prefix_stripped(self) -> None:
releases = self._query_releases()
assert releases.index("my-pkg@2.0.0") > releases.index("1.2.10"), (
"my-pkg@2.0.0 should sort as version 2.0.0 (after 1.x)"
)

def test_length_normalisation(self) -> None:
releases = self._query_releases()
idx_12 = releases.index("1.2")
idx_120 = releases.index("1.2.0")
assert abs(idx_12 - idx_120) <= 1, (
"1.2 and 1.2.0 should sort as equal (both normalise to [1,2,0,0])"
)

def test_desc_is_reverse_of_asc(self) -> None:
asc = self._query_releases(descending=False)
desc = self._query_releases(descending=True)
assert asc == list(reversed(desc))
Loading