Skip to content

Commit acd2d4a

Browse files
authored
fix: optimise segment deletion and cloning with bulk operations (#6401)
1 parent 7058ed1 commit acd2d4a

File tree

10 files changed

+578
-59
lines changed

10 files changed

+578
-59
lines changed

api/core/dataclasses.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from dataclasses import dataclass
2+
from typing import TYPE_CHECKING
3+
4+
if TYPE_CHECKING:
5+
from rest_framework.request import Request
6+
7+
from api_keys.models import MasterAPIKey
8+
from users.models import FFAdminUser
9+
10+
11+
@dataclass
12+
class AuthorData:
13+
user: "FFAdminUser | None" = None
14+
api_key: "MasterAPIKey | None" = None
15+
16+
@classmethod
17+
def from_request(cls, request: "Request") -> "AuthorData":
18+
from users.models import FFAdminUser
19+
20+
if type(request.user) is FFAdminUser:
21+
return cls(user=request.user)
22+
elif hasattr(request.user, "key"):
23+
return cls(api_key=request.user.key)
24+
else:
25+
raise ValueError("Request user must be FFAdminUser or have an API key")

api/features/feature_states/serializers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from rest_framework import serializers
22

3+
from core.dataclasses import AuthorData
34
from environments.models import Environment
45
from features.models import Feature, FeatureState
56
from features.versioning.dataclasses import (
6-
AuthorData,
77
FlagChangeSet,
88
FlagChangeSetV2,
99
SegmentOverrideChangeSet,

api/features/versioning/dataclasses.py

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,12 @@
22

33
from dataclasses import dataclass, field
44
from datetime import datetime
5-
from typing import TYPE_CHECKING
65

76
from pydantic import BaseModel, computed_field
87

8+
from core.dataclasses import AuthorData
99
from features.feature_states.models import FeatureValueType
1010

11-
if TYPE_CHECKING:
12-
from rest_framework.request import Request
13-
14-
from api_keys.models import MasterAPIKey
15-
from users.models import FFAdminUser
16-
17-
18-
@dataclass
19-
class AuthorData:
20-
user: FFAdminUser | None = None
21-
api_key: MasterAPIKey | None = None
22-
23-
@classmethod
24-
def from_request(cls, request: Request) -> AuthorData:
25-
from users.models import FFAdminUser
26-
27-
if type(request.user) is FFAdminUser:
28-
return cls(user=request.user)
29-
elif hasattr(request.user, "key"):
30-
return cls(api_key=request.user.key)
31-
else:
32-
raise ValueError("Request user must be FFAdminUser or have an API key")
33-
3411

3512
class Conflict(BaseModel):
3613
segment_id: int | None = None

api/features/versioning/versioning_service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55
from django.utils import timezone
66
from rest_framework.exceptions import NotFound
77

8+
from core.dataclasses import AuthorData
89
from environments.models import Environment
910
from features.feature_states.models import FeatureValueType
1011
from features.models import Feature, FeatureSegment, FeatureState, FeatureStateValue
1112
from features.versioning.dataclasses import (
12-
AuthorData,
1313
FlagChangeSet,
1414
FlagChangeSetV2,
1515
)

api/segments/models.py

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from features.models import Feature
2929
from metadata.models import Metadata
3030
from projects.models import Project
31+
from segments.services import copy_segment_rules_and_conditions
3132

3233
ModelT = typing.TypeVar("ModelT", bound=models.Model)
3334

@@ -180,40 +181,10 @@ def clone(self, is_revision: bool = False, **extra_attrs: typing.Any) -> "Segmen
180181
return cloned_segment
181182

182183
def copy_rules_and_conditions_from(self, source_segment: "Segment") -> None:
183-
"""
184-
Recursively copy rules and conditions from another segment
185-
"""
186-
assert transaction.get_connection().in_atomic_block, "Must run in a transaction"
187-
188-
# Delete existing rules
189-
SegmentRule.objects.filter(segment=self).delete()
190-
191-
source_rules = SegmentRule.objects.filter(
192-
models.Q(segment=source_segment) | models.Q(rule__segment=source_segment)
184+
copy_segment_rules_and_conditions(
185+
target_segment=self, source_segment=source_segment
193186
)
194187

195-
# Ensure top-level rules are cloned first (for dependencies)
196-
source_rules = source_rules.order_by(models.F("rule").asc(nulls_first=True))
197-
198-
rule_to_cloned_rule_map: dict[SegmentRule, SegmentRule] = {}
199-
for rule in source_rules:
200-
cloned_rule = deepcopy(rule)
201-
cloned_rule.pk = None
202-
cloned_rule.uuid = uuid.uuid4()
203-
cloned_rule.segment = self if rule.segment else None
204-
if rule.rule in rule_to_cloned_rule_map:
205-
cloned_rule.rule = rule_to_cloned_rule_map[rule.rule]
206-
cloned_rule.save()
207-
rule_to_cloned_rule_map[rule] = cloned_rule
208-
209-
source_conditions = Condition.objects.filter(rule__in=rule_to_cloned_rule_map)
210-
for condition in source_conditions:
211-
cloned_condition = deepcopy(condition)
212-
cloned_condition.pk = None
213-
cloned_condition.uuid = uuid.uuid4()
214-
cloned_condition.rule = rule_to_cloned_rule_map[condition.rule]
215-
cloned_condition.save()
216-
217188
def get_create_log_message(self, history_instance) -> typing.Optional[str]: # type: ignore[no-untyped-def]
218189
return SEGMENT_CREATED_MESSAGE % self.name
219190

api/segments/services.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import typing
2+
import uuid
3+
4+
from django.db import models, transaction
5+
from django.utils import timezone
6+
7+
from core.dataclasses import AuthorData
8+
9+
if typing.TYPE_CHECKING:
10+
from segments.models import Segment
11+
12+
13+
def delete_segment(
14+
segment: "Segment",
15+
author: AuthorData,
16+
) -> None:
17+
"""
18+
Delete a segment using optimized bulk operations.
19+
20+
Uses bulk UPDATE/DELETE operations instead of individual soft-deletes,
21+
reducing the number of database queries from O(n) to O(1) where n is
22+
the number of rules and conditions.
23+
24+
Note: This is a temporary solution until we redesign the segment data model.
25+
"""
26+
from features.models import FeatureSegment
27+
from segments.models import Condition, Segment, SegmentRule
28+
from segments.tasks import create_segment_deleted_audit_log
29+
30+
now = timezone.now()
31+
32+
segment_name = segment.name
33+
segment_uuid = str(segment.uuid)
34+
segment_id = segment.id
35+
project_id = segment.project_id
36+
37+
segment_ids = list(
38+
Segment.objects.filter(
39+
models.Q(id=segment.id) | models.Q(version_of_id=segment.id)
40+
).values_list("id", flat=True)
41+
)
42+
43+
top_level_rule_ids = list(
44+
SegmentRule.objects.filter(segment_id__in=segment_ids).values_list(
45+
"id", flat=True
46+
)
47+
)
48+
49+
all_rule_ids = set(top_level_rule_ids)
50+
current_level_ids = top_level_rule_ids
51+
52+
while current_level_ids:
53+
nested_rule_ids = list(
54+
SegmentRule.objects.filter(rule_id__in=current_level_ids).values_list(
55+
"id", flat=True
56+
)
57+
)
58+
all_rule_ids.update(nested_rule_ids)
59+
current_level_ids = nested_rule_ids
60+
61+
all_rule_ids_list = list(all_rule_ids)
62+
63+
with transaction.atomic():
64+
FeatureSegment.objects.filter(segment_id__in=segment_ids).delete()
65+
Condition.objects.filter(rule_id__in=all_rule_ids_list).update(deleted_at=now)
66+
SegmentRule.objects.filter(id__in=all_rule_ids_list).update(deleted_at=now)
67+
Segment.objects.filter(id__in=segment_ids).update(deleted_at=now)
68+
69+
create_segment_deleted_audit_log.delay(
70+
args=(
71+
project_id,
72+
segment_name,
73+
segment_id,
74+
segment_uuid,
75+
author.user.id if author.user else None,
76+
author.api_key.id if author.api_key else None,
77+
now.isoformat(),
78+
)
79+
)
80+
81+
82+
def copy_segment_rules_and_conditions(
83+
target_segment: "Segment",
84+
source_segment: "Segment",
85+
) -> None:
86+
"""
87+
Copy rules and conditions from source to target segment using bulk operations.
88+
89+
If target has existing rules, they are hard-deleted first.
90+
91+
"""
92+
from segments.models import Condition, SegmentRule
93+
94+
assert transaction.get_connection().in_atomic_block, "Must run in a transaction"
95+
96+
SegmentRule.objects.filter(segment=target_segment).delete()
97+
98+
top_level_rules = list(SegmentRule.objects.filter(segment=source_segment))
99+
if not top_level_rules:
100+
return
101+
102+
rule_id_to_cloned_rule: dict[int, SegmentRule] = {}
103+
104+
cloned_top_rules = [
105+
SegmentRule(
106+
uuid=uuid.uuid4(),
107+
segment=target_segment,
108+
rule=None,
109+
type=rule.type,
110+
)
111+
for rule in top_level_rules
112+
]
113+
SegmentRule.objects.bulk_create(cloned_top_rules)
114+
115+
for rule, cloned in zip(top_level_rules, cloned_top_rules):
116+
rule_id_to_cloned_rule[rule.id] = cloned
117+
118+
current_level_rule_ids = [r.id for r in top_level_rules]
119+
while current_level_rule_ids:
120+
nested_rules = list(
121+
SegmentRule.objects.filter(rule_id__in=current_level_rule_ids)
122+
)
123+
cloned_nested = []
124+
for rule in nested_rules:
125+
assert rule.rule_id is not None
126+
cloned_nested.append(
127+
SegmentRule(
128+
uuid=uuid.uuid4(),
129+
segment=None,
130+
rule=rule_id_to_cloned_rule[rule.rule_id],
131+
type=rule.type,
132+
)
133+
)
134+
135+
if cloned_nested:
136+
SegmentRule.objects.bulk_create(cloned_nested)
137+
138+
for rule, cloned in zip(nested_rules, cloned_nested):
139+
rule_id_to_cloned_rule[rule.id] = cloned
140+
141+
current_level_rule_ids = [r.id for r in nested_rules]
142+
143+
source_conditions = list(
144+
Condition.objects.filter(rule_id__in=rule_id_to_cloned_rule.keys())
145+
)
146+
if source_conditions:
147+
Condition.objects.bulk_create(
148+
[
149+
Condition(
150+
uuid=uuid.uuid4(),
151+
rule=rule_id_to_cloned_rule[c.rule_id],
152+
operator=c.operator,
153+
property=c.property,
154+
value=c.value,
155+
description=c.description,
156+
created_with_segment=True,
157+
)
158+
for c in source_conditions
159+
]
160+
)

api/segments/tasks.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,37 @@
1+
from django.utils.dateparse import parse_datetime
12
from task_processor.decorators import (
23
register_task_handler,
34
)
45

6+
from audit.constants import SEGMENT_DELETED_MESSAGE
7+
from audit.models import AuditLog
8+
from audit.related_object_type import RelatedObjectType
59
from segments.models import Segment
610

711

812
@register_task_handler()
913
def delete_segment(segment_id: int) -> None:
1014
Segment.objects.get(pk=segment_id).delete()
15+
16+
17+
@register_task_handler()
18+
def create_segment_deleted_audit_log(
19+
project_id: int,
20+
segment_name: str,
21+
segment_id: int,
22+
segment_uuid: str,
23+
user_id: int | None,
24+
master_api_key_id: int | None,
25+
created_date: str,
26+
) -> None:
27+
AuditLog.objects.create(
28+
project_id=project_id,
29+
environment=None,
30+
log=SEGMENT_DELETED_MESSAGE % segment_name,
31+
author_id=user_id,
32+
master_api_key_id=master_api_key_id,
33+
related_object_id=segment_id,
34+
related_object_type=RelatedObjectType.SEGMENT.name,
35+
related_object_uuid=segment_uuid,
36+
created_date=parse_datetime(created_date),
37+
)

api/segments/views.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from rest_framework.response import Response
1212

1313
from app.pagination import CustomPagination
14+
from core.dataclasses import AuthorData
1415
from edge_api.identities.models import EdgeIdentity
1516
from environments.identities.models import Identity
1617
from environments.models import Environment
@@ -28,6 +29,7 @@
2829
SegmentListQuerySerializer,
2930
SegmentSerializer,
3031
)
32+
from .services import delete_segment
3133

3234
logger = logging.getLogger()
3335

@@ -122,6 +124,12 @@ def associated_features(self, request, *args, **kwargs): # type: ignore[no-unty
122124
serializer = self.get_serializer(queryset, many=True)
123125
return Response(serializer.data)
124126

127+
def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response:
128+
segment = self.get_object()
129+
author = AuthorData.from_request(request)
130+
delete_segment(segment, author=author)
131+
return Response(status=status.HTTP_204_NO_CONTENT)
132+
125133
@swagger_auto_schema(
126134
request_body=CloneSegmentSerializer,
127135
responses={201: SegmentSerializer()},

api/tests/unit/features/versioning/test_unit_versioning_dataclasses.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import pytest
44

5-
from features.versioning.dataclasses import AuthorData, Conflict
5+
from core.dataclasses import AuthorData
6+
from features.versioning.dataclasses import Conflict
67

78

89
@pytest.mark.parametrize("segment_id, expected_result", ((None, True), (1, False)))

0 commit comments

Comments
 (0)