Skip to content

fix(api): block multivariate option changes during live experiments - #8917

Open
TejasLamba2006 wants to merge 3 commits into
Flagsmith:mainfrom
TejasLamba2006:fix/restrict-mv-options-in-live-experiment
Open

TejasLamba2006 wants to merge 3 commits into
Flagsmith:mainfrom
TejasLamba2006:fix/restrict-mv-options-in-live-experiment

Conversation

@TejasLamba2006

Copy link
Copy Markdown

Thanks for submitting a PR! Please check the boxes below:

  • I have read the Contributing Guide.
  • I have added information to docs/ if required so people know about the feature.
  • I have filled in the "Changes" section below.
  • I have filled in the "How did you test this code" section below.

Changes

Contributes to #8766

The API now rejects multivariate option changes on a feature that is in a running experiment:

  • MultivariateFeatureOptionViewSet create, update and destroy return 400 while the feature has a running experiment in any environment. Multivariate options belong to the feature, so one experiment locks them everywhere.
  • Feature create/update: the nested multivariate_options are rejected only when the submitted options differ from the stored ones. The frontend resubmits the whole feature on save, so other fields (description, tags, etc.) still save on a frozen feature.
  • New helper has_live_experiment in experimentation/feature_state_metadata.py.

Frontend: the create-feature modal already disables the add, edit and remove variant controls when the feature is frozen (useFeatureExperimentFreeze). That check only looks at experiments running in the current environment. Blocking variant edits from the UI for a feature running in another environment needs a project-level experiments query, which is not part of this PR. The API rejects those edits regardless.

How did you test this code?

Backend unit tests:

cd api
pytest tests/unit/features/multivariate/test_unit_multivariate_live_experiment.py
pytest tests/unit/features/ -k "multivariate or mv or feature_serializer or create_feature or update_feature"

The new test module covers create, update and delete of MV options during a live experiment (400), the same create still working without one, and feature PATCH with changed options (400) versus unchanged options plus a metadata change (200). The existing multivariate and feature suites pass.

Multivariate options were only restricted in the frontend. The API
accepted create, update and delete on the MV options endpoint, and
changed nested multivariate_options on feature update, even when the
feature was in a running experiment.

Reject these writes with a 400 while any environment has a running
experiment for the feature. On feature update, only reject when the
submitted options differ from the stored ones, so other feature fields
can still be saved.

Closes Flagsmith#8766
@TejasLamba2006
TejasLamba2006 requested a review from a team as a code owner November 9, 2026 16:45
@TejasLamba2006
TejasLamba2006 requested review from emyller and removed request for a team November 9, 2026 16:45
@vercel

vercel Bot commented Nov 9, 2026

Copy link
Copy Markdown

@TejasLamba2006 is attempting to deploy a commit to the Flagsmith Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

Explain Change →

Walkthrough

The API now rejects multivariate option creation, updates, and deletion when the feature has a running experiment in any environment. Feature updates reject changed option content during a running experiment and allow unchanged options. Environment imports raise a validation error before deleting environment-specific state or segments when the feature has a running experiment. The import flow runs in a transaction, and tests cover validation, rollback, and option creation when no experiment is running.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: Moderate · up to d564c

Resolve the import restrictions and resume-locking gap before merging. Valid imports and metadata saves can be rejected, failed imports can appear processed, and concurrent option edits can commit after an experiment resumes.

- [ ] Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Review info

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 8a697a32-dc99-42e6-a773-d976a4f84f00
Commits

Reviewing files that changed from the base of the PR and between 13539af and ffd2b0b.

Files selected for processing: 5

  • api/experimentation/feature_state_metadata.py
  • api/features/constants.py
  • api/features/multivariate/views.py
  • api/features/serializers.py
  • api/tests/unit/features/multivariate/test_unit_multivariate_live_experiment.py

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 4 remain after this review.

Comment thread api/features/multivariate/views.py Resolved
Comment thread api/features/multivariate/views.py Resolved
Comment thread api/features/serializers.py Resolved
Comment thread api/features/serializers.py Resolved

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Review info

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 8a0c27d7-d8b4-4329-8549-44983957b743
Commits

Reviewing files that changed from the base of the PR and between ffd2b0b and 5b4a677.

Files selected for processing: 6

  • api/experimentation/feature_state_metadata.py
  • api/features/import_export/services.py
  • api/features/multivariate/views.py
  • api/features/serializers.py
  • api/tests/unit/features/import_export/test_unit_features_import_export_tasks.py
  • api/tests/unit/features/multivariate/test_unit_multivariate_live_experiment.py

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 1 remain after this review.

Comment thread api/features/import_export/services.py Outdated
Comment on lines +21 to +30
imported_option_values = {
(mv_data["type"], mv_data["value"]) for mv_data in feature_data["multivariate"]
}
existing_option_values = {
(option.type, option.value)
for option in existing_feature.multivariate_options.all()
}
creates_option = not imported_option_values.issubset(existing_option_values)
if creates_option and has_live_experiment(existing_feature.id):
raise ValidationError(LIVE_EXPERIMENT_MV_OPTIONS_MESSAGE)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major · Data Integrity & Integration · Heavy lift

Supported by static analysis

Script executed:

