[discussion] Changing the behaviour of ResponseCache.wrap_conditional
to only honour the very first caller #9525
Description
I feel like this warrants some further discussion, as i think i don't exactly understand the rationale of why we're pretty much doing this, and i think that some gotchas werent received during discussion in #synapse-dev
, so i'm making this verbose issue on it to get on the same page.
Status
#9358 introduced ResponseCache.wrap_conditional
, #9522 builds on it while fixing #9507, and #9458 adds tests that validate its functionality (as given by #9358)
This feature (as of the making of this issue) has the following functionality;
- It acts like
wrap
, but has an extra positional argument which the caller can provide a "conditional
" with, thisconditional
would take the result of the call as it's first argument, and return abool
indicating if the result should be cached in a timed fashion or not. - Currently, if another caller would call
wrap_conditional
with the samekey
, their "conditional
" callable would be added to the set of conditionals, to be all evaluated when the call returns. - When the call returns, this set of conditionals need all to return
True
(and not throw an exception, which is what Harden ResponseCache (and fix #9507) #9522 fixes) for the result to be cached in a timely fashion. - Any subsequent call to
wrap_conditional
will have theirconditional
argument ignored, no matter if it evaluated toFalse
or not.
Discussion terminology
For the purpose of discussion, I'll define the following terms;
wrap*
iswrap
and/orwrap_conditional
, this is in cases where the desired functionality is indistinguishable between the two. (e.g. "doesn't matter if aconditional
is supplied or not")- the "callers", or "the caller", are any function that calls
wrap*
(with the samekey
, in the context of this discussion) - "the very first caller" is the first (timeline-wise) to call to
wrap*
orwrap_conditional
, and start the inner call. This excludes any future callers. - "the first callers" are the callers that have called
wrap*
but are waiting for the inner call to complete, awaiting on aDeferred
-like object. - "subsequent callers" are the callers excluded from the first callers, these callers
Arguments
My argument on going forward with this is to make any caller (first or subsequent) be able to evict/invalidate the cache when a conditional
they give evaluates as False
.
My rationale for this is that calls to wrap*
, as they stand now, should all have the same "effect". i.e. invalidating the cache if conditional
returns False
, this is the careful approach, and so any cache is invalidated whenever it is deemed unsafe or bad to be cached, "better to be safe than sorry", because invalid cache can result in anything from delays to corruption.
@richvdh's argument is to make the very first caller be able to have a say if the result should be cached, because that is semantically equivalent to how it was previously, where the call would decide if it's value is cachable or not.
I agree with this, and that was how #9358 initially approached it, with a relatively-hacky approach of having any call wrap its value in NoTimedCache
(see these 5 commits).
However, I wasn't satisfied with this, and looked at alternatives (like contextvars
, which I didn't feel are very stable or compatible with twisted
at the moment), and eventually settled on turning the approach on its head (to avoid rewriting semantics of many functions that ResponseCache
wrapped around across the codebase, the intention was to be non-invasive) by introducing wrap_conditional
.
Current situation
This approach changed some semantics, because now it was the caller to decide if the result would be cached, and there wasn't a good way to get a "voice" from the called function, so it became a "counsel" of first callers that all had to agree on something being cached (better safe than sorry).
Any function could be (or could become) a caller, and so it was best to be as agnostic as possible, and don't make assumptions who would possibly be callers across the codebase (especially not assuming it'd be a controlled subset, as that creates more implicit contracts that're not easily maintainable), and just value their "opinion" (in the form of conditional
) equally with vetoing.
With a set of {X Y Z}
possibly being the first callers, and only the very first call being able to set the conditional, it could introduce hard-to-resolve bugs where (hypothetically) Z
here being the one that introduces a faulty conditional
, but then X
and Y
also being candidates for becoming the very first caller as well, and this creating a situation where synchronisation of these calls had to be taken into account to fix those bugs.
This is where we are right now, and I'm curious how i could change this approach to be more correct while also semantically making sense.
I agree with richard on a call
being able to set its cachability, but i think because of how i had to change my approach, a different situation has arisen, and i think we're not on the same page on how to best change the situation/approach, or how to work with the new semantics.