Skip to content

Commit 08e88c3

Browse files
feat: add permanent environment document cache (#5187)
1 parent 86098a6 commit 08e88c3

18 files changed

Lines changed: 344 additions & 46 deletions

File tree

api/app/settings/common.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
from app.routers import ReplicaReadStrategy
3030
from app.utils import get_numbered_env_vars_with_prefix
31+
from environments.enums import EnvironmentDocumentCacheMode
3132

3233
django_stubs_ext.monkeypatch()
3334

@@ -703,8 +704,30 @@
703704
"django.core.cache.backends.locmem.LocMemCache",
704705
)
705706

707+
CACHE_ENVIRONMENT_DOCUMENT_LOCATION = env(
708+
"CACHE_ENVIRONMENT_DOCUMENT_LOCATION", default="environment-documents"
709+
)
710+
CACHE_ENVIRONMENT_DOCUMENT_BACKEND = env(
711+
"CACHE_ENVIRONMENT_DOCUMENT_BACKEND", "django.core.cache.backends.db.DatabaseCache"
712+
)
713+
CACHE_ENVIRONMENT_DOCUMENT_MODE = env.enum(
714+
"CACHE_ENVIRONMENT_DOCUMENT_MODE",
715+
enum=EnvironmentDocumentCacheMode,
716+
default=EnvironmentDocumentCacheMode.EXPIRING.value,
717+
)
706718
CACHE_ENVIRONMENT_DOCUMENT_SECONDS = env.int("CACHE_ENVIRONMENT_DOCUMENT_SECONDS", 0)
707-
ENVIRONMENT_DOCUMENT_CACHE_LOCATION = "environment-documents"
719+
CACHE_ENVIRONMENT_DOCUMENT_OPTIONS = env.json(
720+
"CACHE_ENVIRONMENT_DOCUMENT_OPTIONS", default=None
721+
)
722+
723+
if (
724+
CACHE_ENVIRONMENT_DOCUMENT_MODE == EnvironmentDocumentCacheMode.PERSISTENT
725+
and CACHE_ENVIRONMENT_DOCUMENT_SECONDS
726+
):
727+
warnings.warn(
728+
"Ignoring CACHE_ENVIRONMENT_DOCUMENT_SECONDS variable "
729+
'since CACHE_ENVIRONMENT_DOCUMENT_MODE == "PERSISTENT"'
730+
) # pragma: no cover
708731

