Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -308,3 +308,4 @@ CLAUDE.md

rootCA.pem
tests.txt
benchmarks/
2 changes: 2 additions & 0 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,8 @@
CLOUDFLARE_API_KEY = env("CLOUDFLARE_API_KEY", default="")
CLOUDFLARE_API_ZONE = env("CLOUDFLARE_API_ZONE", default="")
CLOUDFLARE_HOSTS = env.list("CLOUDFLARE_HOSTS", default=[])
# max operations per purge request (Business plan caps this at 100)
CLOUDFLARE_PURGE_LIMIT = env.int("CLOUDFLARE_PURGE_LIMIT", default=100)

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

Expand Down
2 changes: 1 addition & 1 deletion config/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,4 @@
SOLR_INDEX_NOTES = True
SOLR_QUERY_NOTES = True

REST_FRAMEWORK["DEFAUKT_VERSION"] = "2.0"
REST_FRAMEWORK["DEFAULT_VERSION"] = "2.0"
7 changes: 5 additions & 2 deletions documentcloud/addons/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,19 +351,22 @@ def test_filter_domain_no_partial_host_match(self, client):
assert response.status_code == status.HTTP_200_OK
assert response.json()["results"] == []

def test_list_expand_query_count(self, client, django_assert_num_queries):
def test_list_expand_query_count(self, client, django_assert_max_num_queries):
"""
Expanding addon+event must not scale queries with the number of runs.
Query count stays flat (the run's addon/github_account/event are
select_related'd and get_active reads a cached PK set).

An upper bound rather than an exact count: the paginator differs by API
version, and only the 2.0 cursor paginator skips the count query.
"""
user = UserFactory(is_staff=True)
client.force_authenticate(user=user)
url = "/api/addon_runs/?expand=addon,event&per_page=100"

for expected_count in range(1, 11):
AddOnRunFactory(user=user, addon=AddOnFactory())
with django_assert_num_queries(7):
with django_assert_max_num_queries(7):
response = client.get(url)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["results"]) == expected_count
Expand Down
117 changes: 117 additions & 0 deletions documentcloud/documents/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""CDN cache invalidation for documents (CloudFront + Cloudflare)."""

# Django
from django.conf import settings

# Standard Library
import logging
import uuid

# Third Party
import boto3
import requests

logger = logging.getLogger(__name__)


class CloudflarePurgeError(requests.RequestException):
"""A Cloudflare purge request was rejected.

Subclasses `RequestException` so the `invalidate_cache` task's
`autoretry_for=(RequestException,)` retries it, alongside the transport
errors `raise_for_status()` already raises.
"""


def _chunk(items, size):
"""Yield successive `size`-length chunks of `items`."""
for i in range(0, len(items), size):
yield items[i : i + size]


def _invalidate_cloudfront(paths):
"""Invalidate the given paths from CloudFront in one batch."""
distribution_id = settings.CLOUDFRONT_DISTRIBUTION_ID
if not distribution_id or not paths:
return
cloudfront = boto3.client("cloudfront")
cloudfront.create_invalidation(
DistributionId=distribution_id,
InvalidationBatch={
"Paths": {"Quantity": len(paths), "Items": paths},
"CallerReference": str(uuid.uuid4()),
},
)


def _invalidate_cloudflare(files=None, tags=None):
"""Purge the given files and tags from Cloudflare.

`files` and `tags` cannot be combined in a single purge request (the zone
purge API is a `oneOf`), so they are sent as separate requests, each
chunked to the plan's per-request operation cap.
"""
zone = settings.CLOUDFLARE_API_ZONE
if not zone:
return
url = f"https://api.cloudflare.com/client/v4/zones/{zone}/purge_cache"
headers = {
"X-Auth-Email": settings.CLOUDFLARE_API_EMAIL,
"X-Auth-Key": settings.CLOUDFLARE_API_KEY,
}
for key, values in (("files", files), ("tags", tags)):
for chunk in _chunk(values or [], settings.CLOUDFLARE_PURGE_LIMIT):
response = requests.post(
url, json={key: chunk}, headers=headers, timeout=10
)
# Cloudflare signals logical failures with `success: false` in a
# 200 body, so a clean status is not enough
if response.ok and response.json().get("success"):
continue
# a 429 is expected under burst load and handled by retry/backoff,
# so log it as a warning and keep real failures at error level
level = logging.WARNING if response.status_code == 429 else logging.ERROR
logger.log(
level,
"Cloudflare cache purge failed [%s]: status=%s body=%s",
key,
response.status_code,
response.text,
)
# raise so the Celery task retries (purging is idempotent):
# HTTPError for a bad status, CloudflarePurgeError for success=false
response.raise_for_status()
raise CloudflarePurgeError(response.text, response=response)


def invalidate_cache_batch(documents):
"""Invalidate the CloudFront and Cloudflare caches for many documents.

