Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.

Commit d7808a2

Browse files
authored
Extend ResponseCache to pass a context object into the callback (#10157)
This is the first of two PRs which seek to address #8518. This first PR lays the groundwork by extending ResponseCache; a second PR (#10158) will update the SyncHandler to actually use it, and fix the bug. The idea here is that we allow the callback given to ResponseCache.wrap to decide whether its result should be cached or not. We do that by (optionally) passing a ResponseCacheContext into it, which it can modify.
1 parent 13577aa commit d7808a2

File tree

5 files changed

+146
-37
lines changed

5 files changed

+146
-37
lines changed

changelog.d/10157.misc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Extend `ResponseCache` to pass a context object into the callback.

synapse/replication/http/_base.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ def register(self, http_server):
285285
self.__class__.__name__,
286286
)
287287

288-
def _check_auth_and_handle(self, request, **kwargs):
288+
async def _check_auth_and_handle(self, request, **kwargs):
289289
"""Called on new incoming requests when caching is enabled. Checks
290290
if there is a cached response for the request and returns that,
291291
otherwise calls `_handle_request` and caches its response.
@@ -300,8 +300,8 @@ def _check_auth_and_handle(self, request, **kwargs):
300300
if self.CACHE:
301301
txn_id = kwargs.pop("txn_id")
302302

303-
return self.response_cache.wrap(
303+
return await self.response_cache.wrap(
304304
txn_id, self._handle_request, request, **kwargs
305305
)
306306

307-
return self._handle_request(request, **kwargs)
307+
return await self._handle_request(request, **kwargs)

synapse/replication/http/membership.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ async def _serialize_payload( # type: ignore
345345

346346
return {}
347347

348-
def _handle_request( # type: ignore
348+
async def _handle_request( # type: ignore
349349
self, request: Request, room_id: str, user_id: str, change: str
350350
) -> Tuple[int, JsonDict]:
351351
logger.info("user membership change: %s in %s", user_id, room_id)

synapse/util/caches/response_cache.py

Lines changed: 73 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
import logging
15-
from typing import Any, Callable, Dict, Generic, Optional, TypeVar
15+
from typing import Any, Awaitable, Callable, Dict, Generic, Optional, TypeVar
16+
17+
import attr
1618

1719
from twisted.internet import defer
1820

@@ -23,10 +25,36 @@
2325

2426
logger = logging.getLogger(__name__)
2527

26-
T = TypeVar("T")
28+
# the type of the key in the cache
29+
KV = TypeVar("KV")
30+
31+
# the type of the result from the operation
32+
RV = TypeVar("RV")
33+
2734

35+
@attr.s(auto_attribs=True)
36+
class ResponseCacheContext(Generic[KV]):
37+
"""Information about a missed ResponseCache hit
2838
29-
class ResponseCache(Generic[T]):
39+
This object can be passed into the callback for additional feedback
40+
"""
41+
42+
cache_key: KV
43+
"""The cache key that caused the cache miss
44+
45+
This should be considered read-only.
46+
47+
TODO: in attrs 20.1, make it frozen with an on_setattr.
48+
"""
49+
50+
should_cache: bool = True
51+
"""Whether the result should be cached once the request completes.
52+
53+
This can be modified by the callback if it decides its result should not be cached.
54+
"""
55+
56+
57+
class ResponseCache(Generic[KV]):
3058
"""
3159
This caches a deferred response. Until the deferred completes it will be
3260
returned from the cache. This means that if the client retries the request
@@ -35,8 +63,10 @@ class ResponseCache(Generic[T]):
3563
"""
3664

3765
def __init__(self, clock: Clock, name: str, timeout_ms: float = 0):
38-
# Requests that haven't finished yet.
39-
self.pending_result_cache = {} # type: Dict[T, ObservableDeferred]
66+
# This is poorly-named: it includes both complete and incomplete results.
67+
# We keep complete results rather than switching to absolute values because
68+
# that makes it easier to cache Failure results.
69+
self.pending_result_cache = {} # type: Dict[KV, ObservableDeferred]
4070

4171
self.clock = clock
4272
self.timeout_sec = timeout_ms / 1000.0
@@ -50,16 +80,13 @@ def size(self) -> int:
5080
def __len__(self) -> int:
5181
return self.size()
5282

53-
def get(self, key: T) -> Optional[defer.Deferred]:
83+
def get(self, key: KV) -> Optional[defer.Deferred]:
5484
"""Look up the given key.
5585
56-
Can return either a new Deferred (which also doesn't follow the synapse
57-
logcontext rules), or, if the request has completed, the actual
58-
result. You will probably want to make_deferred_yieldable the result.
86+
Returns a new Deferred (which also doesn't follow the synapse
87+
logcontext rules). You will probably want to make_deferred_yieldable the result.
5988
60-
If there is no entry for the key, returns None. It is worth noting that
61-
this means there is no way to distinguish a completed result of None
62-
from an absent cache entry.
89+
If there is no entry for the key, returns None.
6390
6491
Args:
6592
key: key to get/set in the cache
@@ -76,42 +103,56 @@ def get(self, key: T) -> Optional[defer.Deferred]:
76103
self._metrics.inc_misses()
77104
return None
78105

