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

Commit f54f877

Browse files
authored
Preparatory work to fix the user directory assuming that any remote membership state events represent a profile change. [rei:userdirpriv] (#14755)
* Remove special-case method for new memberships only, use more generic method * Only collect profiles from state events in public rooms * Add a table to track stale remote user profiles * Add store methods to set and delete rows in this new table * Mark remote profiles as stale when a member state event comes in to a private room * Newsfile Signed-off-by: Olivier Wilkinson (reivilibre) <[email protected]> * Simplify by removing Optionality of `event_id` * Replace names and avatars with None if they're set to dodgy things I think this makes more sense anyway. * Move schema delta to 74 (I missed the boat?) * Turns out these can be None after all --------- Signed-off-by: Olivier Wilkinson (reivilibre) <[email protected]>
1 parent 3bf973e commit f54f877

File tree

4 files changed

+127
-34
lines changed

4 files changed

+127
-34
lines changed

changelog.d/14755.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix a long-standing bug in which the user directory would assume any remote membership state events represent a profile change.

synapse/handlers/user_directory.py

Lines changed: 47 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@
2828

2929
logger = logging.getLogger(__name__)
3030

31+
# Don't refresh a stale user directory entry, using a Federation /profile request,
32+
# for 60 seconds. This gives time for other state events to arrive (which will
33+
# then be coalesced such that only one /profile request is made).
34+
USER_DIRECTORY_STALE_REFRESH_TIME_MS = 60 * 1000
35+
3136

3237
class UserDirectoryHandler(StateDeltasHandler):
3338
"""Handles queries and updates for the user_directory.
@@ -200,8 +205,8 @@ async def _handle_deltas(self, deltas: List[Dict[str, Any]]) -> None:
200205
typ = delta["type"]
201206
state_key = delta["state_key"]
202207
room_id = delta["room_id"]
203-
event_id = delta["event_id"]
204-
prev_event_id = delta["prev_event_id"]
208+
event_id: Optional[str] = delta["event_id"]
209+
prev_event_id: Optional[str] = delta["prev_event_id"]
205210

206211
logger.debug("Handling: %r %r, %s", typ, state_key, event_id)
207212

@@ -297,8 +302,8 @@ async def _handle_room_publicity_change(
297302
async def _handle_room_membership_event(
298303
self,
299304
room_id: str,
300-
prev_event_id: str,
301-
event_id: str,
305+
prev_event_id: Optional[str],
306+
event_id: Optional[str],
302307
state_key: str,
303308
) -> None:
304309
"""Process a single room membershp event.
@@ -348,37 +353,22 @@ async def _handle_room_membership_event(
348353
# Handle any profile changes for remote users.
349354
# (For local users the rest of the application calls
350355
# `handle_local_profile_change`.)
351-
if is_remote:
356+
# Only process if there is an event_id.
357+
if is_remote and event_id is not None:
352358
await self._handle_possible_remote_profile_change(
353359
state_key, room_id, prev_event_id, event_id
354360
)
355361
elif joined is MatchChange.now_true: # The user joined
356362
# This may be the first time we've seen a remote user. If
357363
# so, ensure we have a directory entry for them. (For local users,
358364
# the rest of the application calls `handle_local_profile_change`.)
359-
if is_remote:
360-
await self._upsert_directory_entry_for_remote_user(state_key, event_id)
365+
# Only process if there is an event_id.
366+
if is_remote and event_id is not None:
367+
await self._handle_possible_remote_profile_change(
368+
state_key, room_id, None, event_id
369+
)
361370
await self._track_user_joined_room(room_id, state_key)
362371

363-
async def _upsert_directory_entry_for_remote_user(
364-
self, user_id: str, event_id: str
365-
) -> None:
366-
"""A remote user has just joined a room. Ensure they have an entry in
367-
the user directory. The caller is responsible for making sure they're
368-
remote.
369-
"""
370-
event = await self.store.get_event(event_id, allow_none=True)
371-
# It isn't expected for this event to not exist, but we
372-
# don't want the entire background process to break.
373-
if event is None:
374-
return
375-
376-
logger.debug("Adding new user to dir, %r", user_id)
377-
378-
await self.store.update_profile_in_user_dir(
379-
user_id, event.content.get("displayname"), event.content.get("avatar_url")
380-
)
381-
382372
async def _track_user_joined_room(self, room_id: str, joining_user_id: str) -> None:
383373
"""Someone's just joined a room. Update `users_in_public_rooms` or
384374
`users_who_share_private_rooms` as appropriate.
@@ -460,14 +450,17 @@ async def _handle_possible_remote_profile_change(
460450
user_id: str,
461451
room_id: str,
462452
prev_event_id: Optional[str],
463-
event_id: Optional[str],
453+
event_id: str,
464454
) -> None:
465455
"""Check member event changes for any profile changes and update the
466456
database if there are. This is intended for remote users only. The caller
467457
is responsible for checking that the given user is remote.
468458
"""
469-
if not prev_event_id or not event_id:
470-
return
459+
460+
if not prev_event_id:
461+
# If we don't have an older event to fall back on, just fetch the same
462+
# event itself.
463+
prev_event_id = event_id
471464

472465
prev_event = await self.store.get_event(prev_event_id, allow_none=True)
473466
event = await self.store.get_event(event_id, allow_none=True)
@@ -478,17 +471,37 @@ async def _handle_possible_remote_profile_change(
478471
if event.membership != Membership.JOIN:
479472
return
480473

474+
is_public = await self.store.is_room_world_readable_or_publicly_joinable(
475+
room_id
476+
)
477+
if not is_public:
478+
# Don't collect user profiles from private rooms as they are not guaranteed
479+
# to be the same as the user's global profile.
480+
now_ts = self.clock.time_msec()
481+
await self.store.set_remote_user_profile_in_user_dir_stale(
482+
user_id,
483+
next_try_at_ms=now_ts + USER_DIRECTORY_STALE_REFRESH_TIME_MS,
484+
retry_counter=0,
485+
)
486+
return
487+
481488
prev_name = prev_event.content.get("displayname")
482489
new_name = event.content.get("displayname")
483-
# If the new name is an unexpected form, do not update the directory.
490+
# If the new name is an unexpected form, replace with None.
484491
if not isinstance(new_name, str):
485-
new_name = prev_name
492+
new_name = None
486493

487494
prev_avatar = prev_event.content.get("avatar_url")
488495
new_avatar = event.content.get("avatar_url")
489-
# If the new avatar is an unexpected form, do not update the directory.
496+
# If the new avatar is an unexpected form, replace with None.
490497
if not isinstance(new_avatar, str):
491-
new_avatar = prev_avatar
498+
new_avatar = None
492499

493-
if prev_name != new_name or prev_avatar != new_avatar:
500+
if (
501+
prev_name != new_name
502+
or prev_avatar != new_avatar
503+
or prev_event_id == event_id
504+
):
505+
# Only update if something has changed, or we didn't have a previous event
506+
# in the first place.
494507
await self.store.update_profile_in_user_dir(user_id, new_name, new_avatar)

synapse/storage/databases/main/user_directory.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
from synapse.storage.engines import PostgresEngine, Sqlite3Engine
5555
from synapse.types import (
5656
JsonDict,
57+
UserID,
5758
UserProfile,
5859
get_domain_from_id,
5960
get_localpart_from_id,
@@ -473,11 +474,42 @@ async def is_room_world_readable_or_publicly_joinable(self, room_id: str) -> boo
473474

474475
return False
475476

477+
async def set_remote_user_profile_in_user_dir_stale(
478+
self, user_id: str, next_try_at_ms: int, retry_counter: int
479+
) -> None:
480+
"""
481+
Marks a remote user as having a possibly-stale user directory profile.
482+
483+
Args:
484+
user_id: the remote user who may have a stale profile on this server.
485+
next_try_at_ms: timestamp in ms after which the user directory profile can be
486+
refreshed.
487+
retry_counter: number of failures in refreshing the profile so far. Used for
488+
exponential backoff calculations.
489+
"""
490+
assert not self.hs.is_mine_id(
491+
user_id
492+
), "Can't mark a local user as a stale remote user."
493+
494+
server_name = UserID.from_string(user_id).domain
495+
496+
await self.db_pool.simple_upsert(
497+
table="user_directory_stale_remote_users",
498+
keyvalues={"user_id": user_id},
499+
values={
500+
"next_try_at_ts": next_try_at_ms,
501+
"retry_counter": retry_counter,
502+
"user_server_name": server_name,
503+
},
504+
desc="set_remote_user_profile_in_user_dir_stale",
505+
)
506+
476507
async def update_profile_in_user_dir(
477508
self, user_id: str, display_name: Optional[str], avatar_url: Optional[str]
478509
) -> None:
479510
"""
480511
Update or add a user's profile in the user directory.
512+
If the user is remote, the profile will be marked as not stale.
481513
"""
482514
# If the display name or avatar URL are unexpected types, replace with None.
483515
display_name = non_null_str_or_none(display_name)
@@ -491,6 +523,14 @@ def _update_profile_in_user_dir_txn(txn: LoggingTransaction) -> None:
491523
values={"display_name": display_name, "avatar_url": avatar_url},
492524
)
493525

526+
if not self.hs.is_mine_id(user_id):
527+
# Remote users: Make sure the profile is not marked as stale anymore.
528+
self.db_pool.simple_delete_txn(
529+
txn,
530+
table="user_directory_stale_remote_users",
531+
keyvalues={"user_id": user_id},
532+
)
533+
494534
# The display name that goes into the database index.
495535
index_display_name = display_name
496536
if index_display_name is not None:
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/* Copyright 2022 The Matrix.org Foundation C.I.C
2+
*
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
-- Table containing a list of remote users whose profiles may have changed
17+
-- since their last update in the user directory.
18+
CREATE TABLE user_directory_stale_remote_users (
19+
-- The User ID of the remote user whose profile may be stale.
20+
user_id TEXT NOT NULL PRIMARY KEY,
21+
22+
-- The server name of the user.
23+
user_server_name TEXT NOT NULL,
24+
25+
-- The timestamp (in ms) after which we should next try to request the user's
26+
-- latest profile.
27+
next_try_at_ts BIGINT NOT NULL,
28+
29+
-- The number of retries so far.
30+
-- 0 means we have not yet attempted to refresh the profile.
31+
-- Used for calculating exponential backoff.
32+
retry_counter INTEGER NOT NULL
33+
);
34+
35+
-- Create an index so we can easily query upcoming servers to try.
36+
CREATE INDEX user_directory_stale_remote_users_next_try_idx ON user_directory_stale_remote_users(next_try_at_ts, user_server_name);
37+
38+
-- Create an index so we can easily query upcoming users to try for a particular server.
39+
CREATE INDEX user_directory_stale_remote_users_next_try_by_server_idx ON user_directory_stale_remote_users(user_server_name, next_try_at_ts);

0 commit comments

Comments
 (0)