From ce8b0747ba11e8e8fef3e206135c03f7c6841a10 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 17 Sep 2025 11:07:11 +0100 Subject: [PATCH 01/11] Use @cachedList with `_get_e2e_cross_signing_signatures_for_devices` The use of `@cachedList` requires having a cached, equivalent "single item" method that represents the key and cached value. Calls to methods with @cachedList are then processed and items that are already cached are removed before the @cachedList-decorated method is called. The single item method should not be directly used. The return type needed to be changed to a "Mapping" containing a "Sequence" to satisfy the requirement that cached results must be immutable (lest you risk mutating the cache). `@cachedList` also requires returning an dict where the keys matches that of the items in the cached input list. Our keys are tuples (and you'll later see why this is a pain). --- .../storage/databases/main/end_to_end_keys.py | 105 ++++++++++++------ 1 file changed, 74 insertions(+), 31 deletions(-) diff --git a/synapse/storage/databases/main/end_to_end_keys.py b/synapse/storage/databases/main/end_to_end_keys.py index de72e66cebb..0c6067afdac 100644 --- a/synapse/storage/databases/main/end_to_end_keys.py +++ b/synapse/storage/databases/main/end_to_end_keys.py @@ -478,42 +478,85 @@ def get_e2e_device_keys_txn( result.setdefault(user_id, {})[device_id] = None return result + + @cached() + def _get_e2e_cross_signing_signatures_for_device( + self, + user_id_and_device_id: Tuple[str, str], + ) -> Sequence[Tuple[str, str]]: + """ + The single-item version of `_get_e2e_cross_signing_signatures_for_devices`. + See @cachedList for why a separate method is needed. + """ + raise NotImplementedError() + + @cachedList( + cached_method_name="_get_e2e_cross_signing_signatures_for_device", + list_name="device_query", + ) + async def _get_e2e_cross_signing_signatures_for_devices( + self, device_query: Iterable[Tuple[str, str]] + ) -> Mapping[Tuple[str, str], Optional[Sequence[Tuple[str, str]]]]: + """Get cross-signing signatures for a given list of user IDs and devices. - def _get_e2e_cross_signing_signatures_for_devices_txn( - self, txn: LoggingTransaction, device_query: Iterable[Tuple[str, str]] - ) -> List[Tuple[str, str, str, str]]: - """Get cross-signing signatures for a given list of devices + Args: + An iterable containing tuples of (user ID, device ID). - Returns signatures made by the owners of the devices. + Returns: + A mapping of results. The keys are the original (user_id, device_id) + tuple, while the value is the matching list of tuples of + (key_id, signature). The value will be `None` instead if no + signatures exist for the device (this is a behaviour of + `@cachedList`). - Returns: a list of results; each entry in the list is a tuple of - (user_id, key_id, target_device_id, signature). + Given this method is annotated with `@cachedList`, the return dict's + keys match the tuples within `device_query`, so that cache entries can + be computed from the corresponding values. + + As results are cached, the return type is immutable. """ - signature_query_clauses = [] - signature_query_params = [] + def _get_e2e_cross_signing_signatures_for_devices_txn( + txn: LoggingTransaction, device_query: Iterable[Tuple[str, str]] + ) -> Mapping[Tuple[str, str], Sequence[Tuple[str, str]]]: + signature_query_clauses = [] + signature_query_params = [] + + for user_id, device_id in device_query: + signature_query_clauses.append( + "target_user_id = ? AND target_device_id = ? AND user_id = ?" + ) + signature_query_params.extend([user_id, device_id, user_id]) - for user_id, device_id in device_query: - signature_query_clauses.append( - "target_user_id = ? AND target_device_id = ? AND user_id = ?" - ) - signature_query_params.extend([user_id, device_id, user_id]) - - signature_sql = """ - SELECT user_id, key_id, target_device_id, signature - FROM e2e_cross_signing_signatures WHERE %s - """ % (" OR ".join("(" + q + ")" for q in signature_query_clauses)) - - txn.execute(signature_sql, signature_query_params) - return cast( - List[ - Tuple[ - str, - str, - str, - str, - ] - ], - txn.fetchall(), + signature_sql = """ + SELECT user_id, key_id, target_device_id, signature + FROM e2e_cross_signing_signatures WHERE %s + """ % (" OR ".join("(" + q + ")" for q in signature_query_clauses)) + + txn.execute(signature_sql, signature_query_params) + + devices_and_signatures: Dict[Tuple[str, str], List[Tuple[str, str]]] = {} + + # `@cachedList` requires we return one key for every item in `device_query`. + # Pre-populate `devices_and_signatures` with each key so that none are missing. + # + # If any are missing, they will be cached as `None`, which is not + # what callers expected. + for user_id, device_id in device_query: + devices_and_signatures.setdefault((user_id, device_id), []) + + # Populate the return dictionary with each found key_id and signature. + for user_id, key_id, target_device_id, signature in txn.fetchall(): + signature_tuple = (key_id, signature) + devices_and_signatures[(user_id, target_device_id)].append( + signature_tuple + ) + + return devices_and_signatures + + return await self.db_pool.runInteraction( + "_get_e2e_cross_signing_signatures_for_devices_txn", + _get_e2e_cross_signing_signatures_for_devices_txn, + device_query, ) async def get_e2e_one_time_keys( From 325632fdfccc4abbc3b55dbb71744a42365de126 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 17 Sep 2025 11:12:52 +0100 Subject: [PATCH 02/11] Update the calling site The return type has changed, and we now need to handle a map of user_id/device_id tuples to lists of key signatures. --- .../storage/databases/main/end_to_end_keys.py | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/synapse/storage/databases/main/end_to_end_keys.py b/synapse/storage/databases/main/end_to_end_keys.py index 0c6067afdac..244eb09d2bd 100644 --- a/synapse/storage/databases/main/end_to_end_keys.py +++ b/synapse/storage/databases/main/end_to_end_keys.py @@ -354,15 +354,17 @@ async def get_e2e_device_keys_and_signatures( ) for batch in batch_iter(signature_query, 50): - cross_sigs_result = await self.db_pool.runInteraction( - "get_e2e_cross_signing_signatures_for_devices", - self._get_e2e_cross_signing_signatures_for_devices_txn, - batch, + cross_sigs_result = ( + await self._get_e2e_cross_signing_signatures_for_devices(batch) ) # add each cross-signing signature to the correct device in the result dict. - for user_id, key_id, device_id, signature in cross_sigs_result: + for ( + user_id, + device_id, + ), signature_list in cross_sigs_result.items(): target_device_result = result[user_id][device_id] + # We've only looked up cross-signatures for non-deleted devices with key # data. assert target_device_result is not None @@ -373,7 +375,15 @@ async def get_e2e_device_keys_and_signatures( signing_user_signatures = target_device_signatures.setdefault( user_id, {} ) - signing_user_signatures[key_id] = signature + + if signature_list is None: + # There are no signatures for this user_id/device_id combination. + # We do this here to ensure that the "signatures" key gets created above, + # even if it is empty. + continue + + for key_id, signature in signature_list: + signing_user_signatures[key_id] = signature log_kv(result) return result From 6ffd8b91199e075fa37fdd361c166ef14caecc45 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 17 Sep 2025 11:14:40 +0100 Subject: [PATCH 03/11] Make store_e2e_cross_signing_signatures use a custom txn func This is needed to then call `_invalidate_cache_and_stream_bulk with the `txn` object in the next commit. --- .../storage/databases/main/end_to_end_keys.py | 52 +++++++++++-------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/synapse/storage/databases/main/end_to_end_keys.py b/synapse/storage/databases/main/end_to_end_keys.py index 244eb09d2bd..a122e52ab5a 100644 --- a/synapse/storage/databases/main/end_to_end_keys.py +++ b/synapse/storage/databases/main/end_to_end_keys.py @@ -1825,28 +1825,38 @@ async def store_e2e_cross_signing_signatures( user_id: the user who made the signatures signatures: signatures to add """ - await self.db_pool.simple_insert_many( - "e2e_cross_signing_signatures", - keys=( - "user_id", - "key_id", - "target_user_id", - "target_device_id", - "signature", - ), - values=[ - ( - user_id, - item.signing_key_id, - item.target_user_id, - item.target_device_id, - item.signature, - ) - for item in signatures - ], - desc="add_e2e_signing_key", - ) + def _store_e2e_cross_signing_signatures( + txn: LoggingTransaction, + signatures: "Iterable[SignatureListItem]", + ) -> None: + self.db_pool.simple_insert_many_txn( + txn, + "e2e_cross_signing_signatures", + keys=( + "user_id", + "key_id", + "target_user_id", + "target_device_id", + "signature", + ), + values=[ + ( + user_id, + item.signing_key_id, + item.target_user_id, + item.target_device_id, + item.signature, + ) + for item in signatures + ], + ) + await self.db_pool.runInteraction( + "add_e2e_signing_key", + _store_e2e_cross_signing_signatures, + signatures, + ) + class EndToEndKeyStore(EndToEndKeyWorkerStore, SQLBaseStore): def __init__( From 1ea7cf0c57cafa66c718097c750739299d74f677 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 17 Sep 2025 11:36:36 +0100 Subject: [PATCH 04/11] Invalidate cross signing signatures cache Invalidate and stream whenever we update `e2e_cross_signing_signatures`. --- .../storage/databases/main/end_to_end_keys.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/synapse/storage/databases/main/end_to_end_keys.py b/synapse/storage/databases/main/end_to_end_keys.py index a122e52ab5a..61a6c4b6841 100644 --- a/synapse/storage/databases/main/end_to_end_keys.py +++ b/synapse/storage/databases/main/end_to_end_keys.py @@ -1851,12 +1851,27 @@ def _store_e2e_cross_signing_signatures( ], ) + to_invalidate = [ + # Each entry is a tuple of arguments to + # `_get_e2e_cross_signing_signatures_for_device`, which + # itself takes a tuple. Hence the double-tuple. + ((user_id, item.target_device_id),) + for item in signatures + ] + + if to_invalidate: + self._invalidate_cache_and_stream_bulk( + txn, + self._get_e2e_cross_signing_signatures_for_device, + to_invalidate, + ) + await self.db_pool.runInteraction( "add_e2e_signing_key", _store_e2e_cross_signing_signatures, signatures, ) - + class EndToEndKeyStore(EndToEndKeyWorkerStore, SQLBaseStore): def __init__( From 21c2c0403c4ae77b4556628c3f9a35581125bab5 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 17 Sep 2025 11:46:25 +0100 Subject: [PATCH 05/11] Special-case handling of cache invalidation stream As the "keys" in an incoming cache invalidation stream object appear to always be a string, we have to do some special case handling to convert the string into a tuple of (user_id, device_id). This works, but is awful. --- synapse/storage/databases/main/cache.py | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/synapse/storage/databases/main/cache.py b/synapse/storage/databases/main/cache.py index 77949268125..d7b5a12ae4a 100644 --- a/synapse/storage/databases/main/cache.py +++ b/synapse/storage/databases/main/cache.py @@ -62,6 +62,12 @@ # As above, but for invalidating room caches on room deletion DELETE_ROOM_CACHE_NAME = "dr_cache_fake" +# This cache takes a list of tuples as its first argument, which requires +# special handling. +GET_E2E_CROSS_SIGNING_SIGNATURES_FOR_DEVICE_CACHE_NAME = ( + "_get_e2e_cross_signing_signatures_for_device" +) + # How long between cache invalidation table cleanups, once we have caught up # with the backlog. REGULAR_CLEANUP_INTERVAL_MS = Config.parse_duration("1h") @@ -270,6 +276,30 @@ def process_replication_rows( # room membership. # # self._membership_stream_cache.all_entities_changed(token) # type: ignore[attr-defined] + elif ( + row.cache_func + == GET_E2E_CROSS_SIGNING_SIGNATURES_FOR_DEVICE_CACHE_NAME + ): + # "keys" is a list of strings, where each string is a + # stringified representation of the tuple keys, i.e. + # keys: ['(@userid:domain,DEVICEID)','(@userid2:domain,DEVICEID2)'] + # + # This is a side-effect of not being able to send nested information over replication. + for tuple_key in row.keys: + user_id, device_id = ( + # Remove the leading and following parantheses. + tuple_key[1:-1] + # Split by comma + .split(",") + ) + + # Invalidate each key. + # + # Note: .invalidate takes a tuple of arguments, hence the need + # to nest our tuple in another tuple. + self._get_e2e_cross_signing_signatures_for_device.invalidate( # type: ignore[attr-defined] + ((user_id, device_id),) + ) else: self._attempt_to_invalidate_cache(row.cache_func, row.keys) From b4ff74c2f8a121b30f2950d16684d7a46ad98339 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 17 Sep 2025 11:47:37 +0100 Subject: [PATCH 06/11] Clarify `@cachedList` None-caching behaviour --- synapse/util/caches/descriptors.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/synapse/util/caches/descriptors.py b/synapse/util/caches/descriptors.py index 9630cd6d26b..47b8f4ddc81 100644 --- a/synapse/util/caches/descriptors.py +++ b/synapse/util/caches/descriptors.py @@ -579,9 +579,12 @@ def cachedList( Used to do batch lookups for an already created cache. One of the arguments is specified as a list that is iterated through to lookup keys in the original cache. A new tuple consisting of the (deduplicated) keys that weren't in - the cache gets passed to the original function, which is expected to results + the cache gets passed to the original function, which is expected to result in a map of key to value for each passed value. The new results are stored in the - original cache. Note that any missing values are cached as None. + original cache. + + Note that any values in the input that end up being missing from both the + cache and the returned dictionary will be cached as `None`. Args: cached_method_name: The name of the single-item lookup method. From 3ec9f164f252cfd32fc601ec587b614e990cc146 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 17 Sep 2025 11:48:20 +0100 Subject: [PATCH 07/11] newsfile --- changelog.d/18899.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/18899.feature diff --git a/changelog.d/18899.feature b/changelog.d/18899.feature new file mode 100644 index 00000000000..ee7141efc55 --- /dev/null +++ b/changelog.d/18899.feature @@ -0,0 +1 @@ +Add an in-memory cache to `_get_e2e_cross_signing_signatures_for_devices` to reduce DB load. \ No newline at end of file From a0969675072cecd7cf54a68187e55c962d84616d Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 17 Sep 2025 11:50:50 +0100 Subject: [PATCH 08/11] lint --- synapse/storage/databases/main/end_to_end_keys.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/synapse/storage/databases/main/end_to_end_keys.py b/synapse/storage/databases/main/end_to_end_keys.py index 61a6c4b6841..d9e66c9f555 100644 --- a/synapse/storage/databases/main/end_to_end_keys.py +++ b/synapse/storage/databases/main/end_to_end_keys.py @@ -488,7 +488,7 @@ def get_e2e_device_keys_txn( result.setdefault(user_id, {})[device_id] = None return result - + @cached() def _get_e2e_cross_signing_signatures_for_device( self, @@ -499,7 +499,7 @@ def _get_e2e_cross_signing_signatures_for_device( See @cachedList for why a separate method is needed. """ raise NotImplementedError() - + @cachedList( cached_method_name="_get_e2e_cross_signing_signatures_for_device", list_name="device_query", @@ -525,6 +525,7 @@ async def _get_e2e_cross_signing_signatures_for_devices( As results are cached, the return type is immutable. """ + def _get_e2e_cross_signing_signatures_for_devices_txn( txn: LoggingTransaction, device_query: Iterable[Tuple[str, str]] ) -> Mapping[Tuple[str, str], Sequence[Tuple[str, str]]]: @@ -1825,6 +1826,7 @@ async def store_e2e_cross_signing_signatures( user_id: the user who made the signatures signatures: signatures to add """ + def _store_e2e_cross_signing_signatures( txn: LoggingTransaction, signatures: "Iterable[SignatureListItem]", From c6534e66b1cbaeef33017453ddd77fa793029d44 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 17 Sep 2025 16:46:07 +0100 Subject: [PATCH 09/11] JSON encode replication keys Instead of encoding them using a brittle, custom scheme. Avoids errors where the device ID or user ID contains a comma. --- synapse/storage/databases/main/cache.py | 24 ++++++++++------- .../storage/databases/main/end_to_end_keys.py | 26 ++++++++++++++++--- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/synapse/storage/databases/main/cache.py b/synapse/storage/databases/main/cache.py index d7b5a12ae4a..cad26fefa4b 100644 --- a/synapse/storage/databases/main/cache.py +++ b/synapse/storage/databases/main/cache.py @@ -21,6 +21,7 @@ import itertools +import json import logging from typing import TYPE_CHECKING, Any, Collection, Iterable, List, Optional, Tuple @@ -281,17 +282,20 @@ def process_replication_rows( == GET_E2E_CROSS_SIGNING_SIGNATURES_FOR_DEVICE_CACHE_NAME ): # "keys" is a list of strings, where each string is a - # stringified representation of the tuple keys, i.e. - # keys: ['(@userid:domain,DEVICEID)','(@userid2:domain,DEVICEID2)'] + # JSON-encoded representation of the tuple keys, i.e. + # keys: ['["@userid:domain", "DEVICEID"]','["@userid2:domain", "DEVICEID2"]'] # - # This is a side-effect of not being able to send nested information over replication. - for tuple_key in row.keys: - user_id, device_id = ( - # Remove the leading and following parantheses. - tuple_key[1:-1] - # Split by comma - .split(",") - ) + # This is a side-effect of not being able to send nested + # information over replication. + for json_str in row.keys: + try: + user_id, device_id = json.loads(json_str) + except (json.JSONDecodeError, TypeError): + logger.error( + "Failed to deserialise cache key as valid JSON: %s", + json_str, + ) + continue # Invalidate each key. # diff --git a/synapse/storage/databases/main/end_to_end_keys.py b/synapse/storage/databases/main/end_to_end_keys.py index d9e66c9f555..d979fd2a57b 100644 --- a/synapse/storage/databases/main/end_to_end_keys.py +++ b/synapse/storage/databases/main/end_to_end_keys.py @@ -20,6 +20,7 @@ # # import abc +import json from typing import ( TYPE_CHECKING, Any, @@ -1862,10 +1863,29 @@ def _store_e2e_cross_signing_signatures( ] if to_invalidate: - self._invalidate_cache_and_stream_bulk( + # Invalidate the local cache of this worker. + for cache_key in to_invalidate: + txn.call_after( + self._get_e2e_cross_signing_signatures_for_device.invalidate, + cache_key, + ) + + # Stream cache invalidate keys over replication. + # + # We can only send a primitive per function argument across + # replication. + # + # Encode the array of strings as a JSON string, and we'll unpack + # it on the other side. + to_send = [ + (json.dumps([user_id, item.target_device_id]),) + for item in signatures + ] + + self._send_invalidation_to_replication_bulk( txn, - self._get_e2e_cross_signing_signatures_for_device, - to_invalidate, + cache_name=self._get_e2e_cross_signing_signatures_for_device.__name__, + key_tuples=to_send, ) await self.db_pool.runInteraction( From 90387cc45fdd1d7580be56176a34f25da7c84841 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 17 Sep 2025 18:44:10 +0100 Subject: [PATCH 10/11] Use `make_tuple_in_list_sql_clause` Required defining a three-column overload for the method to pass mypy. Additionally the type-ignore was no longer required according to mypy. --- synapse/storage/database.py | 11 +++++++-- .../storage/databases/main/end_to_end_keys.py | 23 +++++++++---------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/synapse/storage/database.py b/synapse/storage/database.py index cfec36e0fa1..aae029f9105 100644 --- a/synapse/storage/database.py +++ b/synapse/storage/database.py @@ -2653,8 +2653,7 @@ def make_in_list_sql_clause( # These overloads ensure that `columns` and `iterable` values have the same length. -# Suppress "Single overload definition, multiple required" complaint. -@overload # type: ignore[misc] +@overload def make_tuple_in_list_sql_clause( database_engine: BaseDatabaseEngine, columns: Tuple[str, str], @@ -2662,6 +2661,14 @@ def make_tuple_in_list_sql_clause( ) -> Tuple[str, list]: ... +@overload +def make_tuple_in_list_sql_clause( + database_engine: BaseDatabaseEngine, + columns: Tuple[str, str, str], + iterable: Collection[Tuple[Any, Any, Any]], +) -> Tuple[str, list]: ... + + def make_tuple_in_list_sql_clause( database_engine: BaseDatabaseEngine, columns: Tuple[str, ...], diff --git a/synapse/storage/databases/main/end_to_end_keys.py b/synapse/storage/databases/main/end_to_end_keys.py index d979fd2a57b..df4aebfcfc3 100644 --- a/synapse/storage/databases/main/end_to_end_keys.py +++ b/synapse/storage/databases/main/end_to_end_keys.py @@ -530,21 +530,20 @@ async def _get_e2e_cross_signing_signatures_for_devices( def _get_e2e_cross_signing_signatures_for_devices_txn( txn: LoggingTransaction, device_query: Iterable[Tuple[str, str]] ) -> Mapping[Tuple[str, str], Sequence[Tuple[str, str]]]: - signature_query_clauses = [] - signature_query_params = [] - - for user_id, device_id in device_query: - signature_query_clauses.append( - "target_user_id = ? AND target_device_id = ? AND user_id = ?" - ) - signature_query_params.extend([user_id, device_id, user_id]) + where_clause_sql, where_clause_params = make_tuple_in_list_sql_clause( + self.database_engine, + columns=("target_user_id", "target_device_id", "user_id"), + iterable=[ + (user_id, device_id, user_id) for user_id, device_id in device_query + ], + ) - signature_sql = """ + signature_sql = f""" SELECT user_id, key_id, target_device_id, signature - FROM e2e_cross_signing_signatures WHERE %s - """ % (" OR ".join("(" + q + ")" for q in signature_query_clauses)) + FROM e2e_cross_signing_signatures WHERE {where_clause_sql} + """ - txn.execute(signature_sql, signature_query_params) + txn.execute(signature_sql, where_clause_params) devices_and_signatures: Dict[Tuple[str, str], List[Tuple[str, str]]] = {} From e7ca69d6b2bd34eaa103d36afede3bdbcd294261 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 17 Sep 2025 18:50:15 +0100 Subject: [PATCH 11/11] Remove `None` handling Since `_get_e2e_cross_signing_signatures_for_devices` always return a key for every entry in `device_query`, there will never be a case where the function can return `None`, even when decorated with `@cacheList`. --- synapse/storage/databases/main/end_to_end_keys.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/synapse/storage/databases/main/end_to_end_keys.py b/synapse/storage/databases/main/end_to_end_keys.py index df4aebfcfc3..17ccefe6b5b 100644 --- a/synapse/storage/databases/main/end_to_end_keys.py +++ b/synapse/storage/databases/main/end_to_end_keys.py @@ -377,12 +377,6 @@ async def get_e2e_device_keys_and_signatures( user_id, {} ) - if signature_list is None: - # There are no signatures for this user_id/device_id combination. - # We do this here to ensure that the "signatures" key gets created above, - # even if it is empty. - continue - for key_id, signature in signature_list: signing_user_signatures[key_id] = signature @@ -507,7 +501,7 @@ def _get_e2e_cross_signing_signatures_for_device( ) async def _get_e2e_cross_signing_signatures_for_devices( self, device_query: Iterable[Tuple[str, str]] - ) -> Mapping[Tuple[str, str], Optional[Sequence[Tuple[str, str]]]]: + ) -> Mapping[Tuple[str, str], Sequence[Tuple[str, str]]]: """Get cross-signing signatures for a given list of user IDs and devices. Args: @@ -516,9 +510,8 @@ async def _get_e2e_cross_signing_signatures_for_devices( Returns: A mapping of results. The keys are the original (user_id, device_id) tuple, while the value is the matching list of tuples of - (key_id, signature). The value will be `None` instead if no - signatures exist for the device (this is a behaviour of - `@cachedList`). + (key_id, signature). The value will be an empty list if no + signatures exist for the device. Given this method is annotated with `@cachedList`, the return dict's keys match the tuples within `device_query`, so that cache entries can