Skip to content

Commit 83f2aea

Browse files
committed
Gate activity reads on backfill completion
1 parent 88212c0 commit 83f2aea

11 files changed

Lines changed: 179 additions & 34 deletions

File tree

src/sentry/api/helpers/group_index/update.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
resolve_action_source,
3434
)
3535
from sentry.issues.action_log.types import MergeIntoOtherAction
36+
from sentry.issues.derived.gate import should_serve_action_log_activity
3637
from sentry.issues.grouptype import GroupCategory
3738
from sentry.issues.ignored import handle_archived_until_escalating, handle_ignored
3839
from sentry.issues.merge import MergedGroup, handle_merge
@@ -783,9 +784,7 @@ def prepare_response(
783784
if len(group_list) == 1:
784785
if res_type in (GroupResolution.Type.in_next_release, GroupResolution.Type.in_release):
785786
group = group_list[0]
786-
if features.has(
787-
"projects:issue-action-log-activity", group.project, actor=acting_user
788-
):
787+
if should_serve_action_log_activity(group.project, acting_user):
789788
action_log = GroupActionLogEntry.objects.get_actions_for_group(
790789
group, ACTIVITIES_COUNT - 1
791790
)

src/sentry/issues/derived/gate.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
from django.contrib.auth.models import AnonymousUser
2+
13
from sentry import features
24
from sentry.models.options.project_option import ProjectOption
35
from sentry.models.project import Project
6+
from sentry.users.models.user import User
7+
from sentry.users.services.user.model import RpcUser
48

59
# ProjectOption key whose value tracks group action-log backfill state:
610
# true means complete, false means pending, and a missing option means not yet scheduled.
@@ -14,8 +18,19 @@ def is_backfilled(project: Project) -> bool:
1418

1519

1620
def derived_should_be_correct(project: Project) -> bool:
17-
"""The project is backfilled and writing actions, so derived data should cover its full history.
21+
"""
22+
The project is backfilled and writing actions, so derived data should cover its full history.
1823
1924
It may still be wrong, but checking it and surfacing it should be reasonable.
2025
"""
2126
return features.has("projects:issue-action-log-write-to-db", project) and is_backfilled(project)
27+
28+
29+
def should_serve_action_log_activity(
30+
project: Project,
31+
actor: User | RpcUser | AnonymousUser | None = None,
32+
) -> bool:
33+
"""Whether the action log can back this project's Activity-shaped responses."""
34+
return features.has(
35+
"projects:issue-action-log-activity", project, actor=actor
36+
) and derived_should_be_correct(project)

src/sentry/issues/endpoints/group_activities.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
from rest_framework.request import Request
44
from rest_framework.response import Response
55

6-
from sentry import features
76
from sentry.api.api_publish_status import ApiPublishStatus
87
from sentry.api.base import cell_silo_endpoint
98
from sentry.api.helpers.deprecation import deprecated
109
from sentry.api.serializers import serialize
1110
from sentry.api.serializers.models.groupactionlogentry import serialize_first_seen_entry
1211
from sentry.constants import CELL_API_DEPRECATION_DATE
12+
from sentry.issues.derived.gate import should_serve_action_log_activity
1313
from sentry.issues.endpoints.bases.group import GroupEndpoint
1414
from sentry.issues.models.groupactionlogentry import GroupActionLogEntry
1515
from sentry.models.activity import Activity
@@ -33,7 +33,7 @@ def get(self, request: Request, group: Group) -> Response:
3333
"""
3434
Retrieve all the Activities for a Group
3535
"""
36-
if features.has("projects:issue-action-log-activity", group.project, actor=request.user):
36+
if should_serve_action_log_activity(group.project, request.user):
3737
action_log = GroupActionLogEntry.objects.get_actions_for_group(group, 99)
3838
if action_log:
3939
serialized = serialize(action_log, request.user)

src/sentry/issues/endpoints/group_details.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
get_issue_tsdb_group_model,
5555
)
5656
from sentry.issues.derived.check import check_status_consistency
57-
from sentry.issues.derived.gate import derived_should_be_correct
57+
from sentry.issues.derived.gate import derived_should_be_correct, should_serve_action_log_activity
5858
from sentry.issues.endpoints.bases.group import GroupEndpoint
5959
from sentry.issues.escalating.escalating_group_forecast import EscalatingGroupForecast
6060
from sentry.issues.models.groupactionlogentry import GroupActionLogEntry
@@ -368,9 +368,7 @@ def get(self, request: Request, group: Group) -> Response[GroupDetailsResponse]:
368368
}
369369
)
370370

