Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Stop getting missing prev_events after we already know their signature is invalid #13816

Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
924ae2b
Track when the pulled event signature fails
MadLittleMods Sep 14, 2022
d240aeb
Add changelog
MadLittleMods Sep 14, 2022
cfb4e88
Fix reference
MadLittleMods Sep 14, 2022
9b390a3
Stop getting missing prev_events after we already know their signatur…
MadLittleMods Sep 15, 2022
3d8507d
Merge branch 'develop' into madlittlemods/13700-track-when-event-sign…
MadLittleMods Sep 23, 2022
88a75cf
Use callback pattern to record signature failures
MadLittleMods Sep 23, 2022
d29ac0b
Add docstring
MadLittleMods Sep 24, 2022
14e39ee
Record failure from get_pdu and add test
MadLittleMods Sep 24, 2022
7898371
Be more selective about which errors to care about
MadLittleMods Sep 30, 2022
43f1d1a
Merge branch 'develop' into madlittlemods/13700-track-when-event-sign…
MadLittleMods Sep 30, 2022
e32b901
Merge branch 'madlittlemods/13700-track-when-event-signature-fails' i…
MadLittleMods Sep 30, 2022
83feb1b
Merge branch 'develop' into madlittlemods/13700-track-when-event-sign…
MadLittleMods Oct 1, 2022
7d102e8
Merge branch 'develop' into madlittlemods/13700-track-when-event-sign…
MadLittleMods Oct 1, 2022
81410b6
Merge branch 'madlittlemods/13700-track-when-event-signature-fails' i…
MadLittleMods Oct 1, 2022
958fd3b
Merge branch 'develop' into madlittlemods/13622-13700-stop-getting-st…
MadLittleMods Oct 3, 2022
e24db41
Weird name and add tests
MadLittleMods Oct 4, 2022
f853e78
Add changelog
MadLittleMods Oct 4, 2022
43fb6b8
Bail early and fix lints
MadLittleMods Oct 4, 2022
03f23b7
Add integration test with backfill
MadLittleMods Oct 4, 2022
99d3e79
Fix lints and better message
MadLittleMods Oct 4, 2022
f11f5b5
Fix test description
MadLittleMods Oct 4, 2022
6878faa
Fix test descriptions
MadLittleMods Oct 4, 2022
f3b443d
Scratch debug changes
MadLittleMods Oct 5, 2022
d135d41
Stop cascading backoff errors
MadLittleMods Oct 5, 2022
4effca9
Remove scratch changes
MadLittleMods Oct 5, 2022
e3cc054
Use custom FederationPullAttemptBackoffError
MadLittleMods Oct 5, 2022
74f9e03
Fix test to reflect no more cascade
MadLittleMods Oct 5, 2022
0b900e1
Better comments from what I've gathered
MadLittleMods Oct 5, 2022
3cb2826
Clarify which error in comments
MadLittleMods Oct 5, 2022
5f313df
Add some clarification to the test comment
MadLittleMods Oct 5, 2022
02e6bdd
Merge branch 'develop' into madlittlemods/13622-13700-stop-getting-st…
MadLittleMods Oct 12, 2022
7f4fdd2
Not a SynapseError
MadLittleMods Oct 12, 2022
89ffbcb
Make sure usages of _compute_event_context_with_maybe_missing_prevs h…
MadLittleMods Oct 12, 2022
354f682
Remove double "recently"
MadLittleMods Oct 12, 2022
e0b0447
Do the calculation in Python because it's more clear when we need res…
MadLittleMods Oct 12, 2022
e72c4e5
Use built-in select many function
MadLittleMods Oct 12, 2022
4e08039
No need for txn
MadLittleMods Oct 12, 2022
b060652
Rename function to reflect functionality
MadLittleMods Oct 12, 2022
b1a0c1b
Fix test description to make it accurate
MadLittleMods Oct 12, 2022
bccd802
Use more standard string interpolation with `logger`
MadLittleMods Oct 15, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/13816.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Stop fetching missing `prev_events` after we already know their signature is invalid.
25 changes: 25 additions & 0 deletions synapse/api/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,31 @@ def __init__(self, destination: Optional[str]):
)


