Repository: PostHog/posthog
Pull request #29328: feat(flags): finer grained concurrency control
Author: login: haacked
Created: 2025-02-27T22:51:00Z | Merged: 2025-03-06T20:52:10Z

## Problem

https://github.com/user-attachments/assets/7b648607-b44d-414c-bbec-31293f92c476

Follow up to: #29268

#29268 introduces concurrency controls to feature flag editing. If another user saves a change, any change, while you're editing, we block your save from stomping over their changes.

However, the implementation to do that was very broad. For example, if two people change different fields, it would block. It would block even if a user made no changes, but clicked the save button as we'd save it and increment the version (perhaps we should fix that too, but I leave it as an exercise for the reader).

## Changes

Now, when we detect a possible conflict, we look deeper and compare what fields have changed. If the current save attempts to overwrite any fields that have changed while they were editing, we block the save. If there are no conflicts, for example, the current user changes the description while another user changed the filters, then we allow the save to go through.

CAVEAT: This PR doesn't do anything fancy in regards to filters. So if one user edits the variants, while another edits the conditions, we will block because they get saved to the same filters JSON blob. I think this is satisfactory for now.

## Does this work well for both Cloud and self-hosted?

Yes

## How did you test this code?

Manual testing and also unit tests. See the demo video.

### Test Plan

- [x] Test a scheduled change. Make sure it does not fail. It is allowed to stomp on any other changes.
- [x] Edit the same feature flag in two browsers: Change description in one, a release condition in the other. expected: No conflict.
- [x] Edit the same feature flag in two browsers: Change description in one, and description in the other. expected: conflict.



Diff:
diff --git a/frontend/__snapshots__/scenes-app-insights--funnel-historical-trends-edit--dark--webkit.png b/frontend/__snapshots__/scenes-app-insights--funnel-historical-trends-edit--dark--webkit.png
index 09b450723a11..998d485e2c7a 100644
Binary files a/frontend/__snapshots__/scenes-app-insights--funnel-historical-trends-edit--dark--webkit.png and b/frontend/__snapshots__/scenes-app-insights--funnel-historical-trends-edit--dark--webkit.png differ
diff --git a/frontend/__snapshots__/scenes-app-insights--funnel-historical-trends-edit--light--webkit.png b/frontend/__snapshots__/scenes-app-insights--funnel-historical-trends-edit--light--webkit.png
index e2646eed532c..b1f72f5b5828 100644
Binary files a/frontend/__snapshots__/scenes-app-insights--funnel-historical-trends-edit--light--webkit.png and b/frontend/__snapshots__/scenes-app-insights--funnel-historical-trends-edit--light--webkit.png differ
diff --git a/frontend/__snapshots__/scenes-app-insights--stickiness--dark--webkit.png b/frontend/__snapshots__/scenes-app-insights--stickiness--dark--webkit.png
index f20f5d519ada..69a5875ba5e4 100644
Binary files a/frontend/__snapshots__/scenes-app-insights--stickiness--dark--webkit.png and b/frontend/__snapshots__/scenes-app-insights--stickiness--dark--webkit.png differ
diff --git a/frontend/__snapshots__/scenes-app-insights--stickiness--light--webkit.png b/frontend/__snapshots__/scenes-app-insights--stickiness--light--webkit.png
index cccfd1cd2933..9b0dbfa4f5ad 100644
Binary files a/frontend/__snapshots__/scenes-app-insights--stickiness--light--webkit.png and b/frontend/__snapshots__/scenes-app-insights--stickiness--light--webkit.png differ
diff --git a/frontend/src/scenes/experiments/experimentLogic.tsx b/frontend/src/scenes/experiments/experimentLogic.tsx
index 5efc2ab6dc16..7cb036077975 100644
--- a/frontend/src/scenes/experiments/experimentLogic.tsx
+++ b/frontend/src/scenes/experiments/experimentLogic.tsx
@@ -16,6 +16,7 @@ import {
     validateFeatureFlagKey,
     variantKeyToIndexFeatureFlagPayloads,
 } from 'scenes/feature-flags/featureFlagLogic'
+import { featureFlagLogic as sceneFeatureFlagLogic } from 'scenes/feature-flags/featureFlagLogic'
 import { featureFlagsLogic } from 'scenes/feature-flags/featureFlagsLogic'
 import { funnelDataLogic } from 'scenes/funnels/funnelDataLogic'
 import { insightDataLogic } from 'scenes/insights/insightDataLogic'