371-
if features.has(
372-
"projects:issue-action-log-activity", group.project, actor=request.user
373-
):
371+
if should_serve_action_log_activity(group.project, request.user):
374372
action_log = GroupActionLogEntry.objects.get_actions_for_group(group, 99)
375373
if action_log:
376374
# swap action log data in under the activity name

src/sentry/issues/endpoints/group_notes.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from rest_framework.request import Request
99
from rest_framework.response import Response
1010

11-
from sentry import features
1211
from sentry.api.api_publish_status import ApiPublishStatus
1312
from sentry.api.base import cell_silo_endpoint
1413
from sentry.api.helpers.deprecation import deprecated
@@ -25,6 +24,7 @@
2524
GroupActionActor,
2625
GroupActionType,
2726
)
27+
from sentry.issues.derived.gate import should_serve_action_log_activity
2828
from sentry.issues.endpoints.bases.group import GroupEndpoint
2929
from sentry.issues.models.groupactionlogentry import GroupActionLogEntry
3030
from sentry.models.activity import Activity
@@ -58,7 +58,7 @@ class GroupNotesEndpoint(GroupEndpoint):
5858
url_names=["sentry-api-0-group-notes"],
5959
)
6060
def get(self, request: Request, group: Group) -> Response:
61-
if features.has("projects:issue-action-log-activity", group.project, actor=request.user):
61+
if should_serve_action_log_activity(group.project, request.user):
6262
edit_entries = GroupActionLogEntry.objects.filter(
6363
group_id=group.id, type=GroupActionType.COMMENT_EDIT.value
6464
).order_by("-date_added", "-id")
@@ -187,7 +187,7 @@ def post(self, request: Request, group: Group) -> Response:
187187
sender="post",
188188
)
189189

190-
if features.has("projects:issue-action-log-activity", group.project, actor=request.user):
190+
if should_serve_action_log_activity(group.project, request.user):
191191
entry = GroupActionLogEntry.objects.filter(
192192
group_id=group.id,
193193
idempotency_key=activity_action_idempotency_key(activity),

src/sentry/issues/endpoints/group_notes_details.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from rest_framework.request import Request
77
from rest_framework.response import Response
88

9-
from sentry import features
109
from sentry.api.api_publish_status import ApiPublishStatus
1110
from sentry.api.base import cell_silo_endpoint
1211
from sentry.api.exceptions import ResourceDoesNotExist
@@ -24,6 +23,7 @@
2423
resolve_action_source,
2524
)
2625
from sentry.issues.action_log.types import CommentDeleteAction, CommentEditAction
26+
from sentry.issues.derived.gate import should_serve_action_log_activity
2727
from sentry.issues.endpoints.bases.group import GroupEndpoint
2828
from sentry.issues.models.groupactionlogentry import GroupActionLogEntry
2929
from sentry.models.activity import Activity
@@ -80,8 +80,8 @@ def delete(self, request: Request, group: Group, note_id: str) -> Response:
8080
group_id=group.id,
8181
idempotency_key=activity_action_idempotency_key(note),
8282
).first()
83-
if original_comment_log_action is None and features.has(
84-
"projects:issue-action-log-activity", group.project, actor=request.user
83+
if original_comment_log_action is None and should_serve_action_log_activity(
84+
group.project, request.user
8585
):
8686
raise ResourceDoesNotExist
8787

@@ -164,8 +164,8 @@ def put(self, request: Request, group: Group, note_id: str) -> Response:
164164
group_id=group.id,
165165
idempotency_key=activity_action_idempotency_key(note),
166166
).first()
167-
if original_comment_log_action is None and features.has(
168-
"projects:issue-action-log-activity", group.project, actor=request.user
167+
if original_comment_log_action is None and should_serve_action_log_activity(
168+
group.project, request.user
169169
):
170170
raise ResourceDoesNotExist
171171

@@ -208,9 +208,7 @@ def put(self, request: Request, group: Group, note_id: str) -> Response:
208208
sender="put",
209209
)
210210