git diff 13539afcb99c20b4351e26f215d01b49f2a9be03 5b4a677359af2cbd8248f9f60465509bb92357e1 -- api/features/import_export/services.py api/tests/unit/features/import_export/test_unit_features_import_export_tasks.py
nl -ba api/features/import_export/services.py
rg -n -F -e '_import_features_for_environment' -e 'FeatureImport' -e 'feature_import' -- api/features/import_export api/tests/unit/features/import_export

Repository: Flagsmith/flagsmith

Length of output: 21602


Script executed:

nl -ba api/features/import_export/tasks.py | sed -n '1,180p'
nl -ba api/features/import_export/models.py
nl -ba api/features/import_export/serializers.py
nl -ba api/features/import_export/views.py | sed -n '45,68p;135,170p'
nl -ba api/experimentation/feature_state_metadata.py
rg -n -F -e 'ATOMIC_REQUESTS' -e 'import_features_for_environment' -e 'DATABASES =' -- api | head -100
rg -n -F -e 'feature-imports' -e 'featureImports' -e 'feature_imports' -e 'FeatureImport' -- frontend | head -120

Repository: Flagsmith/flagsmith

Length of output: 24317


Script executed:

nl -ba api/app/settings/common.py | sed -n '185,220p;260,295p;330,360p'
nl -ba frontend/web/components/ButterBar.tsx
nl -ba frontend/web/components/import-export/FeatureImport.tsx | sed -n '1,180p;230,330p'
rg -n -F -e 'task_processor' -e 'task-processor' -e 'task-processor-django' -- api/pyproject.toml api/poetry.lock api/requirements* docker-compose* 2>/dev/null
rg --files | rg '(^|/)(task_processor|task-processor)(/|$)|task.processor'

Repository: Flagsmith/flagsmith

Length of output: 21822


Script executed:

rg -n -F -e 'name = "flagsmith-common"' -e 'flagsmith-common' -- api/uv.lock api/poetry.lock api/pyproject.toml 2>/dev/null
rg -n -F -e 'run-task-processor' -e 'task_processor' -- api/app api | head -100
nl -ba docs/docs/deployment-self-hosting/scaling-and-performance/asynchronous-task-processor.md
nl -ba docker-compose.yml | sed -n '78,100p'

Repository: Flagsmith/flagsmith

Length of output: 16817


Web query:

flagsmith common task_processor register_task_handler execute task transaction.atomic github source

Result:

Sources consulted:
- GitHub - Flagsmith/flagsmith-common: Flagsmith's common library · GitHub — https://github.com/Flagsmith/flagsmith-common
- flagsmith/docker-compose.yml at main · Flagsmith/flagsmith · GitHub — https://github.com/Flagsmith/flagsmith/blob/main/docker-compose.yml
- flagsmith-common · PyPI — https://pypi.org/project/flagsmith-common/
- Releases · Flagsmith/flagsmith · GitHub — https://github.com/Flagsmith/flagsmith/releases
- flagsmith-common · PyPI — https://pypi.org/project/flagsmith-common/3.15.0/
- …and 25 more

Script executed:

sed -n '1570,1605p' api/uv.lock
python3 - <<'PY'
import pathlib, tomllib, urllib.request, io, zipfile
lock = tomllib.loads(pathlib.Path('api/uv.lock').read_text())
package = next(p for p in lock['package'] if p['name'] == 'flagsmith-common')
print('locked version:', package['version'])
url = package['wheels'][0]['url']
print('wheel:', url)
try:
    with urllib.request.urlopen(url, timeout=20) as r:
        data = r.read()
    with zipfile.ZipFile(io.BytesIO(data)) as z:
        for name in z.namelist():
            if name.startswith('task_processor/') and name.endswith('.py') and any(s in name for s in ('decorators', 'task_run_method', 'worker', 'processor')):
                print('\n---', name, '---')
                for i, line in enumerate(z.read(name).decode().splitlines(), 1):
                    print(f'{i:4} {line}')
except Exception as exc:
    print(type(exc).__name__, str(exc))
PY

Repository: Flagsmith/flagsmith

Length of output: 1957


Script executed:

python3 - <<'PY'
import urllib.request, io, zipfile, hashlib
url = 'https://files.pythonhosted.org/packages/db/5a/a203df00c5b1cf5fcd6df5857113cf81dcf42b6c6d0887508b86ca808161/flagsmith_common-3.16.1-py3-none-any.whl'
try:
    with urllib.request.urlopen(url, timeout=20) as r:
        data = r.read()
    print('wheel sha256:', hashlib.sha256(data).hexdigest())
    with zipfile.ZipFile(io.BytesIO(data)) as z:
        files = [n for n in z.namelist() if n.startswith('task_processor/') and n.endswith('.py')]
        print('files:', '\n'.join(files))
        for name in files:
            text = z.read(name).decode()
            if any(s in name for s in ('decorators', 'task_run_method', 'processor', 'worker', 'executor')):
                print('\n---', name, '---')
                for i, line in enumerate(text.splitlines(), 1):
                    print(f'{i:4} {line}')
except Exception as exc:
    print(type(exc).__name__, str(exc))
PY

Repository: Flagsmith/flagsmith

Length of output: 43160


Script executed:

