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

Commit c73cc2c

Browse files
authored
Spaces summary: call out to other servers (#9653)
When we hit an unknown room in the space tree, see if there are other servers that we might be able to poll to get the data. Fixes: #9447
1 parent 4655d22 commit c73cc2c

File tree

4 files changed

+324
-27
lines changed

4 files changed

+324
-27
lines changed

changelog.d/9653.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add initial experimental support for a "space summary" API.

synapse/federation/federation_client.py

Lines changed: 170 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,13 @@
2727
List,
2828
Mapping,
2929
Optional,
30+
Sequence,
3031
Tuple,
3132
TypeVar,
3233
Union,
3334
)
3435

36+
import attr
3537
from prometheus_client import Counter
3638

3739
from twisted.internet import defer
@@ -455,6 +457,7 @@ async def _try_destination_list(
455457
description: str,
456458
destinations: Iterable[str],
457459
callback: Callable[[str], Awaitable[T]],
460+
failover_on_unknown_endpoint: bool = False,
458461
) -> T:
459462
"""Try an operation on a series of servers, until it succeeds
460463
@@ -474,6 +477,10 @@ async def _try_destination_list(
474477
next server tried. Normally the stacktrace is logged but this is
475478
suppressed if the exception is an InvalidResponseError.
476479
480+
failover_on_unknown_endpoint: if True, we will try other servers if it looks
481+
like a server doesn't support the endpoint. This is typically useful
482+
if the endpoint in question is new or experimental.
483+
477484
Returns:
478485
The result of callback, if it succeeds
479486
@@ -493,16 +500,31 @@ async def _try_destination_list(
493500
except UnsupportedRoomVersionError:
494501
raise
495502
except HttpResponseException as e:
496-
if not 500 <= e.code < 600:
497-
raise e.to_synapse_error()
498-
else:
499-
logger.warning(
500-
"Failed to %s via %s: %i %s",
501-
description,
502-
destination,
503-
e.code,
504-
e.args[0],
505-
)
503+
synapse_error = e.to_synapse_error()
504+
failover = False
505+
506+
if 500 <= e.code < 600:
507+
failover = True
508+
509+
elif failover_on_unknown_endpoint:
510+
# there is no good way to detect an "unknown" endpoint. Dendrite
511+
# returns a 404 (with no body); synapse returns a 400
512+
# with M_UNRECOGNISED.
513+
if e.code == 404 or (
514+
e.code == 400 and synapse_error.errcode == Codes.UNRECOGNIZED
515+
):
516+
failover = True
517+
518+
if not failover:
519+
raise synapse_error from e
520+
521+
logger.warning(
522+
"Failed to %s via %s: %i %s",
523+
description,
524+
destination,
525+
e.code,
526+
e.args[0],
527+
)
506528
except Exception:
507529
logger.warning(
508530
"Failed to %s via %s", description, destination, exc_info=True
@@ -1042,3 +1064,141 @@ async def get_room_complexity(
10421064
# If we don't manage to find it, return None. It's not an error if a
10431065
# server doesn't give it to us.
10441066
return None
1067+
1068+
async def get_space_summary(
1069+
self,
1070+
destinations: Iterable[str],
1071+
room_id: str,
1072+
suggested_only: bool,
1073+
max_rooms_per_space: Optional[int],
1074+
exclude_rooms: List[str],
1075+
) -> "FederationSpaceSummaryResult":
1076+
"""
1077+
Call other servers to get a summary of the given space
1078+
1079+
1080+
Args:
1081+
destinations: The remote servers. We will try them in turn, omitting any
1082+
that have been blacklisted.
1083+
1084+
room_id: ID of the space to be queried
1085+
1086+
suggested_only: If true, ask the remote server to only return children
1087+
with the "suggested" flag set
1088+
1089+
max_rooms_per_space: A limit on the number of children to return for each
1090+
space
1091+
1092+
exclude_rooms: A list of room IDs to tell the remote server to skip
1093+
1094+
Returns:
1095+
a parsed FederationSpaceSummaryResult
1096+
1097+
Raises:
1098+
SynapseError if we were unable to get a valid summary from any of the
1099+
remote servers
1100+
"""
1101+
1102+
async def send_request(destination: str) -> FederationSpaceSummaryResult:
1103+
res = await self.transport_layer.get_space_summary(
1104+
destination=destination,
1105+
room_id=room_id,
1106+
suggested_only=suggested_only,
1107+
max_rooms_per_space=max_rooms_per_space,
1108+
exclude_rooms=exclude_rooms,
1109+
)
1110+
1111+
try:
1112+
return FederationSpaceSummaryResult.from_json_dict(res)
1113+
except ValueError as e:
1114+
raise InvalidResponseError(str(e))
1115+
1116+
return await self._try_destination_list(
1117+
"fetch space summary",
1118+
destinations,
1119+
send_request,
1120+
failover_on_unknown_endpoint=True,
1121+
)
1122+
1123+
1124+
@attr.s(frozen=True, slots=True)
1125+
class FederationSpaceSummaryEventResult:
1126+
"""Represents a single event in the result of a successful get_space_summary call.
1127+
1128+
It's essentially just a serialised event object, but we do a bit of parsing and
1129+
validation in `from_json_dict` and store some of the validated properties in
1130+
object attributes.
1131+
"""
1132+
1133+
event_type = attr.ib(type=str)
1134+
state_key = attr.ib(type=str)
1135+
via = attr.ib(type=Sequence[str])
1136+
1137+
# the raw data, including the above keys
1138+
data = attr.ib(type=JsonDict)
1139+
1140+
@classmethod
1141+
def from_json_dict(cls, d: JsonDict) -> "FederationSpaceSummaryEventResult":
1142+
"""Parse an event within the result of a /spaces/ request
1143+
1144+
Args:
1145+
d: json object to be parsed
1146+
1147+
Raises:
1148+
ValueError if d is not a valid event
1149+
"""
1150+
1151+
event_type = d.get("type")
1152+
if not isinstance(event_type, str):
1153+
raise ValueError("Invalid event: 'event_type' must be a str")
1154+
1155+
state_key = d.get("state_key")
1156+
if not isinstance(state_key, str):
1157+
raise ValueError("Invalid event: 'state_key' must be a str")
1158+
1159+
content = d.get("content")
1160+
if not isinstance(content, dict):
1161+
raise ValueError("Invalid event: 'content' must be a dict")
1162+
1163+
via = content.get("via")
1164+
if not isinstance(via, Sequence):
1165+
raise ValueError("Invalid event: 'via' must be a list")
1166+
if any(not isinstance(v, str) for v in via):
1167+
raise ValueError("Invalid event: 'via' must be a list of strings")
1168+
1169+
return cls(event_type, state_key, via, d)
1170+
1171+
1172+
@attr.s(frozen=True, slots=True)
1173+
class FederationSpaceSummaryResult:
1174+
"""Represents the data returned by a successful get_space_summary call."""
1175+
1176+
rooms = attr.ib(type=Sequence[JsonDict])
1177+
events = attr.ib(type=Sequence[FederationSpaceSummaryEventResult])
1178+
1179+
@classmethod
1180+
def from_json_dict(cls, d: JsonDict) -> "FederationSpaceSummaryResult":
1181+
"""Parse the result of a /spaces/ request
1182+
1183+
Args:
1184+
d: json object to be parsed
1185+
1186+
Raises:
1187+
ValueError if d is not a valid /spaces/ response
1188+
"""
1189+
rooms = d.get("rooms")
1190+
if not isinstance(rooms, Sequence):
1191+
raise ValueError("'rooms' must be a list")
1192+
if any(not isinstance(r, dict) for r in rooms):
1193+
raise ValueError("Invalid room in 'rooms' list")
1194+
1195+
events = d.get("events")
1196+
if not isinstance(events, Sequence):
1197+
raise ValueError("'events' must be a list")
1198+
if any(not isinstance(e, dict) for e in events):
1199+
raise ValueError("Invalid event in 'events' list")
1200+
parsed_events = [
1201+
FederationSpaceSummaryEventResult.from_json_dict(e) for e in events
1202+
]
1203+
1204+
return cls(rooms, parsed_events)

synapse/federation/transport/client.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import logging
1818
import urllib
19-
from typing import Any, Dict, Optional
19+
from typing import Any, Dict, List, Optional
2020

2121
from synapse.api.constants import Membership
2222
from synapse.api.errors import Codes, HttpResponseException, SynapseError
@@ -26,6 +26,7 @@
2626
FEDERATION_V2_PREFIX,
2727
)
2828
from synapse.logging.utils import log_function
29+
from synapse.types import JsonDict
2930

3031
logger = logging.getLogger(__name__)
3132

@@ -978,6 +979,38 @@ def get_room_complexity(self, destination, room_id):
978979

979980
return self.client.get_json(destination=destination, path=path)
980981

982+
async def get_space_summary(
983+
self,
984+
destination: str,
985+
room_id: str,
986+
suggested_only: bool,
987+
max_rooms_per_space: Optional[int],
988+
exclude_rooms: List[str],
989+
) -> JsonDict:
990+
"""
991+
Args:
992+
destination: The remote server
993+
room_id: The room ID to ask about.
994+
suggested_only: if True, only suggested rooms will be returned
995+
max_rooms_per_space: an optional limit to the number of children to be
996+
returned per space
997+
exclude_rooms: a list of any rooms we can skip
998+
"""
999+
path = _create_path(
1000+
FEDERATION_UNSTABLE_PREFIX, "/org.matrix.msc2946/spaces/%s", room_id
1001+
)
1002+
1003+
params = {
1004+
"suggested_only": suggested_only,
1005+
"exclude_rooms": exclude_rooms,
1006+
}
1007+
if max_rooms_per_space is not None:
1008+
params["max_rooms_per_space"] = max_rooms_per_space
1009+
1010+
return await self.client.post_json(
1011+
destination=destination, path=path, data=params
1012+
)
1013+
9811014

9821015
def _create_path(federation_prefix, path, *args):
9831016
"""

0 commit comments

Comments
 (0)