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

Optimize how we calculate likely_domains during backfill #13575

Merged
merged 15 commits into from
Aug 30, 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/13575.misc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Optimize how Synapse calculates domains to fetch from during backfill.
MadLittleMods marked this conversation as resolved.
Show resolved Hide resolved
53 changes: 10 additions & 43 deletions synapse/handlers/federation.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
from synapse.storage.databases.main.events import PartialStateConflictError
from synapse.storage.databases.main.events_worker import EventRedactBehaviour
from synapse.storage.state import StateFilter
from synapse.types import JsonDict, StateMap, get_domain_from_id
from synapse.types import JsonDict, get_domain_from_id
from synapse.util.async_helpers import Linearizer
from synapse.util.retryutils import NotRetryingDestination
from synapse.visibility import filter_events_for_server
Expand Down Expand Up @@ -99,37 +99,6 @@
)


def get_domains_from_state(state: StateMap[EventBase]) -> List[Tuple[str, int]]:
Copy link
Contributor Author

@MadLittleMods MadLittleMods Aug 20, 2022

Choose a reason for hiding this comment

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

Eliminating this option to get domains for a room in favor of get_current_hosts_in_room.

In a room like #matrixhq, getting all 80k state events in the room, then whittling it down in the app is not faster than a quick targeted database query or maybe even luckily hitting the cache.

"""Get joined domains from state

Args:
state: State map from type/state key to event.

Returns:
Returns a list of servers with the lowest depth of their joins.
Sorted by lowest depth first.
"""
joined_users = [
(state_key, int(event.depth))
for (e_type, state_key), event in state.items()
if e_type == EventTypes.Member and event.membership == Membership.JOIN
]

joined_domains: Dict[str, int] = {}
for u, d in joined_users:
try:
dom = get_domain_from_id(u)
old_d = joined_domains.get(dom)
if old_d:
joined_domains[dom] = min(d, old_d)
else:
joined_domains[dom] = d
except Exception:
pass

return sorted(joined_domains.items(), key=lambda d: d[1])


class _BackfillPointType(Enum):
# a regular backwards extremity (ie, an event which we don't yet have, but which
# is referred to by other events in the DAG)
Expand Down Expand Up @@ -427,21 +396,19 @@ async def _maybe_backfill_inner(
)

# Now we need to decide which hosts to hit first.

# First we try hosts that are already in the room
# First we try hosts that are already in the room.
# TODO: HEURISTIC ALERT.
likely_domains = (
await self._storage_controllers.state.get_current_hosts_in_room(room_id)
)

curr_state = await self._storage_controllers.state.get_current_state(room_id)

curr_domains = get_domains_from_state(curr_state)

likely_domains = [
domain for domain, depth in curr_domains if domain != self.server_name
]

async def try_backfill(domains: List[str]) -> bool:
async def try_backfill(domains: Collection[str]) -> bool:
# TODO: Should we try multiple of these at a time?
for dom in domains:
# We don't want to ask our own server for information we don't have
if dom == self.server_name:
continue

