Skip to content
Open
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
14 changes: 13 additions & 1 deletion src/sentry/tasks/post_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -1628,7 +1628,7 @@ def kick_off_seer_automation(job: PostProcessJob) -> None:
event = job["event"]
group = event.group

# Only run on issues with no existing scan
# Only run on issues with no existing scan - TODO: Update condition for triage signals V0
if group.seer_fixability_score is not None:
return

Expand Down Expand Up @@ -1660,6 +1660,13 @@ def kick_off_seer_automation(job: PostProcessJob) -> None:
):
return

# Check if automation has already been queued or completed for this group
# seer_autofix_last_triggered is set when trigger_autofix is successfully started.
# Use cache with short TTL to hold lock for a short since it takes a few minutes to set seer_autofix_last_triggeredes
cache_key = f"seer_automation_queued:{group.id}"
if cache.get(cache_key) or group.seer_autofix_last_triggered is not None:
return

# Don't run if there's already a task in progress for this issue
lock_key, lock_name = get_issue_summary_lock_key(group.id)
lock = locks.get(lock_key, duration=1, name=lock_name)
Expand All @@ -1684,6 +1691,11 @@ def kick_off_seer_automation(job: PostProcessJob) -> None:
if is_seer_scanner_rate_limited(project, group.organization):
return

# cache.add uses Redis SETNX which atomically sets the key only if it doesn't exist
# Returns False if another process already set the key, ensuring only one process proceeds
if not cache.add(cache_key, True, timeout=600): # 10 minute
return

start_seer_automation.delay(group.id)


Expand Down
131 changes: 131 additions & 0 deletions tests/sentry/tasks/test_post_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -3053,6 +3053,137 @@ def test_kick_off_seer_automation_with_hide_ai_features_enabled(

mock_start_seer_automation.assert_not_called()

@patch(
"sentry.seer.seer_setup.get_seer_org_acknowledgement",
return_value=True,
)
@patch("sentry.tasks.autofix.start_seer_automation.delay")
@with_feature("organizations:gen-ai-features")
def test_kick_off_seer_automation_skips_when_seer_autofix_last_triggered_set(
self, mock_start_seer_automation, mock_get_seer_org_acknowledgement
):
"""Test that automation is skipped when group.seer_autofix_last_triggered is already set"""
self.project.update_option("sentry:seer_scanner_automation", True)
event = self.create_event(
data={"message": "testing"},
project_id=self.project.id,
)

# Set seer_autofix_last_triggered on the group to simulate autofix already triggered
group = event.group
group.seer_autofix_last_triggered = timezone.now()
group.save()

self.call_post_process_group(
is_new=True,
is_regression=False,
is_new_group_environment=True,
event=event,
)

mock_start_seer_automation.assert_not_called()

@patch(
"sentry.seer.seer_setup.get_seer_org_acknowledgement",
return_value=True,
)
@patch("sentry.tasks.autofix.start_seer_automation.delay")
@with_feature("organizations:gen-ai-features")
def test_kick_off_seer_automation_skips_when_cache_key_exists(
self, mock_start_seer_automation, mock_get_seer_org_acknowledgement
):
"""Test that automation is skipped when cache key indicates it's already queued"""
self.project.update_option("sentry:seer_scanner_automation", True)
event = self.create_event(
data={"message": "testing"},
project_id=self.project.id,
)

# Set cache key to simulate automation already queued
cache_key = f"seer_automation_queued:{event.group.id}"
cache.set(cache_key, True, timeout=600)

self.call_post_process_group(
is_new=True,
is_regression=False,
is_new_group_environment=True,
event=event,
)

mock_start_seer_automation.assert_not_called()

# Cleanup
cache.delete(cache_key)

@patch(
"sentry.seer.seer_setup.get_seer_org_acknowledgement",
return_value=True,
)
@patch("sentry.tasks.autofix.start_seer_automation.delay")
@with_feature("organizations:gen-ai-features")
def test_kick_off_seer_automation_uses_atomic_cache_add(
self, mock_start_seer_automation, mock_get_seer_org_acknowledgement
):
"""Test that cache.add atomic operation prevents race conditions"""
self.project.update_option("sentry:seer_scanner_automation", True)
event = self.create_event(
data={"message": "testing"},
project_id=self.project.id,
)

cache_key = f"seer_automation_queued:{event.group.id}"

with patch("sentry.tasks.post_process.cache") as mock_cache:
# Simulate cache.get returning None (not in cache)
# but cache.add returning False (another process set it)
mock_cache.get.return_value = None
mock_cache.add.return_value = False

self.call_post_process_group(
is_new=True,
is_regression=False,
is_new_group_environment=True,
event=event,
)

# Should check cache but not call automation due to cache.add returning False
mock_cache.get.assert_called()
mock_cache.add.assert_called_once_with(cache_key, True, timeout=600)
mock_start_seer_automation.assert_not_called()

@patch(
"sentry.seer.seer_setup.get_seer_org_acknowledgement",
return_value=True,
)
@patch("sentry.tasks.autofix.start_seer_automation.delay")
@with_feature("organizations:gen-ai-features")
def test_kick_off_seer_automation_proceeds_when_cache_add_succeeds(
self, mock_start_seer_automation, mock_get_seer_org_acknowledgement
):
"""Test that automation proceeds when cache.add succeeds (no race condition)"""
self.project.update_option("sentry:seer_scanner_automation", True)
event = self.create_event(
data={"message": "testing"},
project_id=self.project.id,
)

# Ensure seer_autofix_last_triggered is not set
assert event.group.seer_autofix_last_triggered is None

self.call_post_process_group(
is_new=True,
is_regression=False,
is_new_group_environment=True,
event=event,
)

# Should successfully queue automation
mock_start_seer_automation.assert_called_once_with(event.group.id)

# Cleanup
cache_key = f"seer_automation_queued:{event.group.id}"
cache.delete(cache_key)


class PostProcessGroupErrorTest(
TestCase,
Expand Down
Loading