Skip to content

Commit 3a67f2b

Browse files
author
Bartek Ogryczak
authored
perf(issues): add pseudo-cache to test_frequency_rates in GroupSnooze (#69939)
Reducing unnecessary Snuba queries by only querying when Redis count reached. Follows exactly same pattern as #69556
1 parent 64d1662 commit 3a67f2b

File tree

4 files changed

+134
-1
lines changed

4 files changed

+134
-1
lines changed

src/sentry/conf/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1581,6 +1581,8 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
15811581
"organizations:grouping-tree-ui": False,
15821582
# Enable caching group counts in GroupSnooze
15831583
"organizations:groupsnooze-cached-counts": False,
1584+
# Enable caching group frequency rates in GroupSnooze
1585+
"organizations:groupsnooze-cached-rates": False,
15841586
# Allows an org to have a larger set of project ownership rules per project
15851587
"organizations:higher-ownership-limit": False,
15861588
# Enable incidents feature

src/sentry/features/temporary.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ def register_temporary_features(manager: FeatureManager):
7979
manager.add("organizations:grouping-title-ui", OrganizationFeature, FeatureHandlerStrategy.REMOTE)
8080
manager.add("organizations:grouping-tree-ui", OrganizationFeature, FeatureHandlerStrategy.REMOTE)
8181
manager.add("organizations:groupsnooze-cached-counts", OrganizationFeature, FeatureHandlerStrategy.OPTIONS)
82+
manager.add("organizations:groupsnooze-cached-rates", OrganizationFeature, FeatureHandlerStrategy.OPTIONS)
8283
manager.add("organizations:higher-ownership-limit", OrganizationFeature, FeatureHandlerStrategy.INTERNAL)
8384
manager.add("organizations:increased-issue-owners-rate-limit", OrganizationFeature, FeatureHandlerStrategy.INTERNAL)
8485
manager.add("organizations:integrations-custom-alert-priorities", OrganizationFeature, FeatureHandlerStrategy.REMOTE)

src/sentry/models/groupsnooze.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,57 @@ def is_valid(
9797
return True
9898

9999
def test_frequency_rates(self) -> bool:
100-
metrics.incr("groupsnooze.test_frequency_rates")
100+
if features.has(
101+
"organizations:groupsnooze-cached-rates", organization=self.group.project.organization
102+
):
103+
return self.test_frequency_rates_w_cache()
104+
else:
105+
return self.test_frequency_rates_no_cache()
106+
107+
def test_frequency_rates_w_cache(self) -> bool:
108+
cache_key = f"groupsnooze:v1:{self.id}:test_frequency_rate:events_seen_counter"
109+
110+
cache_ttl = self.window * 60 # Redis TTL in seconds (window is in minutes)
111+
112+
value: int | float = float("inf") # using +inf as a sentinel value
113+
114+
try:
115+
value = cache.incr(cache_key)
116+
cache.touch(cache_key, cache_ttl)
117+
except ValueError:
118+
# key doesn't exist, fall back on sentinel value
119+
pass
120+
121+
if value < self.count:
122+
metrics.incr("groupsnooze.test_frequency_rates", tags={"cached": "true", "hit": "true"})
123+
return True
124+
125+
metrics.incr("groupsnooze.test_frequency_rates", tags={"cached": "true", "hit": "false"})
126+
metrics.incr("groupsnooze.test_frequency_rates.snuba_call")
127+
end = timezone.now()
128+
start = end - timedelta(minutes=self.window)
129+
130+
rate = tsdb.backend.get_sums(
131+
model=get_issue_tsdb_group_model(self.group.issue_category),
132+
keys=[self.group_id],
133+
start=start,
134+
end=end,
135+
tenant_ids={"organization_id": self.group.project.organization_id},
136+
referrer_suffix="frequency_snoozes",
137+
)[self.group_id]
138+
139+
# TTL is further into the future than it needs to be, but we'd rather over-estimate
140+
# and call Snuba more often than under-estimate and not trigger
141+
cache.set(cache_key, rate, cache_ttl)
142+
143+
if rate >= self.count:
144+
return False
145+
146+
return True
147+
148+
def test_frequency_rates_no_cache(self) -> bool:
149+
metrics.incr("groupsnooze.test_frequency_rates", tags={"cached": "false"})
150+
metrics.incr("groupsnooze.test_frequency_rates.snuba_call")
101151

102152
end = timezone.now()
103153
start = end - timedelta(minutes=self.window)

tests/sentry/models/test_groupsnooze.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ def test_rate_reached_generic_issue(self):
190190

191191

192192
@apply_feature_flag_on_cls("organizations:groupsnooze-cached-counts")
193+
@apply_feature_flag_on_cls("organizations:groupsnooze-cached-rates")
193194
class GroupSnoozeWCacheTest(GroupSnoozeTest):
194195
"""
195196
Test the cached version of the snooze.
@@ -367,3 +368,82 @@ def test_test_user_count_w_cache_expired(self):
367368
assert not snooze.is_valid(test_rates=True)
368369
assert mocked_count_users_seen.call_count == 3
369370
assert cache_spy.set.called_with(cache_key, 100, 300)
371+
372+
def test_test_frequency_rates_w_cache(self):
373+
snooze = GroupSnooze.objects.create(group=self.group, count=100, window=60)
374+
375+
cache_key = f"groupsnooze:v1:{snooze.id}:test_frequency_rate:events_seen_counter"
376+
377+
with (
378+
mock.patch("sentry.models.groupsnooze.tsdb.backend.get_sums") as mocked_get_sums,
379+
mock.patch.object(
380+
sentry.models.groupsnooze, "cache", wraps=sentry.models.groupsnooze.cache # type: ignore[attr-defined]
381+
) as cache_spy,
382+
):
383+
mocked_get_sums.side_effect = [{snooze.group_id: c} for c in [95, 98, 100]]
384+
385+
cache_spy.set = mock.Mock(side_effect=cache_spy.set)
386+
cache_spy.incr = mock.Mock(side_effect=cache_spy.incr)
387+
388+
assert snooze.is_valid(test_rates=True)
389+
assert mocked_get_sums.call_count == 1
390+
assert cache_spy.set.called_with(cache_key, 95, 3600)
391+
392+
assert snooze.is_valid(test_rates=True)
393+
assert mocked_get_sums.call_count == 1
394+
assert cache_spy.incr.called_with(cache_key)
395+
assert cache_spy.get(cache_key) == 96
396+
397+
assert snooze.is_valid(test_rates=True)
398+
assert cache_spy.get(cache_key) == 97
399+
assert snooze.is_valid(test_rates=True)
400+
assert cache_spy.get(cache_key) == 98
401+
assert snooze.is_valid(test_rates=True)
402+
assert cache_spy.get(cache_key) == 99
403+
404+
# cache counter reaches 100, but gets 98 from get_distinct_counts_totals
405+
406+
assert snooze.is_valid(test_rates=True)
407+
assert mocked_get_sums.call_count == 2
408+
assert cache_spy.set.called_with(cache_key, 98, 3600)
409+
assert cache_spy.get(cache_key) == 98
410+
411+
assert snooze.is_valid(test_rates=True)
412+
assert cache_spy.get(cache_key) == 99
413+
# with this call counter reaches 100, gets 100 from get_distinct_counts_totals, so is_valid returns False
414+
assert not snooze.is_valid(test_rates=True)
415+
assert mocked_get_sums.call_count == 3
416+
417+
def test_test_frequency_rates_w_cache_expired(self):
418+
snooze = GroupSnooze.objects.create(group=self.group, count=100, window=60)
419+
420+
cache_key = f"groupsnooze:v1:{snooze.id}:test_frequency_rate:events_seen_counter"
421+
422+
with (
423+
mock.patch("sentry.models.groupsnooze.tsdb.backend.get_sums") as mocked_get_sums,
424+
mock.patch.object(
425+
sentry.models.groupsnooze, "cache", wraps=sentry.models.groupsnooze.cache # type: ignore[attr-defined]
426+
) as cache_spy,
427+
):
428+
mocked_get_sums.side_effect = [{snooze.group_id: c} for c in [98, 99, 100]]
429+
430+
cache_spy.set = mock.Mock(side_effect=cache_spy.set)
431+
cache_spy.incr = mock.Mock(side_effect=cache_spy.incr)
432+
433+
assert snooze.is_valid(test_rates=True)
434+
assert mocked_get_sums.call_count == 1
435+
assert cache_spy.set.called_with(cache_key, 98, 3600)
436+
437+
# simulate cache expiration
438+
cache_spy.delete(cache_key)
439+
440+
assert snooze.is_valid(test_rates=True)
441+
assert mocked_get_sums.call_count == 2
442+
assert cache_spy.set.called_with(cache_key, 99, 3600)
443+
444+
# simulate cache expiration
445+
cache_spy.delete(cache_key)
446+
447+
assert not snooze.is_valid(test_rates=True)
448+
assert mocked_get_sums.call_count == 3
449+
assert cache_spy.set.called_with(cache_key, 100, 3600)

0 commit comments

Comments
 (0)