This repository has been archived by the owner on Apr 26, 2024. It is now read-only.
-
-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Implement a CachedCall to handle boilerplate of caching results #9353
Closed
+111
−10
Closed
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
# -*- coding: utf-8 -*- | ||
# Copyright 2021 The Matrix.org Foundation C.I.C. | ||
# | ||
# Licensed under the Apache License, Version 2.0 (the "License"); | ||
# you may not use this file except in compliance with the License. | ||
# You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, software | ||
# distributed under the License is distributed on an "AS IS" BASIS, | ||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
# See the License for the specific language governing permissions and | ||
# limitations under the License. | ||
|
||
from typing import Awaitable, Callable, Generic, Optional, TypeVar, Union | ||
|
||
from twisted.internet.defer import Deferred | ||
from twisted.python.failure import Failure | ||
|
||
from synapse.logging.context import make_deferred_yieldable, run_in_background | ||
|
||
TV = TypeVar("TV") | ||
|
||
|
||
class CachedCall(Generic[TV]): | ||
"""A wrapper for asynchronous calls whose results should be shared | ||
|
||
This is useful for wrapping asynchronous functions, where there might be multiple | ||
callers, but we only want to call the underlying function once (and have the result | ||
returned to all callers). | ||
|
||
Similar results can be achieved via a lock of some form, but that typically requires | ||
more boilerplate (and ends up being less efficient). | ||
|
||
Correctly handles Synapse logcontexts (logs and resource usage for the underlying | ||
function are logged against the logcontext which is active when get() is first | ||
called). | ||
|
||
Example usage: | ||
|
||
_cached_val = CachedCall(_load_prop) | ||
|
||
async def handle_request() -> X: | ||
# We can call this multiple times, but it will result in a single call to | ||
# _load_prop(). | ||
return await _cached_val.get() | ||
|
||
async def _load_prop() -> X: | ||
await difficult_operation() | ||
|
||
""" | ||
|
||
__slots__ = ["_callable", "_deferred", "_result"] | ||
|
||
def __init__(self, f: Callable[[], Awaitable[TV]]): | ||
""" | ||
Args: | ||
f: The underlying function. Only one call to this function will be alive | ||
at once (per instance of CachedCall) | ||
""" | ||
self._callable = f # type: Optional[Callable[[], Awaitable[TV]]] | ||
self._deferred = None # type: Optional[Deferred] | ||
self._result = None # type: Union[None, Failure, TV] | ||
|
||
async def get(self) -> TV: | ||
"""Kick off the call if necessary, and return the result""" | ||
|
||
# Fire off the callable now if this is our first time | ||
if not self._deferred: | ||
self._deferred = run_in_background(self._callable) | ||
|
||
# we will never need the callable again, so make sure it can be GCed | ||
self._callable = None | ||
|
||
# once the deferred completes, store the result. We cannot simply leave the | ||
# result in the deferred, since if it's a Failure, GCing the deferred | ||
# would then log a critical error about unhandled Failures. | ||
def got_result(r): | ||
self._result = r | ||
|
||
self._deferred.addBoth(got_result) | ||
|
||
# TODO: consider cancellation semantics. Currently, if the call to get() | ||
# is cancelled, the underlying call will continue (and any future calls | ||
# will get the result/exception), which I think is *probably* ok, modulo | ||
# the fact the underlying call may be logged to a cancelled logcontext, | ||
# and any eventual exception may not be caught. | ||
|
||
# we can now await the deferred, and once it completes, return the result. | ||
await make_deferred_yieldable(self._deferred) | ||
|
||
# I *think* this is the easiest way to correctly raise a Failure without having | ||
# to gut-wrench into the implementation of Deferred. | ||
d = Deferred() | ||
d.callback(self._result) | ||
return await d | ||
Comment on lines
+93
to
+97
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure this will work properly for errors, do we need to call There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It does work, because |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
Maybe slighter nicer ergonomics if we used
__call__
instead?This would let you do something like:
This feels a bit nicer since you've wrapped a function and then it returns a function-like thing and might let you use it as a decorator?
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.
hrm, maybe. I always feel like relying on
__call__
is a bit magical, and tend to prefer that the interactions are made explicit, which is why I did it this way. I could be persuaded though.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.
My thought is that it makes it more like
functools.cached_property
, but perhaps that isn't the goal. 😄