@@ -1342,6 +1343,15 @@ export const experimentLogic = kea<experimentLogicType>([
 
             actions.setSecondaryMetricResultsLoading(false)
         },
+        openReleaseConditionsModal: () => {
+            const numericFlagId = values.experiment.feature_flag?.id
+            if (numericFlagId) {
+                const logic = sceneFeatureFlagLogic.findMounted() || sceneFeatureFlagLogic({ id: numericFlagId })
+                if (logic) {
+                    logic.actions.loadFeatureFlag() // Access the loader through actions
+                }
+            }
+        },
     })),
     loaders(({ actions, props, values }) => ({
         experiment: {
diff --git a/frontend/src/scenes/feature-flags/featureFlagLogic.ts b/frontend/src/scenes/feature-flags/featureFlagLogic.ts
index f6febfbf8dd3..c02906de4f1e 100644
--- a/frontend/src/scenes/feature-flags/featureFlagLogic.ts
+++ b/frontend/src/scenes/feature-flags/featureFlagLogic.ts
@@ -241,6 +241,12 @@ export const getRecordingFilterForFlagVariant = (
     }
 }
 
+// This helper function removes the created_at, id, and created_by fields from a flag
+function cleanFlag(flag: Partial<FeatureFlagType>): Partial<FeatureFlagType> {
+    const { created_at, id, created_by, last_modified_by, ...cleanedFlag } = flag
+    return cleanedFlag
+}
+
 export const featureFlagLogic = kea<featureFlagLogicType>([
     path(['scenes', 'feature-flags', 'featureFlagLogic']),
     props({} as FeatureFlagLogicProps),
@@ -341,6 +347,18 @@ export const featureFlagLogic = kea<featureFlagLogicType>([
         },
     })),
     reducers({
+        originalFeatureFlag: [
+            null as FeatureFlagType | null,
+            {
+                loadFeatureFlagSuccess: (_, { featureFlag }) => {
+                    // Transform the original flag when it's first loaded
+                    // Apply the same transformations we'd use when sending it back
+                    return featureFlag
+                        ? (indexToVariantKeyFeatureFlagPayloads(cleanFlag(featureFlag)) as FeatureFlagType)
+                        : null
+                },
+            },
+        ],
         featureFlag: [
             { ...NEW_FLAG } as FeatureFlagType,
             {
@@ -594,8 +612,7 @@ export const featureFlagLogic = kea<featureFlagLogicType>([
             },
             saveFeatureFlag: async (updatedFlag: Partial<FeatureFlagType>) => {
                 // Destructure all fields we want to exclude or handle specially
-                const { created_at, id, ...flag } = updatedFlag
-
+                const flag = cleanFlag(updatedFlag)
                 const preparedFlag = indexToVariantKeyFeatureFlagPayloads(flag)
 
                 try {
@@ -631,7 +648,10 @@ export const featureFlagLogic = kea<featureFlagLogicType>([
 
                         savedFlag = await api.update(
                             `api/projects/${values.currentProjectId}/feature_flags/${updatedFlag.id}`,
-                            preparedFlag
+                            {
+                                ...preparedFlag,
+                                original_flag: values.originalFeatureFlag,
+                            }
                         )
                     }
 
@@ -644,7 +664,7 @@ export const featureFlagLogic = kea<featureFlagLogicType>([
                 }
             },
             saveSidebarExperimentFeatureFlag: async (updatedFlag: Partial<FeatureFlagType>) => {
-                const { created_at, id, ...flag } = updatedFlag
+                const flag = cleanFlag(updatedFlag)
 
                 const preparedFlag = indexToVariantKeyFeatureFlagPayloads(flag)
 
@@ -662,7 +682,10 @@ export const featureFlagLogic = kea<featureFlagLogicType>([
                     } else {
                         savedFlag = await api.update(
                             `api/projects/${values.currentProjectId}/feature_flags/${updatedFlag.id}`,
-                            preparedFlag
+                            {
+                                ...preparedFlag,
+                                original_flag: values.originalFeatureFlag,
+                            }
                         )
                     }
 
diff --git a/posthog/api/feature_flag.py b/posthog/api/feature_flag.py
index 1b53127c8ed6..7055f52cfc10 100644
--- a/posthog/api/feature_flag.py
+++ b/posthog/api/feature_flag.py
@@ -418,6 +418,10 @@ def create(self, validated_data: dict, *args: Any, **kwargs: Any) -> FeatureFlag
 
     def update(self, instance: FeatureFlag, validated_data: dict, *args: Any, **kwargs: Any) -> FeatureFlag:
         request = self.context["request"]
+        # This is a workaround to ensure update works when called from a scheduled task.
+        if request and not hasattr(request, "data"):
+            request.data = {}
+
         validated_data["last_modified_by"] = request.user
 
         if "deleted" in validated_data and validated_data["deleted"] is True and instance.features.count() > 0:
@@ -425,47 +429,55 @@ def update(self, instance: FeatureFlag, validated_data: dict, *args: Any, **kwar
                 "Cannot delete a feature flag that is in use with early access features. Please delete the early access feature before deleting the flag."
             )
 
+        # First apply all transformations to validated_data
+        validated_key = validated_data.get("key", None)
+        self._update_filters(validated_data)
+
+        if validated_data.get("has_encrypted_payloads", False):
+            if validated_data["filters"]["payloads"]["true"] == REDACTED_PAYLOAD_VALUE:
+                # Don't write the redacted payload to the db, keep the current value instead
+                validated_data["filters"]["payloads"]["true"] = instance.filters["payloads"]["true"]
+            else:
+                encrypt_flag_payloads(validated_data)
+
+        version = request.data.get("version", -1)
+
         with transaction.atomic():
             # select_for_update locks the database row so we ensure version updates are atomic
             locked_instance = FeatureFlag.objects.select_for_update().get(pk=instance.pk)
             locked_version = locked_instance.version or 0
 
-            version = validated_data.get("version", -1)
-
-            # If version is not provided, we don't check for conflicts. This is just in case there's a place
-            # that's using the feature flag that doesn't know about the version field yet.
-            if version != -1 and version != locked_version:
-                raise Conflict(
-                    f"The feature flag was updated by {locked_instance.last_modified_by.email if locked_instance.last_modified_by else 'another user'} since you started editing it. Please refresh and try again."
-                )
-
-            validated_data["version"] = locked_version + 1
-
-            validated_key = validated_data.get("key", None)
             if validated_key:
                 # Delete any soft deleted feature flags with the same key to prevent conflicts
                 FeatureFlag.objects.filter(
                     key=validated_key, team__project_id=instance.team.project_id, deleted=True
                 ).delete()
-            self._update_filters(validated_data)
-
-            if validated_data.get("has_encrypted_payloads", False):
-                if validated_data["filters"]["payloads"]["true"] == REDACTED_PAYLOAD_VALUE:
-                    # Don't write the redacted payload to the db, keep the current value instead
-                    validated_data["filters"]["payloads"]["true"] = instance.filters["payloads"]["true"]
-                else:
-                    encrypt_flag_payloads(validated_data)
-
-            analytics_dashboards = validated_data.pop("analytics_dashboards", None)
 
-            if analytics_dashboards is not None:
-                for dashboard in analytics_dashboards:
-                    FeatureFlagDashboards.objects.get_or_create(dashboard=dashboard, feature_flag=instance)
+            # NOW check for conflicts after all transformations
+            if version != -1 and version != locked_version:
+                conflicting_changes = self._get_conflicting_changes(
+                    locked_instance, validated_data, request.data.get("original_flag", {})
+                )
+                if len(conflicting_changes) > 0:
+                    raise Conflict(
+                        f"The feature flag was updated by {locked_instance.last_modified_by.email if locked_instance.last_modified_by else 'another user'} since you started editing it. Please refresh and try again."
+                    )
 
+            # Continue with the update
+            validated_data["version"] = locked_version + 1
             old_key = instance.key
 
             instance = super().update(instance, validated_data)
 
+        # Continue with the update outside of the transaction. This is an intentional choice
+        # to avoid deadlocks. Not to mention, before making the concurrency changes, these
+        # updates were already occurring outside of a transaction.
+        analytics_dashboards = validated_data.pop("analytics_dashboards", None)
+
+        if analytics_dashboards is not None:
+            for dashboard in analytics_dashboards:
+                FeatureFlagDashboards.objects.get_or_create(dashboard=dashboard, feature_flag=instance)
+
         # Propagate the new variants and aggregation group type index to the linked experiments
         if "filters" in validated_data:
             filters = validated_data["filters"] or {}
@@ -495,6 +507,41 @@ def update(self, instance: FeatureFlag, validated_data: dict, *args: Any, **kwar
 
         return instance
 
+    def _get_conflicting_changes(
+        self, current_instance: FeatureFlag, validated_data: dict, original_flag: dict | None
+    ) -> list[str]:
+        """
+        Returns the list of fields that have conflicts. A conflict is defined as a field that
+        the current user is trying to change that has been changed by another user.
+
+        If the field in validated_data is different from the original_flag, then the current user
+        is trying to change it.
+
+        If a field that the user is trying to change is different in the current_instance, then
+        there is a conflict.
+        """
+
+        if original_flag is None or original_flag == {}:
+            return []
+
+        # Get the fields that the user is trying to change
+        user_changes = [
+            field
+            for field, new_value in validated_data.items()
+            if field in original_flag and new_value != original_flag[field]
+        ]
+
+        # Return the fields that have conflicts
+        # Only include fields where the user's intended change is different from the current value
+        # AND the original value is different from the current value (indicating someone else changed it)
+        return [
+            field
+            for field in user_changes
+            if field in original_flag
+            and original_flag[field] != getattr(current_instance, field)
+            and validated_data[field] != getattr(current_instance, field)
+        ]
+
     def _update_filters(self, validated_data):
         if "get_filters" in validated_data:
             validated_data["filters"] = validated_data.pop("get_filters")
diff --git a/posthog/api/test/test_feature_flag.py b/posthog/api/test/test_feature_flag.py
index f080ab68bc16..7ff3c25f41e3 100644
--- a/posthog/api/test/test_feature_flag.py
+++ b/posthog/api/test/test_feature_flag.py
@@ -810,6 +810,50 @@ def test_updating_feature_flag(self, mock_capture):
             ],
         )
 
+    @patch("posthog.api.feature_flag.report_user_action")
+    def test_updating_feature_flag_partial(self, mock_capture):
+        # Test that we can update a feature flag with only some of the fields
+        # And the unchanged fields are not updated
+        with freeze_time("2021-08-25T22:09:14.252Z") as frozen_datetime:
+            response = self.client.post(
+                f"/api/projects/{self.team.id}/feature_flags/",
+                {
+                    "name": "original name",
+                    "key": "a-feature-flag-that-is-updated",
+                    "filters": {
+                        "groups": [
+                            {
+                                "variant": None,
+                                "properties": [
+                                    {"key": "plan", "type": "person", "value": ["pro"], "operator": "exact"}
+                                ],
+                                "rollout_percentage": 100,
+                            }
+                        ],
+                        "payloads": {},
+                        "multivariate": None,
+                    },
+                },
+                format="json",
+            )
+            self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+            flag_id = response.json()["id"]
+
+            frozen_datetime.tick(delta=timedelta(minutes=10))
+
+            response = self.client.patch(
+                f"/api/projects/{self.team.id}/feature_flags/{flag_id}",
+                {
+                    "name": "Updated name",
+                },
+                format="json",
+            )
+
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+        self.assertEqual(response.json()["name"], "Updated name")
+        self.assertEqual(response.json()["filters"]["groups"][0]["rollout_percentage"], 100)
+
     @patch("posthog.api.feature_flag.report_user_action")
     def test_updating_feature_flag_with_different_user(self, mock_capture):
         with freeze_time("2021-08-25T22:09:14.252Z") as frozen_datetime:
@@ -885,10 +929,19 @@ def test_updating_feature_flag_fails_concurrency_check_when_version_outdated(sel
             self.client.force_login(original_user)
 
             # Original user tries to update the feature flag with the original version
-            # This should fail because the version has been incremented
+            # This should fail because the version has been incremented and the user is
+            # trying to update the name
             response = self.client.patch(
                 f"/api/projects/{self.team.id}/feature_flags/{flag_id}",
-                data={"name": "Another Updated name", "version": original_version},
+                data={
+                    "name": "Another Updated name",
+                    "version": original_version,
+                    "original_flag": {
+                        # Name has since been changed, leading to a conflict
+                        "name": "original name",
+                        "key": "a-feature-flag-that-is-updated",
+                    },
+                },
                 format="json",
             )
 
@@ -916,6 +969,101 @@ def test_updating_feature_flag_fails_concurrency_check_when_version_outdated(sel
             self.assertEqual(feature_flag.name, "Another Updated name")
             self.assertEqual(feature_flag.last_modified_by, different_user)
 
+    @patch("posthog.api.feature_flag.report_user_action")
+    def test_updating_feature_flag_does_not_fail_concurrency_check_when_changing_different_fields(self, mock_capture):
+        # If another users saves changes, but my changes don't conflict with those changes,
+        # then we should not fail the concurrency check
+        with freeze_time("2021-08-25T22:09:14.252Z") as frozen_datetime:
+            # Create flag with original user: version 0
+            original_user = self.user
+            response = self.client.post(
+                f"/api/projects/{self.team.id}/feature_flags/",
+                {
+                    "name": "original name",
+                    "key": "a-feature-flag-that-is-updated",
+                    "filters": {
+                        "groups": [
+                            {
+                                "variant": None,
+                                "properties": [
+                                    {"key": "plan", "type": "person", "value": ["pro"], "operator": "exact"}
+                                ],
+                                "rollout_percentage": 100,
+                            }
+                        ],
+                        "payloads": {},
+                        "multivariate": None,
+                    },
+                },
+                format="json",
+            )
+            self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+            flag_id = response.json()["id"]
+            original_version = response.json()["version"]
+
+            frozen_datetime.tick(delta=timedelta(minutes=10))
+
+            # Create and login as different user
+            different_user = User.objects.create_and_join(self.organization, "different_user@posthog.com", None)
+            self.client.force_login(different_user)
+
+            # Successfully update the feature flag with the different user. This will increment the version
+            response = self.client.patch(
+                f"/api/projects/{self.team.id}/feature_flags/{flag_id}",
+                {"name": "Updated name", "version": original_version},
+                format="json",
+            )
+
+            self.assertEqual(response.status_code, status.HTTP_200_OK)
+            self.client.force_login(original_user)
+
+            # Original user tries to update the feature flag with the original version
+            # However, the user is changing a field that wasn't changed by the other user
+            # This should succeed
+            response = self.client.patch(
+                f"/api/projects/{self.team.id}/feature_flags/{flag_id}",
+                data={
+                    "name": "Updated name",
+                    "filters": {
+                        "groups": [
+                            {
+                                "variant": None,
+                                "properties": [
+                                    {"key": "plan", "type": "person", "value": ["pro"], "operator": "exact"}
+                                ],
+                                "rollout_percentage": 45,
+                            }
+                        ],
+                        "payloads": {},
+                        "multivariate": None,
+                    },
+                    "original_flag": {
+                        "name": "original name",  # This is the same as the name (though not the current name)
+                        "filters": {
+                            "groups": [
+                                {
+                                    "variant": None,
+                                    "properties": [
+                                        {"key": "plan", "type": "person", "value": ["pro"], "operator": "exact"}
+                                    ],
+                                    "rollout_percentage": 100,
+                                }
+                            ],
+                            "payloads": {},
+                            "multivariate": None,
+                        },
+                    },
+                    "version": original_version,
+                },
+                format="json",
+            )
+
+            self.assertEqual(response.status_code, status.HTTP_200_OK)
+            feature_flag = FeatureFlag.objects.get(id=flag_id)
+            self.assertEqual(feature_flag.name, "Updated name")
+            self.assertEqual(feature_flag.last_modified_by, original_user)
+            self.assertEqual(response.json()["filters"]["groups"][0]["rollout_percentage"], 45)
+
     @patch("posthog.api.feature_flag.report_user_action")
     def test_updating_feature_flag_does_not_fail_when_version_not_in_request(self, mock_capture):
         with freeze_time("2021-08-25T22:09:14.252Z") as frozen_datetime:
@@ -949,6 +1097,105 @@ def test_updating_feature_flag_does_not_fail_when_version_not_in_request(self, m
             self.assertEqual(feature_flag.version, 2)
             self.assertEqual(feature_flag.name, "Yet another updated name")
 
+    def test_get_conflicting_changes(self):
+        feature_flag = FeatureFlag.objects.create(
+            team=self.team,
+            key="my-flag",
+            name="Beta feature",
+            active=True,
+            filters={"groups": [{"properties": [], "rollout_percentage": 50}]},
+        )
+
+        serializer = FeatureFlagSerializer(instance=feature_flag, context={"team_id": self.team.id})
+
+        original_flag = {
+            "key": "my-flag",
+            "name": "Alpha feature",  # This has since been changed by another user
+            "active": True,
+            "filters": {"groups": [{"properties": [], "rollout_percentage": 50}]},
+        }
+
+        # Test 1: No conflicts when changing fields that haven't been changed by another user
+        # The name is different from the current value, but the user is not trying to change it
+        validated_data = {"active": False, "key": "my-flag-2", "name": "Alpha feature"}
+        conflicts = serializer._get_conflicting_changes(feature_flag, validated_data, original_flag)
+        self.assertEqual(conflicts, [])
+
+        # Test 2: Detect conflict when changing a field that has been changed by another user
+        feature_flag.active = False
+        feature_flag.save()
+        validated_data = {"name": "Gamma feature"}
+        conflicts = serializer._get_conflicting_changes(feature_flag, validated_data, original_flag)
+        self.assertEqual(conflicts, ["name"])
+
+    def test_get_conflicting_changes_returns_empty_when_original_flag_is_none(self):
+        feature_flag = FeatureFlag.objects.create(
+            team=self.team,
+            key="my-flag",
+            name="Beta feature",
+            active=True,
+            filters={"groups": [{"properties": [], "rollout_percentage": 50}]},
+        )
+
+        serializer = FeatureFlagSerializer(instance=feature_flag, context={"team_id": self.team.id})
+
+        original_flag = None
+
+        # Should be conflict, but since original_flag is None, it will be ignored
+        feature_flag.active = False
+        feature_flag.save()
+        validated_data = {"name": "Gamma feature"}
+        conflicts = serializer._get_conflicting_changes(feature_flag, validated_data, original_flag)
+        self.assertEqual(conflicts, [])
+
+    def test_get_conflicting_changes_with_filter_changes(self):
+        feature_flag = FeatureFlag.objects.create(
+            team=self.team,
+            key="my-flag",
+            name="Beta feature",
+            active=True,
+            filters={
+                # This has since been changed by another user
+                "groups": [{"properties": [], "rollout_percentage": 45}]
+            },
+        )
+
+        serializer = FeatureFlagSerializer(instance=feature_flag, context={"team_id": self.team.id})
+
+        original_flag = {
+            "key": "my-flag",
+            # This has since been changed by another user
+            "name": "Alpha feature",
+            "active": True,
+            "filters": {
+                # This has since been changed by another user
+                "groups": [{"properties": [], "rollout_percentage": 50}]
+            },
+        }
+
+        # Test 1: No conflicts when changing fields that haven't been changed by another user
+        # The name and fliters are different from the current value, but the user is not trying to change them
+        validated_data = {
+            "active": False,
+            "key": "my-flag-2",
+            "name": "Alpha feature",
+            "filters": {"groups": [{"properties": [], "rollout_percentage": 50}]},
+        }
+        conflicts = serializer._get_conflicting_changes(feature_flag, validated_data, original_flag)
+        self.assertEqual(conflicts, [])
+
+        # Test 2: Detect conflict when changing a field that has been changed by another user
+        feature_flag.active = False
+        feature_flag.save()
+        validated_data = {
+            "name": "Gamma feature",
+            "filters": {"groups": [{"properties": [], "rollout_percentage": 100}]},
+            "active": False,
+            "key": "my-flag-2",
+        }
+        conflicts = serializer._get_conflicting_changes(feature_flag, validated_data, original_flag)
+        self.assertEqual(conflicts, ["name", "filters"])
+
     @patch("posthog.api.feature_flag.report_user_action")
     def test_updating_feature_flag_treats_null_version_as_zero(self, mock_capture):
         with freeze_time("2021-08-25T22:09:14.252Z") as frozen_datetime:


Reviews:
user: login: greptile-apps[bot]
body: 
## PR Summary

This PR implements finer-grained concurrency control for feature flags, allowing simultaneous edits to different fields without conflicts.

- Added `_get_conflicting_changes` method in `feature_flag.py` that compares specific fields between original flag and current database state
- Modified update process to only block saves when the same fields were changed by different users
- Added `originalFeatureFlag` reducer in `featureFlagLogic.ts` to track the initial state for accurate conflict detection
- Added `cleanFlag` helper function to remove metadata fields before comparison
- Added comprehensive test cases in `test_feature_flag.py` to verify concurrent edits to different fields succeed

<sub>3 file(s) reviewed, 1 comment(s)</sub>
<sub>[Edit PR Review Bot Settings](https://app.greptile.com/review/github) | [Greptile](https://greptile.com?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=posthog_29328)</sub>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T22:52:53Z
user: login: havenbarnes
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T18:28:22Z
user: login: havenbarnes
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T18:29:28Z
user: login: havenbarnes
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T18:30:48Z
user: login: havenbarnes
body: This looks great! Huge UX win!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T18:31:24Z
user: login: haacked
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T18:49:11Z
user: login: haacked
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T19:19:45Z

Review comments:
diff_hunk: @@ -495,6 +503,41 @@ def update(self, instance: FeatureFlag, validated_data: dict, *args: Any, **kwar
 
         return instance
 
+    def _get_conflicting_changes(
+        self, current_instance: FeatureFlag, validated_data: dict, original_flag: dict | None
+    ) -> list[str]:
+        """
+        Returns the list of fields that have conflicts. A conflict is defined as a field that
+        the current user is trying to change that has been changed by another user.
+
+        If the field in validated_data is different from the original_flag, then the current user
+        is trying to change it.
+
+        If a field that the user is trying to change is different in the current_instance, then
+        there is a conflict.
+        """
+
+        if original_flag is None or {}:
+            return []
path: posthog/api/feature_flag.py
user: login: greptile-apps[bot]
body: logic: This condition should check if `original_flag` is None or empty. The current check `original_flag is None or {}` will always evaluate the second part as True since `{}` is truthy in Python.

```suggestion
        if original_flag is None or original_flag == {}:
            return []
```
created_at: 2025-02-27T22:52:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -418,54 +418,66 @@ def create(self, validated_data: dict, *args: Any, **kwargs: Any) -> FeatureFlag
 
     def update(self, instance: FeatureFlag, validated_data: dict, *args: Any, **kwargs: Any) -> FeatureFlag:
         request = self.context["request"]
+        # This is a workaround to ensure update works when called from a scheduled task.
path: posthog/api/feature_flag.py
user: login: havenbarnes
body: Thanks for catching this, can be tricky!
created_at: 2025-03-06T18:28:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -418,54 +418,66 @@ def create(self, validated_data: dict, *args: Any, **kwargs: Any) -> FeatureFlag
 
     def update(self, instance: FeatureFlag, validated_data: dict, *args: Any, **kwargs: Any) -> FeatureFlag:
         request = self.context["request"]
+        # This is a workaround to ensure update works when called from a scheduled task.
path: posthog/api/feature_flag.py
user: login: havenbarnes
body: I can't quite recall if the scheduled task has `None` for `self.context["request"]` , but I'm guessing you tested this and it does have it, just no `data` 
created_at: 2025-03-06T18:29:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -418,54 +418,66 @@ def create(self, validated_data: dict, *args: Any, **kwargs: Any) -> FeatureFlag
 
     def update(self, instance: FeatureFlag, validated_data: dict, *args: Any, **kwargs: Any) -> FeatureFlag:
         request = self.context["request"]
+        # This is a workaround to ensure update works when called from a scheduled task.
+        if request and not hasattr(request, "data"):
+            request.data = {}
+
         validated_data["last_modified_by"] = request.user
 
         if "deleted" in validated_data and validated_data["deleted"] is True and instance.features.count() > 0:
             raise exceptions.ValidationError(
                 "Cannot delete a feature flag that is in use with early access features. Please delete the early access feature before deleting the flag."
             )
 
+        # First apply all transformations to validated_data
+        validated_key = validated_data.get("key", None)
+        self._update_filters(validated_data)
+
+        if validated_data.get("has_encrypted_payloads", False):
+            if validated_data["filters"]["payloads"]["true"] == REDACTED_PAYLOAD_VALUE:
+                # Don't write the redacted payload to the db, keep the current value instead
+                validated_data["filters"]["payloads"]["true"] = instance.filters["payloads"]["true"]
+            else:
+                encrypt_flag_payloads(validated_data)
+
+        version = request.data.get("version", -1)
+
         with transaction.atomic():
             # select_for_update locks the database row so we ensure version updates are atomic
             locked_instance = FeatureFlag.objects.select_for_update().get(pk=instance.pk)
             locked_version = locked_instance.version or 0
 
-            version = validated_data.get("version", -1)
-
-            # If version is not provided, we don't check for conflicts. This is just in case there's a place
-            # that's using the feature flag that doesn't know about the version field yet.
-            if version != -1 and version != locked_version:
-                raise Conflict(
-                    f"The feature flag was updated by {locked_instance.last_modified_by.email if locked_instance.last_modified_by else 'another user'} since you started editing it. Please refresh and try again."
-                )
-
-            validated_data["version"] = locked_version + 1
-
-            validated_key = validated_data.get("key", None)
             if validated_key:
                 # Delete any soft deleted feature flags with the same key to prevent conflicts
                 FeatureFlag.objects.filter(
                     key=validated_key, team__project_id=instance.team.project_id, deleted=True
                 ).delete()
-            self._update_filters(validated_data)
-
-            if validated_data.get("has_encrypted_payloads", False):
-                if validated_data["filters"]["payloads"]["true"] == REDACTED_PAYLOAD_VALUE:
-                    # Don't write the redacted payload to the db, keep the current value instead
-                    validated_data["filters"]["payloads"]["true"] = instance.filters["payloads"]["true"]
-                else:
-                    encrypt_flag_payloads(validated_data)
-
-            analytics_dashboards = validated_data.pop("analytics_dashboards", None)
 
-            if analytics_dashboards is not None:
-                for dashboard in analytics_dashboards:
-                    FeatureFlagDashboards.objects.get_or_create(dashboard=dashboard, feature_flag=instance)
+            # NOW check for conflicts after all transformations
+            if version != -1 and version != locked_version:
+                conflicting_changes = self._get_conflicting_changes(
+                    locked_instance, validated_data, request.data.get("original_flag", {})
+                )
+                if len(conflicting_changes) > 0:
+                    raise Conflict(
+                        f"The feature flag was updated by {locked_instance.last_modified_by.email if locked_instance.last_modified_by else 'another user'} since you started editing it. Please refresh and try again."
path: posthog/api/feature_flag.py
user: login: havenbarnes
body: This is so awesome
created_at: 2025-03-06T18:30:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -418,54 +418,66 @@ def create(self, validated_data: dict, *args: Any, **kwargs: Any) -> FeatureFlag
 
     def update(self, instance: FeatureFlag, validated_data: dict, *args: Any, **kwargs: Any) -> FeatureFlag:
         request = self.context["request"]
+        # This is a workaround to ensure update works when called from a scheduled task.
path: posthog/api/feature_flag.py
user: login: haacked
body: Yeah, I did test it. It's how we get the `user` that made the change. But I think I'll be more defensive here Just In Case™️.
created_at: 2025-03-06T18:49:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -418,54 +418,66 @@ def create(self, validated_data: dict, *args: Any, **kwargs: Any) -> FeatureFlag
 
     def update(self, instance: FeatureFlag, validated_data: dict, *args: Any, **kwargs: Any) -> FeatureFlag:
         request = self.context["request"]
+        # This is a workaround to ensure update works when called from a scheduled task.
path: posthog/api/feature_flag.py
user: login: haacked
body: Actually, I `request.user` was not introduced by me and it's necessary to change it. So that ensures that `request` exists.
created_at: 2025-03-06T19:19:45Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-02-27T23:07:03Z
body: **Size Change:** 0 B 

**Total Size:** 9.93 MB



<details><summary>ℹ️ <strong>View Unchanged</strong></summary>

| Filename | Size |
| :--- | :---: |
| `frontend/dist/toolbar.js` | 9.93 MB |

</details>



<a href="https://github.com/preactjs/compressed-size-action"><sub>compressed-size-action</sub></a>
author_association: CONTRIBUTOR
user: login: haacked
created_at: 2025-02-28T18:44:54Z
body: Demo of concurrency support when editing rollout conditions for an experiment.

https://github.com/user-attachments/assets/34144391-5f24-4c11-a2a7-543c41dc5883


author_association: CONTRIBUTOR
user: login: haacked
created_at: 2025-02-28T18:56:32Z
body: **Early access feature**
As far as I can tell, you can't directly edit a feature flag from Early access feature. Instead, it links to the connected flag:

![image](https://github.com/user-attachments/assets/983ee614-9b0d-4397-b053-317a6134fd56)

**Surveys**

I believe there is a way to connect surveys to feature flags, but I didn't figure it out from the UI. Looking at the code, it uses `FeatureFlagReleaseConditions` so I think it'll support my changes. I'll ask around.
author_association: CONTRIBUTOR
user: login: posthog-bot
created_at: 2025-03-06T19:08:13Z
body: ## 📸 UI snapshots have been updated

**2** snapshot changes in total. **0** added, **2** modified, **0** deleted:

- `chromium`: **0** added, **0** modified, **0** deleted
- **`webkit`**: **0** added, **2** modified, **0** deleted ([diff for shard 2](https://github.com/PostHog/posthog/pull/29328/commits/545e0e813b5fc2a37ef68d835f4f6d9465849d1a))

Triggered by [this commit](https://github.com/PostHog/posthog/pull/29328/commits/3aa4d64e806f4689e1dddc0f7816f3dc48f760be).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/29328/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR
user: login: posthog-bot
created_at: 2025-03-06T19:26:42Z
body: ## 📸 UI snapshots have been updated

**2** snapshot changes in total. **0** added, **2** modified, **0** deleted:

- `chromium`: **0** added, **0** modified, **0** deleted
- **`webkit`**: **0** added, **2** modified, **0** deleted ([diff for shard 2](https://github.com/PostHog/posthog/pull/29328/commits/b04900042ebbf194e8a7909b47a9b85c62f9bd27))

Triggered by [this commit](https://github.com/PostHog/posthog/pull/29328/commits/8d11ef2cadd14d638e358f367882fedcc5ae7ef9).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/29328/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR
user: login: posthog-bot
created_at: 2025-03-06T19:45:40Z
body: ## 📸 UI snapshots have been updated

**3** snapshot changes in total. **0** added, **3** modified, **0** deleted:

- **`chromium`**: **0** added, **1** modified, **0** deleted ([diff for shard 2](https://github.com/PostHog/posthog/pull/29328/commits/9ee6cd0a03d965e222a5746d84ebae6ac7567153))
- **`webkit`**: **0** added, **2** modified, **0** deleted ([diff for shard 2](https://github.com/PostHog/posthog/pull/29328/commits/e2d683d7ea7ff40fe77f6f2c1f2a5557d84b7476))

Triggered by [this commit](https://github.com/PostHog/posthog/pull/29328/commits/51177d808a4f3356d7fbe7a2dd64935209688108).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/29328/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR
user: login: posthog-bot
created_at: 2025-03-06T20:03:57Z
body: ## 📸 UI snapshots have been updated

**3** snapshot changes in total. **0** added, **3** modified, **0** deleted:

- **`chromium`**: **0** added, **1** modified, **0** deleted ([diff for shard 2](https://github.com/PostHog/posthog/pull/29328/commits/85cd0e39a0153468924c9103d313b54f145f3723))
- **`webkit`**: **0** added, **2** modified, **0** deleted ([diff for shard 2](https://github.com/PostHog/posthog/pull/29328/commits/b8b6d31c3035893783309ccacbcfceb8331dcf09))

Triggered by [this commit](https://github.com/PostHog/posthog/pull/29328/commits/0c9e3067a6a17c70b039399f60a8cb0da20db423).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/29328/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR
user: login: posthog-bot
created_at: 2025-03-06T20:22:16Z
body: ## 📸 UI snapshots have been updated

**6** snapshot changes in total. **0** added, **6** modified, **0** deleted:

- `chromium`: **0** added, **0** modified, **0** deleted
- **`webkit`**: **0** added, **6** modified, **0** deleted ([diff for shard 2](https://github.com/PostHog/posthog/pull/29328/commits/46addeccfd851ba01e1b3b81143d04b36262d179))

Triggered by [this commit](https://github.com/PostHog/posthog/pull/29328/commits/d2ef7824af7a79bb322d75a73d942b87cfd622d2).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/29328/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR
user: login: posthog-bot
created_at: 2025-03-06T20:40:33Z
body: ## 📸 UI snapshots have been updated

**4** snapshot changes in total. **0** added, **4** modified, **0** deleted:

- `chromium`: **0** added, **0** modified, **0** deleted
- **`webkit`**: **0** added, **4** modified, **0** deleted ([diff for shard 2](https://github.com/PostHog/posthog/pull/29328/commits/6240410318a0fa1522dcfe9e326a34ca0bb67adf))

Triggered by [this commit](https://github.com/PostHog/posthog/pull/29328/commits/8b6b339fc7425a6599511f9de9dad96c49623d79).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/29328/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR