# Buggy code
def _purge_room_txn(self, txn, room_id: str) -> List[int]:
    # First we fetch all the state groups that should be deleted, before
    # we delete that information.
    txn.execute(
        """
                SELECT DISTINCT state_group FROM events
                INNER JOIN event_to_state_groups USING(event_id)
                WHERE events.room_id = ?
            """,
        (room_id,),
    )

    state_groups = [row[0] for row in txn]

    # Get all the auth chains that are referenced by events that are to be
    # deleted.
    txn.execute(
        """
            SELECT chain_id, sequence_number FROM events
            LEFT JOIN event_auth_chains USING (event_id)
            WHERE room_id = ?
            """,
        (room_id,),
    )
    referenced_chain_id_tuples = list(txn)

    logger.info("[purge] removing events from event_auth_chain_links")
    txn.executemany(
        """
            DELETE FROM event_auth_chain_links WHERE
            (origin_chain_id = ? AND origin_sequence_number = ?) OR
            (target_chain_id = ? AND target_sequence_number = ?)
            """,
        (
            (chain_id, seq_num, chain_id, seq_num)
            for (chain_id, seq_num) in referenced_chain_id_tuples
        ),
    )

    # Now we delete tables which lack an index on room_id but have one on event_id
    for table in (
        "event_auth",
        "event_edges",
        "event_json",
        "event_push_actions_staging",
        "event_reference_hashes",
        "event_relations",
        "event_to_state_groups",
        "event_auth_chains",
        "event_auth_chain_to_calculate",
        "redactions",
        "rejections",
        "state_events",
    ):
        logger.info("[purge] removing %s from %s", room_id, table)

        txn.execute(
            """
                DELETE FROM %s WHERE event_id IN (
                  SELECT event_id FROM events WHERE room_id=?
                )
                """
            % (table,),
            (room_id,),
        )

    # and finally, the tables with an index on room_id (or no useful index)
    for table in (
        "current_state_events",
        "destination_rooms",
        "event_backward_extremities",
        "event_forward_extremities",
        "event_push_actions",
        "event_search",
        "events",
        "group_rooms",
        "public_room_list_stream",
        "receipts_graph",
        "receipts_linearized",
        "room_aliases",
        "room_depth",
        "room_memberships",
        "room_stats_state",
        "room_stats_current",
        "room_stats_historical",
        "room_stats_earliest_token",
        "rooms",
        "stream_ordering_to_exterm",
        "users_in_public_rooms",
        "users_who_share_private_rooms",
        # no useful index, but let's clear them anyway
        "appservice_room_list",
        "e2e_room_keys",
        "event_push_summary",
        "pusher_throttle",
        "group_summary_rooms",
        "room_account_data",
        "room_tags",
        "local_current_membership",
    ):
        logger.info("[purge] removing %s from %s", room_id, table)
        txn.execute("DELETE FROM %s WHERE room_id=?" % (table,), (room_id,))

    # Other tables we do NOT need to clear out:
    #
    #  - blocked_rooms
    #    This is important, to make sure that we don't accidentally rejoin a blocked
    #    room after it was purged
    #
    #  - user_directory
    #    This has a room_id column, but it is unused
    #

    # Other tables that we might want to consider clearing out include:
    #
    #  - event_reports
    #       Given that these are intended for abuse management my initial
    #       inclination is to leave them in place.
    #
    #  - current_state_delta_stream
    #  - ex_outlier_stream
    #  - room_tags_revisions
    #       The problem with these is that they are largeish and there is no room_id
    #       index on them. In any case we should be clearing out 'stream' tables
    #       periodically anyway (#5888)

    # TODO: we could probably usefully do a bunch of cache invalidation here

    logger.info("[purge] done")

    return state_groups


