Skip to content

Commit 46bc167

Browse files
Edge caches can revalidate with If-Modified-Since after content guards
run, without downloading the file again. Last-Modified is RepositoryContent.pulp_created for the served version, not disk mtime. Filesystem and ArtifactResponse get Cache-Control: public, max-age=0, must-revalidate; object-storage 302s do not. Redis can 304 from a cached last_modified without rebuilding the body. Signed-off-by: Carlos Feria <2582866+carlosthe19916@users.noreply.github.com>
1 parent 107800e commit 46bc167

9 files changed

Lines changed: 842 additions & 94 deletions

File tree

CHANGES/7929.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added `Last-Modified` / `If-Modified-Since` (`304 Not Modified`) and `Cache-Control: public, max-age=0, must-revalidate` on content-app artifact responses (filesystem and `ArtifactResponse`; not object-storage 302s) so edge caches can revalidate after ContentGuard without re-fetching the body.

pulpcore/app/models/publication.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -755,14 +755,26 @@ def get_fallback_ca(self, path):
755755
"""
756756
Return a ContentArtifact for path from the grace-period publication history, or None.
757757
758+
See :meth:`get_fallback` for the publication that contained the unit.
759+
"""
760+
ca, _publication = self.get_fallback(path)
761+
return ca
762+
763+
def get_fallback(self, path):
764+
"""
765+
Return ``(ContentArtifact, Publication)`` from grace-period history, or ``(None, None)``.
766+
758767
Iterates DistributedPublication records for this distribution from newest to oldest,
759768
trying each publication until the path is found. Handles both pass-through and
760769
non-pass-through (PublishedArtifact) publications.
761770
762-
Returns None immediately when DISTRIBUTED_PUBLICATION_RETENTION_PERIOD is 0.
771+
Returns ``(None, None)`` immediately when DISTRIBUTED_PUBLICATION_RETENTION_PERIOD is 0.
772+
The publication is the one that still contains the unit, which may be a superseded
773+
version — callers that need ``RepositoryContent.pulp_created`` must use that publication's
774+
repository version, not the distribution's current one.
763775
"""
764776
if not retain_distributed_pub_enabled():
765-
return None
777+
return None, None
766778
recent_dp = (
767779
DistributedPublication.get_non_expired()
768780
.filter(distribution=self)
@@ -778,7 +790,7 @@ def get_fallback_ca(self, path):
778790
.first()
779791
)
780792
if ca is not None:
781-
return ca
793+
return ca, pub
782794
else:
783795
pa = (
784796
pub.published_artifact.select_related(
@@ -789,8 +801,8 @@ def get_fallback_ca(self, path):
789801
.first()
790802
)
791803
if pa is not None:
792-
return pa.content_artifact
793-
return None
804+
return pa.content_artifact, pub
805+
return None, None
794806

795807
@hook(AFTER_CREATE)
796808
@hook(

pulpcore/cache/cache.py

Lines changed: 91 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
from functools import wraps
55

66
from aiohttp.web import FileResponse, HTTPSuccessful, Request, Response, StreamResponse
7-
from aiohttp.web_exceptions import HTTPFound
7+
from aiohttp.web_exceptions import HTTPFound, HTTPNotModified
88
from django.conf import settings
99
from django.http import FileResponse as ApiFileResponse
1010
from django.http import HttpResponse, HttpResponseRedirect
11+
from django.utils.http import parse_http_date_safe
1112
from redis import ConnectionError
1213
from redis.asyncio import ConnectionError as AConnectionError
1314
from rest_framework.request import Request as ApiRequest
@@ -18,7 +19,7 @@
1819
get_redis_connection,
1920
)
2021
from pulpcore.metrics import artifacts_size_counter
21-
from pulpcore.responses import ArtifactResponse
22+
from pulpcore.responses import ArtifactResponse, PulpFileResponse
2223

2324
DEFAULT_EXPIRES_TTL = settings.CACHE_SETTINGS["EXPIRES_TTL"]
2425

@@ -306,7 +307,7 @@ class AsyncContentCache(AsyncCache):
306307
"""Cache object meant to be used for the content app"""
307308

308309
RESPONSE_TYPES = {
309-
"FileResponse": FileResponse,
310+
"FileResponse": PulpFileResponse,
310311
"ArtifactResponse": ArtifactResponse,
311312
"Response": Response,
312313
"Redirect": HTTPFound,
@@ -349,32 +350,93 @@ async def cached_function(*args, **kwargs):
349350
if self.auth:
350351
await self.auth(request, self, bk)
351352
key = self.make_key(request)
353+
352354
# Check cache
353-
response = await self.make_response(key, bk)
354-
if response is None:
355-
# Cache miss, create new entry
356-
response = await self.make_entry(
357-
key, bk, func, args, kwargs, self.default_expires_ttl
355+
entry = await self.get_entry(key, bk)
356+
if entry is not None:
357+
# Cache hit. Authorization has already run. If the client's If-Modified-Since
358+
# covers the stored last_modified, answer a bodyless 304 without reconstructing
359+
# the full response. Fall back to the header for entries cached before this field.
360+
last_modified = entry.get("last_modified") or entry.get("headers", {}).get(
361+
"Last-Modified"
358362
)
359-
elif size := response.headers.get("X-PULP-ARTIFACT-SIZE"):
360-
artifacts_size_counter.add(size)
361-
363+
if self._not_modified(request, last_modified):
364+
headers = dict(entry.get("headers") or {})
365+
headers["X-PULP-CACHE"] = "HIT"
366+
raise self._make_not_modified(headers, last_modified)
367+
response = self.build_response(entry)
368+
if size := response.headers.get("X-PULP-ARTIFACT-SIZE"):
369+
artifacts_size_counter.add(size)
370+
return response
371+
372+
# Cache miss: build and cache the full response (a 304 is never stored). Still answer
373+
# a matching conditional request with a 304 from the fresh response's Last-Modified,
374+
# but never after a stream has already started writing.
375+
response = await self.make_entry(key, bk, func, args, kwargs, self.default_expires_ttl)
376+
if getattr(response, "prepared", False):
377+
return response
378+
last_modified = response.headers.get("Last-Modified")
379+
if self._not_modified(request, last_modified):
380+
raise self._make_not_modified(response.headers, last_modified)
362381
return response
363382

364383
return cached_function
365384

385+
@staticmethod
386+
def _not_modified(request, last_modified):
387+
"""True when the request's If-Modified-Since covers the given Last-Modified value.
388+
389+
Ignore If-Modified-Since when If-None-Match is present, or when it is later than
390+
the server clock.
391+
"""
392+
if not last_modified:
393+
return False
394+
if request.headers.get("If-None-Match"):
395+
return False
396+
if_modified_since = parse_http_date_safe(request.headers.get("If-Modified-Since", ""))
397+
if if_modified_since is None or if_modified_since > time.time():
398+
return False
399+
lm_epoch = parse_http_date_safe(last_modified)
400+
return lm_epoch is not None and lm_epoch <= if_modified_since
401+
402+
@staticmethod
403+
def _make_not_modified(source_headers, last_modified):
404+
"""Build a bodyless 304 echoing Last-Modified and any caching metadata already present."""
405+
headers = {"Last-Modified": last_modified}
406+
for name in ("Cache-Control", "X-PULP-CACHE"):
407+
if value := source_headers.get(name):
408+
headers[name] = value
409+
return HTTPNotModified(headers=headers)
410+
366411
def get_request_from_args(self, args):
367412
"""Finds the request object from list of args"""
368413
for arg in args:
369414
if isinstance(arg, Request):
370415
return arg
371416

372-
async def make_response(self, key, base_key):
373-
"""Tries to find the cached entry and turn it into a proper response"""
417+
async def get_entry(self, key, base_key):
418+
"""Return the cached entry dict for ``key`` (deleting stale/invalid rows), or None."""
374419
entry = await self.get(key, base_key)
375420
if not entry:
376421
return None
377422
entry = json.loads(entry)
423+
response_type = entry.get("type")
424+
# None means "doesn't expire", unset/absent means "already expired".
425+
expires = entry.get("expires", -1)
426+
if (not response_type or response_type not in self.RESPONSE_TYPES) or (
427+
expires and expires < time.time()
428+
):
429+
# Bad entry, delete from cache
430+
await self.delete(key, base_key)
431+
return None
432+
return entry
433+
434+
def build_response(self, entry):
435+
"""Turn a cached entry dict into a proper response object (marked as a cache HIT)."""
436+
entry = dict(entry) # do not mutate the caller's dict
437+
entry.pop("expires", None)
438+
entry.pop("last_modified", None)
439+
response_type = entry.pop("type")
378440

379441
if binary := entry.pop("body", None):
380442
# raw binary data were translated to their hexadecimal representation and saved in
@@ -383,23 +445,24 @@ async def make_response(self, key, base_key):
383445
# https://docs.aiohttp.org/en/stable/web_reference.html#response
384446
entry["body"] = bytes.fromhex(binary)
385447

386-
response_type = entry.pop("type", None)
387-
# None means "doesn't expire", unset means "already expired".
388-
expires = entry.pop("expires", -1)
389-
if (not response_type or response_type not in self.RESPONSE_TYPES) or (
390-
expires and expires < time.time()
391-
):
392-
# Bad entry, delete from cache
393-
await self.delete(key, base_key)
394-
return None
395448
response = self.RESPONSE_TYPES[response_type](**entry)
396449
response.headers.update({"X-PULP-CACHE": "HIT"})
397450
return response
398451

452+
async def make_response(self, key, base_key):
453+
"""Tries to find the cached entry and turn it into a proper response"""
454+
entry = await self.get_entry(key, base_key)
455+
if entry is None:
456+
return None
457+
return self.build_response(entry)
458+
399459
async def make_entry(self, key, base_key, handler, args, kwargs, expires=DEFAULT_EXPIRES_TTL):
400460
"""Gets the response for the request and try to turn it into a cacheable entry"""
401461
try:
402462
response = await handler(*args, **kwargs)
463+
except HTTPNotModified:
464+
# HTTPNotModified is HTTPSuccessful; do not swallow it into a cached entry.
465+
raise
403466
except (HTTPSuccessful, HTTPFound) as e:
404467
response = e
405468

@@ -408,7 +471,13 @@ async def make_entry(self, key, base_key, handler, args, kwargs, expires=DEFAULT
408471
if hasattr(response, "future_response"):
409472
response = response.future_response
410473

474+
if getattr(response, "status", None) == 304:
475+
return original_response
476+
411477
entry = {"headers": dict(response.headers), "status": response.status}
478+
if last_modified := response.headers.get("Last-Modified"):
479+
# Stored alongside headers so a cache hit can 304 without reconstructing the response.
480+
entry["last_modified"] = last_modified
412481
if expires is not None:
413482
# Redis TTL is not sufficient: https://github.com/pulp/pulpcore/issues/4845
414483
entry["expires"] = expires + time.time()

0 commit comments

Comments
 (0)