Skip to content

Commit 0dfa268

Browse files
authored
fix: /api/v1/projects/{project_pk}/segments/ is broken for compressed environments (#6913)
1 parent 50e4c2b commit 0dfa268

File tree

7 files changed

+64
-113
lines changed

7 files changed

+64
-113
lines changed

api/environments/dynamodb/migrator.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,6 @@ def migrate(self): # type: ignore[no-untyped-def]
108108
api_keys = EnvironmentAPIKey.objects.filter(environment__project_id=project_id)
109109
api_key_wrapper.write_api_keys(api_keys)
110110

111-
identity_wrapper = DynamoIdentityWrapper(
112-
environment_wrapper=environment_wrapper
113-
)
111+
identity_wrapper = DynamoIdentityWrapper()
114112
identity_wrapper.write_identities(self.iter_identities_in_chunks(project_id))
115113
self.project_metadata.finish_identity_migration() # type: ignore[no-untyped-call]

api/environments/dynamodb/wrappers/identity_wrapper.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,13 @@
1313
from environments.dynamodb.constants import IDENTITIES_PAGINATION_LIMIT
1414
from environments.dynamodb.wrappers.exceptions import CapacityBudgetExceeded
1515
from util.engine_models.context.mappers import (
16-
get_context_segments,
16+
is_context_in_segment,
1717
map_environment_identity_to_context,
1818
)
19-
from util.engine_models.environments.models import EnvironmentModel
2019
from util.engine_models.identities.models import IdentityModel
2120
from util.mappers import map_identity_to_identity_document
2221

2322
from .base import BaseDynamoWrapper
24-
from .environment_wrapper import DynamoEnvironmentWrapper
2523

2624
if typing.TYPE_CHECKING:
2725
from boto3.dynamodb.conditions import ConditionBase
@@ -37,12 +35,8 @@
3735

3836

3937
class DynamoIdentityWrapper(BaseDynamoWrapper):
40-
def __init__(
41-
self,
42-
environment_wrapper: DynamoEnvironmentWrapper | None = None,
43-
) -> None:
38+
def __init__(self) -> None:
4439
super().__init__()
45-
self._environment_wrapper = environment_wrapper or DynamoEnvironmentWrapper()
4640

4741
def get_table_name(self) -> str | None: # type: ignore[override]
4842
return settings.IDENTITIES_TABLE_NAME_DYNAMO
@@ -188,22 +182,29 @@ def get_segment_ids(
188182
identity_pk: str = None, # type: ignore[assignment]
189183
identity_model: IdentityModel = None, # type: ignore[assignment]
190184
) -> list: # type: ignore[type-arg]
185+
from environments.models import Environment
186+
from util.mappers.engine import map_segment_to_engine
187+
191188
if not (identity_pk or identity_model):
192189
raise ValueError("Must provide one of identity_pk or identity_model.")
193190

194191
with suppress(ObjectDoesNotExist):
195192
identity = identity_model or IdentityModel.model_validate(
196193
self.get_item_from_uuid(identity_pk)
197194
)
198-
environment = EnvironmentModel.model_validate(
199-
self._environment_wrapper.get_item(identity.environment_api_key)
195+
environment = Environment.objects.select_related("project").get(
196+
api_key=identity.environment_api_key,
200197
)
198+
segments = environment.project.get_segments_from_cache()
201199
context = map_environment_identity_to_context(
202200
environment=environment,
203201
identity=identity,
204202
override_traits=None,
205203
)
206-
segments = get_context_segments(context, environment.project.segments)
207-
return [segment.id for segment in segments]
204+
return [
205+
segment.id
206+
for segment in segments
207+
if is_context_in_segment(context, map_segment_to_engine(segment))
208+
]
208209

209210
return []

api/environments/tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def delete_environment_from_dynamo(api_key: str, environment_id: str): # type:
5050
environment_wrapper.delete_environment(api_key)
5151

5252
# Delete identities
53-
identity_wrapper = DynamoIdentityWrapper(environment_wrapper=environment_wrapper)
53+
identity_wrapper = DynamoIdentityWrapper()
5454
identity_wrapper.delete_all_identities(api_key)
5555

5656
# Delete environment_v2 documents

api/tests/integration/edge_api/identities/test_edge_identity_featurestates_viewset.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
)
2323
from environments.dynamodb import (
2424
DynamoEnvironmentV2Wrapper,
25-
DynamoEnvironmentWrapper,
2625
DynamoIdentityWrapper,
2726
)
2827
from environments.models import Environment
@@ -1128,7 +1127,6 @@ def test_edge_identity_clone_flag_states_from(
11281127
flagsmith_identities_table: Table,
11291128
dynamodb_identity_wrapper: DynamoIdentityWrapper,
11301129
dynamodb_wrapper_v2: DynamoEnvironmentV2Wrapper,
1131-
dynamo_environment_wrapper: DynamoEnvironmentWrapper,
11321130
) -> None:
11331131
mocker.patch(
11341132
"environments.dynamodb.services.DynamoIdentityWrapper",
@@ -1140,11 +1138,6 @@ def test_edge_identity_clone_flag_states_from(
11401138
autospec=True,
11411139
return_value=dynamodb_wrapper_v2,
11421140
)
1143-
mocker.patch(
1144-
"environments.dynamodb.wrappers.identity_wrapper.DynamoEnvironmentWrapper",
1145-
autospec=True,
1146-
return_value=dynamo_environment_wrapper,
1147-
)
11481141

11491142
def create_identity(identifier: str) -> EdgeIdentity:
11501143
identity_model = IdentityModel(

api/tests/unit/environments/dynamodb/test_unit_migrator.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,7 @@ def test_migrate_calls_internal_methods_with_correct_arguments( # type: ignore[
4343
identity_migrator.migrate() # type: ignore[no-untyped-call]
4444

4545
# Then
46-
mocked_identity_wrapper.assert_called_with(
47-
environment_wrapper=mocked_environment_wrapper.return_value
48-
)
46+
mocked_identity_wrapper.assert_called_once_with()
4947

5048
args, kwargs = mocked_identity_wrapper.return_value.write_identities.call_args
5149
assert kwargs == {}

api/tests/unit/environments/dynamodb/wrappers/test_unit_dynamodb_identity_wrapper.py

Lines changed: 44 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33

44
import pytest
55
from boto3.dynamodb.conditions import Key
6+
from boto3.dynamodb.types import Binary
67
from django.core.exceptions import ObjectDoesNotExist
78
from flag_engine.segments.constants import IN
89
from mypy_boto3_dynamodb.service_resource import Table
10+
from pytest_django.fixtures import SettingsWrapper
911
from pytest_mock import MockerFixture
1012
from rest_framework.exceptions import NotFound
1113

@@ -27,7 +29,7 @@
2729
from segments.models import Condition, Segment, SegmentRule
2830
from util.engine_models.identities.models import IdentityModel
2931
from util.mappers import (
30-
map_environment_to_environment_document,
32+
map_environment_to_compressed_environment_document,
3133
map_identity_to_identity_document,
3234
)
3335

@@ -313,12 +315,6 @@ def test_get_segment_ids_returns_correct_segment_ids( # type: ignore[no-untyped
313315

314316
identity_document = map_identity_to_identity_document(identity)
315317
identity_uuid = identity_document["identity_uuid"]
316-
environment_document = map_environment_to_environment_document(environment)
317-
318-
mocked_environment_wrapper = mocker.patch(
319-
"environments.dynamodb.wrappers.identity_wrapper.DynamoEnvironmentWrapper"
320-
)
321-
mocked_environment_wrapper.return_value.get_item.return_value = environment_document
322318

323319
dynamo_identity_wrapper = DynamoIdentityWrapper()
324320
mocked_get_item_from_uuid = mocker.patch.object(
@@ -331,9 +327,6 @@ def test_get_segment_ids_returns_correct_segment_ids( # type: ignore[no-untyped
331327
# Then
332328
assert segment_ids == [identity_matching_segment.id]
333329
mocked_get_item_from_uuid.assert_called_with(identity_uuid)
334-
mocked_environment_wrapper.return_value.get_item.assert_called_with(
335-
environment.api_key
336-
)
337330

338331

339332
def test_get_segment_ids_with_segment_feature_overrides(
@@ -388,12 +381,6 @@ def test_get_segment_ids_with_segment_feature_overrides(
388381

389382
identity_document = map_identity_to_identity_document(identity)
390383
identity_uuid = identity_document["identity_uuid"]
391-
environment_document = map_environment_to_environment_document(environment)
392-
393-
mocked_environment_wrapper = mocker.patch(
394-
"environments.dynamodb.wrappers.identity_wrapper.DynamoEnvironmentWrapper"
395-
)
396-
mocked_environment_wrapper.return_value.get_item.return_value = environment_document
397384

398385
dynamo_identity_wrapper = DynamoIdentityWrapper()
399386
mocker.patch.object(
@@ -430,12 +417,6 @@ def test_get_segment_ids_returns_segment_using_in_operator_for_integer_traits(
430417

431418
identity_document = map_identity_to_identity_document(identity)
432419
identity_uuid = identity_document["identity_uuid"]
433-
environment_document = map_environment_to_environment_document(environment)
434-
435-
mocked_environment_wrapper = mocker.patch(
436-
"environments.dynamodb.wrappers.identity_wrapper.DynamoEnvironmentWrapper"
437-
)
438-
mocked_environment_wrapper.return_value.get_item.return_value = environment_document
439420

440421
dynamo_identity_wrapper = DynamoIdentityWrapper()
441422
mocker.patch.object(
@@ -495,12 +476,6 @@ def test_get_segment_ids_with_identity_model(identity, environment, mocker): #
495476
# Given
496477
identity_document = map_identity_to_identity_document(identity)
497478
identity_model = IdentityModel.parse_obj(identity_document)
498-
environment_document = map_environment_to_environment_document(environment)
499-
500-
mocked_environment_wrapper = mocker.patch(
501-
"environments.dynamodb.wrappers.identity_wrapper.DynamoEnvironmentWrapper"
502-
)
503-
mocked_environment_wrapper.return_value.get_item.return_value = environment_document
504479

505480
dynamo_identity_wrapper = DynamoIdentityWrapper()
506481
mocker.patch.object(
@@ -514,6 +489,47 @@ def test_get_segment_ids_with_identity_model(identity, environment, mocker): #
514489
assert segment_ids == []
515490

516491

492+
def test_get_segment_ids__compressed_environment_in_dynamo__returns_correct_segment_ids(
493+
identity: "Identity",
494+
identity_matching_segment: "Segment",
495+
dynamodb_identity_wrapper: DynamoIdentityWrapper,
496+
flagsmith_identities_table: Table,
497+
flagsmith_environment_table: Table,
498+
settings: "SettingsWrapper",
499+
) -> None:
500+
"""Regression test for https://github.com/Flagsmith/flagsmith/issues/6912
501+
502+
Previously, get_segment_ids read the environment document from DynamoDB
503+
and failed with a ValidationError when the document contained compressed
504+
(gzipped Binary) `project` and `feature_states` fields.
505+
"""
506+
# Given - identity written to DynamoDB
507+
identity_document = map_identity_to_identity_document(identity)
508+
flagsmith_identities_table.put_item(Item=identity_document)
509+
identity_uuid = str(identity_document["identity_uuid"])
510+
511+
# And - a compressed environment document in DynamoDB
512+
settings.ENVIRONMENTS_TABLE_NAME_DYNAMO = flagsmith_environment_table.name
513+
compressed_result = map_environment_to_compressed_environment_document(
514+
identity.environment,
515+
)
516+
flagsmith_environment_table.put_item(Item=compressed_result.document)
517+
518+
# Verify the document actually has compressed Binary fields
519+
stored = flagsmith_environment_table.get_item(
520+
Key={"api_key": identity.environment.api_key},
521+
)["Item"]
522+
assert stored.get("compressed") is True
523+
assert isinstance(stored["project"], Binary)
524+
assert isinstance(stored["feature_states"], Binary)
525+
526+
# When
527+
segment_ids = dynamodb_identity_wrapper.get_segment_ids(identity_uuid)
528+
529+
# Then
530+
assert segment_ids == [identity_matching_segment.id]
531+
532+
517533
def test_identity_wrapper__iter_all_items_paginated__returns_expected(
518534
identity: "Identity",
519535
mocker: "MockerFixture",
@@ -643,41 +659,6 @@ def test_identity_wrapper__iter_all_items_paginated__capacity_budget_set__raises
643659
)
644660

645661

646-
def test_get_segment_ids__called_multiple_times__reuses_environment_wrapper(
647-
project: "Project",
648-
environment: "Environment",
649-
identity: "Identity",
650-
mocker: "MockerFixture",
651-
) -> None:
652-
# Given
653-
identity_document = map_identity_to_identity_document(identity)
654-
environment_document = map_environment_to_environment_document(environment)
655-
656-
mocked_environment_wrapper_class = mocker.patch(
657-
"environments.dynamodb.wrappers.identity_wrapper.DynamoEnvironmentWrapper"
658-
)
659-
mocked_environment_wrapper_class.return_value.get_item.return_value = (
660-
environment_document
661-
)
662-
663-
dynamo_identity_wrapper = DynamoIdentityWrapper()
664-
mocker.patch.object(
665-
dynamo_identity_wrapper,
666-
"get_item_from_uuid",
667-
return_value=identity_document,
668-
)
669-
identity_uuid = str(identity_document["identity_uuid"])
670-
671-
# When
672-
dynamo_identity_wrapper.get_segment_ids(identity_uuid)
673-
dynamo_identity_wrapper.get_segment_ids(identity_uuid)
674-
dynamo_identity_wrapper.get_segment_ids(identity_uuid)
675-
676-
# Then - DynamoEnvironmentWrapper should be instantiated once
677-
# (during DynamoIdentityWrapper.__init__), not once per get_segment_ids call.
678-
assert mocked_environment_wrapper_class.call_count == 1
679-
680-
681662
def test_delete_all_identities__deletes_all_identities_documents_from_dynamodb(
682663
flagsmith_identities_table: Table,
683664
dynamodb_identity_wrapper: DynamoIdentityWrapper,

api/util/engine_models/context/mappers.py

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
"""
77

88
import typing
9-
from typing import Protocol
109

1110
from flag_engine.context.types import (
1211
EvaluationContext,
@@ -23,14 +22,12 @@
2322
from util.engine_models.identities.traits.models import TraitModel
2423
from util.engine_models.segments.models import SegmentModel, SegmentRuleModel
2524

26-
27-
class EnvironmentProtocol(Protocol):
28-
api_key: str
29-
name: str | None
25+
if typing.TYPE_CHECKING:
26+
from environments.models import Environment
3027

3128

3229
def map_environment_identity_to_context(
33-
environment: EnvironmentProtocol,
30+
environment: "Environment",
3431
identity: IdentityModel,
3532
override_traits: typing.Optional[typing.List[TraitModel]],
3633
) -> EvaluationContext:
@@ -40,8 +37,7 @@ def map_environment_identity_to_context(
4037
Vendored from flagsmith-flag-engine's fix/missing-export branch and adapted
4138
to return v10's EvaluationContext TypedDict.
4239
43-
:param environment: Any object with `api_key` and `name` attributes
44-
(e.g. Django Environment or Pydantic EnvironmentModel).
40+
:param environment: An Environment object.
4541
:param identity: The identity model object (Pydantic IdentityModel).
4642
:param override_traits: A list of TraitModel objects, to be used in place of
4743
`identity.identity_traits` if provided.
@@ -184,19 +180,3 @@ def is_context_in_segment(
184180

185181
segment_context = map_segment_to_segment_context(segment)
186182
return v10_is_context_in_segment(context, segment_context)
187-
188-
189-
def get_context_segments(
190-
context: EvaluationContext,
191-
segments: typing.List[SegmentModel],
192-
) -> typing.List[SegmentModel]:
193-
"""
194-
Get the list of segments that match a given evaluation context.
195-
196-
This is a compatibility function for code that expects the old API.
197-
198-
:param context: The EvaluationContext (TypedDict).
199-
:param segments: List of SegmentModel objects to check.
200-
:return: List of matching SegmentModel objects.
201-
"""
202-
return [segment for segment in segments if is_context_in_segment(context, segment)]

0 commit comments

Comments
 (0)