# Traceback (twisted.internet.defer.FirstError)
synapse.http.server: [POST-10040] Failed handle request via 'JoinRoomAliasServlet': <XForwardedForRequest at 0x7f57646fa970 method='POST' uri='/_matrix/client/r0/join/%23synapse%3Amatrix.org' clientproto='HTTP/1.1' site='8008'>
Traceback (most recent call last):
File "/usr/lib/python3.9/site-packages/synapse/http/server.py", line 252, in _async_render_wrapper
callback_return = await self._async_render(request)
File "/usr/lib/python3.9/site-packages/synapse/http/server.py", line 430, in _async_render
callback_return = await raw_callback_return
File "/usr/lib/python3.9/site-packages/synapse/rest/client/v1/room.py", line 301, in on_POST
await self.room_member_handler.update_membership(
File "/usr/lib/python3.9/site-packages/synapse/handlers/room_member.py", line 333, in update_membership
result = await self.update_membership_locked(
File "/usr/lib/python3.9/site-packages/synapse/handlers/room_member.py", line 549, in update_membership_locked
remote_join_response = await self._remote_join(
File "/usr/lib/python3.9/site-packages/synapse/handlers/room_member.py", line 1091, in _remote_join
event_id, stream_id = await self.federation_handler.do_invite_join(
File "/usr/lib/python3.9/site-packages/synapse/handlers/federation.py", line 1400, in do_invite_join
max_stream_id = await self._persist_auth_tree(
File "/usr/lib/python3.9/site-packages/synapse/handlers/federation.py", line 2050, in _persist_auth_tree
await self.persist_events_and_notify(
File "/usr/lib/python3.9/site-packages/synapse/handlers/federation.py", line 2925, in persist_events_and_notify
events, max_stream_token = await self.storage.persistence.persist_events(
File "/usr/lib/python3.9/site-packages/synapse/storage/persist_events.py", line 262, in persist_events
ret_vals = await make_deferred_yieldable(
twisted.internet.defer.FirstError: FirstError[#0, [Failure instance: Traceback: <class 'psycopg2.errors.UniqueViolation'>: duplicate key value violates unique constraint "event_auth_chains_pkey"
DETAIL:  Key (event_id)=($e9U026auDHIgaZPAqlblvPupACjl7jcZDblP970dJPs) already exists.

/usr/lib/python3.9/site-packages/synapse/metrics/background_process_metrics.py:208:run
--- <exception caught here> ---
/usr/lib/python3.9/site-packages/synapse/storage/persist_events.py:172:handle_queue_loop
/usr/lib/python3.9/site-packages/synapse/storage/persist_events.py:324:persisting_queue
/usr/lib/python3.9/site-packages/synapse/storage/persist_events.py:532:_persist_events
/usr/lib/python3.9/site-packages/synapse/storage/databases/main/events.py:171:_persist_events_and_state_updates
/usr/lib/python3.9/site-packages/synapse/storage/database.py:661:runInteraction
/usr/lib/python3.9/site-packages/synapse/storage/database.py:744:runWithConnection
/usr/lib64/python3.9/site-packages/twisted/python/threadpool.py:250:inContext
/usr/lib64/python3.9/site-packages/twisted/python/threadpool.py:266:<lambda>
/usr/lib64/python3.9/site-packages/twisted/python/context.py:122:callWithContext
/usr/lib64/python3.9/site-packages/twisted/python/context.py:85:callWithContext
/usr/lib64/python3.9/site-packages/twisted/enterprise/adbapi.py:306:_runWithConnection
/usr/lib64/python3.9/site-packages/twisted/python/compat.py:464:reraise
/usr/lib64/python3.9/site-packages/twisted/enterprise/adbapi.py:297:_runWithConnection
/usr/lib/python3.9/site-packages/synapse/storage/database.py:739:inner_func
/usr/lib/python3.9/site-packages/synapse/storage/database.py:539:new_transaction
/usr/lib/python3.9/site-packages/synapse/logging/utils.py:71:wrapped
/usr/lib/python3.9/site-packages/synapse/storage/databases/main/events.py:379:_persist_events_txn
/usr/lib/python3.9/site-packages/synapse/storage/databases/main/events.py:472:_persist_event_auth_chain_txn
/usr/lib/python3.9/site-packages/synapse/storage/databases/main/events.py:630:_add_chain_cover_index
/usr/lib/python3.9/site-packages/synapse/storage/database.py:896:simple_insert_many_txn
/usr/lib/python3.9/site-packages/synapse/storage/database.py:274:execute_batch
/usr/lib/python3.9/site-packages/synapse/storage/database.py:319:_do_execute
/usr/lib/python3.9/site-packages/synapse/storage/database.py:274:<lambda>
/usr/lib64/python3.9/site-packages/psycopg2/extras.py:1209:execute_batch
]]

# Fixed code
def _purge_room_txn(self, txn, room_id: str) -> List[int]:
    # First we fetch all the state groups that should be deleted, before
    # we delete that information.
    txn.execute(
        """
                SELECT DISTINCT state_group FROM events
                INNER JOIN event_to_state_groups USING(event_id)
                WHERE events.room_id = ?
            """,
        (room_id,),
    )

    state_groups = [row[0] for row in txn]

    # Get all the auth chains that are referenced by events that are to be
    # deleted.
    txn.execute(
        """
            SELECT chain_id, sequence_number FROM events
            LEFT JOIN event_auth_chains USING (event_id)
            WHERE room_id = ?
            """,
        (room_id,),
    )
    referenced_chain_id_tuples = list(txn)

    logger.info("[purge] removing events from event_auth_chain_links")
    txn.executemany(
        """
            DELETE FROM event_auth_chain_links WHERE
            origin_chain_id = ? AND origin_sequence_number = ?
            """,
        referenced_chain_id_tuples,
    )

    # Now we delete tables which lack an index on room_id but have one on event_id
    for table in (
        "event_auth",
        "event_edges",
        "event_json",
        "event_push_actions_staging",
        "event_reference_hashes",
        "event_relations",
        "event_to_state_groups",
        "event_auth_chains",
        "event_auth_chain_to_calculate",
        "redactions",
        "rejections",
        "state_events",
    ):
        logger.info("[purge] removing %s from %s", room_id, table)

        txn.execute(
            """
                DELETE FROM %s WHERE event_id IN (
                  SELECT event_id FROM events WHERE room_id=?
                )
                """
            % (table,),
            (room_id,),
        )

    # and finally, the tables with an index on room_id (or no useful index)
    for table in (
        "current_state_events",
        "destination_rooms",
        "event_backward_extremities",
        "event_forward_extremities",
        "event_push_actions",
        "event_search",
        "events",
        "group_rooms",
        "public_room_list_stream",
        "receipts_graph",
        "receipts_linearized",
        "room_aliases",
        "room_depth",
        "room_memberships",
        "room_stats_state",
        "room_stats_current",
        "room_stats_historical",
        "room_stats_earliest_token",
        "rooms",
        "stream_ordering_to_exterm",
        "users_in_public_rooms",
        "users_who_share_private_rooms",
        # no useful index, but let's clear them anyway
        "appservice_room_list",
        "e2e_room_keys",
        "event_push_summary",
        "pusher_throttle",
        "group_summary_rooms",
        "room_account_data",
        "room_tags",
        "local_current_membership",
    ):
        logger.info("[purge] removing %s from %s", room_id, table)
        txn.execute("DELETE FROM %s WHERE room_id=?" % (table,), (room_id,))

    # Other tables we do NOT need to clear out:
    #
    #  - blocked_rooms
    #    This is important, to make sure that we don't accidentally rejoin a blocked
    #    room after it was purged
    #
    #  - user_directory
    #    This has a room_id column, but it is unused
    #

    # Other tables that we might want to consider clearing out include:
    #
    #  - event_reports
    #       Given that these are intended for abuse management my initial
    #       inclination is to leave them in place.
    #
    #  - current_state_delta_stream
    #  - ex_outlier_stream
    #  - room_tags_revisions
    #       The problem with these is that they are largeish and there is no room_id
    #       index on them. In any case we should be clearing out 'stream' tables
    #       periodically anyway (#5888)

    # TODO: we could probably usefully do a bunch of cache invalidation here

    logger.info("[purge] done")

    return state_groups