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

Commit 88a78c6

Browse files
author
David Robertson
authored
Cache empty responses from /user/devices (#11587)
If we've never made a request to a remote homeserver, we should cache the response---even if the response is "this user has no devices".
1 parent 0fb3dd0 commit 88a78c6

File tree

5 files changed

+114
-5
lines changed

5 files changed

+114
-5
lines changed

Diff for: changelog.d/11587.bugfix

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix a long-standing bug where Synapse wouldn't cache a response indicating that a remote user has no devices.

Diff for: synapse/handlers/device.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -948,8 +948,16 @@ async def user_device_resync(
948948
devices = []
949949
ignore_devices = True
950950
else:
951+
prev_stream_id = await self.store.get_device_list_last_stream_id_for_remote(
952+
user_id
953+
)
951954
cached_devices = await self.store.get_cached_devices_for_user(user_id)
952-
if cached_devices == {d["device_id"]: d for d in devices}:
955+
956+
# To ensure that a user with no devices is cached, we skip the resync only
957+
# if we have a stream_id from previously writing a cache entry.
958+
if prev_stream_id is not None and cached_devices == {
959+
d["device_id"]: d for d in devices
960+
}:
953961
logging.info(
954962
"Skipping device list resync for %s, as our cache matches already",
955963
user_id,

Diff for: synapse/storage/databases/main/devices.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -713,7 +713,7 @@ def _get_all_device_list_changes_for_remotes(txn):
713713
@cached(max_entries=10000)
714714
async def get_device_list_last_stream_id_for_remote(
715715
self, user_id: str
716-
) -> Optional[Any]:
716+
) -> Optional[str]:
717717
"""Get the last stream_id we got for a user. May be None if we haven't
718718
got any information for them.
719719
"""
@@ -729,7 +729,9 @@ async def get_device_list_last_stream_id_for_remote(
729729
cached_method_name="get_device_list_last_stream_id_for_remote",
730730
list_name="user_ids",
731731
)
732-
async def get_device_list_last_stream_id_for_remotes(self, user_ids: Iterable[str]):
732+
async def get_device_list_last_stream_id_for_remotes(
733+
self, user_ids: Iterable[str]
734+
) -> Dict[str, Optional[str]]:
733735
rows = await self.db_pool.simple_select_many_batch(
734736
table="device_lists_remote_extremeties",
735737
column="user_id",
@@ -1316,6 +1318,7 @@ def _update_remote_device_list_cache_entry_txn(
13161318
content: JsonDict,
13171319
stream_id: str,
13181320
) -> None:
1321+
"""Delete, update or insert a cache entry for this (user, device) pair."""
13191322
if content.get("deleted"):
13201323
self.db_pool.simple_delete_txn(
13211324
txn,
@@ -1375,6 +1378,7 @@ async def update_remote_device_list_cache(
13751378
def _update_remote_device_list_cache_txn(
13761379
self, txn: LoggingTransaction, user_id: str, devices: List[dict], stream_id: int
13771380
) -> None:
1381+
"""Replace the list of cached devices for this user with the given list."""
13781382
self.db_pool.simple_delete_txn(
13791383
txn, table="device_lists_remote_cache", keyvalues={"user_id": user_id}
13801384
)

Diff for: tests/handlers/test_e2e_keys.py

+96
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1414
# See the License for the specific language governing permissions and
1515
# limitations under the License.
16+
from typing import Iterable
1617
from unittest import mock
1718

19+
from parameterized import parameterized
1820
from signedjson import key as key, sign as sign
1921

2022
from twisted.internet import defer
@@ -23,6 +25,7 @@
2325
from synapse.api.errors import Codes, SynapseError
2426

2527
from tests import unittest
28+
from tests.test_utils import make_awaitable
2629

2730

2831
class E2eKeysHandlerTestCase(unittest.HomeserverTestCase):
@@ -765,6 +768,8 @@ def test_query_devices_remote_sync(self):
765768
remote_user_id = "@test:other"
766769
local_user_id = "@test:test"
767770

771+
# Pretend we're sharing a room with the user we're querying. If not,
772+
# `_query_devices_for_destination` will return early.
768773
self.store.get_rooms_for_user = mock.Mock(
769774
return_value=defer.succeed({"some_room_id"})
770775
)
@@ -831,3 +836,94 @@ def test_query_devices_remote_sync(self):
831836
}
832837
},
833838
)
839+
840+
@parameterized.expand(
841+
[
842+
# The remote homeserver's response indicates that this user has 0/1/2 devices.
843+
([],),
844+
(["device_1"],),
845+
(["device_1", "device_2"],),
846+
]
847+
)
848+
def test_query_all_devices_caches_result(self, device_ids: Iterable[str]):
849+
"""Test that requests for all of a remote user's devices are cached.
850+
851+
We do this by asserting that only one call over federation was made, and that
852+
the two queries to the local homeserver produce the same response.
853+
"""
854+
local_user_id = "@test:test"
855+
remote_user_id = "@test:other"
856+
request_body = {"device_keys": {remote_user_id: []}}
857+
858+
response_devices = [
859+
{
860+
"device_id": device_id,
861+
"keys": {
862+
"algorithms": ["dummy"],
863+
"device_id": device_id,
864+
"keys": {f"dummy:{device_id}": "dummy"},
865+
"signatures": {device_id: {f"dummy:{device_id}": "dummy"}},
866+
"unsigned": {},
867+
"user_id": "@test:other",
868+
},
869+
}
870+
for device_id in device_ids
871+
]
872+
873+
response_body = {
874+
"devices": response_devices,
875+
"user_id": remote_user_id,
876+
"stream_id": 12345, # an integer, according to the spec
877+
}
878+
879+
e2e_handler = self.hs.get_e2e_keys_handler()
880+
881+
# Pretend we're sharing a room with the user we're querying. If not,
882+
# `_query_devices_for_destination` will return early.
883+
mock_get_rooms = mock.patch.object(
884+
self.store,
885+
"get_rooms_for_user",
886+
new_callable=mock.MagicMock,
887+
return_value=make_awaitable(["some_room_id"]),
888+
)
889+
mock_request = mock.patch.object(
890+
self.hs.get_federation_client(),
891+
"query_user_devices",
892+
new_callable=mock.MagicMock,
893+
return_value=make_awaitable(response_body),
894+
)
895+
896+
with mock_get_rooms, mock_request as mocked_federation_request:
897+
# Make the first query and sanity check it succeeds.
898+
response_1 = self.get_success(
899+
e2e_handler.query_devices(
900+
request_body,
901+
timeout=10,
902+
from_user_id=local_user_id,
903+
from_device_id="some_device_id",
904+
)
905+
)
906+
self.assertEqual(response_1["failures"], {})
907+
908+
# We should have made a federation request to do so.
909+
mocked_federation_request.assert_called_once()
910+
911+
# Reset the mock so we can prove we don't make a second federation request.
912+
mocked_federation_request.reset_mock()
913+
914+
# Repeat the query.
915+
response_2 = self.get_success(
916+
e2e_handler.query_devices(
917+
request_body,
918+
timeout=10,
919+
from_user_id=local_user_id,
920+
from_device_id="some_device_id",
921+
)
922+
)
923+
self.assertEqual(response_2["failures"], {})
924+
925+
# We should not have made a second federation request.
926+
mocked_federation_request.assert_not_called()
927+
928+
# The two requests to the local homeserver should be identical.
929+
self.assertEqual(response_1, response_2)

Diff for: tests/test_utils/__init__.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import warnings
2020
from asyncio import Future
2121
from binascii import unhexlify
22-
from typing import Any, Awaitable, Callable, TypeVar
22+
from typing import Awaitable, Callable, TypeVar
2323
from unittest.mock import Mock
2424

2525
import attr
@@ -46,7 +46,7 @@ def get_awaitable_result(awaitable: Awaitable[TV]) -> TV:
4646
raise Exception("awaitable has not yet completed")
4747

4848

49-
def make_awaitable(result: Any) -> Awaitable[Any]:
49+
def make_awaitable(result: TV) -> Awaitable[TV]:
5050
"""
5151
Makes an awaitable, suitable for mocking an `async` function.
5252
This uses Futures as they can be awaited multiple times so can be returned

0 commit comments

Comments
 (0)