Transactional cache API, missing some options#471
Conversation
c873e1b to
7fe318a
Compare
a0ce2c7 to
a0186ee
Compare
As I look into it more, I don't think generation IDs or the inner lock are needed for the transactional API. The outer lock should suffice.
`watch::Sender` sets us up to have an asynchronous transactional API. The non-transactional `lookup` and `insert` can use the `Sender` side, acting just like a read/write mutex. But now, `insert` will automatically ping any waiters when the modification is done...and those waiters can wait `async`hronously.
The request headers are needed for insert...but for a transactional insert, they carry along from the original request's headers. Make `request_headers` an explicit additional argument for `insert` rather than part of `WriteOptions`, so we can use `WriteOptions` for other sorts of inserts.
An entry can be: - Present but not obligated (e.g. fresh) - Obligated but not present (request-collapsing lookup) - Neither obligatated nor present (request-collapsing lookup that failed to complete) - Obligated and present (stale-while-revalidate, request-collapsing revalidation) Make these two separate fields of CacheValue. Also, CacheValue doesn't need to be Arc<>'d; remove a layer of indirection.
Note that this implements a recently-changed behavior in the compute platform. Previously, a `PendingTransaction::drop` would *block* on whichever request got the `GoGet` obligation; more recently (and in Viceroy), the `PendingTransaction::drop` does not block on any other transaction.
a0186ee to
481ce8c
Compare
Before returning from `transaction_lookup_async`, we attempt to retrieve data + an obligation from the `CacheKeyObject`, but without blocking for any outstanding obligations. Only if this fails do we generate a background task to wait for other obligations to complete. The net result is that `BusyHandle::pending` will in some cases immediately be `true` upon return of the `BusyHandle`, e.g. when the cache needs filling.
aturon
left a comment
There was a problem hiding this comment.
Here's another batch of comments / questions.
lib/src/cache/store.rs
Outdated
|
|
||
| // Done dealing with other obligations; now we just deal with results that we have. | ||
| // Pick a fresh result if we now have it: | ||
| // fresh or stale. |
There was a problem hiding this comment.
I think this comment line is a leftover?
There was a problem hiding this comment.
Not leftover so much as unclear, given that we don't actually deal with stale entries at this point.
This also made me note a weirdness in the logic, that we short-circuit on obligations without returning data. I 2bf2139 I reordered these blocks to:
- Scan for fresh data
- Scan for stale data that can be revalidated (for now, "just" existing obligations)
- Generate a new obligation
which I think will lend itself better to SWR.
lib/src/cache/store.rs
Outdated
| response_object.transactional = TransactionState::Present(object); | ||
| response_object.generation += 1; | ||
| if !cache_key_objects.vary_rules.contains(meta.vary_rule()) { | ||
| // Insert at the front, run through the rules in order, so we tend towards fresher |
There was a problem hiding this comment.
Should we also move the rule to the front if it already exists?
There was a problem hiding this comment.
Yes, definitely, oversight on my part. Done in fc09643.
I also realize this could be a Vec instead of a VecDeque, since we're only ever working at one end. But I find it easier to read as push_front and iter rather than push and iter().rev() - I'd definitely mess up "use the reverse iterator" at some point.
| return false; | ||
| } | ||
|
|
||
| // Finally, generate an obligation based on the most recent vary rule |
There was a problem hiding this comment.
Have we confirmed this is the behavior in production? I'm thinking of a scenario like:
- Two requests come in for the same URL, but with different headers
- Cache contains:
- Vary rules:
[["header1"], ["header2"]] - Stale responses keyed as:
"header1: foo"and"header2: bar"
- Vary rules:
- First request has
header1: fooandheader2: bar; we mark theheader1: fooobject as obligated - Second request has no
header1, but hasheader2: bar- Thus, we generate a second obligation, even though the one for the second request may well apply.
We could reasonably expect these two requests to be collapses, instead.
There was a problem hiding this comment.
Good thought! I'll add a test for this case, double-check some of the cache code, and circle back.
There was a problem hiding this comment.
Phrasing this slightly differently as I work through it:
If our heuristic for "which variant will apply to the next request" is "what is the last Vary rule received", we would not expect for these requests to be collapsed - because they have distinct header1 values. We'd be erring on the side of "less latency, more requests" by not collapsing them (i.e. not blocking the second request on the fulfillment of the first).
(Per your other comment, my implementation isn't actually using the "most recent received" heuristic, but that's somewhat separate.)
There was a problem hiding this comment.
Wrote a test in 04d9b0e that matches your example (header1 and header2 are swapped, but I'm pretty sure it's the same behaviorally.) I used the cceckman/outside-test branch to run it on compute platform.
This implementation matches the compute platform behavior: the requests are not collapsed.
Presumably the cache code authors of ECP had the "most recent Vary is right" heuristic in mind as well? I can see it getting pretty bad if there's an old Vary: Accept or something that trumps a Vary: Authorization or something, and causes ~all requests to serialize.
There was a problem hiding this comment.
Thanks for checking this out -- that makes good sense to me! 🙏
aturon
left a comment
There was a problem hiding this comment.
Fantastic work! Very happy to see this land ✨
Start implementing the transactional side of the core cache API.
Transactions begin with a
transaction_lookuportransaction_lookup_async. When theCacheBusyHandleisawaited, it either returns a result immediately; returns an obligation indicating "you should fulfill this"; or blocks, until the obligatee abandons or fulfills their obligation.NB, the implementation of
cancelhere matches a recent change to Fastly's Compute Platform. Previously,close(BusyHandle)would block until the obligation was fulfilled or granted to the closer; now,closedoesn't block. This shouldn't really matter to anyone other than making e.g. timeouts to origin fail in better ways.