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

Fix a bug that corrupted the cache of federated space hierarchies #11775

Merged
merged 3 commits into from
Jan 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/11775.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix a long-standing bug where space hierarchy over federation would only work correctly some of the time.
18 changes: 9 additions & 9 deletions synapse/federation/federation_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ def __init__(self, hs: "HomeServer"):
# It is a map of (room ID, suggested-only) -> the response of
# get_room_hierarchy.
self._get_room_hierarchy_cache: ExpiringCache[
Tuple[str, bool], Tuple[JsonDict, Sequence[JsonDict], Sequence[str]]
Tuple[str, bool],
Tuple[JsonDict, Sequence[JsonDict], Sequence[JsonDict], Sequence[str]],
] = ExpiringCache(
cache_name="get_room_hierarchy_cache",
clock=self._clock,
Expand Down Expand Up @@ -1333,7 +1334,7 @@ async def get_room_hierarchy(
destinations: Iterable[str],
room_id: str,
suggested_only: bool,
) -> Tuple[JsonDict, Sequence[JsonDict], Sequence[str]]:
) -> Tuple[JsonDict, Sequence[JsonDict], Sequence[JsonDict], Sequence[str]]:
"""
Call other servers to get a hierarchy of the given room.

Expand All @@ -1348,7 +1349,8 @@ async def get_room_hierarchy(

Returns:
A tuple of:
The room as a JSON dictionary.
The room as a JSON dictionary, without a "children_state" key.
A list of `m.space.child` state events.
A list of children rooms, as JSON dictionaries.
A list of inaccessible children room IDs.

Expand All @@ -1363,7 +1365,7 @@ async def get_room_hierarchy(

async def send_request(
destination: str,
) -> Tuple[JsonDict, Sequence[JsonDict], Sequence[str]]:
) -> Tuple[JsonDict, Sequence[JsonDict], Sequence[JsonDict], Sequence[str]]:
try:
res = await self.transport_layer.get_room_hierarchy(
destination=destination,
Expand Down Expand Up @@ -1392,7 +1394,7 @@ async def send_request(
raise InvalidResponseError("'room' must be a dict")

# Validate children_state of the room.
children_state = room.get("children_state", [])
children_state = room.pop("children_state", [])
if not isinstance(children_state, Sequence):
raise InvalidResponseError("'room.children_state' must be a list")
if any(not isinstance(e, dict) for e in children_state):
Expand Down Expand Up @@ -1421,7 +1423,7 @@ async def send_request(
"Invalid room ID in 'inaccessible_children' list"
)

return room, children, inaccessible_children
return room, children_state, children, inaccessible_children

try:
result = await self._try_destination_list(
Expand Down Expand Up @@ -1469,8 +1471,6 @@ async def send_request(
if event.room_id == room_id:
children_events.append(event.data)
children_room_ids.add(event.state_key)
# And add them under the requested room.
requested_room["children_state"] = children_events

# Find the children rooms.
children = []
Expand All @@ -1480,7 +1480,7 @@ async def send_request(

# It isn't clear from the response whether some of the rooms are
# not accessible.
result = (requested_room, children, ())
result = (requested_room, children_events, children, ())

# Cache the result to avoid fetching data over federation every time.
self._get_room_hierarchy_cache[(room_id, suggested_only)] = result
Expand Down
3 changes: 2 additions & 1 deletion synapse/handlers/room_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,7 @@ async def _summarize_remote_room_hierarchy(
try:
(
room_response,
children_state_events,
children,
inaccessible_children,
) = await self._federation_client.get_room_hierarchy(
Expand All @@ -804,7 +805,7 @@ async def _summarize_remote_room_hierarchy(
}

return (
_RoomEntry(room_id, room_response, room_response.pop("children_state", ())),
Copy link
Contributor Author

@squahtx squahtx Jan 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

room_response comes from a cache and is a JsonDict, which is why .pop() is allowed.

This bug feels preventable if we had a JsonMapping type. Except we can't just define JsonMapping as a Mapping, since Mappings aren't necessarily JSON-serializable. Maybe some NewType magic could work?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe https://docs.python.org/3.10/library/types.html#types.MappingProxyType, but we'd have to register something to make those json encodable. NewType probably the best way forward, short of some kind of const/mutable semantics in the type system.

_RoomEntry(room_id, room_response, children_state_events),
children_by_room_id,
set(inaccessible_children),
)
Expand Down
92 changes: 90 additions & 2 deletions tests/handlers/test_room_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from synapse.api.errors import AuthError, NotFoundError, SynapseError
from synapse.api.room_versions import RoomVersions
from synapse.events import make_event_from_dict
from synapse.federation.transport.client import TransportLayerClient
from synapse.handlers.room_summary import _child_events_comparison_key, _RoomEntry
from synapse.rest import admin
from synapse.rest.client import login, room
Expand Down Expand Up @@ -134,10 +135,18 @@ def prepare(self, reactor, clock, hs: HomeServer):
self._add_child(self.space, self.room, self.token)

def _add_child(
self, space_id: str, room_id: str, token: str, order: Optional[str] = None
self,
space_id: str,
room_id: str,
token: str,
order: Optional[str] = None,
via: Optional[List[str]] = None,
) -> None:
"""Add a child room to a space."""
content: JsonDict = {"via": [self.hs.hostname]}
if via is None:
via = [self.hs.hostname]

content: JsonDict = {"via": via}
if order is not None:
content["order"] = order
self.helper.send_state(
Expand Down Expand Up @@ -1036,6 +1045,85 @@ async def summarize_remote_room_hierarchy(_self, room, suggested_only):
)
self._assert_hierarchy(result, expected)

def test_fed_caching(self):
"""
Federation `/hierarchy` responses should be cached.
"""
fed_hostname = self.hs.hostname + "2"
fed_subspace = "#space:" + fed_hostname
fed_room = "#room:" + fed_hostname

# Add a room to the space which is on another server.
self._add_child(self.space, fed_subspace, self.token, via=[fed_hostname])

federation_requests = 0

async def get_room_hierarchy(
_self: TransportLayerClient,
destination: str,
room_id: str,
suggested_only: bool,
) -> JsonDict:
nonlocal federation_requests
federation_requests += 1

return {
"room": {
"room_id": fed_subspace,
"world_readable": True,
"room_type": RoomTypes.SPACE,
"children_state": [
{
"type": EventTypes.SpaceChild,
"room_id": fed_subspace,
"state_key": fed_room,
"content": {"via": [fed_hostname]},
},
],
},
"children": [
{
"room_id": fed_room,
"world_readable": True,
},
],
"inaccessible_children": [],
}

expected = [
(self.space, [self.room, fed_subspace]),
(self.room, ()),
(fed_subspace, [fed_room]),
(fed_room, ()),
]

with mock.patch(
"synapse.federation.transport.client.TransportLayerClient.get_room_hierarchy",
new=get_room_hierarchy,
):
result = self.get_success(
self.handler.get_room_hierarchy(create_requester(self.user), self.space)
)
self.assertEqual(federation_requests, 1)
self._assert_hierarchy(result, expected)

# The previous federation response should be reused.
result = self.get_success(
self.handler.get_room_hierarchy(create_requester(self.user), self.space)
)
self.assertEqual(federation_requests, 1)
self._assert_hierarchy(result, expected)

# Expire the response cache
self.reactor.advance(5 * 60 + 1)

# A new federation request should be made.
result = self.get_success(
self.handler.get_room_hierarchy(create_requester(self.user), self.space)
)
self.assertEqual(federation_requests, 2)
self._assert_hierarchy(result, expected)


class RoomSummaryTestCase(unittest.HomeserverTestCase):
servlets = [
Expand Down