From 07463d58dc82ee2bdf8a453927674a36ef1847d3 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 23 Sep 2026 19:50:44 +0100 Subject: [PATCH 1/6] fix(Segments): Restore segment list performance on large installations `get_all_live_or_scheduled_overrides` matched a feature state's version against `EnvironmentFeatureVersion.objects.get_live_or_scheduled()`, an uncorrelated subquery over every published version in the installation. On a reported project, the `has_overrides` annotation on the segment list took 59 seconds to return 156 rows, almost all of it spent in a merge anti-join reading ~90k and ~147k version rows, 313 times over. Correlate the superseded-version check with the feature state's own feature and environment instead, which the existing `efv_env_feature_pub_created` index already covers. The same query then runs in 3.6ms, and the planner drops the merge joins that scanning drove it to, reading `features_featuresegment` by segment rather than by primary key. Co-Authored-By: Claude Opus 5 (1M context) --- api/segments/services.py | 16 ++- .../segments/test_unit_segments_services.py | 103 +++++++++++++++++- 2 files changed, 114 insertions(+), 5 deletions(-) diff --git a/api/segments/services.py b/api/segments/services.py index 198ccf214da6..387e0272ff9f 100644 --- a/api/segments/services.py +++ b/api/segments/services.py @@ -22,12 +22,20 @@ def get_all_live_or_scheduled_overrides() -> "QuerySet[FeatureSegment]": environment__use_v2_feature_versioning=False, ) & (no_change_request | committed_change_request) + # Scoped to the feature state's own feature and environment: comparing + # against live or scheduled versions at large reads every version row in + # the installation. + superseding_versions = EnvironmentFeatureVersion.objects.filter( + environment_id=models.OuterRef("environment_id"), + feature_id=models.OuterRef("feature_id"), + published_at__isnull=False, + live_from__gt=models.OuterRef("environment_feature_version__live_from"), + live_from__lte=timezone.now(), + ) with_feature_versioning_v2 = models.Q( environment__use_v2_feature_versioning=True, - environment_feature_version__in=( - EnvironmentFeatureVersion.objects.get_live_or_scheduled() - ), - ) + environment_feature_version__published_at__isnull=False, + ) & ~models.Exists(superseding_versions) live_or_scheduled_feature_states = FeatureState.objects.filter( with_feature_versioning_v1 | with_feature_versioning_v2, diff --git a/api/tests/unit/segments/test_unit_segments_services.py b/api/tests/unit/segments/test_unit_segments_services.py index 93c0be9e3389..d68de5fb80da 100644 --- a/api/tests/unit/segments/test_unit_segments_services.py +++ b/api/tests/unit/segments/test_unit_segments_services.py @@ -1,8 +1,9 @@ from datetime import timedelta -from typing import cast +from typing import Any, cast import pytest from django.db import connection, reset_queries +from django.db.models import QuerySet from django.test.utils import CaptureQueriesContext from django.utils import timezone from flag_engine.segments.constants import EQUAL @@ -552,3 +553,103 @@ def test_get_all_live_or_scheduled_overrides__feature_versioning_v2_scheduled_fe # Then assert overrides == [live_override, scheduled_override] + + +# Enough versions that reading all of them is unmistakable in a query plan, +# while still seeding in well under a second. +UNRELATED_VERSION_COUNT = 2_000 + + +def _create_unrelated_versions(count: int) -> int: + """Fill a second project with published versions. Returns the table's size.""" + organisation = Organisation.objects.create(name="Unrelated organisation") + project = Project.objects.create( + name="Unrelated project", organisation=organisation + ) + environment = Environment.objects.create( + name="Unrelated environment", project=project, use_v2_feature_versioning=True + ) + features = Feature.objects.bulk_create( + [Feature(project=project, name=f"unrelated_{i}") for i in range(count // 10)] + ) + now = timezone.now() + EnvironmentFeatureVersion.objects.bulk_create( + [ + EnvironmentFeatureVersion( + environment=environment, + feature=feature, + published_at=now, + live_from=now - timedelta(days=version + 1), + ) + for feature in features + for version in range(10) + ] + ) + with connection.cursor() as cursor: + cursor.execute("ANALYZE feature_versioning_environmentfeatureversion") + return int(EnvironmentFeatureVersion.objects.count()) + + +def _count_version_rows_read(queryset: "QuerySet[FeatureSegment]") -> int: + """Total rows the plan reads from the versions table, per EXPLAIN ANALYZE.""" + sql, params = queryset.query.sql_with_params() + with connection.cursor() as cursor: + cursor.execute(f"EXPLAIN (ANALYZE, FORMAT JSON) {sql}", params) + plan = cursor.fetchone()[0][0]["Plan"] + + def walk(node: dict[str, Any]) -> int: + rows = 0 + if node.get("Relation Name") == "feature_versioning_environmentfeatureversion": + rows = int(node["Actual Rows"]) * int(node["Actual Loops"]) + return rows + sum(walk(child) for child in node.get("Plans", [])) + + return walk(plan) + + +def test_get_all_live_or_scheduled_overrides__unrelated_versions_exist__does_not_read_them( + environment_v2_versioning: Environment, + feature: Feature, + segment: Segment, +) -> None: + """Evaluating the override check must not read another project's versions. + + The check has to decide whether a feature state's version has been + superseded. Comparing against the set of live or scheduled versions at + large reads every version row in the installation, which is fast enough on + a small database to pass every other test in this module and ruinous on a + real one. + """ + # Given + # An override on the newest version of a feature, so the check has to look + # at whether that version has been superseded rather than short-circuiting. + version = EnvironmentFeatureVersion.objects.get( + environment=environment_v2_versioning, feature=feature + ) + override = FeatureSegment.objects.create( + feature=feature, + segment=segment, + environment=environment_v2_versioning, + environment_feature_version=version, + ) + FeatureState.objects.create( + feature_segment=override, + feature=feature, + environment=environment_v2_versioning, + environment_feature_version=version, + ) + + # And a second project holding far more versions than the one queried. + unrelated_versions = _create_unrelated_versions(count=UNRELATED_VERSION_COUNT) + assert unrelated_versions > UNRELATED_VERSION_COUNT + + # When + versions_read = _count_version_rows_read( + get_all_live_or_scheduled_overrides().filter(segment=segment) + ) + + # Then + assert list(get_all_live_or_scheduled_overrides()) == [override] + + # Scoped to the override's own version, this reads a single row. Compared + # against live or scheduled versions at large, it reads the whole table. + assert versions_read < unrelated_versions / 10 From aca67a31eec660e9b3540fd82f375c7174810b87 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 23 Sep 2026 21:46:50 +0100 Subject: [PATCH 2/6] Deslop --- api/segments/services.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/api/segments/services.py b/api/segments/services.py index 387e0272ff9f..66829be2a813 100644 --- a/api/segments/services.py +++ b/api/segments/services.py @@ -22,9 +22,8 @@ def get_all_live_or_scheduled_overrides() -> "QuerySet[FeatureSegment]": environment__use_v2_feature_versioning=False, ) & (no_change_request | committed_change_request) - # Scoped to the feature state's own feature and environment: comparing - # against live or scheduled versions at large reads every version row in - # the installation. + # Use OuterRefs to ensure the query is scoped to the feature state's own + # feature and environment superseding_versions = EnvironmentFeatureVersion.objects.filter( environment_id=models.OuterRef("environment_id"), feature_id=models.OuterRef("feature_id"), From c2d1e06fa72e0f3b9609667dd539db702d815ece Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:47:04 +0000 Subject: [PATCH 3/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/segments/services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/segments/services.py b/api/segments/services.py index 66829be2a813..c33b15068e5d 100644 --- a/api/segments/services.py +++ b/api/segments/services.py @@ -22,7 +22,7 @@ def get_all_live_or_scheduled_overrides() -> "QuerySet[FeatureSegment]": environment__use_v2_feature_versioning=False, ) & (no_change_request | committed_change_request) - # Use OuterRefs to ensure the query is scoped to the feature state's own + # Use OuterRefs to ensure the query is scoped to the feature state's own # feature and environment superseding_versions = EnvironmentFeatureVersion.objects.filter( environment_id=models.OuterRef("environment_id"), From 53ae4aa9d1f4359ebf84c9b81e191880a980be2c Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 23 Sep 2026 21:54:58 +0100 Subject: [PATCH 4/6] test(segments): Apply review feedback to the override scan test Drop the module-level constant and the helper's return value, and assert the exact number of version rows the plan reads rather than a fraction of the table's size. Co-Authored-By: Claude Opus 5 (1M context) --- .../segments/test_unit_segments_services.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/api/tests/unit/segments/test_unit_segments_services.py b/api/tests/unit/segments/test_unit_segments_services.py index d68de5fb80da..41672b072d94 100644 --- a/api/tests/unit/segments/test_unit_segments_services.py +++ b/api/tests/unit/segments/test_unit_segments_services.py @@ -555,13 +555,8 @@ def test_get_all_live_or_scheduled_overrides__feature_versioning_v2_scheduled_fe assert overrides == [live_override, scheduled_override] -# Enough versions that reading all of them is unmistakable in a query plan, -# while still seeding in well under a second. -UNRELATED_VERSION_COUNT = 2_000 - - -def _create_unrelated_versions(count: int) -> int: - """Fill a second project with published versions. Returns the table's size.""" +def _create_unrelated_versions(count: int) -> None: + """Fill a second project with published versions.""" organisation = Organisation.objects.create(name="Unrelated organisation") project = Project.objects.create( name="Unrelated project", organisation=organisation @@ -587,7 +582,6 @@ def _create_unrelated_versions(count: int) -> int: ) with connection.cursor() as cursor: cursor.execute("ANALYZE feature_versioning_environmentfeatureversion") - return int(EnvironmentFeatureVersion.objects.count()) def _count_version_rows_read(queryset: "QuerySet[FeatureSegment]") -> int: @@ -639,8 +633,7 @@ def test_get_all_live_or_scheduled_overrides__unrelated_versions_exist__does_not ) # And a second project holding far more versions than the one queried. - unrelated_versions = _create_unrelated_versions(count=UNRELATED_VERSION_COUNT) - assert unrelated_versions > UNRELATED_VERSION_COUNT + _create_unrelated_versions(count=2_000) # When versions_read = _count_version_rows_read( @@ -650,6 +643,5 @@ def test_get_all_live_or_scheduled_overrides__unrelated_versions_exist__does_not # Then assert list(get_all_live_or_scheduled_overrides()) == [override] - # Scoped to the override's own version, this reads a single row. Compared - # against live or scheduled versions at large, it reads the whole table. - assert versions_read < unrelated_versions / 10 + # The version the override points at, and nothing else. + assert versions_read == 1 From b77585aaacdb5aebcc6754d290044906b2b20c7b Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 23 Sep 2026 22:29:11 +0100 Subject: [PATCH 5/6] Remove sloppy/pointless test --- .../segments/test_unit_segments_services.py | 95 +------------------ 1 file changed, 1 insertion(+), 94 deletions(-) diff --git a/api/tests/unit/segments/test_unit_segments_services.py b/api/tests/unit/segments/test_unit_segments_services.py index 41672b072d94..93c0be9e3389 100644 --- a/api/tests/unit/segments/test_unit_segments_services.py +++ b/api/tests/unit/segments/test_unit_segments_services.py @@ -1,9 +1,8 @@ from datetime import timedelta -from typing import Any, cast +from typing import cast import pytest from django.db import connection, reset_queries -from django.db.models import QuerySet from django.test.utils import CaptureQueriesContext from django.utils import timezone from flag_engine.segments.constants import EQUAL @@ -553,95 +552,3 @@ def test_get_all_live_or_scheduled_overrides__feature_versioning_v2_scheduled_fe # Then assert overrides == [live_override, scheduled_override] - - -def _create_unrelated_versions(count: int) -> None: - """Fill a second project with published versions.""" - organisation = Organisation.objects.create(name="Unrelated organisation") - project = Project.objects.create( - name="Unrelated project", organisation=organisation - ) - environment = Environment.objects.create( - name="Unrelated environment", project=project, use_v2_feature_versioning=True - ) - features = Feature.objects.bulk_create( - [Feature(project=project, name=f"unrelated_{i}") for i in range(count // 10)] - ) - now = timezone.now() - EnvironmentFeatureVersion.objects.bulk_create( - [ - EnvironmentFeatureVersion( - environment=environment, - feature=feature, - published_at=now, - live_from=now - timedelta(days=version + 1), - ) - for feature in features - for version in range(10) - ] - ) - with connection.cursor() as cursor: - cursor.execute("ANALYZE feature_versioning_environmentfeatureversion") - - -def _count_version_rows_read(queryset: "QuerySet[FeatureSegment]") -> int: - """Total rows the plan reads from the versions table, per EXPLAIN ANALYZE.""" - sql, params = queryset.query.sql_with_params() - with connection.cursor() as cursor: - cursor.execute(f"EXPLAIN (ANALYZE, FORMAT JSON) {sql}", params) - plan = cursor.fetchone()[0][0]["Plan"] - - def walk(node: dict[str, Any]) -> int: - rows = 0 - if node.get("Relation Name") == "feature_versioning_environmentfeatureversion": - rows = int(node["Actual Rows"]) * int(node["Actual Loops"]) - return rows + sum(walk(child) for child in node.get("Plans", [])) - - return walk(plan) - - -def test_get_all_live_or_scheduled_overrides__unrelated_versions_exist__does_not_read_them( - environment_v2_versioning: Environment, - feature: Feature, - segment: Segment, -) -> None: - """Evaluating the override check must not read another project's versions. - - The check has to decide whether a feature state's version has been - superseded. Comparing against the set of live or scheduled versions at - large reads every version row in the installation, which is fast enough on - a small database to pass every other test in this module and ruinous on a - real one. - """ - # Given - # An override on the newest version of a feature, so the check has to look - # at whether that version has been superseded rather than short-circuiting. - version = EnvironmentFeatureVersion.objects.get( - environment=environment_v2_versioning, feature=feature - ) - override = FeatureSegment.objects.create( - feature=feature, - segment=segment, - environment=environment_v2_versioning, - environment_feature_version=version, - ) - FeatureState.objects.create( - feature_segment=override, - feature=feature, - environment=environment_v2_versioning, - environment_feature_version=version, - ) - - # And a second project holding far more versions than the one queried. - _create_unrelated_versions(count=2_000) - - # When - versions_read = _count_version_rows_read( - get_all_live_or_scheduled_overrides().filter(segment=segment) - ) - - # Then - assert list(get_all_live_or_scheduled_overrides()) == [override] - - # The version the override points at, and nothing else. - assert versions_read == 1 From 89534b8ad541d35f4f82ea471a790dc1ccea786f Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 23 Sep 2026 22:36:29 +0100 Subject: [PATCH 6/6] Improve comment --- api/segments/services.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/segments/services.py b/api/segments/services.py index c33b15068e5d..33ba63adc0c2 100644 --- a/api/segments/services.py +++ b/api/segments/services.py @@ -22,8 +22,6 @@ def get_all_live_or_scheduled_overrides() -> "QuerySet[FeatureSegment]": environment__use_v2_feature_versioning=False, ) & (no_change_request | committed_change_request) - # Use OuterRefs to ensure the query is scoped to the feature state's own - # feature and environment superseding_versions = EnvironmentFeatureVersion.objects.filter( environment_id=models.OuterRef("environment_id"), feature_id=models.OuterRef("feature_id"), @@ -31,6 +29,8 @@ def get_all_live_or_scheduled_overrides() -> "QuerySet[FeatureSegment]": live_from__gt=models.OuterRef("environment_feature_version__live_from"), live_from__lte=timezone.now(), ) + # Filtering on not superseded is the same as filtering on the latest + # live EFV but uses the index on feature, environment. with_feature_versioning_v2 = models.Q( environment__use_v2_feature_versioning=True, environment_feature_version__published_at__isnull=False,