Skip to content
Merged
40 changes: 31 additions & 9 deletions src/sentry/api/serializers/models/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,9 +515,11 @@ def __get_release_data_with_environments(self, release_project_envs):
last_seen[release_project_env.release.version] = release_project_env.last_seen

group_counts_by_release: dict[int, dict[int, int]] = {}
for project_id, release_id, new_groups in release_project_envs.annotate(
aggregated_new_issues_count=Sum("new_issues_count")
).values_list("project_id", "release_id", "aggregated_new_issues_count"):
for project_id, release_id, new_groups in (
release_project_envs.values("project_id", "release_id")
.annotate(aggregated_new_issues_count=Sum("new_issues_count"))
.values_list("project_id", "release_id", "aggregated_new_issues_count")
):
group_counts_by_release.setdefault(release_id, {})[project_id] = new_groups

return first_seen, last_seen, group_counts_by_release
Expand All @@ -535,6 +537,18 @@ def _get_release_project_envs(self, item_list, environments, project):

return release_project_envs

def _get_release_project_envs_unordered(self, item_list, environments, project):
release_project_envs = ReleaseProjectEnvironment.objects.filter(
release__in=item_list
).select_related("release")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we joining the release model? This call to select_related seems unnecessary.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We access release_project_env.release.version in other parts of __get_release_data_with_environments, for example:

release_project_env.release.version not in first_seen


if environments is not None:
release_project_envs = release_project_envs.filter(environment__name__in=environments)
if project is not None:
release_project_envs = release_project_envs.filter(project=project)

return release_project_envs
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: N+1 Query Issue in Release Data Retrieval

The new _get_release_project_envs_unordered method, used when environments are specified, omits select_related("project"). This means __get_release_data_with_environments will trigger N+1 database queries when accessing the project relation.

Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this not just accessing project id? We don't access any other attributes of release_project_envs.project.


def get_attrs(self, item_list, user, **kwargs):
project = kwargs.get("project")

Expand All @@ -558,7 +572,6 @@ def get_attrs(self, item_list, user, **kwargs):
raise TypeError("health data requires snuba")

