-
Notifications
You must be signed in to change notification settings - Fork 30
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
Better support stale-while-revalidate option #43
base: main
Are you sure you want to change the base?
Conversation
Thanks for the PR. This functionality is quite desirable. However, my concern is how to communicate to library users that they still need to do the revalidation. If someone simply wrote code like recommended so far: if (cached.satisfiesWithoutRevalidation(req)) {
return recycle(cached);
// gone! no request made
} then they won't know that they still need to perform revalidation in the background. I wouldn't want them to have to check headers themselves separately. Any ideas how the interface could be changed to be more robust in this case? I'm thinking that perhaps |
I agree that this is not the best user interface. I tried to limit the impact of my changes so that it could be usable (for now it isn't really usable). I think having a boolean as a return value prevent complexity and is simple to use and understand however, there is some cases (swr, max-stale) where this boolean doesn't give enough info on what to do. I'm not sure on what interface would work better, maybe instead of returning a boolean, it should return an enum or maybe something more flexible like an object like: const { satisfied, revalidate } = cached.satisfiesWithoutRevalidation(req); Where Most cases would be That could be a new method so that would break anything |
Perhaps a small change could be to keep I also maintain a Rust version of the same package, and I'll have to add it there too. Thanks to Rust's support for enums the API ended up looking completely different: match policy.before_request(req) {
Fresh(response) => return response,
Stale(request) => return policy.after_response(send(request)),
// SWF(request, response) => { update_cache(request)); return response }
} So maybe in JS this could be generalized to something like this: let { request, response } = policy.evaluate(request, response);
if (request) { update_cache(request) }
if (response) { return response } else { wait for the cache here…? } |
I think it would be better to keep a single API for all the cases. To not create a breaking change, it can be a new recommended API that would handle the swr case and maybe other cases like in your
I'm a bit confused on the
That system would allow users to only implement simple case (no swr) const { revalidate, asyncRevalidate, cacheHit } = policy.evaluate(req, res);
if (revalidate) {
const newRes = await update_cache(req);
return newRes;
}
if (cacheHit) {
res.headers = policy.responseHeaders(res);
return res;
} but with easy support of swr const { revalidate, asyncRevalidate, cacheHit } = policy.evaluate(req, res);
if (asyncRevalidate) {
update_cache(req).catch(err => logError(err));
} else if (revalidate) {
const newRes = await update_cache(req);
return newRes;
}
if (cacheHit) {
res.headers = policy.responseHeaders(res); // Could directly come from evaluate
return res;
} |
I've jumped a few steps ahead in my explanation.
Update of headers is not optional. You're simply not allowed to skip that step, even if it took whole day to compute (but it doesn't). Therefore, I want to change the API to always give you the updated headers. Similarly with response revalidation. If the cached version is unusable, you'll have to make a request. If it's s-w-r, you have to make the request anyway. So I want to provide up-to-date request headers for fetch/revalidate whenever it's needed. So if you have something to return (it's fresh or s-w-r), you'll get response headers to use. If you have to make a request to the origin (because it's a miss or s-w-r), you'll get a set of headers for making that request. The observation here is that it doesn't really matter if it's a request due to cache miss or a revalidation request, because you're going to send it to the origin anyway, and it'll go back to the cache to update it. So you don't really need to know the details of what happened. You only have two possible actions: return a response or make an upstream request. S-w-r is a case where you do both, but doesn't change what inputs you need — it's still a response to return and a request to make. That's why I suggested How about this:
|
Thanks for the explanation, much clearer now! I tried again but with some inspiration from your rust example. I think I found something that would solve this: The idea would be to have the type EvaluateRes<T> = {
cacheResponse: Response;
asyncRevalidationPromise?: Promise<T>;
};
async evaluate<T>(
req: Request,
res: Response,
doRequest: (req: Request) => Promise<T>,
): Promise<EvaluateRes<T>> {
// If cache is fresh
// return updated response
// If cache is stale and stale-while-revalidate is valid
// call doRequest(req).catch(logSomewhere)
// return updated response
// Else
// call await doRequest(req);
// send updated response
return {} as EvaluateRes<T>;
} And then, use it like this: async function doRequest<T>(request: Request): Promise<T> {
const response = await callHTTPEndpoint(request);
const policy = new CachePolicy(request, response, {});
if (policy.storable()) {
await cache.save(request, { policy, response }, policy.timeToLive());
}
return response;
}
export async function test() {
// And later, when you receive a new request:
const newReq: Request = { headers: {} };
const cacheRes = await cache.get(newReq);
if (!cacheRes) {
return await doRequest(newReq);
}
const { policy, response } = cacheRes;
const { cacheResponse, asyncRevalidationPromise } = await policy.evaluate(
newReq,
response,
doRequest,
);
// (optional) Let the user have some control on stale while revalidate
if (asyncRevalidationPromise) {
asyncRevalidationPromise.catch(() =>
console.error('SWR request was not successful'),
);
}
return cacheResponse;
} This system remove the need for the user to know when to do the request and if they should await or not the response. It always gives back an updated response. |
@kornelski friendly ping on this discussion 🙂 |
I'm not a fan of inversion of control approach. While it does make it a bit more foolproof, it also makes the API more opaque, and I'm worried it could be too restrictive for some advanced uses. I think there can be two ways forward:
|
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.
Is there any update on this issue? I have tried to read the thread, and I was wondering if it would be a valid solution to add an option to If this would seem acceptable I wouldn't mind to try and provide a PR. |
I'm not planning to merge this PR as-is, because the issues were not addressed: #43 (comment) If you could apply those changes and make another PR I'd be happy to move this forward. |
Thanks for your fast reply. I could have a look if I can provide a PR that would implement option 1. It shouldn't be that very hard (famous last words). The only thing I was wondering is whether you had considered the "opt in" suggestion? But I'll have a look if I can provide this alternative PR if I find the time. |
Can you tell me more what do you expect the opt-in to do? If you want The answer to what to do with |
I quickly created a PR #49 that maybe illustrates better what I mean. Though I can imagine that there might be some subtle details that I am missing or misinterpreting. The way I see it, as an implementor that is interested in adding support for I do understand that the naming |
In my head the usage of PR #49 would look like this.
But again, I know I might be missing something here. So feel free to point it out to me. P.S. When looking at it know, then I guess I'm missing some cases where stale is true, but there was no `stale-while-revalidate' header. In which case you should not trigger the background revalidation job.... I'll have a fresh look at it tomorrow. |
As you see, So the instructions for the user of the library need to be clear, so that they don't have to understand HTTP semantics themselves. Instead of bending the meaning of
In #43 (comment) I've suggested beforeRequest and afterResponse, but this may be unidiomatic in JS. Optional object fields like in #43 (comment) could be better? The complete usage example could look like this: // When serving requests from cache:
const { oldPolicy, oldResponse } = letsPretendThisIsSomeCache.get(
newRequest.url
);
const { withoutRevalidation, revalidationRequest } = oldPolicy.evaluateRequest(newRequest);
if (withoutRevalidation) {
// satisfies without revalidation
oldResponse.headers = withoutRevalidation.responseHeaders;
return oldResponse;
}
const doRequestAsync = async () => {
// Change the request to ask the origin server if the cached response can be used
newRequest.headers = revalidationRequest.requestHeaders;
// Send request to the origin server. The server may respond with status 304
const newResponse = await makeRequest(newRequest);
// Create updated policy and combined response from the old and new data
const { policy, modified, responseHeaders } = oldPolicy.revalidatedPolicy(
newRequest,
newResponse
);
const response = modified ? newResponse : oldResponse;
// Update the cache with the newer/fresher response
letsPretendThisIsSomeCache.set(
newRequest.url,
{ policy, response },
policy.timeToLive()
);
// And proceed returning cached response as usual
response.headers = responseHeaders;
return response;
};
if (revalidationRequest.staleWhileRevalidate) {
doRequestAsync(); // no await
oldResponse.headers = revalidationRequest.staleWhileRevalidate.responseHeaders;
return oldResponse;
} else {
return doRequestAsync();
} I've changed the API to return headers you need immediately instead of asking user to call Also making |
Thanks for the reply. I needed to make sure I understood the needs for the PR correctly. That they are mostly related to API design, and that I was not making any mistakes about the actual HTTP semantics. I will try to make some time to see if I can forge something based on all the input. Of course I would avoid duplication logic for the new API, but I still have one question about this. I assume you want the current |
Hi again, I have compiled the following cases in my head:
An advanced cache implementation
This is the case where a
This is effectively the SWR case, and differs only in
This case might not be very obvious, and it is not very well documented in the RFC or any of the less formal documentation sites. And I actually think it is a minor bug (or incompleteness) in the current implementation of When providing a Assuming my interpretation is correct, the current implementation does not allow me this choice. So maybe not really a bug, but rather an incompleteness. However, I do think there might be an actual bug there. According to the specification, the response can only be used if it is still
Obviously a revalidation should be used, or a 504 Gateway Timeout should be returned by the cache if not successful. I feel I know enough to get started on something, but initially it might look slightly different from your API suggestion in order to cater for the cases mentioned above. I actually would like to abstract away any notion of Feel free to take your time to reply, and to poke holes in my understanding of the HTTP semantics. Kind regards. |
For the purpose of this feature you can treat |
I think the approach I suggested above with |
I do understand the meaning of And then there is the case where you should do synchronous revalidation, but you are still allowed to use the cached response (if you have a valid reason): I.e When the requestCC max-age cannot be satisfied, and revalidation fails, but the cached response is still fresh, then it can still be served. At least according to my reading of the specs, because it is a bit vague in this area. But to be honest I think we are discussing details here. And what I have in my head right now is almost the same as what you suggest. So I'll start by trying it out and provide a usage example that covers the cases as I see them. Maybe I'll prove myself wrong in the process, and otherwise we can continue the discussion starting from some a PR. I might open a separate PR if I feel I might have found a bug in the current implementation (I still have to double check the one that I mentioned above). Regards. |
I updated #49
P.S.: There is no bug (at least not the one I mentioned I was going to double check). |
Currently, there was an issue with the stale-while-revalidate options. The
satisfiesWithoutRevalidation
method didn't allow stale response to be returned when swr was set.I changed the logic so that the stale response is returned in that case.
I also added tests on this and on some edge case with the
max-stale
request option (I'm not 100% sure it the right way but didn't find any specs on this).Related issues: #41, #27