Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions src/sentry/features/temporary.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,8 @@ def register_temporary_features(manager: FeatureManager) -> None:
manager.add("organizations:issue-activity-feed-v2", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable new stack trace component for issue details
manager.add("organizations:issue-details-new-stack-trace", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable invalidating group derived data when groups are merged
manager.add("organizations:hard-delete-derived-data-invalidation", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
# Enable double-reading from EAP for issue feed search queries
manager.add("organizations:issue-feed.eap-search", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
# Remove trace and breadcrumbs from issue summary input
Expand Down
22 changes: 17 additions & 5 deletions src/sentry/tasks/merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
from django.db.models.functions import Coalesce
from taskbroker_client.retry import Retry

from sentry import eventstream, similarity, tsdb
from sentry import eventstream, features, similarity, tsdb
from sentry.db.models.base import Model
from sentry.issues.derived.processing import invalidate_group_derived_data
from sentry.issues.models.groupactionlogentry import GroupActionLogEntry
from sentry.models.group import Group
from sentry.silo.base import SiloMode
Expand Down Expand Up @@ -59,7 +60,10 @@ def merge_groups(
from_object_id = from_object_ids[0]

try:
new_group, _ = get_group_with_redirect(to_object_id)
new_group, _ = get_group_with_redirect(
to_object_id,
queryset=Group.objects.select_related("project__organization"),
)
except Group.DoesNotExist:
logger.warning(
"group.malformed.invalid_id",
Expand Down Expand Up @@ -206,9 +210,17 @@ def merge_groups(
recursed=True,
eventstream_state=eventstream_state,
)
elif eventstream_state:
# All `from_object_ids` have been merged!
eventstream.backend.end_merge(eventstream_state)
else:
if features.has(
"organizations:hard-delete-derived-data-invalidation",
new_group.project.organization,
):
Comment thread
sentry[bot] marked this conversation as resolved.
Comment on lines +215 to +217

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: A NameError occurs if all groups to be merged are missing, as the group variable is accessed before it is assigned.
Severity: HIGH

Suggested Fix

The group variable should be defined before its use on line 213. One approach is to fetch the to_object_id's group and use it for the feature flag check when the from_object_ids list becomes empty. Alternatively, ensure the logic accessing group is only executed when group is guaranteed to be defined.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/sentry/tasks/merge.py#L212-L214

Potential issue: In the `merge_groups` task, if all group IDs in `from_object_ids` fail
to be found in the database (e.g., due to a race condition where a group is deleted
after the task is queued), the `group` variable will never be assigned. When the
`from_object_ids` list becomes empty, the code proceeds to a final `else` block where it
attempts to access `group.project.organization`. This access will raise a `NameError`
because `group` is unbound, causing the task to crash.

# hard delete derived data on the new group - it will be rebuilt when the next action is processed
invalidate_group_derived_data(new_group.id)
Comment thread
cursor[bot] marked this conversation as resolved.

if eventstream_state:
# All `from_object_ids` have been merged!
eventstream.backend.end_merge(eventstream_state)

return True

Expand Down
16 changes: 16 additions & 0 deletions tests/sentry/tasks/test_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from unittest.mock import patch

from sentry import buffer, eventstream
from sentry.issues.models.groupderiveddata import GroupDerivedData
from sentry.models.group import Group
from sentry.models.groupenvironment import GroupEnvironment
from sentry.models.groupmeta import GroupMeta
Expand All @@ -13,6 +14,7 @@
from sentry.tasks.post_process import fetch_buffered_group_stats
from sentry.testutils.cases import SnubaTestCase, TestCase
from sentry.testutils.helpers.datetime import before_now
from sentry.testutils.helpers.features import with_feature
from sentry.testutils.helpers.redis import mock_redis_buffer
from sentry.utils import redis

Expand Down Expand Up @@ -222,6 +224,20 @@ def test_merge_includes_pending_buffer_increments(self) -> None:
assert new_group.times_seen_pending == 3
assert new_group.times_seen_with_pending == 168

@with_feature("organizations:hard-delete-derived-data-invalidation")
def test_merge_invalidates_derived_data(self) -> None:
project = self.create_project()
old_group = self.create_group(project)
new_group = self.create_group(project)

GroupDerivedData.objects.create(group=new_group)

with self.tasks():
merge_groups([old_group.id], new_group.id)

assert Group.objects.filter(id=old_group.id).exists() is False
assert GroupDerivedData.objects.filter(group_id=new_group.id).exists() is False

@mock_redis_buffer()
def test_merge_original_group_id(self) -> None:
project = self.create_project()
Expand Down
Loading