rg -n -F -e 'TASK_RUN_METHOD' -e 'TASK_PROCESSOR_MODE' -- api/app/settings docker-compose.yml
nl -ba api/tests/unit/features/import_export/test_unit_features_import_export_tasks.py | sed -n '180,245p;390,485p'
nl -ba api/features/import_export/constants.py

Repository: Flagsmith/flagsmith

Length of output: 8153


Roll back failed imports and report the reason.

When a later feature needs a new option during a running experiment, earlier overwrites remain committed. The import is marked FAILED without a reason, and the UI can report success. Roll back the import on failure and expose the reason.

Comment thread api/features/import_export/services.py Resolved
Comment thread api/features/multivariate/views.py Resolved
Comment thread api/features/serializers.py Outdated
Comment on lines +312 to +321
def update(self, instance: Feature, validated_data: dict[str, Any]) -> Feature:
if (
"multivariate_options" in validated_data
and has_live_experiment(instance.id)
and not self._changes_multivariate_options(
validated_data["multivariate_options"], instance
)
):
validated_data.pop("multivariate_options")
return super().update(instance, validated_data) # type: ignore[no-any-return]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major · Data Integrity & Integration · Heavy lift

Supported by static analysis

Script executed:

git diff 13539afcb99c20b4351e26f215d01b49f2a9be03 5b4a677359af2cbd8248f9f60465509bb92357e1 -- api/features/serializers.py api/experimentation/feature_state_metadata.py
nl -ba api/features/serializers.py | sed -n '190,390p'
nl -ba api/experimentation/feature_state_metadata.py | sed -n '1,180p'
rg -n 'class .*Feature.*View|serializer_class = .*Feature|ATOMIC_REQUESTS|select_for_update|transaction\.atomic' api/features api/experimentation api/app api/config 2>/dev/null

Repository: Flagsmith/flagsmith

Length of output: 21467


Script executed:

nl -ba api/features/views.py | sed -n '190,460p'
rg -n -F -e 'DeleteBeforeUpdateWritableNestedModelSerializer' -e 'ATOMIC_REQUESTS' -e 'start_experiment' -e 'ExperimentStatus.RUNNING' -- api | sed -n '1,220p'
nl -ba api/experimentation/services.py | sed -n '550,700p'
nl -ba api/experimentation/views.py | sed -n '400,530p'
rg -n -F -e 'DATABASES =' -e 'DATABASES=' -e 'drf-writable-nested' -e 'drf_writable_nested' -- api pyproject.toml uv.lock poetry.lock 2>/dev/null

Repository: Flagsmith/flagsmith

Length of output: 34000


Script executed:

nl -ba api/util/drf_writable_nested/serializers.py | sed -n '1,180p'
rg -n -F -e 'class UpdateFeatureSerializer' -e 'class NestedMultivariateFeatureOptionSerializer' -- api/features/serializers.py api/features/multivariate
nl -ba api/features/serializers.py | sed -n '390,600p'
nl -ba api/features/multivariate/serializers.py | sed -n '1,240p'
nl -ba api/app/settings/common.py | sed -n '185,230p;260,310p'
rg -n -F -e 'def enable_experiment_rollout' -e 'def _transition_status' -- api/experimentation
nl -ba api/experimentation/views.py | sed -n '530,600p'
rg -n -F -e 'DATABASES' -e 'ATOMIC_REQUESTS' -- api/app/settings
sed -n '1238,1245p' api/uv.lock

Repository: Flagsmith/flagsmith

Length of output: 30749


Script executed:

nl -ba api/experimentation/views.py | sed -n '620,655p'
nl -ba api/experimentation/services.py | sed -n '1120,1225p'
nl -ba api/tests/unit/features/multivariate/test_unit_multivariate_live_experiment.py | sed -n '1,260p'
rg -n -F -e 'multivariate_options' -e 'PATCH' -- api/tests/unit/features/test_unit_features_views.py api/tests/unit/features/test_unit_features_serializers.py api/tests/unit/features/multivariate 2>/dev/null | sed -n '1,130p'
nl -ba api/features/multivariate/models.py | sed -n '1,230p'

Repository: Flagsmith/flagsmith

Length of output: 28386


Web query:

github beda software drf writable nested 0.6.4 mixins.py NestedUpdateMixin delete_reverse_relations_if_need update_or_create_reverse_relations source

Result:

Sources consulted:
- https://raw.githubusercontent.com/beda-software/drf-writable-nested/v0.6.4/drf_writable_nested/mixins.py
- drf-writable-nested/drf_writable_nested/mixins.py at v0.6.4 · beda-software/drf-writable-nested · GitHub — https://github.com/beda-software/drf-writable-nested/blob/v0.6.4/drf_writable_nested/mixins.py
- GitHub - beda-software/drf-writable-nested: Writable nested model serializer for Django REST Framework · GitHub — https://github.com/beda-software/drf-writable-nested
- drf-writable-nested · PyPI — https://pypi.org/project/drf-writable-nested/0.5.4/
- …and 36 more

Web query:

site:raw.githubusercontent.com/beda-software/drf-writable-nested/v0.6.4/drf_writable_nested/mixins.py "def delete_reverse_relations_if_need" on_delete

Result:

Sources consulted:
- https://raw.githubusercontent.com/beda-software/drf-writable-nested/v0.6.4/drf_writable_nested/mixins.py