211-
if features.has(
212-
"projects:issue-action-log-activity", group.project, actor=request.user
213-
):
211+
if should_serve_action_log_activity(group.project, request.user):
214212
if original_comment_log_action is not None:
215213
# editing a note doesn't update its COMMENT entry (instead it
216214
# appends a separate COMMENT_EDIT entry), so patch in the fresh

tests/sentry/api/helpers/test_group_index.py

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from sentry.grouping.grouptype import ErrorGroupType
3535
from sentry.issues.action_log import ActionSource, GroupActionActor, action_context_scope
3636
from sentry.issues.action_log.types import GroupActionType, GroupActorType
37+
from sentry.issues.derived.gate import GROUP_ACTION_LOG_BACKFILL_COMPLETED_OPTION
3738
from sentry.issues.issue_search import parse_search_query
3839
from sentry.issues.models.groupactionlogentry import GroupActionLogEntry
3940
from sentry.models.activity import Activity
@@ -736,6 +737,7 @@ def test_resolve_in_next_release_ignores_archived_releases(self) -> None:
736737
def test_resolve_in_next_release_activity_from_action_log(self) -> None:
737738
self.create_release(project=self.project, version="test@1.0.0.0")
738739
group = self.create_group(status=GroupStatus.UNRESOLVED)
740+
self.project.update_option(GROUP_ACTION_LOG_BACKFILL_COMPLETED_OPTION, True)
739741
GroupActionLogEntry.objects.create(
740742
group_id=group.id,
741743
project_id=group.project_id,
@@ -751,24 +753,34 @@ def test_resolve_in_next_release_activity_from_action_log(self) -> None:
751753
request = _wrap_request(http_request, data={"status": "resolvedInNextRelease"})
752754

753755
group_list = get_group_list(self.organization.id, [self.project], request.GET.getlist("id"))
754-
with self.feature("projects:issue-action-log-activity"):
756+
with self.feature(
757+
["projects:issue-action-log-write-to-db", "projects:issue-action-log-activity"]
758+
):
755759
response = update_groups(request, group_list)
756760

757761
activity = response.data["activity"]
758-
assert [entry["type"] for entry in activity] == ["set_resolved", "first_seen"]
762+
# the manually logged RESOLVE exists only in GALE, so its presence means
763+
# the action log was served rather than Activity
764+
assert "set_resolved" in [entry["type"] for entry in activity]
759765
assert activity[-1]["id"] == "0"
760766

761-
def test_resolve_in_next_release_no_activity_without_action_log(self) -> None:
767+
def test_resolve_in_next_release_no_activity_when_action_log_is_empty(self) -> None:
768+
# A gated project can still read an empty log: the GALE write for this
769+
# resolve goes through an outbox that may not have drained yet.
762770
self.create_release(project=self.project, version="test@1.0.0.0")
763771
group = self.create_group(status=GroupStatus.UNRESOLVED)
772+
self.project.update_option(GROUP_ACTION_LOG_BACKFILL_COMPLETED_OPTION, True)
764773

765774
http_request = self.make_request(user=self.user, method="GET")
766775
http_request.GET = QueryDict(query_string=f"id={group.id}")
767776
request = _wrap_request(http_request, data={"status": "resolvedInNextRelease"})
768777

769778
group_list = get_group_list(self.organization.id, [self.project], request.GET.getlist("id"))
770779
with (
771-
self.feature("projects:issue-action-log-activity"),
780+
self.feature(
781+
["projects:issue-action-log-write-to-db", "projects:issue-action-log-activity"]
782+
),
783+
patch.object(GroupActionLogEntry.objects, "get_actions_for_group", return_value=[]),
772784
self.assertLogs("sentry.api.helpers.group_index.update", level="INFO") as logs,
773785
):
774786
response = update_groups(request, group_list)
@@ -778,7 +790,35 @@ def test_resolve_in_next_release_no_activity_without_action_log(self) -> None:
778790
)
779791
assert response is not None
780792
assert "activity" not in response.data
781-
assert GroupActionLogEntry.objects.filter(group_id=group.id).count() == 0
793+
794+
def test_resolve_in_next_release_ignores_action_log_when_not_backfilled(self) -> None:
795+
# The log covers only part of this project's history, so serving it would
796+
# silently drop everything that predates the rollout. Fall back to Activity.
797+
self.create_release(project=self.project, version="test@1.0.0.0")
798+
group = self.create_group(status=GroupStatus.UNRESOLVED)
799+
GroupActionLogEntry.objects.create(
800+
group_id=group.id,
801+
project_id=group.project_id,
802+
type=GroupActionType.COMMENT.value,
803+
actor_type=GroupActorType.USER.value,
804+
actor_id=self.user.id,
805+
source="web",
806+
data={"comment_id": 123, "text": "hello world"},
807+
)
808+
809+
http_request = self.make_request(user=self.user, method="GET")
810+
http_request.GET = QueryDict(query_string=f"id={group.id}")
811+
request = _wrap_request(http_request, data={"status": "resolvedInNextRelease"})
812+
813+
group_list = get_group_list(self.organization.id, [self.project], request.GET.getlist("id"))
814+
with self.feature(
815+
["projects:issue-action-log-write-to-db", "projects:issue-action-log-activity"]
816+
):
817+
response = update_groups(request, group_list)
818+
819+
# the COMMENT only exists in the log, so its absence means Activity was served
820+
activity = response.data["activity"]
821+
assert "note" not in [entry["type"] for entry in activity]
782822

