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).
+
+![Example](auth_chain_diff.dot.png)
+
+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