Script executed:

nl -ba api/tests/unit/features/test_unit_features_views.py | sed -n '2620,2720p'
nl -ba api/experimentation/models.py | sed -n '145,250p'

Repository: Flagsmith/flagsmith

Length of output: 8479


Serialise option changes with experiment starts.

If an experiment starts after validation, changed options still reach the nested update. Replacing options can delete their allocations across environments. Use a shared lock for experiment starts and option writes, then recheck the experiment status before writing.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Review info

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: bff86148-a732-4af1-85f1-7eebf38db057
Commits

Reviewing files that changed from the base of the PR and between 5b4a677 and d564c8d.

Files selected for processing: 7

  • api/experimentation/services.py
  • api/features/import_export/services.py
  • api/features/import_export/tasks.py
  • api/features/multivariate/views.py
  • api/features/serializers.py
  • api/tests/unit/features/import_export/test_unit_features_import_export_tasks.py
  • api/tests/unit/features/multivariate/test_unit_multivariate_live_experiment.py

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 5 remain after this review.

Comment on lines +620 to +622
Feature.objects.select_for_update().filter(
pk=experiment.feature_id
).exists()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Functional Correctness · Quick win

Code analysis · TLA+ model checking

Script executed:

rg -n 'VALID_STATUS_TRANSITIONS|started_at|ExperimentStatus.PAUSED|transition_experiment_status' api/experimentation; sed -n '605,632p' api/experimentation/services.py

Repository: Flagsmith/flagsmith

Length of output: 3106


tla executed:

---- MODULE ReviewModel ----
EXTENDS Naturals
(* Assumption to verify: resuming a paused experiment retains started_at,
   so the new feature lock is skipped. Writer phases represent its feature
   lock, live check, and save. A violation records a save while running.
TLC
INIT Init
NEXT Next
INVARIANT Safe
*)
VARIABLES live, owner, writer, resumed, violation
vars == <<live, owner, writer, resumed, violation>>
Init == /\ live = FALSE /\ owner = "none" /\ writer = "waiting"
        /\ resumed = FALSE /\ violation = FALSE
Check == /\ writer = "waiting" /\ owner = "none"
         /\ owner' = "writer"
         /\ writer' = (IF live THEN "rejected" ELSE "ready")
         /\ UNCHANGED <<live, resumed, violation>>
Resume == /\ ~resumed /\ live' = TRUE /\ resumed' = TRUE
          /\ UNCHANGED <<owner, writer, violation>>
Save == /\ writer = "ready" /\ writer' = "done"
        /\ owner' = "none" /\ violation' = live
        /\ UNCHANGED <<live, resumed>>
Reject == /\ writer = "rejected" /\ writer' = "done"
          /\ owner' = "none" /\ UNCHANGED <<live, resumed, violation>>
Idle == /\ writer = "done" /\ resumed /\ UNCHANGED vars
Next == Check \/ Resume \/ Save \/ Reject \/ Idle
Safe == ~violation
====

Length of output: 3972


Script executed:

git diff 13539afcb99c20b4351e26f215d01b49f2a9be03 d564c8d053d14408261767f8c48432435b3adc51 -- api/experimentation/services.py api/features/multivariate/views.py api/features/serializers.py
nl -ba api/features/multivariate/views.py | sed -n '1,180p'
nl -ba api/experimentation/models.py | sed -n '125,220p'
nl -ba api/experimentation/views.py | sed -n '445,515p;580,655p'
nl -ba api/experimentation/services.py | sed -n '570,645p'
rg -n -F -e 'has_live_experiment' -e 'live_experiment' -e 'transition_experiment_status(' -- api/experimentation api/features api/tests/unit/features/multivariate

Repository: Flagsmith/flagsmith

Length of output: 33851


Script executed:

nl -ba api/experimentation/feature_state_metadata.py | sed -n '1,130p'
rg -n -F -e 'def enable_experiment_rollout' -e 'def _enable_experiment_rollout' -e 'LIVE_EXPERIMENT' -- api/experimentation api/features
rg -n -F -e 'paused' -e 'PAUSED' -- api/tests/unit/features/multivariate/test_unit_multivariate_live_experiment.py api/tests/unit/experimentation

Repository: Flagsmith/flagsmith

Length of output: 4823


Script executed:

nl -ba api/experimentation/services.py | sed -n '1110,1235p'
nl -ba api/features/multivariate/models.py | sed -n '1,240p'
nl -ba api/features/multivariate/serializers.py | sed -n '1,220p'
rg -n -F -e 'multivariate_feature_option' -e 'multivariate_options' -- api/experimentation/services.py api/experimentation/serializers.py
nl -ba api/tests/unit/experimentation/test_experiment_views.py | sed -n '550,620p'
rg -n -F -e 'EXPERIMENTATION_ENABLED' -- api/app/settings/common.py api/experimentation/permissions.py

Repository: Flagsmith/flagsmith

Length of output: 24983


Script executed:

nl -ba api/experimentation/services.py | sed -n '400,465p;900,950p;990,1050p'
nl -ba api/experimentation/permissions.py | sed -n '1,80p'
nl -ba api/tests/unit/experimentation/test_experiment_views.py | sed -n '619,700p'
rg -n -F -e 'multivariate_feature_option.get' -e 'multivariate_feature_option.' -- api/features api/evaluation
rg -n -F -e 'EXPERIMENTATION_ENABLED' -- api/app/settings docker-compose.yml docker-compose.yaml helm charts 2>/dev/null || test "$?" -eq 1

