mirror of
https://github.com/element-hq/synapse.git
synced 2026-11-04 06:57:20 +00:00
Build a new room's events in one Rust call
Behind `experimental_features.rust_room_creation: on`, the room handler collects the events that follow the create event (the creator's join, power levels, join rules, history visibility, the initial state, name, topic) as `(type, state_key, content)`, and `LocalEventSigner` builds, hashes and signs them all in one call. Each event's prev event, depth and auth events follow from the one before, as `create_event` gives them for a batch. Python keeps every check: the requester checks ran when the create event was built, and each built event still goes through `validate_builder`, `validate_new` and the relation check before joining the batch, which persists through `handle_new_client_event` as before. Invites are still built by `create_event`. Rooms the Rust builder does not cover are built in Python and counted as fallbacks: v1 and v2 rooms, MSC4242 state DAG rooms, appservice senders, modules with `check_event_allowed`, initial `m.room.aliases` events, and `m.call.invite` or `m.room.create` in the initial state. If Rust fails, the events are built in Python. If Python rejects them too, the request was bad and gets Python's error. Otherwise it counts as a Rust error. In tests, `SYNAPSE_TEST_RUST_ROOM_CREATION=on` turns the builder on and makes a failure fail the test. Python orders auth events by iterating a set, so the order changes with the process's hash seed. Rust keeps the order of the auth types. A test creates rooms of several configurations and room versions in Python, rebuilds their events in Rust from the same inputs, and compares them with the auth events taken as a set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PVLsNh6akt4ifEizY4D1ta
This commit is contained in:
1 parent
9b6e901fa5
commit
ef18e9ebc7
8 files changed
+741
-59
No files matched your search
+161
-12
@@ -19,7 +19,10 @@
|
||||
//! redacted event with the server's key, and parse the result as an
|
||||
//! [`Event`].
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
|
||||
@@ -38,7 +41,8 @@ use super::{
|
||||
Event,
|
||||
};
|
||||
use crate::{
|
||||
canonical_json::{to_vec_canonical, CanonicalizationOptions},
|
||||
canonical_json::{to_vec_canonical, CanonicalizationOptions, MAX_VALID_INTEGER},
|
||||
event_auth::auth_types_for,
|
||||
federation::signing::FederationSigningKey,
|
||||
room_versions::{EventFormatVersions, RoomVersion},
|
||||
};
|
||||
@@ -73,29 +77,174 @@ impl LocalEventSigner {
|
||||
room_version: &Bound<'_, PyAny>,
|
||||
internal_metadata_dict: &Bound<'_, PyDict>,
|
||||
) -> PyResult<Event> {
|
||||
let room_version: &'static RoomVersion = room_version
|
||||
.getattr("identifier")?
|
||||
.extract::<&str>()?
|
||||
.parse()
|
||||
.map_err(|e| PyValueError::new_err(format!("Unsupported room version: {e}")))?;
|
||||
let room_version = room_version_from_py(room_version)?;
|
||||
|
||||
let internal_metadata = EventInternalMetadata::new(internal_metadata_dict)?;
|
||||
|
||||
self.sign(event_json, room_version, internal_metadata)
|
||||
let event: Map<String, Value> = serde_json::from_slice(event_json)
|
||||
.context("Failed to parse event JSON")
|
||||
.map_err(|err| PyValueError::new_err(format!("{err:#}")))?;
|
||||
|
||||
self.sign(event, room_version, internal_metadata)
|
||||
.map_err(|err| PyValueError::new_err(format!("{err:#}")))
|
||||
}
|
||||
|
||||
/// Build the state events that follow the create event of a new room, as
|
||||
/// the room handler's batch builds them one by one: each event's prev
|
||||
/// event is the one before it, its depth one more, and its auth events
|
||||
/// are picked from the state before it, which starts as `state_map`.
|
||||
///
|
||||
/// `specs` are `(type, state_key, content)`, with the content as its
|
||||
/// canonical JSON. Every event gets `origin_server_ts` and a fresh copy
|
||||
/// of `internal_metadata_dict`.
|
||||
///
|
||||
/// Only for room versions whose events refer to other events by ID
|
||||
/// (not v1 and v2) and that have no state DAG (MSC4242). Raises
|
||||
/// `ValueError` for anything it cannot build, and the caller builds the
|
||||
/// events in Python instead.
|
||||
#[pyo3(signature = (
|
||||
room_version, room_id, sender, prev_event_id, depth, state_map, specs,
|
||||
origin_server_ts, internal_metadata_dict,
|
||||
))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_room_events(
|
||||
&self,
|
||||
room_version: &Bound<'_, PyAny>,
|
||||
room_id: &str,
|
||||
sender: &str,
|
||||
prev_event_id: String,
|
||||
depth: i64,
|
||||
state_map: HashMap<(String, String), String>,
|
||||
specs: Vec<(String, String, Vec<u8>)>,
|
||||
origin_server_ts: i64,
|
||||
internal_metadata_dict: &Bound<'_, PyDict>,
|
||||
) -> PyResult<Vec<Event>> {
|
||||
let room_version = room_version_from_py(room_version)?;
|
||||
let to_py = |err: anyhow::Error| PyValueError::new_err(format!("{err:#}"));
|
||||
|
||||
if room_version.event_format == EventFormatVersions::ROOM_V1_V2
|
||||
|| room_version.msc4242_state_dags
|
||||
{
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"Cannot build a room batch for room version {room_version}"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut state_map = state_map;
|
||||
let mut prev_event_id = prev_event_id;
|
||||
let mut events = Vec::with_capacity(specs.len());
|
||||
|
||||
for (depth, (event_type, state_key, content_json)) in (depth..).zip(specs) {
|
||||
let internal_metadata = EventInternalMetadata::new(internal_metadata_dict)?;
|
||||
let content: Map<String, Value> = serde_json::from_slice(&content_json)
|
||||
.context("Failed to parse event content")
|
||||
.map_err(to_py)?;
|
||||
|
||||
let auth_event_ids = room_batch_auth_event_ids(
|
||||
room_version,
|
||||
room_id,
|
||||
&event_type,
|
||||
sender,
|
||||
&state_key,
|
||||
&content,
|
||||
&state_map,
|
||||
)
|
||||
.map_err(to_py)?;
|
||||
|
||||
let mut unsigned = Map::new();
|
||||
unsigned.insert(AGE_TS.to_owned(), Value::from(origin_server_ts));
|
||||
|
||||
let mut event = Map::new();
|
||||
event.insert("auth_events".to_owned(), Value::from(auth_event_ids));
|
||||
event.insert(
|
||||
"prev_events".to_owned(),
|
||||
Value::from(vec![prev_event_id.clone()]),
|
||||
);
|
||||
event.insert("type".to_owned(), Value::from(event_type.clone()));
|
||||
event.insert("sender".to_owned(), Value::from(sender));
|
||||
event.insert("content".to_owned(), Value::Object(content));
|
||||
event.insert(UNSIGNED.to_owned(), Value::Object(unsigned));
|
||||
// Python caps the depth of new events, so other servers accept
|
||||
// them.
|
||||
event.insert(
|
||||
"depth".to_owned(),
|
||||
Value::from(depth.min(MAX_VALID_INTEGER)),
|
||||
);
|
||||
event.insert("room_id".to_owned(), Value::from(room_id));
|
||||
event.insert("state_key".to_owned(), Value::from(state_key.clone()));
|
||||
event.insert("origin_server_ts".to_owned(), Value::from(origin_server_ts));
|
||||
event.insert(SIGNATURES.to_owned(), Value::Object(Map::new()));
|
||||
|
||||
let event = self
|
||||
.sign(event, room_version, internal_metadata)
|
||||
.map_err(to_py)?;
|
||||
|
||||
prev_event_id = event.event_id().to_owned();
|
||||
state_map.insert((event_type, state_key), prev_event_id.clone());
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
}
|
||||
|
||||
/// The room version Python passes, by its identifier.
|
||||
fn room_version_from_py(room_version: &Bound<'_, PyAny>) -> PyResult<&'static RoomVersion> {
|
||||
room_version
|
||||
.getattr("identifier")?
|
||||
.extract::<&str>()?
|
||||
.parse()
|
||||
.map_err(|e| PyValueError::new_err(format!("Unsupported room version: {e}")))
|
||||
}
|
||||
|
||||
/// The auth events of a new state event, as `compute_auth_events` and the
|
||||
/// builder pick them: the events in `state_map` for the auth types of the
|
||||
/// event, in that order. In MSC4291 rooms the create event is implied by the
|
||||
/// room ID, so it is left out.
|
||||
fn room_batch_auth_event_ids(
|
||||
room_version: &RoomVersion,
|
||||
room_id: &str,
|
||||
event_type: &str,
|
||||
sender: &str,
|
||||
state_key: &str,
|
||||
content: &Map<String, Value>,
|
||||
state_map: &HashMap<(String, String), String>,
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
let content: BTreeMap<Box<str>, Value> = content
|
||||
.iter()
|
||||
.map(|(key, value)| (Box::from(key.as_str()), value.clone()))
|
||||
.collect();
|
||||
let auth_types = auth_types_for(room_version, event_type, sender, Some(state_key), &content)
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
let mut auth_event_ids: Vec<String> = auth_types
|
||||
.into_iter()
|
||||
.filter_map(|(auth_type, auth_state_key)| {
|
||||
state_map
|
||||
.get(&(auth_type.to_owned(), auth_state_key.to_owned()))
|
||||
.filter(|event_id| !event_id.is_empty())
|
||||
.cloned()
|
||||
})
|
||||
.collect();
|
||||
|
||||
if room_version.msc4291_room_ids_as_hashes {
|
||||
let create_event_id = format!("${}", &room_id[1..]);
|
||||
let Some(index) = auth_event_ids.iter().position(|id| *id == create_event_id) else {
|
||||
bail!("the create event {create_event_id} is not in the state");
|
||||
};
|
||||
auth_event_ids.remove(index);
|
||||
}
|
||||
|
||||
Ok(auth_event_ids)
|
||||
}
|
||||
|
||||
impl LocalEventSigner {
|
||||
fn sign(
|
||||
&self,
|
||||
event_json: &[u8],
|
||||
mut event: Map<String, Value>,
|
||||
room_version: &'static RoomVersion,
|
||||
internal_metadata: EventInternalMetadata,
|
||||
) -> anyhow::Result<Event> {
|
||||
let mut event: Map<String, Value> =
|
||||
serde_json::from_slice(event_json).context("Failed to parse event JSON")?;
|
||||
|
||||
let reference_hash = add_hashes_and_signature(
|
||||
&mut event,
|
||||
room_version,
|
||||
|
||||
@@ -399,6 +399,24 @@ class ExperimentalConfig(Config):
|
||||
)
|
||||
self.rust_sliding_sync: Literal["off", "on"] = rust_sliding_sync
|
||||
|
||||
# Whether a new room's events after its create event are built, hashed
|
||||
# and signed in one Rust call, rather than one Python `create_event`
|
||||
# each. Python still runs every check and persists them. Rooms Rust
|
||||
# cannot build (v1 and v2, MSC4242 state DAGs, appservice senders,
|
||||
# `check_event_allowed` modules, `m.room.aliases` in the initial
|
||||
# state) are built in Python.
|
||||
rust_room_creation = experimental.get("rust_room_creation", "off")
|
||||
if rust_room_creation is True:
|
||||
rust_room_creation = "on"
|
||||
elif rust_room_creation is False:
|
||||
rust_room_creation = "off"
|
||||
if rust_room_creation not in ("off", "on"):
|
||||
raise ConfigError(
|
||||
"experimental_features.rust_room_creation must be 'off' or 'on'",
|
||||
("experimental", "rust_room_creation"),
|
||||
)
|
||||
self.rust_room_creation: Literal["off", "on"] = rust_room_creation
|
||||
|
||||
# How much of outbound federation runs in Rust. One ordered level, each
|
||||
# implying the ones before it:
|
||||
# - "off": all Python.
|
||||
|
||||
+186
-44
@@ -39,6 +39,8 @@ from typing import (
|
||||
)
|
||||
|
||||
import attr
|
||||
from canonicaljson import encode_canonical_json
|
||||
from prometheus_client import Counter
|
||||
|
||||
import synapse.events.snapshot
|
||||
from synapse.api.constants import (
|
||||
@@ -79,8 +81,10 @@ from synapse.events.utils import (
|
||||
copy_and_fixup_power_levels_contents,
|
||||
)
|
||||
from synapse.handlers.relations import BundledAggregations
|
||||
from synapse.metrics import SERVER_NAME_LABEL
|
||||
from synapse.rest.admin._base import assert_user_is_admin
|
||||
from synapse.streams import EventSource
|
||||
from synapse.synapse_rust.events import EventInternalMetadata
|
||||
from synapse.types import (
|
||||
JsonDict,
|
||||
JsonMapping,
|
||||
@@ -116,6 +120,23 @@ id_server_scheme = "https://"
|
||||
|
||||
FIVE_MINUTES_IN_MS = 5 * 60 * 1000
|
||||
|
||||
# Event types whose creation runs checks the Rust room builder leaves to
|
||||
# `create_event`: `m.call.invite` against the database, and `m.room.create`,
|
||||
# which cannot be sent again. (`m.room.aliases` in the initial state
|
||||
# already has the create event and the join persisted on their own, which the
|
||||
# Rust builder does not cover.)
|
||||
_NOT_BUILT_IN_RUST = frozenset({EventTypes.CallInvite, EventTypes.Create})
|
||||
|
||||
# New rooms in `rust_room_creation: on` mode. `result` is "rust" for a room
|
||||
# whose events after the create event Rust built, "fallback" for one Rust does
|
||||
# not cover, or "error" for one Python built after Rust failed on it.
|
||||
rust_room_creation_counter = Counter(
|
||||
"synapse_rust_room_creation_total",
|
||||
"Rooms created in `rust_room_creation: on` mode, by whether Rust built "
|
||||
"their initial events",
|
||||
labelnames=["result", SERVER_NAME_LABEL],
|
||||
)
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True, auto_attribs=True)
|
||||
class EventContext:
|
||||
@@ -129,6 +150,10 @@ class EventContext:
|
||||
|
||||
|
||||
class RoomCreationHandler:
|
||||
# Tests set this, so that a Rust failure fails the test rather than falling
|
||||
# back to Python.
|
||||
_rust_room_creation_strict = False
|
||||
|
||||
def __init__(self, hs: "HomeServer"):
|
||||
self.server_name = hs.hostname
|
||||
self.store = hs.get_datastores().main
|
||||
@@ -142,6 +167,8 @@ class RoomCreationHandler:
|
||||
self.room_member_handler = hs.get_room_member_handler()
|
||||
self._event_auth_handler = hs.get_event_auth_handler()
|
||||
self.config = hs.config
|
||||
self._rust_room_creation = hs.config.experimental.rust_room_creation == "on"
|
||||
self._event_signer = hs.get_event_builder_factory().signer
|
||||
self.common_request_ratelimiter = hs.get_request_ratelimiter()
|
||||
self.creation_ratelimiter = Ratelimiter(
|
||||
store=self.store,
|
||||
@@ -1702,6 +1729,90 @@ class RoomCreationHandler:
|
||||
)
|
||||
assert not (persist_create_and_join_alone and batched_invites)
|
||||
|
||||
# In `rust_room_creation: on` mode, the events after the create event
|
||||
# are collected here as `(type, state_key, content)`, then built in one
|
||||
# Rust call by `build_collected_events`. None when each is built in
|
||||
# Python as it comes.
|
||||
collected: list[tuple[str, str, JsonMapping]] | None = None
|
||||
if self._rust_room_creation:
|
||||
if self._rust_room_creation_unsupported(
|
||||
creator, room_version, persist_create_and_join_alone
|
||||
):
|
||||
rust_room_creation_counter.labels(
|
||||
result="fallback", **{SERVER_NAME_LABEL: self.hs.hostname}
|
||||
).inc()
|
||||
else:
|
||||
collected = []
|
||||
|
||||
async def add_event(
|
||||
etype: str, content: JsonMapping, state_key: str = ""
|
||||
) -> None:
|
||||
"""Build the next event of the batch, or collect it for Rust."""
|
||||
if collected is not None:
|
||||
collected.append((etype, state_key, content))
|
||||
return
|
||||
event, context = await create_event(
|
||||
etype, content, True, state_key=state_key
|
||||
)
|
||||
events_to_send.append((event, context))
|
||||
|
||||
async def build_collected_events() -> None:
|
||||
"""Build the collected events and add them to the batch, in Rust, or
|
||||
in Python if Rust cannot build them."""
|
||||
nonlocal collected, depth, prev_event
|
||||
|
||||
specs, collected = collected, None
|
||||
if not specs:
|
||||
return
|
||||
|
||||
if any(etype in _NOT_BUILT_IN_RUST for etype, _, _ in specs):
|
||||
rust_room_creation_counter.labels(
|
||||
result="fallback", **{SERVER_NAME_LABEL: self.hs.hostname}
|
||||
).inc()
|
||||
for etype, state_key, content in specs:
|
||||
await add_event(etype, content, state_key=state_key)
|
||||
return
|
||||
|
||||
try:
|
||||
events = self._build_room_events_in_rust(
|
||||
creator, room_version, room_id, prev_event, depth, state_map, specs
|
||||
)
|
||||
except Exception as rust_error:
|
||||
# Build them in Python. If Python rejects them too, the request
|
||||
# was bad, and Python's error is the one to give. Otherwise Rust
|
||||
# got it wrong.
|
||||
for etype, state_key, content in specs:
|
||||
await add_event(etype, content, state_key=state_key)
|
||||
logger.error(
|
||||
"Failed to build the events of room %s in Rust",
|
||||
room_id,
|
||||
exc_info=rust_error,
|
||||
)
|
||||
rust_room_creation_counter.labels(
|
||||
result="error", **{SERVER_NAME_LABEL: self.hs.hostname}
|
||||
).inc()
|
||||
if self._rust_room_creation_strict:
|
||||
raise
|
||||
return
|
||||
|
||||
rust_room_creation_counter.labels(
|
||||
result="rust", **{SERVER_NAME_LABEL: self.hs.hostname}
|
||||
).inc()
|
||||
|
||||
# The checks `create_event` runs on each event it builds. Those on
|
||||
# the requester ran when it built the create event.
|
||||
for event in events:
|
||||
await self._check_event_built_in_rust(event)
|
||||
context = await self.hs.get_state_handler().calculate_context_info(
|
||||
event,
|
||||
state_ids_before_event=dict(state_map),
|
||||
partial_state=False,
|
||||
)
|
||||
events_to_send.append((event, context))
|
||||
depth += 1
|
||||
prev_event = [event.event_id]
|
||||
state_map[(event.type, event.state_key)] = event.event_id
|
||||
|
||||
if creation_event_with_context is None:
|
||||
# MSC2175 removes the creator field from the create event.
|
||||
if not room_version.implicit_room_creator:
|
||||
@@ -1729,10 +1840,7 @@ class RoomCreationHandler:
|
||||
join_content = await self.room_member_handler.build_creator_join_content(
|
||||
creator, creator_join_profile
|
||||
)
|
||||
join_event, join_context = await create_event(
|
||||
EventTypes.Member, join_content, True, state_key=creator_id
|
||||
)
|
||||
events_to_send.append((join_event, join_context))
|
||||
await add_event(EventTypes.Member, join_content, state_key=creator_id)
|
||||
if persist_create_and_join_alone:
|
||||
await persist_batch()
|
||||
|
||||
@@ -1741,10 +1849,7 @@ class RoomCreationHandler:
|
||||
# If the `initial_state` has `EventTypes.PowerLevels` content, use it.
|
||||
pl_content = initial_state.pop((EventTypes.PowerLevels, ""), None)
|
||||
if pl_content is not None:
|
||||
power_event, power_context = await create_event(
|
||||
EventTypes.PowerLevels, pl_content, True
|
||||
)
|
||||
events_to_send.append((power_event, power_context))
|
||||
await add_event(EventTypes.PowerLevels, pl_content)
|
||||
else:
|
||||
# If the `initial_state` does not have `EventTypes.PowerLevels` content,
|
||||
# use the default power level content.
|
||||
@@ -1803,49 +1908,33 @@ class RoomCreationHandler:
|
||||
# apply those. Supplied room override wins over server config.
|
||||
if power_level_content_override:
|
||||
power_level_content.update(power_level_content_override)
|
||||
pl_event, pl_context = await create_event(
|
||||
EventTypes.PowerLevels,
|
||||
power_level_content,
|
||||
True,
|
||||
)
|
||||
events_to_send.append((pl_event, pl_context))
|
||||
await add_event(EventTypes.PowerLevels, power_level_content)
|
||||
|
||||
if room_alias and (EventTypes.CanonicalAlias, "") not in initial_state:
|
||||
room_alias_event, room_alias_context = await create_event(
|
||||
EventTypes.CanonicalAlias, {"alias": room_alias.to_string()}, True
|
||||
await add_event(
|
||||
EventTypes.CanonicalAlias, {"alias": room_alias.to_string()}
|
||||
)
|
||||
events_to_send.append((room_alias_event, room_alias_context))
|
||||
|
||||
if (EventTypes.JoinRules, "") not in initial_state:
|
||||
join_rules_event, join_rules_context = await create_event(
|
||||
EventTypes.JoinRules,
|
||||
{"join_rule": preset_config["join_rules"]},
|
||||
True,
|
||||
await add_event(
|
||||
EventTypes.JoinRules, {"join_rule": preset_config["join_rules"]}
|
||||
)
|
||||
events_to_send.append((join_rules_event, join_rules_context))
|
||||
|
||||
if (EventTypes.RoomHistoryVisibility, "") not in initial_state:
|
||||
visibility_event, visibility_context = await create_event(
|
||||
await add_event(
|
||||
EventTypes.RoomHistoryVisibility,
|
||||
{"history_visibility": preset_config["history_visibility"]},
|
||||
True,
|
||||
)
|
||||
events_to_send.append((visibility_event, visibility_context))
|
||||
|
||||
if preset_config["guest_can_join"]:
|
||||
if (EventTypes.GuestAccess, "") not in initial_state:
|
||||
guest_access_event, guest_access_context = await create_event(
|
||||
await add_event(
|
||||
EventTypes.GuestAccess,
|
||||
{EventContentFields.GUEST_ACCESS: GuestAccess.CAN_JOIN},
|
||||
True,
|
||||
)
|
||||
events_to_send.append((guest_access_event, guest_access_context))
|
||||
|
||||
for (etype, state_key), content in initial_state.items():
|
||||
event, context = await create_event(
|
||||
etype, content, True, state_key=state_key
|
||||
)
|
||||
events_to_send.append((event, context))
|
||||
await add_event(etype, content, state_key=state_key)
|
||||
|
||||
# If the client supplied its own `m.room.encryption` event in the
|
||||
# initial state, only let it take precedence over the forced default
|
||||
@@ -1862,26 +1951,19 @@ class RoomCreationHandler:
|
||||
and not ignore_forced_encryption
|
||||
and not supplied_encryption_is_valid
|
||||
):
|
||||
encryption_event, encryption_context = await create_event(
|
||||
await add_event(
|
||||
EventTypes.RoomEncryption,
|
||||
{"algorithm": RoomEncryptionAlgorithms.DEFAULT},
|
||||
True,
|
||||
state_key="",
|
||||
)
|
||||
events_to_send.append((encryption_event, encryption_context))
|
||||
|
||||
if "name" in room_config:
|
||||
name = room_config["name"]
|
||||
name_event, name_context = await create_event(
|
||||
EventTypes.Name,
|
||||
{"name": name},
|
||||
True,
|
||||
)
|
||||
events_to_send.append((name_event, name_context))
|
||||
await add_event(EventTypes.Name, {"name": name})
|
||||
|
||||
if "topic" in room_config:
|
||||
topic = room_config["topic"]
|
||||
topic_event, topic_context = await create_event(
|
||||
await add_event(
|
||||
EventTypes.Topic,
|
||||
{
|
||||
EventContentFields.TOPIC: topic,
|
||||
@@ -1890,9 +1972,9 @@ class RoomCreationHandler:
|
||||
EventContentFields.M_TEXT: [{MTextFields.BODY: topic}]
|
||||
},
|
||||
},
|
||||
True,
|
||||
)
|
||||
events_to_send.append((topic_event, topic_context))
|
||||
|
||||
await build_collected_events()
|
||||
|
||||
for invitee, invite_content in batched_invites:
|
||||
# Check the invite against the auth rules before it joins the batch:
|
||||
@@ -1927,6 +2009,66 @@ class RoomCreationHandler:
|
||||
len(invited_users),
|
||||
)
|
||||
|
||||
def _rust_room_creation_unsupported(
|
||||
self,
|
||||
creator: Requester,
|
||||
room_version: RoomVersion,
|
||||
persist_create_and_join_alone: bool,
|
||||
) -> bool:
|
||||
"""Whether a new room's events must be built in Python even in
|
||||
`rust_room_creation: on` mode.
|
||||
|
||||
The Rust builder needs the create event and the join in the batch, refers
|
||||
to events by ID only, and has no state DAG. Events sent by an appservice
|
||||
carry it in their context.
|
||||
"""
|
||||
return (
|
||||
persist_create_and_join_alone
|
||||
or room_version.msc4242_state_dags
|
||||
or creator.app_service_id is not None
|
||||
)
|
||||
|
||||
def _build_room_events_in_rust(
|
||||
self,
|
||||
creator: Requester,
|
||||
room_version: RoomVersion,
|
||||
room_id: str,
|
||||
prev_event: list[str],
|
||||
depth: int,
|
||||
state_map: StateMap[str],
|
||||
specs: list[tuple[str, str, JsonMapping]],
|
||||
) -> list[EventBase]:
|
||||
"""Build, hash and sign the events of `specs` in one Rust call, as
|
||||
`create_event` would build each in turn after `prev_event`."""
|
||||
# The internal metadata `create_event` gives each event it builds.
|
||||
internal_metadata = EventInternalMetadata({})
|
||||
if creator.device_id is not None:
|
||||
internal_metadata.device_id = creator.device_id
|
||||
internal_metadata.outlier = False
|
||||
|
||||
(prev_event_id,) = prev_event
|
||||
return self._event_signer.build_room_events(
|
||||
room_version,
|
||||
room_id,
|
||||
creator.user.to_string(),
|
||||
prev_event_id,
|
||||
depth,
|
||||
dict(state_map),
|
||||
[
|
||||
(etype, state_key, encode_canonical_json(content))
|
||||
for etype, state_key, content in specs
|
||||
],
|
||||
int(self.clock.time_msec()),
|
||||
internal_metadata.get_dict(),
|
||||
)
|
||||
|
||||
async def _check_event_built_in_rust(self, event: EventBase) -> None:
|
||||
"""The checks `create_event` runs on an event of a new room once built."""
|
||||
validator = self.event_creation_handler.validator
|
||||
validator.validate_builder(event)
|
||||
validator.validate_new(event, self.config)
|
||||
await self.event_creation_handler._validate_event_relation(event)
|
||||
|
||||
def _persist_create_and_join_alone(
|
||||
self, room_version: RoomVersion, initial_state: StateMap[Any]
|
||||
) -> bool:
|
||||
|
||||
@@ -558,3 +558,28 @@ class LocalEventSigner:
|
||||
Raises:
|
||||
ValueError: if the JSON is not a valid event for the room version.
|
||||
"""
|
||||
def build_room_events(
|
||||
self,
|
||||
room_version: RoomVersion,
|
||||
room_id: str,
|
||||
sender: str,
|
||||
prev_event_id: str,
|
||||
depth: int,
|
||||
state_map: dict[tuple[str, str], str],
|
||||
specs: list[tuple[str, str, bytes]],
|
||||
origin_server_ts: int,
|
||||
internal_metadata_dict: JsonDict,
|
||||
) -> list[Event]:
|
||||
"""Build the state events that follow the create event of a new room,
|
||||
as the room handler's batch builds them one by one: each event's prev
|
||||
event is the one before it, its depth one more, and its auth events are
|
||||
picked from the state before it, which starts as `state_map`.
|
||||
|
||||
`specs` are `(type, state_key, content)`, with the content as its
|
||||
canonical JSON. Every event gets `origin_server_ts` and its own copy of
|
||||
`internal_metadata_dict`.
|
||||
|
||||
Raises:
|
||||
ValueError: for anything it cannot build, including v1 and v2 rooms
|
||||
and MSC4242 state DAG rooms. Build the events in Python then.
|
||||
"""
|
||||
@@ -0,0 +1,322 @@
|
||||
#
|
||||
# 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:
|
||||
# <https://www.gnu.org/licenses/agpl-3.0.html>.
|
||||
#
|
||||
|
||||
"""`rust_room_creation`: a new room's events built in one Rust call."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from twisted.internet.testing import MemoryReactor
|
||||
|
||||
import synapse
|
||||
from synapse.api.constants import EventTypes
|
||||
from synapse.api.room_versions import KNOWN_ROOM_VERSIONS
|
||||
from synapse.events import EventBase
|
||||
from synapse.handlers.room import rust_room_creation_counter
|
||||
from synapse.metrics import SERVER_NAME_LABEL
|
||||
from synapse.rest.client import login, room
|
||||
from synapse.server import HomeServer
|
||||
from synapse.types import JsonDict, create_requester
|
||||
from synapse.util.clock import Clock
|
||||
|
||||
from tests import unittest
|
||||
from tests.unittest import override_config
|
||||
from tests.utils import RUST_ROOM_CREATION_FOR_TESTS
|
||||
|
||||
# Room configurations that between them create every kind of event the room
|
||||
# handler sends before any invites.
|
||||
ROOM_CONFIGS: list[JsonDict] = [
|
||||
{},
|
||||
{"preset": "public_chat", "name": "A room", "topic": "A topic"},
|
||||
{"preset": "trusted_private_chat", "invite": ["@bob:test"]},
|
||||
{
|
||||
"initial_state": [
|
||||
{"type": "m.room.guest_access", "content": {"guest_access": "can_join"}},
|
||||
{"type": "m.space.child", "state_key": "!a:test", "content": {"via": []}},
|
||||
{
|
||||
"type": "m.room.power_levels",
|
||||
"content": {"users_default": 100, "ban": 50},
|
||||
},
|
||||
{"type": "m.room.encryption", "content": {"algorithm": "m.megolm"}},
|
||||
],
|
||||
"room_alias_name": "alias",
|
||||
},
|
||||
{
|
||||
"power_level_content_override": {"events_default": 10},
|
||||
"creation_content": {"m.federate": False},
|
||||
"is_direct": True,
|
||||
},
|
||||
]
|
||||
|
||||
# Room versions the Rust builder covers, one per event format.
|
||||
ROOM_VERSIONS = ["3", "10", "11", "12"]
|
||||
|
||||
|
||||
def _comparable(event: EventBase, keys: dict[str, tuple[str, str]]) -> JsonDict:
|
||||
"""The event's PDU, with the events it refers to given by their `(type,
|
||||
state_key)` in `keys`, and without its hashes, signatures and ID.
|
||||
|
||||
Python orders the auth events by iterating a set of `(type, state_key)`,
|
||||
which changes with the process's hash seed, so the IDs, which cover that
|
||||
order, cannot be matched. The order means nothing to the protocol, so the
|
||||
auth events are compared as a set.
|
||||
"""
|
||||
pdu = event.get_pdu_json()
|
||||
pdu["auth_events"] = sorted(keys[e] for e in pdu["auth_events"])
|
||||
pdu["prev_events"] = [keys[e] for e in pdu["prev_events"]]
|
||||
for key in ("hashes", "signatures", "event_id"):
|
||||
pdu.pop(key, None)
|
||||
return pdu
|
||||
|
||||
|
||||
def _rust_rooms(hs: HomeServer, result: str) -> float:
|
||||
return rust_room_creation_counter.labels(
|
||||
result=result, **{SERVER_NAME_LABEL: hs.hostname}
|
||||
)._value.get()
|
||||
|
||||
|
||||
class RustRoomCreationTestCase(unittest.HomeserverTestCase):
|
||||
if RUST_ROOM_CREATION_FOR_TESTS == "off":
|
||||
skip = "SYNAPSE_TEST_RUST_ROOM_CREATION turns the Rust room builder off"
|
||||
|
||||
servlets = [
|
||||
synapse.rest.admin.register_servlets_for_client_rest_resource,
|
||||
login.register_servlets,
|
||||
room.register_servlets,
|
||||
]
|
||||
|
||||
def default_config(self) -> JsonDict:
|
||||
config = super().default_config()
|
||||
config.setdefault("experimental_features", {}).setdefault(
|
||||
"rust_room_creation", "on"
|
||||
)
|
||||
return config
|
||||
|
||||
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
||||
self.user_id = self.register_user("alice", "pass")
|
||||
self.tok = self.login("alice", "pass", device_id="DEVICE")
|
||||
self.register_user("bob", "pass")
|
||||
self.handler = hs.get_room_creation_handler()
|
||||
# Strict, whatever SYNAPSE_TEST_RUST_ROOM_CREATION_STRICT says.
|
||||
self.handler._rust_room_creation_strict = True
|
||||
self.store = hs.get_datastores().main
|
||||
|
||||
def _create_room(self, room_version: str, config: JsonDict) -> str:
|
||||
body = dict(config)
|
||||
body["room_version"] = room_version
|
||||
if "room_alias_name" in body:
|
||||
# Aliases are unique.
|
||||
self._aliases = getattr(self, "_aliases", 0) + 1
|
||||
body["room_alias_name"] += str(self._aliases)
|
||||
channel = self.make_request("POST", "/createRoom", body, access_token=self.tok)
|
||||
self.assertEqual(channel.code, 200, channel.json_body)
|
||||
return channel.json_body["room_id"]
|
||||
|
||||
def _room_events(self, room_id: str) -> list[EventBase]:
|
||||
"""The room's events in the order they were sent."""
|
||||
rows = self.get_success(
|
||||
self.store.db_pool.simple_select_list(
|
||||
"events", {"room_id": room_id}, ("event_id", "stream_ordering")
|
||||
)
|
||||
)
|
||||
event_ids = [event_id for event_id, _ in sorted(rows, key=lambda r: r[1])]
|
||||
return self.get_success(self.store.get_events_as_list(event_ids))
|
||||
|
||||
def test_same_events_as_python(self) -> None:
|
||||
"""For each configuration and room version, create the room in Python,
|
||||
then build its events after the create event in Rust from the same
|
||||
inputs: they come out identical, event IDs included."""
|
||||
requester = create_requester(self.user_id, device_id="DEVICE")
|
||||
for room_version in ROOM_VERSIONS:
|
||||
for config in ROOM_CONFIGS:
|
||||
with self.subTest(room_version=room_version, config=config):
|
||||
self.handler._rust_room_creation = False
|
||||
try:
|
||||
room_id = self._create_room(room_version, config)
|
||||
finally:
|
||||
self.handler._rust_room_creation = True
|
||||
|
||||
events = self._room_events(room_id)
|
||||
create, rest = events[0], events[1:]
|
||||
# Stop at the first invite, which `create_event` builds.
|
||||
rest = [
|
||||
e
|
||||
for e in rest
|
||||
if not (
|
||||
e.type == EventTypes.Member and e.membership == "invite"
|
||||
)
|
||||
]
|
||||
# The clock does not move while the batch is built.
|
||||
(ts,) = {e.origin_server_ts for e in rest}
|
||||
|
||||
with patch.object(
|
||||
self.handler.clock,
|
||||
"time_msec",
|
||||
return_value=ts,
|
||||
):
|
||||
built = self.handler._build_room_events_in_rust(
|
||||
requester,
|
||||
KNOWN_ROOM_VERSIONS[room_version],
|
||||
room_id,
|
||||
[create.event_id],
|
||||
create.depth + 1,
|
||||
{(create.type, create.state_key): create.event_id},
|
||||
[(e.type, e.state_key, e.content) for e in rest],
|
||||
)
|
||||
|
||||
self.assertEqual(len(built), len(rest))
|
||||
python_keys = {e.event_id: (e.type, e.state_key) for e in events}
|
||||
rust_keys = {
|
||||
e.event_id: (e.type, e.state_key) for e in [create] + built
|
||||
}
|
||||
for ours, theirs in zip(built, rest):
|
||||
self.assertEqual(
|
||||
_comparable(ours, rust_keys),
|
||||
_comparable(theirs, python_keys),
|
||||
)
|
||||
self.assertEqual(ours.internal_metadata.device_id, "DEVICE")
|
||||
|
||||
def test_rooms_built_in_rust(self) -> None:
|
||||
"""Rooms of every configuration are created with their events built in
|
||||
Rust, and end up with the state Python would give them."""
|
||||
for room_version in ROOM_VERSIONS:
|
||||
for config in ROOM_CONFIGS:
|
||||
with self.subTest(room_version=room_version, config=config):
|
||||
before = _rust_rooms(self.hs, "rust")
|
||||
room_id = self._create_room(room_version, config)
|
||||
self.assertEqual(_rust_rooms(self.hs, "rust"), before + 1)
|
||||
|
||||
state = self.get_success(
|
||||
self.hs.get_storage_controllers().state.get_current_state(
|
||||
room_id
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
state[(EventTypes.Member, self.user_id)].membership, "join"
|
||||
)
|
||||
self.assertIn((EventTypes.PowerLevels, ""), state)
|
||||
|
||||
def test_v1_rooms_fall_back(self) -> None:
|
||||
before = _rust_rooms(self.hs, "fallback")
|
||||
self._create_room("1", {})
|
||||
self.assertEqual(_rust_rooms(self.hs, "fallback"), before + 1)
|
||||
|
||||
def test_aliases_in_initial_state_fall_back(self) -> None:
|
||||
"""`m.room.aliases` needs the join persisted on its own."""
|
||||
before = _rust_rooms(self.hs, "fallback")
|
||||
self._create_room(
|
||||
"10",
|
||||
{
|
||||
"initial_state": [
|
||||
{"type": "m.room.aliases", "state_key": "test", "content": {}}
|
||||
]
|
||||
},
|
||||
)
|
||||
self.assertEqual(_rust_rooms(self.hs, "fallback"), before + 1)
|
||||
|
||||
def test_invalid_initial_state(self) -> None:
|
||||
"""An event `validate_new` rejects fails the request as in Python."""
|
||||
channel = self.make_request(
|
||||
"POST",
|
||||
"/createRoom",
|
||||
{
|
||||
"initial_state": [
|
||||
{"type": "m.room.power_levels", "content": {"ban": "high"}}
|
||||
]
|
||||
},
|
||||
access_token=self.tok,
|
||||
)
|
||||
self.assertEqual(channel.code, 400, channel.json_body)
|
||||
self.assertEqual(channel.json_body["errcode"], "M_BAD_JSON")
|
||||
|
||||
def test_create_event_in_initial_state(self) -> None:
|
||||
"""A second create event is refused by `create_event`, as in Python."""
|
||||
before = _rust_rooms(self.hs, "fallback")
|
||||
channel = self.make_request(
|
||||
"POST",
|
||||
"/createRoom",
|
||||
{
|
||||
"room_version": "10",
|
||||
"initial_state": [
|
||||
{"type": "m.room.create", "content": {"room_version": "10"}}
|
||||
],
|
||||
},
|
||||
access_token=self.tok,
|
||||
)
|
||||
self.assertEqual(channel.code, 400, channel.json_body)
|
||||
self.assertEqual(channel.json_body["errcode"], "M_INVALID_PARAM")
|
||||
self.assertEqual(_rust_rooms(self.hs, "fallback"), before + 1)
|
||||
|
||||
def test_bad_input_is_not_a_rust_error(self) -> None:
|
||||
"""Input Rust cannot build and Python rejects gets Python's error, even
|
||||
in strict mode, and does not count as a Rust error."""
|
||||
before = _rust_rooms(self.hs, "error")
|
||||
channel = self.make_request(
|
||||
"POST",
|
||||
"/createRoom",
|
||||
{
|
||||
"initial_state": [
|
||||
{
|
||||
"type": "m.room.member",
|
||||
"state_key": "@bob:test",
|
||||
"content": {"displayname": "no membership"},
|
||||
}
|
||||
]
|
||||
},
|
||||
access_token=self.tok,
|
||||
)
|
||||
self.assertEqual(channel.code, 400, channel.json_body)
|
||||
self.assertEqual(_rust_rooms(self.hs, "error"), before)
|
||||
|
||||
def test_rust_error_falls_back(self) -> None:
|
||||
"""Outside strict mode, a Rust failure builds the room in Python."""
|
||||
self.handler._rust_room_creation_strict = False
|
||||
|
||||
def fail(*args: Any, **kwargs: Any) -> Any:
|
||||
raise ValueError("boom")
|
||||
|
||||
before = _rust_rooms(self.hs, "error")
|
||||
with patch.object(self.handler, "_event_signer") as signer:
|
||||
signer.build_room_events.side_effect = fail
|
||||
room_id = self._create_room("10", {"name": "A room"})
|
||||
self.assertEqual(_rust_rooms(self.hs, "error"), before + 1)
|
||||
events = self._room_events(room_id)
|
||||
self.assertEqual(
|
||||
[e.type for e in events][:3],
|
||||
[EventTypes.Create, EventTypes.Member, EventTypes.PowerLevels],
|
||||
)
|
||||
|
||||
@override_config({"experimental_features": {"rust_room_creation": "off"}})
|
||||
def test_off(self) -> None:
|
||||
if RUST_ROOM_CREATION_FOR_TESTS:
|
||||
self.skipTest("SYNAPSE_TEST_RUST_ROOM_CREATION sets the mode")
|
||||
before = _rust_rooms(self.hs, "rust")
|
||||
self._create_room("10", {})
|
||||
self.assertEqual(_rust_rooms(self.hs, "rust"), before)
|
||||
|
||||
def test_appservice_falls_back(self) -> None:
|
||||
"""An appservice sender's events carry it in their context."""
|
||||
appservice = Mock()
|
||||
appservice.id = "an_appservice"
|
||||
requester = create_requester(self.user_id, app_service=appservice)
|
||||
self.assertTrue(
|
||||
self.handler._rust_room_creation_unsupported(
|
||||
requester, KNOWN_ROOM_VERSIONS["10"], False
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
self.handler._rust_room_creation_unsupported(
|
||||
create_requester(self.user_id), KNOWN_ROOM_VERSIONS["10"], False
|
||||
)
|
||||
)
|
||||
@@ -794,10 +794,14 @@ class RoomsCreateTestCase(RoomBase):
|
||||
|
||||
def _expected_create_room_txn_count(self) -> int:
|
||||
"""The Python transactions a plain room creation runs. The state groups
|
||||
are written in Rust on the native driver, outside them."""
|
||||
are written in Rust on the native driver, outside them, and building the
|
||||
events in Rust skips looking up the room version."""
|
||||
count = 10
|
||||
if self.hs.get_datastores().state._rust_state_group_writer is not None:
|
||||
return 9
|
||||
return 10
|
||||
count -= 1
|
||||
if self.hs.config.experimental.rust_room_creation == "on":
|
||||
count -= 1
|
||||
return count
|
||||
|
||||
def test_post_room_no_keys(self) -> None:
|
||||
# POST with no config keys, expect new room id
|
||||
|
||||
@@ -97,6 +97,7 @@ from synapse.config.homeserver import HomeServerConfig
|
||||
from synapse.events.auto_accept_invites import InviteAutoAccepter
|
||||
from synapse.events.presence_router import load_legacy_presence_router
|
||||
from synapse.handlers.auth import load_legacy_password_auth_providers
|
||||
from synapse.handlers.room import RoomCreationHandler
|
||||
from synapse.http.site import SynapseRequest
|
||||
from synapse.logging.context import ContextResourceUsage, LoggingContext
|
||||
from synapse.module_api.callbacks.spamchecker_callbacks import load_legacy_spam_checkers
|
||||
@@ -138,6 +139,8 @@ from tests.utils import (
|
||||
POSTGRES_USER,
|
||||
RUST_PERSIST_FOR_TESTS,
|
||||
RUST_PERSIST_STRICT_FOR_TESTS,
|
||||
RUST_ROOM_CREATION_FOR_TESTS,
|
||||
RUST_ROOM_CREATION_STRICT_FOR_TESTS,
|
||||
RUST_SLIDING_SYNC_FOR_TESTS,
|
||||
RUST_SLIDING_SYNC_STRICT_FOR_TESTS,
|
||||
RUST_STATE_RES_FOR_TESTS,
|
||||
@@ -1415,6 +1418,13 @@ def setup_test_homeserver(
|
||||
Literal["off", "on"], RUST_SLIDING_SYNC_FOR_TESTS
|
||||
)
|
||||
|
||||
if RUST_ROOM_CREATION_FOR_TESTS:
|
||||
if RUST_ROOM_CREATION_FOR_TESTS not in ("off", "on"):
|
||||
raise ValueError("SYNAPSE_TEST_RUST_ROOM_CREATION must be off or on")
|
||||
config.experimental.rust_room_creation = cast(
|
||||
Literal["off", "on"], RUST_ROOM_CREATION_FOR_TESTS
|
||||
)
|
||||
|
||||
if USE_POSTGRES_FOR_TESTS:
|
||||
test_db = "synapse_test_%s" % uuid.uuid4().hex
|
||||
|
||||
@@ -1643,6 +1653,11 @@ def setup_test_homeserver(
|
||||
)
|
||||
sync_handler._rust_sync_strict = RUST_SYNC_STRICT_FOR_TESTS
|
||||
|
||||
if RUST_ROOM_CREATION_FOR_TESTS:
|
||||
RoomCreationHandler._rust_room_creation_strict = (
|
||||
RUST_ROOM_CREATION_STRICT_FOR_TESTS
|
||||
)
|
||||
|
||||
if RUST_SLIDING_SYNC_FOR_TESTS:
|
||||
# Likewise for the Rust sliding sync handler.
|
||||
sliding_sync_handler = hs.get_sliding_sync_handler()
|
||||
|
||||
@@ -78,6 +78,13 @@ RUST_SLIDING_SYNC_FOR_TESTS = os.environ.get("SYNAPSE_TEST_RUST_SLIDING_SYNC")
|
||||
RUST_SLIDING_SYNC_STRICT_FOR_TESTS = (
|
||||
os.environ.get("SYNAPSE_TEST_RUST_SLIDING_SYNC_STRICT", "1") != "0"
|
||||
)
|
||||
# As above, for `experimental_features.rust_room_creation` ("off" or "on").
|
||||
RUST_ROOM_CREATION_FOR_TESTS = os.environ.get("SYNAPSE_TEST_RUST_ROOM_CREATION")
|
||||
# A Rust error fails the test rather than falling back to building the room's
|
||||
# events in Python. Set to 0 to fall back and count, as production does.
|
||||
RUST_ROOM_CREATION_STRICT_FOR_TESTS = (
|
||||
os.environ.get("SYNAPSE_TEST_RUST_ROOM_CREATION_STRICT", "1") != "0"
|
||||
)
|
||||
# At `rust_federation_sending: transactions`, check every transaction body Rust
|
||||
# builds against the one Python would build, and fail the test if they differ.
|
||||
RUST_FED_STRICT_FOR_TESTS = os.environ.get("SYNAPSE_TEST_RUST_FED_STRICT", "1") != "0"
|
||||
|
||||
Reference in new issue
Block a user