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

Commit 28989cb

Browse files
babolivierclokep
andauthored
Add a background job to automatically delete stale devices (#12855)
Co-authored-by: Patrick Cloke <clokep@users.noreply.github.com>
1 parent 888eb73 commit 28989cb

File tree

6 files changed

+135
-1
lines changed

6 files changed

+135
-1
lines changed

changelog.d/12855.feature

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add a configurable background job to delete stale devices.

docs/usage/configuration/config_documentation.md

+12
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,18 @@ Example configuration:
575575
dummy_events_threshold: 5
576576
```
577577
---
578+
Config option `delete_stale_devices_after`
579+
580+
An optional duration. If set, Synapse will run a daily background task to log out and
581+
delete any device that hasn't been accessed for more than the specified amount of time.
582+
583+
Defaults to no duration, which means devices are never pruned.
584+
585+
Example configuration:
586+
```yaml
587+
delete_stale_devices_after: 1y
588+
```
589+
578590
## Homeserver blocking ##
579591
Useful options for Synapse admins.
580592

synapse/config/server.py

+11
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,17 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None:
679679
config.get("exclude_rooms_from_sync") or []
680680
)
681681

682+
delete_stale_devices_after: Optional[str] = (
683+
config.get("delete_stale_devices_after") or None
684+
)
685+
686+
if delete_stale_devices_after is not None:
687+
self.delete_stale_devices_after: Optional[int] = self.parse_duration(
688+
delete_stale_devices_after
689+
)
690+
else:
691+
self.delete_stale_devices_after = None
692+
682693
def has_tls_listener(self) -> bool:
683694
return any(listener.tls for listener in self.listeners)
684695

synapse/handlers/device.py

+29-1
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
logger = logging.getLogger(__name__)
6262

6363
MAX_DEVICE_DISPLAY_NAME_LEN = 100
64+
DELETE_STALE_DEVICES_INTERVAL_MS = 24 * 60 * 60 * 1000
6465

6566

6667
class DeviceWorkerHandler:
@@ -295,6 +296,19 @@ def __init__(self, hs: "HomeServer"):
295296
# On start up check if there are any updates pending.
296297
hs.get_reactor().callWhenRunning(self._handle_new_device_update_async)
297298

299+
self._delete_stale_devices_after = hs.config.server.delete_stale_devices_after
300+
301+
# Ideally we would run this on a worker and condition this on the
302+
# "run_background_tasks_on" setting, but this would mean making the notification
303+
# of device list changes over federation work on workers, which is nontrivial.
304+
if self._delete_stale_devices_after is not None:
305+
self.clock.looping_call(
306+
run_as_background_process,
307+
DELETE_STALE_DEVICES_INTERVAL_MS,
308+
"delete_stale_devices",
309+
self._delete_stale_devices,
310+
)
311+
298312
def _check_device_name_length(self, name: Optional[str]) -> None:
299313
"""
300314
Checks whether a device name is longer than the maximum allowed length.
@@ -370,6 +384,19 @@ async def check_device_registered(
370384

371385
raise errors.StoreError(500, "Couldn't generate a device ID.")
372386

387+
async def _delete_stale_devices(self) -> None:
388+
"""Background task that deletes devices which haven't been accessed for more than
389+
a configured time period.
390+
"""
391+
# We should only be running this job if the config option is defined.
392+
assert self._delete_stale_devices_after is not None
393+
now_ms = self.clock.time_msec()
394+
since_ms = now_ms - self._delete_stale_devices_after
395+
devices = await self.store.get_local_devices_not_accessed_since(since_ms)
396+
397+
for user_id, user_devices in devices.items():
398+
await self.delete_devices(user_id, user_devices)
399+
373400
@trace
374401
async def delete_device(self, user_id: str, device_id: str) -> None:
375402
"""Delete the given device
@@ -692,7 +719,8 @@ async def _handle_new_device_update_async(self) -> None:
692719
)
693720
# TODO: when called, this isn't in a logging context.
694721
# This leads to log spam, sentry event spam, and massive
695-
# memory usage. See #12552.
722+
# memory usage.
723+
# See https://github.com/matrix-org/synapse/issues/12552.
696724
# log_kv(
697725
# {"message": "sent device update to host", "host": host}
698726
# )

synapse/storage/databases/main/devices.py

+39
Original file line numberDiff line numberDiff line change
@@ -1154,6 +1154,45 @@ def _prune_txn(txn: LoggingTransaction) -> None:
11541154
_prune_txn,
11551155
)
11561156

1157+
async def get_local_devices_not_accessed_since(
1158+
self, since_ms: int
1159+
) -> Dict[str, List[str]]:
1160+
"""Retrieves local devices that haven't been accessed since a given date.
1161+
1162+
Args:
1163+
since_ms: the timestamp to select on, every device with a last access date
1164+
from before that time is returned.
1165+
1166+
Returns:
1167+
A dictionary with an entry for each user with at least one device matching
1168+
the request, which value is a list of the device ID(s) for the corresponding
1169+
device(s).
1170+
"""
1171+
1172+
def get_devices_not_accessed_since_txn(
1173+
txn: LoggingTransaction,
1174+
) -> List[Dict[str, str]]:
1175+
sql = """
1176+
SELECT user_id, device_id
1177+
FROM devices WHERE last_seen < ? AND hidden = FALSE
1178+
"""
1179+
txn.execute(sql, (since_ms,))
1180+
return self.db_pool.cursor_to_dict(txn)
1181+
1182+
rows = await self.db_pool.runInteraction(
1183+
"get_devices_not_accessed_since",
1184+
get_devices_not_accessed_since_txn,
1185+
)
1186+
1187+
devices: Dict[str, List[str]] = {}
1188+
for row in rows:
1189+
# Remote devices are never stale from our point of view.
1190+
if self.hs.is_mine_id(row["user_id"]):
1191+
user_devices = devices.setdefault(row["user_id"], [])
1192+
user_devices.append(row["device_id"])
1193+
1194+
return devices
1195+
11571196

11581197
class DeviceBackgroundUpdateStore(SQLBaseStore):
11591198
def __init__(

tests/rest/client/test_device_lists.py tests/rest/client/test_devices.py

+43
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,13 @@
1313
# limitations under the License.
1414
from http import HTTPStatus
1515

16+
from twisted.test.proto_helpers import MemoryReactor
17+
18+
from synapse.api.errors import NotFoundError
1619
from synapse.rest import admin, devices, room, sync
1720
from synapse.rest.client import account, login, register
21+
from synapse.server import HomeServer
22+
from synapse.util import Clock
1823

1924
from tests import unittest
2025

@@ -157,3 +162,41 @@ def test_not_receiving_local_device_list_changes(self) -> None:
157162
self.assertNotIn(
158163
alice_user_id, changed_device_lists, bob_sync_channel.json_body
159164
)
165+
166+
167+
class DevicesTestCase(unittest.HomeserverTestCase):
168+
servlets = [
169+
admin.register_servlets,
170+
login.register_servlets,
171+
sync.register_servlets,
172+
]
173+
174+
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
175+
self.handler = hs.get_device_handler()
176+
177+
@unittest.override_config({"delete_stale_devices_after": 72000000})
178+
def test_delete_stale_devices(self) -> None:
179+
"""Tests that stale devices are automatically removed after a set time of
180+
inactivity.
181+
The configuration is set to delete devices that haven't been used in the past 20h.
182+
"""
183+
# Register a user and creates 2 devices for them.
184+
user_id = self.register_user("user", "password")
185+
tok1 = self.login("user", "password", device_id="abc")
186+
tok2 = self.login("user", "password", device_id="def")
187+
188+
# Sync them so they have a last_seen value.
189+
self.make_request("GET", "/sync", access_token=tok1)
190+
self.make_request("GET", "/sync", access_token=tok2)
191+
192+
# Advance half a day and sync again with one of the devices, so that the next
193+
# time the background job runs we don't delete this device (since it will look
194+
# for devices that haven't been used for over an hour).
195+
self.reactor.advance(43200)
196+
self.make_request("GET", "/sync", access_token=tok1)
197+
198+
# Advance another half a day, and check that the device that has synced still
199+
# exists but the one that hasn't has been removed.
200+
self.reactor.advance(43200)
201+
self.get_success(self.handler.get_device(user_id, "abc"))
202+
self.get_failure(self.handler.get_device(user_id, "def"), NotFoundError)

0 commit comments

Comments
 (0)