Repository: Flagsmith/flagsmith

Length of output: 13459


Script executed:

rg -n -F -e 'def create_experiment_audit_log' -e 'class AuditLog(' -e 'def apply_experiment_rollout' -- api/experimentation api/audit
nl -ba api/experimentation/services.py | sed -n '515,575p;1030,1110p'
nl -ba api/features/versioning/schemas.py | sed -n '1,85p'
nl -ba docker-compose.yml | sed -n '20,45p'
rg -n -F -e 'get_experiment_rollout' -e '_expected_variant_shares' -- api/experimentation/services.py

Repository: Flagsmith/flagsmith

Length of output: 10425


Web query:

PostgreSQL foreign key update unchanged key skip referential integrity trigger RI_FKey_fk_upd_check_required select for key share

Result:

Sources consulted:
- postgres/src/backend/utils/adt/ri_triggers.c at master · postgres/postgres · GitHub — https://github.com/postgres/postgres/blob/master/src/backend/utils/adt/ri_triggers.c
- PostgreSQL Source Code: src/backend/utils/adt/ri_triggers.c Source File — https://doxygen.postgresql.org/ri__triggers_8c_source.html
- PostgreSQL Source Code: src/backend/utils/adt/ri_triggers.c File Reference — https://doxygen.postgresql.org/ri__triggers_8c.html
- …and 27 more

Script executed:

nl -ba docker-compose.yml | sed -n '1,20p;44,70p'
rg -n -F -e 'postgres:' -e 'postgresql:' -- docker-compose.yml api/pyproject.toml api/Dockerfile

Repository: Flagsmith/flagsmith

Length of output: 2589


Script executed:

python3 - <<'PY'
import urllib.request
url='https://raw.githubusercontent.com/postgres/postgres/REL_15_5/src/backend/utils/adt/ri_triggers.c'
try:
    text=urllib.request.urlopen(url, timeout=15).read().decode()
except Exception as e:
    print('FETCH FAILED:',type(e).__name__,str(e))
else:
    lines=text.splitlines()
    for i,line in enumerate(lines):
        if line.startswith('RI_FKey_fk_upd_check_required('):
            print(url)
            for j in range(i-12, min(i+85,len(lines))):
                print(f'{j+1}: {lines[j]}')
            break
    else:
        print('Function not found')
PY

Repository: Flagsmith/flagsmith

Length of output: 3544


Lock the feature when resuming an experiment.

If an option update passes its check while an experiment is paused, a concurrent resume can commit before the update. The option change can then commit while the experiment is running. Acquire the feature lock for every transition to RUNNING.

Comment on lines +21 to +22
if has_live_experiment(existing_feature.id):
raise ValidationError(LIVE_EXPERIMENT_MV_OPTIONS_MESSAGE)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major · Functional Correctness · Quick win

Supported by static analysis

Script executed:

sed -n '1,110p' api/features/import_export/services.py; rg -n 'OVERWRITE_DESTRUCTIVE|live experiment|running experiment' api/features/import_export api/tests/unit/features/import_export docs

Repository: Flagsmith/flagsmith

Length of output: 6848


Script executed:

git diff 13539afcb99c20b4351e26f215d01b49f2a9be03 d564c8d053d14408261767f8c48432435b3adc51 -- api/features/import_export/services.py api/features/import_export/tasks.py api/tests/unit/features/import_export/test_unit_features_import_export_tasks.py
nl -ba api/features/import_export/services.py
nl -ba api/features/import_export/tasks.py | sed -n '1,230p'
nl -ba api/tests/unit/features/import_export/test_unit_features_import_export_tasks.py | sed -n '1,180p'
rg -n -F -e 'overwrite_feature_for_environment' -e 'OVERWRITE_DESTRUCTIVE' -e 'Overwrite Destructive' -- api/features/import_export api/tests/unit/features/import_export docs frontend

Repository: Flagsmith/flagsmith

Length of output: 33801


Script executed:

nl -ba api/experimentation/feature_state_metadata.py
nl -ba docs/docs/administration-and-security/data-management/bulk-import-and-export.md | sed -n '20,65p'
rg -n -e '^class (FeatureState|FeatureStateValue|FeatureSegment|MultivariateFeatureStateValue|MultivariateFeatureOption)' -e 'def save\(' api/features/models.py api/features/multivariate/models.py
rg -n -F -e 'FeatureStateValue' -e 'MultivariateFeatureStateValue' -e 'FeatureSegment' api/features/signals.py api/features/multivariate api/features/apps.py
nl -ba api/tests/unit/features/import_export/test_unit_features_import_export_tasks.py | sed -n '460,760p'
nl -ba api/features/import_export/serializers.py | sed -n '1,130p'
rg -n -F -e 'LIVE_EXPERIMENT_MV_OPTIONS_MESSAGE' -e 'def get_environment_flags_list' api/features

Repository: Flagsmith/flagsmith

Length of output: 26237


Script executed:

