From c7e20ac34fe745da9946d475a849e8ab6a2202b0 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Fri, 18 Sep 2026 15:57:20 +0100 Subject: [PATCH] feat(api): Add command to remove identity overrides that no longer exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Identity overrides live in two places: `identity_features` on the identity document, and one document per (feature, identity) in `environments_v2`. A write landing between another request's read and its write is invisible to that request's changeset, so the `environments_v2` document survives the identity being deleted. Once the identity is gone, or its uuid has changed, nothing can reach the stale document — it is keyed on an identity uuid that no longer resolves. `delete_orphaned_identity_overrides` reconciles an environment against its identity documents, which are the source of truth for both remote evaluation and the identity page. Deletes are conditional on `identity_uuid` so an override recreated between being read and being deleted is left alone, rather than the repair carrying the same race as the bug. Co-Authored-By: Claude Opus 5 --- api/edge_api/identities/dataclasses.py | 26 ++ .../identities/edge_identity_service.py | 112 +++++++ .../delete_orphaned_identity_overrides.py | 94 ++++++ api/environments/dynamodb/constants.py | 1 + api/environments/dynamodb/wrappers/base.py | 20 +- .../dynamodb/wrappers/environment_wrapper.py | 32 +- .../dynamodb/wrappers/identity_wrapper.py | 34 ++- ...unit_delete_orphaned_identity_overrides.py | 288 ++++++++++++++++++ api/util/util.py | 13 + .../observability/_events-catalogue.md | 2 +- 10 files changed, 615 insertions(+), 7 deletions(-) create mode 100644 api/edge_api/identities/dataclasses.py create mode 100644 api/edge_api/management/commands/delete_orphaned_identity_overrides.py create mode 100644 api/tests/unit/edge_api/test_unit_delete_orphaned_identity_overrides.py diff --git a/api/edge_api/identities/dataclasses.py b/api/edge_api/identities/dataclasses.py new file mode 100644 index 000000000000..c08bd689b59c --- /dev/null +++ b/api/edge_api/identities/dataclasses.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from enum import Enum + + +class OrphanedIdentityOverrideReason(Enum): + """ + Why an `environments_v2` identity override no longer reflects its identity. + """ + + # No identity document exists for the identifier any more. + IDENTITY_DELETED = "identity_deleted" + # The identifier exists, but as a different identity than the one the + # override was written against, so the override can never be reached. + IDENTITY_UUID_CHANGED = "identity_uuid_changed" + # The identity is the same one, but no longer overrides this feature. + OVERRIDE_REMOVED = "override_removed" + + +@dataclass(frozen=True) +class OrphanedIdentityOverride: + document_key: str + identifier: str + identity_uuid: str + feature_id: int + feature_name: str + reason: OrphanedIdentityOverrideReason diff --git a/api/edge_api/identities/edge_identity_service.py b/api/edge_api/identities/edge_identity_service.py index 5fda5c4f4d87..58f61fccfe0e 100644 --- a/api/edge_api/identities/edge_identity_service.py +++ b/api/edge_api/identities/edge_identity_service.py @@ -1,10 +1,24 @@ +import typing +from collections import defaultdict +from typing import Any, Generator + from django.core.exceptions import ObjectDoesNotExist +from edge_api.identities.dataclasses import ( + OrphanedIdentityOverride, + OrphanedIdentityOverrideReason, +) from edge_api.identities.models import EdgeIdentity from environments.dynamodb import DynamoEnvironmentV2Wrapper +from environments.dynamodb.constants import DYNAMODB_MAX_BATCH_GET_ITEM_COUNT from environments.dynamodb.types import ( IdentityOverrideV2, ) +from util.engine_models.identities.models import IdentityModel +from util.util import iter_chunks + +if typing.TYPE_CHECKING: + from environments.models import Environment ddb_environment_v2_wrapper = DynamoEnvironmentV2Wrapper() @@ -41,6 +55,104 @@ def get_edge_identity_override_keys(environment_id: int) -> list[str]: return [item["document_key"] for item in override_items] +def iter_orphaned_identity_overrides( + environment: "Environment", +) -> Generator[OrphanedIdentityOverride, None, None]: + """ + Yield the environment's identity overrides that its identities no longer have. + + The identity document is the source of truth: it is what remote evaluation + and the identity page read. An `environments_v2` override the identity does + not have is therefore stale, and is still served to local evaluation SDKs + and listed on the feature's identity overrides tab. + """ + override_documents = ( + ddb_environment_v2_wrapper.get_identity_overrides_by_environment_id( + environment_id=environment.id, + projection_expression_attributes=[ + "document_key", + "identifier", + "identity_uuid", + "feature_state.feature", + ], + ) + ) + documents_by_identifier: dict[str, list[dict[str, Any]]] = defaultdict(list) + for override_document in override_documents: + documents_by_identifier[override_document["identifier"]].append( + override_document + ) + + for identifiers in iter_chunks( + documents_by_identifier, + chunk_size=DYNAMODB_MAX_BATCH_GET_ITEM_COUNT, + ): + identifiers_by_composite_key = { + IdentityModel.generate_composite_key( + environment.api_key, identifier + ): identifier + for identifier in identifiers + } + identity_documents = { + identity_document["composite_key"]: identity_document + for identity_document in EdgeIdentity.dynamo_wrapper.iter_items_by_composite_keys( + identifiers_by_composite_key, + projection_expression="composite_key,identity_uuid,identity_features", + ) + } + for composite_key, identifier in identifiers_by_composite_key.items(): + identity_document = identity_documents.get(composite_key) + for override_document in documents_by_identifier[identifier]: + feature = override_document["feature_state"]["feature"] + if reason := _get_orphaned_identity_override_reason( + override_document=override_document, + identity_document=identity_document, + ): + yield OrphanedIdentityOverride( + document_key=override_document["document_key"], + identifier=identifier, + identity_uuid=override_document["identity_uuid"], + feature_id=int(feature["id"]), + feature_name=feature["name"], + reason=reason, + ) + + +def delete_orphaned_identity_override( + environment_id: int, + orphaned_identity_override: OrphanedIdentityOverride, +) -> bool: + """ + Delete a stale identity override, unless it has been rewritten since it was read. + + :return: whether the override was deleted. + """ + return ddb_environment_v2_wrapper.delete_identity_override_if_unchanged( + environment_id=environment_id, + document_key=orphaned_identity_override.document_key, + identity_uuid=orphaned_identity_override.identity_uuid, + ) + + +def _get_orphaned_identity_override_reason( + override_document: dict[str, Any], + identity_document: dict[str, Any] | None, +) -> OrphanedIdentityOverrideReason | None: + if identity_document is None: + return OrphanedIdentityOverrideReason.IDENTITY_DELETED + if identity_document["identity_uuid"] != override_document["identity_uuid"]: + return OrphanedIdentityOverrideReason.IDENTITY_UUID_CHANGED + overridden_feature_ids = { + int(feature_state["feature"]["id"]) + for feature_state in identity_document.get("identity_features") or [] + } + if int(override_document["feature_state"]["feature"]["id"]) not in ( + overridden_feature_ids + ): + return OrphanedIdentityOverrideReason.OVERRIDE_REMOVED + return None + + def get_overridden_feature_ids_for_edge_identity(identity_uuid: str) -> set[int]: try: identity_document = EdgeIdentity.dynamo_wrapper.get_item_from_uuid( diff --git a/api/edge_api/management/commands/delete_orphaned_identity_overrides.py b/api/edge_api/management/commands/delete_orphaned_identity_overrides.py new file mode 100644 index 000000000000..652e67cf3a60 --- /dev/null +++ b/api/edge_api/management/commands/delete_orphaned_identity_overrides.py @@ -0,0 +1,94 @@ +from argparse import ArgumentParser +from collections import Counter +from typing import Any + +import structlog +from django.core.management import BaseCommand, CommandError + +from edge_api.identities.edge_identity_service import ( + delete_orphaned_identity_override, + iter_orphaned_identity_overrides, +) +from environments.models import Environment + +logger: structlog.BoundLogger = structlog.get_logger("edge_identities") + + +class Command(BaseCommand): + help = ( + "Delete identity overrides in the environments_v2 table that the identity " + "they belong to no longer has, so that local evaluation and the dashboard " + "agree with the identity document." + ) + + def add_arguments(self, parser: ArgumentParser) -> None: + parser.add_argument( + "--environment-id", + dest="environment_id", + type=int, + required=True, + help="ID of the environment to reconcile", + ) + parser.add_argument( + "--dry-run", + dest="dry_run", + action="store_true", + help="Report what would be deleted without deleting anything", + ) + + def handle( + self, + *args: Any, + environment_id: int, + dry_run: bool, + **options: Any, + ) -> None: + try: + environment = Environment.objects.get(id=environment_id) + except Environment.DoesNotExist: + raise CommandError(f"Environment {environment_id} does not exist") + + log: structlog.BoundLogger = logger.bind( # type: ignore[assignment] + environment__id=environment.id, + dry_run=dry_run, + ) + log.info("identity_override.reconciliation_started") + + reasons: Counter[str] = Counter() + deleted_count = skipped_count = 0 + + for orphaned_identity_override in iter_orphaned_identity_overrides(environment): + reasons[orphaned_identity_override.reason.value] += 1 + self.stdout.write( + "\t".join( + [ + orphaned_identity_override.reason.value, + orphaned_identity_override.feature_name, + orphaned_identity_override.identifier, + orphaned_identity_override.document_key, + ] + ) + ) + if dry_run: + continue + if delete_orphaned_identity_override( + environment_id=environment.id, + orphaned_identity_override=orphaned_identity_override, + ): + deleted_count += 1 + else: + # The override was rewritten between being read and being + # deleted, so it is no longer the stale document we identified. + skipped_count += 1 + log.info( + "identity_override.delete_skipped", + document_key=orphaned_identity_override.document_key, + ) + + log.info( + "identity_override.reconciliation_finished", + orphaned__count=sum(reasons.values()), + deleted__count=deleted_count, + skipped__count=skipped_count, + reasons=dict(reasons), + ) diff --git a/api/environments/dynamodb/constants.py b/api/environments/dynamodb/constants.py index fc46a8154293..a9a23f8ccfbf 100644 --- a/api/environments/dynamodb/constants.py +++ b/api/environments/dynamodb/constants.py @@ -7,6 +7,7 @@ ENVIRONMENTS_V2_SECONDARY_INDEX_PARTITION_KEY = "environment_api_key" DYNAMODB_MAX_BATCH_WRITE_ITEM_COUNT = 25 +DYNAMODB_MAX_BATCH_GET_ITEM_COUNT = 100 IDENTITIES_PAGINATION_LIMIT = 1000 SYSTEM_TRAIT_WRITE_MAX_ATTEMPTS = 3 diff --git a/api/environments/dynamodb/wrappers/base.py b/api/environments/dynamodb/wrappers/base.py index 499958d1712c..2f9d8a57d70e 100644 --- a/api/environments/dynamodb/wrappers/base.py +++ b/api/environments/dynamodb/wrappers/base.py @@ -8,7 +8,7 @@ from sentry_sdk import set_context # TODO @kgustyr: Replace with OTel if typing.TYPE_CHECKING: - from mypy_boto3_dynamodb.service_resource import Table + from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table from mypy_boto3_dynamodb.type_defs import ( QueryOutputTableTypeDef, ScanOutputTableTypeDef, @@ -27,6 +27,7 @@ class BaseDynamoWrapper: def __init__(self) -> None: self._table: typing.Optional["Table"] = None + self._resource: typing.Optional["DynamoDBServiceResource"] = None @property def table(self) -> typing.Optional["Table"]: @@ -34,14 +35,25 @@ def table(self) -> typing.Optional["Table"]: self._table = self.get_table() return self._table + @property + def resource(self) -> "DynamoDBServiceResource": + """ + The service resource behind `table`, for operations that span items — + e.g. `batch_get_item`, which is not available on a `Table`. + """ + if not self._resource: + self._resource = self.get_resource() + return self._resource + def get_table_name(self) -> str: return self.table_name + def get_resource(self) -> "DynamoDBServiceResource": + return boto3.resource("dynamodb", config=Config(tcp_keepalive=True)) + def get_table(self) -> "Table | None": if table_name := self.get_table_name(): - return boto3.resource("dynamodb", config=Config(tcp_keepalive=True)).Table( - table_name - ) + return self.resource.Table(table_name) return None @property diff --git a/api/environments/dynamodb/wrappers/environment_wrapper.py b/api/environments/dynamodb/wrappers/environment_wrapper.py index 0ac4ee81fc08..b52fc2575e10 100644 --- a/api/environments/dynamodb/wrappers/environment_wrapper.py +++ b/api/environments/dynamodb/wrappers/environment_wrapper.py @@ -3,7 +3,8 @@ from typing import Any, Iterable import structlog -from boto3.dynamodb.conditions import ConditionBase, Key +from boto3.dynamodb.conditions import Attr, ConditionBase, Key +from botocore.exceptions import ClientError from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.db.models import prefetch_related_objects @@ -216,6 +217,35 @@ def delete_environment(self, environment_id: int): # type: ignore[no-untyped-de }, ) + def delete_identity_override_if_unchanged( + self, + environment_id: int, + document_key: str, + identity_uuid: str, + ) -> bool: + """ + Delete a single identity override document, unless it has been rewritten. + + The condition guards against removing an override that was recreated + between a caller deciding it was stale and this delete landing — without + it, a repair would carry the same race as the bug it repairs. + + :return: whether the document was deleted. + """ + try: + self.table.delete_item( # type: ignore[union-attr] + Key={ + ENVIRONMENTS_V2_PARTITION_KEY: str(environment_id), + ENVIRONMENTS_V2_SORT_KEY: document_key, + }, + ConditionExpression=Attr("identity_uuid").eq(identity_uuid), + ) + except ClientError as exc: + if exc.response["Error"]["Code"] == "ConditionalCheckFailedException": + return False + raise + return True + def delete_identity_overrides(self, environment_id: int, feature_id: int) -> None: filter_expression = self.get_identity_overrides_key_condition_expression( environment_id=environment_id, feature_id=feature_id diff --git a/api/environments/dynamodb/wrappers/identity_wrapper.py b/api/environments/dynamodb/wrappers/identity_wrapper.py index b649192b6d39..e0384cd487f7 100644 --- a/api/environments/dynamodb/wrappers/identity_wrapper.py +++ b/api/environments/dynamodb/wrappers/identity_wrapper.py @@ -2,7 +2,7 @@ import typing from contextlib import suppress from decimal import Decimal -from typing import Iterable +from typing import Generator, Iterable from boto3.dynamodb.conditions import Attr, Key from botocore.exceptions import ClientError @@ -12,6 +12,7 @@ from edge_api.identities.search import EdgeIdentitySearchData from environments.dynamodb.constants import ( + DYNAMODB_MAX_BATCH_GET_ITEM_COUNT, IDENTITIES_PAGINATION_LIMIT, SYSTEM_TRAIT_WRITE_MAX_ATTEMPTS, ) @@ -31,12 +32,14 @@ map_engine_identity_to_identity_document, map_identity_to_identity_document, ) +from util.util import iter_chunks from .base import BaseDynamoWrapper if typing.TYPE_CHECKING: from boto3.dynamodb.conditions import ConditionBase from mypy_boto3_dynamodb.type_defs import ( + KeysAndAttributesServiceResourceTypeDef, QueryInputTableQueryTypeDef, QueryOutputTableTypeDef, TableAttributeValueTypeDef, @@ -87,6 +90,35 @@ def write_identities(self, identities: Iterable["Identity"]): # type: ignore[no def get_item(self, composite_key: str) -> typing.Optional[dict]: # type: ignore[type-arg] return self.table.get_item(Key={"composite_key": composite_key}).get("Item") # type: ignore[union-attr] + def iter_items_by_composite_keys( + self, + composite_keys: Iterable[str], + projection_expression: str | None = None, + ) -> Generator[dict[str, typing.Any], None, None]: + """ + Read identity documents in batches, yielding only those that exist. + + Keys with no document are silently absent from the results — callers that + care about the difference should compare against the keys they asked for. + """ + table_name = self.get_table_name() + assert table_name is not None + for chunk in iter_chunks( + composite_keys, chunk_size=DYNAMODB_MAX_BATCH_GET_ITEM_COUNT + ): + keys_and_attributes: "KeysAndAttributesServiceResourceTypeDef" = { + "Keys": [{"composite_key": composite_key} for composite_key in chunk] + } + if projection_expression: + keys_and_attributes["ProjectionExpression"] = projection_expression + request_items = {table_name: keys_and_attributes} + while request_items: + response = self.resource.batch_get_item(RequestItems=request_items) + yield from response["Responses"].get(table_name, []) + # DynamoDB returns keys it declined to read — e.g. when the + # response would exceed 16MB — and expects them to be retried. + request_items = response.get("UnprocessedKeys") # type: ignore[assignment] + def set_system_trait( self, *, diff --git a/api/tests/unit/edge_api/test_unit_delete_orphaned_identity_overrides.py b/api/tests/unit/edge_api/test_unit_delete_orphaned_identity_overrides.py new file mode 100644 index 000000000000..c66b5aa1b12e --- /dev/null +++ b/api/tests/unit/edge_api/test_unit_delete_orphaned_identity_overrides.py @@ -0,0 +1,288 @@ +import uuid + +import pytest +from django.core.management import CommandError, call_command +from mypy_boto3_dynamodb.service_resource import Table +from pytest_mock import MockerFixture +from pytest_structlog import StructuredLogCapture + +from environments.dynamodb import ( + DynamoEnvironmentV2Wrapper, + DynamoIdentityWrapper, +) +from environments.dynamodb.types import IdentityOverrideV2 +from environments.dynamodb.utils import ( + get_environments_v2_identity_override_document_key, +) +from environments.models import Environment +from features.models import Feature, FeatureState +from projects.models import Project +from util.engine_models.identities.models import IdentityModel +from util.mappers import ( + map_engine_identity_to_identity_document, + map_feature_state_to_engine, + map_identity_override_to_identity_override_document, +) + +COMMAND = "delete_orphaned_identity_overrides" + + +def _put_identity_override( + table: Table, + environment: Environment, + feature: Feature, + identifier: str, + identity_uuid: str, +) -> str: + document = map_identity_override_to_identity_override_document( + IdentityOverrideV2( + environment_id=str(environment.id), + environment_api_key=environment.api_key, + document_key=get_environments_v2_identity_override_document_key( + feature_id=feature.id, + identity_uuid=identity_uuid, + ), + identifier=identifier, + identity_uuid=identity_uuid, + feature_state=map_feature_state_to_engine( + FeatureState( + feature=feature, + enabled=True, + environment=environment, + ), + ), + ) + ) + table.put_item(Item=document) + return document["document_key"] # type: ignore[return-value] + + +def _put_identity( + table: Table, + environment: Environment, + feature: Feature | None, + identifier: str, + identity_uuid: str, +) -> None: + identity = IdentityModel( + identifier=identifier, + environment_api_key=environment.api_key, + identity_uuid=identity_uuid, # type: ignore[arg-type] + ) + if feature: + identity.identity_features.append( + map_feature_state_to_engine( + FeatureState( + feature=feature, + enabled=True, + environment=environment, + ), + ) + ) + table.put_item(Item=map_engine_identity_to_identity_document(identity)) + + +def _get_document_keys(table: Table, environment: Environment) -> set[str]: + return { + str(item["document_key"]) + for item in table.scan()["Items"] + if str(item["environment_id"]) == str(environment.id) + } + + +@pytest.fixture() +def orphaned_overrides( + flagsmith_environments_v2_table: Table, + flagsmith_identities_table: Table, + dynamodb_wrapper_v2: DynamoEnvironmentV2Wrapper, + dynamodb_identity_wrapper: DynamoIdentityWrapper, + dynamo_enabled_project: Project, + environment: Environment, + feature: Feature, +) -> dict[str, str]: + """ + Four identity overrides: one still valid, and one for each way of going stale. + """ + document_keys = {} + + valid_uuid = str(uuid.uuid4()) + document_keys["valid"] = _put_identity_override( + flagsmith_environments_v2_table, environment, feature, "valid", valid_uuid + ) + _put_identity(flagsmith_identities_table, environment, feature, "valid", valid_uuid) + + # The identity was deleted, so no document exists for the identifier. + document_keys["identity_deleted"] = _put_identity_override( + flagsmith_environments_v2_table, + environment, + feature, + "identity-deleted", + str(uuid.uuid4()), + ) + + # The identifier was recreated, so it resolves to a different identity. + document_keys["identity_uuid_changed"] = _put_identity_override( + flagsmith_environments_v2_table, + environment, + feature, + "uuid-changed", + str(uuid.uuid4()), + ) + _put_identity( + flagsmith_identities_table, + environment, + feature, + "uuid-changed", + str(uuid.uuid4()), + ) + + # The same identity no longer overrides the feature. + override_removed_uuid = str(uuid.uuid4()) + document_keys["override_removed"] = _put_identity_override( + flagsmith_environments_v2_table, + environment, + feature, + "override-removed", + override_removed_uuid, + ) + _put_identity( + flagsmith_identities_table, + environment, + None, + "override-removed", + override_removed_uuid, + ) + + return document_keys + + +def test_delete_orphaned_identity_overrides__stale_overrides__deletes_only_stale( + flagsmith_environments_v2_table: Table, + environment: Environment, + orphaned_overrides: dict[str, str], +) -> None: + # Given + assert len(_get_document_keys(flagsmith_environments_v2_table, environment)) == 4 + + # When + call_command(COMMAND, environment_id=environment.id) + + # Then + assert _get_document_keys(flagsmith_environments_v2_table, environment) == { + orphaned_overrides["valid"] + } + + +def test_delete_orphaned_identity_overrides__dry_run__deletes_nothing( + flagsmith_environments_v2_table: Table, + environment: Environment, + orphaned_overrides: dict[str, str], + capsys: pytest.CaptureFixture[str], +) -> None: + # Given / When + call_command(COMMAND, environment_id=environment.id, dry_run=True) + + # Then + assert _get_document_keys(flagsmith_environments_v2_table, environment) == set( + orphaned_overrides.values() + ) + reported = capsys.readouterr().out + assert "identity_deleted" in reported + assert "identity_uuid_changed" in reported + assert "override_removed" in reported + assert orphaned_overrides["valid"] not in reported + + +def test_delete_orphaned_identity_overrides__override_recreated__leaves_it_alone( + flagsmith_environments_v2_table: Table, + environment: Environment, + orphaned_overrides: dict[str, str], + mocker: MockerFixture, + log: StructuredLogCapture, +) -> None: + # Given - every delete loses the condition, as it would if the override had + # been recreated between being read and being deleted + mocker.patch( + "edge_api.management.commands.delete_orphaned_identity_overrides" + ".delete_orphaned_identity_override", + return_value=False, + ) + + # When + call_command(COMMAND, environment_id=environment.id) + + # Then + assert _get_document_keys(flagsmith_environments_v2_table, environment) == set( + orphaned_overrides.values() + ) + assert log.has("identity_override.delete_skipped", level="info") + assert log.events[-1] == { + "level": "info", + "event": "identity_override.reconciliation_finished", + "environment__id": environment.id, + "dry_run": False, + "orphaned__count": 3, + "deleted__count": 0, + "skipped__count": 3, + "reasons": { + "identity_deleted": 1, + "identity_uuid_changed": 1, + "override_removed": 1, + }, + } + + +def test_delete_orphaned_identity_overrides__no_such_environment__raises_expected( + db: None, +) -> None: + # Given + environment_id = 99999 + + # When / Then + with pytest.raises( + CommandError, match=f"Environment {environment_id} does not exist" + ): + call_command(COMMAND, environment_id=environment_id) + + +def test_delete_orphaned_identity_overrides__override_rewritten__skips_deletion( + flagsmith_environments_v2_table: Table, + flagsmith_identities_table: Table, + dynamodb_wrapper_v2: DynamoEnvironmentV2Wrapper, + dynamodb_identity_wrapper: DynamoIdentityWrapper, + dynamo_enabled_project: Project, + environment: Environment, + feature: Feature, +) -> None: + """ + An override recreated between being read and being deleted must survive — + otherwise the repair carries the same race as the bug it repairs. + """ + # Given + identity_uuid = str(uuid.uuid4()) + document_key = _put_identity_override( + flagsmith_environments_v2_table, + environment, + feature, + "rewritten", + identity_uuid, + ) + + # When - the document is rewritten against a different identity, so the + # delete's condition no longer holds + flagsmith_environments_v2_table.update_item( + Key={"environment_id": str(environment.id), "document_key": document_key}, + UpdateExpression="SET identity_uuid = :uuid", + ExpressionAttributeValues={":uuid": str(uuid.uuid4())}, + ) + deleted = dynamodb_wrapper_v2.delete_identity_override_if_unchanged( + environment_id=environment.id, + document_key=document_key, + identity_uuid=identity_uuid, + ) + + # Then + assert deleted is False + assert _get_document_keys(flagsmith_environments_v2_table, environment) == { + document_key + } diff --git a/api/util/util.py b/api/util/util.py index 157fb11e3170..a00812cd2437 100644 --- a/api/util/util.py +++ b/api/util/util.py @@ -20,6 +20,19 @@ def decorator(*args, **kwargs): # type: ignore[no-untyped-def] return decorator +def iter_chunks( + iterable: Iterable[T], + *, + chunk_size: int, +) -> Generator[list[T], None, None]: + """ + Iterate over `iterable`, yielding lists of at most `chunk_size` items. + """ + iterator = iter(iterable) + while chunk := list(islice(iterator, chunk_size)): + yield chunk + + def iter_paired_chunks( iterable_1: Iterable[T], iterable_2: Iterable[T], diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 3826b5823873..51d659c3c4bc 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -186,7 +186,7 @@ Attributes: ### `dynamodb.environment_document_compressed` Logged at `info` from: - - `api/environments/dynamodb/wrappers/environment_wrapper.py:93` + - `api/environments/dynamodb/wrappers/environment_wrapper.py:94` Attributes: - `environment_api_key`