From d893bd393e29370a2f3747b45ba00a8135235c2b Mon Sep 17 00:00:00 2001 From: Steve Jalim Date: Sat, 16 Nov 2024 22:19:32 +0000 Subject: [PATCH 1/5] Add cacheing to geo.valid_country_code for performance boost Saves 11 SQL queries on the releasnotes page by cacheing the country code lookup for an hour. Tested on /en-US/firefox/132.0.1/releasenotes/ Cold cache: 14 queries / 2066ms Warm cache: 3 queries / 222ms --- bedrock/settings/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bedrock/settings/base.py b/bedrock/settings/base.py index 0a793151bd8..4bc5ae917f4 100644 --- a/bedrock/settings/base.py +++ b/bedrock/settings/base.py @@ -86,7 +86,7 @@ def data_path(*args): "image_renditions": {"URL": f"{REDIS_URL}/0"}, } -CACHE_TIME_SHORT = config("CACHE_TIME_SHORT", parser=int, default=f"{60 * 10}") # 10 mins +CACHE_TIME_DEFAULT = 60 * 10 # 10 mins CACHE_TIME_MED = 60 * 60 # 1 hour CACHE_TIME_LONG = 60 * 60 * 6 # 6 hours @@ -95,7 +95,7 @@ def data_path(*args): "default": { "BACKEND": "bedrock.base.cache.SimpleDictCache", "LOCATION": "default", - "TIMEOUT": CACHE_TIME_SHORT, + "TIMEOUT": CACHE_TIME_DEFAULT, "OPTIONS": { "MAX_ENTRIES": 5000, "CULL_FREQUENCY": 4, # 1/4 entries deleted if max reached From 560f8ae2bf51bcb9fcc4f344041871042f3b7af7 Mon Sep 17 00:00:00 2001 From: Steve Jalim Date: Sat, 16 Nov 2024 22:55:33 +0000 Subject: [PATCH 2/5] Rename 'default' cache time to CACHE_TIME_SHORT to make it more meaningful a name --- bedrock/settings/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bedrock/settings/base.py b/bedrock/settings/base.py index 4bc5ae917f4..39aff996e7e 100644 --- a/bedrock/settings/base.py +++ b/bedrock/settings/base.py @@ -86,7 +86,7 @@ def data_path(*args): "image_renditions": {"URL": f"{REDIS_URL}/0"}, } -CACHE_TIME_DEFAULT = 60 * 10 # 10 mins +CACHE_TIME_SHORT = 60 * 10 # 10 mins CACHE_TIME_MED = 60 * 60 # 1 hour CACHE_TIME_LONG = 60 * 60 * 6 # 6 hours @@ -95,7 +95,7 @@ def data_path(*args): "default": { "BACKEND": "bedrock.base.cache.SimpleDictCache", "LOCATION": "default", - "TIMEOUT": CACHE_TIME_DEFAULT, + "TIMEOUT": CACHE_TIME_SHORT, "OPTIONS": { "MAX_ENTRIES": 5000, "CULL_FREQUENCY": 4, # 1/4 entries deleted if max reached From 5b161ce4bc537437d2cfc010aa1d61918846fc7f Mon Sep 17 00:00:00 2001 From: Steve Jalim Date: Fri, 22 Nov 2024 10:20:14 +0000 Subject: [PATCH 3/5] Add a cache-baced 'lookahead' to know whether it's worth hitting the CMS for a page ...because if the page isn't in the lookahead, we can avoid touching the DB just to ultimately return a 404 --- bedrock/cms/apps.py | 16 ++++ bedrock/cms/bedrock_wagtail_urls.py | 34 ++++++++ bedrock/cms/decorators.py | 30 +++++++ bedrock/cms/signal_handlers.py | 99 +++++++++++++++++++++ bedrock/cms/tests/conftest.py | 25 ++++++ bedrock/cms/tests/test_decorators.py | 58 ++++++++++++- bedrock/cms/tests/test_utils.py | 124 ++++++++++++++++++++++++++- bedrock/cms/utils.py | 60 +++++++++++++ bedrock/settings/base.py | 2 + bedrock/urls.py | 7 +- 10 files changed, 450 insertions(+), 5 deletions(-) create mode 100644 bedrock/cms/bedrock_wagtail_urls.py create mode 100644 bedrock/cms/signal_handlers.py diff --git a/bedrock/cms/apps.py b/bedrock/cms/apps.py index a1f57a449c1..7f696ab955a 100644 --- a/bedrock/cms/apps.py +++ b/bedrock/cms/apps.py @@ -3,8 +3,24 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. from django.apps import AppConfig +from django.db import connection +from django.db.models.signals import post_migrate class CmsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "bedrock.cms" + + def ready(self): + import bedrock.cms.signal_handlers # noqa: F401 + from bedrock.cms.utils import warm_page_path_cache + + if "wagtailcore_page" in connection.introspection.table_names(): + # The route to take if the DB already exists in a viable state + warm_page_path_cache() + else: + # The route to take the DB isn't ready yet (eg tests or an empty DB) + post_migrate.connect( + bedrock.cms.signal_handlers.trigger_cache_warming, + sender=self, + ) diff --git a/bedrock/cms/bedrock_wagtail_urls.py b/bedrock/cms/bedrock_wagtail_urls.py new file mode 100644 index 00000000000..e1bcd228859 --- /dev/null +++ b/bedrock/cms/bedrock_wagtail_urls.py @@ -0,0 +1,34 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +# Custom version of wagtail_urls that wraps the wagtail_serve route +# with a decorator that does a lookahead to see if Wagtail will 404 or not +# (based on a precomputed cache of URLs in the CMS) + +from django.urls import re_path + +from wagtail.urls import urlpatterns as wagtail_urlpatterns +from wagtail.views import serve as wagtail_serve + +from bedrock.cms.decorators import pre_check_for_cms_404 + +# Modify the wagtail_urlpatterns to replace `wagtail_serve` with a decorated +# version of the same view, so we can pre-empt Wagtail looking up a page +# that we know will be a 404 +custom_wagtail_urls = [] + +for pattern in wagtail_urlpatterns: + if hasattr(pattern.callback, "__name__") and pattern.callback.__name__ == "serve": + custom_wagtail_urls.append( + re_path( + # This is a RegexPattern not a RoutePattern, which is why we use re_path not path + pattern.pattern, + pre_check_for_cms_404(wagtail_serve), + name=pattern.name, + ) + ) + else: + custom_wagtail_urls.append(pattern) + +# Note: custom_wagtail_urls is imported into the main project urls.py instead of wagtail_urls diff --git a/bedrock/cms/decorators.py b/bedrock/cms/decorators.py index 7321795507a..04b4ff6aef0 100644 --- a/bedrock/cms/decorators.py +++ b/bedrock/cms/decorators.py @@ -2,17 +2,23 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. +import logging from functools import wraps +from django.conf import settings from django.http import Http404 from wagtail.views import serve as wagtail_serve from bedrock.base.i18n import remove_lang_prefix +from bedrock.cms.utils import path_exists_in_cms from lib.l10n_utils.fluent import get_active_locales from .utils import get_cms_locales_for_path +logger = logging.getLogger(__name__) + + HTTP_200_OK = 200 @@ -176,3 +182,27 @@ def wrapped_view(request, *args, **kwargs): else: # Otherwise, apply the decorator directly to view_func return decorator(view_func) + + +def pre_check_for_cms_404(view): + """ + Decorator intended to avoid going through the Wagtail's serve view + for a route that we know will be a 404. How do we know? We have a + pre-warmed cache of all the pages of _live_ pages known to Wagtail + - see bedrock.cms.utils for that. + + This decorator can be skipped if settings.CMS_DO_PAGE_PATH_PRECHECK is + set to False via env vars. + """ + + def wrapped_view(request, *args, **kwargs): + _path_to_check = request.path + if settings.CMS_DO_PAGE_PATH_PRECHECK: + if not path_exists_in_cms(_path_to_check): + logger.info(f"Raising early 404 for {_path_to_check} because it doesn't exist in the CMS") + raise Http404 + + # Proceed to the original view + return view(request, *args, **kwargs) + + return wrapped_view diff --git a/bedrock/cms/signal_handlers.py b/bedrock/cms/signal_handlers.py new file mode 100644 index 00000000000..38b15fac3b4 --- /dev/null +++ b/bedrock/cms/signal_handlers.py @@ -0,0 +1,99 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import logging +from typing import TYPE_CHECKING, Type + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.mail import send_mail +from django.template.loader import render_to_string + +from wagtail.signals import page_published, page_unpublished, post_page_move +from wagtail_localize_smartling.signals import individual_translation_imported + +from bedrock.cms.utils import warm_page_path_cache + +if TYPE_CHECKING: + from wagtail_localize.models import Translation + from wagtail_localize_smartling.models import Job + + +logger = logging.getLogger(__name__) + + +def notify_of_imported_translation( + sender: Type["Job"], + instance: "Job", + translation: "Translation", + **kwargs, +): + """ + Signal handler for receiving news that a translation has landed from + Smartling. + + For now, sends a notification email to all Admins + """ + UserModel = get_user_model() + + admin_emails = UserModel.objects.filter( + is_superuser=True, + is_active=True, + ).values_list("email", flat=True) + admin_emails = [email for email in admin_emails if email] # Safety check to ensure no empty email addresses are included + + if not admin_emails: + logger.warning("Unable to send translation-imported email alerts: no admins in system") + return + + email_subject = "New translations imported into Bedrock CMS" + + job_name = instance.name + translation_source_name = str(instance.translation_source.get_source_instance()) + + smartling_cms_dashboard_url = f"{settings.WAGTAILADMIN_BASE_URL}/cms-admin/smartling-jobs/inspect/{instance.pk}/" + + email_body = render_to_string( + template_name="cms/email/notifications/individual_translation_imported__body.txt", + context={ + "job_name": job_name, + "translation_source_name": translation_source_name, + "translation_target_language_code": translation.target_locale.language_code, + "smartling_cms_dashboard_url": smartling_cms_dashboard_url, + }, + ) + + send_mail( + subject=email_subject, + message=email_body, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=admin_emails, + ) + logger.info(f"Translation-imported notification sent to {len(admin_emails)} admins") + + +individual_translation_imported.connect(notify_of_imported_translation, weak=False) + + +def trigger_cache_warming(sender, **kwargs): + # Run after the post-migrate signal is sent for the `cms` app + warm_page_path_cache() + + +def rebuild_path_cache_after_page_move(sender, **kwargs): + # Check if a page has moved up or down within the tree + # (rather than just being reordered). If it has really moved + # then we should update the cache + if kwargs["url_path_before"] == kwargs["url_path_after"]: + # No URLs are changing - nothing to do here! + return + + # The page is moving, so we need to rebuild the entire pre-empting-lookup cache + warm_page_path_cache() + + +post_page_move.connect(rebuild_path_cache_after_page_move) + +page_published.connect(trigger_cache_warming) +page_unpublished.connect(trigger_cache_warming) diff --git a/bedrock/cms/tests/conftest.py b/bedrock/cms/tests/conftest.py index 3142f05febf..ad555673c1f 100644 --- a/bedrock/cms/tests/conftest.py +++ b/bedrock/cms/tests/conftest.py @@ -4,6 +4,7 @@ import pytest import wagtail_factories +from wagtail.contrib.redirects.models import Redirect from wagtail.models import Locale, Site from bedrock.cms.tests.factories import LocaleFactory, SimpleRichTextPageFactory @@ -74,8 +75,14 @@ def tiny_localized_site(): site = Site.objects.get(is_default_site=True) en_us_root_page = site.root_page + fr_root_page = en_us_root_page.copy_for_translation(fr_locale) + rev = fr_root_page.save_revision() + fr_root_page.publish(rev) + pt_br_root_page = en_us_root_page.copy_for_translation(pt_br_locale) + rev = pt_br_root_page.save_revision() + pt_br_root_page.publish(rev) en_us_homepage = SimpleRichTextPageFactory( title="Test Page", @@ -148,3 +155,21 @@ def tiny_localized_site(): assert fr_homepage.live is True assert fr_child.live is True assert fr_grandchild.live is True + + +@pytest.fixture +def tiny_localized_site_redirects(): + """Some test redirects that complement the tiny_localized_site fixture. + + Useful for things like the tests for the cache-based lookup + in bedrock.cms.tests.test_utils.test_path_exists_in_cms + """ + + Redirect.add_redirect( + old_path="/fr/moved-page/", + redirect_to="/fr/test-page/child-page/", + ) + Redirect.add_redirect( + old_path="/en-US/deeper/nested/moved-page/", + redirect_to="/fr/test-page/", + ) diff --git a/bedrock/cms/tests/test_decorators.py b/bedrock/cms/tests/test_decorators.py index c4a5a66387e..c88a83643fc 100644 --- a/bedrock/cms/tests/test_decorators.py +++ b/bedrock/cms/tests/test_decorators.py @@ -3,13 +3,14 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. +from django.http import Http404, HttpResponse from django.urls import path import pytest from wagtail.rich_text import RichText from bedrock.base.i18n import bedrock_i18n_patterns -from bedrock.cms.decorators import prefer_cms +from bedrock.cms.decorators import pre_check_for_cms_404, prefer_cms from bedrock.cms.tests import decorator_test_views from bedrock.urls import urlpatterns as mozorg_urlpatterns @@ -446,3 +447,58 @@ def test_prefer_cms_rejects_invalid_setup(mocker, config, expect_exeption): prefer_cms(view_func=fake_view, **config) else: prefer_cms(view_func=fake_view, **config) + + +def dummy_view(request, *args, **kwargs): + return HttpResponse("Hello, world!") + + +@pytest.mark.parametrize("pretend_that_path_exists", [True, False]) +def test_pre_check_for_cms_404( + pretend_that_path_exists, + mocker, + rf, + settings, + client, + tiny_localized_site, +): + settings.CMS_DO_PAGE_PATH_PRECHECK = True + mocked_view = mocker.spy(dummy_view, "__call__") # Spy on the test view + mocked_path_exists_in_cms = mocker.patch("bedrock.cms.decorators.path_exists_in_cms") + mocked_path_exists_in_cms.return_value = pretend_that_path_exists + + decorated_view = pre_check_for_cms_404(mocked_view) + request = rf.get("/path/is/irrelevant/because/we/are/mocking/path_exists_in_cms") + + if pretend_that_path_exists: + response = decorated_view(request) + # Assert: Verify the original view was called + mocked_view.assert_called_once_with(request) + assert response.content == b"Hello, world!" + else: + with pytest.raises(Http404): # Expect an Http404 since path_exists_in_cms returns False + decorated_view(request) + mocked_view.assert_not_called() + + +@pytest.mark.parametrize("pretend_that_path_exists", [True, False]) +def test_pre_check_for_cms_404__show_can_be_disabled_with_settings( + pretend_that_path_exists, + mocker, + rf, + settings, + client, + tiny_localized_site, +): + settings.CMS_DO_PAGE_PATH_PRECHECK = False + mocked_view = mocker.spy(dummy_view, "__call__") # Spy on the test view + mocked_path_exists_in_cms = mocker.patch("bedrock.cms.decorators.path_exists_in_cms") + mocked_path_exists_in_cms.return_value = pretend_that_path_exists + + decorated_view = pre_check_for_cms_404(mocked_view) + request = rf.get("/path/is/irrelevant/because/we/are/mocking/path_exists_in_cms") + + # The fake view will always be called because the pre-check isn't being used + response = decorated_view(request) + mocked_view.assert_called_once_with(request) + assert response.content == b"Hello, world!" diff --git a/bedrock/cms/tests/test_utils.py b/bedrock/cms/tests/test_utils.py index f925fa3ce2f..69d775692fb 100644 --- a/bedrock/cms/tests/test_utils.py +++ b/bedrock/cms/tests/test_utils.py @@ -3,11 +3,21 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. +from django.core.cache import cache + import pytest from wagtail.coreutils import get_dummy_request from wagtail.models import Locale, Page -from bedrock.cms.utils import get_cms_locales_for_path, get_locales_for_cms_page, get_page_for_request +from bedrock.cms.utils import ( + BEDROCK_ALL_CMS_PATHS_CACHE_KEY, + _get_all_cms_paths, + get_cms_locales_for_path, + get_locales_for_cms_page, + get_page_for_request, + path_exists_in_cms, + warm_page_path_cache, +) pytestmark = [pytest.mark.django_db] @@ -142,3 +152,115 @@ def test_get_cms_locales_for_path( if get_page_for_request_should_return_a_page: mock_get_page_for_request.assert_called_once_with(request=request) mock_get_locales_for_cms_page.assert_called_once_with(page=page) + + +@pytest.mark.parametrize( + "path, hit_expected", + ( + # Regular path + ("/en-US/test-page/child-page/", True), + # Checking missing trailing slashes + ("/en-US/test-page/child-page", True), + # Checking querystrings don't fox things + ("/en-US/test-page/child-page/?some=querystring", True), + ("/en-US/test-page/child-page/?some=querystring&and=more-stuff", True), + # Checking deeper in the tree + ("/fr/test-page/child-page/grandchild-page/", True), + ("/fr/test-page/child-page/grandchild-page/great-grandchild/", False), + ("/fr/test-page/child-page/grandchild-page/?testing=yes!", True), + ("/fr/test-page/child-page/grandchild-page/?testing=yes!&other=things", True), + # Pages that would 404 + ("/en-US/test-page/not-child-page/", False), + ("/en-US/test-page/not-child-page", False), + ("/fr/grandchild-page/", False), + ("/en-US/not-a-path", False), + ("/en-US/not-a-path/", False), + ("/en-US/", True), + ("/fr/", True), + ("/pt-BR/", True), + ("/pt-BR/test-page/", True), + # Checking paths for redirects are in the cache too + ("/fr/moved-page", True), + ("/en-US/deeper/nested/moved-page", True), + ("/fr/moved-page/", True), # Trailing slash is not part of the redirect + # Confirm that the CMS admin route is not counted as "existing in the CMS" + # (which also means page previews and draftsharing links are unaffected by this lookup) + ("/cms-admin/", False), + # Confirm that some known-only-to-django URLs are not in the page lookup cache + ("/django-admin/", False), + ("/careers/", False), + ), +) +def test_path_exists_in_cms( + client, + tiny_localized_site, + tiny_localized_site_redirects, + path, + hit_expected, +): + cache.delete(BEDROCK_ALL_CMS_PATHS_CACHE_KEY) + assert path_exists_in_cms(path) == hit_expected + + some_django_served_urls = [ + "/cms-admin/", + "/django-admin/", + "/careers/", + ] + + # Also confirm that what would happen without the lookup is what we expect + if ( + hit_expected is False # These are pages that should 404 + and path not in some_django_served_urls # This is not in the URLs the CMS knows about but + ): + assert client.get(path, follow=True).status_code == 404 + else: + # These are pages that should be serveable by Wagtail in some way + if "moved-page" in path: + # The "moved-page" is a route that's been configured as a Redirect + # so will 301 when we get it + resp = client.get(path) + assert resp.status_code == 301 + assert client.get(resp.headers["location"]).status_code == 200 + else: + # These are regular page serves + assert client.get(path, follow=True).status_code == 200 + + +def test_warm_page_path_cache(mocker): + cache.delete(BEDROCK_ALL_CMS_PATHS_CACHE_KEY) + + mock_get_all_cms_paths = mocker.patch("bedrock.cms.utils._get_all_cms_paths") + expected = set(["this", "is a", "test"]) + mock_get_all_cms_paths.return_value = expected + + assert cache.get(BEDROCK_ALL_CMS_PATHS_CACHE_KEY) is None + + warm_page_path_cache() + assert cache.get(BEDROCK_ALL_CMS_PATHS_CACHE_KEY) is expected + + expected_updated = set(["this", "is also a", "test"]) + mock_get_all_cms_paths.return_value = expected_updated + + warm_page_path_cache() + assert cache.get(BEDROCK_ALL_CMS_PATHS_CACHE_KEY) is expected_updated + + +def test__get_all_cms_paths(client, tiny_localized_site, tiny_localized_site_redirects): + expected = set( + [ + "/en-US/", + "/en-US/test-page/", + "/en-US/test-page/child-page/", + "/fr/", + "/fr/test-page/", + "/fr/test-page/child-page/", + "/fr/test-page/child-page/grandchild-page/", + "/pt-BR/", + "/pt-BR/test-page/", + "/pt-BR/test-page/child-page/", + "/fr/moved-page", # No trailing slashes on redirects + "/en-US/deeper/nested/moved-page", # No trailing slashes on redirects + ] + ) + actual = _get_all_cms_paths() + assert actual == expected diff --git a/bedrock/cms/utils.py b/bedrock/cms/utils.py index eed3b69b408..8c1a4c7d9e8 100644 --- a/bedrock/cms/utils.py +++ b/bedrock/cms/utils.py @@ -1,14 +1,23 @@ # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. +import logging + +from django.conf import settings +from django.core.cache import cache from django.core.exceptions import ObjectDoesNotExist from django.db.models import Subquery from django.http import Http404 +from wagtail.contrib.redirects.models import Redirect from wagtail.models import Locale, Page from bedrock.base.i18n import split_path_and_normalize_language +logger = logging.getLogger(__name__) + +BEDROCK_ALL_CMS_PATHS_CACHE_KEY = "bedrock_cms_all_known_live_page_paths" + def get_page_for_request(*, request): """For the given HTTPRequest (and its path) find the corresponding Wagtail @@ -61,3 +70,54 @@ def get_cms_locales_for_path(request): locales = get_locales_for_cms_page(page=page) return locales + + +def _get_all_cms_paths() -> set: + """Fetch all the possible URL paths that are available + in Wagtail, in all locales, for LIVE pages only and for + all Redirects + """ + pages = set([x.url for x in Page.objects.live() if x.url is not None]) + redirects = set([x.old_path for x in Redirect.objects.all()]) + + return pages.union(redirects) + + +def path_exists_in_cms(path: str) -> bool: + """ + Using the paths cached via warm_page_path_cache, return a boolean + simply showing whether or not Wagtail can serve the requested path + (whether as a Page or as a Redirect). + + Avoiding Wagtail-raised 404s is the goal of this function. + We don't care if there'll be a chain of redirects or a slash being + auto-appended - we'll let Django/Wagtail handle that. + """ + + cms_paths = cache.get(BEDROCK_ALL_CMS_PATHS_CACHE_KEY) + if cms_paths is None: + cms_paths = warm_page_path_cache() + if "?" in path: + path = path.partition("?")[0] + + if path in cms_paths: + return True + elif not path.endswith("/") and f"{path}/" in cms_paths: + # pages have trailing slashes in their paths, but we might get asked for one without it + return True + elif path.endswith("/") and path[:-1] in cms_paths: + # redirects have no trailing slashes in their paths, but we might get asked for one with it + return True + return False + + +def warm_page_path_cache() -> set(): + paths = _get_all_cms_paths() + logger.info(f"Warming the cache '{BEDROCK_ALL_CMS_PATHS_CACHE_KEY}' with {len(paths)} paths ") + + cache.set( + BEDROCK_ALL_CMS_PATHS_CACHE_KEY, + paths, + settings.CACHE_TIME_LONG, + ) + return paths diff --git a/bedrock/settings/base.py b/bedrock/settings/base.py index 39aff996e7e..86c8ff00c94 100644 --- a/bedrock/settings/base.py +++ b/bedrock/settings/base.py @@ -2488,6 +2488,8 @@ def lazy_wagtail_langs(): CMS_ALLOWED_PAGE_MODELS = _allowed_page_models +CMS_DO_PAGE_PATH_PRECHECK = config("CMS_DO_PAGE_PATH_PRECHECK", default="True", parser=bool) + # Our use of django-waffle relies on the following 2 settings to be set this way so that if a switch # doesn't exist, we get `None` back from `switch_is_active`. WAFFLE_SWITCH_DEFAULT = None diff --git a/bedrock/urls.py b/bedrock/urls.py index d60460bd1f8..5ddda2f6888 100644 --- a/bedrock/urls.py +++ b/bedrock/urls.py @@ -8,13 +8,13 @@ from django.utils.module_loading import import_string import wagtaildraftsharing.urls as wagtaildraftsharing_urls -from wagtail import urls as wagtail_urls from wagtail.admin import urls as wagtailadmin_urls from wagtail.documents import urls as wagtaildocs_urls from watchman import views as watchman_views from bedrock.base import views as base_views from bedrock.base.i18n import bedrock_i18n_patterns +from bedrock.cms.bedrock_wagtail_urls import custom_wagtail_urls # The default django 404 and 500 handler doesn't run the ContextProcessors, # which breaks the base template page. So we replace them with views that do! @@ -90,7 +90,8 @@ # Note that statics are handled via Whitenoise's middleware # Wagtail is the catch-all route, and it will raise a 404 if needed. -# Note that we're also using localised URLs here +# We have customised wagtail_urls in order to decorate wagtail_serve +# to 'get ahead' of that 404 raising, to avoid hitting the database. urlpatterns += bedrock_i18n_patterns( - path("", include(wagtail_urls)), + path("", include(custom_wagtail_urls)), ) From f9d91fc240e6cd49535c536d3ecb5edb3ce7b1e1 Mon Sep 17 00:00:00 2001 From: Steve Jalim Date: Tue, 14 Jan 2025 12:35:39 +0000 Subject: [PATCH 4/5] Add helpers to support a 'hybrid cache' option that uses locmem + DB cache In order to balance the need for a distributed cache with the speed of a local-memory cache, we've come up with a couple of helper functions that wrap the following behaviour: * If it's in the local-memory cache, return that immediately. * If it's not, fall back to the DB cache, and if the key exists there, return that, cacheing it in local memory again on the way through * If the local memory cache and DB cache both miss, just return the default value for the helper function * Set the value in the local memory cache and DB cache at (almost) the same time * If the DB cache is not reachable (eg the DB is a read-only replica), log this loudly, as it's a sign the helper has not been used appropriately, but still set the local-memory version for now, to prevent total failure. IMPORTANT: before this can be used in production, we need to create the cache table in the database with ./manage.py createcachetable AFTER this code has been deployed. This sounds a bit chicken-and-egg but we hopefully can do it via direct DB connection in the worst case. --- bedrock/base/cache.py | 92 +++++++++++++++++ .../base/tests/test_hybrid_cache_helpers.py | 98 +++++++++++++++++++ bedrock/cms/utils.py | 2 +- bedrock/settings/base.py | 7 +- 4 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 bedrock/base/tests/test_hybrid_cache_helpers.py diff --git a/bedrock/base/cache.py b/bedrock/base/cache.py index acddcb389c5..a7b64e20c03 100644 --- a/bedrock/base/cache.py +++ b/bedrock/base/cache.py @@ -2,10 +2,16 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. +import logging + +from django.conf import settings +from django.core.cache import caches from django.core.cache.backends.locmem import DEFAULT_TIMEOUT, LocMemCache from bedrock.base import metrics +logger = logging.getLogger(__name__) + class SimpleDictCache(LocMemCache): """A local memory cache that doesn't pickle values. @@ -70,3 +76,89 @@ def incr(self, key, delta=1, version=None): with self._lock: self._cache[key] = new_value return new_value + + +# === HYBRID CACHE BEHAVIOUR === +# +# Our "hybrid cache" approach uses SimpleDictCache as a local, read-through +# cache on the pod, which falls back to get values from a distributed +# DB-backed cache. +# The DB-backed cache is NOT a read-through cache and only has its values +# set explicitly. + +local_cache = caches["default"] # This is the SimpleDictCache +db_cache = caches["db"] + + +def get_from_hybrid_cache(key, default=None): + """ + Retrieve a value from the hybrid cache. First checks local cache, then falls + back to DB cache. + + If found in DB cache, the value is added to local cache for faster subsequent + access. + + This can be called from any code, because it does not require write access to + the DB. + + :param key: The cache key to retrieve. + :param default: Default value to return if the key is not found in either cache. + + :return: The cached value, or the default if the key is not found. + """ + # Check local cache + value = local_cache.get(key) + if value is not None: + return value + + # Check DB cache and if it has a value, pop it into + # the local cache en route to returning the value + value = db_cache.get(key) + if value is not None: + local_cache.set( + key, + value, + timeout=settings.CACHE_TIME_SHORT, + ) + return value + + return default + + +def set_in_hybrid_cache(key, value, timeout=None): + """ + Set a value in the hybrid cache. + + Writes to both the local cache and the DB cache. + + IMPORTANT: this should only be called from somewhere with DB-write access - + i.e. the CMS deployment pod – if it is called from a Web deployment pod, it + will only set the local-memory cache and also log an exception, because + there will be unpredictable results if you're tryint to cache cache + something that should be available across pods -- and if you're not you + should just use the regular 'default' local-memory cache directly. + + :param key: The cache key to set. + :param value: The value to cache. + :param timeout: Timeout for DB cache in seconds (local cache will use a shorter timeout by default). + """ + # Set in DB cache first, with the provided optional timeout. + # In settings we have a timeout of None, so it will never expire + # but still can be replaced (via this helper function) + + try: + db_cache.set( + key, + value, + timeout=timeout, + ) + except Exception as ex: + # Cope with the DB cache not being available - eg + logger.exception(f"Could not set value in DB-backed cache: {ex}") + + # Set in local cache with a short timeout + local_cache.set( + key, + value, + timeout=settings.CACHE_TIME_SHORT, + ) diff --git a/bedrock/base/tests/test_hybrid_cache_helpers.py b/bedrock/base/tests/test_hybrid_cache_helpers.py new file mode 100644 index 00000000000..3641bfc6617 --- /dev/null +++ b/bedrock/base/tests/test_hybrid_cache_helpers.py @@ -0,0 +1,98 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +from django.conf import settings +from django.core.cache import caches + +import pytest + +from bedrock.base.cache import get_from_hybrid_cache, set_in_hybrid_cache + +pytestmark = [ + pytest.mark.django_db, +] + +local_cache = caches["default"] +db_cache = caches["db"] + + +@pytest.fixture(autouse=True) +def clear_caches(): + print("REMOVE PRINT STATEMENT") + local_cache.clear() + db_cache.clear() + + +def test_hybrid_cache_get(): + key = "test_key" + value = "test_value" + + local_cache.set( + key, + value, + timeout=settings.CACHE_TIME_SHORT, + ) + db_cache.set( + key, + value, + timeout=settings.CACHE_TIME_SHORT, + ) + + # Test getting from local cache directly + assert get_from_hybrid_cache(key) == value + + # Test falling back to db cache and populating local cache + local_cache.clear() + assert local_cache.get(key) is None + assert db_cache.get(key) == value + + assert get_from_hybrid_cache(key) == value + assert local_cache.get(key) == value + + +def test_hybrid_cache_get_no_values_in_local_or_db_cache(): + key = "test_key" + + assert local_cache.get(key) is None + assert db_cache.get(key) is None + assert get_from_hybrid_cache(key) is None + + +def test_hybrid_cache_get__default_value(): + # Test getting default value when key is not found + assert ( + get_from_hybrid_cache( + "non_existent_key", + default="default_value", + ) + == "default_value" + ) + + +def test_hybrid_cache_set(): + key = "test_key" + value = "test_value" + set_in_hybrid_cache(key, value) + + assert local_cache.get(key) == value + assert db_cache.get(key) == value + + +def test_set_in_hybrid_cache_db_cache_failure(caplog, mocker): + key = "test_key_db_failure" + value = "test_value_db_failure" + timeout = 60 + + mocker.patch.object( + db_cache, + "set", + side_effect=Exception("Faked DB cache failure"), + ) + + set_in_hybrid_cache(key, value, timeout) + + assert local_cache.get(key) == value + assert db_cache.get(key) is None + + assert caplog.records[0].msg == "Could not set value in DB-backed cache: Faked DB cache failure" diff --git a/bedrock/cms/utils.py b/bedrock/cms/utils.py index 8c1a4c7d9e8..b140e085222 100644 --- a/bedrock/cms/utils.py +++ b/bedrock/cms/utils.py @@ -111,7 +111,7 @@ def path_exists_in_cms(path: str) -> bool: return False -def warm_page_path_cache() -> set(): +def warm_page_path_cache() -> set: paths = _get_all_cms_paths() logger.info(f"Warming the cache '{BEDROCK_ALL_CMS_PATHS_CACHE_KEY}' with {len(paths)} paths ") diff --git a/bedrock/settings/base.py b/bedrock/settings/base.py index 86c8ff00c94..3534aab4066 100644 --- a/bedrock/settings/base.py +++ b/bedrock/settings/base.py @@ -90,7 +90,6 @@ def data_path(*args): CACHE_TIME_MED = 60 * 60 # 1 hour CACHE_TIME_LONG = 60 * 60 * 6 # 6 hours - CACHES = { "default": { "BACKEND": "bedrock.base.cache.SimpleDictCache", @@ -101,6 +100,12 @@ def data_path(*args): "CULL_FREQUENCY": 4, # 1/4 entries deleted if max reached }, }, + "db": { + # Intended for use as a slower – but distributed - cache + # See bedrock.base.cache.get_from_hybrid_cache and set_in_hybrid_cache + "BACKEND": "django.core.cache.backends.db.DatabaseCache", + "TIMEOUT": None, # cached items will not expire + }, } # Logging From edf49d4477398e9f407a0a9768f3ca35ebb68654 Mon Sep 17 00:00:00 2001 From: Steve Jalim Date: Mon, 27 Jan 2025 19:17:57 +0000 Subject: [PATCH 5/5] REBASE ME - WIP on wrapped cache approach --- bedrock/base/cache.py | 88 -------------- bedrock/cms/migrations/0003_simplekvstore.py | 39 +++++++ bedrock/cms/models/__init__.py | 1 + bedrock/cms/models/utility.py | 45 +++++++ .../tests/test_hybrid_cache_helpers.py | 68 +++++++---- bedrock/cms/utils.py | 110 +++++++++++++++++- bedrock/settings/base.py | 2 +- 7 files changed, 240 insertions(+), 113 deletions(-) create mode 100644 bedrock/cms/migrations/0003_simplekvstore.py create mode 100644 bedrock/cms/models/utility.py rename bedrock/{base => cms}/tests/test_hybrid_cache_helpers.py (53%) diff --git a/bedrock/base/cache.py b/bedrock/base/cache.py index a7b64e20c03..6ddda5269dc 100644 --- a/bedrock/base/cache.py +++ b/bedrock/base/cache.py @@ -4,8 +4,6 @@ import logging -from django.conf import settings -from django.core.cache import caches from django.core.cache.backends.locmem import DEFAULT_TIMEOUT, LocMemCache from bedrock.base import metrics @@ -76,89 +74,3 @@ def incr(self, key, delta=1, version=None): with self._lock: self._cache[key] = new_value return new_value - - -# === HYBRID CACHE BEHAVIOUR === -# -# Our "hybrid cache" approach uses SimpleDictCache as a local, read-through -# cache on the pod, which falls back to get values from a distributed -# DB-backed cache. -# The DB-backed cache is NOT a read-through cache and only has its values -# set explicitly. - -local_cache = caches["default"] # This is the SimpleDictCache -db_cache = caches["db"] - - -def get_from_hybrid_cache(key, default=None): - """ - Retrieve a value from the hybrid cache. First checks local cache, then falls - back to DB cache. - - If found in DB cache, the value is added to local cache for faster subsequent - access. - - This can be called from any code, because it does not require write access to - the DB. - - :param key: The cache key to retrieve. - :param default: Default value to return if the key is not found in either cache. - - :return: The cached value, or the default if the key is not found. - """ - # Check local cache - value = local_cache.get(key) - if value is not None: - return value - - # Check DB cache and if it has a value, pop it into - # the local cache en route to returning the value - value = db_cache.get(key) - if value is not None: - local_cache.set( - key, - value, - timeout=settings.CACHE_TIME_SHORT, - ) - return value - - return default - - -def set_in_hybrid_cache(key, value, timeout=None): - """ - Set a value in the hybrid cache. - - Writes to both the local cache and the DB cache. - - IMPORTANT: this should only be called from somewhere with DB-write access - - i.e. the CMS deployment pod – if it is called from a Web deployment pod, it - will only set the local-memory cache and also log an exception, because - there will be unpredictable results if you're tryint to cache cache - something that should be available across pods -- and if you're not you - should just use the regular 'default' local-memory cache directly. - - :param key: The cache key to set. - :param value: The value to cache. - :param timeout: Timeout for DB cache in seconds (local cache will use a shorter timeout by default). - """ - # Set in DB cache first, with the provided optional timeout. - # In settings we have a timeout of None, so it will never expire - # but still can be replaced (via this helper function) - - try: - db_cache.set( - key, - value, - timeout=timeout, - ) - except Exception as ex: - # Cope with the DB cache not being available - eg - logger.exception(f"Could not set value in DB-backed cache: {ex}") - - # Set in local cache with a short timeout - local_cache.set( - key, - value, - timeout=settings.CACHE_TIME_SHORT, - ) diff --git a/bedrock/cms/migrations/0003_simplekvstore.py b/bedrock/cms/migrations/0003_simplekvstore.py new file mode 100644 index 00000000000..7d37f369cf3 --- /dev/null +++ b/bedrock/cms/migrations/0003_simplekvstore.py @@ -0,0 +1,39 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +# Generated by Django 4.2.18 on 2025-01-27 15:19 + +from django.db import migrations, models + +import bedrock.cms.models.utility + + +class Migration(migrations.Migration): + dependencies = [ + ("cms", "0002_bedrockimage_bedrockrendition"), + ] + + operations = [ + migrations.CreateModel( + name="SimpleKVStore", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("key", models.CharField(max_length=64, unique=True)), + ( + "value", + models.JSONField(encoder=bedrock.cms.models.utility.SetAwareEncoder), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("last_updated", models.DateTimeField(auto_now=True)), + ], + ), + ] diff --git a/bedrock/cms/models/__init__.py b/bedrock/cms/models/__init__.py index 12412805b75..e77d5f4ce71 100644 --- a/bedrock/cms/models/__init__.py +++ b/bedrock/cms/models/__init__.py @@ -4,3 +4,4 @@ from .pages import * # noqa from .images import * # noqa +from .utility import * # noqa diff --git a/bedrock/cms/models/utility.py b/bedrock/cms/models/utility.py new file mode 100644 index 00000000000..a115f4e128a --- /dev/null +++ b/bedrock/cms/models/utility.py @@ -0,0 +1,45 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import json + +from django.db import models + + +class SetAwareEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, set): + return list(obj) + return json.JSONEncoder.default(self, obj) + + +class SimpleKVStore(models.Model): + """Allows us to use the DB as a simple key-value store via the ORM""" + + key = models.CharField( + blank=False, + max_length=64, + null=False, + unique=True, + ) + value = models.JSONField( + blank=False, + null=False, + encoder=SetAwareEncoder, + ) + created = models.DateTimeField(auto_now_add=True) + last_updated = models.DateTimeField(auto_now=True) + + def __repr__(self): + return f" 10: + return f"{self.value}..." + else: + return self.value diff --git a/bedrock/base/tests/test_hybrid_cache_helpers.py b/bedrock/cms/tests/test_hybrid_cache_helpers.py similarity index 53% rename from bedrock/base/tests/test_hybrid_cache_helpers.py rename to bedrock/cms/tests/test_hybrid_cache_helpers.py index 3641bfc6617..8e841308c74 100644 --- a/bedrock/base/tests/test_hybrid_cache_helpers.py +++ b/bedrock/cms/tests/test_hybrid_cache_helpers.py @@ -7,21 +7,36 @@ import pytest -from bedrock.base.cache import get_from_hybrid_cache, set_in_hybrid_cache +from bedrock.cms.models import SimpleKVStore +from bedrock.cms.utils import get_from_cache_wrapped_kv_store, set_in_cached_wrapped_kv_store pytestmark = [ pytest.mark.django_db, ] local_cache = caches["default"] -db_cache = caches["db"] @pytest.fixture(autouse=True) def clear_caches(): - print("REMOVE PRINT STATEMENT") local_cache.clear() - db_cache.clear() + + +def _set_in_db(key, value): + try: + store = SimpleKVStore.objects.get(key=key) + except SimpleKVStore.DoesNotExist: + store = SimpleKVStore(key=key) + store.value = value + store.save() + + +def _get_from_db(key): + try: + store = SimpleKVStore.objects.get(key=key) + return store.value + except SimpleKVStore.DoesNotExist: + return None def test_hybrid_cache_get(): @@ -33,21 +48,20 @@ def test_hybrid_cache_get(): value, timeout=settings.CACHE_TIME_SHORT, ) - db_cache.set( + _set_in_db( key, value, - timeout=settings.CACHE_TIME_SHORT, ) # Test getting from local cache directly - assert get_from_hybrid_cache(key) == value + assert get_from_cache_wrapped_kv_store(key) == value # Test falling back to db cache and populating local cache local_cache.clear() assert local_cache.get(key) is None - assert db_cache.get(key) == value + assert _get_from_db(key) == value - assert get_from_hybrid_cache(key) == value + assert get_from_cache_wrapped_kv_store(key) == value assert local_cache.get(key) == value @@ -55,14 +69,14 @@ def test_hybrid_cache_get_no_values_in_local_or_db_cache(): key = "test_key" assert local_cache.get(key) is None - assert db_cache.get(key) is None - assert get_from_hybrid_cache(key) is None + assert _get_from_db(key) is None + assert get_from_cache_wrapped_kv_store(key) is None def test_hybrid_cache_get__default_value(): # Test getting default value when key is not found assert ( - get_from_hybrid_cache( + get_from_cache_wrapped_kv_store( "non_existent_key", default="default_value", ) @@ -73,26 +87,38 @@ def test_hybrid_cache_get__default_value(): def test_hybrid_cache_set(): key = "test_key" value = "test_value" - set_in_hybrid_cache(key, value) + set_in_cached_wrapped_kv_store(key, value) assert local_cache.get(key) == value - assert db_cache.get(key) == value + assert _get_from_db(key) == value -def test_set_in_hybrid_cache_db_cache_failure(caplog, mocker): +def test_set_in_cached_wrapped_kv_store_db_cache_failure(caplog, mocker): key = "test_key_db_failure" value = "test_value_db_failure" - timeout = 60 - mocker.patch.object( - db_cache, - "set", + mocker.patch( + "bedrock.cms.utils._set_in_db_kv_store", side_effect=Exception("Faked DB cache failure"), ) - set_in_hybrid_cache(key, value, timeout) + set_in_cached_wrapped_kv_store(key, value) assert local_cache.get(key) == value - assert db_cache.get(key) is None + assert _get_from_db(key) is None assert caplog.records[0].msg == "Could not set value in DB-backed cache: Faked DB cache failure" + + +def test_type_conversion_of_set_during_db_storage(): + input = set(["hello", "world", 123]) + assert isinstance(input, set) + + set_in_cached_wrapped_kv_store("test-key", input) + output = get_from_cache_wrapped_kv_store("test-key") + assert isinstance(output, list) + + # also be sure the locmem version was turned into a list, too + assert isinstance(local_cache.get("test-key"), list) + + assert set(output) == input diff --git a/bedrock/cms/utils.py b/bedrock/cms/utils.py index b140e085222..989fef93d74 100644 --- a/bedrock/cms/utils.py +++ b/bedrock/cms/utils.py @@ -13,6 +13,7 @@ from wagtail.models import Locale, Page from bedrock.base.i18n import split_path_and_normalize_language +from bedrock.cms.models import utility as utility_models # to avoid circular dep logger = logging.getLogger(__name__) @@ -94,9 +95,16 @@ def path_exists_in_cms(path: str) -> bool: auto-appended - we'll let Django/Wagtail handle that. """ - cms_paths = cache.get(BEDROCK_ALL_CMS_PATHS_CACHE_KEY) + cms_paths = get_from_cache_wrapped_kv_store(key=BEDROCK_ALL_CMS_PATHS_CACHE_KEY, cast_to=set) + if cms_paths is None: cms_paths = warm_page_path_cache() + else: + # The set is turned into a list when put into the DB, but + # when we pull it out we can't reliably decode it as a set() unless + # we definitely know it should be one -- which we do in this case. + cms_paths = set(cms_paths) + if "?" in path: path = path.partition("?")[0] @@ -115,9 +123,105 @@ def warm_page_path_cache() -> set: paths = _get_all_cms_paths() logger.info(f"Warming the cache '{BEDROCK_ALL_CMS_PATHS_CACHE_KEY}' with {len(paths)} paths ") - cache.set( + set_in_cached_wrapped_kv_store( BEDROCK_ALL_CMS_PATHS_CACHE_KEY, paths, - settings.CACHE_TIME_LONG, ) return paths + + +def _get_from_db_kv_store(key, default=None): + try: + return utility_models.SimpleKVStore.objects.get(key=key).value + except utility_models.SimpleKVStore.DoesNotExist: + logger.info(f"SimpleKVStore: No key {key} found") + return default + + +def _set_in_db_kv_store(key, value): + try: + stored = utility_models.SimpleKVStore.objects.get(key=key) + except utility_models.SimpleKVStore.DoesNotExist: + logger.info(f"SimpleKVStore: No key {key} found, making new one") + stored = utility_models.SimpleKVStore(key=key) + + logger.info(f"SimpleKVStore: setting value for {key}") + stored.value = value + stored.save() + + return stored + + +def get_from_cache_wrapped_kv_store(key, default=None): + """ + Retrieve a value from the hybrid cache. First checks local cache, then falls + back to DB KV store. + + If found in DB cache, the value is added to local cache for faster subsequent + access. + + This can be called from any code, because it does not require write access to + the DB. + + :param key: The cache key to retrieve. + :param default: Default value to return if the key is not found in either cache. + + :return: The cached value, or the default if the key is not found. + """ + # Check local cache + value = cache.get(key) + + if value is not None: + return value + + # Check DB for the value... + value = _get_from_db_kv_store(key) + # ... and if it has a value, pop it into + # the local cache en route to returning the value + if value is not None: + cache.set( + key, + value, + timeout=settings.CACHE_TIME_SHORT, + ) + return value + + return default + + +def set_in_cached_wrapped_kv_store(key, value): + """ + Set a value in the hybrid "cache". + + Writes to both the local cache and the DB KV table. + + IMPORTANT: this should only be called from somewhere with DB-write access - + i.e. the CMS deployment pod. If it is called from a Web deployment pod, it + will only set the local-memory cache and also log an exception, because + there will be unpredictable results if you're trying to cache + something that should be available across pods -- and if you're not, you + should just use the regular 'default' local-memory cache directly. + + :param key: The cache key to set. + :param value: The value to cache. + """ + # Set in DB first + try: + stored = _set_in_db_kv_store( + key, + value, + ) + # Storing may turn a set into a list. We want to stably cache that + # rather than storing a list in the DB and a set in locmem + stored.refresh_from_db() + value = stored.value + except Exception as ex: + # Cope with the DB cache not being available + logger.exception(f"Could not set value in DB-backed cache: {ex}") + + # Set in local cache with a deliberately short timeout + cache.set( + key, + value, + timeout=settings.CACHE_TIME_SHORT, + ) diff --git a/bedrock/settings/base.py b/bedrock/settings/base.py index 3534aab4066..96bc95d701e 100644 --- a/bedrock/settings/base.py +++ b/bedrock/settings/base.py @@ -102,7 +102,7 @@ def data_path(*args): }, "db": { # Intended for use as a slower – but distributed - cache - # See bedrock.base.cache.get_from_hybrid_cache and set_in_hybrid_cache + # See bedrock.base.cache.get_from_cache_wrapped_kv_store and set_in_cached_wrapped_kv_store "BACKEND": "django.core.cache.backends.db.DatabaseCache", "TIMEOUT": None, # cached items will not expire },