nl -ba api/features/multivariate/models.py | sed -n '1,200p'
nl -ba api/features/models.py | sed -n '248,390p;483,710p;1159,1270p'
nl -ba api/features/signals.py | sed -n '1,260p'
nl -ba api/features/versioning/versioning_service.py | sed -n '1,125p'
rg -n -F -e 'get_environment_flags_list(' -e 'get_live_feature_states(' -e 'feature_state_value' api/evaluation api/util/mappers/sdk.py
nl -ba api/features/constants.py | sed -n '1,25p'
nl -ba api/features/import_export/views.py | sed -n '1,180p'

Repository: Flagsmith/flagsmith

Length of output: 43530


Script executed:

nl -ba api/evaluation/mappers.py | sed -n '185,240p;285,325p;365,425p'
rg -n -e '@hook' -e '^    def ' api/features/models.py | sed -n '35,120p'
nl -ba api/features/models.py | sed -n '745,1020p'
rg -n -F -e 'map_environment' -e 'map_feature_state' api/evaluation/services.py api/evaluation/mappers.py

Repository: Flagsmith/flagsmith

Length of output: 24140


Allow isolated imports into other environments.

If an experiment runs in environment A, a destructive import into environment B fails even when it reuses all existing options. The entire import batch rolls back. Allow this case while still blocking new options and destructive imports into an environment with a running experiment.


Major · Functional Correctness · Heavy lift

Code analysis · TLA+ model checking

Script executed:

rg -n 'overwrite_feature_for_environment|select_for_update|ATOMIC_REQUESTS|isolation_level' api/features/import_export api/experimentation api/app; sed -n '12,84p' api/features/import_export/services.py; sed -n '134,175p' api/features/import_export/tasks.py

Repository: Flagsmith/flagsmith

Length of output: 5261


tla executed:

---- MODULE ReviewModel ----
(* The import takes no feature lock. Start abstracts a complete first-start
   transaction, including its feature lock. It can finish between the import
   check and write. Violation means the import writes while running.
TLC
INIT Init
NEXT Next
INVARIANT Safe
*)
VARIABLES live, phase, started, violation
vars == <<live, phase, started, violation>>
Init == /\ live = FALSE /\ phase = "check"
        /\ started = FALSE /\ violation = FALSE
Check == /\ phase = "check"
         /\ phase' = (IF live THEN "done" ELSE "write")
         /\ UNCHANGED <<live, started, violation>>
Start == /\ ~started /\ started' = TRUE /\ live' = TRUE
         /\ UNCHANGED <<phase, violation>>
Write == /\ phase = "write" /\ phase' = "done"
         /\ violation' = live /\ UNCHANGED <<live, started>>
Idle == /\ phase = "done" /\ started /\ UNCHANGED vars
Next == Check \/ Start \/ Write \/ Idle
Safe == ~violation
====

Length of output: 3770


Script executed:

git diff 13539afcb99c20b4351e26f215d01b49f2a9be03 d564c8d053d14408261767f8c48432435b3adc51 -- api/features/import_export/services.py api/features/import_export/tasks.py api/experimentation/services.py api/features/multivariate/views.py
nl -ba api/features/import_export/services.py | sed -n '1,140p'
nl -ba api/features/import_export/tasks.py | sed -n '105,180p'
nl -ba api/experimentation/services.py | sed -n '40,115p;550,710p'
rg -n -F -e 'overwrite_feature_for_environment(' -e 'has_live_experiment(' -- api/
rg -n -F -e 'FeatureSegment' -e 'experiment' -- api/experimentation/models.py api/experimentation/services.py | sed -n '1,180p'

Repository: Flagsmith/flagsmith

Length of output: 42554


Script executed:

nl -ba api/experimentation/feature_state_metadata.py | sed -n '1,110p'
nl -ba api/experimentation/services.py | sed -n '410,475p;860,910p;910,1105p'
nl -ba api/experimentation/models.py | sed -n '125,210p'
rg -n -F -e 'class FeatureSegment' -e 'class FeatureState' -e 'feature_segment = ' -- api/features/models.py
rg -n -F -e 'apply_experiment_rollout(' -e 'transition_experiment_status(' -- api/experimentation api/tests/unit/experimentation
rg -n -F -e 'rollout_segment' -e 'feature_segment' -- api/features/versioning/versioning_service.py api/experimentation/views.py
nl -ba api/app/settings/common.py | sed -n '190,220p;265,295p'

Repository: Flagsmith/flagsmith

Length of output: 29115


Script executed:

nl -ba api/features/models.py | sed -n '240,360p;480,580p'
nl -ba api/experimentation/serializers.py | sed -n '335,425p'
nl -ba api/experimentation/views.py | sed -n '480,540p;590,645p'
nl -ba api/features/versioning/versioning_service.py | sed -n '100,155p;175,225p'
nl -ba api/tests/unit/features/import_export/test_unit_features_import_export_tasks.py | sed -n '1,180p'
rg -n -F -e 'get_feature_state_metadata_builder' -e 'in_experiment' -- api/ | sed -n '1,140p'
git show 13539afcb99c20b4351e26f215d01b49f2a9be03:api/features/import_export/services.py | sed -n '1,90p'

Repository: Flagsmith/flagsmith

Length of output: 36085


Script executed:

