cache queries and revalidate with incremental static regeneration

This commit is contained in:
2026-07-07 02:32:21 +02:00
parent d63b14b2e5
commit e600d0a663
22 changed files with 381 additions and 114 deletions
+3
View File
@@ -6,3 +6,6 @@ class DnsCmsConfig(AppConfig):
def ready(self):
from dnscms import signals # noqa: F401
from dnscms.revalidation import register_signal_handlers
register_signal_handlers()
+97
View File
@@ -0,0 +1,97 @@
"""Purge the frontend cache when content changes.
The frontend caches all GraphQL responses under a single "cms" cache tag;
any content change POSTs to its /api/revalidate endpoint for a global purge.
"""
import logging
import threading
import urllib.request
from django.conf import settings
from django.db import transaction
from django.db.models.signals import post_delete, post_save
logger = logging.getLogger(__name__)
WEBHOOK_TIMEOUT_SECONDS = 5
# Non-page models exposed over GraphQL; pages are covered by wagtail signals
REVALIDATING_MODELS = [
"contacts.ContactEntity",
"events.EventCategory",
"events.EventOrganizer",
"images.CustomImage",
"openinghours.OpeningHoursSet",
]
_warned_unconfigured = False
def _send_webhook(url, secret, reason):
request = urllib.request.Request(
url, data=b"", method="POST", headers={"X-Revalidate-Secret": secret}
)
try:
with urllib.request.urlopen(request, timeout=WEBHOOK_TIMEOUT_SECONDS) as response:
logger.info("frontend revalidation ok (%s): HTTP %s", reason, response.status)
except Exception:
logger.exception("frontend revalidation failed (%s)", reason)
def trigger_frontend_revalidation(reason):
global _warned_unconfigured
secret = settings.REVALIDATE_WEBHOOK_SECRET
if not secret:
if not _warned_unconfigured:
logger.warning("REVALIDATE_WEBHOOK_SECRET is not set, skipping frontend revalidation")
_warned_unconfigured = True
return
url = f"{settings.FRONTEND_BASE_URL}/api/revalidate"
# After commit so the frontend can't re-render from pre-commit data,
# in a thread so publishing never blocks on the frontend
transaction.on_commit(
lambda: threading.Thread(
target=_send_webhook, args=(url, secret, reason), daemon=True
).start()
)
def _on_page_published(sender, instance, **kwargs):
trigger_frontend_revalidation(f"page_published: {instance.slug}")
def _on_page_unpublished(sender, instance, **kwargs):
trigger_frontend_revalidation(f"page_unpublished: {instance.slug}")
def _on_page_moved(sender, instance, **kwargs):
trigger_frontend_revalidation(f"post_page_move: {instance.slug}")
def _on_page_deleted(sender, instance, **kwargs):
trigger_frontend_revalidation(f"page_deleted: {instance.slug}")
def _on_model_changed(sender, instance, **kwargs):
trigger_frontend_revalidation(f"{sender._meta.label}: {instance}")
def register_signal_handlers():
from wagtail.models import Page
from wagtail.signals import page_published, page_unpublished, post_page_move
page_published.connect(_on_page_published, dispatch_uid="revalidation.page_published")
page_unpublished.connect(_on_page_unpublished, dispatch_uid="revalidation.page_unpublished")
post_page_move.connect(_on_page_moved, dispatch_uid="revalidation.post_page_move")
# Wagtail sends no signal on page deletion; MTI deletes of any page type
# also emit post_delete for the base Page row
post_delete.connect(_on_page_deleted, sender=Page, dispatch_uid="revalidation.page_deleted")
for label in REVALIDATING_MODELS:
post_save.connect(
_on_model_changed, sender=label, dispatch_uid=f"revalidation.save.{label}"
)
post_delete.connect(
_on_model_changed, sender=label, dispatch_uid=f"revalidation.delete.{label}"
)
+4
View File
@@ -200,6 +200,10 @@ BASE_URL = WAGTAIL_BASE_URL
# redirect "View Live" clicks on the CMS host over to the headless frontend.
FRONTEND_BASE_URL = os.environ.get("FRONTEND_BASE_URL", "http://localhost:3000").rstrip("/")
# Shared secret for the frontend cache purge webhook (see dnscms/revalidation.py).
# Unset means no webhooks are sent.
REVALIDATE_WEBHOOK_SECRET = os.environ.get("REVALIDATE_WEBHOOK_SECRET", "")
WAGTAIL_HEADLESS_PREVIEW = {
"CLIENT_URLS": {"default": f"{FRONTEND_BASE_URL}/api/preview"},
"SERVE_BASE_URL": FRONTEND_BASE_URL,
+127
View File
@@ -0,0 +1,127 @@
import logging
import urllib.error
import pytest
from dnscms import revalidation
from events.models import EventCategory
from tests.conftest import GenericPageFactory
WEBHOOK_URL = "https://frontend.example.com/api/revalidate"
class InlineThread:
def __init__(self, target=None, args=(), kwargs=None, daemon=None):
self._target = target
self._args = args
self._kwargs = kwargs or {}
def start(self):
self._target(*self._args, **self._kwargs)
@pytest.fixture
def webhook_calls(monkeypatch, settings):
settings.REVALIDATE_WEBHOOK_SECRET = "s3cret"
settings.FRONTEND_BASE_URL = "https://frontend.example.com"
calls = []
monkeypatch.setattr(revalidation.threading, "Thread", InlineThread)
monkeypatch.setattr(
revalidation, "_send_webhook", lambda url, secret, reason: calls.append((url, secret))
)
return calls
def test_page_publish_triggers_webhook(
home_page, webhook_calls, django_capture_on_commit_callbacks
):
page = GenericPageFactory(parent=home_page)
with django_capture_on_commit_callbacks(execute=True):
page.save_revision().publish()
assert webhook_calls == [(WEBHOOK_URL, "s3cret")]
def test_page_unpublish_triggers_webhook(
home_page, webhook_calls, django_capture_on_commit_callbacks
):
page = GenericPageFactory(parent=home_page)
page.save_revision().publish()
webhook_calls.clear()
with django_capture_on_commit_callbacks(execute=True):
page.unpublish()
assert webhook_calls == [(WEBHOOK_URL, "s3cret")]
def test_page_delete_triggers_webhook(
home_page, webhook_calls, django_capture_on_commit_callbacks
):
# live=False isolates the post_delete path: deleting a live page would
# also fire page_unpublished (a harmless duplicate purge in production)
page = GenericPageFactory(parent=home_page, live=False)
webhook_calls.clear()
with django_capture_on_commit_callbacks(execute=True):
page.delete()
assert webhook_calls == [(WEBHOOK_URL, "s3cret")]
@pytest.mark.django_db
def test_snippet_save_triggers_webhook(webhook_calls, django_capture_on_commit_callbacks):
with django_capture_on_commit_callbacks(execute=True):
EventCategory.objects.create(name="Konsert", slug="konsert")
assert webhook_calls == [(WEBHOOK_URL, "s3cret")]
def test_unset_secret_skips_and_warns_once(settings, monkeypatch, caplog):
settings.REVALIDATE_WEBHOOK_SECRET = ""
monkeypatch.setattr(revalidation, "_warned_unconfigured", False)
with caplog.at_level(logging.WARNING, logger="dnscms.revalidation"):
revalidation.trigger_frontend_revalidation("test")
revalidation.trigger_frontend_revalidation("test")
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 1
def test_send_webhook_failure_is_swallowed(monkeypatch, caplog):
def raise_urlerror(request, timeout):
raise urllib.error.URLError("connection refused")
monkeypatch.setattr(revalidation.urllib.request, "urlopen", raise_urlerror)
with caplog.at_level(logging.ERROR, logger="dnscms.revalidation"):
revalidation._send_webhook(WEBHOOK_URL, "s3cret", "test")
assert any("frontend revalidation failed" in r.message for r in caplog.records)
def test_send_webhook_request_shape(monkeypatch):
seen = {}
class FakeResponse:
status = 200
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def fake_urlopen(request, timeout):
seen["request"] = request
seen["timeout"] = timeout
return FakeResponse()
monkeypatch.setattr(revalidation.urllib.request, "urlopen", fake_urlopen)
revalidation._send_webhook(WEBHOOK_URL, "s3cret", "test")
request = seen["request"]
assert request.get_method() == "POST"
assert request.get_header("X-revalidate-secret") == "s3cret"
assert seen["timeout"] == revalidation.WEBHOOK_TIMEOUT_SECONDS