diff --git a/changelog.d/19424.feature b/changelog.d/19424.feature new file mode 100644 index 00000000000..732a583f6b9 --- /dev/null +++ b/changelog.d/19424.feature @@ -0,0 +1 @@ +Add experimental support for MSC4242: State DAGs. Excludes federation support. diff --git a/rust/src/events/internal_metadata.rs b/rust/src/events/internal_metadata.rs index fa40fdcfad8..750c70814ea 100644 --- a/rust/src/events/internal_metadata.rs +++ b/rust/src/events/internal_metadata.rs @@ -59,6 +59,7 @@ enum EventInternalMetadataData { TxnId(Box), TokenId(i64), DeviceId(Box), + CalculatedAuthEventIDs(Vec), } impl EventInternalMetadataData { @@ -123,6 +124,10 @@ impl EventInternalMetadataData { pyo3::intern!(py, "device_id"), o.into_pyobject(py).unwrap_infallible().into_any(), ), + EventInternalMetadataData::CalculatedAuthEventIDs(o) => ( + pyo3::intern!(py, "calculated_auth_event_ids"), + o.into_pyobject(py).unwrap().into_any(), + ), } } @@ -190,6 +195,11 @@ impl EventInternalMetadataData { .map(String::into_boxed_str) .with_context(|| format!("'{key_str}' has invalid type"))?, ), + "calculated_auth_event_ids" => EventInternalMetadataData::CalculatedAuthEventIDs( + value + .extract() + .with_context(|| format!("'{key_str}' has invalid type"))?, + ), _ => return Ok(None), }; @@ -472,6 +482,17 @@ impl EventInternalMetadata { set_property!(self, TxnId, obj.into_boxed_str()); } + /// The calculated auth event IDs, if it was set when the event was created. + #[getter] + fn get_calculated_auth_event_ids(&self) -> PyResult<&Vec> { + let s = get_property!(self, CalculatedAuthEventIDs)?; + Ok(s) + } + #[setter] + fn set_calculated_auth_event_ids(&mut self, obj: Vec) { + set_property!(self, CalculatedAuthEventIDs, obj); + } + /// The access token ID of the user who sent this event, if any. #[getter] fn get_token_id(&self) -> PyResult { diff --git a/synapse/api/room_versions.py b/synapse/api/room_versions.py index 2f98d7a8a86..3e068194240 100644 --- a/synapse/api/room_versions.py +++ b/synapse/api/room_versions.py @@ -36,6 +36,7 @@ class EventFormatVersions: ROOM_V3 = 2 # MSC1659-style $hash event id format: used for room v3 ROOM_V4_PLUS = 3 # MSC1884-style $hash format: introduced for room v4 ROOM_V11_HYDRA_PLUS = 4 # MSC4291 room IDs as hashes: introduced for room HydraV11 + ROOM_VMSC4242 = 5 # MSC4242 state dags: adds prev_state_events, removes auth_events KNOWN_EVENT_FORMAT_VERSIONS = { @@ -43,6 +44,7 @@ class EventFormatVersions: EventFormatVersions.ROOM_V3, EventFormatVersions.ROOM_V4_PLUS, EventFormatVersions.ROOM_V11_HYDRA_PLUS, + EventFormatVersions.ROOM_VMSC4242, } @@ -111,6 +113,8 @@ class RoomVersion: msc3931_push_features: tuple[str, ...] # values from PushRuleRoomFlag # MSC3757: Restricting who can overwrite a state event msc3757_enabled: bool + # MSC4242: Creates events with prev_state_events instead of auth_events and derives state from it. + msc4242_state_dags: bool # MSC4289: Creator power enabled msc4289_creator_power_enabled: bool # MSC4291: Room IDs as hashes of the create event @@ -137,6 +141,7 @@ class RoomVersions: enforce_int_power_levels=False, msc3931_push_features=(), msc3757_enabled=False, + msc4242_state_dags=False, msc4289_creator_power_enabled=False, msc4291_room_ids_as_hashes=False, ) @@ -159,6 +164,7 @@ class RoomVersions: enforce_int_power_levels=False, msc3931_push_features=(), msc3757_enabled=False, + msc4242_state_dags=False, msc4289_creator_power_enabled=False, msc4291_room_ids_as_hashes=False, ) @@ -181,6 +187,7 @@ class RoomVersions: enforce_int_power_levels=False, msc3931_push_features=(), msc3757_enabled=False, + msc4242_state_dags=False, msc4289_creator_power_enabled=False, msc4291_room_ids_as_hashes=False, ) @@ -203,6 +210,7 @@ class RoomVersions: enforce_int_power_levels=False, msc3931_push_features=(), msc3757_enabled=False, + msc4242_state_dags=False, msc4289_creator_power_enabled=False, msc4291_room_ids_as_hashes=False, ) @@ -225,6 +233,7 @@ class RoomVersions: enforce_int_power_levels=False, msc3931_push_features=(), msc3757_enabled=False, + msc4242_state_dags=False, msc4289_creator_power_enabled=False, msc4291_room_ids_as_hashes=False, ) @@ -247,6 +256,7 @@ class RoomVersions: enforce_int_power_levels=False, msc3931_push_features=(), msc3757_enabled=False, + msc4242_state_dags=False, msc4289_creator_power_enabled=False, msc4291_room_ids_as_hashes=False, ) @@ -269,6 +279,7 @@ class RoomVersions: enforce_int_power_levels=False, msc3931_push_features=(), msc3757_enabled=False, + msc4242_state_dags=False, msc4289_creator_power_enabled=False, msc4291_room_ids_as_hashes=False, ) @@ -291,6 +302,7 @@ class RoomVersions: enforce_int_power_levels=False, msc3931_push_features=(), msc3757_enabled=False, + msc4242_state_dags=False, msc4289_creator_power_enabled=False, msc4291_room_ids_as_hashes=False, ) @@ -313,6 +325,7 @@ class RoomVersions: enforce_int_power_levels=False, msc3931_push_features=(), msc3757_enabled=False, + msc4242_state_dags=False, msc4289_creator_power_enabled=False, msc4291_room_ids_as_hashes=False, ) @@ -335,6 +348,7 @@ class RoomVersions: enforce_int_power_levels=True, msc3931_push_features=(), msc3757_enabled=False, + msc4242_state_dags=False, msc4289_creator_power_enabled=False, msc4291_room_ids_as_hashes=False, ) @@ -358,6 +372,7 @@ class RoomVersions: enforce_int_power_levels=True, msc3931_push_features=(PushRuleRoomFlag.EXTENSIBLE_EVENTS,), msc3757_enabled=False, + msc4242_state_dags=False, msc4289_creator_power_enabled=False, msc4291_room_ids_as_hashes=False, ) @@ -381,6 +396,7 @@ class RoomVersions: enforce_int_power_levels=True, msc3931_push_features=(), msc3757_enabled=True, + msc4242_state_dags=False, msc4289_creator_power_enabled=False, msc4291_room_ids_as_hashes=False, ) @@ -403,6 +419,7 @@ class RoomVersions: enforce_int_power_levels=True, msc3931_push_features=(), msc3757_enabled=False, + msc4242_state_dags=False, msc4289_creator_power_enabled=False, msc4291_room_ids_as_hashes=False, ) @@ -426,6 +443,7 @@ class RoomVersions: enforce_int_power_levels=True, msc3931_push_features=(), msc3757_enabled=True, + msc4242_state_dags=False, msc4289_creator_power_enabled=False, msc4291_room_ids_as_hashes=False, ) @@ -438,8 +456,8 @@ class RoomVersions: special_case_aliases_auth=False, strict_canonicaljson=True, limit_notifications_power_levels=True, - implicit_room_creator=True, # Used by MSC3820 - updated_redaction_rules=True, # Used by MSC3820 + implicit_room_creator=True, + updated_redaction_rules=True, restricted_join_rule=True, restricted_join_rule_fix=True, knock_join_rule=True, @@ -448,6 +466,7 @@ class RoomVersions: enforce_int_power_levels=True, msc3931_push_features=(), msc3757_enabled=False, + msc4242_state_dags=False, msc4289_creator_power_enabled=True, # Changed from v11 msc4291_room_ids_as_hashes=True, # Changed from v11 ) @@ -470,9 +489,33 @@ class RoomVersions: enforce_int_power_levels=True, msc3931_push_features=(), msc3757_enabled=False, + msc4242_state_dags=False, msc4289_creator_power_enabled=True, # Changed from v11 msc4291_room_ids_as_hashes=True, # Changed from v11 ) + MSC4242v12 = RoomVersion( + "org.matrix.msc4242.12", # MSC4242 (State DAGs) based on room version "12" + RoomDisposition.UNSTABLE, + EventFormatVersions.ROOM_VMSC4242, + StateResolutionVersions.V2_1, + enforce_key_validity=True, + special_case_aliases_auth=False, + strict_canonicaljson=True, + limit_notifications_power_levels=True, + implicit_room_creator=True, + updated_redaction_rules=True, + restricted_join_rule=True, + restricted_join_rule_fix=True, + knock_join_rule=True, + msc3389_relation_redactions=False, + knock_restricted_join_rule=True, + enforce_int_power_levels=True, + msc3931_push_features=(), + msc3757_enabled=False, + msc4289_creator_power_enabled=True, + msc4291_room_ids_as_hashes=True, + msc4242_state_dags=True, + ) KNOWN_ROOM_VERSIONS: dict[str, RoomVersion] = { @@ -492,6 +535,7 @@ class RoomVersions: RoomVersions.V12, RoomVersions.MSC3757v10, RoomVersions.MSC3757v11, + RoomVersions.MSC4242v12, RoomVersions.HydraV11, ) } diff --git a/synapse/event_auth.py b/synapse/event_auth.py index 7098843742e..4bfe42f9bf2 100644 --- a/synapse/event_auth.py +++ b/synapse/event_auth.py @@ -62,7 +62,7 @@ RoomVersion, RoomVersions, ) -from synapse.events import is_creator +from synapse.events import FrozenEventVMSC4242, is_creator from synapse.state import CREATE_KEY from synapse.storage.databases.main.events_worker import EventRedactBehaviour from synapse.types import ( @@ -187,6 +187,73 @@ async def check_state_independent_auth_rules( # 1.5 Otherwise, allow return + # State DAGs 2. Considering the event's prev_state_events: + if event.room_version.msc4242_state_dags: + prev_state_events_ids = set(cast(FrozenEventVMSC4242, event).prev_state_events) + needed_prev_state_event_ids = { + event_id + for event_id in prev_state_events_ids + if not batched_auth_events or event_id not in batched_auth_events + } + # We need to do some checks on the events provided as prev_state_events, so we need to load them. + # Try to load the `prev_state_events` from `batched_auth_events` initially as that can save us + # a database hit. + prev_state_events = ( + {} + if not batched_auth_events + else { + e: batched_auth_events[e] + for e in prev_state_events_ids - needed_prev_state_event_ids + } + ) + if len(needed_prev_state_event_ids) > 0: + needed_prev_state_events = await store.get_events( + needed_prev_state_event_ids, + redact_behaviour=EventRedactBehaviour.as_is, + allow_rejected=True, + ) + prev_state_events.update(needed_prev_state_events) + if len(prev_state_events) != len(prev_state_events_ids): + # we should have all the prev state events by now, so if we do not, that suggests + # a synapse programming error + known_prev_state_event_ids = set(prev_state_events) + raise AssertionError( + f"Event {event.event_id} has unknown prev_state_events " + + f"{len(prev_state_events)} != {len(prev_state_events_ids)} " + + f"{prev_state_events_ids - known_prev_state_event_ids} missing " + + f"out of {prev_state_events_ids}" + ) + for prev_state_event in prev_state_events.values(): + # 2.1 If there are entries which do not belong in the same room, reject. + if prev_state_event.room_id != event.room_id: + raise AuthError( + 403, + "During auth for event %s in room %s, found event %s in prev_state_events " + "which belongs to a different room %s" + % ( + event.event_id, + event.room_id, + prev_state_event.event_id, + prev_state_event.room_id, + ), + ) + # 2.2 If there are entries which do not have a state_key, reject. + if not prev_state_event.is_state(): + raise AuthError( + 403, + f"During auth for event {event.event_id} in room {event.room_id}, event has a " + + f"prev_state_event which is not state: {prev_state_event.event_id}", + ) + # 2.3 If there are entries which were themselves rejected under the checks performed on + # receipt of a PDU, reject. + if prev_state_event.rejected_reason is not None: + raise AuthError( + 403, + f"During auth for event {event.event_id} in room {event.room_id}, event has a " + + f"prev_state_event which is rejected ({prev_state_event.rejected_reason}): " + + f"{prev_state_event.event_id}", + ) + # 2. Reject if event has auth_events that: ... auth_events: ChainMap[str, EventBase] = ChainMap() if batched_auth_events: @@ -472,6 +539,12 @@ def _check_create(event: "EventBase") -> None: if event.prev_event_ids(): raise AuthError(403, "Create event has prev events") + # State DAGs 1.2 If it has any prev_state_events, reject. + if event.room_version.msc4242_state_dags: + assert isinstance(event, FrozenEventVMSC4242) + if len(event.prev_state_events) > 0: + raise AuthError(403, "Create event has prev state events") + if event.room_version.msc4291_room_ids_as_hashes: # 1.2 If the create event has a room_id, reject if "room_id" in event: diff --git a/synapse/events/__init__.py b/synapse/events/__init__.py index e6162997dd7..12b5451643a 100644 --- a/synapse/events/__init__.py +++ b/synapse/events/__init__.py @@ -44,10 +44,7 @@ ) from synapse.api.room_versions import EventFormatVersions, RoomVersion, RoomVersions from synapse.synapse_rust.events import EventInternalMetadata -from synapse.types import ( - JsonDict, - StrCollection, -) +from synapse.types import JsonDict, StateKey, StrCollection from synapse.util.caches import intern_dict from synapse.util.duration import Duration from synapse.util.frozenutils import freeze @@ -579,9 +576,60 @@ def auth_event_ids(self) -> StrCollection: return [*self._dict["auth_events"], create_event_id] +class FrozenEventVMSC4242(FrozenEventV4): + """FrozenEventVMSC4242, which differs from FrozenEventV4 only in the addition of prev_state_events""" + + format_version = EventFormatVersions.ROOM_VMSC4242 + prev_state_events: DictProperty[list[str]] = DictProperty("prev_state_events") + + def __init__( + self, + event_dict: JsonDict, + room_version: RoomVersion, + internal_metadata_dict: JsonDict | None = None, + rejected_reason: str | None = None, + ): + # Similar to how we assert event_id isn't in V2+ events, we do the same with auth_events. + # We don't expect `auth_events` in the wire format because we calculate it from prev_state_events. + assert "auth_events" not in event_dict + super().__init__( + event_dict=event_dict, + room_version=room_version, + internal_metadata_dict=internal_metadata_dict, + rejected_reason=rejected_reason, + ) + + def auth_event_ids(self) -> StrCollection: + """Returns the list of _calculated_ auth event IDs. + + Returns: + The list of event IDs of this event's auth events + """ + # Catches cases where we accidentally call auth_event_ids() prior to calculating what they + # actually are. The exception being the m.room.create event which has no auth events. + if self.type != EventTypes.Create: + assert len(self.internal_metadata.calculated_auth_event_ids) > 0 + return self.internal_metadata.calculated_auth_event_ids + + def __repr__(self) -> str: + rejection = f"REJECTED={self.rejected_reason}, " if self.rejected_reason else "" + + return ( + f"<{self.__class__.__name__} " + f"{rejection}" + f"event_id={self.event_id}, " + f"type={self.get('type')}, " + f"state_key={self.get('state_key')}, " + f"prev_events={self.get('prev_events')}, " + f"prev_state_events={self.get('prev_state_events')}, " + f"outlier={self.internal_metadata.is_outlier()}" + ">" + ) + + def _event_type_from_format_version( format_version: int, -) -> type[FrozenEvent | FrozenEventV2 | FrozenEventV3]: +) -> type[FrozenEvent | FrozenEventV2 | FrozenEventV3 | FrozenEventVMSC4242]: """Returns the python type to use to construct an Event object for the given event format version. @@ -598,6 +646,8 @@ def _event_type_from_format_version( return FrozenEventV2 elif format_version == EventFormatVersions.ROOM_V4_PLUS: return FrozenEventV3 + elif format_version == EventFormatVersions.ROOM_VMSC4242: + return FrozenEventVMSC4242 elif format_version == EventFormatVersions.ROOM_V11_HYDRA_PLUS: return FrozenEventV4 else: @@ -659,6 +709,24 @@ def relation_from_event(event: EventBase) -> _EventRelation | None: return _EventRelation(parent_id, rel_type, aggregation_key) +def event_exists_in_state_dag( + event: Union["EventBase", "EventBuilder", "EventMetadata", "StateKey"], +) -> bool: + """Given an event, returns true if this event should form part of the state DAG. + Only valid for room versions which use a state DAG (MSC4242).""" + state_key = None + if isinstance(event, EventMetadata): + state_key = event.state_key + elif isinstance(event, tuple): # StateKey + # can't use StateKey else you get: + # "Subscripted generics cannot be used with class and instance checks" + state_key = event[1] + else: + state_key = event.state_key if event.is_state() else None + + return state_key is not None + + def is_creator(create: EventBase, user_id: str) -> bool: """ Return true if the provided user ID is the room creator. @@ -693,3 +761,13 @@ class StrippedStateEvent: state_key: str sender: str content: dict[str, Any] + + +@attr.s(slots=True, frozen=True, auto_attribs=True) +class EventMetadata: + """Returned by `get_metadata_for_events`""" + + room_id: str + event_type: str + state_key: str | None + rejection_reason: str | None diff --git a/synapse/events/builder.py b/synapse/events/builder.py index 2cd1bf6106f..8920eef3e65 100644 --- a/synapse/events/builder.py +++ b/synapse/events/builder.py @@ -132,6 +132,7 @@ async def build( prev_event_ids: list[str], auth_event_ids: list[str] | None, depth: int | None = None, + prev_state_events: list[str] | None = None, ) -> EventBase: """Transform into a fully signed and hashed event @@ -143,18 +144,61 @@ async def build( depth: Override the depth used to order the event in the DAG. Should normally be set to None, which will cause the depth to be calculated based on the prev_events. - + prev_state_events: The event IDs to use as prev_state_events. + Only applicable on MSC4242 state DAG rooms. If this is supplied, auth_event_ids + must not be specified unless this event is part of a batch such that the builder + will be unable to compute the auth_event_ids due to the events not being persisted + yet. Returns: The signed and hashed event. """ + # If the caller specifies this, make sure the room version supports it. + if prev_state_events: + assert self.room_version.msc4242_state_dags + if self.room_version.msc4242_state_dags: + assert prev_state_events is not None + if self.room_id: + state_ids = await self._state.compute_state_after_events( + self.room_id, + prev_state_events, + state_filter=StateFilter.from_types( + auth_types_for_event(self.room_version, self) + ), + await_full_state=False, + ) + # When we create rooms we only insert the create+member events, and batch the rest. + # Therefore, we may not have state_ids from compute_state_after_events as the + # prev_state_events are unknown. If this happens, the caller provides the auth events + # to use instead. + calculated_auth_event_ids: list[ + str + ] = [] # assume it's the create event which has [] + if len(state_ids) == 0 and len(prev_state_events) > 0: + # it's a batched event, so we should have been provided the auth_events + assert auth_event_ids and len(auth_event_ids) > 0 + calculated_auth_event_ids = auth_event_ids + else: + calculated_auth_event_ids = ( + self._event_auth_handler.compute_auth_events(self, state_ids) + ) + else: + # event is a state DAG event and is the create event (room_id is not provided), + # therefore there are no auth_events. + calculated_auth_event_ids = [] + assert self.type == EventTypes.Create and self.state_key == "" + self.internal_metadata.calculated_auth_event_ids = calculated_auth_event_ids + auth_event_ids = calculated_auth_event_ids + # Create events always have empty auth_events. if self.type == EventTypes.Create and self.is_state() and self.state_key == "": auth_event_ids = [] # Calculate auth_events for non-create events + # this block must not be hit for MSC4242 rooms as it resolves state with prev_events if auth_event_ids is None: # Every non-create event must have a room ID assert self.room_id is not None + assert not self.room_version.msc4242_state_dags state_ids = await self._state.compute_state_after_events( self.room_id, prev_event_ids, @@ -231,7 +275,6 @@ async def build( # rejected by other servers (and so that they can be persisted in # the db) depth = min(depth, MAX_DEPTH) - event_dict: dict[str, Any] = { "auth_events": auth_events, "prev_events": prev_events, @@ -241,8 +284,6 @@ async def build( "unsigned": self.unsigned, "depth": depth, } - if self.room_id is not None: - event_dict["room_id"] = self.room_id if self.room_version.msc4291_room_ids_as_hashes: # In MSC4291: the create event has no room ID as the create event ID /is/ the room ID. @@ -262,6 +303,14 @@ async def build( auth_event_ids.remove(create_event_id) event_dict["auth_events"] = auth_event_ids + if self.room_version.msc4242_state_dags: + # Auth events are removed entirely on state DAG rooms + event_dict.pop("auth_events") + assert prev_state_events is not None + event_dict["prev_state_events"] = prev_state_events + if self.room_id is not None: + event_dict["room_id"] = self.room_id + if self.is_state(): event_dict["state_key"] = self._state_key diff --git a/synapse/events/utils.py b/synapse/events/utils.py index b79a68f589a..fedf7ee72d9 100644 --- a/synapse/events/utils.py +++ b/synapse/events/utils.py @@ -145,6 +145,10 @@ def prune_event_dict(room_version: RoomVersion, event_dict: JsonDict) -> JsonDic # Earlier room versions from had additional allowed keys. if not room_version.updated_redaction_rules: allowed_keys.extend(["prev_state", "membership", "origin"]) + # Custom room versions add new allowed keys and remove others + if room_version.msc4242_state_dags: + allowed_keys.extend(["prev_state_events"]) + allowed_keys.remove("auth_events") event_type = event_dict["type"] diff --git a/synapse/events/validator.py b/synapse/events/validator.py index b27f8a942af..ff22b2287f2 100644 --- a/synapse/events/validator.py +++ b/synapse/events/validator.py @@ -63,14 +63,17 @@ def validate_new(self, event: EventBase, config: HomeServerConfig) -> None: if event.format_version == EventFormatVersions.ROOM_V1_V2: EventID.from_string(event.event_id) - required = [ + required = { "auth_events", "content", "hashes", "prev_events", "sender", "type", - ] + } + if event.room_version.msc4242_state_dags: + required.remove("auth_events") + required.add("prev_state_events") for k in required: if k not in event: diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py index ba738ad65e7..bc77b0bac6c 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -42,7 +42,12 @@ import attr from prometheus_client import Counter -from synapse.api.constants import Direction, EventContentFields, EventTypes, Membership +from synapse.api.constants import ( + Direction, + EventContentFields, + EventTypes, + Membership, +) from synapse.api.errors import ( CodeMessageException, Codes, @@ -119,6 +124,8 @@ class SendJoinResult: origin: str state: list[EventBase] auth_chain: list[EventBase] + # Only valid for state DAG rooms (MSC4242) + state_dag: list[EventBase] | None # True if 'state' elides non-critical membership events partial_state: bool @@ -658,7 +665,10 @@ async def get_pdu( @trace @tag_args async def get_room_state_ids( - self, destination: str, room_id: str, event_id: str + self, + destination: str, + room_id: str, + event_id: str, ) -> tuple[list[str], list[str]]: """Calls the /state_ids endpoint to fetch the state at a particular point in the room, and the auth events for the given event @@ -670,7 +680,9 @@ async def get_room_state_ids( InvalidResponseError: if fields in the response have the wrong type. """ result = await self.transport_layer.get_room_state_ids( - destination, room_id, event_id=event_id + destination, + room_id, + event_id=event_id, ) state_event_ids = result["pdu_ids"] @@ -1178,7 +1190,6 @@ async def send_request(destination: str) -> SendJoinResult: response = await self._do_send_join( room_version, destination, pdu, omit_members=partial_state ) - # If an event was returned (and expected to be returned): # # * Ensure it has the same event ID (note that the event ID is a hash @@ -1201,14 +1212,22 @@ async def send_request(destination: str) -> SendJoinResult: event = pdu state = response.state - auth_chain = response.auth_events - + auth_events = response.auth_events create_event = None for e in state: if (e.type, e.state_key) == (EventTypes.Create, ""): create_event = e break + if room_version.msc4242_state_dags and response.state_dag: + # assign to auth_events to reuse the below code which ultimately just does + # sig/hash checks. We'll set the right field in SendJoinResult later. + auth_events = response.state_dag + for e in response.state_dag: + if (e.type, e.state_key) == (EventTypes.Create, ""): + create_event = e + break + if create_event is None: # If the state doesn't have a create event then the room is # invalid, and it would fail auth checks anyway. @@ -1227,7 +1246,7 @@ async def send_request(destination: str) -> SendJoinResult: ) logger.info( - "Processing from send_join %d events", len(state) + len(auth_chain) + "Processing from send_join %d events", len(state) + len(auth_events) ) # We now go and check the signatures and hashes for the event. Note @@ -1246,7 +1265,7 @@ async def _execute(pdu: EventBase) -> None: valid_pdus_map[valid_pdu.event_id] = valid_pdu await concurrently_execute( - _execute, itertools.chain(state, auth_chain), 10000 + _execute, itertools.chain(state, auth_events), 10000 ) # NB: We *need* to copy to ensure that we don't have multiple @@ -1259,27 +1278,28 @@ async def _execute(pdu: EventBase) -> None: signed_auth = [ valid_pdus_map[p.event_id] - for p in auth_chain + for p in auth_events if p.event_id in valid_pdus_map ] # NB: We *need* to copy to ensure that we don't have multiple # references being passed on, as that causes... issues. + # TODO(kegan): It's unclear why we only need to do this for state and not auth_events for s in signed_state: s.internal_metadata = s.internal_metadata.copy() - # double-check that the auth chain doesn't include a different create event - auth_chain_create_events = [ + # double-check that the auth events doesn't include a different create event + auth_events_create_events = [ e.event_id for e in signed_auth if (e.type, e.state_key) == (EventTypes.Create, "") ] - if auth_chain_create_events and auth_chain_create_events != [ + if auth_events_create_events and auth_events_create_events != [ create_event.event_id ]: raise InvalidResponseError( "Unexpected create event(s) in auth chain: %s" - % (auth_chain_create_events,) + % (auth_events_create_events,) ) servers_in_room = None @@ -1301,10 +1321,21 @@ async def _execute(pdu: EventBase) -> None: # Fix things up in case the remote homeserver is badly behaved. servers_in_room.add(destination) + signed_auth_events = signed_auth + signed_state_dag = None + if room_version.msc4242_state_dags: + # We previously set the state dag to auth_events so re-assign it correctly + signed_state_dag = signed_auth + # Ensure the caller cannot accidentally use these values even if the server + # returned them. + signed_state = [] + signed_auth_events = [] + return SendJoinResult( event=event, state=signed_state, - auth_chain=signed_auth, + auth_chain=signed_auth_events, + state_dag=signed_state_dag, origin=destination, partial_state=response.members_omitted, servers_in_room=servers_in_room or frozenset(), @@ -1608,6 +1639,7 @@ async def get_missing_events( limit: int, min_depth: int, timeout: int, + state_dag: bool = False, ) -> list[EventBase]: """Tries to fetch events we are missing. This is called when we receive an event without having received all of its ancestors. @@ -1623,6 +1655,7 @@ async def get_missing_events( limit: Maximum number of events to return. min_depth: Minimum depth of events to return. timeout: Max time to wait in ms + state_dag: True to walk the state DAG (MSC4242 rooms) """ try: content = await self.transport_layer.get_missing_events( @@ -1633,6 +1666,7 @@ async def get_missing_events( limit=limit, min_depth=min_depth, timeout=timeout, + state_dag=state_dag, ) room_version = await self.store.get_room_version(room_id) diff --git a/synapse/federation/federation_server.py b/synapse/federation/federation_server.py index eff6d637894..5ab07e561f3 100644 --- a/synapse/federation/federation_server.py +++ b/synapse/federation/federation_server.py @@ -29,6 +29,8 @@ Callable, Collection, Mapping, + Sequence, + cast, ) from prometheus_client import Counter, Gauge, Histogram @@ -54,7 +56,7 @@ ) from synapse.api.room_versions import KNOWN_ROOM_VERSIONS, RoomVersion from synapse.crypto.event_signing import compute_event_signature -from synapse.events import EventBase +from synapse.events import EventBase, FrozenEventVMSC4242 from synapse.events.snapshot import EventPersistencePair from synapse.federation.federation_base import ( FederationBase, @@ -597,7 +599,10 @@ async def _process_edu(edu_dict: JsonDict) -> None: ) async def on_room_state_request( - self, origin: str, room_id: str, event_id: str + self, + origin: str, + room_id: str, + event_id: str, ) -> tuple[int, JsonDict]: await self._event_auth_handler.assert_host_in_room(room_id, origin) origin_host, _ = parse_server_name(origin) @@ -621,7 +626,10 @@ async def on_room_state_request( @trace @tag_args async def on_state_ids_request( - self, origin: str, room_id: str, event_id: str + self, + origin: str, + room_id: str, + event_id: str, ) -> tuple[int, JsonDict]: if not event_id: raise NotImplementedError("Specify an event") @@ -642,17 +650,27 @@ async def on_state_ids_request( @trace @tag_args async def _on_state_ids_request_compute( - self, room_id: str, event_id: str + self, + room_id: str, + event_id: str, ) -> JsonDict: - state_ids = await self.handler.get_state_ids_for_pdu(room_id, event_id) + state_ids = await self.handler.get_state_ids_for_pdu( + room_id, + event_id, + ) auth_chain_ids = await self.store.get_auth_chain_ids(room_id, state_ids) return {"pdu_ids": state_ids, "auth_chain_ids": list(auth_chain_ids)} async def _on_context_state_request_compute( - self, room_id: str, event_id: str + self, + room_id: str, + event_id: str, ) -> dict[str, list]: pdus: Collection[EventBase] - event_ids = await self.handler.get_state_ids_for_pdu(room_id, event_id) + event_ids = await self.handler.get_state_ids_for_pdu( + room_id, + event_id, + ) pdus = await self.store.get_events_as_list(event_ids) auth_chain = await self.store.get_auth_chain( @@ -772,6 +790,10 @@ async def on_send_join_request( origin, content, Membership.JOIN, room_id ) + if event.room_version.msc4242_state_dags and caller_supports_partial_state: + # TODO(kegan): for now, MSC4242 won't support partial state for ease of prototyping. + caller_supports_partial_state = False + prev_state_ids = await context.get_prev_state_ids() state_event_ids: Collection[str] @@ -782,15 +804,31 @@ async def on_send_join_request( event, prev_state_ids, summary ) servers_in_room = await self.state.get_hosts_in_room_at_events( - room_id, event_ids=event.prev_event_ids() + room_id, + event_ids=event.prev_state_events + if isinstance(event, FrozenEventVMSC4242) + else event.prev_event_ids(), ) else: state_event_ids = prev_state_ids.values() servers_in_room = None - auth_chain_event_ids = await self.store.get_auth_chain_ids( - room_id, state_event_ids - ) + state_dag = None + auth_chain_event_ids = set() + if event.room_version.msc4242_state_dags: + assert isinstance(event, FrozenEventVMSC4242) + # NOTE: we don't return the state dag for forward extremities that aren't part of this + # join event to make it easier for the receiving server to set their own forward + # extremities (they are equal to the join event's prev_state_events). This means we may + # fail to sync concurrent forks not on the path to the join event, but this is an + # outstanding problem in general. + state_dag = await self.store.get_state_dag( + room_id, set(event.prev_state_events) + ) + else: + auth_chain_event_ids = await self.store.get_auth_chain_ids( + room_id, state_event_ids + ) # if the caller has opted in, we can omit any auth_chain events which are # already in state_event_ids @@ -807,13 +845,18 @@ async def on_send_join_request( resp = { "event": event_json, "state": serialize_and_filter_pdus(state_events, time_now), - "auth_chain": serialize_and_filter_pdus(auth_chain_events, time_now), "members_omitted": caller_supports_partial_state, } + if state_dag is None: + resp["auth_chain"] = serialize_and_filter_pdus(auth_chain_events, time_now) + else: + resp["state_dag"] = serialize_and_filter_pdus( + cast(Sequence[EventBase], state_dag.values()), time_now + ) + del resp["state"] if servers_in_room is not None: resp["servers_in_room"] = list(servers_in_room) - return resp async def on_make_leave_request( @@ -1110,6 +1153,7 @@ async def on_get_missing_events( earliest_events: list[str], latest_events: list[str], limit: int, + walk_state_dag: bool = False, ) -> dict[str, list]: async with self._server_linearizer.queue((origin, room_id)): origin_host, _ = parse_server_name(origin) @@ -1124,7 +1168,7 @@ async def on_get_missing_events( ) missing_events = await self.handler.on_get_missing_events( - origin, room_id, earliest_events, latest_events, limit + origin, room_id, earliest_events, latest_events, limit, walk_state_dag ) if len(missing_events) < 5: diff --git a/synapse/federation/sender/__init__.py b/synapse/federation/sender/__init__.py index f7240c2f7f9..ea5c63114c8 100644 --- a/synapse/federation/sender/__init__.py +++ b/synapse/federation/sender/__init__.py @@ -148,7 +148,7 @@ import synapse.metrics from synapse.api.constants import EventTypes, Membership from synapse.api.presence import UserPresenceState -from synapse.events import EventBase +from synapse.events import EventBase, FrozenEventVMSC4242 from synapse.federation.sender.per_destination_queue import ( CATCHUP_RETRY_INTERVAL, PerDestinationQueue, @@ -660,7 +660,10 @@ async def handle_event(event: EventBase) -> None: # banned then it won't receive the event because it won't # be in the room after the ban. destinations = await self.state.get_hosts_in_room_at_events( - event.room_id, event_ids=event.prev_event_ids() + event.room_id, + event_ids=event.prev_state_events + if isinstance(event, FrozenEventVMSC4242) + else event.prev_event_ids(), ) except Exception: logger.exception( diff --git a/synapse/federation/transport/client.py b/synapse/federation/transport/client.py index 35d3c30c699..62d09114a25 100644 --- a/synapse/federation/transport/client.py +++ b/synapse/federation/transport/client.py @@ -69,7 +69,10 @@ def shutdown(self) -> None: self.client.shutdown() async def get_room_state_ids( - self, destination: str, room_id: str, event_id: str + self, + destination: str, + room_id: str, + event_id: str, ) -> JsonDict: """Requests the IDs of all state for a given room at the given event. @@ -78,22 +81,26 @@ async def get_room_state_ids( to get the state from. room_id: the room we want the state of event_id: The event we want the context at. - Returns: Results in a dict received from the remote homeserver. """ logger.debug("get_room_state_ids dest=%s, room=%s", destination, room_id) path = _create_v1_path("/state_ids/%s", room_id) + qps = {"event_id": event_id} return await self.client.get_json( destination, path=path, - args={"event_id": event_id}, + args=qps, try_trailing_slash_on_400=True, ) async def get_room_state( - self, room_version: RoomVersion, destination: str, room_id: str, event_id: str + self, + room_version: RoomVersion, + destination: str, + room_id: str, + event_id: str, ) -> "StateRequestResponse": """Requests the full state for a given room at the given event. @@ -103,15 +110,15 @@ async def get_room_state( to get the state from. room_id: the room we want the state of event_id: The event we want the context at. - Returns: Results in a dict received from the remote homeserver. """ path = _create_v1_path("/state/%s", room_id) + qps = {"event_id": event_id} return await self.client.get_json( destination, path=path, - args={"event_id": event_id}, + args=qps, # This can take a looooooong time for large rooms. Give this a generous # timeout of 10 minutes to avoid the partial state resync timing out early # and trying a bunch of servers who haven't seen our join yet. @@ -787,18 +794,21 @@ async def get_missing_events( limit: int, min_depth: int, timeout: int, + state_dag: bool, ) -> JsonDict: path = _create_v1_path("/get_missing_events/%s", room_id) - + data = { + "limit": int(limit), + "min_depth": int(min_depth), + "earliest_events": earliest_events, + "latest_events": latest_events, + } + if state_dag: + data["org.matrix.msc4242.state_dag"] = True return await self.client.post_json( destination=destination, path=path, - data={ - "limit": int(limit), - "min_depth": int(min_depth), - "earliest_events": earliest_events, - "latest_events": latest_events, - }, + data=data, timeout=timeout, ) @@ -993,6 +1003,9 @@ class SendJoinResponse: state: list[EventBase] # The raw join event from the /send_join response. event_dict: JsonDict + # MSC4242: State DAGs. Always included for state dag rooms, else an empty list. + # Replaces auth_events. + state_dag: list[EventBase] # The parsed join event from the /send_join response. This will be None if # "event" is not included in the response. event: EventBase | None = None @@ -1079,7 +1092,7 @@ class SendJoinParser(ByteParser[SendJoinResponse]): MAX_RESPONSE_SIZE = 500 * 1024 * 1024 def __init__(self, room_version: RoomVersion, v1_api: bool): - self._response = SendJoinResponse([], [], event_dict={}) + self._response = SendJoinResponse([], [], event_dict={}, state_dag=[]) self._room_version = room_version self._coros: list[Generator[None, bytes, None]] = [] @@ -1123,6 +1136,15 @@ def __init__(self, room_version: RoomVersion, v1_api: bool): ) ) + if room_version.msc4242_state_dags: + self._coros.append( + ijson.items_coro( + _event_list_parser(room_version, self._response.state_dag), + prefix + "state_dag.item", + use_float=True, + ) + ) + def write(self, data: bytes) -> int: for c in self._coros: c.send(data) diff --git a/synapse/federation/transport/server/federation.py b/synapse/federation/transport/server/federation.py index d783e6da518..e1d2d162af9 100644 --- a/synapse/federation/transport/server/federation.py +++ b/synapse/federation/transport/server/federation.py @@ -638,6 +638,7 @@ async def on_POST( limit = int(content.get("limit", 10)) earliest_events = content.get("earliest_events", []) latest_events = content.get("latest_events", []) + walk_state_dag = content.get("org.matrix.msc4242.state_dag", False) result = await self.handler.on_get_missing_events( origin, @@ -645,6 +646,7 @@ async def on_POST( earliest_events=earliest_events, latest_events=latest_events, limit=limit, + walk_state_dag=walk_state_dag, ) return 200, result diff --git a/synapse/handlers/admin.py b/synapse/handlers/admin.py index 2fb0e5814f2..49af2cf5e04 100644 --- a/synapse/handlers/admin.py +++ b/synapse/handlers/admin.py @@ -485,9 +485,18 @@ async def _redact_all_events( event_dict["redacts"] = event.event_id try: + prev_state_events = None + if room_version.msc4242_state_dags: + # TODO(kegan): better way to typecast and get at this field? + prev_state_events = event.get_dict().get( + "prev_state_events", None + ) + assert prev_state_events is not None, ( + "Parent event of redaction has no `prev_state_events` which should be impossible as `prev_state_events` is a required field in MSC4242 rooms" + ) # set the prev event to the offending message to allow for redactions # to be processed in the case where the user has been kicked/banned before - # redactions are requested + # redactions are requested. ( redaction, _, @@ -496,6 +505,7 @@ async def _redact_all_events( event_dict, prev_event_ids=[event.event_id], ratelimit=False, + prev_state_events=prev_state_events, ) except Exception as ex: logger.info( diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index 14805ac80f7..88c1abe9e79 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -669,6 +669,13 @@ async def do_invite_join( logger.debug("do_invite_join auth_chain: %s", auth_chain) logger.debug("do_invite_join state: %s", state) + # If the 'state_dag' field is set, everything will be derived from it. + if ret.state_dag: + logger.debug("do_invite_join state_dag: %s", ret.state_dag) + state = ret.state_dag + # TODO(kegan): is this actually important for processing? Shouldn't we be using + # the actual DAG here? + state.sort(key=lambda e: e.depth) logger.debug("do_invite_join event: %s", event) @@ -965,6 +972,7 @@ async def on_make_join_request( # Note that this requires the /send_join request to come back to the # same server. prev_event_ids = None + prev_state_events = None if room_version.restricted_join_rule: # Note that the room's state can change out from under us and render our # nice join rules-conformant event non-conformant by the time we build the @@ -981,6 +989,9 @@ async def on_make_join_request( state_ids = await self._state_storage_controller.get_current_state_ids( room_id ) + prev_state_events = list( + await self.store.get_state_dag_extremities(room_id) + ) if await self._event_auth_handler.has_restricted_join_rules( state_ids, room_version ): @@ -1021,6 +1032,7 @@ async def on_make_join_request( ) = await self.event_creation_handler.create_new_client_event( builder=builder, prev_event_ids=prev_event_ids, + prev_state_events=prev_state_events, ) except SynapseError as e: logger.warning("Failed to create join to %s because %s", room_id, e) @@ -1301,7 +1313,11 @@ async def on_make_knock_request( @trace @tag_args - async def get_state_ids_for_pdu(self, room_id: str, event_id: str) -> list[str]: + async def get_state_ids_for_pdu( + self, + room_id: str, + event_id: str, + ) -> list[str]: """Returns the state at the event. i.e. not including said event.""" event = await self.store.get_event(event_id, check_room_id=room_id) if event.internal_metadata.outlier: @@ -1412,11 +1428,17 @@ async def on_get_missing_events( earliest_events: list[str], latest_events: list[str], limit: int, + walk_state_dag: bool, ) -> list[EventBase]: # We allow partially joined rooms since in this case we are filtering out # non-local events in `filter_events_for_server`. await self._event_auth_handler.assert_host_in_room(room_id, origin, True) + if walk_state_dag: + return await self.on_get_missing_events_state_dag( + room_id, earliest_events, latest_events, limit + ) + # Only allow up to 20 events to be retrieved per request. limit = min(limit, 20) @@ -1439,6 +1461,32 @@ async def on_get_missing_events( return missing_events + async def on_get_missing_events_state_dag( + self, + room_id: str, + earliest_events: list[str], + latest_events: list[str], + limit: int, + ) -> list[EventBase]: + """Processes a /get_missing_events request for the state DAG. + + This is similar to processing the normal DAG with a few notable exceptions: + * There is no max 20 limit applied. As the entire state DAG needs to be filled in, we + cannot arbitrarily set a low limit. If the state DAG delta is 1000s of events, we need + to rely on the sender to set sensible limits depending on the bandwidth/round trip + tradeoff. We rely on existing server rate limits here to prevent abuse. + * We do not filter any events in the state DAG. History visibility does not filter out + delivery of auth chain events, so neither should this. All of the returned events will + be treated as outliers and as such will not be delivered to clients. + """ + missing_events = await self.store.get_missing_events_state_dag( + room_id=room_id, + earliest_events=earliest_events, + latest_events=latest_events, + limit=limit, + ) + return missing_events + async def exchange_third_party_invite( self, sender_user_id: str, target_user_id: str, room_id: str, signed: JsonDict ) -> None: diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index e314180e122..701933aeb05 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -60,7 +60,7 @@ check_state_independent_auth_rules, validate_event_for_room_version, ) -from synapse.events import EventBase +from synapse.events import EventBase, FrozenEventVMSC4242, event_exists_in_state_dag from synapse.events.snapshot import ( EventContext, EventPersistencePair, @@ -522,8 +522,10 @@ async def process_remote_join( Args: origin: Where the events came from room_id: - auth_events - state + auth_events: The auth chain from the send_join response. For MSC4242 State DAG rooms, + this is ignored. + state The current state of the room. For MSC4242 rooms, this is the state DAG and so + includes the auth chains for state. event room_version: The room version we expect this room to have, and will raise if it doesn't match the version in the create event. @@ -555,6 +557,14 @@ async def process_remote_join( if room_version.identifier != room_version_id: raise SynapseError(400, "Room version mismatch") + if room_version.msc4242_state_dags: + # We should be provided with a connected state DAG, so check it before persisting them. + # If we find a gap, the response is invalid so refuse the join. + if not is_state_dag_connected( + [ev for ev in state if isinstance(ev, FrozenEventVMSC4242)] + ): + raise SynapseError(502, "State DAG is not connected") + # persist the auth chain and state events. # # any invalid events here will be marked as rejected, and we'll carry on. @@ -566,9 +576,14 @@ async def process_remote_join( # signatures right now doesn't mean that we will *never* be able to, so it # is premature to reject them. # - await self._auth_and_persist_outliers( - room_id, itertools.chain(auth_events, state) + has_rejected_events = await self._auth_and_persist_outliers( + room_id, + # for state DAG rooms, auth_events = [] so this is fine + itertools.chain(auth_events, state), + from_send_join=True, ) + if room_version.msc4242_state_dags and has_rejected_events: + raise SynapseError(502, "State DAG included rejected events") # and now persist the join event itself. logger.info( @@ -620,6 +635,19 @@ async def process_remote_join( ), ) ) + elif room_version.msc4242_state_dags: + assert isinstance(event, FrozenEventVMSC4242) + # we don't blindly trust the 'current state', and instead do state res to calculate it, + # which we can do because we just did _auth_and_persist_outliers on the state DAG. + state_before = ( + await self._state_handler.resolve_state_groups_for_events( + event.room_id, + event.prev_state_events, + ) + ) + state_ids_before_event = await state_before.get_state( + self._state_storage_controller + ) else: state_ids_before_event = { (e.type, e.state_key): e.event_id for e in state @@ -1119,6 +1147,10 @@ async def _compute_event_context_with_maybe_missing_prevs( In other words: we should only call this method if `event` has been *pulled* as part of a batch of missing prev events, or similar. + For state DAG rooms, we simply fill in the state DAG instead of asking the remote server + for the state after each missing `prev_event`, thereby avoiding security issues that can + occur if the remote server lies about the room state. + Params: dest: the remote server to ask for state at the missing prevs. Typically, this will be the server we got `event` from. @@ -1136,6 +1168,13 @@ async def _compute_event_context_with_maybe_missing_prevs( """ room_id = event.room_id event_id = event.event_id + room_version = await self._store.get_room_version_id(room_id) + room_version_obj = KNOWN_ROOM_VERSIONS[room_version] + + if room_version_obj.msc4242_state_dags: + return await self._compute_event_context_with_maybe_missing_prevs_state_dag( + dest, event + ) prevs = set(event.prev_event_ids()) seen = await self._store.have_events_in_timeline(prevs) @@ -1195,7 +1234,10 @@ async def _compute_event_context_with_maybe_missing_prevs( # by the get_pdu_cache in federation_client. remote_state_map = ( await self._get_state_ids_after_missing_prev_event( - dest, room_id, p + dest, + room_id, + p, + event.room_version, ) ) @@ -1212,7 +1254,6 @@ async def _compute_event_context_with_maybe_missing_prevs( # we don't need this any more, let's delete it. del ours - room_version = await self._store.get_room_version_id(room_id) state_map = await self._state_resolution_handler.resolve_events_with_store( room_id, room_version, @@ -1237,6 +1278,24 @@ async def _compute_event_context_with_maybe_missing_prevs( event, state_ids_before_event=state_map, partial_state=partial_state ) + async def _compute_event_context_with_maybe_missing_prevs_state_dag( + self, dest: str, event: EventBase + ) -> EventContext: + # before we can compute the event context we need to calculate the auth event IDs + # for this event. Before we can do that, we need to calculate the auth state before + # this event. Before we can do that, we need to fill in the state DAG for the + # prev_state_events in this event. + assert isinstance(event, FrozenEventVMSC4242) + missed_events = await self._fetch_missing_state_dag_events(dest, event) + await self._auth_and_persist_outliers(event.room_id, missed_events) + ( + ctx, + calculated_auth_event_ids, + ) = await self._calculate_state_dag_context(event) + event.internal_metadata.calculated_auth_event_ids = calculated_auth_event_ids + + return ctx + @trace @tag_args async def _get_state_ids_after_missing_prev_event( @@ -1244,6 +1303,7 @@ async def _get_state_ids_after_missing_prev_event( destination: str, room_id: str, event_id: str, + room_version: RoomVersion, ) -> StateMap[str]: """Requests all of the room state at a given event from a remote homeserver. @@ -1251,7 +1311,7 @@ async def _get_state_ids_after_missing_prev_event( destination: The remote homeserver to query for the state. room_id: The id of the room we're interested in. event_id: The id of the event we want the state at. - + room_version: The version of the room Returns: The event ids of the state *after* the given event. @@ -1271,7 +1331,9 @@ async def _get_state_ids_after_missing_prev_event( state_event_ids, auth_event_ids, ) = await self._federation_client.get_room_state_ids( - destination, room_id, event_id=event_id + destination, + room_id, + event_id=event_id, ) logger.debug( @@ -1280,6 +1342,92 @@ async def _get_state_ids_after_missing_prev_event( len(auth_event_ids), ) + # ensure all these events are in the DB before we continue further. + await self._persist_state_response( + destination, room_id, event_id, state_event_ids, auth_event_ids + ) + + # if we couldn't get the prev event in question, that's a problem. + remote_event = await self._store.get_event( + event_id, + allow_none=True, + allow_rejected=True, + redact_behaviour=EventRedactBehaviour.as_is, + ) + if not remote_event: + raise Exception("Unable to get missing prev_event %s" % (event_id,)) + + # We now need to fill out the state map, which involves fetching the + # type and state key for each event ID in the state. + state_map = {} + + event_metadata = await self._store.get_metadata_for_events(state_event_ids) + for state_event_id, metadata in event_metadata.items(): + if metadata.room_id != room_id: + # This is a bogus situation, but since we may only discover it a long time + # after it happened, we try our best to carry on, by just omitting the + # bad events from the returned state set. + # + # This can happen if a remote server claims that the state or + # auth_events at an event in room A are actually events in room B + logger.warning( + "Remote server %s claims event %s in room %s is an auth/state " + "event in room %s", + destination, + state_event_id, + metadata.room_id, + room_id, + ) + continue + + if metadata.state_key is None: + logger.warning( + "Remote server gave us non-state event in state: %s", state_event_id + ) + continue + + state_map[(metadata.event_type, metadata.state_key)] = state_event_id + + # missing state at that event is a warning, not a blocker + # XXX: this doesn't sound right? it means that we'll end up with incomplete + # state. + failed_to_fetch = set(state_event_ids) - event_metadata.keys() + # `event_id` could be missing from `event_metadata` because it's not necessarily + # a state event. We've already checked that we've fetched it above. + failed_to_fetch.discard(event_id) + if failed_to_fetch: + logger.warning( + "Failed to fetch missing state events for %s %s", + event_id, + failed_to_fetch, + ) + set_tag( + SynapseTags.RESULT_PREFIX + "failed_to_fetch", + str(failed_to_fetch), + ) + set_tag( + SynapseTags.RESULT_PREFIX + "failed_to_fetch.length", + str(len(failed_to_fetch)), + ) + + if remote_event.is_state() and remote_event.rejected_reason is None: + state_map[(remote_event.type, remote_event.state_key)] = ( + remote_event.event_id + ) + + return state_map + + async def _persist_state_response( + self, + destination: str, + room_id: str, + event_id: str, + state_event_ids: list[str], + auth_event_ids: list[str], + ) -> None: + """Ensure that all events from a /state{_ids} response have been seen and are in the database. + This function side-effects by inserting missed events into the database. + """ # Start by checking events we already have in the DB desired_events = set(state_event_ids) desired_events.add(event_id) @@ -1344,76 +1492,6 @@ async def _get_state_ids_after_missing_prev_event( destination=destination, room_id=room_id, event_ids=missing_event_ids ) - # We now need to fill out the state map, which involves fetching the - # type and state key for each event ID in the state. - state_map = {} - - event_metadata = await self._store.get_metadata_for_events(state_event_ids) - for state_event_id, metadata in event_metadata.items(): - if metadata.room_id != room_id: - # This is a bogus situation, but since we may only discover it a long time - # after it happened, we try our best to carry on, by just omitting the - # bad events from the returned state set. - # - # This can happen if a remote server claims that the state or - # auth_events at an event in room A are actually events in room B - logger.warning( - "Remote server %s claims event %s in room %s is an auth/state " - "event in room %s", - destination, - state_event_id, - metadata.room_id, - room_id, - ) - continue - - if metadata.state_key is None: - logger.warning( - "Remote server gave us non-state event in state: %s", state_event_id - ) - continue - - state_map[(metadata.event_type, metadata.state_key)] = state_event_id - - # if we couldn't get the prev event in question, that's a problem. - remote_event = await self._store.get_event( - event_id, - allow_none=True, - allow_rejected=True, - redact_behaviour=EventRedactBehaviour.as_is, - ) - if not remote_event: - raise Exception("Unable to get missing prev_event %s" % (event_id,)) - - # missing state at that event is a warning, not a blocker - # XXX: this doesn't sound right? it means that we'll end up with incomplete - # state. - failed_to_fetch = desired_events - event_metadata.keys() - # `event_id` could be missing from `event_metadata` because it's not necessarily - # a state event. We've already checked that we've fetched it above. - failed_to_fetch.discard(event_id) - if failed_to_fetch: - logger.warning( - "Failed to fetch missing state events for %s %s", - event_id, - failed_to_fetch, - ) - set_tag( - SynapseTags.RESULT_PREFIX + "failed_to_fetch", - str(failed_to_fetch), - ) - set_tag( - SynapseTags.RESULT_PREFIX + "failed_to_fetch.length", - str(len(failed_to_fetch)), - ) - - if remote_event.is_state() and remote_event.rejected_reason is None: - state_map[(remote_event.type, remote_event.state_key)] = ( - remote_event.event_id - ) - - return state_map - @trace @tag_args async def _get_state_and_persist( @@ -1422,7 +1500,7 @@ async def _get_state_and_persist( """Get the complete room state at a given event, and persist any new events as outliers""" room_version = await self._store.get_room_version(room_id) - auth_events, state_events = await self._federation_client.get_room_state( + state_events, auth_events = await self._federation_client.get_room_state( destination, room_id, event_id=event_id, room_version=room_version ) logger.info("/state returned %i events", len(auth_events) + len(state_events)) @@ -1629,10 +1707,10 @@ async def backfill_event_id( @trace @tag_args - async def _get_events_and_persist( + async def _get_events_from_remote( self, destination: str, room_id: str, event_ids: StrCollection - ) -> None: - """Fetch the given events from a server, and persist them as outliers. + ) -> list[EventBase]: + """Fetch the given events from a server. This function *does not* recursively get missing auth events of the newly fetched events. Callers must include in the `event_ids` argument @@ -1671,13 +1749,32 @@ async def get_event(event_id: str) -> None: ) await concurrently_execute(get_event, event_ids, 5) + return events + + @trace + @tag_args + async def _get_events_and_persist( + self, destination: str, room_id: str, event_ids: StrCollection + ) -> None: + """Fetch the given events from a server, and persist them as outliers. + + This function *does not* recursively get missing auth events of the + newly fetched events. Callers must include in the `event_ids` argument + any missing events from the auth chain. + + Logs a warning if we can't find the given event. + """ + events = await self._get_events_from_remote(destination, room_id, event_ids) logger.info("Fetched %i events of %i requested", len(events), len(event_ids)) await self._auth_and_persist_outliers(room_id, events) @trace async def _auth_and_persist_outliers( - self, room_id: str, events: Iterable[EventBase] - ) -> None: + self, + room_id: str, + events: Iterable[EventBase], + from_send_join: bool = False, + ) -> bool: """Persist a batch of outlier events fetched from remote servers. We first sort the events to make sure that we process each event's auth_events @@ -1690,8 +1787,15 @@ async def _auth_and_persist_outliers( room_id: the room that the events are meant to be in (though this has not yet been checked) events: the events that have been fetched + from_send_join: If True, the events in `events` are from a /send_join response. This + allows some optimisations to be performed as we know we don't need to query the database + for events. State DAG rooms use this. + Returns: + True if some outlier events were rejected. """ event_map = {event.event_id: event for event in events} + if len(event_map) == 0: + return False # nothing to do event_ids = event_map.keys() set_tag( @@ -1702,6 +1806,9 @@ async def _auth_and_persist_outliers( SynapseTags.FUNC_ARG_PREFIX + "event_ids.length", str(len(event_ids)), ) + is_state_dag_room = event_map[ + list(event_ids)[0] + ].room_version.msc4242_state_dags # filter out any events we have already seen. This might happen because # the events were eagerly pushed to us (eg, during a room join), or because @@ -1718,9 +1825,18 @@ async def _auth_and_persist_outliers( # We need to persist an event's auth events before the event. auth_graph = { - ev.event_id: [e_id for e_id in ev.auth_event_ids() if e_id in event_map] + ev.event_id: [ + e_id + for e_id in ( + ev.auth_event_ids() + if not isinstance(ev, FrozenEventVMSC4242) + else ev.prev_state_events + ) + if e_id in event_map + ] for ev in event_map.values() } + # XXX: confusing name: this isn't _only_ auth events! sorted_auth_event_ids = sorted_topologically(event_map.keys(), auth_graph) sorted_auth_events = [event_map[e_id] for e_id in sorted_auth_event_ids] logger.info( @@ -1729,92 +1845,234 @@ async def _auth_and_persist_outliers( shortstr(e.event_id for e in sorted_auth_events), ) - # get all the auth events for all the events in this batch. By now, they should - # have been persisted. - auth_event_ids = { - aid for event in sorted_auth_events for aid in event.auth_event_ids() - } - auth_map = { - ev.event_id: ev - for ev in sorted_auth_events - if ev.event_id in auth_event_ids - } - - missing_events = auth_event_ids.difference(auth_map) - if missing_events: - persisted_events = await self._store.get_events( - missing_events, - allow_rejected=True, - redact_behaviour=EventRedactBehaviour.as_is, - ) - auth_map.update(persisted_events) + has_rejected_events = False events_and_contexts_to_persist: list[EventPersistencePair] = [] - async def prep(event: EventBase) -> None: - with nested_logging_context(suffix=event.event_id): - auth = [] - for auth_event_id in event.auth_event_ids(): - ae = auth_map.get(auth_event_id) - if not ae: - # the fact we can't find the auth event doesn't mean it doesn't - # exist, which means it is premature to reject `event`. Instead we - # just ignore it for now. + if is_state_dag_room: + event_id_to_state_group: dict[str, int] = {} + state_group_to_state_map: dict[int, StateMap[str]] = {} + processed_event_map: dict[str, EventBase] = {} + + # other room versions now fetch missed auth_events and check that the event is allowed + # according to event-supplied auth_events. MSC4242 rooms instead _calculate_ the auth + # state and then determine if the event is allowed. + async def process(event: EventBase) -> tuple[EventBase, EventContext]: + assert isinstance(event, FrozenEventVMSC4242) + with nested_logging_context(suffix=event.event_id): + known_prev_state_maps = None + if from_send_join: + state_groups = [ + event_id_to_state_group[event_id] + for event_id in event.prev_state_events + ] + known_prev_state_maps = { + sg: state_group_to_state_map[sg] for sg in state_groups + } + # The following is very similar to EventBuilder.build which also calculates + # what the auth events should be. + ( + context, + calculated_auth_event_ids, + ) = await self._calculate_state_dag_context( + event, + known_prev_state_maps=known_prev_state_maps, + event_map=processed_event_map, + ) + # set metadata fields + event.internal_metadata.calculated_auth_event_ids = ( + calculated_auth_event_ids + ) + event.internal_metadata.outlier = True + # fetch the calculated auth events for checking auth rules + batched_auth_events = None + if from_send_join: + # we already have the auth events as we're processing a /send_join response. + # So let's pull them out now. + calculated_auth_events = { + event_id: event_map[event_id] + for event_id in calculated_auth_event_ids + } + batched_auth_events = { + event_id: event_map[event_id] + for event_id in ( + calculated_auth_event_ids + event.prev_state_events + ) + } + if context._state_group is not None: + event_id_to_state_group[event.event_id] = ( + context._state_group + ) + state_group_to_state_map[ + context._state_group + ] = await context.get_current_state_ids() + + else: + calculated_auth_events = await self._store.get_events( + calculated_auth_event_ids, + allow_rejected=True, + redact_behaviour=EventRedactBehaviour.as_is, + ) + try: + validate_event_for_room_version(event) + await check_state_independent_auth_rules( + self._store, event, batched_auth_events + ) + check_state_dependent_auth_rules( + event, calculated_auth_events.values() + ) + except AuthError as e: + logger.warning("Rejecting %r because %s", event, e) + context.rejected = RejectedReason.AUTH_ERROR + except EventSizeError as e: + if e.unpersistable: + # This event is completely unpersistable. + raise e + # TODO(kegan): check before merging if we can be stricter here. + # Otherwise, we are somewhat lenient and just persist the event + # as rejected, for moderate compatibility with older Synapse + # versions. logger.warning( - "Dropping event %s, which relies on auth_event %s, which could not be found", - event, - auth_event_id, + "While validating received event %r: %s", event, e ) - # Drop the event from the auth_map too, else we may incorrectly persist - # events which depend on this dropped event. - auth_map.pop(event.event_id, None) - return - auth.append(ae) + context.rejected = RejectedReason.OVERSIZED_EVENT + + processed_event_map[event.event_id] = event + return (event, context) + + # we cannot prep in a batch then persist in a batch like with other room versions + # because we need to have persisted the state before the prev_state_events _before_ + # we can work out what the calculated auth_events are for the next event. If we try + # to do what other room versions do, _calculate_state_dag_context will fail due + # to not knowing the state groups at prev_state_events. + # The exception to this is when processing a /send_join response as we have all the + # events in-memory. + if from_send_join: + for i, event in enumerate(sorted_auth_events): + event, context = await process(event) + if context.rejected is not None: + has_rejected_events = True + events_and_contexts_to_persist.append((event, context)) + # The above function is typically not async, and so won't yield to + # the reactor. For large rooms let's yield to the reactor + # occasionally to ensure we don't block other work. + if (i + 1) % 1000 == 0: + await self._clock.sleep(Duration(seconds=0)) + + for batch in batch_iter(events_and_contexts_to_persist, 1000): + await self.persist_events_and_notify( + room_id, + batch, + # Mark these events as backfilled as they're historic events that will + # eventually be backfilled. For example, missing events we fetch + # during backfill should be marked as backfilled as well. + backfilled=True, + ) + else: + for event in sorted_auth_events: + event_and_context = await process(event) + await self.persist_events_and_notify( + room_id, + [event_and_context], + # Mark these events as backfilled as they're historic events that will + # eventually be backfilled. For example, missing events we fetch + # during backfill should be marked as backfilled as well. + backfilled=True, + ) + if event_and_context[1].rejected is not None: + has_rejected_events = True + else: + # get all the auth events for all the events in this batch. By now, they should + # have been persisted. + auth_event_ids = { + aid for event in sorted_auth_events for aid in event.auth_event_ids() + } + auth_map = { + ev.event_id: ev + for ev in sorted_auth_events + if ev.event_id in auth_event_ids + } - # we're not bothering about room state, so flag the event as an outlier. - event.internal_metadata.outlier = True + missing_events = auth_event_ids.difference(auth_map) + if missing_events: + persisted_events = await self._store.get_events( + missing_events, + allow_rejected=True, + redact_behaviour=EventRedactBehaviour.as_is, + ) + auth_map.update(persisted_events) + + async def prep(event: EventBase) -> bool: + with nested_logging_context(suffix=event.event_id): + auth = [] + for auth_event_id in event.auth_event_ids(): + ae = auth_map.get(auth_event_id) + if not ae: + # the fact we can't find the auth event doesn't mean it doesn't + # exist, which means it is premature to reject `event`. Instead we + # just ignore it for now. + logger.warning( + "Dropping event %s, which relies on auth_event %s, which could not be found", + event, + auth_event_id, + ) + # Drop the event from the auth_map too, else we may incorrectly persist + # events which depend on this dropped event. + auth_map.pop(event.event_id, None) + return False + auth.append(ae) + + # we're not bothering about room state, so flag the event as an outlier. + event.internal_metadata.outlier = True + + context = EventContext.for_outlier(self._storage_controllers) + try: + validate_event_for_room_version(event) + await check_state_independent_auth_rules( + self._store, event, batched_auth_events=auth_map + ) + check_state_dependent_auth_rules(event, auth) + except AuthError as e: + logger.warning("Rejecting %r because %s", event, e) + context.rejected = RejectedReason.AUTH_ERROR + except EventSizeError as e: + if e.unpersistable: + # This event is completely unpersistable. + raise e + # Otherwise, we are somewhat lenient and just persist the event + # as rejected, for moderate compatibility with older Synapse + # versions. + logger.warning( + "While validating received event %r: %s", event, e + ) + context.rejected = RejectedReason.OVERSIZED_EVENT - context = EventContext.for_outlier(self._storage_controllers) - try: - validate_event_for_room_version(event) - await check_state_independent_auth_rules( - self._store, event, batched_auth_events=auth_map - ) - check_state_dependent_auth_rules(event, auth) - except AuthError as e: - logger.warning("Rejecting %r because %s", event, e) - context.rejected = RejectedReason.AUTH_ERROR - except EventSizeError as e: - if e.unpersistable: - # This event is completely unpersistable. - raise e - # Otherwise, we are somewhat lenient and just persist the event - # as rejected, for moderate compatibility with older Synapse - # versions. - logger.warning("While validating received event %r: %s", event, e) - context.rejected = RejectedReason.OVERSIZED_EVENT - - events_and_contexts_to_persist.append((event, context)) - - for i, event in enumerate(sorted_auth_events): - await prep(event) - - # The above function is typically not async, and so won't yield to - # the reactor. For large rooms let's yield to the reactor - # occasionally to ensure we don't block other work. - if (i + 1) % 1000 == 0: - await self._clock.sleep(Duration(seconds=0)) - - # Also persist the new event in batches for similar reasons as above. - for batch in batch_iter(events_and_contexts_to_persist, 1000): - await self.persist_events_and_notify( - room_id, - batch, - # Mark these events as backfilled as they're historic events that will - # eventually be backfilled. For example, missing events we fetch - # during backfill should be marked as backfilled as well. - backfilled=True, - ) + events_and_contexts_to_persist.append((event, context)) + return context.rejected is not None + + for i, event in enumerate(sorted_auth_events): + is_rejected = await prep(event) + if is_rejected: + has_rejected_events = True + + # The above function is typically not async, and so won't yield to + # the reactor. For large rooms let's yield to the reactor + # occasionally to ensure we don't block other work. + if (i + 1) % 1000 == 0: + await self._clock.sleep(Duration(seconds=0)) + + # Also persist the new event in batches for similar reasons as above. + for batch in batch_iter(events_and_contexts_to_persist, 1000): + await self.persist_events_and_notify( + room_id, + batch, + # Mark these events as backfilled as they're historic events that will + # eventually be backfilled. For example, missing events we fetch + # during backfill should be marked as backfilled as well. + backfilled=True, + ) + + return has_rejected_events @trace async def _check_event_auth( @@ -1863,7 +2121,9 @@ async def _check_event_auth( # caller rather than swallow with `context.rejected` (since we cannot be # certain that there is a permanent problem with the event). claimed_auth_events = await self._load_or_fetch_auth_events_for_event( - origin, event + origin, + event, + context, ) set_tag( SynapseTags.RESULT_PREFIX + "claimed_auth_events", @@ -1888,6 +2148,11 @@ async def _check_event_auth( context.rejected = RejectedReason.AUTH_ERROR return + if event.room_version.msc4242_state_dags: + # Step 4 also did Step 5 as we calculated the auth events from the state before the event + # so we're done. + return + # now check the auth rules pass against the room state before the event # https://spec.matrix.org/v1.3/server-server-api/#checks-performed-on-receipt-of-a-pdu: # 5. Passes authorization rules based on the state before the event, @@ -1987,6 +2252,78 @@ async def _maybe_kick_guest_users(self, event: EventBase) -> None: current_state_list = list(current_state.values()) await self._get_room_member_handler().kick_guest_users(current_state_list) + async def _calculate_state_dag_context( + self, + event: FrozenEventVMSC4242, + existing_context: EventContext | None = None, + known_prev_state_maps: dict[int, StateMap[str]] | None = None, + event_map: dict[str, EventBase] | None = None, + ) -> tuple[EventContext, list[str]]: + """Calculates the state events for an event based on any provided existing context. + If no context is provided, calculates the state before the prev state events and + returns a new one. + + Args: + event: The event to calculate the state at. + existing_context: The precalculated state if known. + known_prev_state_maps: The state maps for all the prev_state_events, if known. + This avoids an event ID to state group lookup which allow batch processing if the events + aren't yet persisted. + Returns: + The persisted EventContext and the calculated auth event IDs. + """ + if len(event.prev_state_events) == 0 and event.type != EventTypes.Create: + raise SynapseError(502, f"event {event.event_id} has no prev_state_events") + + if existing_context: + state_ids = await existing_context.get_prev_state_ids() + return existing_context, self._event_auth_handler.compute_auth_events( + event, state_ids + ) + # load the room state at the prev_state_events. This is the expensive bit but + # it's a one-time cost as we remember the calculated auth events. + # TODO(kegan): we could be smarter here and cache what the calculated auth events are + # so we don't need to do state res, keyed off ([prev_state_events], sender), since the + # auth events for a given event is determined by its position in the state DAG and who is + # performing the operation. Without this cache, the same user sending 5 events incurs + # 5 state res operations. The cache must also be accessible to EventBuilder.build for local + # event creation, so somewhere in state handler perhaps? + if known_prev_state_maps is not None and len(known_prev_state_maps) > 0: + if len(known_prev_state_maps) == 1: + state_ids = list(known_prev_state_maps.values())[0] + else: + res = await self._state_resolution_handler.resolve_state_groups( + event.room_id, + event.room_version.identifier, + known_prev_state_maps, + event_map=event_map, + state_res_store=StateResolutionStore( + self._store, self._state_deletion_store + ), + ) + state_ids = await res.get_state(self._state_storage_controller) + else: + state_ids = await self._state_handler.compute_state_after_events( + event.room_id, + event.prev_state_events, + state_filter=None, # can't apply this as we need to persist the state group afterwards + await_full_state=False, + ) + # we should always have some kind of resolved state after the prev_state_events, except for + # the create event. If we don't, yell loudly. + if event.type != EventTypes.Create: + assert len(state_ids) > 0 + context = await self._state_handler.compute_event_context( + event, + state_ids, + # Ideally this would always be False but code asserts this is None if there are no + # state_ids, which can happen normally if we reach the create event, so appease it. + partial_state=None + if len(event.prev_state_events) == 0 and event.type == EventTypes.Create + else False, + ) + return context, self._event_auth_handler.compute_auth_events(event, state_ids) + async def _check_for_soft_fail( self, event: EventBase, @@ -2010,9 +2347,15 @@ async def _check_for_soft_fail( # current state, it may have been derived from state resolution between # partial and full state and may not be accurate. return + is_state_dag_room = event.room_version.msc4242_state_dags - extrem_ids = await self._store.get_latest_event_ids_in_room(event.room_id) - prev_event_ids = set(event.prev_event_ids()) + if is_state_dag_room: + extrem_ids = await self._store.get_state_dag_extremities(event.room_id) + assert isinstance(event, FrozenEventVMSC4242) + prev_event_ids = set(event.prev_state_events) + else: + extrem_ids = await self._store.get_latest_event_ids_in_room(event.room_id) + prev_event_ids = set(event.prev_event_ids()) if extrem_ids == prev_event_ids: # If they're the same then the current state is the same as the @@ -2026,27 +2369,17 @@ async def _check_for_soft_fail( auth_types = auth_types_for_event(room_version_obj, event) # Calculate the "current state". - seen_event_ids = await self._store.have_events_in_timeline(prev_event_ids) - has_missing_prevs = bool(prev_event_ids - seen_event_ids) - if has_missing_prevs: - # We don't have all the prev_events of this event, which means we have a - # gap in the graph, and the new event is going to become a new backwards - # extremity. - # - # In this case we want to be a little careful as we might have been - # down for a while and have an incorrect view of the current state, - # however we still want to do checks as gaps are easy to - # maliciously manufacture. - # - # So we use a "current state" that is actually a state - # resolution across the current forward extremities and the - # given state at the event. This should correctly handle cases - # like bans, especially with state res v2. - - state_sets_d = await self._state_storage_controller.get_state_groups_ids( + if is_state_dag_room: + # Because we may have just come back online after a long time, we don't know + # which is newer: our forward extremities or the event's state. As such, + # we do state resolution across those state sets to try to ensure we are seeing + # the 'current' state, particularly for catching bans. The block comment below + # says much the same thing but conditionally applies it based on missing prev_events, + # but in a state DAG world we always have prev_state_events. + state_sets_fwd = await self._state_storage_controller.get_state_groups_ids( event.room_id, extrem_ids ) - state_sets: list[StateMap[str]] = list(state_sets_d.values()) + state_sets: list[StateMap[str]] = list(state_sets_fwd.values()) state_ids = await context.get_prev_state_ids() state_sets.append(state_ids) current_state_ids = ( @@ -2061,11 +2394,48 @@ async def _check_for_soft_fail( ) ) else: - current_state_ids = ( - await self._state_storage_controller.get_current_state_ids( - event.room_id, StateFilter.from_types(auth_types) + seen_event_ids = await self._store.have_events_in_timeline(prev_event_ids) + has_missing_prevs = bool(prev_event_ids - seen_event_ids) + if has_missing_prevs: + # We don't have all the prev_events of this event, which means we have a + # gap in the graph, and the new event is going to become a new backwards + # extremity. + # + # In this case we want to be a little careful as we might have been + # down for a while and have an incorrect view of the current state, + # however we still want to do checks as gaps are easy to + # maliciously manufacture. + # + # So we use a "current state" that is actually a state + # resolution across the current forward extremities and the + # given state at the event. This should correctly handle cases + # like bans, especially with state res v2. + + state_sets_d = ( + await self._state_storage_controller.get_state_groups_ids( + event.room_id, extrem_ids + ) + ) + state_sets = list(state_sets_d.values()) + state_ids = await context.get_prev_state_ids() + state_sets.append(state_ids) + current_state_ids = ( + await self._state_resolution_handler.resolve_events_with_store( + event.room_id, + room_version, + state_sets, + event_map=None, + state_res_store=StateResolutionStore( + self._store, self._state_deletion_store + ), + ) + ) + else: + current_state_ids = ( + await self._state_storage_controller.get_current_state_ids( + event.room_id, StateFilter.from_types(auth_types) + ) ) - ) logger.debug( "Doing soft-fail check for %s: state %s", @@ -2101,7 +2471,10 @@ async def _check_for_soft_fail( event.internal_metadata.soft_failed = True async def _load_or_fetch_auth_events_for_event( - self, destination: str | None, event: EventBase + self, + destination: str | None, + event: EventBase, + context: EventContext, ) -> Collection[EventBase]: """Fetch this event's auth_events, from database or remote @@ -2123,6 +2496,8 @@ async def _load_or_fetch_auth_events_for_event( event: the event whose auth_events we want + context: The state before the event, if known. + Returns: all of the events listed in `event.auth_events_ids`, after deduplication @@ -2132,7 +2507,11 @@ async def _load_or_fetch_auth_events_for_event( AuthError if we were unable to fetch the auth_events for any reason. """ - event_auth_event_ids = set(event.auth_event_ids()) + event_auth_event_ids = set( + event.auth_event_ids() + if not isinstance(event, FrozenEventVMSC4242) + else event.prev_state_events + ) event_auth_events = await self._store.get_events( event_auth_event_ids, allow_rejected=True ) @@ -2140,6 +2519,21 @@ async def _load_or_fetch_auth_events_for_event( event_auth_events.keys() ) if not missing_auth_event_ids: + if isinstance(event, FrozenEventVMSC4242): + # we still need to calculate the auth_events before returning, as we only made + # sure we know of the prev_state_events. + _, calculated_auth_event_ids = await self._calculate_state_dag_context( + event, + context, + ) + event.internal_metadata.calculated_auth_event_ids = ( + calculated_auth_event_ids + ) + calculated_events = await self._store.get_events( + calculated_auth_event_ids, + allow_rejected=True, # TODO(kegan): probably False? + ) + return calculated_events.values() return event_auth_events.values() if destination is None: # this shouldn't happen: destination must be set unless we know we have already @@ -2155,9 +2549,16 @@ async def _load_or_fetch_auth_events_for_event( missing_auth_event_ids, ) try: - await self._get_remote_auth_chain_for_event( - destination, event.room_id, event.event_id - ) + if event.room_version.msc4242_state_dags: + assert isinstance(event, FrozenEventVMSC4242) + missed_events = await self._fetch_missing_state_dag_events( + destination, event + ) + await self._auth_and_persist_outliers(event.room_id, missed_events) + else: + await self._get_remote_auth_chain_for_event( + destination, event.room_id, event.event_id + ) except Exception as e: logger.warning("Failed to get auth chain for %s: %s", event, e) # in this case, it's very likely we still won't have all the auth @@ -2170,6 +2571,20 @@ async def _load_or_fetch_auth_events_for_event( missing_auth_event_ids.difference_update(extra_auth_events.keys()) event_auth_events.update(extra_auth_events) if not missing_auth_event_ids: + if isinstance(event, FrozenEventVMSC4242): + # we still need to calculate the auth_events before returning. + _, calculated_auth_event_ids = await self._calculate_state_dag_context( + event, + context, + ) + event.internal_metadata.calculated_auth_event_ids = ( + calculated_auth_event_ids + ) + calculated_events = await self._store.get_events( + calculated_auth_event_ids, + allow_rejected=True, # TODO(kegan): probably False? + ) + return calculated_events.values() return event_auth_events.values() # we still don't have all the auth events. @@ -2213,6 +2628,225 @@ async def _get_remote_auth_chain_for_event( await self._auth_and_persist_outliers(room_id, remote_auth_events) + @trace + @tag_args + async def _fetch_missing_state_dag_events( + self, + destination: str, + event: FrozenEventVMSC4242, + ) -> Iterable[EventBase]: + """If we are missing some of an event's prev state events, request them until we fill in + the complete state DAG. + + Args: + destination: where to fetch the state dag from + event: The event we're missing prev_state_events for. + Returns: + The missed state DAG events. + """ + # The logic for this is gnarly but the general idea is to hit /get_missing_events with + # the event ID (hence known as the "back set") to walk back up the state DAG + # breadth first. We then need to see if we have seen any of these events, in which case they + # can be removed from the back set. + # When the back set size reaches 0, we have filled in the entire DAG. It's gnarly because + # we don't have a clear idea when we have filled in the state DAG without checking with the + # database for potentially a lot of events. Consider the following graph: + # .-- E <- F <- G + # A <- B <- C <- D + # `-- H <- I <- J + # Assume this server knows A-G and receives J. Knowing the forwards extremities in the room + # (G) does nothing to help us know when we have connected up the state DAG at D. + # Thus, this function queries the database for all returned events to see if we have seen + # them, and then filters them out. + # + # This function terminates when either: + # - the back set size is 0 (we filled in the gap) + # - the back set entries do not change after a round of /get_missing_events + # (we're not making forward progress), in which case this indicates the remote server is + # lying to us and not sending us events we need. + # + # There are tradeoffs here between # round trips, amount of memory consumed and # DB hits. + # We could reduce memory consumed by persisting intermediate events in a staging area + # on disk. We could reduce DB hits by only querying a subset of events (and using the + # topological ordering) which may mean we try to process events we've already seen. We try + # to reduce the # round trips by exponentially increasing the limit in each request. + # Better algorithms exist here (search for "set reconciliation") but as of today, we don't + # do any of them. + + # this function is expensive. See if we need to do it at all. + seen = await self._store.have_seen_events( + event.room_id, event.prev_state_events + ) + if seen == set(event.prev_state_events): + return [] + + room_id = event.room_id + # we allow this amount of time for each event we're going to receive. + # This dynamically adjusts the timeout to account for very large responses. + timeout_ms_per_event = 100 + iteration = 0 + limit = 8 + # we maintain 3 sets: the back set is what the next /gme request will be, and the + # /gme response events get bucketed into one of these 3 sets (seen, missed, back) sets. + missed_events: dict[str, FrozenEventVMSC4242] = {} + seen_event_ids: set[str] = set() + # we operate on state events, but we may have originally hit the backwards extremity with + # a message event. If we do, we need to grab the prev_state_events first to seed the + # back set. /get_missing_events will not return the event we provide to it in latest_events. + back_set: dict[str, FrozenEventVMSC4242] = {} + if event_exists_in_state_dag(event): + back_set = {event.event_id: event} + else: + prev_state_events = await self._get_events_from_remote( + destination, room_id, event.prev_state_events + ) + back_set = { + e.event_id: e + for e in prev_state_events + if e.event_id not in seen and isinstance(e, FrozenEventVMSC4242) + } + # the back set now consists of state events we have not seen, so ensure we return them + # to the caller + missed_events = {e.event_id: e for e in back_set.values()} + + while len(back_set) > 0: + logger.info( + "missed=%s seen=%s back_set=%s", + missed_events.keys(), + seen_event_ids, + back_set.keys(), + ) + # remember which events we're querying for. If we don't make forward progress we'll bail + before_back_set = set(back_set) + max_events_per_req = limit * pow(2, iteration) # 8x1, 8x2, 8x4, ... + try: + # 10s base then +(100ms x # events) on top e.g 64 events = +6400ms = 16.4s + timeout = 10000 + (limit * timeout_ms_per_event) + remote_events = await self._federation_client.get_missing_events( + destination, + room_id, + earliest_events_ids=[], + latest_events=back_set.values(), + limit=max_events_per_req, + min_depth=0, + timeout=timeout, + state_dag=True, + ) + remote_events_map = { + ev.event_id: ev + for ev in remote_events + if isinstance(ev, FrozenEventVMSC4242) + } + except RequestSendFailed as e1: + logger.warning( + "Failed to get missing state dag events from remote: %s", e1 + ) + # by returning nothing we all but guarantee that the processing of the event + # received over federation will fail. We'll try doing this again the next time + # this server sends an event to us. + # TODO(kegan): Having a staging area of auth events we have got but not yet authed + # would help us stop doing repeat work. + return [] + + # bucket the remote events into seen / unseen. We include each event's prev_state_events + # here because that way we might be able to skip another request i.e we know we have + # seen the prev_state_events so don't bother fetching them again. + remote_event_ids = { + event_id + for event in remote_events_map.values() + for event_id in event.prev_state_events + } + remote_event_ids.update(remote_events_map.keys()) + seen_remotes = await self._store.have_seen_events( + room_id, + remote_event_ids, + ) + seen_event_ids.update(seen_remotes) + unseen_remotes = set(remote_events_map).difference(seen_remotes) + + # all unseen events must be returned + missed_events.update( + {k: v for (k, v) in remote_events_map.items() if k in unseen_remotes} + ) + + # now figure out what the new back set is. In the common case, remote events will have + # a long chain of new events e.g A <- B <- C <- D so we want to walk up this graph + # if all the events are unseen. If there are seen events (e.g A) then when we reach A + # we terminate that branch as we have filled in the gap. + # In order to avoid mutating the dict whilst iterating, we iterate over the back set + # snapshot we took earlier, and try to exhaust it (i.e it maps an event ID to a new + # earlier event ID(s) or None if we filled in the gap.) + back_queue = list(before_back_set) + new_back_set: set[str] = set() + while back_queue: + # If the prevs are: + # - All seen: we've filled in the gap, don't add this event to the back set. + # - All fetched: add all the prevs to the back set. + # - Mixed seen/fetched: add all the fetched prevs to the back set + # - Any unseen: keep this event in the back set. + back_event_id = back_queue.pop(0) + back_event = missed_events.get( + back_event_id, back_set.get(back_event_id) + ) + if back_event is None: + continue + seen_all_prevs = all( + pae in seen_event_ids for pae in back_event.prev_state_events + ) + if seen_all_prevs: + continue + has_any_unseen_prev = any( + pae not in seen_event_ids and pae not in missed_events + for pae in back_event.prev_state_events + ) + if has_any_unseen_prev: + new_back_set.add(back_event_id) + continue + + # if we reach here then we have a mixture of seen/fetched prevs. Add the fetched + # prevs to the queue + back_queue.extend( + pae + for pae in back_event.prev_state_events + if pae not in seen_event_ids + ) + + # if there is an event with lots of prev_state_events, so many that our limit won't + # pull them all in, then we have a problem if we give up trying to walk backwards. + # Ensure the limit is at least that large before giving up. + max_prev_state_events_on_single_event = max( + [len(ev.prev_state_events) for (_, ev) in back_set.items()] + ) + if ( + new_back_set == before_back_set + and max_events_per_req > max_prev_state_events_on_single_event + ): + # we didn't make forward progress, give up. + logger.warning( + "Failed to make forward progress when walking back through state dag, stuck at back set %s", + before_back_set, + ) + return [] + iteration += 1 # let the limit exponentially increase + + # edge case: the initial event we put as latest_events has so many prev_state_events that + # we did not make forward progress yet. In this case, missed_events does NOT have the + # initial event, as it was not returned from /get_missing_events. Therefore, we just + # special case this scenario and set the back set accordingly. + if new_back_set == {event.event_id}: + back_set = {event.event_id: event} + else: + back_set = { + event_id: missed_events[event_id] for event_id in new_back_set + } + + logger.info( + "fetch_missing_state_dag_events returning %s events from %s", + len(missed_events), + event.event_id, + ) + return missed_events.values() + @trace async def _run_push_actions_and_persist_event( self, event: EventBase, context: EventContext, backfilled: bool = False @@ -2411,10 +3045,23 @@ def _sanity_check_event(self, ev: EventBase) -> None: ) raise SynapseError(HTTPStatus.BAD_REQUEST, "Too many prev_events") - if len(ev.auth_event_ids()) > 10: + if not isinstance(ev, FrozenEventVMSC4242) and len(ev.auth_event_ids()) > 10: logger.warning( "Rejecting event %s which has %i auth_events", ev.event_id, len(ev.auth_event_ids()), ) raise SynapseError(HTTPStatus.BAD_REQUEST, "Too many auth_events") + + +def is_state_dag_connected(state_dag: list[FrozenEventVMSC4242]) -> bool: + assert len(state_dag) > 0 + have_event_ids = {ev.event_id for ev in state_dag} + all_prev_state_events = { + ev_id + for state_events in [ev.prev_state_events for ev in state_dag] + for ev_id in state_events + } + # have_event_ids should consist of all events including forward extremities, + # making all_prev_state_events a strict subset. + return all_prev_state_events.issubset(have_event_ids) diff --git a/synapse/handlers/message.py b/synapse/handlers/message.py index 99ce120736d..f09de7ccb42 100644 --- a/synapse/handlers/message.py +++ b/synapse/handlers/message.py @@ -53,7 +53,7 @@ from synapse.api.room_versions import KNOWN_ROOM_VERSIONS from synapse.api.urls import ConsentURIBuilder from synapse.event_auth import validate_event_for_room_version -from synapse.events import EventBase, relation_from_event +from synapse.events import EventBase, FrozenEventVMSC4242, relation_from_event from synapse.events.builder import EventBuilder from synapse.events.snapshot import ( EventContext, @@ -585,6 +585,7 @@ async def create_event( state_map: StateMap[str] | None = None, for_batch: bool = False, current_state_group: int | None = None, + prev_state_events: list[str] | None = None, ) -> tuple[EventBase, UnpersistedEventContextBase]: """ Given a dict from a client, create a new event. If bool for_batch is true, will @@ -639,6 +640,10 @@ async def create_event( current_state_group: the current state group, used only for creating events for batch persisting + prev_state_events: + The state event IDs which represent the current forward extremities of the state DAG. + Only applicable on room versions which use a state DAG (MSC4242). + Raises: ResourceLimitError if server is blocked to some resource being exceeded @@ -738,6 +743,7 @@ async def create_event( state_map=state_map, for_batch=for_batch, current_state_group=current_state_group, + prev_state_events=prev_state_events, ) # In an ideal world we wouldn't need the second part of this condition. However, @@ -966,6 +972,7 @@ async def create_and_send_nonmember_event( ignore_shadow_ban: bool = False, outlier: bool = False, depth: int | None = None, + prev_state_events: list[str] | None = None, ) -> tuple[EventBase, int]: """ Creates an event, then sends it. @@ -994,7 +1001,9 @@ async def create_and_send_nonmember_event( depth: Override the depth used to order the event in the DAG. Should normally be set to None, which will cause the depth to be calculated based on the prev_events. - + prev_state_events: + The state event IDs which represent the current forward extremities of the state DAG. + Only applicable on room versions which use a state DAG (MSC4242). Returns: The event, and its stream ordering (if deduplication happened, the previous, duplicate event). @@ -1090,6 +1099,7 @@ async def create_and_send_nonmember_event( ignore_shadow_ban=ignore_shadow_ban, outlier=outlier, depth=depth, + prev_state_events=prev_state_events, ) async def _create_and_send_nonmember_event_locked( @@ -1103,6 +1113,7 @@ async def _create_and_send_nonmember_event_locked( ignore_shadow_ban: bool = False, outlier: bool = False, depth: int | None = None, + prev_state_events: list[str] | None = None, ) -> tuple[EventBase, int]: room_id = event_dict["room_id"] @@ -1131,6 +1142,7 @@ async def _create_and_send_nonmember_event_locked( state_event_ids=state_event_ids, outlier=outlier, depth=depth, + prev_state_events=prev_state_events, ) context = await unpersisted_context.persist(event) @@ -1225,6 +1237,7 @@ async def create_new_client_event( state_map: StateMap[str] | None = None, for_batch: bool = False, current_state_group: int | None = None, + prev_state_events: list[str] | None = None, ) -> tuple[EventBase, UnpersistedEventContextBase]: """Create a new event for a local client. If bool for_batch is true, will create an event using the prev_event_ids, and will create an event context for @@ -1266,9 +1279,30 @@ async def create_new_client_event( current_state_group: the current state group, used only for creating events for batch persisting + prev_state_events: + The state event IDs which represent the current forward extremities of the state DAG. + Only applicable on room versions which use a state DAG (MSC4242). + If unset, populates them from the current state dag forward extremities. + Returns: Tuple of created event, UnpersistedEventContext """ + if builder.room_version.msc4242_state_dags: + assert auth_event_ids is None + # (kegan) I can't find any call-site which uses this. We can't risk letting in + # untrusted input, so for now assert that we aren't told about any state. + assert state_event_ids is None + + if builder.room_id: + if prev_state_events is None: + prev_state_events = list( + await self.store.get_state_dag_extremities(builder.room_id) + ) + else: + # create event doesn't need prev_state_events to be fetched, but it must be non-None. + assert builder.type == EventTypes.Create and builder.state_key == "" + prev_state_events = [] + # Strip down the state_event_ids to only what we need to auth the event. # For example, we don't need extra m.room.member that don't match event.sender if state_event_ids is not None: @@ -1342,7 +1376,10 @@ async def create_new_client_event( assert state_map is not None auth_ids = self._event_auth_handler.compute_auth_events(builder, state_map) event = await builder.build( - prev_event_ids=prev_event_ids, auth_event_ids=auth_ids, depth=depth + prev_event_ids=prev_event_ids, + auth_event_ids=auth_ids, + depth=depth, + prev_state_events=prev_state_events, ) context: UnpersistedEventContextBase = ( @@ -1359,6 +1396,7 @@ async def create_new_client_event( prev_event_ids=prev_event_ids, auth_event_ids=auth_event_ids, depth=depth, + prev_state_events=prev_state_events, ) # Pass on the outlier property from the builder to the event @@ -1548,6 +1586,20 @@ async def handle_new_client_event( auth_event = event_id_to_event.get(event_id) if auth_event: batched_auth_events[event_id] = auth_event + if event.room_version.msc4242_state_dags: + assert isinstance(event, FrozenEventVMSC4242) + # State DAG rooms will check that the prev_state_events are not rejected. + # To do that, we need to make sure we pass in the prev_state_events as + # batched_auth_events, else we will fail the event due to the + # prev_state_events not existing in the database. + for prev_state_event_id in event.prev_state_events: + prev_state_event = event_id_to_event.get( + prev_state_event_id + ) + if prev_state_event: + batched_auth_events[prev_state_event_id] = ( + prev_state_event + ) await self._event_auth_handler.check_auth_rules_from_context( event, batched_auth_events ) @@ -1802,7 +1854,10 @@ async def cache_joined_hosts_for_events( # set for a while, so that the expiry time is reset. state_entry = await self.state.resolve_state_groups_for_events( - event.room_id, event_ids=event.prev_event_ids() + event.room_id, + event_ids=event.prev_state_events + if isinstance(event, FrozenEventVMSC4242) + else event.prev_event_ids(), ) if state_entry.state_group: @@ -2345,9 +2400,16 @@ async def _rebuild_event_after_third_party_rules( # case. prev_event_ids = await self.store.get_prev_events_for_room(builder.room_id) + prev_state_events = None + if original_event.room_version.msc4242_state_dags: + prev_state_events = list( + await self.store.get_state_dag_extremities(builder.room_id) + ) + event = await builder.build( prev_event_ids=prev_event_ids, auth_event_ids=None, + prev_state_events=prev_state_events, ) # we rebuild the event context, to be on the safe side. If nothing else, diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py index 1c3489a00e3..66b4d4440c1 100644 --- a/synapse/handlers/room.py +++ b/synapse/handlers/room.py @@ -65,7 +65,7 @@ from synapse.api.ratelimiting import Ratelimiter from synapse.api.room_versions import KNOWN_ROOM_VERSIONS, RoomVersion from synapse.event_auth import validate_event_for_room_version -from synapse.events import EventBase +from synapse.events import EventBase, event_exists_in_state_dag from synapse.events.snapshot import UnpersistedEventContext from synapse.events.utils import copy_and_fixup_power_levels_contents from synapse.handlers.relations import BundledAggregations @@ -1486,6 +1486,11 @@ async def _send_events_for_new_room( # the most recently created event prev_event: list[str] = [] + # the most recently created state event + prev_state_event: list[str] | None = ( + [] if room_version.msc4242_state_dags else None + ) + # a map of event types, state keys -> event_ids. We collect these mappings this as events are # created (but not persisted to the db) to determine state for future created events # (as this info can't be pulled from the db) @@ -1512,6 +1517,7 @@ async def create_event( """ nonlocal depth nonlocal prev_event + nonlocal prev_state_event # Create the event dictionary. event_dict = {"type": etype, "content": content} @@ -1525,6 +1531,7 @@ async def create_event( creator, event_dict, prev_event_ids=prev_event, + prev_state_events=prev_state_event, depth=depth, # Take a copy to ensure each event gets a unique copy of # state_map since it is modified below. @@ -1535,7 +1542,8 @@ async def create_event( depth += 1 prev_event = [new_event.event_id] state_map[(new_event.type, new_event.state_key)] = new_event.event_id - + if room_version.msc4242_state_dags and event_exists_in_state_dag(new_event): + prev_state_event = [new_event.event_id] return new_event, new_unpersisted_context preset_config, config = self._room_preset_config(room_config) @@ -1568,6 +1576,8 @@ async def create_event( ignore_shadow_ban=True, ) last_sent_event_id = ev.event_id + if room_version.msc4242_state_dags: + prev_state_event = [ev.event_id] member_event_id, _ = await self.room_member_handler.update_membership( creator, @@ -1579,8 +1589,11 @@ async def create_event( new_room=True, prev_event_ids=[last_sent_event_id], depth=depth, + prev_state_events=prev_state_event, ) prev_event = [member_event_id] + if room_version.msc4242_state_dags: + prev_state_event = [member_event_id] # update the depth and state map here as the membership event has been created # through a different code path diff --git a/synapse/handlers/room_member.py b/synapse/handlers/room_member.py index 0c6be727169..35135eec12c 100644 --- a/synapse/handlers/room_member.py +++ b/synapse/handlers/room_member.py @@ -36,9 +36,11 @@ from synapse.api.errors import ( AuthError, Codes, + NotFoundError, PartialStateConflictError, ShadowBanError, SynapseError, + UnsupportedRoomVersionError, ) from synapse.api.ratelimiting import Ratelimiter from synapse.event_auth import get_named_level, get_power_level_event @@ -408,6 +410,7 @@ async def _local_membership_update( require_consent: bool = True, outlier: bool = False, origin_server_ts: int | None = None, + prev_state_events: list[str] | None = None, ) -> tuple[str, int]: """ Internal membership update function to get an existing event or create @@ -492,6 +495,7 @@ async def _local_membership_update( depth=depth, require_consent=require_consent, outlier=outlier, + prev_state_events=prev_state_events, ) context = await unpersisted_context.persist(event) prev_state_ids = await context.get_prev_state_ids( @@ -587,6 +591,7 @@ async def update_membership( state_event_ids: list[str] | None = None, depth: int | None = None, origin_server_ts: int | None = None, + prev_state_events: list[str] | None = None, ) -> tuple[str, int]: """Update a user's membership in a room. @@ -679,6 +684,7 @@ async def update_membership( state_event_ids=state_event_ids, depth=depth, origin_server_ts=origin_server_ts, + prev_state_events=prev_state_events, ) return result @@ -701,6 +707,7 @@ async def update_membership_locked( state_event_ids: list[str] | None = None, depth: int | None = None, origin_server_ts: int | None = None, + prev_state_events: list[str] | None = None, ) -> tuple[str, int]: """Helper for update_membership. @@ -943,9 +950,20 @@ async def update_membership_locked( require_consent=require_consent, outlier=outlier, origin_server_ts=origin_server_ts, + prev_state_events=prev_state_events, ) - latest_event_ids = await self.store.get_prev_events_for_room(room_id) + is_state_dags = False + try: + room_version = await self.store.get_room_version(room_id) + is_state_dags = room_version.msc4242_state_dags + except (NotFoundError, UnsupportedRoomVersionError): + pass + + if is_state_dags: + latest_event_ids = list(await self.store.get_state_dag_extremities(room_id)) + else: + latest_event_ids = await self.store.get_prev_events_for_room(room_id) is_partial_state_room = await self.store.is_partial_state_room(room_id) partial_state_before_join = await self.state_handler.compute_state_after_events( @@ -1156,6 +1174,8 @@ async def update_membership_locked( # see: https://github.com/matrix-org/synapse/issues/7139 if len(latest_event_ids) == 0: latest_event_ids = [invite.event_id] + if invite.room_version.msc4242_state_dags: + prev_state_events = [invite.event_id] # or perhaps this is a remote room that a local user has knocked on elif current_membership_type == Membership.KNOCK: @@ -1201,6 +1221,7 @@ async def update_membership_locked( require_consent=require_consent, outlier=outlier, origin_server_ts=origin_server_ts, + prev_state_events=prev_state_events, ) async def check_for_any_membership_in_room( @@ -2098,9 +2119,17 @@ async def _generate_local_out_of_band_leave( # # the prev_events consist solely of the previous membership event. prev_event_ids = [previous_membership_event.event_id] - auth_event_ids = ( - list(previous_membership_event.auth_event_ids()) + prev_event_ids - ) + auth_event_ids = None + # Authorise the leave by referencing the previous membership + prev_state_events = None + if previous_membership_event.room_version.msc4242_state_dags: + prev_state_events = [ + previous_membership_event.event_id, + ] + else: + auth_event_ids = ( + list(previous_membership_event.auth_event_ids()) + prev_event_ids + ) # Try several times, it could fail with PartialStateConflictError # in handle_new_client_event, cf comment in except block. @@ -2117,6 +2146,7 @@ async def _generate_local_out_of_band_leave( prev_event_ids=prev_event_ids, auth_event_ids=auth_event_ids, outlier=True, + prev_state_events=prev_state_events, ) context = await unpersisted_context.persist(event) event.internal_metadata.out_of_band_membership = True diff --git a/synapse/state/__init__.py b/synapse/state/__init__.py index a92233c863e..75b21e474a3 100644 --- a/synapse/state/__init__.py +++ b/synapse/state/__init__.py @@ -37,7 +37,7 @@ from synapse.api.constants import EventTypes from synapse.api.room_versions import KNOWN_ROOM_VERSIONS, StateResolutionVersions -from synapse.events import EventBase +from synapse.events import EventBase, FrozenEventVMSC4242 from synapse.events.snapshot import ( EventContext, UnpersistedEventContext, @@ -239,6 +239,7 @@ async def compute_state_after_events( ) return await ret.get_state(self._state_storage_controller, state_filter) + # TODO: Remove as this is unused async def get_current_user_ids_in_room( self, room_id: str, latest_event_ids: StrCollection ) -> set[str]: @@ -303,7 +304,8 @@ async def calculate_context_info( membership events. `False` if `state_ids_before_event` is the full state. `None` when `state_ids_before_event` is not provided. In this case, the - flag will be calculated based on `event`'s prev events. + flag will be calculated based on `event`'s `prev_events` or `prev_state_events` + for state DAG rooms. state_group_before_event: the current state group at the time of event, if known Returns: @@ -337,7 +339,11 @@ async def calculate_context_info( # (This is slightly racy - the prev-events might get fixed up before we use # their states - but I don't think that really matters; it just means we # might redundantly recalculate the state for this event later.) - prev_event_ids = event.prev_event_ids() + prev_event_ids = frozenset( + event.prev_state_events + if isinstance(event, FrozenEventVMSC4242) + else event.prev_event_ids() + ) incomplete_prev_events = await self.store.get_partial_state_events( prev_event_ids ) @@ -355,7 +361,7 @@ async def calculate_context_info( entry = await self.resolve_state_groups_for_events( event.room_id, - event.prev_event_ids(), + prev_event_ids, await_full_state=False, ) diff --git a/synapse/storage/controllers/persist_events.py b/synapse/storage/controllers/persist_events.py index 2948227807f..86ba2b906fa 100644 --- a/synapse/storage/controllers/persist_events.py +++ b/synapse/storage/controllers/persist_events.py @@ -35,6 +35,7 @@ Generic, Iterable, TypeVar, + cast, ) import attr @@ -43,7 +44,9 @@ from twisted.internet import defer from synapse.api.constants import EventTypes, Membership -from synapse.events import EventBase +from synapse.api.errors import SynapseError +from synapse.api.room_versions import KNOWN_ROOM_VERSIONS +from synapse.events import EventBase, FrozenEventVMSC4242, event_exists_in_state_dag from synapse.events.snapshot import EventContext, EventPersistencePair from synapse.handlers.worker_lock import NEW_EVENT_DURING_PURGE_LOCK_NAME from synapse.logging.context import PreserveLoggingContext, make_deferred_yieldable @@ -68,6 +71,7 @@ from synapse.types.state import StateFilter from synapse.util.async_helpers import ObservableDeferred, yieldable_gather_results from synapse.util.metrics import Measure +from synapse.util.stringutils import shortstr if TYPE_CHECKING: from synapse.server import HomeServer @@ -111,6 +115,14 @@ buckets=(0, 1, 2, 3, 5, 7, 10, 15, 20, 50, 100, 200, 500, "+Inf"), ) +# The number of forward extremities for each new event. +msc4242_state_dag_forward_extremities_counter = Histogram( + "synapse_storage_msc4242_state_dag_forward_extremities_persisted", + "Number of forward extremities for each new event in the state DAG", + labelnames=[SERVER_NAME_LABEL], + buckets=(1, 2, 3, 5, 7, 10, 15, 20, 50, 100, 200, 500, "+Inf"), +) + state_resolutions_during_persistence = Counter( "synapse_storage_events_state_resolutions_during_persistence", "Number of times we had to do state res to calculate new current state", @@ -529,7 +541,15 @@ async def _calculate_current_state(self, room_id: str) -> StateMap[str]: Returns: map from (type, state_key) to event id for the current state in the room """ - latest_event_ids = await self.main_store.get_latest_event_ids_in_room(room_id) + room_version = await self.main_store.get_room_version_id(room_id) + room_version_obj = KNOWN_ROOM_VERSIONS[room_version] + if room_version_obj.msc4242_state_dags: + latest_event_ids = await self.main_store.get_state_dag_extremities(room_id) + else: + latest_event_ids = await self.main_store.get_latest_event_ids_in_room( + room_id + ) + state_groups = set( ( await self.main_store._get_state_group_for_events(latest_event_ids) @@ -551,7 +571,6 @@ async def _calculate_current_state(self, room_id: str) -> StateMap[str]: # Avoid a circular import. from synapse.state import StateResolutionStore - room_version = await self.main_store.get_room_version_id(room_id) res = await self._state_resolution_handler.resolve_state_groups( room_id, room_version, @@ -615,28 +634,52 @@ async def _persist_event_batch( for x in range(0, len(events_and_contexts), 100) ] + # Get the room version for the first event. This room version is the same for all events + # as events_and_contexts is all for one room. + assert len(events_and_contexts) > 0 + room_version = events_and_contexts[0][0].room_version + for chunk in chunks: # We can't easily parallelize these since different chunks # might contain the same event. :( new_forward_extremities = None state_delta_for_room = None + new_state_dag_extrems = None if not backfilled: - with Measure( - self._clock, - name="_calculate_state_and_extrem", - server_name=self.server_name, - ): - # Work out the new "current state" for the room. - # We do this by working out what the new extremities are and then - # calculating the state from that. - ( - new_forward_extremities, - state_delta_for_room, - ) = await self._calculate_new_forward_extremities_and_state_delta( - room_id, chunk - ) + if room_version and room_version.msc4242_state_dags: + with Measure( + self._clock, + name="_process_state_dag_forward_extremities_and_state_delta", + server_name=self.server_name, + ): + assert all( + isinstance(ev, FrozenEventVMSC4242) for ev, _ in chunk + ) + ( + new_forward_extremities, # for prev_events + state_delta_for_room, # for state groups + new_state_dag_extrems, # for prev_state_events + ) = await self._process_state_dag_forward_extremities_and_state_delta( + room_id, + cast(list[tuple[FrozenEventVMSC4242, EventContext]], chunk), + ) + else: + with Measure( + self._clock, + name="_calculate_state_and_extrem", + server_name=self.server_name, + ): + # Work out the new "current state" for the room. + # We do this by working out what the new extremities are and then + # calculating the state from that. + ( + new_forward_extremities, + state_delta_for_room, + ) = await self._calculate_new_forward_extremities_and_state_delta( + room_id, chunk + ) with Measure( self._clock, @@ -666,6 +709,7 @@ async def _persist_event_batch( use_negative_stream_ordering=backfilled, inhibit_local_membership_updates=backfilled, new_event_links=new_event_links, + new_state_dag_forward_extremities=new_state_dag_extrems, ) return replaced_events @@ -793,6 +837,208 @@ async def _calculate_new_forward_extremities_and_state_delta( return (new_forward_extremities, delta) + async def _process_state_dag_forward_extremities_and_state_delta( + self, + room_id: str, + event_contexts: list[tuple[FrozenEventVMSC4242, EventContext]], + ) -> tuple[set[str] | None, DeltaState | None, set[str] | None]: + """Process the forwards extremities for state DAG rooms. + Returns: + - the new room dag extremities which should be written when these events are persisted. + - the state delta for the room, if applicable. + - the new state dag extremities which should be written when these events are persisted. + + NB: this does not write them because if it did, new events may see them _before_ the events + get persisted, causing failures in retrieving state groups. + """ + # Update forward extremities + # ...for the state DAG + existing_state_dag_fwd_extrems = ( + await self.main_store.get_state_dag_extremities(room_id) + ) + new_state_dag_fwd_extrems = await self._calculate_new_state_dag_extremities( + room_id, + existing_state_dag_fwd_extrems, + event_contexts, + ) + # ...and the room DAG + existing_room_dag_fwd_extrems = ( + await self.main_store.get_latest_event_ids_in_room(room_id) + ) + new_room_dag_fwd_extrems = await self._calculate_new_extremities( + room_id, + cast(list[EventPersistencePair], event_contexts), + existing_room_dag_fwd_extrems, + ) + assert new_room_dag_fwd_extrems, ( + f"No room dag forward extremities left in room {room_id}!" + ) + + # See if we need to calculate a state delta + if new_state_dag_fwd_extrems == existing_state_dag_fwd_extrems: + # No change in state extremities, so no new state to calculate + return new_room_dag_fwd_extrems, None, new_state_dag_fwd_extrems + + with Measure( + self._clock, + name="persist_events.state_dag.get_new_state_after_events", + server_name=self.server_name, + ): + (current_state, delta_ids, _) = await self._get_new_state_after_events( + room_id, + cast(list[EventPersistencePair], event_contexts), + existing_state_dag_fwd_extrems, + new_state_dag_fwd_extrems, + # do not prune forward extremities in the state DAG + # else we lose eventual delivery + should_prune=False, + ) + + # Following logic cargoculted from _calculate_new_forward_extremities_and_state_delta + # If either are not None then there has been a change, + # and we need to work out the delta (or use that + # given) + delta = None + if delta_ids is not None: + # If there is a delta we know that we've + # only added or replaced state, never + # removed keys entirely. + delta = DeltaState([], delta_ids) + elif current_state is not None: + with Measure( + self._clock, + name="persist_events.calculate_state_delta", + server_name=self.server_name, + ): + delta = await self._calculate_state_delta(room_id, current_state) + + if delta: + # If we have a change of state then lets check + # whether we're actually still a member of the room, + # or if our last user left. If we're no longer in + # the room then we delete the current state and + # extremities. + is_still_joined = await self._is_server_still_joined( + room_id, + cast(list[EventPersistencePair], event_contexts), + delta, + ) + if not is_still_joined: + logger.info("Server no longer in room %s", room_id) + delta.no_longer_in_room = True + + return new_room_dag_fwd_extrems, delta, new_state_dag_fwd_extrems + + async def _calculate_new_state_dag_extremities( + self, + room_id: str, + existing_fwd_extrems: frozenset[str], + event_contexts: list[tuple[FrozenEventVMSC4242, EventContext]], + ) -> set[str]: + """Calculate the new state dag forward extremities. Modifies existing_fwd_extrems. + + Assumes that event_contexts are only state events which should be in the state DAG. + + Raises: + SynapseError: if the new events include unknown prev_state_events + AssertionError: if there are no state DAG forward extremities remaining in the room + """ + # filter out events which don't belong in the state dag. + new_state_events_contexts = [ + (e, ctx) for e, ctx in event_contexts if event_exists_in_state_dag(e) + ] + if len(new_state_events_contexts) == 0: + # if there are no state events being persisted, then the fwd extremities of the state dag + # do not change. + return set(existing_fwd_extrems) + + # This logic is very similar to _calculate_new_extremities with a few key differences: + # - We do not "Remove any events which are prev_events of any existing events." because the + # state DAG mandates that events are processed in causal order, so there MUST NOT be any + # existing, processed events which have the to-be-persisted events as prev_state_events. + # - We don't care if they are an "outlier" in the main room dag, so long as they AREN'T + # an outlier on the state dag, which this function checks, so we don't check outlier-ness. + # - We allow *soft-failed* events to become forward extremities, as per the MSC. We do not + # allow *rejected* events to become forward extremities though. + + rejected_events = [ev for ev, ctx in new_state_events_contexts if ctx.rejected] + new_state_events = [ + ev for ev, ctx in new_state_events_contexts if not ctx.rejected + ] + # We want to check that we are not missing any prev_state_events. + # To do this, we include rejected events in this check. + all_new_state_events = set(rejected_events + new_state_events) + + # First, verify that we know all prev_state_events. If we fail this check then we don't have + # a complete DAG and that is bad, so bail out. + + # Start with them all missing. + missing_prev_state_events = { + e_id for event in all_new_state_events for e_id in event.prev_state_events + } + + # remove prev events which appear in all_events + missing_prev_state_events.difference_update( + event.event_id for event in all_new_state_events + ) + # the rest of these events should be present in the DB. Some of them may be forward extremities, + # some may not be, that's ok. + seen_events = await self.main_store.have_seen_events( + room_id, + missing_prev_state_events, + ) + missing_prev_state_events.difference_update(seen_events) + + if len(missing_prev_state_events) > 0: + logger.error( + "_calculate_new_state_dag_extremities: missing the following prev_state_events in room %s : %s", + room_id, + missing_prev_state_events, + ) + logger.error( + "_calculate_new_state_dag_extremities: was handling %s", + shortstr([ev.event_id for ev in all_new_state_events]), + ) + raise SynapseError( + code=500, + msg=f"missing {len(missing_prev_state_events)} prev_state_events in room {room_id}", + ) + + # Now calculate the forward extremities. + + # start with the existing forward extremities + result = set(existing_fwd_extrems) + + # add all the new events to the list + result.update(event.event_id for event in new_state_events) + + # Now remove all events which are prev_state_events of any of the new events + result.difference_update( + e_id for event in new_state_events for e_id in event.prev_state_events + ) + + # Finally handle the case where the new events have rejected/soft-failed `prev_state_events`. + # If they do we need to remove them and their `prev_state_events`, + # otherwise we end up with dangling extremities. + # Specifically, this handles the case where (F=fwd extrem, SF=soft-failed, N=new event) + # F <-- SF <-- SF <-- N + # where we want to remove F as a forward extremity and replace with N. + existing_prevs = await self.persist_events_store._get_prevs_before_rejected( + (e_id for event in new_state_events for e_id in event.prev_state_events), + include_soft_failed=False, + ) + result.difference_update(existing_prevs) + + # We only update metrics for events that change forward extremities + if result != existing_fwd_extrems: + msc4242_state_dag_forward_extremities_counter.labels( + **{SERVER_NAME_LABEL: self.server_name} + ).observe(len(result)) + + # There should always be at least one forward extremity. + assert result, f"No state dag forward extremities left in room {room_id}!" + return result + async def _calculate_new_extremities( self, room_id: str, @@ -859,6 +1105,7 @@ async def _get_new_state_after_events( events_context: list[EventPersistencePair], old_latest_event_ids: AbstractSet[str], new_latest_event_ids: set[str], + should_prune: bool = True, ) -> tuple[StateMap[str] | None, StateMap[str] | None, set[str]]: """Calculate the current state dict after adding some new events to a room @@ -876,6 +1123,12 @@ async def _get_new_state_after_events( new_latest_event_ids : the new forward extremities for the room. + should_prune : + if true, attempt to prune the forward extremities. + Pruning means we will not communicate some new events to other servers, + which can compromise eventual delivery, so graphs which are fully synchronised + e.g. state DAGs should not prune. + Returns: Returns a tuple of two state maps and a set of new forward extremities. @@ -1015,7 +1268,7 @@ async def _get_new_state_after_events( # If the returned state matches the state group of one of the new # forward extremities then we check if we are able to prune some state # extremities. - if res.state_group and res.state_group in new_state_groups: + if should_prune and res.state_group and res.state_group in new_state_groups: new_latest_event_ids = await self._prune_extremities( room_id, new_latest_event_ids, diff --git a/synapse/storage/database.py b/synapse/storage/database.py index 6e38b55686b..06c5bd23c50 100644 --- a/synapse/storage/database.py +++ b/synapse/storage/database.py @@ -1713,7 +1713,6 @@ def simple_upsert_many_txn_native_upsert( ", ".join(key_names), latter, ) - txn.execute_values(sql, args, fetch=False) else: diff --git a/synapse/storage/databases/main/event_federation.py b/synapse/storage/databases/main/event_federation.py index cc7083b605d..ea2bb93380b 100644 --- a/synapse/storage/databases/main/event_federation.py +++ b/synapse/storage/databases/main/event_federation.py @@ -37,7 +37,7 @@ from synapse.api.constants import MAX_DEPTH from synapse.api.errors import StoreError from synapse.api.room_versions import EventFormatVersions, RoomVersion -from synapse.events import EventBase, make_event_from_dict +from synapse.events import EventBase, FrozenEventVMSC4242, make_event_from_dict from synapse.logging.opentracing import tag_args, trace from synapse.metrics import SERVER_NAME_LABEL from synapse.metrics.background_process_metrics import wrap_as_background_process @@ -258,6 +258,59 @@ async def get_auth_chain_ids( include_given, ) + async def get_state_dag( + self, room_id: str, forward_extrems: set[str] + ) -> dict[str, FrozenEventVMSC4242]: + """Get the current state DAG for the given room. + + This function is called when calculating a /send_join response. + This does not check that the room is an state DAG room, so check this + before calling this function! + + This functions guarantees that the returned state DAG is connected. + + Args: + room_id: The room to get the state dag for + Returns: + A map of event_id => event + """ + + def _get_state_events_txn(txn: LoggingTransaction, room_id: str) -> list[str]: + sql = """ + SELECT event_id FROM msc4242_state_dag_edges WHERE room_id = ? + """ + txn.execute(sql, (room_id,)) + event_ids = [ev_id for (ev_id,) in txn] + return event_ids + + # Pull out all auth events for this room using the events_by_room_and_type index. + # This is going to pull in erroneous events e.g m.room.members with no state keys but that's + # okay as we'll filter them out next. + event_ids = await self.db_pool.runInteraction( + "_get_state_events_txn", + _get_state_events_txn, + room_id, + ) + event_map = await self.get_events(event_ids) + # Filter the returned state events to only include ones on the paths back from the forward + # extremities. + result: dict[str, FrozenEventVMSC4242] = {} + next = forward_extrems + seen: set[str] = set() + while len(next) > 0: + # Pull the event and add the prev_state_events. + # We must have the event. + event_id = next.pop() + if event_id in seen: + continue + seen.add(event_id) + ev = event_map[event_id] + assert isinstance(ev, FrozenEventVMSC4242) + result[event_id] = ev + for pae in ev.prev_state_events: + next.add(pae) + return result + def _get_auth_chain_ids_using_cover_index_txn( self, txn: LoggingTransaction, @@ -1493,6 +1546,15 @@ async def get_latest_event_ids_in_room(self, room_id: str) -> frozenset[str]: ) return frozenset(event_ids) + async def get_state_dag_extremities(self, room_id: str) -> frozenset[str]: + event_ids = await self.db_pool.simple_select_onecol( + table="msc4242_state_dag_forward_extremities", + keyvalues={"room_id": room_id}, + retcol="event_id", + desc="get_state_dag_extremities", + ) + return frozenset(event_ids) + async def get_min_depth(self, room_id: str) -> int | None: """For the given room, get the minimum depth we have seen for it.""" return await self.db_pool.runInteraction( @@ -1978,6 +2040,57 @@ def _get_missing_events( event_results.reverse() return event_results + async def get_missing_events_state_dag( + self, + room_id: str, + earliest_events: list[str], + latest_events: list[str], + limit: int, + ) -> list[EventBase]: + ids = await self.db_pool.runInteraction( + "get_missing_events_state_dag", + self._get_missing_events_state_dag, + room_id, + earliest_events, + latest_events, + limit, + ) + return await self.get_events_as_list(ids) + + def _get_missing_events_state_dag( + self, + txn: LoggingTransaction, + room_id: str, + earliest_events: list[str], + latest_events: list[str], + limit: int, + ) -> list[str]: + seen_events = set(earliest_events) + # ascii sort + front_queue = sorted(set(latest_events) - seen_events) + event_results: list[str] = [] + # TODO(kegan): use a recursive CTE? + query = ( + "SELECT prev_state_event_id FROM msc4242_state_dag_edges " + "WHERE room_id = ? AND event_id = ? " + "ORDER BY prev_state_event_id ASC " + "LIMIT ?" + ) + + while front_queue and len(event_results) < limit: + event_id = front_queue.pop(0) + txn.execute(query, (room_id, event_id, limit - len(event_results))) + # None check because the m.room.create event has NULL prev_state_events + new_results = [ + t[0] for t in txn if t[0] is not None and t[0] not in seen_events + ] + for next in new_results: + front_queue.append(next) + seen_events |= set(new_results) + event_results.extend(new_results) + + return event_results + @trace @tag_args async def get_successor_events(self, event_id: str) -> list[str]: diff --git a/synapse/storage/databases/main/events.py b/synapse/storage/databases/main/events.py index cb452dbc9b1..2df1b0a6d47 100644 --- a/synapse/storage/databases/main/events.py +++ b/synapse/storage/databases/main/events.py @@ -48,7 +48,9 @@ from synapse.api.room_versions import RoomVersions from synapse.events import ( EventBase, + FrozenEventVMSC4242, StrippedStateEvent, + event_exists_in_state_dag, is_creator, relation_from_event, ) @@ -295,6 +297,7 @@ async def _persist_events_and_state_updates( new_event_links: dict[str, NewEventChainLinks], use_negative_stream_ordering: bool = False, inhibit_local_membership_updates: bool = False, + new_state_dag_forward_extremities: set[str] | None = None, ) -> None: """Persist a set of events alongside updates to the current state and forward extremities tables. @@ -315,6 +318,8 @@ async def _persist_events_and_state_updates( from being updated by these events. This should be set to True for backfilled events because backfilled events in the past do not affect the current local state. + new_state_dag_forward_extremities: A set of event IDs that are the new forward + extremities for the state DAG for this room. MSC4242 only. Returns: Resolves when the events have been persisted @@ -379,6 +384,7 @@ async def _persist_events_and_state_updates( new_forward_extremities=new_forward_extremities, new_event_links=new_event_links, sliding_sync_table_changes=sliding_sync_table_changes, + new_state_dag_forward_extremities=new_state_dag_forward_extremities, ) persist_event_counter.labels(**{SERVER_NAME_LABEL: self.server_name}).inc( len(events_and_contexts) @@ -962,8 +968,10 @@ def _get_events_which_are_prevs_txn( return results - async def _get_prevs_before_rejected(self, event_ids: Iterable[str]) -> set[str]: - """Get soft-failed ancestors to remove from the extremities. + async def _get_prevs_before_rejected( + self, event_ids: Iterable[str], include_soft_failed: bool = True + ) -> set[str]: + """Get soft-failed/rejected ancestors to remove from the extremities. Given a set of events, find all those that have been soft-failed or rejected. Returns those soft failed/rejected events and their prev @@ -976,7 +984,8 @@ async def _get_prevs_before_rejected(self, event_ids: Iterable[str]) -> set[str] Args: event_ids: Events to find prev events for. Note that these must have already been persisted. - + include_soft_failed: Soft-failed events are included in the search. If false, only + rejected events are included. Returns: The previous events. """ @@ -1016,7 +1025,7 @@ def _get_prevs_before_rejected_txn( continue soft_failed = db_to_json(metadata).get("soft_failed") - if soft_failed or rejected: + if (include_soft_failed and soft_failed) or rejected: to_recursively_check.append(prev_event_id) existing_prevs.add(prev_event_id) @@ -1038,6 +1047,7 @@ def _persist_events_txn( new_forward_extremities: set[str] | None, new_event_links: dict[str, NewEventChainLinks], sliding_sync_table_changes: SlidingSyncTableChanges | None, + new_state_dag_forward_extremities: set[str] | None = None, ) -> None: """Insert some number of room events into the necessary database tables. @@ -1146,6 +1156,11 @@ def _persist_events_txn( max_stream_order=max_stream_order, ) + if new_state_dag_forward_extremities: + self._set_state_dag_extremities_txn( + txn, room_id, new_state_dag_forward_extremities + ) + self._persist_transaction_ids_txn(txn, events_and_contexts) # Insert into event_to_state_groups. @@ -2475,6 +2490,29 @@ def _update_forward_extremities_txn( ], ) + def _set_state_dag_extremities_txn( + self, txn: LoggingTransaction, room_id: str, new_extrems: Collection[str] + ) -> None: + self.db_pool.simple_delete_txn( + txn, + table="msc4242_state_dag_forward_extremities", + keyvalues={ + "room_id": room_id, + }, + ) + self.db_pool.simple_insert_many_txn( + txn, + table="msc4242_state_dag_forward_extremities", + keys=("room_id", "event_id"), + values=[ + ( + room_id, + event_id, + ) + for event_id in new_extrems + ], + ) + @classmethod def _filter_events_and_contexts_for_duplicates( cls, events_and_contexts: list[EventPersistencePair] @@ -2859,6 +2897,12 @@ def _update_metadata_tables_txn( self._handle_event_relations(txn, event) + if event.room_version.msc4242_state_dags and event_exists_in_state_dag( + event + ): + assert isinstance(event, FrozenEventVMSC4242) + self._store_state_dag_edges(txn, event) + # Store the labels for this event. labels = event.content.get(EventContentFields.LABELS) if labels: @@ -2935,6 +2979,36 @@ def local_prefill() -> None: txn.async_call_after(external_prefill) txn.call_after(local_prefill) + def _store_state_dag_edges( + self, txn: LoggingTransaction, event: FrozenEventVMSC4242 + ) -> None: + # the create event has no edge but we still need to persist it as get_state_dag just + # yanks all rows in this table. It's a bit gross to store NULL as the prev_state_event_id + # though. + if len(event.prev_state_events) == 0 and event.type == EventTypes.Create: + self.db_pool.simple_insert_txn( + txn, + table="msc4242_state_dag_edges", + values={ + "room_id": event.room_id, + "event_id": event.event_id, + "prev_state_event_id": None, + }, + ) + return + assert len(event.prev_state_events) > 0 + self.db_pool.simple_upsert_many_txn( + txn, + table="msc4242_state_dag_edges", + key_names=["room_id", "event_id", "prev_state_event_id"], + key_values=[ + (event.room_id, event.event_id, prev_state_event) + for prev_state_event in event.prev_state_events + ], + value_names=(), + value_values=(), + ) + def _store_redaction(self, txn: LoggingTransaction, event: EventBase) -> None: assert event.redacts is not None self.db_pool.simple_upsert_txn( @@ -3455,7 +3529,13 @@ def _store_event_state_mappings_txn( """ state_groups = {} for event, context in events_and_contexts: - if event.internal_metadata.is_outlier(): + # state dag rooms allow outliers to have state, as `/get_missing_events` state dag events are nominally + # outliers (not present in the timeline) but do need state persisted so we can calculate + # what the auth_events are for the event. + if ( + not event.room_version.msc4242_state_dags + and event.internal_metadata.is_outlier() + ): # double-check that we don't have any events that claim to be outliers # *and* have partial state (which is meaningless: we should have no # state at all for an outlier) diff --git a/synapse/storage/databases/main/purge_events.py b/synapse/storage/databases/main/purge_events.py index d55ea5cf7d4..fe8079c2010 100644 --- a/synapse/storage/databases/main/purge_events.py +++ b/synapse/storage/databases/main/purge_events.py @@ -71,6 +71,10 @@ # so must be deleted first. "sliding_sync_joined_rooms", "sliding_sync_membership_snapshots", + # Note: msc4242_state_dag_forward_extremities/edges have a foreign key to the `events` table + # so must be deleted first. + "msc4242_state_dag_forward_extremities", + "msc4242_state_dag_edges", "events", "federation_inbound_events_staging", "receipts_graph", diff --git a/synapse/storage/databases/main/state.py b/synapse/storage/databases/main/state.py index cfde107b486..87523e6f180 100644 --- a/synapse/storage/databases/main/state.py +++ b/synapse/storage/databases/main/state.py @@ -38,7 +38,7 @@ from synapse.api.constants import EventContentFields, EventTypes, Membership from synapse.api.errors import NotFoundError, UnsupportedRoomVersionError from synapse.api.room_versions import KNOWN_ROOM_VERSIONS, RoomVersion -from synapse.events import EventBase +from synapse.events import EventBase, EventMetadata from synapse.events.snapshot import EventContext from synapse.logging.opentracing import trace from synapse.replication.tcp.streams import UnPartialStatedEventStream @@ -78,16 +78,6 @@ class Sentinel: ROOM_UNKNOWN_SENTINEL = Sentinel() -@attr.s(slots=True, frozen=True, auto_attribs=True) -class EventMetadata: - """Returned by `get_metadata_for_events`""" - - room_id: str - event_type: str - state_key: str | None - rejection_reason: str | None - - def _retrieve_and_check_room_version(room_id: str, room_version_id: str) -> RoomVersion: v = KNOWN_ROOM_VERSIONS.get(room_version_id) if not v: diff --git a/synapse/storage/schema/__init__.py b/synapse/storage/schema/__init__.py index c4c4d7bcc4a..43a7c84be94 100644 --- a/synapse/storage/schema/__init__.py +++ b/synapse/storage/schema/__init__.py @@ -171,6 +171,7 @@ Changes in SCHEMA_VERSION = 93 - MSC4140: Set delayed events to be uniquely identifiable by their delay ID. + - MSC4242: Add state DAG tables. """ diff --git a/synapse/storage/schema/main/delta/93/04_state_dag_fwd_extrems.sql b/synapse/storage/schema/main/delta/93/04_state_dag_fwd_extrems.sql new file mode 100644 index 00000000000..2fdb96b25cd --- /dev/null +++ b/synapse/storage/schema/main/delta/93/04_state_dag_fwd_extrems.sql @@ -0,0 +1,42 @@ +-- +-- This file is licensed under the Affero General Public License (AGPL) version 3. +-- +-- Copyright (C) 2026 Element Creations, Ltd +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as +-- published by the Free Software Foundation, either version 3 of the +-- License, or (at your option) any later version. +-- +-- See the GNU Affero General Public License for more details: +-- . + +CREATE TABLE IF NOT EXISTS msc4242_state_dag_forward_extremities( + -- we always expect the room to exist. If it gets removed, delete fwd extremities. + room_id TEXT NOT NULL REFERENCES rooms(room_id) ON DELETE CASCADE, + event_id TEXT NOT NULL REFERENCES events(event_id) ON DELETE CASCADE, + -- it doesn't make sense to reference the same event multiple times, and this uniqueness + -- index is also used to delete events once they are no longer forward extremities. + UNIQUE (event_id) +); +-- When creating events, we want to select all forward extremities for a room which this index helps with. +CREATE INDEX msc4242_state_dag_room ON msc4242_state_dag_forward_extremities(room_id); + + +CREATE TABLE IF NOT EXISTS msc4242_state_dag_edges( + -- Deleting the room deletes the state DAG. + room_id TEXT NOT NULL REFERENCES rooms(room_id) ON DELETE CASCADE, + -- the event IDs being referenced must exist (hence REFERENCES) and we do not want to accidentally delete + -- the event and create a hole in the state DAG. It is not possible for a state + -- DAG room to function with an holey DAG, so these events _cannot_ be purged. To purge them, the + -- entire room would need to be deleted. + event_id TEXT NOT NULL REFERENCES events(event_id), + -- one of the `prev_state_events` for this event ID. We must have it since we must have the entire state DAG. + -- can be NULL for the create event. + prev_state_event_id TEXT REFERENCES events(event_id) + -- calculated depth for this event ID. There is some denormalisation here as we're storing the depth + -- multiple times for each prev_state_event_id. + -- depth BIGINT NOT NULL +); +CREATE INDEX msc4242_state_dag_edges_by_room ON msc4242_state_dag_edges(room_id); +CREATE UNIQUE INDEX msc4242_state_dag_edges_key ON msc4242_state_dag_edges(room_id, event_id, prev_state_event_id); diff --git a/synapse/synapse_rust/events.pyi b/synapse/synapse_rust/events.pyi index 0add391c651..ebe9184b079 100644 --- a/synapse/synapse_rust/events.pyi +++ b/synapse/synapse_rust/events.pyi @@ -43,6 +43,9 @@ class EventInternalMetadata: device_id: str """The device ID of the user who sent this event, if any.""" + # MSC4242 state dags + calculated_auth_event_ids: list[str] + def get_dict(self) -> JsonDict: ... def is_outlier(self) -> bool: ... def copy(self) -> "EventInternalMetadata": ... diff --git a/tests/federation/test_federation_out_of_band_membership.py b/tests/federation/test_federation_out_of_band_membership.py index a1ab72b7a18..f556460af4a 100644 --- a/tests/federation/test_federation_out_of_band_membership.py +++ b/tests/federation/test_federation_out_of_band_membership.py @@ -395,6 +395,7 @@ async def put_json( user1_invite_membership_event, ], event_dict=user1_join_membership_event_signed.get_pdu_json(), + state_dag=[], event=user1_join_membership_event_signed, members_omitted=False, servers_in_room=[ diff --git a/tests/federation/test_federation_server.py b/tests/federation/test_federation_server.py index 275e5dfa1d8..3652e8c6b5d 100644 --- a/tests/federation/test_federation_server.py +++ b/tests/federation/test_federation_server.py @@ -534,36 +534,54 @@ def _test_send_join_common(self, room_version: str) -> None: ) self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body) - # we should get complete room state back - returned_state = [ - (ev["type"], ev["state_key"]) for ev in channel.json_body["state"] - ] - self.assertCountEqual( - returned_state, - [ - ("m.room.create", ""), - ("m.room.power_levels", ""), - ("m.room.join_rules", ""), - ("m.room.history_visibility", ""), - ("m.room.member", f"@kermit_v{room_version}:test"), - ("m.room.member", f"@fozzie_v{room_version}:test"), - # nb: *not* the joining user - ], - ) + if KNOWN_ROOM_VERSIONS[room_version].msc4242_state_dags: + # we should get complete state dag back + returned_state_dag = [ + (ev["type"], ev["state_key"]) for ev in channel.json_body["state_dag"] + ] + self.assertCountEqual( + returned_state_dag, + [ + ("m.room.create", ""), + ("m.room.power_levels", ""), + ("m.room.join_rules", ""), + ("m.room.history_visibility", ""), + ("m.room.member", f"@kermit_v{room_version}:test"), + ("m.room.member", f"@fozzie_v{room_version}:test"), + # nb: *not* the joining user + ], + ) + else: + # we should get complete room state back + returned_state = [ + (ev["type"], ev["state_key"]) for ev in channel.json_body["state"] + ] + self.assertCountEqual( + returned_state, + [ + ("m.room.create", ""), + ("m.room.power_levels", ""), + ("m.room.join_rules", ""), + ("m.room.history_visibility", ""), + ("m.room.member", f"@kermit_v{room_version}:test"), + ("m.room.member", f"@fozzie_v{room_version}:test"), + # nb: *not* the joining user + ], + ) - # also check the auth chain - returned_auth_chain_events = [ - (ev["type"], ev["state_key"]) for ev in channel.json_body["auth_chain"] - ] - self.assertCountEqual( - returned_auth_chain_events, - [ - ("m.room.create", ""), - ("m.room.member", f"@kermit_v{room_version}:test"), - ("m.room.power_levels", ""), - ("m.room.join_rules", ""), - ], - ) + # also check the auth chain + returned_auth_chain_events = [ + (ev["type"], ev["state_key"]) for ev in channel.json_body["auth_chain"] + ] + self.assertCountEqual( + returned_auth_chain_events, + [ + ("m.room.create", ""), + ("m.room.member", f"@kermit_v{room_version}:test"), + ("m.room.power_levels", ""), + ("m.room.join_rules", ""), + ], + ) # the room should show that the new user is a member r = self.get_success(self._storage_controllers.state.get_current_state(room_id)) diff --git a/tests/handlers/test_federation.py b/tests/handlers/test_federation.py index 7085531548c..25f0522887b 100644 --- a/tests/handlers/test_federation.py +++ b/tests/handlers/test_federation.py @@ -652,6 +652,7 @@ def test_failed_partial_join_is_clean(self) -> None: ], partial_state=True, servers_in_room={"example.com"}, + state_dag=None, ) ) diff --git a/tests/handlers/test_federation_event.py b/tests/handlers/test_federation_event.py index 3d856b93462..745a02a8073 100644 --- a/tests/handlers/test_federation_event.py +++ b/tests/handlers/test_federation_event.py @@ -18,19 +18,23 @@ # [This file includes modifications made by New Vector Limited] # # +from typing import Iterable from unittest import mock from twisted.internet.testing import MemoryReactor from synapse.api.errors import AuthError, StoreError -from synapse.api.room_versions import RoomVersion +from synapse.api.room_versions import RoomVersion, RoomVersions from synapse.event_auth import ( check_state_dependent_auth_rules, check_state_independent_auth_rules, ) -from synapse.events import make_event_from_dict +from synapse.events import EventBase, FrozenEventVMSC4242, make_event_from_dict from synapse.events.snapshot import EventContext from synapse.federation.transport.client import StateRequestResponse +from synapse.handlers.federation_event import ( + is_state_dag_connected, +) from synapse.logging.context import LoggingContext from synapse.rest import admin from synapse.rest.client import login, room @@ -1121,7 +1125,9 @@ async def get_event( return {"pdus": [missing_event.get_pdu_json()]} async def get_room_state_ids( - destination: str, room_id: str, event_id: str + destination: str, + room_id: str, + event_id: str, ) -> JsonDict: self.assertEqual(destination, self.OTHER_SERVER_NAME) self.assertEqual(event_id, missing_event.event_id) @@ -1131,7 +1137,10 @@ async def get_room_state_ids( } async def get_room_state( - room_version: RoomVersion, destination: str, room_id: str, event_id: str + room_version: RoomVersion, + destination: str, + room_id: str, + event_id: str, ) -> StateRequestResponse: self.assertEqual(destination, self.OTHER_SERVER_NAME) self.assertEqual(event_id, missing_event.event_id) @@ -1179,3 +1188,298 @@ async def get_room_state( bert_member_event.event_id, "Rejected kick event unexpectedly became part of room state.", ) + + +MSC4242_ROOM_ID = "!msc4242:example.com" +counter = 1 + + +class FederationEventMSC4242AuthDAGTests(unittest.FederatingHomeserverTestCase): + def test_is_state_dag_connected(self) -> None: + linear_1 = msc4242_event([]) + linear_2 = msc4242_event([linear_1.event_id]) + linear_3 = msc4242_event([linear_2.event_id]) + self.assertEqual(is_state_dag_connected([linear_3, linear_1, linear_2]), True) + + fork_1 = msc4242_event([]) + fork_2 = msc4242_event([fork_1.event_id]) + fork_3 = msc4242_event([fork_1.event_id]) + fork_4 = msc4242_event([fork_1.event_id]) + fork_5 = msc4242_event([fork_2.event_id, fork_4.event_id]) + self.assertEqual( + is_state_dag_connected([fork_1, fork_2, fork_3, fork_4, fork_5]), True + ) + + unconnected_1 = msc4242_event([]) + unconnected_2 = msc4242_event([unconnected_1.event_id]) + unconnected_3 = msc4242_event(["$unknown"]) + self.assertEqual( + is_state_dag_connected([unconnected_1, unconnected_2, unconnected_3]), False + ) + + def test_fetch_missing_state_dag_events_linear(self) -> None: + linear = self.make_state_dag( + { + "A": [], + "B": ["A"], + "C": ["B"], + } + ) + seen_events: set[str] = set() + get_missing_events_req_resps = { + ("C",): ["A", "B"], + } + self.prepare_handler(seen_events, linear, get_missing_events_req_resps) + self.assert_fetch_missing_state_dag_events(linear, "C", ["A", "B"]) + + def test_fetch_missing_state_dag_events_linear_seen(self) -> None: + linear = self.make_state_dag( + { + "A": [], + "B": ["A"], + "C": ["B"], + "D": ["C"], + } + ) + seen_events: set[str] = {"A", "B"} + get_missing_events_req_resps = { + ("D",): ["C"], + ("C",): ["B"], + } + self.prepare_handler(seen_events, linear, get_missing_events_req_resps) + self.assert_fetch_missing_state_dag_events(linear, "D", ["C"]) + + def test_fetch_missing_state_dag_events_fork_merge(self) -> None: + fork_merge = self.make_state_dag( + { + "A": [], + "B": ["A"], + "C": ["A"], + "D": ["C"], + "E": ["B"], + "F": ["D", "E"], + } + ) + seen_events: set[str] = set() + get_missing_events_req_resps = { + ("F",): ["D", "E"], + ( + "D", + "E", + ): ["C", "A"], + ("E",): ["B", "A"], + } + self.prepare_handler(seen_events, fork_merge, get_missing_events_req_resps) + self.assert_fetch_missing_state_dag_events( + fork_merge, "F", ["A", "B", "C", "D", "E"] + ) + + def test_fetch_missing_state_dag_events_give_up_no_forward_progress(self) -> None: + fork_merge = self.make_state_dag( + { + "A": [], + "B": ["A"], + "C": ["A"], + "D": ["C", "B"], + } + ) + seen_events: set[str] = set() + get_missing_events_req_resps = { + ("D",): ["C"], # never provide B + } + self.prepare_handler(seen_events, fork_merge, get_missing_events_req_resps) + self.assert_fetch_missing_state_dag_events(fork_merge, "D", []) + + def test_fetch_missing_state_dag_events_seen(self) -> None: + fork_merge = self.make_state_dag( + { + "A": [], + "B": ["A"], + "C": ["A"], + "D": ["C"], + "E": ["B"], + "F": ["D", "E"], + } + ) + seen_events: set[str] = {"A", "B"} + get_missing_events_req_resps = { + ("F",): ["D", "E"], + ("D",): ["C", "A"], # NB: not D,E as we've seen E's prev_state_events => B. + ("E",): ["B", "A"], + } + self.prepare_handler(seen_events, fork_merge, get_missing_events_req_resps) + self.assert_fetch_missing_state_dag_events(fork_merge, "F", ["C", "D", "E"]) + + # test that we maintain a visited set so we don't needlessly make expensive /get_missing_events + # calls in cases like: + # A <- B <- C <- D <------------------------ I + # `----- E <-- F <-- G <-- H <--` + # In this scenario, there's two paths to A: via D and via EFGH. Both paths converge at C and then + # go to A. The first path to reach A should be remembered so we don't make additional requests. + def test_fetch_missing_state_dag_events_memoise(self) -> None: + memoise_concurrent = self.make_state_dag( + { + "A": [], + "B": ["A"], + "C": ["B"], + "D": ["C"], + "E": ["C"], + "F": ["E"], + "G": ["F"], + "H": ["G"], + "I": ["D", "H"], + } + ) + get_missing_events_req_resps = { + ("I",): ["D", "H"], + ( + "D", + "H", + ): ["C", "G"], + ("C", "G"): ["B", "F"], + ("B", "F"): ["A", "E"], + # we should never request C on its own as we should remember we have visited C already. + } + self.prepare_handler(set(), memoise_concurrent, get_missing_events_req_resps) + self.assert_fetch_missing_state_dag_events( + memoise_concurrent, + "I", + [ + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + ], + ) + + def test_fetch_missing_state_dag_seen_all(self) -> None: + seen_all = self.make_state_dag( + { + "A": [], + "B": ["A"], + "C": ["A"], + "D": ["C"], + } + ) + seen_events: set[str] = {"A", "B", "C", "D"} + get_missing_events_req_resps: dict[tuple, list[str]] = {} + self.prepare_handler(seen_events, seen_all, get_missing_events_req_resps) + self.assert_fetch_missing_state_dag_events(seen_all, "D", []) + + def assert_fetch_missing_state_dag_events( + self, + graph: dict[str, FrozenEventVMSC4242], + start_event_id: str, + want_event_ids: list[str], + ) -> None: + """Run _fetch_missing_state_dag_events with the param start_event_id on the graph provided + and ensure the result matches want_event_ids.""" + got = self.get_success( + self.hs.get_federation_event_handler()._fetch_missing_state_dag_events( + "unknown", graph[start_event_id] + ) + ) + self.assertEqual( + {ev.event_id for ev in got}, {graph[x].event_id for x in want_event_ids} + ) + + def prepare_handler( + self, + seen_fake_events: set[str], + graph: dict[str, FrozenEventVMSC4242], + gme_req_resps: dict[tuple, list[str]], + ) -> None: + """Setup mocks on federation event handler to return the right data at the right time. + Args: + seen_fake_events: The events seen by this homeserver already (in the database) + graph: The prepared state DAG graph mapping from fake event ID to real event. + gme_req_resps: The /get_missing_events responses to return. The keys are the events + provided as 'latest_events' and the values will be the events returned.""" + h = self.hs.get_federation_event_handler() + h._federation_client = mock.Mock( + spec=[ + "get_missing_events", + ] + ) + for x in graph: + print(f"{x} => {graph[x].event_id}") + + async def get_missing_events( + destination: str, + room_id: str, + earliest_events_ids: Iterable[str], + latest_events: Iterable[EventBase], + limit: int, + min_depth: int, + timeout: int, + state_dag: bool = False, + ) -> list[EventBase]: + assert state_dag + assert room_id == MSC4242_ROOM_ID + target_key = [ev.event_id for ev in latest_events] + target_key.sort() + assert target_key + # test which response to return + for req, resp in gme_req_resps.items(): + key = [graph[x].event_id for x in req] + key.sort() + if key == target_key: + return [graph[x] for x in resp] + raise AssertionError( + f"get_missing_events with latest={target_key} but no matches found (tested {len(gme_req_resps)})" + ) + + h._federation_client.get_missing_events.side_effect = get_missing_events + + seen_events = { + graph[fake_event_id].event_id for fake_event_id in seen_fake_events + } + + async def have_seen_events(room_id: str, event_ids: Iterable[str]) -> set[str]: + return seen_events.intersection(set(event_ids)) + + main_store = self.hs.get_datastores().main + main_store.have_seen_events = mock.AsyncMock() # type: ignore[method-assign] + main_store.have_seen_events.side_effect = have_seen_events + + def make_state_dag( + self, graph: dict[str, list[str]] + ) -> dict[str, FrozenEventVMSC4242]: + """Create a state dag from a graph of event_id -> prev_state_events. + Returns a map of fake ID to real event. + The map must be sorted topologically else this raises an exception.""" + fake_to_real_id: dict[str, str] = {} + result: dict[str, FrozenEventVMSC4242] = {} + for fake_id, fake_prev_state_events in graph.items(): + # lookup fails if graph not sorted topologically + paes = [fake_to_real_id[fpae] for fpae in fake_prev_state_events] + ev = msc4242_event(paes) + fake_to_real_id[fake_id] = ev.event_id + result[fake_id] = ev + return result + + +def msc4242_event(prev_state_events: list[str]) -> FrozenEventVMSC4242: + global counter + counter += 1 + ev = make_event_from_dict( + { + "type": "m.room.member", + "state_key": "@alice:example.com", + "content": { + "membership": "join", + }, + "sender": "@alice:example.com", + "origin_server_ts": counter, # ensure hashed event changes + "room_id": MSC4242_ROOM_ID, + "prev_state_events": prev_state_events, + "prev_events": [], # we shouldn't look at this field + }, + RoomVersions.MSC4242v12, + ) + assert isinstance(ev, FrozenEventVMSC4242) + return ev diff --git a/tests/handlers/test_room_member.py b/tests/handlers/test_room_member.py index 3890abdbc83..c84ad5e9e7a 100644 --- a/tests/handlers/test_room_member.py +++ b/tests/handlers/test_room_member.py @@ -172,6 +172,7 @@ def test_remote_joins_contribute_to_rate_limit(self) -> None: auth_chain=[create_event], partial_state=False, servers_in_room=frozenset(), + state_dag=None, ) ) diff --git a/tests/storage/test_event_federation.py b/tests/storage/test_event_federation.py index 59e914ca8bd..e4e8cd4569e 100644 --- a/tests/storage/test_event_federation.py +++ b/tests/storage/test_event_federation.py @@ -19,6 +19,7 @@ # import datetime +from collections import namedtuple from typing import ( Collection, Iterable, @@ -38,8 +39,9 @@ KNOWN_ROOM_VERSIONS, EventFormatVersions, RoomVersion, + RoomVersions, ) -from synapse.events import EventBase +from synapse.events import EventBase, FrozenEventVMSC4242 from synapse.rest import admin from synapse.rest.client import login, room from synapse.server import HomeServer @@ -1414,6 +1416,174 @@ def test_get_event_ids_to_not_pull_from_backoff_retry_after_backoff_duration( # elapsed past the backoff range so there is no events to backoff from. self.assertEqual(event_ids_with_backoff, {}) + def test_get_state_dag(self) -> None: + """ + Test that MSC4242 state dag rooms can return the complete state dag on request. + """ + # Create the room + user_id = self.register_user("alice", "test") + tok = self.login("alice", "test") + room_id = self.helper.create_room_as( + room_creator=user_id, + tok=tok, + room_version=RoomVersions.MSC4242v12.identifier, + ) + resp = self.helper.send_state( + room_id, + "m.room.join_rules", + {"join_rule": "knock"}, + tok=tok, + ) + latest = resp["event_id"] + state_dag = self.get_success( + self.store.get_state_dag(room_id, {latest}), + ) + # create <- member <- pl <- join_rules <- his vis <- join_rules + self.assertEquals(len(state_dag), 6) + want_types = [ + EventTypes.Create, + EventTypes.Member, + EventTypes.PowerLevels, + EventTypes.JoinRules, + EventTypes.RoomHistoryVisibility, + EventTypes.JoinRules, + ] + curr = {latest} + while len(curr) > 0: + event_id = curr.pop() + ev = state_dag[event_id] + want_type = want_types.pop() + self.assertEqual(ev.type, want_type) + curr.update(ev.prev_state_events) + + def test_get_missing_events_state_dag(self) -> None: + # Primarily testing to make sure that we sort events + # correctly when there are multiple prev_state_events + # .- C -- D ---. + # A <- B E + # `- R -- W --` + # `-- T -` + graph = { + "A": [], + "B": ["A"], + "C": ["B"], + "R": ["B"], + "D": ["C"], + "W": ["R"], + "T": ["R"], + "E": ["W", "D", "T"], + } + room_id = "@state_dag:local" + events = [ + cast( + FrozenEventVMSC4242, + StateDAGFakeEvent( + event_id, + room_id, + EventTypes.Create if len(graph[event_id]) == 0 else "foo", + graph[event_id], + ), + ) + for event_id in graph + ] + + def insert(txn: LoggingTransaction) -> None: + # store these to satisfy fk constraints + self.persist_events.db_pool.simple_insert_many_txn( + txn, + table="events", + keys=( + "instance_name", + "stream_ordering", + "topological_ordering", + "depth", + "event_id", + "room_id", + "type", + "processed", + "outlier", + "origin_server_ts", + "received_ts", + "sender", + "contains_url", + "state_key", + "rejection_reason", + ), + values=[ + ( + "test", + event.internal_metadata.stream_ordering, + event.depth, # topological_ordering + event.depth, # depth + event.event_id, + event.room_id, + event.type, + True, # processed + False, # outlier + 1337, + 1741622420, + event.sender, + False, # contains url + event.state_key, + False, # rejected + ) + for event in events + ], + ) + for ev in events: + self.persist_events._store_state_dag_edges( + txn, + ev, + ) + + # satisfy fk constraints + self.get_success( + self.store.store_room(room_id, "foo", False, RoomVersions.MSC4242v12) + ) + self.get_success( + self.store.db_pool.runInteraction( + "_store_state_dag_edges", + insert, + ) + ) + + # .- C -- D ---. + # A <- B E + # `- R -- W --` + # `-- T -` + TestCase = namedtuple("TestCase", "latest want limit") + test_cases = [ + TestCase(latest=["E"], want=["D", "T", "W"], limit=3), + TestCase(latest=["W", "T", "D"], want=["C", "R"], limit=2), + # breadth first and new entries are added to the end, sorted lexicographically + TestCase(latest=["E"], want=["D", "T", "W", "C", "R", "B", "A"], limit=100), + # we should sort the latest values initially + TestCase(latest=["E", "C"], want=["B", "D", "T", "W"], limit=4), + TestCase(latest=["C", "E"], want=["B", "D", "T", "W"], limit=4), + # dupes are ignored + TestCase( + latest=["E", "E", "C", "C", "C"], want=["B", "D", "T", "W"], limit=4 + ), + # include latest events in response + TestCase(latest=["W", "E"], want=["D", "T", "W", "R"], limit=4), + ] + + for test_case in test_cases: + + def do_test(txn: LoggingTransaction) -> None: + got = self.store._get_missing_events_state_dag( + txn, + room_id, + [], + test_case.latest, + test_case.limit, + ) + self.assertEquals(got, test_case.want) + + self.get_success( + self.store.db_pool.runInteraction("test_case", do_test), + ) + @attr.s(auto_attribs=True) class FakeEvent: @@ -1431,3 +1601,19 @@ def auth_event_ids(self) -> list[str]: def is_state(self) -> bool: return True + + +@attr.s(auto_attribs=True) +class StateDAGFakeEvent: + event_id: str + room_id: str + type: str + prev_state_events: list[str] + state_key = "foo" + depth = 1 + sender = "foo" + + internal_metadata = EventInternalMetadata({}) + + def is_state(self) -> bool: + return True diff --git a/tests/storage/test_msc4242_state_dag.py b/tests/storage/test_msc4242_state_dag.py new file mode 100644 index 00000000000..9fe1dcabd44 --- /dev/null +++ b/tests/storage/test_msc4242_state_dag.py @@ -0,0 +1,350 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2026 Element Creations, Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# See the GNU Affero General Public License for more details: +# . + +from typing import Iterable +from unittest.mock import Mock + +from twisted.test.proto_helpers import MemoryReactor + +from synapse.api.constants import EventTypes +from synapse.api.errors import SynapseError +from synapse.api.room_versions import RoomVersions +from synapse.events import FrozenEventVMSC4242, make_event_from_dict +from synapse.events.snapshot import EventContext +from synapse.rest.client import room +from synapse.server import HomeServer +from synapse.util.clock import Clock + +from tests.unittest import HomeserverTestCase + + +class MSC4242StateDagsTests(HomeserverTestCase): + user_id = "@user1:server" + servlets = [room.register_servlets] + + def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer: + hs = self.setup_test_homeserver("server") + return hs + + def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: + self.room_id = self.helper.create_room_as( + self.user_id, + room_version=RoomVersions.MSC4242v12.identifier, + ) + + self.store = hs.get_datastores().main + self._storage_controllers = self.hs.get_storage_controllers() + + def _get_prev_state_events(self, event_id: str) -> list[str]: + ev = self.helper.get_event(self.room_id, event_id) + prev_state_events: list[str] | None = ev.get("prev_state_events", None) + assert prev_state_events is not None + return prev_state_events + + def test_forward_extremities_are_calculated(self) -> None: + """ + Check that forward extremities are set as prev_state_events and that they don't change + for non-state events. + """ + # they don't change for messages + first_event_id = self.helper.send(self.room_id, body="test1")["event_id"] + first_prev_state_event = self._get_prev_state_events(first_event_id) + assert len(first_prev_state_event) == 1 + second_id = self.helper.send(self.room_id, body="test2")["event_id"] + second_prev_state_event = self._get_prev_state_events(second_id) + assert len(second_prev_state_event) == 1 + self.assertEquals(first_prev_state_event, second_prev_state_event) + + # send an auth event, which should change the prev_state_events on *subsequent* events + join_rule_state_event_id = self.helper.send_state( + self.room_id, + EventTypes.JoinRules, + { + "join_rule": "knock", + }, + tok="nope", + )["event_id"] + join_rule_prev_state_event_ids = self._get_prev_state_events( + join_rule_state_event_id + ) + self.assertEquals(second_prev_state_event, join_rule_prev_state_event_ids) + + # prev_state_events should always point to the join rule now + third_event_id = self.helper.send(self.room_id, body="test3")["event_id"] + third_prev_state_event = self._get_prev_state_events(third_event_id) + self.assertEquals(third_prev_state_event, [join_rule_state_event_id]) + # and non-auth state should also update prev_state_events + name_state_event_id = self.helper.send_state( + self.room_id, + EventTypes.Name, + { + "name": "State DAGs!", + }, + tok="nope", + )["event_id"] + name_prev_state_event_ids = self._get_prev_state_events(name_state_event_id) + self.assertEquals(name_prev_state_event_ids, [join_rule_state_event_id]) + fourth_event_id = self.helper.send(self.room_id, body="test4")["event_id"] + fourth_prev_state_event = self._get_prev_state_events(fourth_event_id) + self.assertEquals(fourth_prev_state_event, [name_state_event_id]) + + +class MSC4242EventPersistenceAuthDagsStoreTestCase(HomeserverTestCase): + servlets = [ + room.register_servlets, + ] + + def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: + self.store = hs.get_datastores().main + persistence = hs.get_storage_controllers().persistence + assert persistence is not None + self.persistence = persistence + self.room_id = "!foo:bar" + self.seen_event_ids: set[str] = set() + self.persistence.main_store = Mock(spec=["have_seen_events"]) + self.persistence.main_store.have_seen_events.side_effect = ( + self._have_seen_events + ) + self.rejected_event_ids_and_their_prevs: set[str] = set() + self.persistence.persist_events_store = Mock( + spec=["_get_prevs_before_rejected"] + ) + self.persistence.persist_events_store._get_prevs_before_rejected.side_effect = ( + self._get_prevs_before_rejected + ) + + async def _have_seen_events( + self, room_id: str, event_ids: Iterable[str] + ) -> set[str]: + unknown_events = set(event_ids) + return self.seen_event_ids.intersection(unknown_events) + + async def _get_prevs_before_rejected( + self, event_ids: Iterable[str], include_soft_failed: bool = True + ) -> set[str]: + return self.rejected_event_ids_and_their_prevs + + def _make_event( + self, + id: str, + prev_state_events: list[str], + rejected: bool = False, + soft_failed: bool = False, + ) -> tuple[FrozenEventVMSC4242, EventContext]: + ev = make_event_from_dict( + { + "prev_state_events": prev_state_events, + "content": { + "membership": "join", + }, + "sender": "@unimportant:info", + "state_key": "@unimportant:info", + "type": "m.room.member", + "room_id": self.room_id, + }, + room_version=RoomVersions.MSC4242v12, + ) + if soft_failed: + ev.internal_metadata.soft_failed = True + assert isinstance(ev, FrozenEventVMSC4242) + ev._event_id = id + ctx = Mock() + ctx.rejected = rejected + return ev, ctx + + def _test( + self, + current_fwds: list[str], + new_events: list[tuple[FrozenEventVMSC4242, EventContext]], + want_new_extrems: set[str], + want_raises: bool = False, + ) -> None: + """ + Tests the logic of _calculate_new_state_dag_extremities. + + Tests that the new extremities calculated as a result of processing current_fwds and new_events + matches want_new_extrems or raises if want_raises is True. + """ + coroutine = self.persistence._calculate_new_state_dag_extremities( + self.room_id, + frozenset(current_fwds), + new_events, + ) + if want_raises: + f = self.get_failure(coroutine, SynapseError) + assert f is not None + return + + new_extrems = set(self.get_success(coroutine)) + self.assertEqual( + new_extrems, + want_new_extrems, + f"want_new_extrems={want_new_extrems} got={new_extrems}", + ) + + def test_calculate_new_state_dag_extremities_simple(self) -> None: + # Simple linear chain + self._test( + current_fwds=[], + new_events=[ + self._make_event("$1", []), + self._make_event("$2", ["$1"]), + self._make_event("$3", ["$2"]), + self._make_event("$4", ["$3"]), + ], + want_new_extrems={"$4"}, + ) + + def test_calculate_new_state_dag_extremities_fork(self) -> None: + # Simple fork so we end up with two forward extrems + self._test( + current_fwds=[], + new_events=[ + self._make_event("$1", []), + self._make_event("$2", ["$1"]), + self._make_event("$3", ["$2"]), + self._make_event("$4", ["$2"]), + ], + want_new_extrems={"$3", "$4"}, + ) + + def test_calculate_new_state_dag_extremities_merge(self) -> None: + # Simple fork so we end up with two forward extrems + self._test( + current_fwds=[], + new_events=[ + self._make_event("$1", []), + self._make_event("$2", ["$1"]), + self._make_event("$3", ["$1"]), + self._make_event("$4", ["$2", "$3"]), + ], + want_new_extrems={"$4"}, + ) + + def test_calculate_new_state_dag_extremities_fork_on_existing(self) -> None: + # Fork where we are adding to older events + self.seen_event_ids = {"$1", "$2", "$3"} + self._test( + current_fwds=["$3"], + new_events=[ + self._make_event("$4", ["$3"]), # append to the forward extrem + self._make_event("$5", ["$1"]), # append to the root + ], + want_new_extrems={"$4", "$5"}, + ) + + def test_calculate_new_state_dag_extremities_merge_on_existing(self) -> None: + # Merge where we are merging to older events + self.seen_event_ids = {"$1", "$2", "$3"} + self._test( + current_fwds=["$3"], + new_events=[ + self._make_event("$4", ["$3", "$2"]), + ], + want_new_extrems={"$4"}, + ) + + def test_calculate_new_state_dag_extremities_merge_on_not_current(self) -> None: + # Merge where we are merging to older events + self.seen_event_ids = {"$1", "$2", "$3"} + self._test( + current_fwds=["$3"], + new_events=[ + self._make_event("$4", ["$1", "$2"]), + ], + want_new_extrems={"$3", "$4"}, + ) + + def test_calculate_new_state_dag_extremities_append_with_rejected(self) -> None: + # rejected events cannot be forward extremities + self.seen_event_ids = {"$1", "$2", "$3"} + self._test( + current_fwds=["$3"], + new_events=[ + self._make_event("$4", ["$3"], rejected=True), + ], + want_new_extrems={"$3"}, + ) + + self._test( + current_fwds=["$3"], + new_events=[ + self._make_event("$4", ["$3"], rejected=True), + self._make_event("$5", ["$4"], rejected=True), + ], + want_new_extrems={"$3"}, + ) + + def test_calculate_new_state_dag_extremities_append_with_rejected_in_chain( + self, + ) -> None: + # rejected events cannot be forward extremities, but events that come after them can. + # this shouldn't cause multiple forward extremities. + self.seen_event_ids = {"$1", "$2", "$3"} + self.rejected_event_ids_and_their_prevs = {"$4", "$3"} + self._test( + current_fwds=["$3"], + new_events=[ + self._make_event("$4", ["$3"], rejected=True), + self._make_event("$5", ["$4"]), + ], + want_new_extrems={"$5"}, + ) + + def test_calculate_new_state_dag_extremities_missing_prevs_raises(self) -> None: + self._test( + current_fwds=[], + new_events=[ + self._make_event("$1", []), + self._make_event("$2", ["$1"]), + self._make_event("$3", ["$unknown"]), + self._make_event("$4", ["$3"]), + ], + want_new_extrems={"$4"}, + want_raises=True, + ) + + def test_calculate_new_state_dag_extremities_complex(self) -> None: + """ + 1 + | \ + 2 4 + | + 3 + + Exists already, then becomes... + + 1______ + | \\ | + 2 4 5R + | | | + 3--7 6R + | \\ / \ + 10R 8 9 + + """ + # Merge where we are merging to older events + self.seen_event_ids = {"$1", "$2", "$3", "$4"} + self.rejected_event_ids_and_their_prevs = {"$1", "$5", "$6", "$3", "$10"} + self._test( + current_fwds=["$3", "$4"], + new_events=[ + self._make_event("$5", ["$1"], rejected=True), + self._make_event("$6", ["$5"], rejected=True), + self._make_event("$7", ["$4", "$3"]), + self._make_event("$8", ["$6", "$7"]), + self._make_event("$9", ["$6"]), + self._make_event("$10", ["$3"], rejected=True), + ], + want_new_extrems={"$8", "$9"}, + ) diff --git a/tests/storage/test_redaction.py b/tests/storage/test_redaction.py index 92eb99f1d50..ca78ad85aeb 100644 --- a/tests/storage/test_redaction.py +++ b/tests/storage/test_redaction.py @@ -250,6 +250,7 @@ async def build( prev_event_ids: list[str], auth_event_ids: list[str] | None, depth: int | None = None, + prev_state_events: list[str] | None = None, ) -> EventBase: built_event = await self._base_builder.build( prev_event_ids=prev_event_ids, auth_event_ids=auth_event_ids diff --git a/tests/storage/test_stream.py b/tests/storage/test_stream.py index d51fa1f8bad..8fce7c03e9b 100644 --- a/tests/storage/test_stream.py +++ b/tests/storage/test_stream.py @@ -1455,6 +1455,7 @@ def test_remote_join(self) -> None: auth_chain=[create_event, creator_join_event], partial_state=False, servers_in_room=frozenset(), + state_dag=None, ) ) diff --git a/tests/test_event_auth.py b/tests/test_event_auth.py index 934a2fd3071..9258f0d4dc1 100644 --- a/tests/test_event_auth.py +++ b/tests/test_event_auth.py @@ -20,15 +20,16 @@ # import unittest +from collections import namedtuple from typing import Any, Collection, Iterable from parameterized import parameterized from synapse import event_auth -from synapse.api.constants import EventContentFields +from synapse.api.constants import EventContentFields, RejectedReason from synapse.api.errors import AuthError, SynapseError from synapse.api.room_versions import EventFormatVersions, RoomVersion, RoomVersions -from synapse.events import EventBase, make_event_from_dict +from synapse.events import EventBase, event_exists_in_state_dag, make_event_from_dict from synapse.storage.databases.main.events_worker import EventRedactBehaviour from synapse.types import JsonDict, get_domain_from_id @@ -374,6 +375,195 @@ def test_msc2432_alias_event(self) -> None: auth_events, ) + def test_msc4242_state_dag_rules(self) -> None: + """Tests additional rules in place for state DAG rooms. + + 1. m.room.create => if it has any prev_state_events, reject. + 2. Considering the event's prev_state_events: + i. If there are entries which do not belong in the same room, reject. + ii. If there are entries which do not have a state_key, reject. + iii. If there are entries which were themselves rejected under the checks performed on receipt of a PDU, reject. + """ + creator = "@creator:example.com" + room_version = RoomVersions.MSC4242v12 + + create_event = make_event_from_dict( + { + "type": "m.room.create", + "sender": creator, + "state_key": "", + "content": {"creator": creator}, + "prev_events": [], + "prev_state_events": [], + }, + room_version, + ) + create_event_2 = make_event_from_dict( + { + "type": "m.room.create", + "sender": creator, + "state_key": "", + "content": {"creator": creator, "another": "room"}, + "prev_events": [], + "prev_state_events": [], + }, + room_version, + ) + room_id = create_event.room_id + another_room_id = create_event_2.room_id + join_event = make_event_from_dict( + { + "room_id": room_id, + "type": "m.room.member", + "sender": creator, + "state_key": creator, + "content": {"membership": "join"}, + "prev_events": [create_event.event_id], + "prev_state_events": [create_event.event_id], + }, + room_version, + {"calculated_auth_event_ids": [create_event.event_id]}, + ) + event_in_another_room = make_event_from_dict( + { + "room_id": another_room_id, + "type": "m.room.join_rules", + "sender": creator, + "state_key": "", + "content": {"join_rule": "public"}, + "prev_events": [join_event.event_id], + "prev_state_events": [join_event.event_id], + }, + room_version, + {"calculated_auth_event_ids": [create_event.event_id, join_event.event_id]}, + ) + msg_event = make_event_from_dict( + { + "room_id": room_id, + "type": "m.room.message", + "sender": creator, + "content": {"msgtype": "m.text", "body": "I am a message"}, + "prev_events": [join_event.event_id], + "prev_state_events": [join_event.event_id], + }, + room_version, + {"calculated_auth_event_ids": [create_event.event_id, join_event.event_id]}, + ) + rejected_event = make_event_from_dict( + { + "room_id": room_id, + "type": "m.room.name", + "sender": creator, + "state_key": "", + "content": {"name": "REJECTED"}, + "prev_events": [join_event.event_id], + "prev_state_events": [join_event.event_id], + }, + room_version, + {"calculated_auth_event_ids": [create_event.event_id, join_event.event_id]}, + rejected_reason=RejectedReason.AUTH_ERROR, + ) + RejectingTestCase = namedtuple( + "RejectingTestCase", "name events_in_store test_event" + ) + rejecting_test_cases = [ + RejectingTestCase( + name="create event has prev_state_events", + events_in_store=[], + test_event=make_event_from_dict( + { + "type": "m.room.create", + "sender": creator, + "state_key": "", + "content": {"creator": creator}, + "prev_events": [], + "prev_state_events": [create_event.event_id], + }, + room_version, + {}, + ), + ), + RejectingTestCase( + name="prev_state_event belongs in a different room", + events_in_store=[create_event, join_event, event_in_another_room], + test_event=make_event_from_dict( + { + "room_id": room_id, + "type": "m.room.name", + "sender": creator, + "state_key": "", + "content": {"name": "prev_state_event is in another room"}, + "prev_events": [join_event.event_id], + "prev_state_events": [event_in_another_room.event_id], + }, + room_version, + { + "calculated_auth_event_ids": [ + create_event.event_id, + join_event.event_id, + ] + }, + ), + ), + RejectingTestCase( + name="prev_state_event is a message event", + events_in_store=[create_event, join_event, msg_event], + test_event=make_event_from_dict( + { + "room_id": room_id, + "type": "m.room.name", + "sender": creator, + "state_key": "", + "content": {"name": "prev state event is a message"}, + "prev_events": [msg_event.event_id], + "prev_state_events": [msg_event.event_id], + }, + room_version, + { + "calculated_auth_event_ids": [ + create_event.event_id, + join_event.event_id, + ] + }, + ), + ), + RejectingTestCase( + name="prev_state_event was rejected", + events_in_store=[create_event, join_event, rejected_event], + test_event=make_event_from_dict( + { + "room_id": room_id, + "type": "m.room.name", + "sender": creator, + "state_key": "", + "content": {"name": "prev state event was rejected"}, + "prev_events": [join_event.event_id], + "prev_state_events": [rejected_event.event_id], + }, + room_version, + { + "calculated_auth_event_ids": [ + create_event.event_id, + join_event.event_id, + ] + }, + ), + ), + ] + + for test_case in rejecting_test_cases: + event_store = _StubEventSourceStore() + event_store.add_events(test_case.events_in_store) + + with self.assertRaises( + AuthError, msg=f"test case {test_case.name} was not rejected" + ): + get_awaitable_result( + event_auth.check_state_independent_auth_rules( + event_store, test_case.test_event + ) + ) + @parameterized.expand([(RoomVersions.V1, True), (RoomVersions.V6, False)]) def test_notifications( self, room_version: RoomVersion, allow_modification: bool @@ -769,6 +959,105 @@ def create_event(pl_event_content: dict[str, Any]) -> EventBase: with self.assertRaises(SynapseError): event_auth._check_power_levels(event.room_version, event, {}) + def test_event_exists_in_state_dag(self) -> None: + events_that_exist_in_state_dag = [ + { + "type": "m.room.create", + "state_key": "", + "content": {}, + }, + { + "type": "m.room.join_rules", + "state_key": "", + "content": {}, + }, + { + "type": "m.room.power_levels", + "state_key": "", + "content": {}, + }, + { + "type": "m.room.server_acl", + "state_key": "", + "content": {}, + }, + { + "type": "m.room.member", + "state_key": "@alice:somewhere", + "content": {}, + }, + { + "type": "m.room.third_party_invite", + "state_key": "flibble", + "content": {}, + }, + { + "type": "m.room.create", + "state_key": " ", + "content": {}, + }, + { + "type": "m.room.join_rules", + "state_key": " ", + "content": {}, + }, + { + "type": "m.room.power_levels", + "state_key": " ", + "content": {}, + }, + { + "type": "m.room.name", + "state_key": "", + "content": {}, + }, + { + "type": "m.room.member", + "state_key": "", + "content": {}, + }, + { + "type": "m.room.member", + "state_key": "hello_world", + "content": {}, + }, + ] + events_that_dont_exist_in_state_dag = [ + { + "type": "m.room.message", + "content": {}, + }, + { + "type": "m.room.create", + "content": {}, + }, + { + "type": "m.room.join_rules", + "content": {}, + }, + { + "type": "m.room.power_levels", + "content": {}, + }, + ] + + def check_events(events: list[dict], should_exist: bool) -> None: + for ev in events: + base = { + "room_id": TEST_ROOM_ID, + "sender": "@test:test.com", + "signatures": {"test.com": {"ed25519:0": "some9signature"}}, + } + base.update(ev) + event = make_event_from_dict(base, RoomVersions.V10) + got = event_exists_in_state_dag(event) + self.assertEqual( + got, should_exist, f"{ev} should_exist={should_exist} but got {got}" + ) + + check_events(events_that_exist_in_state_dag, should_exist=True) + check_events(events_that_dont_exist_in_state_dag, should_exist=False) + # helpers for making events TEST_DOMAIN = "example.com"