rg -n -F -e 'def enable_experiment_rollout' -e 'def get_environment_flags_list' -e 'def get_environment_document' -- api/experimentation api/features/versioning api/util/mappers
nl -ba api/experimentation/services.py | sed -n '1095,1195p'
nl -ba api/util/mappers/engine.py | sed -n '160,255p'
nl -ba api/features/versioning/versioning_service.py | sed -n '1,110p'
nl -ba api/tests/unit/environments/test_unit_environments_views_sdk_environment.py | sed -n '115,180p'
rg -n -F -e 'rebuild_environment_document' -e 'AFTER_DELETE' -- api/features/models.py api/features/signals.py api/features/import_export/ 2>/dev/null || test "$?" -eq 1

Repository: Flagsmith/flagsmith

Length of output: 16309


Script executed:

rg -n -F -e 'postgres:' -e 'postgresql:' -e 'POSTGRES_VERSION' -- docker-compose.yml docker-compose.yaml .github api 2>/dev/null || test "$?" -eq 1

Repository: Flagsmith/flagsmith

Length of output: 1197


Web query:

PostgreSQL documentation explicit locking SELECT FOR UPDATE ordinary SELECT does not block row locks READ COMMITTED

Result:

Sources consulted:
- PostgreSQL: Documentation: 18: 13.3. Explicit Locking — https://www.postgresql.org/docs/current/explicit-locking.html
- PostgreSQL: Documentation: 19: 13.3. Explicit Locking — https://www.postgresql.org/docs/19/explicit-locking.html
- PostgreSQL: Documentation: 7.3: Explicit Locking — https://www.postgresql.org/docs/7.3/explicit-locking.html
- PostgreSQL: Documentation: 9.0: Explicit Locking — https://www.postgresql.org/docs/9/explicit-locking.html
- PostgreSQL: Documentation: 8.0: Explicit Locking — https://www.postgresql.org/docs/8.0/explicit-locking.html
- …and 11 more

Web query:

site:postgresql.org/docs/15/explicit-locking.html "Row-level locks do not affect data querying"

Result:

Sources consulted:
- PostgreSQL: Documentation: 15: 13.3. Explicit Locking — https://www.postgresql.org/docs/15/explicit-locking.html
- PostgreSQL: Documentation: 14: 13.3. Explicit Locking — https://www.postgresql.org/docs/14/explicit-locking.html
- PostgreSQL 15.17 Documentation — https://www.postgresql.org/files/documentation/pdf/15/postgresql-15-US.pdf
- PostgreSQL: Documentation: 19: 13.3. Explicit Locking — https://www.postgresql.org/docs/19/explicit-locking.html
- PostgreSQL 18.1 Documentation — https://www.postgresql.org/files/documentation/pdf/18/postgresql-18-US.pdf
- …and 13 more

Prevent imports from deleting a newly started rollout.

If an experiment starts after this check, the import can delete its rollout override while the experiment remains running. The experiment then stops serving its configured rollout. Ensure imports cannot remove the override during this transition.