adoption_stages = {}
release_project_envs = None
if self.with_adoption_stages:
release_project_envs = self._get_release_project_envs(item_list, environments, project)
adoption_stages = self._get_release_adoption_stages(release_project_envs)
Expand All @@ -568,10 +581,9 @@ def get_attrs(self, item_list, user, **kwargs):
project, item_list, no_snuba_for_release_creation
)
else:
if release_project_envs is None:
release_project_envs = self._get_release_project_envs(
item_list, environments, project
)
release_project_envs = self._get_release_project_envs_unordered(
item_list, environments, project
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Redundant Query Overwrites Ordered Data

When with_adoption_stages is true and environments are present, an ordered release_project_envs query is performed, then immediately overwritten by an unordered query for the same data. This results in a redundant database call and removes ordering that adoption stages may rely on, potentially breaking their functionality.

Fix in Cursor Fix in Web

(
first_seen,
last_seen,
Expand Down Expand Up @@ -626,11 +638,21 @@ def get_attrs(self, item_list, user, **kwargs):
has_health_data = {}

for pr in project_releases:
# Use environment-filtered new groups count if available, otherwise fall back to ReleaseProject data
new_groups_count_env_filtered = issue_counts_by_release.get(pr["release_id"], {}).get(
pr["project__id"]
)
new_groups_count = (
new_groups_count_env_filtered
if new_groups_count_env_filtered is not None
else pr["new_groups"]
)

pr_rv: _ProjectDict = {
"id": pr["project__id"],
"slug": pr["project__slug"],
"name": pr["project__name"],
"new_groups": pr["new_groups"],
"new_groups": new_groups_count,
"platform": pr["project__platform"],
"platforms": platforms_by_project.get(pr["project__id"]) or [],
}
Expand Down
250 changes: 250 additions & 0 deletions tests/sentry/api/serializers/test_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,256 @@ def test_with_none_new_groups(self) -> None:
assert result["version"] == "0.1"
assert result["newGroups"] == 0 # Should default to 0 when None

def test_new_groups_single_release(self) -> None:
"""
Test new groups counts for one release with multiple projects, each having different issue counts.
"""
project_a = self.create_project(name="Project A", slug="project-a")
project_b = self.create_project(
name="Project B", slug="project-b", organization=project_a.organization
)

release_version = "1.0.0"
release = Release.objects.create(
organization_id=project_a.organization_id, version=release_version
)
release.add_project(project_a)
release.add_project(project_b)

# 3 new groups for project A, 2 new groups for project B
ReleaseProject.objects.filter(release=release, project=project_a).update(new_groups=3)
ReleaseProject.objects.filter(release=release, project=project_b).update(new_groups=2)

result = serialize(release, self.user)
assert result["newGroups"] == 5

projects = {p["id"]: p for p in result["projects"]}
assert projects[project_a.id]["newGroups"] == 3
assert projects[project_b.id]["newGroups"] == 2

assert projects[project_a.id]["name"] == "Project A"
assert projects[project_a.id]["slug"] == "project-a"
assert projects[project_b.id]["name"] == "Project B"
assert projects[project_b.id]["slug"] == "project-b"

def test_new_groups_multiple_releases(self) -> None:
"""
Test new groups count for multiple releases per project.
"""
project_a = self.create_project(name="Project A", slug="project-a")
project_b = self.create_project(
name="Project B", slug="project-b", organization=project_a.organization
)

release_1 = Release.objects.create(
organization_id=project_a.organization_id, version="1.0.0"
)
release_1.add_project(project_a)
release_1.add_project(project_b)
release_2 = Release.objects.create(
organization_id=project_a.organization_id, version="2.0.0"
)
release_2.add_project(project_a)
release_2.add_project(project_b)

# Release 1.0.0 has 3 new groups for project A, 2 new groups for project B
ReleaseProject.objects.filter(release=release_1, project=project_a).update(new_groups=3)
ReleaseProject.objects.filter(release=release_1, project=project_b).update(new_groups=2)

# Release 2.0.0 has 1 new groups for project A, 4 new groups for project B
ReleaseProject.objects.filter(release=release_2, project=project_a).update(new_groups=1)
ReleaseProject.objects.filter(release=release_2, project=project_b).update(new_groups=4)

# 1. Serialize Release 1.0.0
result = serialize(release_1, self.user)
assert result["version"] == "1.0.0"
assert result["newGroups"] == 5
projects = {p["id"]: p for p in result["projects"]}
assert projects[project_a.id]["newGroups"] == 3
assert projects[project_b.id]["newGroups"] == 2

# 2. Serialize Release 2.0.0
result = serialize(release_2, self.user)
assert result["version"] == "2.0.0"
assert result["newGroups"] == 5
projects = {p["id"]: p for p in result["projects"]}
assert projects[project_a.id]["newGroups"] == 1
assert projects[project_b.id]["newGroups"] == 4

# 3. Serialize both releases together
result = serialize([release_1, release_2], self.user)
assert len(result) == 2
serialized_releases = {r["version"]: r for r in result}
serialized_release_1 = serialized_releases["1.0.0"]
serialized_release_2 = serialized_releases["2.0.0"]
assert serialized_release_1["newGroups"] == 5
assert serialized_release_2["newGroups"] == 5
projects_1 = {p["id"]: p for p in serialized_release_1["projects"]}
projects_2 = {p["id"]: p for p in serialized_release_2["projects"]}
assert projects_1[project_a.id]["newGroups"] == 3
assert projects_1[project_b.id]["newGroups"] == 2
assert projects_2[project_a.id]["newGroups"] == 1
assert projects_2[project_b.id]["newGroups"] == 4

def test_new_groups_environment_filtering(self) -> None:
"""
Test new group counts for a single release with environment filtering.
"""
project_a = self.create_project(name="Project A", slug="project-a")
project_b = self.create_project(
name="Project B", slug="project-b", organization=project_a.organization
)

production = self.create_environment(name="production", organization=project_a.organization)
staging = self.create_environment(name="staging", organization=project_a.organization)

release = Release.objects.create(organization_id=project_a.organization_id, version="1.0.0")
release.add_project(project_a)
release.add_project(project_b)

# 4 new groups for project A, 2 new groups for project B
ReleaseProject.objects.filter(release=release, project=project_a).update(new_groups=4)
ReleaseProject.objects.filter(release=release, project=project_b).update(new_groups=2)

# Project A: 3 issues in production, 1 issue in staging (total = 4)
ReleaseProjectEnvironment.objects.create(
release=release, project=project_a, environment=production, new_issues_count=3
)
ReleaseProjectEnvironment.objects.create(
release=release, project=project_a, environment=staging, new_issues_count=1
)

# Project B: 2 issues in production, 0 issues in staging (total = 2)
ReleaseProjectEnvironment.objects.create(
release=release, project=project_b, environment=production, new_issues_count=2
)
ReleaseProjectEnvironment.objects.create(
release=release, project=project_b, environment=staging, new_issues_count=0
)

# 1. No environment filter
result = serialize(release, self.user)
projects = {p["id"]: p for p in result["projects"]}
assert projects[project_a.id]["newGroups"] == 4
assert projects[project_b.id]["newGroups"] == 2
assert result["newGroups"] == 6

# 2. Filter by production environment
result = serialize(release, self.user, environments=["production"])
projects = {p["id"]: p for p in result["projects"]}
assert projects[project_a.id]["newGroups"] == 3
assert projects[project_b.id]["newGroups"] == 2
assert result["newGroups"] == 5

# 3. Filter by staging environment
result = serialize(release, self.user, environments=["staging"])
projects = {p["id"]: p for p in result["projects"]}
assert projects[project_a.id]["newGroups"] == 1
assert projects[project_b.id]["newGroups"] == 0
assert result["newGroups"] == 1

# 4. Filter by both environments
result = serialize(release, self.user, environments=["production", "staging"])
projects = {p["id"]: p for p in result["projects"]}
assert projects[project_a.id]["newGroups"] == 4
assert projects[project_b.id]["newGroups"] == 2
assert result["newGroups"] == 6

def test_new_groups_multiple_releases_environment_filtering(self) -> None:
"""
Test new group counts for multiple releases with different environments.
"""
project_a = self.create_project(name="Project A", slug="project-a")
project_b = self.create_project(
name="Project B", slug="project-b", organization=project_a.organization
)

production = self.create_environment(name="production", organization=project_a.organization)
staging = self.create_environment(name="staging", organization=project_a.organization)

release_1 = Release.objects.create(
organization_id=project_a.organization_id, version="1.0.0"
)
release_1.add_project(project_a)
release_1.add_project(project_b)

release_2 = Release.objects.create(
organization_id=project_a.organization_id, version="2.0.0"
)
release_2.add_project(project_a)
release_2.add_project(project_b)

# Release 1.0.0: Project A = 4 (3+1), Project B = 2 (2+0)
ReleaseProject.objects.filter(release=release_1, project=project_a).update(new_groups=4)
ReleaseProject.objects.filter(release=release_1, project=project_b).update(new_groups=2)
# Release 2.0.0: Project A = 3 (1+2), Project B = 5 (4+1)
ReleaseProject.objects.filter(release=release_2, project=project_a).update(new_groups=3)
ReleaseProject.objects.filter(release=release_2, project=project_b).update(new_groups=5)

# Release 1.0.0 - Project A: 3 in production, 1 in staging
ReleaseProjectEnvironment.objects.create(
release=release_1, project=project_a, environment=production, new_issues_count=3
)
ReleaseProjectEnvironment.objects.create(
release=release_1, project=project_a, environment=staging, new_issues_count=1
)
# Release 1.0.0 - Project B: 2 in production, 0 in staging (no staging record)
ReleaseProjectEnvironment.objects.create(
release=release_1, project=project_b, environment=production, new_issues_count=2
)
# Release 2.0.0 - Project A: 1 in production, 2 in staging
ReleaseProjectEnvironment.objects.create(
release=release_2, project=project_a, environment=production, new_issues_count=1
)
ReleaseProjectEnvironment.objects.create(
release=release_2, project=project_a, environment=staging, new_issues_count=2
)
# Release 2.0.0 - Project B: 4 in production, 1 in staging
ReleaseProjectEnvironment.objects.create(
release=release_2, project=project_b, environment=production, new_issues_count=4
)
ReleaseProjectEnvironment.objects.create(
release=release_2, project=project_b, environment=staging, new_issues_count=1
)

# 1. Serialize Release 1.0.0 with no environment filter
result = serialize(release_1, self.user)
assert result["newGroups"] == 6
projects = {p["id"]: p for p in result["projects"]}
assert projects[project_a.id]["newGroups"] == 4
assert projects[project_b.id]["newGroups"] == 2

# 2. Serialize Release 1.0.0 with production filter
result = serialize(release_1, self.user, environments=["production"])
assert result["version"] == "1.0.0"
assert result["newGroups"] == 5
projects = {p["id"]: p for p in result["projects"]}
assert projects[project_a.id]["newGroups"] == 3
assert projects[project_b.id]["newGroups"] == 2

# 3. Serialize Release 2.0.0 with production filter
result = serialize(release_2, self.user, environments=["production"])
assert result["version"] == "2.0.0"
assert result["newGroups"] == 5
projects = {p["id"]: p for p in result["projects"]}
assert projects[project_a.id]["newGroups"] == 1
assert projects[project_b.id]["newGroups"] == 4

# 4. Serialize both releases with production filter
result = serialize([release_1, release_2], self.user, environments=["production"])
assert len(result) == 2
serialized_releases = {r["version"]: r for r in result}
serialized_release_1 = serialized_releases["1.0.0"]
serialized_release_2 = serialized_releases["2.0.0"]
assert serialized_release_1["newGroups"] == 5
assert serialized_release_2["newGroups"] == 5
projects_1 = {p["id"]: p for p in serialized_release_1["projects"]}
projects_2 = {p["id"]: p for p in serialized_release_2["projects"]}
assert projects_1[project_a.id]["newGroups"] == 3
assert projects_1[project_b.id]["newGroups"] == 2
assert projects_2[project_a.id]["newGroups"] == 1
assert projects_2[project_b.id]["newGroups"] == 4


class ReleaseRefsSerializerTest(TestCase):
def test_simple(self) -> None:
Expand Down
Loading