783823

784824
class MergeGroupsTest(TestCase):

tests/sentry/issues/endpoints/test_group_activities.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from sentry.issues.action_log.types import ActionSource, GroupActionType, GroupActorType
2+
from sentry.issues.derived.gate import GROUP_ACTION_LOG_BACKFILL_COMPLETED_OPTION
23
from sentry.issues.models.groupactionlogentry import GroupActionLogEntry
34
from sentry.models.activity import Activity
45
from sentry.models.group import GroupStatus
@@ -44,9 +45,10 @@ def test_endpoint_with_group_activities(self) -> None:
4445
assert "activity" in response.data
4546
assert len(response.data["activity"]) == 5
4647

47-
@with_feature("projects:issue-action-log-activity")
48+
@with_feature(["projects:issue-action-log-write-to-db", "projects:issue-action-log-activity"])
4849
def test_endpoint_with_group_action_log_entries(self) -> None:
4950
group = self.create_group(status=GroupStatus.UNRESOLVED)
51+
group.project.update_option(GROUP_ACTION_LOG_BACKFILL_COMPLETED_OPTION, True)
5052

5153
for i in range(0, 4):
5254
GroupActionLogEntry.objects.create(

tests/sentry/issues/endpoints/test_group_details.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
ActionSource,
1818
GroupActionActor,
1919
GroupActionType,
20+
GroupActorType,
2021
ReconcileStatusAction,
2122
)
2223
from sentry.issues.constants import cache_key_for_issue_view
@@ -134,6 +135,7 @@ def test_no_releases(self) -> None:
134135
@with_feature(["projects:issue-action-log-write-to-db", "projects:issue-action-log-activity"])
135136
def test_group_action_log_entry(self) -> None:
136137
group = self.create_group()
138+
group.project.update_option(GROUP_ACTION_LOG_BACKFILL_COMPLETED_OPTION, True)
137139

