Improve caching for presence (#19939)

This does two things, first it adds a config flag to ignore rooms for
the purposes of presence routing.

Secondly, it changes the caching behaviour to try and improve the cache
hit ratio. Previously, the size of the `do_users_share_a_room` cache
(which stores pairs of users) needs to `O(n²)` for the number of online
users, which is infeasible for large servers.

Instead, we call `get_users_in_room` for both the syncing and updated
users. This sounds more expensive, but a) we will already have cached
the syncing user's rooms, and b) we will only calculate the updated
user's rooms once (rather than once per syncing user).

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik Johnston
2026-07-13 09:34:07 +01:00
committed by GitHub
co-authored by Claude Fable 5
parent c0c2b37d5e
commit 0bd28389b1
8 changed files with 330 additions and 11 deletions
+1
View File
@@ -0,0 +1 @@
Add an `exclude_rooms_from_presence` configuration option to stop presence being routed between users solely because they share one of the listed rooms.
+1
View File
@@ -0,0 +1 @@
Minor presence performance improvements for large servers.
@@ -4310,6 +4310,16 @@ exclude_rooms_from_sync:
- '!foo:example.com'
```
---
### `exclude_rooms_from_presence`
*(array)* A list of rooms to exclude from presence updates. Presence will not be routed between two users solely because they share one of these rooms. Users who also share a non-excluded room continue to exchange presence as normal. Defaults to `[]`.
Example configuration:
```yaml
exclude_rooms_from_presence:
- '!foo:example.com'
```
---
## Opentracing
Configuration options related to Opentracing support.
+12
View File
@@ -5354,6 +5354,18 @@ properties:
default: []
examples:
- - "!foo:example.com"
exclude_rooms_from_presence:
type: array
description: >-
A list of rooms to exclude from presence updates. Presence will not be
routed between two users solely because they share one of these rooms.
Users who also share a non-excluded room continue to exchange presence as
normal.
items:
type: string
default: []
examples:
- - "!foo:example.com"
opentracing:
type: object
description: >-
+4
View File
@@ -936,6 +936,10 @@ class ServerConfig(Config):
config.get("exclude_rooms_from_sync") or []
)
self.rooms_to_exclude_from_presence: list[str] = (
config.get("exclude_rooms_from_presence") or []
)
delete_stale_devices_after: str | None = (
config.get("delete_stale_devices_after") or None
)
+77 -9
View File
@@ -222,6 +222,12 @@ class BasePresenceHandler(abc.ABC):
self._presence_enabled = hs.config.server.presence_enabled
self._track_presence = hs.config.server.track_presence
# Rooms which, on their own, should not cause presence to be routed
# between their members. See `exclude_rooms_from_presence` in the config.
self._rooms_to_exclude_from_presence = frozenset(
hs.config.server.rooms_to_exclude_from_presence
)
# The (configurable) presence state machine timers.
self._last_active_granularity = (
hs.config.server.presence_last_active_granularity
@@ -435,6 +441,7 @@ class BasePresenceHandler(abc.ABC):
self.store,
self.presence_router,
states,
self._rooms_to_exclude_from_presence,
)
for destinations, host_states in hosts_to_states:
@@ -688,7 +695,12 @@ class WorkerPresenceHandler(BasePresenceHandler):
async def notify_from_replication(
self, states: list[UserPresenceState], stream_id: int
) -> None:
parties = await get_interested_parties(self.store, self.presence_router, states)
parties = await get_interested_parties(
self.store,
self.presence_router,
states,
self._rooms_to_exclude_from_presence,
)
room_ids_to_states, users_to_states = parties
self.notifier.on_new_event(
@@ -1141,6 +1153,7 @@ class PresenceHandler(BasePresenceHandler):
self.store,
self.presence_router,
list(to_federation_ping.values()),
self._rooms_to_exclude_from_presence,
)
for destinations, states in hosts_to_states:
@@ -1414,7 +1427,12 @@ class PresenceHandler(BasePresenceHandler):
"""
stream_id, max_token = await self.store.update_presence(states)
parties = await get_interested_parties(self.store, self.presence_router, states)
parties = await get_interested_parties(
self.store,
self.presence_router,
states,
self._rooms_to_exclude_from_presence,
)
room_ids_to_states, users_to_states = parties
self.notifier.on_new_event(
@@ -1561,7 +1579,10 @@ class PresenceHandler(BasePresenceHandler):
observed_user.to_string()
)
if observer_room_ids & observed_room_ids:
shared_room_ids = (
observer_room_ids & observed_room_ids
) - self._rooms_to_exclude_from_presence
if shared_room_ids:
return True
return False
@@ -1672,6 +1693,12 @@ class PresenceHandler(BasePresenceHandler):
to be handled.
"""
# Excluded rooms should not, on their own, share presence between their
# members. This method is entirely per-room presence fan-out, so skip
# excluded rooms wholesale.
if room_id in self._rooms_to_exclude_from_presence:
return
# Sets of newly joined users. Note that if the local server is
# joining a remote room for the first time we'll see both the joining
# user and all remote users as newly joined.
@@ -1929,6 +1956,9 @@ class PresenceEventSource(EventSource[int, UserPresenceState]):
self.server_name = hs.hostname
self.clock = hs.get_clock()
self.store = hs.get_datastores().main
self._rooms_to_exclude_from_presence = frozenset(
hs.config.server.rooms_to_exclude_from_presence
)
async def get_new_events(
self,
@@ -2043,9 +2073,31 @@ class PresenceEventSource(EventSource[int, UserPresenceState]):
**{SERVER_NAME_LABEL: self.server_name},
).inc()
sharing_users = await self.store.do_users_share_a_room(
user_id, updated_users
)
# An updated user is interesting if they share a
# (non-excluded) room with the syncing user. We check by
# intersecting the cached per-user room sets rather than via
# `do_users_share_a_room`: its per-pair cache has a
# quadratic working set and is cleared wholesale on every
# membership change, so on busy servers every check missed
# into SQL.
#
# For every presence update we need to run this code for
# every user that is currently syncing. The
# `get_rooms_for_user` will therefore be computed only once
# for each updated user regardless of the number of syncing
# users.
#
# The syncing user's rooms will also be cached as its needed
# during sync processing anyway.
my_rooms = await self.store.get_rooms_for_user(user_id)
if self._rooms_to_exclude_from_presence:
my_rooms = my_rooms - self._rooms_to_exclude_from_presence
rooms_by_user = await self.store.get_rooms_for_users(updated_users)
sharing_users = {
updated_user
for updated_user, rooms in rooms_by_user.items()
if not my_rooms.isdisjoint(rooms)
}
interested_and_updated_users = (
sharing_users.union(additional_users_interested_in)
@@ -2060,7 +2112,9 @@ class PresenceEventSource(EventSource[int, UserPresenceState]):
).inc()
users_interested_in = (
await self.store.get_users_who_share_room_with_user(user_id)
await self.store.get_users_who_share_room_with_user(
user_id, self._rooms_to_exclude_from_presence
)
)
users_interested_in.update(additional_users_interested_in)
@@ -2073,7 +2127,9 @@ class PresenceEventSource(EventSource[int, UserPresenceState]):
# No from_key has been specified. Return the presence for all users
# this user is interested in
interested_and_updated_users = (
await self.store.get_users_who_share_room_with_user(user_id)
await self.store.get_users_who_share_room_with_user(
user_id, self._rooms_to_exclude_from_presence
)
)
interested_and_updated_users.update(additional_users_interested_in)
@@ -2473,7 +2529,10 @@ def _combine_device_states(
async def get_interested_parties(
store: DataStore, presence_router: PresenceRouter, states: list[UserPresenceState]
store: DataStore,
presence_router: PresenceRouter,
states: list[UserPresenceState],
excluded_rooms: AbstractSet[str] = frozenset(),
) -> tuple[dict[str, list[UserPresenceState]], dict[str, list[UserPresenceState]]]:
"""Given a list of states return which entities (rooms, users)
are interested in the given states.
@@ -2482,6 +2541,8 @@ async def get_interested_parties(
store: The homeserver's data store.
presence_router: A module for augmenting the destinations for presence updates.
states: A list of incoming user presence updates.
excluded_rooms: Rooms which should not, on their own, cause presence to
be routed between their members.
Returns:
A 2-tuple of `(room_ids_to_states, users_to_states)`,
@@ -2492,6 +2553,8 @@ async def get_interested_parties(
for state in states:
room_ids = await store.get_rooms_for_user(state.user_id)
for room_id in room_ids:
if room_id in excluded_rooms:
continue
room_ids_to_states.setdefault(room_id, []).append(state)
# Always notify self
@@ -2512,6 +2575,7 @@ async def get_interested_remotes(
store: DataStore,
presence_router: PresenceRouter,
states: list[UserPresenceState],
excluded_rooms: AbstractSet[str] = frozenset(),
) -> list[tuple[StrCollection, Collection[UserPresenceState]]]:
"""Given a list of presence states figure out which remote servers
should be sent which.
@@ -2522,6 +2586,8 @@ async def get_interested_remotes(
store: The homeserver's data store.
presence_router: A module for augmenting the destinations for presence updates.
states: A list of incoming user presence updates.
excluded_rooms: Rooms which should not, on their own, cause presence to
be routed to their remote members.
Returns:
A map from destinations to presence states to send to that destination.
@@ -2535,6 +2601,8 @@ async def get_interested_remotes(
room_ids = await store.get_rooms_for_user(state.user_id)
hosts: set[str] = set()
for room_id in room_ids:
if room_id in excluded_rooms:
continue
room_hosts = await store.get_current_hosts_in_room(room_id)
hosts.update(room_hosts)
hosts_and_states.append((hosts, [state]))
+12 -2
View File
@@ -971,12 +971,22 @@ class RoomMemberWorkerStore(EventsWorkerStore, CacheInvalidationWorkerStore):
return {u for u, share_room in user_dict.items() if share_room}
async def get_users_who_share_room_with_user(self, user_id: str) -> set[str]:
"""Returns the set of users who share a room with `user_id`"""
async def get_users_who_share_room_with_user(
self, user_id: str, excluded_rooms: AbstractSet[str] = frozenset()
) -> set[str]:
"""Returns the set of users who share a room with `user_id`.
Args:
user_id: The user to find the co-occupants of.
excluded_rooms: Rooms which should not, on their own, count as a
shared room.
"""
room_ids = await self.get_rooms_for_user(user_id)
user_who_share_room: set[str] = set()
for room_id in room_ids:
if room_id in excluded_rooms:
continue
user_ids = await self.get_users_in_room(room_id)
user_who_share_room.update(user_ids)
+213
View File
@@ -51,6 +51,8 @@ from synapse.handlers.presence import (
FEDERATION_TIMEOUT,
PresenceHandler,
WorkerPresenceHandler,
get_interested_parties,
get_interested_remotes,
handle_timeout,
handle_update,
)
@@ -2323,6 +2325,217 @@ class PresenceJoinTestCase(unittest.HomeserverTestCase):
return event
class PresenceExcludeRoomsTestCase(unittest.HomeserverTestCase):
"""Tests that `exclude_rooms_from_presence` stops presence being routed
between users solely because they share an excluded room."""
servlets = [
admin.register_servlets,
login.register_servlets,
room.register_servlets,
]
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.hs = hs
self.store = hs.get_datastores().main
self.presence_router = hs.get_presence_router()
self.presence_handler = hs.get_presence_handler()
self.user1 = self.register_user("user1", "pass")
self.token1 = self.login("user1", "pass")
self.user2 = self.register_user("user2", "pass")
self.token2 = self.login("user2", "pass")
def test_excluded_rooms_not_routed(self) -> None:
# Two rooms that user1 is joined to.
excluded_room = self.helper.create_room_as(self.user1, tok=self.token1)
shared_room = self.helper.create_room_as(self.user1, tok=self.token1)
state = UserPresenceState.default(self.user1)
# Without any exclusions both rooms are interested in user1's presence.
room_ids_to_states, users_to_states = self.get_success(
get_interested_parties(self.store, self.presence_router, [state])
)
self.assertIn(excluded_room, room_ids_to_states)
self.assertIn(shared_room, room_ids_to_states)
# Excluding one room drops it as an interested party, but the other
# (non-excluded) room still routes presence...
room_ids_to_states, users_to_states = self.get_success(
get_interested_parties(
self.store,
self.presence_router,
[state],
frozenset({excluded_room}),
)
)
self.assertNotIn(excluded_room, room_ids_to_states)
self.assertIn(shared_room, room_ids_to_states)
# ...and the user always receives their own presence, even when all of
# their rooms are excluded.
room_ids_to_states, users_to_states = self.get_success(
get_interested_parties(
self.store,
self.presence_router,
[state],
frozenset({excluded_room, shared_room}),
)
)
self.assertNotIn(excluded_room, room_ids_to_states)
self.assertNotIn(shared_room, room_ids_to_states)
self.assertIn(self.user1, users_to_states)
@override_config({"exclude_rooms_from_presence": ["!excluded:test"]})
def test_config_populates_handler(self) -> None:
"""The config option should be plumbed through to the presence handler
and the presence event source as a frozenset."""
self.assertEqual(
self.presence_handler._rooms_to_exclude_from_presence,
frozenset({"!excluded:test"}),
)
event_source = self.hs.get_event_sources().sources.presence
self.assertEqual(
event_source._rooms_to_exclude_from_presence,
frozenset({"!excluded:test"}),
)
def test_is_visible_respects_excluded_rooms(self) -> None:
"""`is_visible` (which drives the read side of /sync) should not
consider two users to share presence solely via an excluded room."""
user1 = UserID.from_string(self.user1)
user2 = UserID.from_string(self.user2)
# A single shared room: the two users can see each other's presence.
excluded_room = self.helper.create_room_as(self.user1, tok=self.token1)
self.helper.join(excluded_room, self.user2, tok=self.token2)
self.assertTrue(
self.get_success(self.presence_handler.is_visible(user2, user1))
)
# Excluding the only shared room hides presence between them.
self.presence_handler._rooms_to_exclude_from_presence = frozenset(
{excluded_room}
)
self.assertFalse(
self.get_success(self.presence_handler.is_visible(user2, user1))
)
# But a second, non-excluded shared room restores visibility.
shared_room = self.helper.create_room_as(self.user1, tok=self.token1)
self.helper.join(shared_room, self.user2, tok=self.token2)
self.assertTrue(
self.get_success(self.presence_handler.is_visible(user2, user1))
)
def test_get_interested_remotes_respects_excluded_rooms(self) -> None:
"""The federation fan-out side (`get_interested_remotes`) must not route
presence to servers reached solely via an excluded room."""
excluded_room = self.helper.create_room_as(self.user1, tok=self.token1)
state = UserPresenceState.default(self.user1)
def hosts_for(excluded: frozenset) -> set:
result = self.get_success(
get_interested_remotes(
self.store, self.presence_router, [state], excluded
)
)
hosts: set[str] = set()
for room_hosts, _ in result:
hosts.update(room_hosts)
return hosts
# The local server is a host in the room (all members are local here),
# so presence would be routed there...
self.assertIn("test", hosts_for(frozenset()))
# ...but excluding the only room removes it as a source of destinations.
self.assertNotIn("test", hosts_for(frozenset({excluded_room})))
class PresenceGetNewEventsStreamTestCase(unittest.HomeserverTestCase):
"""Tests the incremental (`from_key`) branch of
`PresenceEventSource.get_new_events`, which decides which updated users are
interesting to the syncing user by intersecting their cached room sets.
"""
servlets = [
admin.register_servlets,
login.register_servlets,
room.register_servlets,
]
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.presence_handler = hs.get_presence_handler()
self.event_source = hs.get_event_sources().sources.presence
self.user1 = self.register_user("user1", "pass")
self.token1 = self.login("user1", "pass")
self.user2 = self.register_user("user2", "pass")
self.token2 = self.login("user2", "pass")
self.user3 = self.register_user("user3", "pass")
self.token3 = self.login("user3", "pass")
def _set_presence(self, user_id: str, state: str = "online") -> None:
self.get_success(
self.presence_handler.set_state(
UserID.from_string(user_id), "dev", {"presence": state}
)
)
def _updated_users_seen_by(self, user_id: str, from_key: int) -> set[str]:
states, _ = self.get_success(
self.event_source.get_new_events(
user=UserID.from_string(user_id), from_key=from_key
)
)
return {state.user_id for state in states}
def test_incremental_interest(self) -> None:
"""A syncing user sees updates from users they share a room with (and
themselves), but not from strangers."""
shared_room = self.helper.create_room_as(self.user1, tok=self.token1)
self.helper.join(shared_room, self.user2, tok=self.token2)
# user3 is in an unrelated room.
self.helper.create_room_as(self.user3, tok=self.token3)
from_key = self.event_source.get_current_key()
self._set_presence(self.user1)
self._set_presence(self.user2)
self._set_presence(self.user3)
seen = self._updated_users_seen_by(self.user2, from_key)
self.assertIn(self.user1, seen)
self.assertIn(self.user2, seen) # always sees own updates
self.assertNotIn(self.user3, seen)
def test_incremental_interest_excluded_room(self) -> None:
"""Sharing only an excluded room does not make an updated user
interesting; sharing an additional normal room does."""
excluded_room = self.helper.create_room_as(self.user1, tok=self.token1)
self.helper.join(excluded_room, self.user2, tok=self.token2)
self.event_source._rooms_to_exclude_from_presence = frozenset({excluded_room})
from_key = self.event_source.get_current_key()
self._set_presence(self.user1, "online")
seen = self._updated_users_seen_by(self.user2, from_key)
self.assertNotIn(self.user1, seen)
# A second, non-excluded shared room restores interest. (Use a
# different presence state, as repeating the same one would not
# generate a new update.)
shared_room = self.helper.create_room_as(self.user1, tok=self.token1)
self.helper.join(shared_room, self.user2, tok=self.token2)
from_key = self.event_source.get_current_key()
self._set_presence(self.user1, "unavailable")
seen = self._updated_users_seen_by(self.user2, from_key)
self.assertIn(self.user1, seen)
class WorkerPresenceThrottleTestCase(BaseMultiWorkerStreamTestCase):
"""Tests that sync workers suppress the per-sync-request presence updates
that the presence writer would discard anyway, while relaying genuine