try:
await self._federation_event_handler.backfill(
dom, room_id, limit=100, extremities=extremities_to_request
Expand Down
14 changes: 6 additions & 8 deletions synapse/handlers/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@
from synapse.events import EventBase
from synapse.events.utils import copy_and_fixup_power_levels_contents
from synapse.federation.federation_client import InvalidResponseError
from synapse.handlers.federation import get_domains_from_state
from synapse.handlers.relations import BundledAggregations
from synapse.module_api import NOT_SPAM
from synapse.rest.admin._base import assert_user_is_admin
Expand Down Expand Up @@ -1459,17 +1458,16 @@ async def get_event_for_timestamp(
timestamp,
)

# Find other homeservers from the given state in the room
curr_state = await self._storage_controllers.state.get_current_state(
room_id
likely_domains = (
await self._storage_controllers.state.get_current_hosts_in_room(room_id)
)
curr_domains = get_domains_from_state(curr_state)
likely_domains = [
domain for domain, depth in curr_domains if domain != self.server_name
]

# Loop through each homeserver candidate until we get a succesful response
for domain in likely_domains:
MadLittleMods marked this conversation as resolved.
Show resolved Hide resolved
# We don't want to ask our own server for information we don't have
if domain == self.server_name:
continue

try:
remote_response = await self.federation_client.timestamp_to_event(
domain, room_id, timestamp, direction
Expand Down
3 changes: 1 addition & 2 deletions synapse/storage/controllers/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
List,
Mapping,
Optional,
Set,
Tuple,
)

Expand Down Expand Up @@ -520,7 +519,7 @@ async def get_current_state_event(
)
return state_map.get(key)

async def get_current_hosts_in_room(self, room_id: str) -> Set[str]:
async def get_current_hosts_in_room(self, room_id: str) -> List[str]:
"""Get current hosts in room based on current state."""

await self._partial_state_room_tracker.await_full_state(room_id)
Expand Down
88 changes: 71 additions & 17 deletions synapse/storage/databases/main/roommember.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,27 +187,48 @@ def _check_safe_current_state_events_membership_updated_txn(

@cached(max_entries=100000, iterable=True)
async def get_users_in_room(self, room_id: str) -> List[str]:
"""
Returns a list of users in the room sorted by longest in the room first
(aka. with the lowest depth). This is done to match the sort in
`get_current_hosts_in_room()` and so we can re-use the cache but it's
not horrible to have here either.
"""

return await self.db_pool.runInteraction(
"get_users_in_room", self.get_users_in_room_txn, room_id
)

def get_users_in_room_txn(self, txn: LoggingTransaction, room_id: str) -> List[str]:
"""
Returns a list of users in the room sorted by longest in the room first
(aka. with the lowest depth). This is done to match the sort in
`get_current_hosts_in_room()` and so we can re-use the cache but it's
not horrible to have here either.
"""
# If we can assume current_state_events.membership is up to date
# then we can avoid a join, which is a Very Good Thing given how
# frequently this function gets called.
if self._current_state_events_membership_up_to_date:
sql = """
SELECT state_key FROM current_state_events
WHERE type = 'm.room.member' AND room_id = ? AND membership = ?
SELECT c.state_key FROM current_state_events as c
/* Get the depth of the event from the events table */
INNER JOIN events AS e USING (event_id)
Copy link
Contributor

Choose a reason for hiding this comment

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

Is adding this join here going to bite us in the bum for callers that don't actually care about the depth ordering?

As an aside, adding an index on events(event_id, depth) could be considered to speed up this if it turns out to be a problem, but on the other hand we're not keen on adding more indices to the events tables because it's frequently written.

Copy link
Contributor Author

@MadLittleMods MadLittleMods Aug 23, 2022

Choose a reason for hiding this comment

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

Time-wise, I think by the nature of the extra complexity, it will take more time but it's not something I optimized for here. If we don't like this trade-off, we could remove the cache-shortcut lookup in get_current_hosts_in_room around it (it's kinda doing the same thing anyway looking in current_state_events).

Is there another concern about the order itself?

Copy link
Contributor Author

@MadLittleMods MadLittleMods Aug 23, 2022

Choose a reason for hiding this comment

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

fwiw, here are some first-run timings. Not sure how much the first one affects the second one 🤷. Doubling the time isn't very good though so I'm up for removing the cache-shortcut lookup (just poke me further). Or we just sort by oldest current_state_events rows (not necessarily depth order but might be pretty close in practice) and not worry about the slight difference it makes to backfill (this one maybe sounds more interesting to me).

$ psql synapse
synapse=# \timing on

synapse=# SELECT c.state_key FROM current_state_events as c
/* Get the depth of the event from the events table */
INNER JOIN events AS e USING (event_id)
WHERE c.type = 'm.room.member' AND c.room_id = '!OGEhHVWSdvArJzumhm:matrix.org' AND membership = 'join'
/* Sorted by lowest depth first */
ORDER BY e.depth ASC;

Time: 4374.227 ms (00:04.374)


synapse=# SELECT c.state_key FROM current_state_events as c
WHERE c.type = 'm.room.member' AND c.room_id = '!OGEhHVWSdvArJzumhm:matrix.org' AND membership = 'join';

Time: 1947.409 ms (00:01.947)

Copy link
Contributor

Choose a reason for hiding this comment

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

Only doubling isn't as bad as I was expecting, so maybe it's fine. Possibly worth checking a graph for how much time is spent on get_users_in_rooms and thinking if it would burn us if that time were doubled.

Copy link
Contributor Author

@MadLittleMods MadLittleMods Aug 23, 2022

Choose a reason for hiding this comment

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

Only doubling isn't as bad as I was expecting, so maybe it's fine. Possibly worth checking a graph for how much time is spent on get_users_in_rooms and thinking if it would burn us if that time were doubled.

Do we have a graph for this? I don't see special metrics annotated in it but 👍 it would be good to look at if we had it. Depending on confidence, we can merge a PR to measure it.


I've looked at the usages and there are some areas where it doesn't feel great to increase the time (calculating if an appservice is interested in an event (this could probably be smarter though anyway to use the user namespace regex directly on the members of the room like we do to get the domains), calculating join_authorised_via_users_server in a /make_join (idk how important this is), newly joined rooms in /sync, presence). Overall seems kinda fine ⏩

I can create separate PRs to fix up the following areas first, then we can merge this after those places are changed.

There are a few areas where it feels like we shouldn't be using get_users_in_room and can just use a direct lookup for the single user:

There are a few places where we should use get_current_hosts_in_room instead:

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here is some follow-up. I think this PR was rolled out on matrix.org (or maybe just the RC was which wouldn't include this?)

The average looks about the same. It seems a bit more peakier on total txn time.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Another follow-up, this time I think the changes are actually deployed to matrix.org as indicated by that change in the UP graph at the top (and /versions shows a hash that includes it)

It looks like it does take significantly more time on average (~100ms vs ~10ms before) but we're doing the query a whole lot more less probably because we fixed up those mis-uses.

https://grafana.matrix.org/d/000000012/synapse?orgId=1&from=1662362659334&to=1662535459334&viewPanel=180

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This probably has a bigger penalty for other usage patterns, see #13942

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Probably fixed #13942 via #13958 (another get_users_in_room mis-use)

Fixed up another mis-use in #13960

And found another mis-use that's tracked by #13967. Since this involves more than just switching it out, I'm not going to tackle. Feel free to pick it up!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This ended up as two functions get_current_hosts_in_room and get_current_hosts_in_room_ordered so that we don't have to take the performance hit of the extra join in get_users_in_room, see #13972

WHERE c.type = 'm.room.member' AND c.room_id = ? AND membership = ?
/* Sorted by lowest depth first */
ORDER BY e.depth ASC;
"""
else:
sql = """
SELECT state_key FROM room_memberships as m
SELECT c.state_key FROM room_memberships as m
/* Get the depth of the event from the events table */
INNER JOIN events AS e USING (event_id)
INNER JOIN current_state_events as c
ON m.event_id = c.event_id
AND m.room_id = c.room_id
AND m.user_id = c.state_key
WHERE c.type = 'm.room.member' AND c.room_id = ? AND m.membership = ?
/* Sorted by lowest depth first */
ORDER BY e.depth ASC;
"""

txn.execute(sql, (room_id, Membership.JOIN))
Expand Down Expand Up @@ -1018,37 +1039,70 @@ async def _check_host_room_membership(
return True

@cached(iterable=True, max_entries=10000)
async def get_current_hosts_in_room(self, room_id: str) -> Set[str]:
"""Get current hosts in room based on current state."""
async def get_current_hosts_in_room(self, room_id: str) -> List[str]:
"""
Get current hosts in room based on current state.

The heuristic of sorting by servers who have been in the room the
longest is good because they're most likely to have anything we ask
about.

Returns:
Returns a list of servers sorted by longest in the room first. (aka.
sorted by join with the lowest depth first).
"""

# First we check if we already have `get_users_in_room` in the cache, as
# we can just calculate result from that
MadLittleMods marked this conversation as resolved.
Show resolved Hide resolved
users = self.get_users_in_room.cache.get_immediate(
(room_id,), None, update_metrics=False
)
if users is not None:
return {get_domain_from_id(u) for u in users}

if isinstance(self.database_engine, Sqlite3Engine):
if users is None and isinstance(self.database_engine, Sqlite3Engine):
# If we're using SQLite then let's just always use
# `get_users_in_room` rather than funky SQL.
users = await self.get_users_in_room(room_id)
return {get_domain_from_id(u) for u in users}

if users is not None:
# Because `users` is sorted from lowest -> highest depth, the list
# of domains will also be sorted that way.
domains: List[str] = []
# We use a `Set` just for fast lookups
domain_set: Set[str] = set()
for u in users:
domain = get_domain_from_id(u)
if domain not in domain_set:
domain_set.add(domain)
domains.append(domain)
return domains

# For PostgreSQL we can use a regex to pull out the domains from the
# joined users in `current_state_events` via regex.

def get_current_hosts_in_room_txn(txn: LoggingTransaction) -> Set[str]:
def get_current_hosts_in_room_txn(txn: LoggingTransaction) -> List[str]:
# Returns a list of servers currently joined in the room sorted by
# longest in the room first (aka. with the lowest depth). The
# heuristic of sorting by servers who have been in the room the
# longest is good because they're most likely to have anything we
# ask about.
sql = """
SELECT DISTINCT substring(state_key FROM '@[^:]*:(.*)$')
FROM current_state_events
SELECT
/* Match the domain part of the MXID */
substring(c.state_key FROM '@[^:]*:(.*)$') as server_domain
FROM current_state_events c
/* Get the depth of the event from the events table */
INNER JOIN events AS e USING (event_id)
WHERE
type = 'm.room.member'
AND membership = 'join'
AND room_id = ?
/* Find any join state events in the room */
c.type = 'm.room.member'
AND c.membership = 'join'
AND c.room_id = ?
/* Group all state events from the same domain into their own buckets (groups) */
GROUP BY server_domain
/* Sorted by lowest depth first */
ORDER BY min(e.depth) ASC;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updating the query to order by depth to match what get_domains_from_state did before (drop-in replacement).

The heuristic of sorting by servers who have been there the longest is good because they're most likely to have anything we ask about.

"""
txn.execute(sql, (room_id,))
return {d for d, in txn}
return [d for d, in txn]

return await self.db_pool.runInteraction(
"get_current_hosts_in_room", get_current_hosts_in_room_txn
Expand Down