-
Notifications
You must be signed in to change notification settings - Fork 10
Invalidate CDN cache on plain document edits #439
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
Open
eyeseast
wants to merge
5
commits into
master
Choose a base branch
from
400-invalidate-cache
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| """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__) | ||
|
|
||
|
|
||
| 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): | ||
| requests.post(url, json={key: chunk}, headers=headers, timeout=10) | ||
|
|
||
|
|
||
| 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) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| # Third Party | ||
| import pytest | ||
|
|
||
| # DocumentCloud | ||
| from documentcloud.documents.cache import invalidate_cache_batch | ||
| from documentcloud.documents.choices import Access | ||
| from documentcloud.documents.tests.factories import DocumentFactory | ||
|
|
||
|
|
||
| @pytest.mark.django_db() | ||
| class TestDocumentCacheInvalidation: | ||
| """`invalidate_cache_batch` purges the API by Cache-Tag and URLs by URL.""" | ||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def cache_settings(self, settings): | ||
| settings.CLOUDFLARE_API_ZONE = "zone123" | ||
| settings.CLOUDFLARE_API_EMAIL = "cache@example.com" | ||
| settings.CLOUDFLARE_API_KEY = "secret" | ||
| settings.CLOUDFLARE_HOSTS = ["https://www.example.com"] | ||
| settings.CLOUDFRONT_DISTRIBUTION_ID = "" | ||
| settings.PUBLIC_ASSET_URL = "https://assets.example.com/documents/" | ||
|
|
||
| @pytest.fixture | ||
| def mock_post(self, mocker): | ||
| return mocker.patch("documentcloud.documents.cache.requests.post") | ||
|
|
||
| def test_cache_tag(self): | ||
| """The Cache-Tag is `doc-{pk}`.""" | ||
| document = DocumentFactory() | ||
| assert document.cache_tag == f"doc-{document.pk}" | ||
|
|
||
| def test_batch_purges_tag_and_urls(self, mock_post): | ||
| """One Cloudflare request purges the `doc-{id}` tag, another the URLs. | ||
|
|
||
| `files` and `tags` are mutually exclusive in a single zone purge | ||
| request, so they must be sent separately. | ||
| """ | ||
| document = DocumentFactory() | ||
|
|
||
| invalidate_cache_batch([document]) | ||
|
|
||
| assert mock_post.call_count == 2 | ||
| payloads = [call.kwargs["json"] for call in mock_post.call_args_list] | ||
| tags_payload = next(p for p in payloads if "tags" in p) | ||
| files_payload = next(p for p in payloads if "files" in p) | ||
| assert tags_payload["tags"] == [f"doc-{document.pk}"] | ||
| assert ( | ||
| f"https://www.example.com{document.get_absolute_url()}" | ||
| in files_payload["files"] | ||
| ) | ||
| # never both keys in one request | ||
| assert all(("tags" in p) != ("files" in p) for p in payloads) | ||
|
|
||
| def test_batch_always_purges_frontend_urls_even_when_private(self, mock_post): | ||
| """On a public -> private flip `access` is already private by purge | ||
| time, so the frontend URLs must be purged unconditionally (5b) - the | ||
| public copy may still be cached at the edge.""" | ||
| document = DocumentFactory(access=Access.private) | ||
|
|
||
| invalidate_cache_batch([document]) | ||
|
|
||
| files_payload = next( | ||
| call.kwargs["json"] | ||
| for call in mock_post.call_args_list | ||
| if "files" in call.kwargs["json"] | ||
| ) | ||
| assert ( | ||
| f"https://www.example.com{document.get_absolute_url()}" | ||
| in files_payload["files"] | ||
| ) | ||
|
|
||
| def test_batch_chunks_to_the_purge_limit(self, mock_post, settings): | ||
| """Each purge request is chunked to the configured cap.""" | ||
| settings.CLOUDFLARE_PURGE_LIMIT = 2 | ||
| documents = DocumentFactory.create_batch(3) | ||
|
|
||
| invalidate_cache_batch(documents) | ||
|
|
||
| # 3 tags -> chunks of 2 -> 2 requests | ||
| # 3 docs x (1 host + 1 asset) = 6 files -> chunks of 2 -> 3 requests | ||
| assert mock_post.call_count == 5 | ||
|
|
||
| def test_batch_no_op_without_zone(self, mock_post, settings): | ||
| """No Cloudflare zone configured means no purge request.""" | ||
| settings.CLOUDFLARE_API_ZONE = "" | ||
| document = DocumentFactory() | ||
|
|
||
| invalidate_cache_batch([document]) | ||
|
|
||
| mock_post.assert_not_called() | ||
|
|
||
| def test_batch_empty_is_noop(self, mock_post): | ||
| """An empty batch issues no requests.""" | ||
| invalidate_cache_batch([]) | ||
| mock_post.assert_not_called() | ||
|
|
||
| @pytest.mark.usefixtures("mock_post") | ||
| def test_batch_purges_cloudfront_paths(self, mocker, settings): | ||
| """CloudFront is invalidated by path for every document in the batch.""" | ||
| settings.CLOUDFRONT_DISTRIBUTION_ID = "DIST123" | ||
| mock_boto = mocker.patch("documentcloud.documents.cache.boto3") | ||
| documents = DocumentFactory.create_batch(2) | ||
|
|
||
| invalidate_cache_batch(documents) | ||
|
|
||
| create_invalidation = mock_boto.client.return_value.create_invalidation | ||
| create_invalidation.assert_called_once() | ||
| paths = create_invalidation.call_args.kwargs["InvalidationBatch"]["Paths"] | ||
| assert paths["Quantity"] == 2 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| # Third Party | ||
| import pytest | ||
|
|
||
| # DocumentCloud | ||
| from documentcloud.documents.tasks import invalidate_cache | ||
| from documentcloud.documents.tests.factories import DocumentFactory | ||
|
|
||
|
|
||
| @pytest.mark.django_db() | ||
| class TestInvalidateCacheTask: | ||
| """The `invalidate_cache` task batches purges and clears `cache_dirty`.""" | ||
|
|
||
| def test_accepts_single_pk(self, mocker): | ||
| """A single pk arg purges one document.""" | ||
| mock_batch = mocker.patch( | ||
| "documentcloud.documents.tasks.invalidate_cache_batch" | ||
| ) | ||
| document = DocumentFactory(cache_dirty=True) | ||
|
|
||
| invalidate_cache(document.pk) | ||
|
|
||
| mock_batch.assert_called_once() | ||
| (documents,) = mock_batch.call_args[0] | ||
| assert [d.pk for d in documents] == [document.pk] | ||
| document.refresh_from_db() | ||
| assert document.cache_dirty is False | ||
|
|
||
| def test_accepts_many_pks(self, mocker): | ||
| """Several pk args are purged in a single batch.""" | ||
| mock_batch = mocker.patch( | ||
| "documentcloud.documents.tasks.invalidate_cache_batch" | ||
| ) | ||
| documents = DocumentFactory.create_batch(3, cache_dirty=True) | ||
|
|
||
| invalidate_cache(*[d.pk for d in documents]) | ||
|
|
||
| assert mock_batch.call_count == 1 | ||
| (called,) = mock_batch.call_args[0] | ||
| assert {d.pk for d in called} == {d.pk for d in documents} | ||
| for document in documents: | ||
| document.refresh_from_db() | ||
| assert document.cache_dirty is False | ||
|
|
||
| def test_clears_dirty_without_bumping_updated_at(self, mocker): | ||
| """Clearing the flag must not look like a content edit. | ||
|
|
||
| `updated_at` is an `AutoLastModifiedField`; bumping it on every purge | ||
| would silently reset the freshness signal (3) and demote the document | ||
| to the shortest TTL tier (4). The flag is cleared with a queryset | ||
| `.update()` precisely so `save()` (and the field) never fires. | ||
| """ | ||
| mocker.patch("documentcloud.documents.tasks.invalidate_cache_batch") | ||
| document = DocumentFactory(cache_dirty=True) | ||
| original_updated_at = document.updated_at | ||
|
|
||
| invalidate_cache(document.pk) | ||
|
|
||
| document.refresh_from_db() | ||
| assert document.cache_dirty is False | ||
| assert document.updated_at == original_updated_at |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The response status code should be checked here - can retry intermittent errors and log them if they still fail after some retries.
Also, does this need to be rate limited to comply with their limits?