Skip to content

Commit 3f74d46

Browse files
emyllerclaude
andauthored
fix(Code References): Code references are slow to query (#7463)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7c664b3 commit 3f74d46

14 files changed

Lines changed: 1063 additions & 344 deletions

File tree

api/features/serializers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,7 @@ def get_last_modified_in_current_environment(
443443
class FeatureSerializerWithMetadata(MetadataSerializerMixin, CreateFeatureSerializer):
444444
metadata = MetadataSerializer(required=False, many=True)
445445

446+
# NOTE: This field is populated by `projects.code_references.services.annotate_feature_queryset_with_code_references_summary`.
446447
code_references_counts = FeatureFlagCodeReferencesRepositoryCountSerializer(
447448
many=True,
448449
read_only=True,

api/features/views.py

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,11 @@
66
from common.core.utils import is_database_replica_setup, using_database_replica
77
from common.projects.permissions import VIEW_PROJECT
88
from django.conf import settings
9-
from django.contrib.postgres.fields import ArrayField
109
from django.core.cache import caches
1110
from django.db.models import (
1211
BooleanField,
1312
Case,
1413
Exists,
15-
JSONField,
1614
Max,
1715
OuterRef,
1816
Q,
@@ -62,7 +60,6 @@
6260
NestedEnvironmentPermissions,
6361
)
6462
from features.value_types import BOOLEAN, INTEGER, STRING
65-
from integrations.flagsmith.client import get_openfeature_client
6663
from projects.code_references.services import (
6764
annotate_feature_queryset_with_code_references_summary,
6865
)
@@ -221,18 +218,7 @@ def get_queryset(self): # type: ignore[no-untyped-def]
221218
query_serializer.is_valid(raise_exception=True)
222219
query_data = query_serializer.validated_data
223220

224-
# TODO: Delete this after https://github.com/flagsmith/flagsmith/issues/6832 is resolved
225-
organisation = project.organisation
226-
if get_openfeature_client().get_boolean_value(
227-
"code_references_ui_stats",
228-
default_value=False,
229-
evaluation_context=organisation.openfeature_evaluation_context,
230-
):
231-
queryset = annotate_feature_queryset_with_code_references_summary(queryset)
232-
else:
233-
queryset = queryset.annotate(
234-
code_references_counts=Value([], output_field=ArrayField(JSONField()))
235-
)
221+
queryset = annotate_feature_queryset_with_code_references_summary(queryset)
236222

237223
queryset = self._filter_queryset(queryset, query_serializer)
238224

api/integrations/flagsmith/data/environment.json

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -92,19 +92,6 @@
9292
"featurestate_uuid": "e0d380a6-bdbc-4ad6-ae6f-b8b77d8beae6",
9393
"multivariate_feature_state_values": []
9494
},
95-
{
96-
"django_id": 1212320,
97-
"enabled": false,
98-
"feature": {
99-
"id": 192793,
100-
"name": "code_references_ui_stats",
101-
"type": "STANDARD"
102-
},
103-
"feature_segment": null,
104-
"feature_state_value": null,
105-
"featurestate_uuid": "f976df2f-2341-4623-8425-d6eda23a2ebc",
106-
"multivariate_feature_state_values": []
107-
},
10895
{
10996
"django_id": 1229327,
11097
"enabled": false,
Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,2 @@
1-
# TODO: Implement history cleanup?
2-
FEATURE_FLAG_CODE_REFERENCES_RETENTION_DAYS = 30
3-
41
# Linux maximum file path length, as per limits.h/PATH_MAX
52
MAX_FILE_PATH_LENGTH = 4096
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import hashlib
2+
import json
3+
from itertools import groupby
4+
from operator import attrgetter
5+
from typing import TypedDict
6+
7+
import django.db.models.deletion
8+
from django.apps.registry import Apps
9+
from django.db import migrations, models
10+
from django.db.models import Max
11+
12+
13+
class LegacyCodeReference(TypedDict):
14+
feature_name: str
15+
file_path: str
16+
line_number: int
17+
18+
19+
class StoredCodeReference(TypedDict):
20+
file_path: str
21+
line_number: int
22+
23+
24+
def _hash_references(references: list[StoredCodeReference]) -> str:
25+
return hashlib.md5(
26+
json.dumps(references, sort_keys=True).encode(),
27+
usedforsecurity=False,
28+
).hexdigest()
29+
30+
31+
def migrate_scans_forward(apps: Apps, _: object) -> None:
32+
"""Split each legacy scan into new cardinality (per-repository and per-feature)"""
33+
34+
LegacyScan = apps.get_model("code_references", "FeatureFlagCodeReferencesScan")
35+
PerFeatureScan = apps.get_model("code_references", "ScannedCodeReferences")
36+
Repository = apps.get_model("code_references", "VCSRepository")
37+
Feature = apps.get_model("features", "Feature")
38+
39+
legacy_scans_summaries = LegacyScan.objects.values(
40+
"project_id",
41+
"repository_url",
42+
"vcs_provider",
43+
).annotate(last_scanned_at=Max("created_at"))
44+
45+
repositories = {
46+
(summary["project_id"], summary["repository_url"]): Repository.objects.create(
47+
project_id=summary["project_id"],
48+
url=summary["repository_url"],
49+
vcs_provider=summary["vcs_provider"],
50+
last_scanned_at=summary["last_scanned_at"],
51+
)
52+
for summary in legacy_scans_summaries
53+
}
54+
55+
# Oldest-first per project so the newest scan wins on hash collisions
56+
legacy_scans = LegacyScan.objects.order_by("project_id", "created_at").iterator()
57+
grouped_scans = groupby(legacy_scans, key=attrgetter("project_id"))
58+
for project_id, project_scans in grouped_scans:
59+
features = {
60+
(feature.project_id, feature.name): feature
61+
for feature in Feature.objects.filter(
62+
project_id=project_id,
63+
deleted_at__isnull=True, # Historical models drop SoftDeleteManager
64+
)
65+
}
66+
for legacy_scan in project_scans:
67+
repository_url = legacy_scan.repository_url
68+
repository = repositories[project_id, repository_url]
69+
70+
references_by_feature: dict[str, list[StoredCodeReference]] = {}
71+
for reference in legacy_scan.code_references:
72+
feature_name = reference["feature_name"]
73+
references_by_feature.setdefault(feature_name, []).append(
74+
StoredCodeReference(
75+
file_path=reference["file_path"],
76+
line_number=reference["line_number"],
77+
)
78+
)
79+
80+
for feature_name, references in references_by_feature.items():
81+
if not (feature := features.get((project_id, feature_name))):
82+
continue
83+
PerFeatureScan.objects.update_or_create(
84+
feature=feature,
85+
repository=repository,
86+
code_references_hash=_hash_references(references),
87+
defaults={
88+
"revision": legacy_scan.revision,
89+
"code_references": references,
90+
"created_at": legacy_scan.created_at,
91+
},
92+
)
93+
94+
95+
def migrate_scans_backward(apps: Apps, _: object) -> None:
96+
"""Mirror each per-feature row back into the legacy single-table layout."""
97+
LegacyScan = apps.get_model("code_references", "FeatureFlagCodeReferencesScan")
98+
PerFeatureScan = apps.get_model("code_references", "ScannedCodeReferences")
99+
LegacyScan._meta.get_field("created_at").auto_now_add = False
100+
101+
per_feature_scans = PerFeatureScan.objects.select_related(
102+
"repository",
103+
"feature",
104+
).iterator(chunk_size=200)
105+
106+
for per_feature_scan in per_feature_scans:
107+
repository = per_feature_scan.repository
108+
feature_name = per_feature_scan.feature.name
109+
LegacyScan.objects.create(
110+
project_id=repository.project_id,
111+
repository_url=repository.url,
112+
vcs_provider=repository.vcs_provider,
113+
revision=per_feature_scan.revision,
114+
code_references=[
115+
{"feature_name": feature_name, **reference}
116+
for reference in per_feature_scan.code_references
117+
],
118+
created_at=per_feature_scan.created_at,
119+
)
120+
121+
122+
class Migration(migrations.Migration):
123+
dependencies = [
124+
("code_references", "0002_add_project_repo_created_index"),
125+
("features", "0066_constrain_feature_type"),
126+
("projects", "0029_bump_default_project_limits"),
127+
]
128+
129+
operations = [
130+
migrations.CreateModel(
131+
name="VCSRepository",
132+
fields=[
133+
(
134+
"id",
135+
models.AutoField(
136+
auto_created=True,
137+
primary_key=True,
138+
serialize=False,
139+
verbose_name="ID",
140+
),
141+
),
142+
("created_at", models.DateTimeField(auto_now_add=True)),
143+
("url", models.URLField()),
144+
(
145+
"vcs_provider",
146+
models.CharField(
147+
choices=[("github", "GitHub")],
148+
max_length=50,
149+
),
150+
),
151+
("last_scanned_at", models.DateTimeField(null=True)),
152+
(
153+
"project",
154+
models.ForeignKey(
155+
on_delete=django.db.models.deletion.CASCADE,
156+
related_name="vcs_repositories",
157+
to="projects.project",
158+
),
159+
),
160+
],
161+
),
162+
migrations.AddConstraint(
163+
model_name="vcsrepository",
164+
constraint=models.UniqueConstraint(
165+
fields=("project", "url"),
166+
name="unique_vcs_repository",
167+
),
168+
),
169+
migrations.CreateModel(
170+
name="ScannedCodeReferences",
171+
fields=[
172+
(
173+
"id",
174+
models.AutoField(
175+
auto_created=True,
176+
primary_key=True,
177+
serialize=False,
178+
verbose_name="ID",
179+
),
180+
),
181+
("created_at", models.DateTimeField()),
182+
("revision", models.CharField(max_length=100)),
183+
("code_references", models.JSONField(default=list)),
184+
("code_references_hash", models.CharField(max_length=32)),
185+
(
186+
"feature",
187+
models.ForeignKey(
188+
on_delete=django.db.models.deletion.CASCADE,
189+
related_name="scanned_code_references",
190+
to="features.feature",
191+
),
192+
),
193+
(
194+
"repository",
195+
models.ForeignKey(
196+
on_delete=django.db.models.deletion.CASCADE,
197+
related_name="scanned_code_references",
198+
to="code_references.vcsrepository",
199+
),
200+
),
201+
],
202+
),
203+
migrations.AddConstraint(
204+
model_name="scannedcodereferences",
205+
constraint=models.UniqueConstraint(
206+
fields=("feature", "repository", "code_references_hash"),
207+
name="unique_scanned_code_references",
208+
),
209+
),
210+
migrations.AddIndex(
211+
model_name="scannedcodereferences",
212+
index=models.Index(
213+
fields=("feature", "repository", "created_at"),
214+
name="cr_feature_repo_created_idx",
215+
),
216+
),
217+
migrations.RunPython(
218+
code=migrate_scans_forward,
219+
reverse_code=migrate_scans_backward,
220+
),
221+
migrations.DeleteModel(
222+
name="FeatureFlagCodeReferencesScan",
223+
),
224+
]
Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,75 @@
11
from django.db import models
22

3-
from projects.code_references.types import JSONCodeReference, VCSProvider
3+
from projects.code_references.types import StoredCodeReference, VCSProvider
44

55

6-
class FeatureFlagCodeReferencesScan(models.Model):
6+
class VCSRepository(models.Model):
77
"""
8-
A scan of feature flag code references in a repository
8+
A VCS repository that is scanned for feature flag code references
99
"""
1010

11+
created_at = models.DateTimeField(auto_now_add=True)
12+
1113
project = models.ForeignKey(
1214
"projects.Project",
1315
on_delete=models.CASCADE,
14-
related_name="code_references",
16+
related_name="vcs_repositories",
1517
)
1618

1719
# Provider-agnostic URL to the web UI of the repository, e.g. https://github.flagsmith.com/backend/
18-
repository_url = models.URLField()
20+
url = models.URLField()
1921

2022
vcs_provider = models.CharField(
2123
max_length=50,
2224
choices=VCSProvider.choices,
23-
default=VCSProvider.GITHUB, # TODO: Remove when adding other providers
2425
)
26+
27+
last_scanned_at = models.DateTimeField(null=True)
28+
29+
class Meta:
30+
constraints = [
31+
models.UniqueConstraint(
32+
fields=["project", "url"],
33+
name="unique_vcs_repository",
34+
),
35+
]
36+
37+
38+
class ScannedCodeReferences(models.Model):
39+
"""
40+
A list of code references for a feature scanned from a VCS repository
41+
"""
42+
43+
created_at = models.DateTimeField()
44+
45+
feature = models.ForeignKey(
46+
"features.Feature",
47+
on_delete=models.CASCADE,
48+
related_name="scanned_code_references",
49+
)
50+
51+
repository = models.ForeignKey(
52+
VCSRepository,
53+
on_delete=models.CASCADE,
54+
related_name="scanned_code_references",
55+
)
56+
2557
revision = models.CharField(max_length=100)
26-
code_references = models.JSONField[list[JSONCodeReference]](default=list)
2758

28-
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
59+
code_references = models.JSONField[list[StoredCodeReference]](default=list)
60+
61+
code_references_hash = models.CharField(max_length=32)
2962

3063
class Meta:
31-
ordering = ["-created_at"]
64+
constraints = [
65+
models.UniqueConstraint( # Supports batch-insert with ignore-conflicts
66+
fields=["feature", "repository", "code_references_hash"],
67+
name="unique_scanned_code_references",
68+
),
69+
]
3270
indexes = [
33-
models.Index(
34-
fields=["project", "repository_url", "-created_at"],
35-
name="code_ref_proj_repo_created_idx",
71+
models.Index( # Supports finding the latest scan for a feature/repository
72+
fields=["feature", "repository", "created_at"],
73+
name="cr_feature_repo_created_idx",
3674
),
3775
]

0 commit comments

Comments
 (0)