Skip to content

Commit ede739b

Browse files
fix: N dynamodb queries in feature list endpoint (#6758)
1 parent 4712ef4 commit ede739b

14 files changed

Lines changed: 172 additions & 240 deletions

File tree

api/edge_api/identities/edge_identity_service.py

Lines changed: 9 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from edge_api.identities.models import EdgeIdentity
44
from environments.dynamodb import DynamoEnvironmentV2Wrapper
55
from environments.dynamodb.types import (
6-
IdentityOverridesV2List,
76
IdentityOverrideV2,
87
)
98

@@ -22,40 +21,24 @@ def get_edge_identity_overrides(
2221
)
2322
return [
2423
IdentityOverrideV2.model_validate(
25-
{**item, "environment_id": str(item["environment_id"])} # type: ignore[dict-item,index]
24+
{**item, "environment_id": str(item["environment_id"])}
2625
)
2726
for item in override_items
2827
]
2928

3029

31-
def get_edge_identity_overrides_for_feature_ids(
32-
environment_id: int,
33-
feature_ids: None | list[int] = None,
34-
) -> list[IdentityOverridesV2List]:
35-
query_responses = (
30+
def get_edge_identity_override_keys(environment_id: int) -> list[str]:
31+
"""
32+
Get all the identity overrides for an environment, returning only the document key
33+
for optimised performance when the key is all that is needed.
34+
"""
35+
override_items = (
3636
ddb_environment_v2_wrapper.get_identity_overrides_by_environment_id(
3737
environment_id=environment_id,
38-
feature_ids=feature_ids,
38+
projection_expression_attributes=["document_key"],
3939
)
4040
)
41-
42-
results = []
43-
for identity_overrides_query_response in query_responses:
44-
identity_overrides = [
45-
IdentityOverrideV2.model_validate(
46-
{**item, "environment_id": str(item["environment_id"])}
47-
)
48-
for item in identity_overrides_query_response.items # type: ignore[union-attr]
49-
]
50-
complete = identity_overrides_query_response.is_num_identity_overrides_complete # type: ignore[union-attr]
51-
results.append(
52-
IdentityOverridesV2List(
53-
identity_overrides=identity_overrides,
54-
is_num_identity_overrides_complete=complete,
55-
)
56-
)
57-
58-
return results
41+
return [item["document_key"] for item in override_items]
5942

6043

6144
def get_overridden_feature_ids_for_edge_identity(identity_uuid: str) -> set[int]:

api/environments/dynamodb/types.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,6 @@ class IdentityOverridesV2Changeset:
9797
to_put: list[IdentityOverrideV2]
9898

9999

100-
@dataclass
101-
class IdentityOverridesV2List:
102-
identity_overrides: list[IdentityOverrideV2]
103-
is_num_identity_overrides_complete: bool
104-
105-
106100
@dataclass
107101
class EdgeV2MigrationResult:
108102
identity_overrides_changeset: IdentityOverridesV2Changeset

api/environments/dynamodb/utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@ def get_environments_v2_identity_override_document_key(
1111
if identity_uuid is None:
1212
return f"identity_override:{feature_id}:"
1313
return f"identity_override:{feature_id}:{identity_uuid}"
14+
15+
16+
def get_feature_id_from_identity_override_document_key(document_key: str) -> int:
17+
return int(document_key.split(":")[1])

api/environments/dynamodb/wrappers/environment_wrapper.py

Lines changed: 15 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import typing
2-
from concurrent.futures import ThreadPoolExecutor
3-
from dataclasses import dataclass
42
from typing import Any, Iterable
53

64
from boto3.dynamodb.conditions import Key
@@ -31,12 +29,6 @@
3129
from environments.models import Environment
3230

3331

34-
@dataclass
35-
class IdentityOverridesQueryResponse:
36-
items: list[dict[str, Any]]
37-
is_num_identity_overrides_complete: bool
38-
39-
4032
class BaseDynamoEnvironmentWrapper(BaseDynamoWrapper):
4133
def write_environment(self, environment: "Environment") -> None:
4234
self.write_environments([environment])
@@ -74,52 +66,25 @@ def get_identity_overrides_by_environment_id(
7466
self,
7567
environment_id: int,
7668
feature_id: int | None = None,
77-
feature_ids: None | list[int] = None,
78-
) -> list[dict[str, Any]] | list[IdentityOverridesQueryResponse]:
79-
try:
80-
if feature_ids is None:
81-
return list(
82-
self.query_iter_all_items(
83-
KeyConditionExpression=self.get_identity_overrides_key_condition_expression(
84-
environment_id=environment_id,
85-
feature_id=feature_id,
86-
)
87-
)
88-
)
89-
90-
else:
91-
futures = []
92-
with ThreadPoolExecutor() as executor:
93-
for feature_id in feature_ids:
94-
futures.append(
95-
executor.submit(
96-
self.get_identity_overrides_page,
97-
environment_id,
98-
feature_id,
99-
)
100-
)
101-
102-
results = [future.result() for future in futures]
103-
return results
69+
projection_expression_attributes: list[str] | None = None,
70+
) -> list[dict[str, Any]]:
71+
key_condition_expression = self.get_identity_overrides_key_condition_expression(
72+
environment_id=environment_id,
73+
feature_id=feature_id,
74+
)
75+
query_kwargs: dict[str, Any] = {
76+
"KeyConditionExpression": key_condition_expression,
77+
}
78+
if projection_expression_attributes:
79+
query_kwargs["ProjectionExpression"] = ",".join(
80+
projection_expression_attributes
81+
)
10482

83+
try:
84+
return list(self.query_iter_all_items(**query_kwargs))
10585
except KeyError as e:
10686
raise ObjectDoesNotExist() from e
10787

108-
def get_identity_overrides_page(
109-
self, environment_id: int, feature_id: int
110-
) -> IdentityOverridesQueryResponse:
111-
query_response = self.table.query( # type: ignore[union-attr]
112-
KeyConditionExpression=self.get_identity_overrides_key_condition_expression( # type: ignore[arg-type]
113-
environment_id=environment_id,
114-
feature_id=feature_id,
115-
)
116-
)
117-
last_evaluated_key = query_response.get("LastEvaluatedKey")
118-
return IdentityOverridesQueryResponse(
119-
items=query_response["Items"],
120-
is_num_identity_overrides_complete=last_evaluated_key is None,
121-
)
122-
12388
def get_identity_overrides_key_condition_expression(
12489
self,
12590
environment_id: int,

api/features/dataclasses.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ class EnvironmentFeatureOverridesData:
1414

1515
num_segment_overrides: int = 0
1616
num_identity_overrides: typing.Optional[int] = None
17-
is_num_identity_overrides_complete: bool = True
1817

1918
def add_identity_override(self): # type: ignore[no-untyped-def]
2019
"""

api/features/features_service.py

Lines changed: 23 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
from concurrent.futures import ThreadPoolExecutor
33

44
from edge_api.identities.edge_identity_service import (
5-
get_edge_identity_overrides_for_feature_ids,
5+
get_edge_identity_override_keys,
6+
)
7+
from environments.dynamodb.utils import (
8+
get_feature_id_from_identity_override_document_key,
69
)
710
from features.dataclasses import EnvironmentFeatureOverridesData
811
from features.versioning.versioning_service import get_environment_flags_list
@@ -16,12 +19,11 @@
1619

1720
def get_overrides_data(
1821
environment: "Environment",
19-
feature_ids: None | list[int] = None,
2022
) -> OverridesData:
2123
"""
2224
Get correct overrides counts for a given environment.
2325
24-
:param project: project to get overrides data for
26+
:param environment: environment to get overrides data for
2527
:return: overrides data getter dictionary of {feature_id: EnvironmentFeatureOverridesData}
2628
"""
2729
project = environment.project
@@ -30,7 +32,7 @@ def get_overrides_data(
3032
if project.edge_v2_identity_overrides_migrated:
3133
# If v2 migration is complete, count segment overrides from Core
3234
# and identity overrides from DynamoDB.
33-
return get_edge_overrides_data(environment, feature_ids)
35+
return get_edge_overrides_data(environment)
3436
# If v2 migration is not started, in progress, or incomplete,
3537
# only count segment overrides from Core.
3638
# v1 Edge identity overrides are uncountable.
@@ -71,9 +73,7 @@ def get_core_overrides_data(
7173
return all_overrides_data
7274

7375

74-
def get_edge_overrides_data(
75-
environment: "Environment", feature_ids: None | list[int] = None
76-
) -> OverridesData:
76+
def get_edge_overrides_data(environment: "Environment") -> OverridesData:
7777
"""
7878
Get the number of identity / segment overrides in a given environment for each feature in the
7979
project.
@@ -83,37 +83,32 @@ def get_edge_overrides_data(
8383
:return OverridesData: dictionary of {feature_id: EnvironmentFeatureOverridesData}
8484
"""
8585

86-
assert feature_ids is not None
87-
8886
with ThreadPoolExecutor() as executor:
89-
get_environment_flags_list_future = executor.submit(
90-
get_environment_flags_list,
91-
environment,
92-
)
87+
# Note: We intentionally let the get_environment_flags_list
88+
# call happen on the main thread to simplify testing.
89+
# The call to dynamo happens on a separate thread, which
90+
# still gives us the parallelism we need without needing
91+
# an extra thread. This does mean that the order of execution
92+
# is important here.
9393
get_overrides_data_future = executor.submit(
94-
get_edge_identity_overrides_for_feature_ids,
94+
get_edge_identity_override_keys,
9595
environment_id=environment.id,
96-
feature_ids=feature_ids,
9796
)
97+
flags_list = get_environment_flags_list(environment)
9898
all_overrides_data: OverridesData = {}
9999

100-
for feature_state in get_environment_flags_list_future.result():
100+
for feature_state in flags_list:
101101
env_feature_overrides_data = all_overrides_data.setdefault(
102102
feature_state.feature_id, EnvironmentFeatureOverridesData()
103103
)
104104
if feature_state.feature_segment_id:
105105
env_feature_overrides_data.num_segment_overrides += 1
106-
for identity_overrides_v2_list in get_overrides_data_future.result():
107-
for identity_override in identity_overrides_v2_list.identity_overrides:
108-
# Only override features that exists in core
109-
if identity_override.feature_state.feature.id in all_overrides_data:
110-
all_overrides_data[ # type: ignore[no-untyped-call]
111-
identity_override.feature_state.feature.id
112-
].add_identity_override()
113-
all_overrides_data[
114-
identity_override.feature_state.feature.id
115-
].is_num_identity_overrides_complete = (
116-
identity_overrides_v2_list.is_num_identity_overrides_complete
117-
)
106+
for identity_override_key in get_overrides_data_future.result():
107+
feature_id = get_feature_id_from_identity_override_document_key(
108+
identity_override_key
109+
)
110+
# Only override features that exists in core
111+
if feature_id in all_overrides_data:
112+
all_overrides_data[feature_id].add_identity_override() # type: ignore[no-untyped-call]
118113

119114
return all_overrides_data

api/features/serializers.py

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -180,11 +180,10 @@ class CreateFeatureSerializer(DeleteBeforeUpdateWritableNestedModelSerializer):
180180
"in the environment provided by the `environment` query parameter. "
181181
"Note: will return null for Edge enabled projects."
182182
)
183-
is_num_identity_overrides_complete = serializers.SerializerMethodField(
184-
help_text="A boolean that indicates whether there are more"
185-
" identity overrides than are being listed, if `False`. This field is "
186-
"`True` when querying overrides data for a features list page and "
187-
"exact data has been returned."
183+
184+
# This is kept for backwards compatibility, but is always true
185+
is_num_identity_overrides_complete = serializers.BooleanField(
186+
read_only=True, default=True
188187
)
189188

190189
last_modified_in_any_environment = serializers.SerializerMethodField(
@@ -345,14 +344,6 @@ def get_num_identity_overrides(self, instance: Feature) -> int | None:
345344
except (KeyError, AttributeError):
346345
return None
347346

348-
def get_is_num_identity_overrides_complete(self, instance: Feature) -> bool | None:
349-
try:
350-
return self.context["overrides_data"][ # type: ignore[no-any-return]
351-
instance.id
352-
].is_num_identity_overrides_complete
353-
except (KeyError, AttributeError):
354-
return None
355-
356347
def get_last_modified_in_any_environment(
357348
self, instance: Feature
358349
) -> datetime | None:

api/features/views.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -349,9 +349,7 @@ def get_serializer_context(self): # type: ignore[no-untyped-def]
349349
environment = get_object_or_404(
350350
Environment, id=self.request.query_params["environment"]
351351
)
352-
context["overrides_data"] = get_overrides_data(
353-
environment, self.feature_ids
354-
)
352+
context["overrides_data"] = get_overrides_data(environment)
355353

356354
return context
357355

api/tests/conftest.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import typing
2+
import uuid
23

34
import pytest
45
from django.conf import settings
@@ -11,11 +12,18 @@
1112
DynamoEnvironmentWrapper,
1213
DynamoIdentityWrapper,
1314
)
15+
from environments.dynamodb.types import IdentityOverrideV2
16+
from environments.dynamodb.utils import (
17+
get_environments_v2_identity_override_document_key,
18+
)
1419
from environments.models import Environment
20+
from features.models import Feature, FeatureState
1521
from tests.types import MigratorFactory
1622
from util.mappers import (
1723
map_environment_to_environment_document,
1824
map_environment_to_environment_v2_document,
25+
map_feature_state_to_engine,
26+
map_identity_override_to_identity_override_document,
1927
)
2028

2129

@@ -102,6 +110,39 @@ def dynamo_enabled_project_environment_one_document(
102110
return environment_dict
103111

104112

113+
@pytest.fixture()
114+
def identity_override_document(
115+
flagsmith_environments_v2_table: Table,
116+
environment: Environment,
117+
feature: Feature,
118+
) -> dict[str, typing.Any]:
119+
identity_uuid = str(uuid.uuid4())
120+
identifier = "identity-with-dynamo-override"
121+
122+
identity_override = IdentityOverrideV2(
123+
environment_id=str(environment.id),
124+
environment_api_key=environment.api_key,
125+
document_key=get_environments_v2_identity_override_document_key(
126+
feature_id=feature.id, identity_uuid=identity_uuid
127+
),
128+
identifier=identifier,
129+
identity_uuid=identity_uuid,
130+
feature_state=map_feature_state_to_engine(
131+
FeatureState(
132+
feature=feature,
133+
enabled=True,
134+
environment=environment,
135+
),
136+
),
137+
)
138+
139+
identity_override_document = map_identity_override_to_identity_override_document(
140+
identity_override
141+
)
142+
flagsmith_environments_v2_table.put_item(Item=identity_override_document)
143+
return identity_override_document
144+
145+
105146
@pytest.fixture()
106147
def dynamo_environment_wrapper(
107148
flagsmith_environment_table: Table,

api/tests/unit/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from environments.models import Environment
1111
from features.models import Feature
1212
from organisations.models import Organisation, OrganisationRole
13-
from projects.models import Project
13+
from projects.models import EdgeV2MigrationStatus, Project
1414
from projects.tags.models import Tag
1515
from segments.models import Segment
1616
from users.models import FFAdminUser
@@ -112,6 +112,7 @@ def dynamo_enabled_project(organisation): # type: ignore[no-untyped-def]
112112
name="Dynamo enabled project",
113113
organisation=organisation,
114114
enable_dynamo_db=True,
115+
edge_v2_migration_status=EdgeV2MigrationStatus.COMPLETE,
115116
)
116117

117118

0 commit comments

Comments
 (0)