From 1315a2e8be702a513d49c1142e9e52b642286635 Mon Sep 17 00:00:00 2001 From: Erik Johnston <erik@matrix.org> Date: Mon, 11 Jan 2021 16:09:22 +0000 Subject: [PATCH] Use a chain cover index to efficiently calculate auth chain difference (#8868) --- changelog.d/8868.misc | 1 + docs/auth_chain_diff.dot | 32 ++ docs/auth_chain_diff.dot.png | Bin 0 -> 42427 bytes docs/auth_chain_difference_algorithm.md | 108 ++++ synapse/storage/database.py | 22 +- .../databases/main/event_federation.py | 185 ++++++ synapse/storage/databases/main/events.py | 535 +++++++++++++++++- synapse/storage/databases/main/room.py | 51 +- .../schema/delta/59/04_event_auth_chains.sql | 52 ++ .../59/04_event_auth_chains.sql.postgres | 16 + synapse/util/iterutils.py | 53 +- tests/storage/test_event_chain.py | 472 +++++++++++++++ tests/storage/test_event_federation.py | 249 +++++++- tests/util/test_itertools.py | 41 +- 14 files changed, 1769 insertions(+), 48 deletions(-) create mode 100644 changelog.d/8868.misc create mode 100644 docs/auth_chain_diff.dot create mode 100644 docs/auth_chain_diff.dot.png create mode 100644 docs/auth_chain_difference_algorithm.md create mode 100644 synapse/storage/databases/main/schema/delta/59/04_event_auth_chains.sql create mode 100644 synapse/storage/databases/main/schema/delta/59/04_event_auth_chains.sql.postgres create mode 100644 tests/storage/test_event_chain.py diff --git a/changelog.d/8868.misc b/changelog.d/8868.misc new file mode 100644 index 0000000000..1a11e30944 --- /dev/null +++ b/changelog.d/8868.misc @@ -0,0 +1 @@ +Improve efficiency of large state resolutions for new rooms. diff --git a/docs/auth_chain_diff.dot b/docs/auth_chain_diff.dot new file mode 100644 index 0000000000..978d579ada --- /dev/null +++ b/docs/auth_chain_diff.dot @@ -0,0 +1,32 @@ +digraph auth { + nodesep=0.5; + rankdir="RL"; + + C [label="Create (1,1)"]; + + BJ [label="Bob's Join (2,1)", color=red]; + BJ2 [label="Bob's Join (2,2)", color=red]; + BJ2 -> BJ [color=red, dir=none]; + + subgraph cluster_foo { + A1 [label="Alice's invite (4,1)", color=blue]; + A2 [label="Alice's Join (4,2)", color=blue]; + A3 [label="Alice's Join (4,3)", color=blue]; + A3 -> A2 -> A1 [color=blue, dir=none]; + color=none; + } + + PL1 [label="Power Level (3,1)", color=darkgreen]; + PL2 [label="Power Level (3,2)", color=darkgreen]; + PL2 -> PL1 [color=darkgreen, dir=none]; + + {rank = same; C; BJ; PL1; A1;} + + A1 -> C [color=grey]; + A1 -> BJ [color=grey]; + PL1 -> C [color=grey]; + BJ2 -> PL1 [penwidth=2]; + + A3 -> PL2 [penwidth=2]; + A1 -> PL1 -> BJ -> C [penwidth=2]; +} diff --git a/docs/auth_chain_diff.dot.png b/docs/auth_chain_diff.dot.png new file mode 100644 index 0000000000000000000000000000000000000000..771c07308f08e21900cb8e1190471f803ce1ab04 GIT binary patch literal 42427 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 literal 0 HcmV?d00001 diff --git a/docs/auth_chain_difference_algorithm.md b/docs/auth_chain_difference_algorithm.md new file mode 100644 index 0000000000..30f72a70da --- /dev/null +++ b/docs/auth_chain_difference_algorithm.md @@ -0,0 +1,108 @@ +# Auth Chain Difference Algorithm + +The auth chain difference algorithm is used by V2 state resolution, where a +naive implementation can be a significant source of CPU and DB usage. + +### Definitions + +A *state set* is a set of state events; e.g. the input of a state resolution +algorithm is a collection of state sets. + +The *auth chain* of a set of events are all the events' auth events and *their* +auth events, recursively (i.e. the events reachable by walking the graph induced +by an event's auth events links). + +The *auth chain difference* of a collection of state sets is the union minus the +intersection of the sets of auth chains corresponding to the state sets, i.e an +event is in the auth chain difference if it is reachable by walking the auth +event graph from at least one of the state sets but not from *all* of the state +sets. + +## Breadth First Walk Algorithm + +A way of calculating the auth chain difference without calculating the full auth +chains for each state set is to do a parallel breadth first walk (ordered by +depth) of each state set's auth chain. By tracking which events are reachable +from each state set we can finish early if every pending event is reachable from +every state set. + +This can work well for state sets that have a small auth chain difference, but +can be very inefficient for larger differences. However, this algorithm is still +used if we don't have a chain cover index for the room (e.g. because we're in +the process of indexing it). + +## Chain Cover Index + +Synapse computes auth chain differences by pre-computing a "chain cover" index +for the auth chain in a room, allowing efficient reachability queries like "is +event A in the auth chain of event B". This is done by assigning every event a +*chain ID* and *sequence number* (e.g. `(5,3)`), and having a map of *links* +between chains (e.g. `(5,3) -> (2,4)`) such that A is reachable by B (i.e. `A` +is in the auth chain of `B`) if and only if either: + +1. A and B have the same chain ID and `A`'s sequence number is less than `B`'s + sequence number; or +2. there is a link `L` between `B`'s chain ID and `A`'s chain ID such that + `L.start_seq_no` <= `B.seq_no` and `A.seq_no` <= `L.end_seq_no`. + +There are actually two potential implementations, one where we store links from +each chain to every other reachable chain (the transitive closure of the links +graph), and one where we remove redundant links (the transitive reduction of the +links graph) e.g. if we have chains `C3 -> C2 -> C1` then the link `C3 -> C1` +would not be stored. Synapse uses the former implementations so that it doesn't +need to recurse to test reachability between chains. + +### Example + +An example auth graph would look like the following, where chains have been +formed based on type/state_key and are denoted by colour and are labelled with +`(chain ID, sequence number)`. Links are denoted by the arrows (links in grey +are those that would be remove in the second implementation described above). + + + +Note that we don't include all links between events and their auth events, as +most of those links would be redundant. For example, all events point to the +create event, but each chain only needs the one link from it's base to the +create event. + +## Using the Index + +This index can be used to calculate the auth chain difference of the state sets +by looking at the chain ID and sequence numbers reachable from each state set: + +1. For every state set lookup the chain ID/sequence numbers of each state event +2. Use the index to find all chains and the maximum sequence number reachable + from each state set. +3. The auth chain difference is then all events in each chain that have sequence + numbers between the maximum sequence number reachable from *any* state set and + the minimum reachable by *all* state sets (if any). + +Note that steps 2 is effectively calculating the auth chain for each state set +(in terms of chain IDs and sequence numbers), and step 3 is calculating the +difference between the union and intersection of the auth chains. + +### Worked Example + +For example, given the above graph, we can calculate the difference between +state sets consisting of: + +1. `S1`: Alice's invite `(4,1)` and Bob's second join `(2,2)`; and +2. `S2`: Alice's second join `(4,3)` and Bob's first join `(2,1)`. + +Using the index we see that the following auth chains are reachable from each +state set: + +1. `S1`: `(1,1)`, `(2,2)`, `(3,1)` & `(4,1)` +2. `S2`: `(1,1)`, `(2,1)`, `(3,2)` & `(4,3)` + +And so, for each the ranges that are in the auth chain difference: +1. Chain 1: None, (since everything can reach the create event). +2. Chain 2: The range `(1, 2]` (i.e. just `2`), as `1` is reachable by all state + sets and the maximum reachable is `2` (corresponding to Bob's second join). +3. Chain 3: Similarly the range `(1, 2]` (corresponding to the second power + level). +4. Chain 4: The range `(1, 3]` (corresponding to both of Alice's joins). + +So the final result is: Bob's second join `(2,2)`, the second power level +`(3,2)` and both of Alice's joins `(4,2)` & `(4,3)`. diff --git a/synapse/storage/database.py b/synapse/storage/database.py index b70ca3087b..6cfadc2b4e 100644 --- a/synapse/storage/database.py +++ b/synapse/storage/database.py @@ -179,6 +179,9 @@ class LoggingDatabaseConnection: _CallbackListEntry = Tuple["Callable[..., None]", Iterable[Any], Dict[str, Any]] +R = TypeVar("R") + + class LoggingTransaction: """An object that almost-transparently proxies for the 'txn' object passed to the constructor. Adds logging and metrics to the .execute() @@ -266,6 +269,20 @@ class LoggingTransaction: for val in args: self.execute(sql, val) + def execute_values(self, sql: str, *args: Any) -> List[Tuple]: + """Corresponds to psycopg2.extras.execute_values. Only available when + using postgres. + + Always sets fetch=True when caling `execute_values`, so will return the + results. + """ + assert isinstance(self.database_engine, PostgresEngine) + from psycopg2.extras import execute_values # type: ignore + + return self._do_execute( + lambda *x: execute_values(self.txn, *x, fetch=True), sql, *args + ) + def execute(self, sql: str, *args: Any) -> None: self._do_execute(self.txn.execute, sql, *args) @@ -276,7 +293,7 @@ class LoggingTransaction: "Strip newlines out of SQL so that the loggers in the DB are on one line" return " ".join(line.strip() for line in sql.splitlines() if line.strip()) - def _do_execute(self, func, sql: str, *args: Any) -> None: + def _do_execute(self, func: Callable[..., R], sql: str, *args: Any) -> R: sql = self._make_sql_one_line(sql) # TODO(paul): Maybe use 'info' and 'debug' for values? @@ -347,9 +364,6 @@ class PerformanceCounters: return top_n_counters -R = TypeVar("R") - - class DatabasePool: """Wraps a single physical database and connection pool. diff --git a/synapse/storage/databases/main/event_federation.py b/synapse/storage/databases/main/event_federation.py index ebffd89251..8326640d20 100644 --- a/synapse/storage/databases/main/event_federation.py +++ b/synapse/storage/databases/main/event_federation.py @@ -24,6 +24,8 @@ from synapse.storage._base import SQLBaseStore, make_in_list_sql_clause from synapse.storage.database import DatabasePool, LoggingTransaction from synapse.storage.databases.main.events_worker import EventsWorkerStore from synapse.storage.databases.main.signatures import SignatureWorkerStore +from synapse.storage.engines import PostgresEngine +from synapse.storage.types import Cursor from synapse.types import Collection from synapse.util.caches.descriptors import cached from synapse.util.caches.lrucache import LruCache @@ -32,6 +34,11 @@ from synapse.util.iterutils import batch_iter logger = logging.getLogger(__name__) +class _NoChainCoverIndex(Exception): + def __init__(self, room_id: str): + super().__init__("Unexpectedly no chain cover for events in %s" % (room_id,)) + + class EventFederationWorkerStore(EventsWorkerStore, SignatureWorkerStore, SQLBaseStore): def __init__(self, database: DatabasePool, db_conn, hs): super().__init__(database, db_conn, hs) @@ -151,15 +158,193 @@ class EventFederationWorkerStore(EventsWorkerStore, SignatureWorkerStore, SQLBas The set of the difference in auth chains. """ + # Check if we have indexed the room so we can use the chain cover + # algorithm. + room = await self.get_room(room_id) + if room["has_auth_chain_index"]: + try: + return await self.db_pool.runInteraction( + "get_auth_chain_difference_chains", + self._get_auth_chain_difference_using_cover_index_txn, + room_id, + state_sets, + ) + except _NoChainCoverIndex: + # For whatever reason we don't actually have a chain cover index + # for the events in question, so we fall back to the old method. + pass + return await self.db_pool.runInteraction( "get_auth_chain_difference", self._get_auth_chain_difference_txn, state_sets, ) + def _get_auth_chain_difference_using_cover_index_txn( + self, txn: Cursor, room_id: str, state_sets: List[Set[str]] + ) -> Set[str]: + """Calculates the auth chain difference using the chain index. + + See docs/auth_chain_difference_algorithm.md for details + """ + + # First we look up the chain ID/sequence numbers for all the events, and + # work out the chain/sequence numbers reachable from each state set. + + initial_events = set(state_sets[0]).union(*state_sets[1:]) + + # Map from event_id -> (chain ID, seq no) + chain_info = {} # type: Dict[str, Tuple[int, int]] + + # Map from chain ID -> seq no -> event Id + chain_to_event = {} # type: Dict[int, Dict[int, str]] + + # All the chains that we've found that are reachable from the state + # sets. + seen_chains = set() # type: Set[int] + + sql = """ + SELECT event_id, chain_id, sequence_number + FROM event_auth_chains + WHERE %s + """ + for batch in batch_iter(initial_events, 1000): + clause, args = make_in_list_sql_clause( + txn.database_engine, "event_id", batch + ) + txn.execute(sql % (clause,), args) + + for event_id, chain_id, sequence_number in txn: + chain_info[event_id] = (chain_id, sequence_number) + seen_chains.add(chain_id) + chain_to_event.setdefault(chain_id, {})[sequence_number] = event_id + + # Check that we actually have a chain ID for all the events. + events_missing_chain_info = initial_events.difference(chain_info) + if events_missing_chain_info: + # This can happen due to e.g. downgrade/upgrade of the server. We + # raise an exception and fall back to the previous algorithm. + logger.info( + "Unexpectedly found that events don't have chain IDs in room %s: %s", + room_id, + events_missing_chain_info, + ) + raise _NoChainCoverIndex(room_id) + + # Corresponds to `state_sets`, except as a map from chain ID to max + # sequence number reachable from the state set. + set_to_chain = [] # type: List[Dict[int, int]] + for state_set in state_sets: + chains = {} # type: Dict[int, int] + set_to_chain.append(chains) + + for event_id in state_set: + chain_id, seq_no = chain_info[event_id] + + chains[chain_id] = max(seq_no, chains.get(chain_id, 0)) + + # Now we look up all links for the chains we have, adding chains to + # set_to_chain that are reachable from each set. + sql = """ + SELECT + origin_chain_id, origin_sequence_number, + target_chain_id, target_sequence_number + FROM event_auth_chain_links + WHERE %s + """ + + # (We need to take a copy of `seen_chains` as we want to mutate it in + # the loop) + for batch in batch_iter(set(seen_chains), 1000): + clause, args = make_in_list_sql_clause( + txn.database_engine, "origin_chain_id", batch + ) + txn.execute(sql % (clause,), args) + + for ( + origin_chain_id, + origin_sequence_number, + target_chain_id, + target_sequence_number, + ) in txn: + for chains in set_to_chain: + # chains are only reachable if the origin sequence number of + # the link is less than the max sequence number in the + # origin chain. + if origin_sequence_number <= chains.get(origin_chain_id, 0): + chains[target_chain_id] = max( + target_sequence_number, chains.get(target_chain_id, 0), + ) + + seen_chains.add(target_chain_id) + + # Now for each chain we figure out the maximum sequence number reachable + # from *any* state set and the minimum sequence number reachable from + # *all* state sets. Events in that range are in the auth chain + # difference. + result = set() + + # Mapping from chain ID to the range of sequence numbers that should be + # pulled from the database. + chain_to_gap = {} # type: Dict[int, Tuple[int, int]] + + for chain_id in seen_chains: + min_seq_no = min(chains.get(chain_id, 0) for chains in set_to_chain) + max_seq_no = max(chains.get(chain_id, 0) for chains in set_to_chain) + + if min_seq_no < max_seq_no: + # We have a non empty gap, try and fill it from the events that + # we have, otherwise add them to the list of gaps to pull out + # from the DB. + for seq_no in range(min_seq_no + 1, max_seq_no + 1): + event_id = chain_to_event.get(chain_id, {}).get(seq_no) + if event_id: + result.add(event_id) + else: + chain_to_gap[chain_id] = (min_seq_no, max_seq_no) + break + + if not chain_to_gap: + # If there are no gaps to fetch, we're done! + return result + + if isinstance(self.database_engine, PostgresEngine): + # We can use `execute_values` to efficiently fetch the gaps when + # using postgres. + sql = """ + SELECT event_id + FROM event_auth_chains AS c, (VALUES ?) AS l(chain_id, min_seq, max_seq) + WHERE + c.chain_id = l.chain_id + AND min_seq < sequence_number AND sequence_number <= max_seq + """ + + args = [ + (chain_id, min_no, max_no) + for chain_id, (min_no, max_no) in chain_to_gap.items() + ] + + rows = txn.execute_values(sql, args) + result.update(r for r, in rows) + else: + # For SQLite we just fall back to doing a noddy for loop. + sql = """ + SELECT event_id FROM event_auth_chains + WHERE chain_id = ? AND ? < sequence_number AND sequence_number <= ? + """ + for chain_id, (min_no, max_no) in chain_to_gap.items(): + txn.execute(sql, (chain_id, min_no, max_no)) + result.update(r for r, in txn) + + return result + def _get_auth_chain_difference_txn( self, txn, state_sets: List[Set[str]] ) -> Set[str]: + """Calculates the auth chain difference using a breadth first search. + + This is used when we don't have a cover index for the room. + """ # Algorithm Description # ~~~~~~~~~~~~~~~~~~~~~ diff --git a/synapse/storage/databases/main/events.py b/synapse/storage/databases/main/events.py index 5e7753e09b..186f064036 100644 --- a/synapse/storage/databases/main/events.py +++ b/synapse/storage/databases/main/events.py @@ -17,7 +17,17 @@ import itertools import logging from collections import OrderedDict, namedtuple -from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Set, Tuple +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Generator, + Iterable, + List, + Optional, + Set, + Tuple, +) import attr from prometheus_client import Counter @@ -33,9 +43,10 @@ from synapse.storage._base import db_to_json, make_in_list_sql_clause from synapse.storage.database import DatabasePool, LoggingTransaction from synapse.storage.databases.main.search import SearchEntry from synapse.storage.util.id_generators import MultiWriterIdGenerator +from synapse.storage.util.sequence import build_sequence_generator from synapse.types import StateMap, get_domain_from_id from synapse.util import json_encoder -from synapse.util.iterutils import batch_iter +from synapse.util.iterutils import batch_iter, sorted_topologically if TYPE_CHECKING: from synapse.server import HomeServer @@ -89,6 +100,14 @@ class PersistEventsStore: self._clock = hs.get_clock() self._instance_name = hs.get_instance_name() + def get_chain_id_txn(txn): + txn.execute("SELECT COALESCE(max(chain_id), 0) FROM event_auth_chains") + return txn.fetchone()[0] + + self._event_chain_id_gen = build_sequence_generator( + db.engine, get_chain_id_txn, "event_auth_chain_id" + ) + self._ephemeral_messages_enabled = hs.config.enable_ephemeral_messages self.is_mine_id = hs.is_mine_id @@ -366,6 +385,36 @@ class PersistEventsStore: # Insert into event_to_state_groups. self._store_event_state_mappings_txn(txn, events_and_contexts) + self._persist_event_auth_chain_txn(txn, [e for e, _ in events_and_contexts]) + + # _store_rejected_events_txn filters out any events which were + # rejected, and returns the filtered list. + events_and_contexts = self._store_rejected_events_txn( + txn, events_and_contexts=events_and_contexts + ) + + # From this point onwards the events are only ones that weren't + # rejected. + + self._update_metadata_tables_txn( + txn, + events_and_contexts=events_and_contexts, + all_events_and_contexts=all_events_and_contexts, + backfilled=backfilled, + ) + + # We call this last as it assumes we've inserted the events into + # room_memberships, where applicable. + self._update_current_state_txn(txn, state_delta_for_room, min_stream_order) + + def _persist_event_auth_chain_txn( + self, txn: LoggingTransaction, events: List[EventBase], + ) -> None: + + # We only care about state events, so this if there are no state events. + if not any(e.is_state() for e in events): + return + # We want to store event_auth mappings for rejected events, as they're # used in state res v2. # This is only necessary if the rejected event appears in an accepted @@ -381,31 +430,357 @@ class PersistEventsStore: "room_id": event.room_id, "auth_id": auth_id, } - for event, _ in events_and_contexts + for event in events for auth_id in event.auth_event_ids() if event.is_state() ], ) - # _store_rejected_events_txn filters out any events which were - # rejected, and returns the filtered list. - events_and_contexts = self._store_rejected_events_txn( - txn, events_and_contexts=events_and_contexts + # We now calculate chain ID/sequence numbers for any state events we're + # persisting. We ignore out of band memberships as we're not in the room + # and won't have their auth chain (we'll fix it up later if we join the + # room). + # + # See: docs/auth_chain_difference_algorithm.md + + # We ignore legacy rooms that we aren't filling the chain cover index + # for. + rows = self.db_pool.simple_select_many_txn( + txn, + table="rooms", + column="room_id", + iterable={event.room_id for event in events if event.is_state()}, + keyvalues={}, + retcols=("room_id", "has_auth_chain_index"), ) + rooms_using_chain_index = { + row["room_id"] for row in rows if row["has_auth_chain_index"] + } - # From this point onwards the events are only ones that weren't - # rejected. + state_events = { + event.event_id: event + for event in events + if event.is_state() and event.room_id in rooms_using_chain_index + } - self._update_metadata_tables_txn( + if not state_events: + return + + # Map from event ID to chain ID/sequence number. + chain_map = {} # type: Dict[str, Tuple[int, int]] + + # We need to know the type/state_key and auth events of the events we're + # calculating chain IDs for. We don't rely on having the full Event + # instances as we'll potentially be pulling more events from the DB and + # we don't need the overhead of fetching/parsing the full event JSON. + event_to_types = { + e.event_id: (e.type, e.state_key) for e in state_events.values() + } + event_to_auth_chain = { + e.event_id: e.auth_event_ids() for e in state_events.values() + } + + # Set of event IDs to calculate chain ID/seq numbers for. + events_to_calc_chain_id_for = set(state_events) + + # We check if there are any events that need to be handled in the rooms + # we're looking at. These should just be out of band memberships, where + # we didn't have the auth chain when we first persisted. + rows = self.db_pool.simple_select_many_txn( txn, - events_and_contexts=events_and_contexts, - all_events_and_contexts=all_events_and_contexts, - backfilled=backfilled, + table="event_auth_chain_to_calculate", + keyvalues={}, + column="room_id", + iterable={e.room_id for e in state_events.values()}, + retcols=("event_id", "type", "state_key"), ) + for row in rows: + event_id = row["event_id"] + event_type = row["type"] + state_key = row["state_key"] + + # (We could pull out the auth events for all rows at once using + # simple_select_many, but this case happens rarely and almost always + # with a single row.) + auth_events = self.db_pool.simple_select_onecol_txn( + txn, "event_auth", keyvalues={"event_id": event_id}, retcol="auth_id", + ) - # We call this last as it assumes we've inserted the events into - # room_memberships, where applicable. - self._update_current_state_txn(txn, state_delta_for_room, min_stream_order) + events_to_calc_chain_id_for.add(event_id) + event_to_types[event_id] = (event_type, state_key) + event_to_auth_chain[event_id] = auth_events + + # First we get the chain ID and sequence numbers for the events' + # auth events (that aren't also currently being persisted). + # + # Note that there there is an edge case here where we might not have + # calculated chains and sequence numbers for events that were "out + # of band". We handle this case by fetching the necessary info and + # adding it to the set of events to calculate chain IDs for. + + missing_auth_chains = { + a_id + for auth_events in event_to_auth_chain.values() + for a_id in auth_events + if a_id not in events_to_calc_chain_id_for + } + + # We loop here in case we find an out of band membership and need to + # fetch their auth event info. + while missing_auth_chains: + sql = """ + SELECT event_id, events.type, state_key, chain_id, sequence_number + FROM events + INNER JOIN state_events USING (event_id) + LEFT JOIN event_auth_chains USING (event_id) + WHERE + """ + clause, args = make_in_list_sql_clause( + txn.database_engine, "event_id", missing_auth_chains, + ) + txn.execute(sql + clause, args) + + missing_auth_chains.clear() + + for auth_id, event_type, state_key, chain_id, sequence_number in txn: + event_to_types[auth_id] = (event_type, state_key) + + if chain_id is None: + # No chain ID, so the event was persisted out of band. + # We add to list of events to calculate auth chains for. + + events_to_calc_chain_id_for.add(auth_id) + + event_to_auth_chain[ + auth_id + ] = self.db_pool.simple_select_onecol_txn( + txn, + "event_auth", + keyvalues={"event_id": auth_id}, + retcol="auth_id", + ) + + missing_auth_chains.update( + e + for e in event_to_auth_chain[auth_id] + if e not in event_to_types + ) + else: + chain_map[auth_id] = (chain_id, sequence_number) + + # Now we check if we have any events where we don't have auth chain, + # this should only be out of band memberships. + for event_id in sorted_topologically(event_to_auth_chain, event_to_auth_chain): + for auth_id in event_to_auth_chain[event_id]: + if ( + auth_id not in chain_map + and auth_id not in events_to_calc_chain_id_for + ): + events_to_calc_chain_id_for.discard(event_id) + + # If this is an event we're trying to persist we add it to + # the list of events to calculate chain IDs for next time + # around. (Otherwise we will have already added it to the + # table). + event = state_events.get(event_id) + if event: + self.db_pool.simple_insert_txn( + txn, + table="event_auth_chain_to_calculate", + values={ + "event_id": event.event_id, + "room_id": event.room_id, + "type": event.type, + "state_key": event.state_key, + }, + ) + + # We stop checking the event's auth events since we've + # discarded it. + break + + if not events_to_calc_chain_id_for: + return + + # We now calculate the chain IDs/sequence numbers for the events. We + # do this by looking at the chain ID and sequence number of any auth + # event with the same type/state_key and incrementing the sequence + # number by one. If there was no match or the chain ID/sequence + # number is already taken we generate a new chain. + # + # We need to do this in a topologically sorted order as we want to + # generate chain IDs/sequence numbers of an event's auth events + # before the event itself. + chains_tuples_allocated = set() # type: Set[Tuple[int, int]] + new_chain_tuples = {} # type: Dict[str, Tuple[int, int]] + for event_id in sorted_topologically( + events_to_calc_chain_id_for, event_to_auth_chain + ): + existing_chain_id = None + for auth_id in event_to_auth_chain[event_id]: + if event_to_types.get(event_id) == event_to_types.get(auth_id): + existing_chain_id = chain_map[auth_id] + break + + new_chain_tuple = None + if existing_chain_id: + # We found a chain ID/sequence number candidate, check its + # not already taken. + proposed_new_id = existing_chain_id[0] + proposed_new_seq = existing_chain_id[1] + 1 + if (proposed_new_id, proposed_new_seq) not in chains_tuples_allocated: + already_allocated = self.db_pool.simple_select_one_onecol_txn( + txn, + table="event_auth_chains", + keyvalues={ + "chain_id": proposed_new_id, + "sequence_number": proposed_new_seq, + }, + retcol="event_id", + allow_none=True, + ) + if already_allocated: + # Mark it as already allocated so we don't need to hit + # the DB again. + chains_tuples_allocated.add((proposed_new_id, proposed_new_seq)) + else: + new_chain_tuple = ( + proposed_new_id, + proposed_new_seq, + ) + + if not new_chain_tuple: + new_chain_tuple = (self._event_chain_id_gen.get_next_id_txn(txn), 1) + + chains_tuples_allocated.add(new_chain_tuple) + + chain_map[event_id] = new_chain_tuple + new_chain_tuples[event_id] = new_chain_tuple + + self.db_pool.simple_insert_many_txn( + txn, + table="event_auth_chains", + values=[ + {"event_id": event_id, "chain_id": c_id, "sequence_number": seq} + for event_id, (c_id, seq) in new_chain_tuples.items() + ], + ) + + self.db_pool.simple_delete_many_txn( + txn, + table="event_auth_chain_to_calculate", + keyvalues={}, + column="event_id", + iterable=new_chain_tuples, + ) + + # Now we need to calculate any new links between chains caused by + # the new events. + # + # Links are pairs of chain ID/sequence numbers such that for any + # event A (CA, SA) and any event B (CB, SB), B is in A's auth chain + # if and only if there is at least one link (CA, S1) -> (CB, S2) + # where SA >= S1 and S2 >= SB. + # + # We try and avoid adding redundant links to the table, e.g. if we + # have two links between two chains which both start/end at the + # sequence number event (or cross) then one can be safely dropped. + # + # To calculate new links we look at every new event and: + # 1. Fetch the chain ID/sequence numbers of its auth events, + # discarding any that are reachable by other auth events, or + # that have the same chain ID as the event. + # 2. For each retained auth event we: + # a. Add a link from the event's to the auth event's chain + # ID/sequence number; and + # b. Add a link from the event to every chain reachable by the + # auth event. + + # Step 1, fetch all existing links from all the chains we've seen + # referenced. + chain_links = _LinkMap() + rows = self.db_pool.simple_select_many_txn( + txn, + table="event_auth_chain_links", + column="origin_chain_id", + iterable={chain_id for chain_id, _ in chain_map.values()}, + keyvalues={}, + retcols=( + "origin_chain_id", + "origin_sequence_number", + "target_chain_id", + "target_sequence_number", + ), + ) + for row in rows: + chain_links.add_link( + (row["origin_chain_id"], row["origin_sequence_number"]), + (row["target_chain_id"], row["target_sequence_number"]), + new=False, + ) + + # We do this in toplogical order to avoid adding redundant links. + for event_id in sorted_topologically( + events_to_calc_chain_id_for, event_to_auth_chain + ): + chain_id, sequence_number = chain_map[event_id] + + # Filter out auth events that are reachable by other auth + # events. We do this by looking at every permutation of pairs of + # auth events (A, B) to check if B is reachable from A. + reduction = { + a_id + for a_id in event_to_auth_chain[event_id] + if chain_map[a_id][0] != chain_id + } + for start_auth_id, end_auth_id in itertools.permutations( + event_to_auth_chain[event_id], r=2, + ): + if chain_links.exists_path_from( + chain_map[start_auth_id], chain_map[end_auth_id] + ): + reduction.discard(end_auth_id) + + # Step 2, figure out what the new links are from the reduced + # list of auth events. + for auth_id in reduction: + auth_chain_id, auth_sequence_number = chain_map[auth_id] + + # Step 2a, add link between the event and auth event + chain_links.add_link( + (chain_id, sequence_number), (auth_chain_id, auth_sequence_number) + ) + + # Step 2b, add a link to chains reachable from the auth + # event. + for target_id, target_seq in chain_links.get_links_from( + (auth_chain_id, auth_sequence_number) + ): + if target_id == chain_id: + continue + + chain_links.add_link( + (chain_id, sequence_number), (target_id, target_seq) + ) + + self.db_pool.simple_insert_many_txn( + txn, + table="event_auth_chain_links", + values=[ + { + "origin_chain_id": source_id, + "origin_sequence_number": source_seq, + "target_chain_id": target_id, + "target_sequence_number": target_seq, + } + for ( + source_id, + source_seq, + target_id, + target_seq, + ) in chain_links.get_additions() + ], + ) def _persist_transaction_ids_txn( self, @@ -1521,3 +1896,131 @@ class PersistEventsStore: if not ev.internal_metadata.is_outlier() ], ) + + +@attr.s(slots=True) +class _LinkMap: + """A helper type for tracking links between chains. + """ + + # Stores the set of links as nested maps: source chain ID -> target chain ID + # -> source sequence number -> target sequence number. + maps = attr.ib(type=Dict[int, Dict[int, Dict[int, int]]], factory=dict) + + # Stores the links that have been added (with new set to true), as tuples of + # `(source chain ID, source sequence no, target chain ID, target sequence no.)` + additions = attr.ib(type=Set[Tuple[int, int, int, int]], factory=set) + + def add_link( + self, + src_tuple: Tuple[int, int], + target_tuple: Tuple[int, int], + new: bool = True, + ) -> bool: + """Add a new link between two chains, ensuring no redundant links are added. + + New links should be added in topological order. + + Args: + src_tuple: The chain ID/sequence number of the source of the link. + target_tuple: The chain ID/sequence number of the target of the link. + new: Whether this is a "new" link, i.e. should it be returned + by `get_additions`. + + Returns: + True if a link was added, false if the given link was dropped as redundant + """ + src_chain, src_seq = src_tuple + target_chain, target_seq = target_tuple + + current_links = self.maps.setdefault(src_chain, {}).setdefault(target_chain, {}) + + assert src_chain != target_chain + + if new: + # Check if the new link is redundant + for current_seq_src, current_seq_target in current_links.items(): + # If a link "crosses" another link then its redundant. For example + # in the following link 1 (L1) is redundant, as any event reachable + # via L1 is *also* reachable via L2. + # + # Chain A Chain B + # | | + # L1 |------ | + # | | | + # L2 |---- | -->| + # | | | + # | |--->| + # | | + # | | + # + # So we only need to keep links which *do not* cross, i.e. links + # that both start and end above or below an existing link. + # + # Note, since we add links in topological ordering we should never + # see `src_seq` less than `current_seq_src`. + + if current_seq_src <= src_seq and target_seq <= current_seq_target: + # This new link is redundant, nothing to do. + return False + + self.additions.add((src_chain, src_seq, target_chain, target_seq)) + + current_links[src_seq] = target_seq + return True + + def get_links_from( + self, src_tuple: Tuple[int, int] + ) -> Generator[Tuple[int, int], None, None]: + """Gets the chains reachable from the given chain/sequence number. + + Yields: + The chain ID and sequence number the link points to. + """ + src_chain, src_seq = src_tuple + for target_id, sequence_numbers in self.maps.get(src_chain, {}).items(): + for link_src_seq, target_seq in sequence_numbers.items(): + if link_src_seq <= src_seq: + yield target_id, target_seq + + def get_links_between( + self, source_chain: int, target_chain: int + ) -> Generator[Tuple[int, int], None, None]: + """Gets the links between two chains. + + Yields: + The source and target sequence numbers. + """ + + yield from self.maps.get(source_chain, {}).get(target_chain, {}).items() + + def get_additions(self) -> Generator[Tuple[int, int, int, int], None, None]: + """Gets any newly added links. + + Yields: + The source chain ID/sequence number and target chain ID/sequence number + """ + + for src_chain, src_seq, target_chain, _ in self.additions: + target_seq = self.maps.get(src_chain, {}).get(target_chain, {}).get(src_seq) + if target_seq is not None: + yield (src_chain, src_seq, target_chain, target_seq) + + def exists_path_from( + self, src_tuple: Tuple[int, int], target_tuple: Tuple[int, int], + ) -> bool: + """Checks if there is a path between the source chain ID/sequence and + target chain ID/sequence. + """ + src_chain, src_seq = src_tuple + target_chain, target_seq = target_tuple + + if src_chain == target_chain: + return target_seq <= src_seq + + links = self.get_links_between(src_chain, target_chain) + for link_start_seq, link_end_seq in links: + if link_start_seq <= src_seq and target_seq <= link_end_seq: + return True + + return False diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py index 4650d0689b..284f2ce77c 100644 --- a/synapse/storage/databases/main/room.py +++ b/synapse/storage/databases/main/room.py @@ -84,7 +84,7 @@ class RoomWorkerStore(SQLBaseStore): return await self.db_pool.simple_select_one( table="rooms", keyvalues={"room_id": room_id}, - retcols=("room_id", "is_public", "creator"), + retcols=("room_id", "is_public", "creator", "has_auth_chain_index"), desc="get_room", allow_none=True, ) @@ -1166,6 +1166,37 @@ class RoomBackgroundUpdateStore(SQLBaseStore): # It's overridden by RoomStore for the synapse master. raise NotImplementedError() + async def has_auth_chain_index(self, room_id: str) -> bool: + """Check if the room has (or can have) a chain cover index. + + Defaults to True if we don't have an entry in `rooms` table nor any + events for the room. + """ + + has_auth_chain_index = await self.db_pool.simple_select_one_onecol( + table="rooms", + keyvalues={"room_id": room_id}, + retcol="has_auth_chain_index", + desc="has_auth_chain_index", + allow_none=True, + ) + + if has_auth_chain_index: + return True + + # It's possible that we already have events for the room in our DB + # without a corresponding room entry. If we do then we don't want to + # mark the room as having an auth chain cover index. + max_ordering = await self.db_pool.simple_select_one_onecol( + table="events", + keyvalues={"room_id": room_id}, + retcol="MAX(stream_ordering)", + allow_none=True, + desc="upsert_room_on_join", + ) + + return max_ordering is None + class RoomStore(RoomBackgroundUpdateStore, RoomWorkerStore, SearchStore): def __init__(self, database: DatabasePool, db_conn, hs): @@ -1179,12 +1210,21 @@ class RoomStore(RoomBackgroundUpdateStore, RoomWorkerStore, SearchStore): Called when we join a room over federation, and overwrites any room version currently in the table. """ + # It's possible that we already have events for the room in our DB + # without a corresponding room entry. If we do then we don't want to + # mark the room as having an auth chain cover index. + has_auth_chain_index = await self.has_auth_chain_index(room_id) + await self.db_pool.simple_upsert( desc="upsert_room_on_join", table="rooms", keyvalues={"room_id": room_id}, values={"room_version": room_version.identifier}, - insertion_values={"is_public": False, "creator": ""}, + insertion_values={ + "is_public": False, + "creator": "", + "has_auth_chain_index": has_auth_chain_index, + }, # rooms has a unique constraint on room_id, so no need to lock when doing an # emulated upsert. lock=False, @@ -1219,6 +1259,7 @@ class RoomStore(RoomBackgroundUpdateStore, RoomWorkerStore, SearchStore): "creator": room_creator_user_id, "is_public": is_public, "room_version": room_version.identifier, + "has_auth_chain_index": True, }, ) if is_public: @@ -1247,6 +1288,11 @@ class RoomStore(RoomBackgroundUpdateStore, RoomWorkerStore, SearchStore): When we receive an invite or any other event over federation that may relate to a room we are not in, store the version of the room if we don't already know the room version. """ + # It's possible that we already have events for the room in our DB + # without a corresponding room entry. If we do then we don't want to + # mark the room as having an auth chain cover index. + has_auth_chain_index = await self.has_auth_chain_index(room_id) + await self.db_pool.simple_upsert( desc="maybe_store_room_on_outlier_membership", table="rooms", @@ -1256,6 +1302,7 @@ class RoomStore(RoomBackgroundUpdateStore, RoomWorkerStore, SearchStore): "room_version": room_version.identifier, "is_public": False, "creator": "", + "has_auth_chain_index": has_auth_chain_index, }, # rooms has a unique constraint on room_id, so no need to lock when doing an # emulated upsert. diff --git a/synapse/storage/databases/main/schema/delta/59/04_event_auth_chains.sql b/synapse/storage/databases/main/schema/delta/59/04_event_auth_chains.sql new file mode 100644 index 0000000000..729196cfd5 --- /dev/null +++ b/synapse/storage/databases/main/schema/delta/59/04_event_auth_chains.sql @@ -0,0 +1,52 @@ +/* Copyright 2020 The Matrix.org Foundation C.I.C + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +-- See docs/auth_chain_difference_algorithm.md + +CREATE TABLE event_auth_chains ( + event_id TEXT PRIMARY KEY, + chain_id BIGINT NOT NULL, + sequence_number BIGINT NOT NULL +); + +CREATE UNIQUE INDEX event_auth_chains_c_seq_index ON event_auth_chains (chain_id, sequence_number); + + +CREATE TABLE event_auth_chain_links ( + origin_chain_id BIGINT NOT NULL, + origin_sequence_number BIGINT NOT NULL, + + target_chain_id BIGINT NOT NULL, + target_sequence_number BIGINT NOT NULL +); + + +CREATE INDEX event_auth_chain_links_idx ON event_auth_chain_links (origin_chain_id, target_chain_id); + + +-- Events that we have persisted but not calculated auth chains for, +-- e.g. out of band memberships (where we don't have the auth chain) +CREATE TABLE event_auth_chain_to_calculate ( + event_id TEXT PRIMARY KEY, + room_id TEXT NOT NULL, + type TEXT NOT NULL, + state_key TEXT NOT NULL +); + +CREATE INDEX event_auth_chain_to_calculate_rm_id ON event_auth_chain_to_calculate(room_id); + + +-- Whether we've calculated the above index for a room. +ALTER TABLE rooms ADD COLUMN has_auth_chain_index BOOLEAN; diff --git a/synapse/storage/databases/main/schema/delta/59/04_event_auth_chains.sql.postgres b/synapse/storage/databases/main/schema/delta/59/04_event_auth_chains.sql.postgres new file mode 100644 index 0000000000..e8a035bbeb --- /dev/null +++ b/synapse/storage/databases/main/schema/delta/59/04_event_auth_chains.sql.postgres @@ -0,0 +1,16 @@ +/* Copyright 2020 The Matrix.org Foundation C.I.C + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +CREATE SEQUENCE IF NOT EXISTS event_auth_chain_id; diff --git a/synapse/util/iterutils.py b/synapse/util/iterutils.py index 06faeebe7f..f7b4857a84 100644 --- a/synapse/util/iterutils.py +++ b/synapse/util/iterutils.py @@ -13,8 +13,21 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import heapq from itertools import islice -from typing import Iterable, Iterator, Sequence, Tuple, TypeVar +from typing import ( + Dict, + Generator, + Iterable, + Iterator, + Mapping, + Sequence, + Set, + Tuple, + TypeVar, +) + +from synapse.types import Collection T = TypeVar("T") @@ -46,3 +59,41 @@ def chunk_seq(iseq: ISeq, maxlen: int) -> Iterable[ISeq]: If the input is empty, no chunks are returned. """ return (iseq[i : i + maxlen] for i in range(0, len(iseq), maxlen)) + + +def sorted_topologically( + nodes: Iterable[T], graph: Mapping[T, Collection[T]], +) -> Generator[T, None, None]: + """Given a set of nodes and a graph, yield the nodes in toplogical order. + + For example `sorted_topologically([1, 2], {1: [2]})` will yield `2, 1`. + """ + + # This is implemented by Kahn's algorithm. + + degree_map = {node: 0 for node in nodes} + reverse_graph = {} # type: Dict[T, Set[T]] + + for node, edges in graph.items(): + if node not in degree_map: + continue + + for edge in edges: + if edge in degree_map: + degree_map[node] += 1 + + reverse_graph.setdefault(edge, set()).add(node) + reverse_graph.setdefault(node, set()) + + zero_degree = [node for node, degree in degree_map.items() if degree == 0] + heapq.heapify(zero_degree) + + while zero_degree: + node = heapq.heappop(zero_degree) + yield node + + for edge in reverse_graph[node]: + if edge in degree_map: + degree_map[edge] -= 1 + if degree_map[edge] == 0: + heapq.heappush(zero_degree, edge) diff --git a/tests/storage/test_event_chain.py b/tests/storage/test_event_chain.py new file mode 100644 index 0000000000..83c377824b --- /dev/null +++ b/tests/storage/test_event_chain.py @@ -0,0 +1,472 @@ +# -*- coding: utf-8 -*- +# Copyright 2020 The Matrix.org Foundation C.I.C. +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Dict, List, Tuple + +from twisted.trial import unittest + +from synapse.api.constants import EventTypes +from synapse.api.room_versions import RoomVersions +from synapse.events import EventBase +from synapse.storage.databases.main.events import _LinkMap + +from tests.unittest import HomeserverTestCase + + +class EventChainStoreTestCase(HomeserverTestCase): + def prepare(self, reactor, clock, hs): + self.store = hs.get_datastore() + self._next_stream_ordering = 1 + + def test_simple(self): + """Test that the example in `docs/auth_chain_difference_algorithm.md` + works. + """ + + event_factory = self.hs.get_event_builder_factory() + bob = "@creator:test" + alice = "@alice:test" + room_id = "!room:test" + + # Ensure that we have a rooms entry so that we generate the chain index. + self.get_success( + self.store.store_room( + room_id=room_id, + room_creator_user_id="", + is_public=True, + room_version=RoomVersions.V6, + ) + ) + + create = self.get_success( + event_factory.for_room_version( + RoomVersions.V6, + { + "type": EventTypes.Create, + "state_key": "", + "sender": bob, + "room_id": room_id, + "content": {"tag": "create"}, + }, + ).build(prev_event_ids=[], auth_event_ids=[]) + ) + + bob_join = self.get_success( + event_factory.for_room_version( + RoomVersions.V6, + { + "type": EventTypes.Member, + "state_key": bob, + "sender": bob, + "room_id": room_id, + "content": {"tag": "bob_join"}, + }, + ).build(prev_event_ids=[], auth_event_ids=[create.event_id]) + ) + + power = self.get_success( + event_factory.for_room_version( + RoomVersions.V6, + { + "type": EventTypes.PowerLevels, + "state_key": "", + "sender": bob, + "room_id": room_id, + "content": {"tag": "power"}, + }, + ).build( + prev_event_ids=[], auth_event_ids=[create.event_id, bob_join.event_id], + ) + ) + + alice_invite = self.get_success( + event_factory.for_room_version( + RoomVersions.V6, + { + "type": EventTypes.Member, + "state_key": alice, + "sender": bob, + "room_id": room_id, + "content": {"tag": "alice_invite"}, + }, + ).build( + prev_event_ids=[], + auth_event_ids=[create.event_id, bob_join.event_id, power.event_id], + ) + ) + + alice_join = self.get_success( + event_factory.for_room_version( + RoomVersions.V6, + { + "type": EventTypes.Member, + "state_key": alice, + "sender": alice, + "room_id": room_id, + "content": {"tag": "alice_join"}, + }, + ).build( + prev_event_ids=[], + auth_event_ids=[create.event_id, alice_invite.event_id, power.event_id], + ) + ) + + power_2 = self.get_success( + event_factory.for_room_version( + RoomVersions.V6, + { + "type": EventTypes.PowerLevels, + "state_key": "", + "sender": bob, + "room_id": room_id, + "content": {"tag": "power_2"}, + }, + ).build( + prev_event_ids=[], + auth_event_ids=[create.event_id, bob_join.event_id, power.event_id], + ) + ) + + bob_join_2 = self.get_success( + event_factory.for_room_version( + RoomVersions.V6, + { + "type": EventTypes.Member, + "state_key": bob, + "sender": bob, + "room_id": room_id, + "content": {"tag": "bob_join_2"}, + }, + ).build( + prev_event_ids=[], + auth_event_ids=[create.event_id, bob_join.event_id, power.event_id], + ) + ) + + alice_join2 = self.get_success( + event_factory.for_room_version( + RoomVersions.V6, + { + "type": EventTypes.Member, + "state_key": alice, + "sender": alice, + "room_id": room_id, + "content": {"tag": "alice_join2"}, + }, + ).build( + prev_event_ids=[], + auth_event_ids=[ + create.event_id, + alice_join.event_id, + power_2.event_id, + ], + ) + ) + + events = [ + create, + bob_join, + power, + alice_invite, + alice_join, + bob_join_2, + power_2, + alice_join2, + ] + + expected_links = [ + (bob_join, create), + (power, create), + (power, bob_join), + (alice_invite, create), + (alice_invite, power), + (alice_invite, bob_join), + (bob_join_2, power), + (alice_join2, power_2), + ] + + self.persist(events) + chain_map, link_map = self.fetch_chains(events) + + # Check that the expected links and only the expected links have been + # added. + self.assertEqual(len(expected_links), len(list(link_map.get_additions()))) + + for start, end in expected_links: + start_id, start_seq = chain_map[start.event_id] + end_id, end_seq = chain_map[end.event_id] + + self.assertIn( + (start_seq, end_seq), list(link_map.get_links_between(start_id, end_id)) + ) + + # Test that everything can reach the create event, but the create event + # can't reach anything. + for event in events[1:]: + self.assertTrue( + link_map.exists_path_from( + chain_map[event.event_id], chain_map[create.event_id] + ), + ) + + self.assertFalse( + link_map.exists_path_from( + chain_map[create.event_id], chain_map[event.event_id], + ), + ) + + def test_out_of_order_events(self): + """Test that we handle persisting events that we don't have the full + auth chain for yet (which should only happen for out of band memberships). + """ + event_factory = self.hs.get_event_builder_factory() + bob = "@creator:test" + alice = "@alice:test" + room_id = "!room:test" + + # Ensure that we have a rooms entry so that we generate the chain index. + self.get_success( + self.store.store_room( + room_id=room_id, + room_creator_user_id="", + is_public=True, + room_version=RoomVersions.V6, + ) + ) + + # First persist the base room. + create = self.get_success( + event_factory.for_room_version( + RoomVersions.V6, + { + "type": EventTypes.Create, + "state_key": "", + "sender": bob, + "room_id": room_id, + "content": {"tag": "create"}, + }, + ).build(prev_event_ids=[], auth_event_ids=[]) + ) + + bob_join = self.get_success( + event_factory.for_room_version( + RoomVersions.V6, + { + "type": EventTypes.Member, + "state_key": bob, + "sender": bob, + "room_id": room_id, + "content": {"tag": "bob_join"}, + }, + ).build(prev_event_ids=[], auth_event_ids=[create.event_id]) + ) + + power = self.get_success( + event_factory.for_room_version( + RoomVersions.V6, + { + "type": EventTypes.PowerLevels, + "state_key": "", + "sender": bob, + "room_id": room_id, + "content": {"tag": "power"}, + }, + ).build( + prev_event_ids=[], auth_event_ids=[create.event_id, bob_join.event_id], + ) + ) + + self.persist([create, bob_join, power]) + + # Now persist an invite and a couple of memberships out of order. + alice_invite = self.get_success( + event_factory.for_room_version( + RoomVersions.V6, + { + "type": EventTypes.Member, + "state_key": alice, + "sender": bob, + "room_id": room_id, + "content": {"tag": "alice_invite"}, + }, + ).build( + prev_event_ids=[], + auth_event_ids=[create.event_id, bob_join.event_id, power.event_id], + ) + ) + + alice_join = self.get_success( + event_factory.for_room_version( + RoomVersions.V6, + { + "type": EventTypes.Member, + "state_key": alice, + "sender": alice, + "room_id": room_id, + "content": {"tag": "alice_join"}, + }, + ).build( + prev_event_ids=[], + auth_event_ids=[create.event_id, alice_invite.event_id, power.event_id], + ) + ) + + alice_join2 = self.get_success( + event_factory.for_room_version( + RoomVersions.V6, + { + "type": EventTypes.Member, + "state_key": alice, + "sender": alice, + "room_id": room_id, + "content": {"tag": "alice_join2"}, + }, + ).build( + prev_event_ids=[], + auth_event_ids=[create.event_id, alice_join.event_id, power.event_id], + ) + ) + + self.persist([alice_join]) + self.persist([alice_join2]) + self.persist([alice_invite]) + + # The end result should be sane. + events = [create, bob_join, power, alice_invite, alice_join] + + chain_map, link_map = self.fetch_chains(events) + + expected_links = [ + (bob_join, create), + (power, create), + (power, bob_join), + (alice_invite, create), + (alice_invite, power), + (alice_invite, bob_join), + ] + + # Check that the expected links and only the expected links have been + # added. + self.assertEqual(len(expected_links), len(list(link_map.get_additions()))) + + for start, end in expected_links: + start_id, start_seq = chain_map[start.event_id] + end_id, end_seq = chain_map[end.event_id] + + self.assertIn( + (start_seq, end_seq), list(link_map.get_links_between(start_id, end_id)) + ) + + def persist( + self, events: List[EventBase], + ): + """Persist the given events and check that the links generated match + those given. + """ + + persist_events_store = self.hs.get_datastores().persist_events + + for e in events: + e.internal_metadata.stream_ordering = self._next_stream_ordering + self._next_stream_ordering += 1 + + def _persist(txn): + # We need to persist the events to the events and state_events + # tables. + persist_events_store._store_event_txn(txn, [(e, {}) for e in events]) + + # Actually call the function that calculates the auth chain stuff. + persist_events_store._persist_event_auth_chain_txn(txn, events) + + self.get_success( + persist_events_store.db_pool.runInteraction("_persist", _persist,) + ) + + def fetch_chains( + self, events: List[EventBase] + ) -> Tuple[Dict[str, Tuple[int, int]], _LinkMap]: + + # Fetch the map from event ID -> (chain ID, sequence number) + rows = self.get_success( + self.store.db_pool.simple_select_many_batch( + table="event_auth_chains", + column="event_id", + iterable=[e.event_id for e in events], + retcols=("event_id", "chain_id", "sequence_number"), + keyvalues={}, + ) + ) + + chain_map = { + row["event_id"]: (row["chain_id"], row["sequence_number"]) for row in rows + } + + # Fetch all the links and pass them to the _LinkMap. + rows = self.get_success( + self.store.db_pool.simple_select_many_batch( + table="event_auth_chain_links", + column="origin_chain_id", + iterable=[chain_id for chain_id, _ in chain_map.values()], + retcols=( + "origin_chain_id", + "origin_sequence_number", + "target_chain_id", + "target_sequence_number", + ), + keyvalues={}, + ) + ) + + link_map = _LinkMap() + for row in rows: + added = link_map.add_link( + (row["origin_chain_id"], row["origin_sequence_number"]), + (row["target_chain_id"], row["target_sequence_number"]), + ) + + # We shouldn't have persisted any redundant links + self.assertTrue(added) + + return chain_map, link_map + + +class LinkMapTestCase(unittest.TestCase): + def test_simple(self): + """Basic tests for the LinkMap. + """ + link_map = _LinkMap() + + link_map.add_link((1, 1), (2, 1), new=False) + self.assertCountEqual(link_map.get_links_between(1, 2), [(1, 1)]) + self.assertCountEqual(link_map.get_links_from((1, 1)), [(2, 1)]) + self.assertCountEqual(link_map.get_additions(), []) + self.assertTrue(link_map.exists_path_from((1, 5), (2, 1))) + self.assertFalse(link_map.exists_path_from((1, 5), (2, 2))) + self.assertTrue(link_map.exists_path_from((1, 5), (1, 1))) + self.assertFalse(link_map.exists_path_from((1, 1), (1, 5))) + + # Attempting to add a redundant link is ignored. + self.assertFalse(link_map.add_link((1, 4), (2, 1))) + self.assertCountEqual(link_map.get_links_between(1, 2), [(1, 1)]) + + # Adding new non-redundant links works + self.assertTrue(link_map.add_link((1, 3), (2, 3))) + self.assertCountEqual(link_map.get_links_between(1, 2), [(1, 1), (3, 3)]) + + self.assertTrue(link_map.add_link((2, 5), (1, 3))) + self.assertCountEqual(link_map.get_links_between(2, 1), [(5, 3)]) + self.assertCountEqual(link_map.get_links_between(1, 2), [(1, 1), (3, 3)]) + + self.assertCountEqual(link_map.get_additions(), [(1, 3, 2, 3), (2, 5, 1, 3)]) diff --git a/tests/storage/test_event_federation.py b/tests/storage/test_event_federation.py index 482506d731..9d04a066d8 100644 --- a/tests/storage/test_event_federation.py +++ b/tests/storage/test_event_federation.py @@ -13,6 +13,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +import attr +from parameterized import parameterized + +from synapse.events import _EventInternalMetadata + import tests.unittest import tests.utils @@ -113,7 +118,8 @@ class EventFederationWorkerStoreTestCase(tests.unittest.HomeserverTestCase): r = self.get_success(self.store.get_rooms_with_many_extremities(5, 1, [room1])) self.assertTrue(r == [room2] or r == [room3]) - def test_auth_difference(self): + @parameterized.expand([(True,), (False,)]) + def test_auth_difference(self, use_chain_cover_index: bool): room_id = "@ROOM:local" # The silly auth graph we use to test the auth difference algorithm, @@ -159,46 +165,223 @@ class EventFederationWorkerStoreTestCase(tests.unittest.HomeserverTestCase): "j": 1, } + # Mark the room as not having a cover index + + def store_room(txn): + self.store.db_pool.simple_insert_txn( + txn, + "rooms", + { + "room_id": room_id, + "creator": "room_creator_user_id", + "is_public": True, + "room_version": "6", + "has_auth_chain_index": use_chain_cover_index, + }, + ) + + self.get_success(self.store.db_pool.runInteraction("store_room", store_room)) + # We rudely fiddle with the appropriate tables directly, as that's much # easier than constructing events properly. - def insert_event(txn, event_id, stream_ordering): + def insert_event(txn): + stream_ordering = 0 + + for event_id in auth_graph: + stream_ordering += 1 + depth = depth_map[event_id] + + self.store.db_pool.simple_insert_txn( + txn, + table="events", + values={ + "event_id": event_id, + "room_id": room_id, + "depth": depth, + "topological_ordering": depth, + "type": "m.test", + "processed": True, + "outlier": False, + "stream_ordering": stream_ordering, + }, + ) + + self.hs.datastores.persist_events._persist_event_auth_chain_txn( + txn, + [ + FakeEvent(event_id, room_id, auth_graph[event_id]) + for event_id in auth_graph + ], + ) + + self.get_success(self.store.db_pool.runInteraction("insert", insert_event,)) + + # Now actually test that various combinations give the right result: + + difference = self.get_success( + self.store.get_auth_chain_difference(room_id, [{"a"}, {"b"}]) + ) + self.assertSetEqual(difference, {"a", "b"}) + + difference = self.get_success( + self.store.get_auth_chain_difference(room_id, [{"a"}, {"b"}, {"c"}]) + ) + self.assertSetEqual(difference, {"a", "b", "c", "e", "f"}) + + difference = self.get_success( + self.store.get_auth_chain_difference(room_id, [{"a", "c"}, {"b"}]) + ) + self.assertSetEqual(difference, {"a", "b", "c"}) + + difference = self.get_success( + self.store.get_auth_chain_difference(room_id, [{"a", "c"}, {"b", "c"}]) + ) + self.assertSetEqual(difference, {"a", "b"}) + + difference = self.get_success( + self.store.get_auth_chain_difference(room_id, [{"a"}, {"b"}, {"d"}]) + ) + self.assertSetEqual(difference, {"a", "b", "d", "e"}) + + difference = self.get_success( + self.store.get_auth_chain_difference(room_id, [{"a"}, {"b"}, {"c"}, {"d"}]) + ) + self.assertSetEqual(difference, {"a", "b", "c", "d", "e", "f"}) + + difference = self.get_success( + self.store.get_auth_chain_difference(room_id, [{"a"}, {"b"}, {"e"}]) + ) + self.assertSetEqual(difference, {"a", "b"}) + + difference = self.get_success( + self.store.get_auth_chain_difference(room_id, [{"a"}]) + ) + self.assertSetEqual(difference, set()) + + def test_auth_difference_partial_cover(self): + """Test that we correctly handle rooms where not all events have a chain + cover calculated. This can happen in some obscure edge cases, including + during the background update that calculates the chain cover for old + rooms. + """ + + room_id = "@ROOM:local" + + # The silly auth graph we use to test the auth difference algorithm, + # where the top are the most recent events. + # + # A B + # \ / + # D E + # \ | + # ` F C + # | /| + # G ´ | + # | \ | + # H I + # | | + # K J + + auth_graph = { + "a": ["e"], + "b": ["e"], + "c": ["g", "i"], + "d": ["f"], + "e": ["f"], + "f": ["g"], + "g": ["h", "i"], + "h": ["k"], + "i": ["j"], + "k": [], + "j": [], + } + + depth_map = { + "a": 7, + "b": 7, + "c": 4, + "d": 6, + "e": 6, + "f": 5, + "g": 3, + "h": 2, + "i": 2, + "k": 1, + "j": 1, + } - depth = depth_map[event_id] + # We rudely fiddle with the appropriate tables directly, as that's much + # easier than constructing events properly. + def insert_event(txn): + # First insert the room and mark it as having a chain cover. self.store.db_pool.simple_insert_txn( txn, - table="events", - values={ - "event_id": event_id, + "rooms", + { "room_id": room_id, - "depth": depth, - "topological_ordering": depth, - "type": "m.test", - "processed": True, - "outlier": False, - "stream_ordering": stream_ordering, + "creator": "room_creator_user_id", + "is_public": True, + "room_version": "6", + "has_auth_chain_index": True, }, ) - self.store.db_pool.simple_insert_many_txn( + stream_ordering = 0 + + for event_id in auth_graph: + stream_ordering += 1 + depth = depth_map[event_id] + + self.store.db_pool.simple_insert_txn( + txn, + table="events", + values={ + "event_id": event_id, + "room_id": room_id, + "depth": depth, + "topological_ordering": depth, + "type": "m.test", + "processed": True, + "outlier": False, + "stream_ordering": stream_ordering, + }, + ) + + # Insert all events apart from 'B' + self.hs.datastores.persist_events._persist_event_auth_chain_txn( txn, - table="event_auth", - values=[ - {"event_id": event_id, "room_id": room_id, "auth_id": a} - for a in auth_graph[event_id] + [ + FakeEvent(event_id, room_id, auth_graph[event_id]) + for event_id in auth_graph + if event_id != "b" ], ) - next_stream_ordering = 0 - for event_id in auth_graph: - next_stream_ordering += 1 - self.get_success( - self.store.db_pool.runInteraction( - "insert", insert_event, event_id, next_stream_ordering - ) + # Now we insert the event 'B' without a chain cover, by temporarily + # pretending the room doesn't have a chain cover. + + self.store.db_pool.simple_update_txn( + txn, + table="rooms", + keyvalues={"room_id": room_id}, + updatevalues={"has_auth_chain_index": False}, + ) + + self.hs.datastores.persist_events._persist_event_auth_chain_txn( + txn, [FakeEvent("b", room_id, auth_graph["b"])], + ) + + self.store.db_pool.simple_update_txn( + txn, + table="rooms", + keyvalues={"room_id": room_id}, + updatevalues={"has_auth_chain_index": True}, ) + self.get_success(self.store.db_pool.runInteraction("insert", insert_event,)) + # Now actually test that various combinations give the right result: difference = self.get_success( @@ -240,3 +423,21 @@ class EventFederationWorkerStoreTestCase(tests.unittest.HomeserverTestCase): self.store.get_auth_chain_difference(room_id, [{"a"}]) ) self.assertSetEqual(difference, set()) + + +@attr.s +class FakeEvent: + event_id = attr.ib() + room_id = attr.ib() + auth_events = attr.ib() + + type = "foo" + state_key = "foo" + + internal_metadata = _EventInternalMetadata({}) + + def auth_event_ids(self): + return self.auth_events + + def is_state(self): + return True diff --git a/tests/util/test_itertools.py b/tests/util/test_itertools.py index 0ab0a91483..1184cea5a3 100644 --- a/tests/util/test_itertools.py +++ b/tests/util/test_itertools.py @@ -12,7 +12,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -from synapse.util.iterutils import chunk_seq +from typing import Dict, List + +from synapse.util.iterutils import chunk_seq, sorted_topologically from tests.unittest import TestCase @@ -45,3 +47,40 @@ class ChunkSeqTests(TestCase): self.assertEqual( list(parts), [], ) + + +class SortTopologically(TestCase): + def test_empty(self): + "Test that an empty graph works correctly" + + graph = {} # type: Dict[int, List[int]] + self.assertEqual(list(sorted_topologically([], graph)), []) + + def test_disconnected(self): + "Test that a graph with no edges work" + + graph = {1: [], 2: []} # type: Dict[int, List[int]] + + # For disconnected nodes the output is simply sorted. + self.assertEqual(list(sorted_topologically([1, 2], graph)), [1, 2]) + + def test_linear(self): + "Test that a simple `4 -> 3 -> 2 -> 1` graph works" + + graph = {1: [], 2: [1], 3: [2], 4: [3]} # type: Dict[int, List[int]] + + self.assertEqual(list(sorted_topologically([4, 3, 2, 1], graph)), [1, 2, 3, 4]) + + def test_subset(self): + "Test that only sorting a subset of the graph works" + graph = {1: [], 2: [1], 3: [2], 4: [3]} # type: Dict[int, List[int]] + + self.assertEqual(list(sorted_topologically([4, 3], graph)), [3, 4]) + + def test_fork(self): + "Test that a forked graph works" + graph = {1: [], 2: [1], 3: [1], 4: [2, 3]} # type: Dict[int, List[int]] + + # Valid orderings are `[1, 3, 2, 4]` or `[1, 2, 3, 4]`, but we should + # always get the same one. + self.assertEqual(list(sorted_topologically([4, 3, 2, 1], graph)), [1, 2, 3, 4]) -- GitLab