79-
def set(self, key: T, deferred: defer.Deferred) -> defer.Deferred:
106+
def _set(
107+
self, context: ResponseCacheContext[KV], deferred: defer.Deferred
108+
) -> defer.Deferred:
80109
"""Set the entry for the given key to the given deferred.
81110
82111
*deferred* should run its callbacks in the sentinel logcontext (ie,
83112
you should wrap normal synapse deferreds with
84113
synapse.logging.context.run_in_background).
85114
86-
Can return either a new Deferred (which also doesn't follow the synapse
87-
logcontext rules), or, if *deferred* was already complete, the actual
88-
result. You will probably want to make_deferred_yieldable the result.
115+
Returns a new Deferred (which also doesn't follow the synapse logcontext rules).
116+
You will probably want to make_deferred_yieldable the result.
89117
90118
Args:
91-
key: key to get/set in the cache
119+
context: Information about the cache miss
92120
deferred: The deferred which resolves to the result.
93121
94122
Returns:
95123
A new deferred which resolves to the actual result.
96124
"""
97125
result = ObservableDeferred(deferred, consumeErrors=True)
126+
key = context.cache_key
98127
self.pending_result_cache[key] = result
99128

100-
def remove(r):
101-
if self.timeout_sec:
129+
def on_complete(r):
130+
# if this cache has a non-zero timeout, and the callback has not cleared
131+
# the should_cache bit, we leave it in the cache for now and schedule
132+
# its removal later.
133+
if self.timeout_sec and context.should_cache:
102134
self.clock.call_later(
103135
self.timeout_sec, self.pending_result_cache.pop, key, None
104136
)
105137
else:
138+
# otherwise, remove the result immediately.
106139
self.pending_result_cache.pop(key, None)
107140
return r
108141

109-
result.addBoth(remove)
142+
# make sure we do this *after* adding the entry to pending_result_cache,
143+
# in case the result is already complete (in which case flipping the order would
144+
# leave us with a stuck entry in the cache).
145+
result.addBoth(on_complete)
110146
return result.observe()
111147

112-
def wrap(
113-
self, key: T, callback: Callable[..., Any], *args: Any, **kwargs: Any
114-
) -> defer.Deferred:
148+
async def wrap(
149+
self,
150+
key: KV,
151+
callback: Callable[..., Awaitable[RV]],
152+
*args: Any,
153+
cache_context: bool = False,
154+
**kwargs: Any,
155+
) -> RV:
115156
"""Wrap together a *get* and *set* call, taking care of logcontexts
116157
117158
First looks up the key in the cache, and if it is present makes it
@@ -140,22 +181,28 @@ async def handle_request(request):
140181
141182
*args: positional parameters to pass to the callback, if it is used
142183
184+
cache_context: if set, the callback will be given a `cache_context` kw arg,
185+
which will be a ResponseCacheContext object.
186+
143187
**kwargs: named parameters to pass to the callback, if it is used
144188
145189
Returns:
146-
Deferred which resolves to the result
190+
The result of the callback (from the cache, or otherwise)
147191
"""
148192
result = self.get(key)
149193
if not result:
150194
logger.debug(
151195
"[%s]: no cached result for [%s], calculating new one", self._name, key
152196
)
197+
context = ResponseCacheContext(cache_key=key)
198+
if cache_context:
199+
kwargs["cache_context"] = context
153200
d = run_in_background(callback, *args, **kwargs)
154-
result = self.set(key, d)
201+
result = self._set(context, d)
155202
elif not isinstance(result, defer.Deferred) or result.called:
156203
logger.info("[%s]: using completed cached result for [%s]", self._name, key)
157204
else:
158205
logger.info(
159206
"[%s]: using incomplete cached result for [%s]", self._name, key
160207
)
161-
return make_deferred_yieldable(result)
208+
return await make_deferred_yieldable(result)

tests/util/caches/test_responsecache.py renamed to tests/util/caches/test_response_cache.py

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,17 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14+
from parameterized import parameterized
1415