Comment on lines +319 to +323
if self._changes_multivariate_options(
validated_data["multivariate_options"], instance
):
raise serializers.ValidationError(
{"multivariate_options": LIVE_EXPERIMENT_MV_OPTIONS_MESSAGE}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Functional Correctness · Low value

Supported by static analysis

Script executed:

sed -n '1,180p' api/features/multivariate/serializers.py; rg -n 'class DeleteBeforeUpdateWritableNestedModelSerializer|partial=True|multivariate_options' api/tests/unit/features/test* api/core/serializers*

Repository: Flagsmith/flagsmith

Length of output: 8164


Script executed:

git diff 13539afcb99c20b4351e26f215d01b49f2a9be03 d564c8d053d14408261767f8c48432435b3adc51 -- api/features/serializers.py api/tests/unit/features/multivariate/test_unit_multivariate_live_experiment.py
nl -ba api/features/serializers.py | sed -n '1,390p'
rg -n -F -e 'DeleteBeforeUpdateWritableNestedModelSerializer' -e 'class FeatureViewSet' -e 'class NestedMultivariateFeatureOptionSerializer' -e '_changes_multivariate_options' -- api

Repository: Flagsmith/flagsmith

Length of output: 31654


Script executed:

nl -ba api/util/drf_writable_nested/serializers.py | sed -n '1,180p'
nl -ba api/features/multivariate/serializers.py | sed -n '1,55p'
rg -n -F -e 'class MultivariateFeatureOption' -e 'class FeatureViewSet' -e 'CreateFeatureSerializer' -- api/features/multivariate/models.py api/features/views.py
nl -ba api/features/multivariate/models.py | sed -n '1,180p'
nl -ba api/features/views.py | sed -n '200,440p'
rg -n -i -e 'drf-writable-nested' -e 'djangorestframework' -- api/pyproject.toml api/poetry.lock api/requirements* pyproject.toml uv.lock 2>/dev/null
rg -n -F -e 'partial' -e 'multivariate_options' -- api/tests/unit/features/test_unit_features_serializers.py api/tests/unit/features/multivariate/test_unit_multivariate_live_experiment.py

Repository: Flagsmith/flagsmith

Length of output: 26910


Script executed:

rg -n -F -e 'class UpdateFeatureSerializer' -e 'class ListFeatureSerializer' -e 'class ProjectFeatureSerializer' -- api/features/serializers.py
rg -n -F -e 'name = "drf-writable-nested"' -e 'name = "djangorestframework"' -- api/uv.lock uv.lock api/poetry.lock 2>/dev/null
rg -n -F 'class AbstractBaseFeatureValueModel' api/features/feature_states/models.py
nl -ba api/features/feature_states/models.py | sed -n '1,125p'
rg -n -F -e 'description' -e 'partial_update' -- api/tests/unit/features/test_unit_features_views.py | sed -n '1,70p'

Repository: Flagsmith/flagsmith

Length of output: 5679


Web query:

github beda-software drf-writable-nested 0.6.4 mixins.py update_or_create_reverse_relations _get_serializer partial delete_reverse_relations_if_need source

Result:

Sources consulted:
- https://raw.githubusercontent.com/beda-software/drf-writable-nested/v0.6.4/drf_writable_nested/mixins.py
- drf-writable-nested/drf_writable_nested/mixins.py at v0.6.4 · beda-software/drf-writable-nested · GitHub — https://github.com/beda-software/drf-writable-nested/blob/v0.6.4/drf_writable_nested/mixins.py
- GitHub - beda-software/drf-writable-nested: Writable nested model serializer for Django REST Framework · GitHub — https://github.com/beda-software/drf-writable-nested
- drf-writable-nested · PyPI — https://pypi.org/project/drf-writable-nested/0.5.4/
- …and 53 more

Script executed:

nl -ba api/uv.lock | sed -n '1112,1127p;1234,1250p'
nl -ba api/features/serializers.py | sed -n '470,610p'
nl -ba api/tests/unit/features/test_unit_features_views.py | sed -n '655,701p'
nl -ba api/tests/unit/features/multivariate/test_unit_multivariate_live_experiment.py | sed -n '175,233p'

Repository: Flagsmith/flagsmith

Length of output: 12996


Web query:

https://raw.githubusercontent.com/beda-software/drf-writable-nested/v0.6.4/drf_writable_nested/mixins.py "def update_or_create_reverse_relations" "def delete_reverse_relations_if_need" _get_related_pk serializer.save source code

Result:

Sources consulted:
- https://raw.githubusercontent.com/beda-software/drf-writable-nested/v0.6.4/drf_writable_nested/mixins.py

Web query:

django rest framework 3.18.3 fields.py validate_empty_values root.partial SkipField serializers.py ModelSerializer update source

Result:

Sources consulted:
- django-rest-framework/rest_framework/fields.py at 3.18.3 · encode/django-rest-framework · GitHub — https://github.com/encode/django-rest-framework/blob/3.18.3/rest_framework/fields.py
- django-rest-framework/rest_framework/serializers.py at 3.18.3 · encode/django-rest-framework · GitHub — https://github.com/encode/django-rest-framework/blob/3.18.3/rest_framework/serializers.py
- …and 33 more

Web query:

https://raw.githubusercontent.com/encode/django-rest-framework/3.18.3/rest_framework/serializers.py "def update(self, instance, validated_data)" "setattr(instance, attr, value)" ModelSerializer

Result:

Sources consulted:
- https://raw.githubusercontent.com/encode/django-rest-framework/3.18.3/rest_framework/serializers.py

Compare partial options against their stored values.

During a live experiment, a PATCH that resubmits an option’s ID and unchanged string_value can receive HTTP 400 if it omits type or allocation. The comparison treats those fields as None and blocks the description save. Resolve omitted fields from the identified option in both checks.

project=project, name="created before failure"
).exists()
assert FeatureSegment.objects.filter(pk=feature_segment.pk).exists()
assert feature.feature_states.get(environment=environment).enabled is False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trivial · Maintainability & Code Quality · Low value

Supported by static analysis

Script executed:

sed -n '40,165p' api/tests/unit/features/import_export/test_unit_features_import_export_tasks.py
sed -n '1,115p' api/features/import_export/services.py
sed -n '125,185p' api/features/import_export/tasks.py

Repository: Flagsmith/flagsmith

Length of output: 9584


Script executed:

printf '%s\n' '--- import task test module ---'
nl -ba api/tests/unit/features/import_export/test_unit_features_import_export_tasks.py | sed -n '1,165p'
printf '%s\n' '--- test coverage in import/export tests ---'
rg -n -i 'rollback|rolls.back|atomic|FAILED|overwrite|enabled' api/tests/unit/features/import_export
printf '%s\n' '--- feature fixture ---'
nl -ba api/tests/conftest.py | sed -n '625,660p'
printf '%s\n' '--- model state creation ---'
nl -ba api/features/models.py | sed -n '115,135p;175,200p;915,950p'

Repository: Flagsmith/flagsmith

Length of output: 20115


Use a different initial enabled value.

The initial and imported values are both False, so the assertion cannot detect an imported False replacing the initial value. Start with True to test preservation after rejection.

Suggested change

     # Given
+    feature_state = feature.feature_states.get(environment=environment)
+    feature_state.enabled = True
+    feature_state.save()
     Experiment.objects.create(
-    assert feature.feature_states.get(environment=environment).enabled is False
+    assert feature.feature_states.get(environment=environment).enabled is True

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant