http-cache-semantics
http-cache-semantics copied to clipboard
Implement interface for invalidation after POST/PUT
A cache MUST invalidate the effective Request URI (Section 5.5 of [RFC7230]) as well as the URI(s) in the Location and Content-Location response header fields (if present) when a non-error status code is received in response to an unsafe request method.
However, a cache MUST NOT invalidate a URI from a Location or Content-Location response header field if the host part of that URI differs from the host part in the effective request URI (Section 5.5 of [RFC7230]). This helps prevent denial-of-service attacks.
A cache MUST invalidate the effective request URI (Section 5.5 of [RFC7230]) when it receives a non-error response to a request with a method whose safety is unknown.
http://httpwg.org/specs/rfc7234.html#invalidation
As far as I am aware, the prototcol for this is simply:
Expose an invalidation() method which returns:
- If the Method is one of "GET", "HEAD", "OPTIONS", "TRACE", return null
- Return an object with path, host and calculated full url to invalidate.
Does it need to be any more complex than that?
That sounds about right.
I haven't thought through revalidation yet, so I wasn't sure if these would overlap.
Spitballing:
A. CachePolicy would expose a method validation() which returns:
- If the cached response can not be revalidated, return null [stop]
- Return an object with props indicating i. the pre-requisites for revalidation ii. the necessary host, path, headers and calculated full URL for the validation request
B. CachePolicy would expose a method freshen( validationResponse ) which returns a RefreshPolicy object exposing:
- validators - array of validators with which to select responses to freshen (if the new response contains strong/weak validators)
- updateable( cachedResponse ) which returns true if the specified response should be selected for update
- responseHeaders( cachedResponse ) which returns the updated, filtered set of response headers after update (following rules http://httpwg.org/specs/rfc7234.html#rfc.section.4.3.4 )
If the above is acceptable then we would expect:
- A.1. -> call invalidation()
- B.2. "updateable" returns false -> call invalidation()
Which, to my mind makes these two concerns (invalidation, revalidation) related, but not overlapping.
On higher level I'm using this API:
const response = await cache.get(request, modifiedRequest => {
return http.makeRequest(modifiedRequest);
});
where the callback gets request with added headers for revalidation. Returns promise with updated response. The response is then used to update the cached request.
In such API invalidation is an implementation detail. However, I'm not sure if it's not too inflexible, e.g. it doesn't allow stating preference for last-modified over etag.
ok but the freshening rules specify that you may need to update multiple cached responses.
If the new response contains a strong validator (see Section 2.1 of [RFC7232]), then that strong validator identifies the selected representation for update. All of the stored responses with the same strong validator are selected. If none of the stored responses contain the same strong validator, then the cache MUST NOT use the new response to update any stored responses.
Good point. Am I correct thinking that even strong validators are per URL (not per host)?
Since there's satisfiesRequestWithoutRevalidation, let's try one with revalidation:
const x = cache.satisfiesRequest(request, options);
I'm not sure what it should return. It needs to contain revalidation request, but returning null on success is icky, so maybe {satisfies: false, request: {…}}.
or
const result = cache.evaluate(request, options);
if (result.satisfies) {
return result.responseHeaders();
}
const response = await fetch(result.revalidationRequest());
result.update(response);
if (result.satisfies) {
return result.responseHeaders();
} else {
// dunno?
}
edit: nah, it's terrible.
It would seem that strong validators can be per URL - i can't find a limitation on this in the spec. Furthermore, the main use for freshening multiple responses seems to be related to multiple responses for the same resource but with different header values (other than those specified by the Vary restrictions).
Essentially, there are four possible outcomes when evaluating a request against a cached response:
- The cached response is fresh
- The cached response is stale but the request allows it using e.g. "Cache-Control: max-stale=1000"
- The cached response is stale and must be validated
- The cached response is stale and cannot be validated
(1) and (2) are covered by satisfiesRequestWithoutRevalidation
I think your second design has some merit - perhaps if we start with a method validationRequest so we can at least do:
if (cachePolicy.satisfiesRequestWithoutRevalidation(request, options)) {
// proceed with cached response
} else {
const validationRequest = cachePolicy.validationRequest(); // handles (3)
if(validationRequest) {
const validationResponse = await fetch(validationRequest);
// mechanism to freshen tbd
} else {
// mechanism to invalidate tbd
}
}
Yup, that makes sense.
I'd like to hide from users as much logic as possible, so validationRequest could return the original request, even if it doesn't revalidate.
if (cachePolicy.satisfiesRequestWithoutRevalidation(request, options)) {
return [cachePolicy.responseHeaders(), cachedBody];
} else {
const validationRequest = cachePolicy.validationRequest();
const validationResponse = await fetch(validationRequest);
// equivalent of cachePolicy = new CachePolicy(validationRequest, validationResponse); ?
cachePolicy.update(validationResponse);
// So here the cache only needs to know whether to keep the old body or use the new one
return [cachePolicy.responseHeaders(), cachedOrNewBody];
}
const validationRequest = cachePolicy.needsRequest(request);
if (validationRequest) {
const validationResponse = await fetch(validationRequest);
if (cachePolicy.update(validationResponse)) {
cachedBody = validationResponse.body;
}
}
return [cachePolicy.responseHeaders(), cachedBody];
Oh, validationRequest() needs to know all cached representations to build If-None-Match, and then on 304 response will have to help choose the right one.
An efficient public cache would need to know about all such representations, yes. But, I'm not sure that means this library needs to work on the basis of more than one. Perhaps that could be a later refinement?
I think it would be enough to make the validation request on behalf of 1 response, and then help in selecting responses which can be freshened.
e.g. starting simple: https://github.com/goofballLogic/http-cache-semantics/commit/c18bbba4577c2193c337e9e028ebfb3266561368
Rules for forming the validation request appear to be very simple (complexity comes with sub-range requests which I note you don't support): https://github.com/goofballLogic/http-cache-semantics/commit/26339d9e7fa69c4578fcbcb6f0b9c43515bf62a5
allowing revalidation via HEAD: https://github.com/goofballLogic/http-cache-semantics/commit/fbfb403c7f8c6e6ba44efb411cae1ae8c812a63b
Ok, here's a compromise proposal for processing response from the revalidation request:
- freshen() method on CachePolicy, a factory method returning: i. new (freshened or replaced) CachePolicy and Response ii. criteria for selecting other responses to update iii. callback to freshen other CachePolicy and responses.
const validationResponse = await fetch(validationRequest);
const {
validCachePolicy, // freshened (or replaced) cache policy
validBody, // body to use after validation
updateCriteria, // object hash with props to match
freshen, // (selectedCachePolicy, selectedResponseBody) => { validCachePolicy, validBody }
} = cachePolicy.freshen(validationResponse);
Your implementation looks good.
Keeping CachePolicy immutable and returning a new one sounds good.
Currently the policy doesn't store bodies, and I think it shouldn't (I JSON-stringify it in a DB column, but store bodies as files on disk). So it can't return old validBody. But maybe it doesn't have to? For users the logic may be simple - if the validation response has a body, use it.
How would criteria look like?
- you are right - will rethink the body part a little
- criteria - i think a simple has of props to match would suffice (b/c usually it'll just be an etag along with the url and host ). perhaps:
{ url: '/my-resource', headers: { host: 'www.test.com', etag: '"123456789"' } }
Criteria looks like it's exposing details and shifting hard work to the caller. And what about weak etags? What if the header had whitespace? or host had a default port?
So perhaps that should be a function that takes a request (or cachePolicy?) and does the matching (and maybe even updating).
Agreed. the criteria concept was to allow efficient querying of databases, but, as you say, it makes correct usage more complex for the user. I'll have a stab at this today.
Interesting point about databases. I was initially planning to expose some kind of "primary key" built from Vary headers in order to know which ones to keep and which ones are redundant.
This concept could be extended to all requests, so on high level it could be "these are the tags/keys for the request" when you cache it, and then "delete these tags/keys" when you invalidate.
For most cases, it would probably be sufficient to query your database by url and method, then run each of the matching CachePolicies through our matching function to determine whether or not it's selected for refresh. The only case where i can see this being problematic is for URLs like "/" "/favicon.ico" etc. which might have a very large number of matches.
Your concept of a primary key is starting to sound more appealing. Would it include method, URL and host as well as the Varying headers?
Actually, new thought: in order to do the "weak-validator" work, our function probably needs to evaluate potential CachePolicies as a set (not one-by-one). This is in order to select "the most recent of those matching stored responses".
So, we can't really apply freshening logic to a single CachePolicy at all (unless by implication that there are no others).
Would the following work instead:
const validationResponse = await fetch(validationRequest);
/*
selectablePolicies: array of retrieved CachePolicy which may be freshened
(excluding the current cachePolicy?)
*/
const cacheSelectors = cachePolicy.selectors(validationResponse);
const selectorPolicies = await Promise.all( cacheSelectors.map( x => dbFetch.bySelector( x ) ) );
const selectablePolicies = [].concat.apply([],selectorPolicies);
/*
freshened: [ { validCachePolicy, newResponseBody } ] (newResponseBody may be undefined)
*/
const freshened = cachePolicy.freshen(validationResponse, selectablePolicies);
this api could also be used by people who are comfortable that there will be no matching of multiple CachePolicy by just skipping the selectors step:
const freshened = cachePolicy.freshen(validationResponse);
Preliminary work: Identify strong and/or weak validators for a response: https://github.com/goofballLogic/http-cache-semantics/commit/57e8e7aca025c4d16e0068d28a994a7fa7eab61d
I hope it's ok - i'm going to go ahead an implement an algorithm for the cache key based on http://httpwg.org/specs/rfc7234.html#rfc.section.4.1 and http://httpwg.org/specs/rfc7234.html#freshening.responses (the preliminary work mentioned above).
I need a working implementation rather urgently for another project...
Ok, here's a sample cachekey:
[ 1, 'http://www.w3c.org:80/Protocols/rfc2616/rfc2616-sec14.html', [ [ 'moon-phase', 'sun', 'weather' ], [ '', 'shining', 'nice,bright' ] ], { etag: '"123456789"' }, { 'last-modified': 'Tue, 15 Nov 1994 12:45:56 GMT' } ]
Code and tests show more here:
https://github.com/goofballLogic/http-cache-semantics/commit/3a5e6c8b02e593d98506e90e9611ec957b70165a
If this cachekey were to be persisted in a relational database, you might consider storing it as 5 distinct columns:
| v | url | vary | strong | weak | body |
|---|---|---|---|---|---|
| 1 | http://www.w3c.org:80/Protocols/rfc2616/rfc2616-sec14.html | [ [ 'moon-phase', 'sun', 'weather' ], [ '', 'shining', 'nice,bright' ] ] | { etag: '"123456789"' } | { 'last-modified': 'Tue, 15 Nov 1994 12:45:56 GMT' } | "<!DOCTYPE html>\n<html>. . ." |
This would allow us to query the database using different combinations of validators along with primary (url) and secondary (vary) keys
Generating selectors based on a validationResponse:
https://github.com/goofballLogic/http-cache-semantics/commit/7927178edd6795a605e70055087eee37502f527b
How is user supposed to know what to do with "strong" and "weak" columns?
It's great that you're adding tests for everything.
The approach seems fine in general. I'm picky about implementation details, so I'll probably want to tweak a few things (code for getting weak and strong validators is "clever", and I'm suspicious of static methods, so I'll see if I can avoid them).
Currently I have limited time, so this will probably wait. I need to eventually improve my cache, so I'll merge the enhancements once I try them out.
Ok. If you have detailed guidance about required changes, I'm happy to implement them - I'm completely ok with following your aesthetic and algorithmic preferences.
Alternatively, I can just submit a PR for what I've done and leave it for you to complete.
Regarding "strong" or "weak" columns: the selector() call returns a selector which is always a cache key containing either a strong or a weak validator (per the spec). The user can then use this to compare against cache keys either in memory, in a relational store (such as shown) or in a nosql store. The selector specifies everything the user needs to query for cached policies which may be freshened or invalidated.
I'll put together a suggested markdown page showing usage, as this may help explain the overall design.