138140
# activity dual writes to GALE. use action context scope to attribute it to the user rather than system
139141
data = {"assignee": str(self.user.id)}
@@ -173,6 +175,7 @@ def test_group_action_log_entry(self) -> None:
173175
def test_group_action_log_comment_is_addressable(self) -> None:
174176
self.login_as(user=self.user)
175177
group = self.create_group()
178+
group.project.update_option(GROUP_ACTION_LOG_BACKFILL_COMPLETED_OPTION, True)
176179

177180
comments_url = f"/api/0/issues/{group.id}/comments/"
178181
response = self.client.post(comments_url, format="json", data={"text": "original"})
@@ -205,6 +208,64 @@ def test_group_action_log_comment_is_addressable(self) -> None:
205208
response = self.client.delete(f"{comments_url}{note_id}/", format="json")
206209
assert response.status_code == 204, response.status_code
207210

211+
@with_feature(["projects:issue-action-log-write-to-db", "projects:issue-action-log-activity"])
212+
def test_group_action_log_ignored_when_not_backfilled(self) -> None:
213+
# The log covers only part of this project's history, so serving it would
214+
# silently drop everything that predates the rollout. Fall back to Activity.
215+
self.login_as(user=self.user)
216+
group = self.create_group()
217+
self.create_group_action_log_entry(
218+
group=group,
219+
type=GroupActionType.COMMENT,
220+
actor_type=GroupActorType.USER,
221+
actor_id=self.user.id,
222+
data={"comment_id": 123, "text": "hello world"},
223+
)
224+
225+
url = f"/api/0/organizations/{group.organization.slug}/issues/{group.id}/"
226+
response = self.client.get(url, format="json")
227+
assert response.status_code == 200, response.content
228+
229+
# the COMMENT only exists in the log, so its absence means Activity was served
230+
assert [item["type"] for item in response.data["activity"]] == ["first_seen"]
231+
232+
@with_feature(["projects:issue-action-log-write-to-db", "projects:issue-action-log-activity"])
233+
def test_group_action_log_served_when_backfilled(self) -> None:
234+
self.login_as(user=self.user)
235+
group = self.create_group()
236+
group.project.update_option(GROUP_ACTION_LOG_BACKFILL_COMPLETED_OPTION, True)
237+
self.create_group_action_log_entry(
238+
group=group,
239+
type=GroupActionType.COMMENT,
240+
actor_type=GroupActorType.USER,
241+
actor_id=self.user.id,
242+
data={"comment_id": 123, "text": "hello world"},
243+
)
244+
245+
url = f"/api/0/organizations/{group.organization.slug}/issues/{group.id}/"
246+
response = self.client.get(url, format="json")
247+
assert response.status_code == 200, response.content
248+
249+
assert [item["type"] for item in response.data["activity"]] == ["note", "first_seen"]
250+
251+
def test_group_action_log_ignored_when_flag_off(self) -> None:
252+
self.login_as(user=self.user)
253+
group = self.create_group()
254+
group.project.update_option(GROUP_ACTION_LOG_BACKFILL_COMPLETED_OPTION, True)
255+
self.create_group_action_log_entry(
256+
group=group,
257+
type=GroupActionType.COMMENT,
258+
actor_type=GroupActorType.USER,
259+
actor_id=self.user.id,
260+
data={"comment_id": 123, "text": "hello world"},
261+
)
262+
263+
url = f"/api/0/organizations/{group.organization.slug}/issues/{group.id}/"
264+
response = self.client.get(url, format="json")
265+
assert response.status_code == 200, response.content
266+
267+
assert [item["type"] for item in response.data["activity"]] == ["first_seen"]
268+
208269
def test_pending_delete_pending_merge_excluded(self) -> None:
209270
group1 = self.create_group(status=GroupStatus.PENDING_DELETION)
210271
group2 = self.create_group(status=GroupStatus.DELETION_IN_PROGRESS)

0 commit comments

Comments
 (0)