class FederationPullAttemptBackoffError(SynapseError):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure it makes sense for this to inherit from SynapseError. The risk is that this manages to bubble all the way to the client API, returning a 403, which seems entirely wrong.

Copy link
Contributor Author

@MadLittleMods MadLittleMods Oct 12, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense 👍. The docstrings for SynapseError and FederationError could use some improving (would do in separate PR so we don't hang up on that).

We probably want to also update FederationDeniedError in a separate PR as well?

class FederationDeniedError(SynapseError):

Copy link
Contributor Author

@MadLittleMods MadLittleMods Oct 15, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created #14190 and #14191 to track these follow-up updates to explain those error classes better ⏩

"""
Raised to indicate that we are are deliberately not attempting to pull the given
event over federation because we've already done so recently and are backing off.

Attributes:
event_id: The event_id which we are refusing to pull
message: A custom error message that gives more context
"""

def __init__(self, event_id: str, message: Optional[str]):
self.event_id = event_id

if message:
error_message = message
else:
error_message = f"Not attempting to pull event={self.event_id} because we already tried to pull it recently (backing off)."

super().__init__(
code=403,
msg=error_message,
errcode=Codes.FORBIDDEN,
)


class InteractiveAuthIncompleteError(Exception):
"""An error raised when UI auth is not yet complete

Expand Down
25 changes: 25 additions & 0 deletions synapse/handlers/federation_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
AuthError,
Codes,
FederationError,
FederationPullAttemptBackoffError,
HttpResponseException,
RequestSendFailed,
SynapseError,
Expand Down Expand Up @@ -908,6 +909,18 @@ async def _process_pulled_event(
logger.warning("Pulled event %s failed history check.", event_id)
else:
raise
except FederationPullAttemptBackoffError as exc:
# Log a warning about why we failed to process the event (the error message
# for `FederationPullAttemptBackoffError` is pretty good)
logger.warning(str(exc))
# We do not record a failed pull attempt when we backoff fetching a missing
# `prev_event` because not being able to fetch the `prev_events` just means
# we won't be able to de-outlier the pulled event. But we can still use an
# `outlier` in the state/auth chain for another event. So we shouldn't stop
# a downstream event from trying to pull it.
#
# This avoids a cascade of backoff for all events in the DAG downstream from
# one event backoff upstream.

@trace
async def _compute_event_context_with_maybe_missing_prevs(
Expand Down Expand Up @@ -955,6 +968,18 @@ async def _compute_event_context_with_maybe_missing_prevs(
seen = await self._store.have_events_in_timeline(prevs)
missing_prevs = prevs - seen

# If we've already recently attempted to pull this missing event recently, don't
# try it again so soon. Since we have to fetch all of the prev_events, we can
# bail early here if we find any to ignore.
prevs_to_ignore = await self._store.filter_event_ids_with_pull_attempt_backoff(
room_id, missing_prevs
)
erikjohnston marked this conversation as resolved.
Show resolved Hide resolved
if len(prevs_to_ignore) > 0:
raise FederationPullAttemptBackoffError(
MadLittleMods marked this conversation as resolved.
Show resolved Hide resolved
event_id=prevs_to_ignore[0],
MadLittleMods marked this conversation as resolved.
Show resolved Hide resolved
message=f"While computing context for event={event_id}, not attempting to pull missing prev_event={prevs_to_ignore[0]} because we already tried to pull recently (backing off).",
)
MadLittleMods marked this conversation as resolved.
Show resolved Hide resolved

if not missing_prevs:
return await self._state_handler.compute_event_context(event)

Expand Down
78 changes: 78 additions & 0 deletions synapse/storage/databases/main/event_federation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1501,6 +1501,12 @@ async def record_event_failed_pull_attempt(
event_id: The event that failed to be fetched or processed
cause: The error message or reason that we failed to pull the event
"""
logger.debug(
"record_event_failed_pull_attempt room_id=%s, event_id=%s, cause=%s",
room_id,
event_id,
cause,
)
await self.db_pool.runInteraction(
"record_event_failed_pull_attempt",
self._record_event_failed_pull_attempt_upsert_txn,
Expand Down Expand Up @@ -1530,6 +1536,78 @@ def _record_event_failed_pull_attempt_upsert_txn(

txn.execute(sql, (room_id, event_id, 1, self._clock.time_msec(), cause))

@trace
async def filter_event_ids_with_pull_attempt_backoff(
MadLittleMods marked this conversation as resolved.
Show resolved Hide resolved
MadLittleMods marked this conversation as resolved.
Show resolved Hide resolved
self,
room_id: str,
event_ids: Collection[str],
) -> List[str]:
"""
Filter down the events to ones that we've failed to pull before recently. Uses
exponential backoff.

Args:
room_id: The room that the events belong to
event_ids: A list of events to filter down

Returns:
List of event_ids that should not be attempted to be pulled
"""

def _filter_event_ids_with_pull_attempt_backoff_txn(
txn: LoggingTransaction,
) -> List[str]:
where_event_ids_match_clause, values = make_in_list_sql_clause(
txn.database_engine, "event_id", event_ids
)

if isinstance(self.database_engine, PostgresEngine):
least_function = "least"
elif isinstance(self.database_engine, Sqlite3Engine):
least_function = "min"
else:
raise RuntimeError("Unknown database engine")

sql = f"""
SELECT event_id FROM event_failed_pull_attempts
WHERE
room_id = ?
AND {where_event_ids_match_clause}
/**
* Exponential back-off (up to the upper bound) so we don't try to
* pull the same event over and over. ex. 2hr, 4hr, 8hr, 16hr, etc.
*
* We use `1 << n` as a power of 2 equivalent for compatibility
* with older SQLites. The left shift equivalent only works with
* powers of 2 because left shift is a binary operation (base-2).
* Otherwise, we would use `power(2, n)` or the power operator, `2^n`.
*/
AND ? /* current_time */ < last_attempt_ts + (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It occurs to me that these inline SQL comments get sent over the wire to postgres, which is probably not totally ideal. Might be worth moving the big ones out?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, TBH I'm wondering whether for this it would be clearer to just fetch event_id, last_attempt_ts, num_attempts and do the checks in python?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, TBH I'm wondering whether for this it would be clearer to just fetch event_id, last_attempt_ts, num_attempts and do the checks in python?

I think this makes a lot of sense here since we have to get a response out for each event_ids anyway 👍



It occurs to me that these inline SQL comments get sent over the wire to postgres, which is probably not totally ideal. Might be worth moving the big ones out?

In terms of other places to worry about this: we already strip out the newlines which is why I use /* */ instead of inline --. Is there a way we can strip the comments out so we don't lose the nice readability? The performance of find/replacing this every time doesn't sound great but our _make_sql_one_line doesn't look particularly optimized by eye either 🤔. I'm not sure why we started doing that (introduced in #1939), maybe to get rid of all of the extra indentation spaces in multi-line strings.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I was thinking about that too, though we'd want to make sure that we do it safely (I'm not sure if just stripping /* ... */ is safe by itself).

Note that we don't actually send the one-line SQL to the database, we use it for logging.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created #14192 to continue this comments in SQL conversation ⏩

Note that we don't actually send the one-line SQL to the database, we use it for logging.

huh, I swore this was a problem before. Good to know!

(1 << {least_function}(num_attempts, ? /* max doubling steps */))
* ? /* step */
)
"""

txn.execute(
sql,
(
room_id,
*values,
self._clock.time_msec(),
BACKFILL_EVENT_EXPONENTIAL_BACKOFF_MAXIMUM_DOUBLING_STEPS,
BACKFILL_EVENT_EXPONENTIAL_BACKOFF_STEP_MILLISECONDS,
),
)

event_ids_to_ignore_result = cast(List[Tuple[str]], txn.fetchall())

return [event_id for event_id, in event_ids_to_ignore_result]

return await self.db_pool.runInteraction(
"filter_event_ids_with_pull_attempt_backoff_txn",
_filter_event_ids_with_pull_attempt_backoff_txn,
)

async def get_missing_events(
self,
room_id: str,
Expand Down
201 changes: 199 additions & 2 deletions tests/handlers/test_federation_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from typing import Optional
from unittest import mock

from synapse.api.errors import AuthError
from synapse.api.errors import AuthError, StoreError
from synapse.api.room_versions import RoomVersion
from synapse.event_auth import (
check_state_dependent_auth_rules,
Expand Down Expand Up @@ -43,7 +43,7 @@ class FederationEventHandlerTests(unittest.FederatingHomeserverTestCase):
def make_homeserver(self, reactor, clock):
# mock out the federation transport client
self.mock_federation_transport_client = mock.Mock(
spec=["get_room_state_ids", "get_room_state", "get_event"]
spec=["get_room_state_ids", "get_room_state", "get_event", "backfill"]
)
return super().setup_test_homeserver(
federation_transport_client=self.mock_federation_transport_client
Expand Down Expand Up @@ -459,6 +459,203 @@ def test_process_pulled_event_clears_backfill_attempts_after_being_successfully_
)
self.assertIsNotNone(persisted, "pulled event was not persisted at all")

def test_backfill_signature_failure_does_not_fetch_same_prev_event_later(
self,
) -> None:
"""
Test to make sure we backoff and don't try to fetch a missing prev_event when we
already know it has a invalid signature from checking the signatures of all of
the events in the backfill response.
"""
OTHER_USER = f"@user:{self.OTHER_SERVER_NAME}"
main_store = self.hs.get_datastores().main

# Create the room
user_id = self.register_user("kermit", "test")
tok = self.login("kermit", "test")
room_id = self.helper.create_room_as(room_creator=user_id, tok=tok)
room_version = self.get_success(main_store.get_room_version(room_id))

# Allow the remote user to send state events
self.helper.send_state(
room_id,
"m.room.power_levels",
{"events_default": 0, "state_default": 0},
tok=tok,
)

# Add the remote user to the room
member_event = self.get_success(
event_injection.inject_member_event(self.hs, room_id, OTHER_USER, "join")
)

initial_state_map = self.get_success(
main_store.get_partial_current_state_ids(room_id)
)

auth_event_ids = [
initial_state_map[("m.room.create", "")],
initial_state_map[("m.room.power_levels", "")],
member_event.event_id,
]

# We purposely don't run `add_hashes_and_signatures_from_other_server`
# over this because we want the signature check to fail.
pulled_event_without_signatures = make_event_from_dict(
{
"type": "test_regular_type",
"room_id": room_id,
"sender": OTHER_USER,
"prev_events": [member_event.event_id],
"auth_events": auth_event_ids,
"origin_server_ts": 1,
"depth": 12,
"content": {"body": "pulled_event_without_signatures"},
},
room_version,
)

# Create a regular event that should pass except for the
# `pulled_event_without_signatures` in the `prev_event`.
pulled_event = make_event_from_dict(
self.add_hashes_and_signatures_from_other_server(
{
"type": "test_regular_type",
"room_id": room_id,
"sender": OTHER_USER,
"prev_events": [
member_event.event_id,
pulled_event_without_signatures.event_id,
],
"auth_events": auth_event_ids,
"origin_server_ts": 1,
"depth": 12,
"content": {"body": "pulled_event"},
}
),
room_version,
)

# We expect an outbound request to /backfill, so stub that out
self.mock_federation_transport_client.backfill.return_value = make_awaitable(
{
"origin": self.OTHER_SERVER_NAME,
"origin_server_ts": 123,
"pdus": [
# This is one of the important aspects of this test: we include
# `pulled_event_without_signatures` so it fails the signature check
# when we filter down the backfill response down to events which
# have valid signatures in
# `_check_sigs_and_hash_for_pulled_events_and_fetch`
pulled_event_without_signatures.get_pdu_json(),
# Then later when we process this valid signature event, when we
# fetch the missing `prev_event`s, we want to make sure that we
# backoff and don't try and fetch `pulled_event_without_signatures`
# again since we know it just had an invalid signature.
pulled_event.get_pdu_json(),
],
}
)

# Keep track of the count and make sure we don't make any of these requests
event_endpoint_requested_count = 0
room_state_ids_endpoint_requested_count = 0
room_state_endpoint_requested_count = 0

async def get_event(
destination: str, event_id: str, timeout: Optional[int] = None
) -> None:
nonlocal event_endpoint_requested_count
event_endpoint_requested_count += 1

async def get_room_state_ids(
destination: str, room_id: str, event_id: str
) -> None:
nonlocal room_state_ids_endpoint_requested_count
room_state_ids_endpoint_requested_count += 1

async def get_room_state(
room_version: RoomVersion, destination: str, room_id: str, event_id: str
) -> None:
nonlocal room_state_endpoint_requested_count
room_state_endpoint_requested_count += 1

# We don't expect an outbound request to `/event`, `/state_ids`, or `/state` in
# the happy path but if the logic is sneaking around what we expect, stub that
# out so we can detect that failure
self.mock_federation_transport_client.get_event.side_effect = get_event
self.mock_federation_transport_client.get_room_state_ids.side_effect = (
get_room_state_ids
)
self.mock_federation_transport_client.get_room_state.side_effect = (
get_room_state
)

# The function under test: try to backfill and process the pulled event
with LoggingContext("test"):
self.get_success(
self.hs.get_federation_event_handler().backfill(
self.OTHER_SERVER_NAME,
room_id,
limit=1,
extremities=["$some_extremity"],
)
)

if event_endpoint_requested_count > 0:
self.fail(
"We don't expect an outbound request to /event in the happy path but if "
"the logic is sneaking around what we expect, make sure to fail the test. "
"We don't expect it because the signature failure should cause us to backoff "
"and not asking about pulled_event_without_signatures="
f"{pulled_event_without_signatures.event_id} again"
)

if room_state_ids_endpoint_requested_count > 0:
self.fail(
"We don't expect an outbound request to /state_ids in the happy path but if "
"the logic is sneaking around what we expect, make sure to fail the test. "
"We don't expect it because the signature failure should cause us to backoff "
"and not asking about pulled_event_without_signatures="
f"{pulled_event_without_signatures.event_id} again"
)

if room_state_endpoint_requested_count > 0:
self.fail(
"We don't expect an outbound request to /state in the happy path but if "
"the logic is sneaking around what we expect, make sure to fail the test. "
"We don't expect it because the signature failure should cause us to backoff "
"and not asking about pulled_event_without_signatures="
f"{pulled_event_without_signatures.event_id} again"
)

# Make sure we only recorded a single failure which corresponds to the signature
# failure initially in `_check_sigs_and_hash_for_pulled_events_and_fetch` before
# we process all of the pulled events.
backfill_num_attempts_for_event_without_signatures = self.get_success(
main_store.db_pool.simple_select_one_onecol(
table="event_failed_pull_attempts",
keyvalues={"event_id": pulled_event_without_signatures.event_id},
retcol="num_attempts",
)
)
self.assertEqual(backfill_num_attempts_for_event_without_signatures, 1)

# And make sure we didn't record a failure for the event that has the missing
# prev_event because we don't want to cause a cascade of failures. Not being
# able to fetch the `prev_events` just means we won't be able to de-outlier the
# pulled event. But we can still use an `outlier` in the state/auth chain for
# another event. So we shouldn't stop a downstream event from trying to pull it.
self.get_failure(
main_store.db_pool.simple_select_one_onecol(
table="event_failed_pull_attempts",
keyvalues={"event_id": pulled_event.event_id},
retcol="num_attempts",
),
# StoreError: 404: No row found
StoreError,
)

def test_process_pulled_event_with_rejected_missing_state(self) -> None:
"""Ensure that we correctly handle pulled events with missing state containing a
rejected state event
Expand Down
Loading