Open kornelski opened 8 years ago
As far as I am aware, the prototcol for this is simply:
Expose an invalidation() method which returns:
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:
B. CachePolicy would expose a method freshen( validationResponse ) which returns a RefreshPolicy object exposing:
If the above is acceptable then we would expect:
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:
(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.
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:
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?
{ 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:
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:
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.
Here's a start: https://github.com/goofballLogic/http-cache-semantics/blob/master/revalidation-invalidation.md:
TBD:
either a strong or a weak validator (per the spec).
BTW, I'm aiming for an API that doesn't require users to know the spec at all. I'm hoping the library can be used instead of reading the RFCs.
I think the selector and key methods should not live on cachePolicy. The functionality probably should go to another class.
someServer.on(async req => {
const matcher = CacheMatcher(req);
const pkey = matcher.primaryKey(); // effective URL
const cachePolicies = await db.get(pkey); // [CachePolicy, CachePolicy, …]
const {satisfiedPolicy, revalidationRequest} = matcher.match(cachePolicies);
if (satisfiedPolicy) {
return fromCache();
} else {
const res = await fetch(revalidationRequest);
…// still thinking
}
});
or another way
someServer.on(async req => {
const matcher = CacheMatcher(req);
const pkey = matcher.primaryKey(); // effective URL
for row of await db.get_all("select * from cache where pkey = ?",[pkey]) {
if (matcher.satisfiesWithoutRevalidation(row.cachePolicy)) {
return row.cachedResponse;
}
}
// now matcher has seen all responses, so it can build revalidation request with all etags
const revalidationRequest = matcher.revalidationRequest();
const res = await fetch(revalidationRequest);
// I'm not sure about this. It'd probably need to instruct what to add/update and what to delete?
const {cachePolicy, secondaryKeys} = matcher.update(res);
// secondary key would be built from sorted vary headers
for skey of secondaryKeys {
db.exec("update cache set cachePolicy = ? where pkey = ? and skey = ?", [cachePolicy, pkey, skey]);
}
});
ok, i'll let you think it through in your own time. you have the makings of a good library here.
I've published an update with a less ambitious API :)
http://httpwg.org/specs/rfc7234.html#invalidation