Cloudflare purges the API responses by Cache-Tag (`doc-{id}`) and the
frontend pages + public asset by URL; the two are mutually exclusive in a
single zone purge request, so they go in separate (chunked) requests.
CloudFront purges the underlying document file by path.
"""
documents = list(documents)
if not documents:
return
logger.info("Invalidating cache for %s", [document.pk for document in documents])

cloudfront_paths = []
cloudflare_files = []
cloudflare_tags = []
for document in documents:
# the doc path without the s3 bucket name
doc_path = document.doc_path[document.doc_path.index("/") :]
cloudfront_paths.append(doc_path)
# always purge the frontend URLs: on a public -> private flip `access`
# is already private by now, but the public copy may still be cached at
# the edge - purging a URL that was never cached is harmless
cloudflare_files.extend(
host + document.get_absolute_url() for host in settings.CLOUDFLARE_HOSTS
)
cloudflare_files.append(settings.PUBLIC_ASSET_URL + doc_path[1:])
cloudflare_tags.append(document.cache_tag)

_invalidate_cloudfront(cloudfront_paths)
_invalidate_cloudflare(files=cloudflare_files, tags=cloudflare_tags)
63 changes: 15 additions & 48 deletions documentcloud/documents/models/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,10 @@
import logging
import sys
import time
import uuid
from io import BytesIO

# Third Party
import boto3
import pymupdf
import requests
from listcrunch import crunch, uncrunch
from pikepdf import Page as PikePage, Pdf, Rectangle

Expand Down Expand Up @@ -283,13 +280,19 @@ def save(self, *args, **kwargs):
@transaction.atomic
def destroy(self):
# DocumentCloud
from documentcloud.documents.tasks import delete_document_files, solr_delete
from documentcloud.documents.tasks import (
delete_document_files,
invalidate_cache,
solr_delete,
)

self.status = Status.deleted
self.save()
DeletedDocument.objects.create(pk=self.pk)
transaction.on_commit(lambda: delete_document_files.delay(self.path))
transaction.on_commit(lambda: solr_delete.delay(self.pk))
# the CDN may still be serving the (now deleted) public copy
transaction.on_commit(lambda: invalidate_cache.delay(self.pk))

@property
def path(self):
Expand Down Expand Up @@ -710,51 +713,15 @@ def page_filter(text):

return solr_document

def invalidate_cache(self):
"""
Invalidate public CDN cache for this document's underlying file,
plus frontend URLs in Cloudflare
"""
logger.info("Invalidating cache for %s", self.pk)
doc_path = self.doc_path[self.doc_path.index("/") :]

# cloudfront
distribution_id = settings.CLOUDFRONT_DISTRIBUTION_ID
if distribution_id:
# we want the doc path without the s3 bucket name
cloudfront = boto3.client("cloudfront")
cloudfront.create_invalidation(
DistributionId=distribution_id,
InvalidationBatch={
"Paths": {"Quantity": 1, "Items": [doc_path]},
"CallerReference": str(uuid.uuid4()),
},
)

# cloudflare
cloudflare_email = settings.CLOUDFLARE_API_EMAIL
cloudflare_key = settings.CLOUDFLARE_API_KEY
cloudflare_zone = settings.CLOUDFLARE_API_ZONE
asset_url = settings.PUBLIC_ASSET_URL + doc_path[1:]
@property
def cache_tag(self):
"""The Cloudflare Cache-Tag marking this document's cached API responses.

if self.access == Access.public:
public_urls = [
host + self.get_absolute_url() for host in settings.CLOUDFLARE_HOSTS
] + [asset_url]
else:
public_urls = [asset_url]

if cloudflare_zone:
requests.post(
"https://api.cloudflare.com/client/v4/zones/"
f"{cloudflare_zone}/purge_cache",
json={"files": public_urls},
headers={
"X-Auth-Email": cloudflare_email,
"X-Auth-Key": cloudflare_key,
},
timeout=10,
)
Purging this one tag clears the bare `/api/documents/{pk}/` URL and
every `?expand=…` / per-`Origin` variant at once - the key spaces a
URL purge can't enumerate.
"""
return f"doc-{self.pk}"

def index_on_commit(self, **kwargs):
"""Index the document in Solr on tranasction commit"""
Expand Down
26 changes: 19 additions & 7 deletions documentcloud/documents/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from documentcloud.common.environment import httpsub, storage
from documentcloud.core.choices import Language
from documentcloud.documents import entity_extraction, modifications, solr
from documentcloud.documents.cache import invalidate_cache_batch
from documentcloud.documents.choices import Access, Status
from documentcloud.documents.models import Document, DocumentError
from documentcloud.documents.search import SOLR, SOLR_NOTES
Expand Down Expand Up @@ -411,13 +412,24 @@ def publish_scheduled_documents():
document.index_on_commit(field_updates={"status": "set"})


@shared_task
def invalidate_cache(document_pk):
"""Invalidate the CloudFront and CloudFlare caches"""
document = Document.objects.get(pk=document_pk)
document.invalidate_cache()
document.cache_dirty = False
document.save()
@shared_task(autoretry_for=(RequestException,), retry_backoff=30)
def invalidate_cache(*document_pks):
"""Invalidate the CloudFront and CloudFlare caches for the given documents.

Variadic so the input is always iterable: `invalidate_cache.delay(pk)`
purges one document, `invalidate_cache.delay(*pks)` purges a batch in one
set of requests rather than one task per document.

Retries on Cloudflare request failures - purging is idempotent, and
`cache_dirty` is only cleared once the purge succeeds.
"""
# only pk/slug are needed to build the purge URLs and tags - skip the
# heavy columns (page_spec, data, description, ...)
documents = list(Document.objects.filter(pk__in=document_pks).only("pk", "slug"))
invalidate_cache_batch(documents)
# clear the flag with a queryset update so we don't bump `updated_at` (an
# AutoLastModifiedField) - a cache purge is not a content change
Document.objects.filter(pk__in=document_pks).update(cache_dirty=False)


# page modifications
Expand Down
Loading
Loading