-
Notifications
You must be signed in to change notification settings - Fork 571
feat(api): Add command to remove identity overrides that no longer exist #8557
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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), | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Revalidate the exact override instance before deletion.
Based on learnings: stale cleanup must revalidate both the resource identity and the exact observed record instance before deletion. 📍 Affects 2 files
Source: Learnings |
||
| ) | ||
| 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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
🔎 Supported by static analysis
🏁 Script executed:
Repository: Flagsmith/flagsmith
Length of output: 33319
🏁 Script executed:
Repository: Flagsmith/flagsmith
Length of output: 19694
🏁 Script executed:
Repository: Flagsmith/flagsmith
Length of output: 10743
🏁 Script executed:
Repository: Flagsmith/flagsmith
Length of output: 735
🏁 Script executed:
Repository: Flagsmith/flagsmith
Length of output: 14462
🏁 Script executed:
Repository: Flagsmith/flagsmith
Length of output: 9237
🏁 Script executed:
Repository: Flagsmith/flagsmith
Length of output: 2276
Use a thread-local or request-local DynamoDB resource.
BaseDynamoWrapper.resourcecaches theboto3.resource("dynamodb", ...)result, andtablecaches a table derived from it.EdgeIdentity.dynamo_wrapperis a class-level instance used by request views. Each Gunicorn worker therefore shares these objects across its request threads; production ECS configuration uses 15 threads per worker. Boto3 resources are not thread-safe, so concurrent requests can cause unsafe access and request failures.Use a separate wrapper, resource, and table per thread or request. If you replace the resource with a client, refactor the table operations to use the client API.