Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add permanent environment document cache #5187

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from task_processor.task_run_method import TaskRunMethod # type: ignore[import-untyped]

from app.routers import ReplicaReadStrategy
from environments.enums import EnvironmentDocumentCacheMode

env = Env()

Expand Down Expand Up @@ -679,8 +680,25 @@
"django.core.cache.backends.locmem.LocMemCache",
)

CACHE_ENVIRONMENT_DOCUMENT_SECONDS = env.int("CACHE_ENVIRONMENT_DOCUMENT_SECONDS", 0)
ENVIRONMENT_DOCUMENT_CACHE_LOCATION = "environment-documents"
ENVIRONMENT_DOCUMENT_CACHE_BACKEND = env(
"ENVIRONMENT_DOCUMENT_CACHE_BACKEND", "django.core.cache.backends.db.DatabaseCache"
)
ENVIRONMENT_DOCUMENT_CACHE_MODE = env.enum(
"ENVIRONMENT_DOCUMENT_CACHE_MODE",
type=EnvironmentDocumentCacheMode,
default=EnvironmentDocumentCacheMode.EXPIRING.value,
)
CACHE_ENVIRONMENT_DOCUMENT_SECONDS = env.int("CACHE_ENVIRONMENT_DOCUMENT_SECONDS", 0)

if (
ENVIRONMENT_DOCUMENT_CACHE_MODE == EnvironmentDocumentCacheMode.PERSISTENT
and CACHE_ENVIRONMENT_DOCUMENT_SECONDS
):
warnings.warn(
"Ignoring CACHE_ENVIRONMENT_DOCUMENT_SECONDS variable "
'since CACHE_ENVIRONMENT_DOCUMENT_MODE == "PERSISTENT"'
)

USER_THROTTLE_CACHE_NAME = "user-throttle"
USER_THROTTLE_CACHE_BACKEND = env.str(
Expand Down Expand Up @@ -736,9 +754,14 @@
"TIMEOUT": 12 * 60 * 60, # 12 hours
},
ENVIRONMENT_DOCUMENT_CACHE_LOCATION: {
"BACKEND": "django.core.cache.backends.db.DatabaseCache",
"BACKEND": ENVIRONMENT_DOCUMENT_CACHE_BACKEND,
"LOCATION": ENVIRONMENT_DOCUMENT_CACHE_LOCATION,
"timeout": CACHE_ENVIRONMENT_DOCUMENT_SECONDS,
"TIMEOUT": (
None
if ENVIRONMENT_DOCUMENT_CACHE_MODE
== EnvironmentDocumentCacheMode.PERSISTENT
else CACHE_ENVIRONMENT_DOCUMENT_SECONDS
),
},
GET_FLAGS_ENDPOINT_CACHE_NAME: {
"BACKEND": GET_FLAGS_ENDPOINT_CACHE_BACKEND,
Expand Down
10 changes: 9 additions & 1 deletion api/environments/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from __future__ import unicode_literals

from django.contrib import admin
from django.db.models import QuerySet
from django.http import HttpRequest

from .models import Environment, Webhook
from .tasks import rebuild_environment_document
Expand Down Expand Up @@ -29,7 +31,13 @@ class EnvironmentAdmin(admin.ModelAdmin): # type: ignore[type-arg]
)
inlines = (WebhookInline,)

# Since environment documents are stored using the API key in cache, and
# DynamoDB for SaaS, we shouldn't allow changing the API key.
readonly_fields = ("api_key",)

@admin.action(description="Rebuild selected environment documents")
def rebuild_environments(self, request, queryset): # type: ignore[no-untyped-def]
def rebuild_environments(
self, request: HttpRequest, queryset: QuerySet[Environment]
) -> None:
for environment in queryset:
rebuild_environment_document.delay(args=(environment.id,))
6 changes: 6 additions & 0 deletions api/environments/enums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import enum