709732
USER_THROTTLE_CACHE_NAME = "user-throttle"
710733
USER_THROTTLE_CACHE_BACKEND = env.str(
@@ -761,10 +784,16 @@
761784
"LOCATION": CHARGEBEE_CACHE_LOCATION,
762785
"TIMEOUT": 12 * 60 * 60, # 12 hours
763786
},
764-
ENVIRONMENT_DOCUMENT_CACHE_LOCATION: {
765-
"BACKEND": "django.core.cache.backends.db.DatabaseCache",
766-
"LOCATION": ENVIRONMENT_DOCUMENT_CACHE_LOCATION,
767-
"timeout": CACHE_ENVIRONMENT_DOCUMENT_SECONDS,
787+
CACHE_ENVIRONMENT_DOCUMENT_LOCATION: {
788+
"BACKEND": CACHE_ENVIRONMENT_DOCUMENT_BACKEND,
789+
"LOCATION": CACHE_ENVIRONMENT_DOCUMENT_LOCATION,
790+
"TIMEOUT": (
791+
None
792+
if CACHE_ENVIRONMENT_DOCUMENT_MODE
793+
== EnvironmentDocumentCacheMode.PERSISTENT
794+
else CACHE_ENVIRONMENT_DOCUMENT_SECONDS
795+
),
796+
"OPTIONS": CACHE_ENVIRONMENT_DOCUMENT_OPTIONS or {},
768797
},
769798
GET_FLAGS_ENDPOINT_CACHE_NAME: {
770799
"BACKEND": GET_FLAGS_ENDPOINT_CACHE_BACKEND,

api/environments/admin.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from __future__ import unicode_literals
33

44
from django.contrib import admin
5+
from django.db.models import QuerySet
6+
from django.http import HttpRequest
57

68
from .models import Environment, Webhook
79
from .tasks import rebuild_environment_document
@@ -30,6 +32,8 @@ class EnvironmentAdmin(admin.ModelAdmin): # type: ignore[type-arg]
3032
inlines = (WebhookInline,)
3133

3234
@admin.action(description="Rebuild selected environment documents")
33-
def rebuild_environments(self, request, queryset): # type: ignore[no-untyped-def]
35+
def rebuild_environments(
36+
self, request: HttpRequest, queryset: QuerySet[Environment]
37+
) -> None:
3438
for environment in queryset:
3539
rebuild_environment_document.delay(args=(environment.id,))

api/environments/enums.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import enum
2+
3+
4+
class EnvironmentDocumentCacheMode(enum.Enum):
5+
PERSISTENT = "PERSISTENT"
6+
EXPIRING = "EXPIRING"

api/environments/metrics.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import prometheus_client
2+
3+
CACHE_HIT = "CACHE_HIT"
4+
CACHE_MISS = "CACHE_MISS"
5+
6+
flagsmith_environment_document_cache_queries_total = prometheus_client.Counter(
7+
"flagsmith_environment_document_cache_queries_total",
8+
"Results of cache retrieval for environment document (hit or miss)",
9+
["result"],
10+
)

api/environments/models.py

Lines changed: 65 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -38,20 +38,29 @@
3838
DynamoEnvironmentV2Wrapper,
3939
DynamoEnvironmentWrapper,
4040
)
41+
from environments.enums import EnvironmentDocumentCacheMode
4142
from environments.exceptions import EnvironmentHeaderNotPresentError
4243
from environments.managers import EnvironmentManager
44+
from environments.metrics import (
45+
CACHE_HIT,
46+
CACHE_MISS,
47+
flagsmith_environment_document_cache_queries_total,
48+
)
4349
from features.models import Feature, FeatureSegment, FeatureState
4450
from features.multivariate.models import MultivariateFeatureStateValue
4551
from metadata.models import Metadata
4652
from projects.models import Project
4753
from segments.models import Segment
48-
from util.mappers import map_environment_to_sdk_document
54+
from util.mappers import (
55+
map_environment_to_environment_document,
56+
map_environment_to_sdk_document,
57+
)
4958
from webhooks.models import AbstractBaseExportableWebhookModel
5059

5160
logger = logging.getLogger(__name__)
5261

5362
environment_cache = caches[settings.ENVIRONMENT_CACHE_NAME]
54-
environment_document_cache = caches[settings.ENVIRONMENT_DOCUMENT_CACHE_LOCATION]
63+
environment_document_cache = caches[settings.CACHE_ENVIRONMENT_DOCUMENT_LOCATION]
5564
environment_segments_cache = caches[settings.ENVIRONMENT_SEGMENTS_CACHE_NAME]
5665
bad_environments_cache = caches[settings.BAD_ENVIRONMENTS_CACHE_LOCATION]
5766

@@ -149,22 +158,36 @@ class Environment(
149158
class Meta:
150159
ordering = ["id"]
151160

152-
@hook(AFTER_CREATE)
153-
def create_feature_states(self): # type: ignore[no-untyped-def]
161+
@hook(AFTER_CREATE) # type: ignore[misc]
162+
def create_feature_states(self) -> None:
154163
FeatureState.create_initial_feature_states_for_environment(environment=self)
155164

156-
@hook(AFTER_UPDATE)
157-
def clear_environment_cache(self): # type: ignore[no-untyped-def]
165+
@hook(AFTER_UPDATE) # type: ignore[misc]
166+
def clear_environment_cache(self) -> None:
158167
# TODO: this could rebuild the cache itself (using an async task)
159168
environment_cache.delete(self.initial_value("api_key"))
160169

161-
@hook(AFTER_DELETE)
162-
def delete_from_dynamo(self): # type: ignore[no-untyped-def]
170+
@hook(AFTER_UPDATE, when="api_key", has_changed=True) # type: ignore[misc]
171+
def update_environment_document_cache(self) -> None:
172+
environment_document_cache.delete(self.initial_value("api_key"))
173+
self.write_environment_documents(self.id)
174+
175+
@hook(AFTER_DELETE) # type: ignore[misc]
176+
def delete_from_dynamo(self) -> None:
163177
if self.project.enable_dynamo_db and environment_wrapper.is_enabled:
164178
from environments.tasks import delete_environment_from_dynamo
165179

166180
delete_environment_from_dynamo.delay(args=(self.api_key, self.id))
167181

182+
@hook(AFTER_DELETE) # type: ignore[misc]
183+
def delete_environment_document_from_cache(self) -> None:
184+
if (
185+
settings.CACHE_ENVIRONMENT_DOCUMENT_MODE
186+
== EnvironmentDocumentCacheMode.PERSISTENT
187+
or settings.CACHE_ENVIRONMENT_DOCUMENT_SECONDS > 0
188+
):
189+
environment_document_cache.delete(self.api_key)
190+
168191
def __str__(self): # type: ignore[no-untyped-def]
169192
return "Project %s - Environment %s" % (self.project.name, self.name)
170193

@@ -245,7 +268,7 @@ def get_from_cache(cls, api_key): # type: ignore[no-untyped-def]
245268
logger.info("Environment with api_key %s does not exist" % api_key)
246269

247270
@classmethod
248-
def write_environments_to_dynamodb(
271+
def write_environment_documents(
249272
cls,
250273
environment_id: int = None, # type: ignore[assignment]
251274
project_id: int = None, # type: ignore[assignment]
@@ -281,17 +304,31 @@ def write_environments_to_dynamodb(
281304
# project (which should always be the case). Since we're working with fairly
282305
# small querysets here, this shouldn't have a noticeable impact on performance.
283306
project: Project | None = getattr(environments[0], "project", None)
284-
for environment in environments[1:]:
285-
if not environment.project == project:
286-
raise RuntimeError("Environments must all belong to the same project.")
287-
288-
if not all([project, project.enable_dynamo_db, environment_wrapper.is_enabled]): # type: ignore[union-attr]
307+
if project is None: # pragma: no cover
289308
return
290309

291-
environment_wrapper.write_environments(environments)
310+
for environment in environments[1:]:
311+
if not environment.project == project: # pragma: no cover
312+
raise RuntimeError("Environments must all belong to the same project.")
292313

293-
if project.edge_v2_environments_migrated and environment_v2_wrapper.is_enabled: # type: ignore[union-attr]
294-
environment_v2_wrapper.write_environments(environments)
314+
if project.enable_dynamo_db and environment_wrapper.is_enabled:
315+
environment_wrapper.write_environments(environments)
316+
317+
if (
318+
project.edge_v2_environments_migrated
319+
and environment_v2_wrapper.is_enabled
320+
):
321+
environment_v2_wrapper.write_environments(environments)
322+
elif (
323+
settings.CACHE_ENVIRONMENT_DOCUMENT_MODE
324+
== EnvironmentDocumentCacheMode.PERSISTENT
325+
):
326+
environment_document_cache.set_many(
327+
{
328+
e.api_key: map_environment_to_environment_document(e)
329+
for e in environments
330+
}
331+
)
295332

296333
def get_feature_state(
297334
self,
@@ -364,7 +401,11 @@ def get_environment_document(
364401
cls,
365402
api_key: str,
366403
) -> dict[str, typing.Any]:
367-
if settings.CACHE_ENVIRONMENT_DOCUMENT_SECONDS > 0:
404+
if (
405+
settings.CACHE_ENVIRONMENT_DOCUMENT_SECONDS > 0
406+
or settings.CACHE_ENVIRONMENT_DOCUMENT_MODE
407+
== EnvironmentDocumentCacheMode.PERSISTENT
408+
):
368409
return cls._get_environment_document_from_cache(api_key)
369410
return cls._get_environment_document_from_db(api_key)
370411

@@ -386,9 +427,14 @@ def _get_environment_document_from_cache(
386427
api_key: str,
387428
) -> dict[str, typing.Any]:
388429
environment_document = environment_document_cache.get(api_key)
389-
if not environment_document:
430+
if not (cache_hit := environment_document is not None):
390431
environment_document = cls._get_environment_document_from_db(api_key)
391432
environment_document_cache.set(api_key, environment_document)
433+
434+
flagsmith_environment_document_cache_queries_total.labels(
435+
result=CACHE_HIT if cache_hit else CACHE_MISS,
436+
).inc()
437+
392438
return environment_document # type: ignore[no-any-return]
393439

394440
@classmethod

api/environments/tasks.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@
1919

2020
@register_task_handler(priority=TaskPriority.HIGH)
2121
def rebuild_environment_document(environment_id: int) -> None:
22-
Environment.write_environments_to_dynamodb(environment_id=environment_id)
22+
Environment.write_environment_documents(environment_id=environment_id)
2323

2424

2525
@register_task_handler(priority=TaskPriority.HIGHEST)
2626
def process_environment_update(audit_log_id: int): # type: ignore[no-untyped-def]
2727
audit_log = AuditLog.objects.get(id=audit_log_id)
2828

2929
# Send environment document to dynamodb
30-
Environment.write_environments_to_dynamodb(
30+
Environment.write_environment_documents(
3131
environment_id=audit_log.environment_id, project_id=audit_log.project_id
3232
)
3333

api/integrations/common/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def write_environment_to_dynamodb(self): # type: ignore[no-untyped-def]
3535
self.__class__.__name__,
3636
)
3737
return
38-
Environment.write_environments_to_dynamodb(environment_id=self.environment_id)
38+
Environment.write_environment_documents(environment_id=self.environment_id)
3939

4040
@hook(AFTER_UPDATE)
4141
def clear_environment_cache(self): # type: ignore[no-untyped-def]

api/integrations/webhook/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,4 @@ class WebhookConfiguration(
2121
@hook(AFTER_SAVE)
2222
@hook(AFTER_DELETE)
2323
def write_environment_to_dynamodb(self): # type: ignore[no-untyped-def]
24-
Environment.write_environments_to_dynamodb(environment_id=self.environment_id)
24+
Environment.write_environment_documents(environment_id=self.environment_id)

api/projects/tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
def write_environments_to_dynamodb(project_id: int) -> None:
1212
from environments.models import Environment
1313

14-
Environment.write_environments_to_dynamodb(project_id=project_id)
14+
Environment.write_environment_documents(project_id=project_id)
1515

1616

1717
@register_task_handler()

api/tests/integration/conftest.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@
22
import uuid
33

44
import pytest
5+
from django.core.cache import BaseCache
6+
from django.core.cache.backends.locmem import LocMemCache
57
from django.test import Client as DjangoClient
68
from django.urls import reverse
79
from pytest_django.fixtures import SettingsWrapper
10+
from pytest_mock import MockerFixture
811
from rest_framework import status
912
from rest_framework.test import APIClient
1013

1114
from app.utils import create_hash
15+
from environments.enums import EnvironmentDocumentCacheMode
1216
from organisations.models import Organisation
1317
from tests.integration.helpers import create_mv_option_with_api
1418

@@ -524,3 +528,15 @@ def identity_featurestate(admin_client, environment, feature, identity): # type
524528
url, data=json.dumps(data), content_type="application/json"
525529
)
526530
return response.json()["id"]
531+
532+
533+
@pytest.fixture()
534+
def persistent_environment_document_cache(
535+
settings: SettingsWrapper,
536+
mocker: MockerFixture,
537+
environment: int,
538+
) -> BaseCache:
539+
settings.CACHE_ENVIRONMENT_DOCUMENT_MODE = EnvironmentDocumentCacheMode.PERSISTENT
540+
cache = LocMemCache(name="environment_document", params={})
541+
mocker.patch("environments.models.environment_document_cache", cache)
542+
return cache

0 commit comments

Comments
 (0)