diff --git a/synapse/config/server.py b/synapse/config/server.py
index f73d5e1f66..657322cb1f 100644
--- a/synapse/config/server.py
+++ b/synapse/config/server.py
@@ -679,6 +679,17 @@ class ServerConfig(Config):
config.get("exclude_rooms_from_sync") or []
)
+ delete_stale_devices_after: Optional[str] = (
+ config.get("delete_stale_devices_after") or None
+ )
+
+ if delete_stale_devices_after is not None:
+ self.delete_stale_devices_after: Optional[int] = self.parse_duration(
+ delete_stale_devices_after
+ )
+ else:
+ self.delete_stale_devices_after = None
+
def has_tls_listener(self) -> bool:
return any(listener.tls for listener in self.listeners)
diff --git a/synapse/handlers/device.py b/synapse/handlers/device.py
index 438a549339..2a56473dc6 100644
--- a/synapse/handlers/device.py
+++ b/synapse/handlers/device.py
@@ -61,6 +61,7 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
MAX_DEVICE_DISPLAY_NAME_LEN = 100
+DELETE_STALE_DEVICES_INTERVAL_MS = 24 * 60 * 60 * 1000
class DeviceWorkerHandler:
@@ -295,6 +296,19 @@ class DeviceHandler(DeviceWorkerHandler):
# On start up check if there are any updates pending.
hs.get_reactor().callWhenRunning(self._handle_new_device_update_async)
+ self._delete_stale_devices_after = hs.config.server.delete_stale_devices_after
+
+ # Ideally we would run this on a worker and condition this on the
+ # "run_background_tasks_on" setting, but this would mean making the notification
+ # of device list changes over federation work on workers, which is nontrivial.
+ if self._delete_stale_devices_after is not None:
+ self.clock.looping_call(
+ run_as_background_process,
+ DELETE_STALE_DEVICES_INTERVAL_MS,
+ "delete_stale_devices",
+ self._delete_stale_devices,
+ )
+
def _check_device_name_length(self, name: Optional[str]) -> None:
"""
Checks whether a device name is longer than the maximum allowed length.
@@ -370,6 +384,19 @@ class DeviceHandler(DeviceWorkerHandler):
raise errors.StoreError(500, "Couldn't generate a device ID.")
+ async def _delete_stale_devices(self) -> None:
+ """Background task that deletes devices which haven't been accessed for more than
+ a configured time period.
+ """
+ # We should only be running this job if the config option is defined.
+ assert self._delete_stale_devices_after is not None
+ now_ms = self.clock.time_msec()
+ since_ms = now_ms - self._delete_stale_devices_after
+ devices = await self.store.get_local_devices_not_accessed_since(since_ms)
+
+ for user_id, user_devices in devices.items():
+ await self.delete_devices(user_id, user_devices)
+
@trace
async def delete_device(self, user_id: str, device_id: str) -> None:
"""Delete the given device
@@ -692,7 +719,8 @@ class DeviceHandler(DeviceWorkerHandler):
)
# TODO: when called, this isn't in a logging context.
# This leads to log spam, sentry event spam, and massive
- # memory usage. See #12552.
+ # memory usage.
+ # See https://github.com/matrix-org/synapse/issues/12552.
# log_kv(
# {"message": "sent device update to host", "host": host}
# )
diff --git a/synapse/storage/databases/main/devices.py b/synapse/storage/databases/main/devices.py
index dd43bae784..d900064c07 100644
--- a/synapse/storage/databases/main/devices.py
+++ b/synapse/storage/databases/main/devices.py
@@ -1154,6 +1154,45 @@ class DeviceWorkerStore(SQLBaseStore):
_prune_txn,
)
+ async def get_local_devices_not_accessed_since(
+ self, since_ms: int
+ ) -> Dict[str, List[str]]:
+ """Retrieves local devices that haven't been accessed since a given date.
+
+ Args:
+ since_ms: the timestamp to select on, every device with a last access date
+ from before that time is returned.
+
+ Returns:
+ A dictionary with an entry for each user with at least one device matching
+ the request, which value is a list of the device ID(s) for the corresponding
+ device(s).
+ """
+
+ def get_devices_not_accessed_since_txn(
+ txn: LoggingTransaction,
+ ) -> List[Dict[str, str]]:
+ sql = """
+ SELECT user_id, device_id
+ FROM devices WHERE last_seen < ? AND hidden = FALSE
+ """
+ txn.execute(sql, (since_ms,))
+ return self.db_pool.cursor_to_dict(txn)
+
+ rows = await self.db_pool.runInteraction(
+ "get_devices_not_accessed_since",
+ get_devices_not_accessed_since_txn,
+ )
+
+ devices: Dict[str, List[str]] = {}
+ for row in rows:
+ # Remote devices are never stale from our point of view.
+ if self.hs.is_mine_id(row["user_id"]):
+ user_devices = devices.setdefault(row["user_id"], [])
+ user_devices.append(row["device_id"])
+
+ return devices
+
class DeviceBackgroundUpdateStore(SQLBaseStore):
def __init__(
|