15-
from synapse.util.caches.response_cache import ResponseCache
16+
from twisted.internet import defer
17+
18+
from synapse.util.caches.response_cache import ResponseCache, ResponseCacheContext
1619

1720
from tests.server import get_clock
1821
from tests.unittest import TestCase
1922

2023

21-
class DeferredCacheTestCase(TestCase):
24+
class ResponseCacheTestCase(TestCase):
2225
"""
2326
A TestCase class for ResponseCache.
2427
@@ -48,7 +51,9 @@ def test_cache_hit(self):
4851

4952
expected_result = "howdy"
5053

51-
wrap_d = cache.wrap(0, self.instant_return, expected_result)
54+
wrap_d = defer.ensureDeferred(
55+
cache.wrap(0, self.instant_return, expected_result)
56+
)
5257

5358
self.assertEqual(
5459
expected_result,
@@ -66,7 +71,9 @@ def test_cache_miss(self):
6671

6772
expected_result = "howdy"
6873

69-
wrap_d = cache.wrap(0, self.instant_return, expected_result)
74+
wrap_d = defer.ensureDeferred(
75+
cache.wrap(0, self.instant_return, expected_result)
76+
)
7077

7178
self.assertEqual(
7279
expected_result,
@@ -80,7 +87,9 @@ def test_cache_expire(self):
8087

8188
expected_result = "howdy"
8289

83-
wrap_d = cache.wrap(0, self.instant_return, expected_result)
90+
wrap_d = defer.ensureDeferred(
91+
cache.wrap(0, self.instant_return, expected_result)
92+
)
8493

8594
self.assertEqual(expected_result, self.successResultOf(wrap_d))
8695
self.assertEqual(
@@ -99,7 +108,10 @@ def test_cache_wait_hit(self):
99108

100109
expected_result = "howdy"
101110

102-
wrap_d = cache.wrap(0, self.delayed_return, expected_result)
111+
wrap_d = defer.ensureDeferred(
112+
cache.wrap(0, self.delayed_return, expected_result)
113+
)
114+
103115
self.assertNoResult(wrap_d)
104116

105117
# function wakes up, returns result
@@ -112,7 +124,9 @@ def test_cache_wait_expire(self):
112124

113125
expected_result = "howdy"
114126

115-
wrap_d = cache.wrap(0, self.delayed_return, expected_result)
127+
wrap_d = defer.ensureDeferred(
128+
cache.wrap(0, self.delayed_return, expected_result)
129+
)
116130
self.assertNoResult(wrap_d)
117131

118132
# stop at 1 second to callback cache eviction callLater at that time, then another to set time at 2
@@ -129,3 +143,50 @@ def test_cache_wait_expire(self):
129143
self.reactor.pump((2,))
130144

131145
self.assertIsNone(cache.get(0), "cache should not have the result now")
146+
147+
@parameterized.expand([(True,), (False,)])
148+
def test_cache_context_nocache(self, should_cache: bool):
149+
"""If the callback clears the should_cache bit, the result should not be cached"""
150+
cache = self.with_cache("medium_cache", ms=3000)
151+
152+
expected_result = "howdy"
153+
154+
call_count = 0
155+
156+
async def non_caching(o: str, cache_context: ResponseCacheContext[int]):
157+
nonlocal call_count
158+
call_count += 1
159+
await self.clock.sleep(1)
160+
cache_context.should_cache = should_cache
161+
return o
162+
163+
wrap_d = defer.ensureDeferred(
164+
cache.wrap(0, non_caching, expected_result, cache_context=True)
165+
)
166+
# there should be no result to start with
167+
self.assertNoResult(wrap_d)
168+
169+
# a second call should also return a pending deferred
170+
wrap2_d = defer.ensureDeferred(
171+
cache.wrap(0, non_caching, expected_result, cache_context=True)
172+
)
173+
self.assertNoResult(wrap2_d)
174+
175+
# and there should have been exactly one call
176+
self.assertEqual(call_count, 1)
177+
178+
# let the call complete
179+
self.reactor.advance(1)
180+
181+
# both results should have completed
182+
self.assertEqual(expected_result, self.successResultOf(wrap_d))
183+
self.assertEqual(expected_result, self.successResultOf(wrap2_d))
184+
185+
if should_cache:
186+
self.assertEqual(
187+
expected_result,
188+
self.successResultOf(cache.get(0)),
189+
"cache should still have the result",
190+
)
191+
else:
192+
self.assertIsNone(cache.get(0), "cache should not have the result")

0 commit comments

Comments
 (0)