class EnvironmentDocumentCacheMode(enum.Enum):
PERSISTENT = "PERSISTENT"
EXPIRING = "EXPIRING"
42 changes: 32 additions & 10 deletions api/environments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,18 @@
DynamoEnvironmentV2Wrapper,
DynamoEnvironmentWrapper,
)
from environments.enums import EnvironmentDocumentCacheMode
from environments.exceptions import EnvironmentHeaderNotPresentError
from environments.managers import EnvironmentManager
from features.models import Feature, FeatureSegment, FeatureState
from features.multivariate.models import MultivariateFeatureStateValue
from metadata.models import Metadata
from projects.models import Project
from segments.models import Segment
from util.mappers import map_environment_to_sdk_document
from util.mappers import (
map_environment_to_environment_document,
map_environment_to_sdk_document,
)
from webhooks.models import AbstractBaseExportableWebhookModel

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -245,7 +249,7 @@ def get_from_cache(cls, api_key): # type: ignore[no-untyped-def]
logger.info("Environment with api_key %s does not exist" % api_key)

@classmethod
def write_environments_to_dynamodb(
def write_environment_documents(
cls,
environment_id: int = None, # type: ignore[assignment]
project_id: int = None, # type: ignore[assignment]
Expand Down Expand Up @@ -281,17 +285,31 @@ def write_environments_to_dynamodb(
# project (which should always be the case). Since we're working with fairly
# small querysets here, this shouldn't have a noticeable impact on performance.
project: Project | None = getattr(environments[0], "project", None)
if project is None:
return

for environment in environments[1:]:
if not environment.project == project:
raise RuntimeError("Environments must all belong to the same project.")

if not all([project, project.enable_dynamo_db, environment_wrapper.is_enabled]): # type: ignore[union-attr]
return

environment_wrapper.write_environments(environments)

if project.edge_v2_environments_migrated and environment_v2_wrapper.is_enabled: # type: ignore[union-attr]
environment_v2_wrapper.write_environments(environments)
if project.enable_dynamo_db and environment_wrapper.is_enabled:
environment_wrapper.write_environments(environments)

if (
project.edge_v2_environments_migrated
and environment_v2_wrapper.is_enabled
):
environment_v2_wrapper.write_environments(environments)
elif (
settings.ENVIRONMENT_DOCUMENT_CACHE_MODE
== EnvironmentDocumentCacheMode.PERSISTENT
):
environment_document_cache.set_many(
{
e.api_key: map_environment_to_environment_document(e)
for e in environments
}
)

def get_feature_state(
self,
Expand Down Expand Up @@ -364,7 +382,11 @@ def get_environment_document(
cls,
api_key: str,
) -> dict[str, typing.Any]:
if settings.CACHE_ENVIRONMENT_DOCUMENT_SECONDS > 0:
if (
settings.CACHE_ENVIRONMENT_DOCUMENT_SECONDS > 0
or settings.ENVIRONMENT_DOCUMENT_CACHE_MODE
== EnvironmentDocumentCacheMode.PERSISTENT
):
return cls._get_environment_document_from_cache(api_key)
return cls._get_environment_document_from_db(api_key)

Expand Down
4 changes: 2 additions & 2 deletions api/environments/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@

@register_task_handler(priority=TaskPriority.HIGH) # type: ignore[misc]
def rebuild_environment_document(environment_id: int) -> None:
Environment.write_environments_to_dynamodb(environment_id=environment_id)
Environment.write_environment_documents(environment_id=environment_id)


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

# Send environment document to dynamodb
Environment.write_environments_to_dynamodb(
Environment.write_environment_documents(
environment_id=audit_log.environment_id, project_id=audit_log.project_id
)

Expand Down
2 changes: 1 addition & 1 deletion api/integrations/common/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def write_environment_to_dynamodb(self): # type: ignore[no-untyped-def]
self.__class__.__name__,
)
return
Environment.write_environments_to_dynamodb(environment_id=self.environment_id)
Environment.write_environment_documents(environment_id=self.environment_id)

@hook(AFTER_UPDATE)
def clear_environment_cache(self): # type: ignore[no-untyped-def]
Expand Down
2 changes: 1 addition & 1 deletion api/integrations/webhook/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ class WebhookConfiguration(
@hook(AFTER_SAVE)
@hook(AFTER_DELETE)
def write_environment_to_dynamodb(self): # type: ignore[no-untyped-def]
Environment.write_environments_to_dynamodb(environment_id=self.environment_id)
Environment.write_environment_documents(environment_id=self.environment_id)
2 changes: 1 addition & 1 deletion api/projects/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
def write_environments_to_dynamodb(project_id: int) -> None:
from environments.models import Environment

Environment.write_environments_to_dynamodb(project_id=project_id)
Environment.write_environment_documents(project_id=project_id)


@register_task_handler() # type: ignore[misc]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import json
from typing import Any

import pytest
from django.urls import reverse
from pytest_django.fixtures import DjangoAssertNumQueries, SettingsWrapper
from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped]
from pytest_mock import MockerFixture
from rest_framework import status
from rest_framework.test import APIClient

from environments.enums import EnvironmentDocumentCacheMode
from tests.integration.helpers import (
get_env_feature_states_list_with_api,
get_feature_segement_list_with_api,
Expand Down Expand Up @@ -217,3 +221,51 @@ def test_env_clone_clones_segments_overrides( # type: ignore[no-untyped-def]
== clone_feature_segment_id
)
assert clone_feature_segment_id != source_feature_segment_id


class FakeCache:
def __init__(self) -> None:
self._cache: dict[str, Any] = {}

def set(self, key: str, value: Any) -> None:
self._cache[key] = value

def set_many(self, d: dict[str, Any]) -> None:
self._cache.update(d)

def get(self, key: str) -> Any:
return self._cache.get(key)


@pytest.fixture()
def persistent_environment_document_cache(
settings: SettingsWrapper, mocker: MockerFixture
) -> FakeCache:
settings.ENVIRONMENT_DOCUMENT_CACHE_MODE = EnvironmentDocumentCacheMode.PERSISTENT

mock_environment_document_cache = FakeCache()
mocker.patch(
"environments.models.environment_document_cache",
mock_environment_document_cache,
)
return mock_environment_document_cache


def test_get_environment_document_using_persistent_cache(
persistent_environment_document_cache: FakeCache,
environment: int,
environment_api_key: str,
feature: int,
server_side_sdk_client: APIClient,
django_assert_num_queries: DjangoAssertNumQueries,
) -> None:
# Given
url = reverse("api-v1:environment-document")

# When
with django_assert_num_queries(1):
# We still expect 1 query for authentication
response = server_side_sdk_client.get(url)

# Then
assert response.status_code == status.HTTP_200_OK
7 changes: 5 additions & 2 deletions api/tests/unit/environments/test_unit_environments_admin.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
from django.contrib.admin.sites import AdminSite
from pytest_mock import MockerFixture

from environments.admin import EnvironmentAdmin
from environments.models import Environment


def test_environment_admin_rebuild_environments(environment, mocker): # type: ignore[no-untyped-def]
def test_environment_admin_rebuild_environments(
environment: Environment, mocker: MockerFixture
) -> None:
# GIVEN
mocked_rebuild_environment_document = mocker.patch(
"environments.admin.rebuild_environment_document"
)
environment_admin = EnvironmentAdmin(Environment, AdminSite())
# WHEN
environment_admin.rebuild_environments( # type: ignore[no-untyped-call]
environment_admin.rebuild_environments(
request=mocker.MagicMock(), queryset=Environment.objects.all()
)
# THEN
Expand Down
16 changes: 8 additions & 8 deletions api/tests/unit/environments/test_unit_environments_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ def test_write_environments_to_dynamodb_with_environment( # type: ignore[no-unt
mock_dynamo_env_wrapper.reset_mock()

# When
Environment.write_environments_to_dynamodb(
Environment.write_environment_documents(
environment_id=dynamo_enabled_project_environment_one.id
)

Expand All @@ -465,7 +465,7 @@ def test_write_environments_to_dynamodb_project( # type: ignore[no-untyped-def]
mock_dynamo_env_wrapper.reset_mock()

# When
Environment.write_environments_to_dynamodb(project_id=dynamo_enabled_project.id)
Environment.write_environment_documents(project_id=dynamo_enabled_project.id)

# Then
args, kwargs = mock_dynamo_env_wrapper.write_environments.call_args
Expand All @@ -485,7 +485,7 @@ def test_write_environments_to_dynamodb_with_environment_and_project( # type: i
mock_dynamo_env_wrapper.reset_mock()

# When
Environment.write_environments_to_dynamodb(
Environment.write_environment_documents(
environment_id=dynamo_enabled_project_environment_one.id
)

Expand All @@ -512,7 +512,7 @@ def test_write_environments_to_dynamodb__project_environments_v2_migrated__call_
mock_dynamo_env_v2_wrapper.is_enabled = True

# When
Environment.write_environments_to_dynamodb(project_id=dynamo_enabled_project.id)
Environment.write_environment_documents(project_id=dynamo_enabled_project.id)

# Then
args, kwargs = mock_dynamo_env_v2_wrapper.write_environments.call_args
Expand All @@ -536,7 +536,7 @@ def test_write_environments_to_dynamodb__project_environments_v2_migrated__wrapp
dynamo_enabled_project.save()

# When
Environment.write_environments_to_dynamodb(project_id=dynamo_enabled_project.id)
Environment.write_environment_documents(project_id=dynamo_enabled_project.id)

# Then
mock_dynamo_env_v2_wrapper.write_environments.assert_not_called()
Expand All @@ -563,7 +563,7 @@ def test_write_environments_to_dynamodb__project_environments_v2_not_migrated__w
mock_dynamo_env_v2_wrapper.is_enabled = True

# When
Environment.write_environments_to_dynamodb(project_id=dynamo_enabled_project.id)
Environment.write_environment_documents(project_id=dynamo_enabled_project.id)

# Then
mock_dynamo_env_v2_wrapper.write_environments.assert_not_called()
Expand Down Expand Up @@ -663,7 +663,7 @@ def test_environment_get_environment_document_with_caching_when_document_in_cach
environment, django_assert_num_queries, settings, mocker
):
# Given
settings.CACHE_ENVIRONMENT_DOCUMENT_SECONDS = 60
settings.CACHE_ENVIRONMENT_DOCUMENT_TIMEOUT = 60

mocked_environment_document_cache = mocker.patch(
"environments.models.environment_document_cache"
Expand All @@ -685,7 +685,7 @@ def test_environment_get_environment_document_with_caching_when_document_not_in_
environment, django_assert_num_queries, settings, mocker
):
# Given
settings.CACHE_ENVIRONMENT_DOCUMENT_SECONDS = 60
settings.CACHE_ENVIRONMENT_DOCUMENT_TIMEOUT = 60

mocked_environment_document_cache = mocker.patch(
"environments.models.environment_document_cache"
Expand Down
4 changes: 2 additions & 2 deletions api/tests/unit/environments/test_unit_environments_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def test_process_environment_update_with_environment_audit_log(environment, mock
process_environment_update(audit_log_id=audit_log.id)

# Then
mock_environment_model_class.write_environments_to_dynamodb.assert_called_once_with(
mock_environment_model_class.write_environment_documents.assert_called_once_with(
environment_id=environment.id, project_id=environment.project.id
)
mock_send_environment_update_message_for_environment.assert_called_once_with(
Expand Down Expand Up @@ -76,7 +76,7 @@ def test_process_environment_update_with_project_audit_log(environment, mocker):
process_environment_update(audit_log_id=audit_log.id)

# Then
mock_environment_model_class.write_environments_to_dynamodb.assert_called_once_with(
mock_environment_model_class.write_environment_documents.assert_called_once_with(
environment_id=None, project_id=environment.project.id
)
mock_send_environment_update_message_for_environment.assert_not_called()
Expand Down
Loading
Loading