Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4b0fd01
Port the BundledAggregations class to Rust
erikjohnston Jun 15, 2026
40a2180
Split event serialization into async/sync parts
erikjohnston Jun 3, 2026
fc56cf9
Port the synchronous event serialization core to Rust
erikjohnston Jun 15, 2026
7d5dc63
Add changelog
erikjohnston Jun 15, 2026
e6f7bf5
Fix misleading doc comment on JsonObject::as_map
erikjohnston Jun 18, 2026
a3c4302
Rename SerializeEventConfig.only_event_fields -> event_field_allowlist
erikjohnston Jun 18, 2026
d6110fa
Add field-level doc comments to SerializeEventConfig
erikjohnston Jun 18, 2026
812180f
Rename serialize_core_inner -> serialize_event
erikjohnston Jun 18, 2026
3aa6fe8
Clarify `event_id` comment
erikjohnston Jun 18, 2026
722407f
Use `unsigned_mut` in age and update comment.
erikjohnston Jun 18, 2026
ebb587e
Use `unsigned_mut` for stripping room state
erikjohnston Jun 18, 2026
1cb9fab
Comment why as i64 is safe
erikjohnston Jun 19, 2026
32b0eaf
Comment on what transaction IDs are
erikjohnston Jun 22, 2026
a1cd8f3
Add Unsigned::age_ts function
erikjohnston Jun 22, 2026
586368c
Create and use `Event::redacts()`
erikjohnston Jun 22, 2026
3254557
Error if object_entry_mut finds non-object
erikjohnston Jun 22, 2026
a6c349e
Add create_config helper
erikjohnston Jun 22, 2026
1abf5e7
Apply suggestions from code review
erikjohnston Jun 24, 2026
7124eeb
Expand 'age' comment
erikjohnston Jun 24, 2026
74616d6
simplify object_entry_mut
erikjohnston Jun 24, 2026
18d488c
Add FIXME
erikjohnston Jun 24, 2026
1ace531
Replace Clone with shallow_copy
erikjohnston Jun 24, 2026
15a1eea
Merge remote-tracking branch 'origin/develop' into erikj/rust_event_s…
erikjohnston Jun 24, 2026
64d33f3
Fix merge
erikjohnston Jun 24, 2026
4c7d2b5
Revert "Replace Clone with shallow_copy"
erikjohnston Jun 25, 2026
0efa5d5
Replace Clone with Py<..> references
erikjohnston Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/19837.misc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Port the synchronous core of client event serialization to Rust.
5 changes: 5 additions & 0 deletions rust/src/duration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ impl SynapseDuration {
Self { milliseconds }
}

/// Returns the duration as a number of milliseconds.
pub const fn as_millis(&self) -> u64 {
self.milliseconds
}

/// Creates a `SynapseDuration` from a number of hours.
pub const fn from_hours(hours: u32) -> Self {
// We take a u32 here so that we know the multiplication won't overflow.
Expand Down
30 changes: 30 additions & 0 deletions rust/src/events/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,36 @@ pub mod unsigned_field {
pub const AGE_TS: &str = "age_ts";
/// Unsigned field: redacted_because
pub const REDACTED_BECAUSE: &str = "redacted_because";
/// Unsigned field: redacted_by
pub const REDACTED_BY: &str = "redacted_by";
/// Unsigned field: transaction_id
pub const TRANSACTION_ID: &str = "transaction_id";
/// Unsigned field: org.matrix.msc4140.delay_id
pub const DELAY_ID: &str = "org.matrix.msc4140.delay_id";
/// Unsigned field: membership (MSC4115)
pub const MEMBERSHIP: &str = "membership";
/// Unsigned field: msc4354_sticky_duration_ttl_ms (MSC4354)
pub const STICKY_TTL: &str = "msc4354_sticky_duration_ttl_ms";
/// Unsigned field: io.element.synapse.soft_failed (admin metadata)
pub const SOFT_FAILED: &str = "io.element.synapse.soft_failed";
/// Unsigned field: io.element.synapse.policy_server_spammy (admin metadata)
pub const POLICY_SERVER_SPAMMY: &str = "io.element.synapse.policy_server_spammy";
/// Unsigned field: invite_room_state
pub const INVITE_ROOM_STATE: &str = "invite_room_state";
/// Unsigned field: knock_room_state
pub const KNOCK_ROOM_STATE: &str = "knock_room_state";
/// Unsigned field: m.relations
pub const M_RELATIONS: &str = "m.relations";
}

/// Relation types (the `rel_type` of an `m.relates_to`).
pub mod relation_type {
/// Relation type: m.reference
pub const REFERENCE: &str = "m.reference";
/// Relation type: m.replace
pub const REPLACE: &str = "m.replace";
/// Relation type: m.thread
pub const THREAD: &str = "m.thread";
}

/// Membership Event Fields
Expand Down
12 changes: 8 additions & 4 deletions rust/src/events/formats/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,17 +95,21 @@ pub use vmsc4242::EventFormatVMSC4242;
/// pyclass.
///
/// The `signatures` and `unsigned` fields are kept separate from the other
/// fields as they are mutable (and must be deep-copied if the event is cloned).
/// `common_fields` and `specific_fields` are both `#[serde(flatten)]`ed so that
/// the serialised JSON is a single flat object matching the Matrix spec.
/// fields as they are mutable. Note the derived [`Clone`] is *shallow*: it
/// shares the mutable `signatures`/`unsigned`/internal state behind their
/// `Arc`s (cheap, and fine for read-only uses such as bundled aggregations).
/// Use [`FormattedEvent::deep_copy`] when an independently-mutable copy is
/// required. `common_fields` and `specific_fields` are both
/// `#[serde(flatten)]`ed so that the serialised JSON is a single flat object
/// matching the Matrix spec.
Comment on lines 97 to +104

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The Clone seems like a new footgun

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Hmm, yes. Not really sure I see much alternative though.

If this was a pure Rust class we could get rid of the interior mutability and have a top level Arc, however to make Python work we need to be able to get a mutable version of EventInternalMetadata and that requires interior mutability.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why don't we do what was previously suggested: "and must be deep-copied if the event is cloned"?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Mmm, suppose we could. Really, we want a reference to the same event here, in that we could also store it as a Py<Event> (I somewhat want to avoid storing python references as then you have to partake in the GC). Event::deep_copy is really only intended for if you want a completely new copy of an event that you can edit (which is very rare, mostly we just share a reference to the same event).

I suppose we could also implement a shallow_clone() method instead, so as to force users to choose?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I've added a shallow_copy in 1ace531, though in practice it has highlighted a couple of things:

  1. This means that other things (like ThreadAggregation) has to manually derive clone.
  2. Actually, cloning isn't as cheap as I thought as it will copy the immutable bits in FormattedEvent that aren't in an Arc.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I've changed tack and made the aggregations structs store a Py<Event> for now, and dropped both Clone and shallow_copy.

It's not entirely clear to me if we have to implement __traverse__, since we know there won't be any reference cycles between the relation classes and the Event. Nonetheless I've implemented it to make sure we don't accidentally leak memory.

c.f. https://pyo3.rs/v0.28.3/class/protocols#garbage-collector-integration and https://docs.python.org/3/c-api/gcsupport.html

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is FormattedEvent meant to still have Clone? and the docstring is accurate? (hasn't changed)


Feels like using Py<Event> means we're forever tying ourselves to Python when we want this code to stand on its own in a pure Rust codebase.


It's not entirely clear to me if we have to implement __traverse__, since we know there won't be any reference cycles between the relation classes and the Event. Nonetheless I've implemented it to make sure we don't accidentally leak memory.

c.f. https://pyo3.rs/v0.28.3/class/protocols#garbage-collector-integration and https://docs.python.org/3/c-api/gcsupport.html

To make it more clear, why aren't we also implementing __clear__?

Without __clear__, it seems like the latest_event Event will forever be stuck (can't be GC'ed)


These methods are part of the C API, PyPy does not necessarily honor them. If you are building for PyPy you should measure memory consumption to make sure you do not have runaway memory growth. See this issue on the PyPy bug tracker.

-- https://pyo3.rs/v0.28.3/class/protocols#garbage-collector-integration

Do we care about PyPy at all?

///
/// Note, deserialization of this struct must not be done from
/// [`serde_json::Value`] nor [`pythonize::depythonize`], due to a bug with
/// `#[serde(flatten)]` combined with the `arbitrary_precision` feature.
/// Instead, deserialize directly from a JSON string with
/// `serde_json::from_str`. See https://github.com/serde-rs/serde/issues/2230
/// for details.
#[derive(Serialize, Deserialize)]
#[derive(Clone, Serialize, Deserialize)]
pub struct FormattedEvent<E = Arc<EventFormatEnum>> {
/// The event's signatures.
///
Expand Down
38 changes: 38 additions & 0 deletions rust/src/events/internal_metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,44 @@ impl EventInternalMetadata {
.write()
.map_err(|_| PyRuntimeError::new_err("EventInternalMetadata lock poisoned"))
}

/// The event ID of the redaction event, if this event has been redacted.
pub fn redacted_by(&self) -> PyResult<Option<String>> {
Ok(self.read_inner()?.redacted_by.clone())
}

/// The transaction ID, if set when the event was created.
Comment thread
erikjohnston marked this conversation as resolved.
///
/// The transaction ID comes from the `txn_id` path parameter of the
/// client-server API request used to send the event.
pub fn txn_id(&self) -> PyResult<Option<String>> {
Ok(self.read_inner()?.get_txn_id().map(|s| s.to_owned()))
}

/// The device ID of the sender, if set.
pub fn device_id(&self) -> PyResult<Option<String>> {
Ok(self.read_inner()?.get_device_id().map(|s| s.to_owned()))
}

/// The access token ID of the sender, if set.
pub fn token_id(&self) -> PyResult<Option<i64>> {
Ok(self.read_inner()?.get_token_id())
}

/// The delay ID, set only if the event was a delayed event.
pub fn delay_id(&self) -> PyResult<Option<String>> {
Ok(self.read_inner()?.get_delay_id().map(|s| s.to_owned()))
}

/// Whether the event has been soft failed.
pub fn soft_failed(&self) -> PyResult<bool> {
Ok(self.read_inner()?.is_soft_failed())
}

/// Whether the policy server marked this event as spammy.
pub fn policy_server_spammy(&self) -> PyResult<bool> {
Ok(self.read_inner()?.get_policy_server_spammy())
}
}

/// Helper to convert `None` to an `AttributeError` for a property getter.
Expand Down
81 changes: 59 additions & 22 deletions rust/src/events/json_object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,16 @@ use std::{collections::BTreeMap, sync::Arc};

use pyo3::{
exceptions::{PyKeyError, PyTypeError},
prelude::Borrowed,
pyclass, pymethods,
types::{
PyAnyMethods, PyIterator, PyList, PyListMethods, PyMapping, PySet, PySetMethods, PyTuple,
},
Bound, IntoPyObject, IntoPyObjectExt, Py, PyAny, PyResult, Python,
Bound, FromPyObject, IntoPyObject, IntoPyObjectExt, Py, PyAny, PyErr, PyResult, Python,
};
use pythonize::{depythonize, pythonize};
use serde::{Deserialize, Serialize};
use serde_json::Value;

/// A generic class for representing immutable JSON objects.
///
Expand All @@ -40,34 +42,46 @@ pub struct JsonObject {
object: Arc<BTreeMap<Box<str>, serde_json::Value>>,
}

#[pymethods]
impl JsonObject {
#[new]
#[pyo3(signature = (content = None))]
fn new<'a, 'py>(content: Option<&'a Bound<'py, PyAny>>) -> PyResult<Self> {
let Some(content) = content else {
// If no content is provided, default to an empty object.
return Ok(Self::default());
};
// We implement `FromPyObject` to allow `JsonObject` to be used as function
// arguments.
impl<'py> FromPyObject<'_, 'py> for JsonObject {
type Error = PyErr;

if let Ok(content) = content.cast::<JsonObject>() {
// If the content is already a JsonObject, we can just clone the
// underlying map (this is safe as the object is immutable).
fn extract(ob: Borrowed<'_, 'py, PyAny>) -> Result<Self, Self::Error> {
// Fast path: already a JsonObject, so just share the underlying map
// (cheap, as it's immutable and behind an `Arc`).
if let Ok(obj) = ob.cast::<JsonObject>() {
return Ok(JsonObject {
object: content.get().object.clone(),
object: obj.get().object.clone(),
});
}

let Ok(content) = content.cast::<PyMapping>() else {
return Err(PyTypeError::new_err("'content' must be a mapping"));
};

// Use pythonize to try and convert from a mapping.
let content = depythonize(content)?;
Ok(Self {
object: Arc::new(content),
// Otherwise accept any mapping and convert it via pythonize. Unlike the
// `#[new]` constructor we don't accept `None` here: an absent value is
// represented as `Option<JsonObject>` at the field/argument level.
let mapping = ob
.cast::<PyMapping>()
.map_err(|_| PyTypeError::new_err("expected a mapping"))?;
let object: BTreeMap<Box<str>, Value> = depythonize(&mapping)?;
Ok(JsonObject {
object: Arc::new(object),
})
}
}

#[pymethods]
impl JsonObject {
#[new]
#[pyo3(signature = (content = None))]
fn new(content: Option<&Bound<'_, PyAny>>) -> PyResult<Self> {
match content {
// If no content is provided, default to an empty object.
None => Ok(Self::default()),
// Otherwise reuse the `FromPyObject` path, which accepts an
// existing `JsonObject` or any Python mapping.
Some(content) => JsonObject::extract(content.as_borrowed()),
}
}

fn __len__(&self) -> usize {
self.object.len()
Expand Down Expand Up @@ -197,6 +211,29 @@ impl JsonObject {
pub fn get_field(&self, key: &str) -> Option<&serde_json::Value> {
self.object.get(key)
}

/// Returns a reference to the underlying map of this object's entries.
pub fn as_map(&self) -> &BTreeMap<Box<str>, Value> {
&self.object
}
Comment on lines +215 to +218

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Unused

Helpful?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Hmm, yeah it's become unused now. I think it's likely going to be useful in future, so am minded to leave it in now that we wrote it.


/// Whether the object has no entries.
pub fn is_empty(&self) -> bool {
self.object.is_empty()
}

pub fn iter(&self) -> impl Iterator<Item = (&Box<str>, &Value)> {
self.object.iter()
}
}

impl<'a> IntoIterator for &'a JsonObject {
type Item = (&'a Box<str>, &'a serde_json::Value);
type IntoIter = std::collections::btree_map::Iter<'a, Box<str>, serde_json::Value>;

fn into_iter(self) -> Self::IntoIter {
self.object.as_ref().iter()
}
}

/// Helper class returned by `JsonObject.keys()` to act as a view into the keys
Expand Down
31 changes: 26 additions & 5 deletions rust/src/events/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ use pyo3::{
wrap_pyfunction, Bound, IntoPyObject, PyAny, PyResult, Python,
};
use pythonize::{depythonize, pythonize};
use serde_json::Value;

use crate::events::{
constants::event_type::M_ROOM_MEMBER,
Expand Down Expand Up @@ -87,6 +88,8 @@ pub mod filter;
pub mod formats;
pub mod internal_metadata;
pub mod json_object;
pub mod relations;
pub mod serialize;
pub mod signatures;
pub mod unsigned;
pub mod utils;
Expand All @@ -107,9 +110,14 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()>
child_module.add_class::<json_object::JsonObjectValuesView>()?;
child_module.add_class::<json_object::JsonObjectItemsView>()?;
child_module.add_class::<Event>()?;
child_module.add_class::<relations::BundledAggregations>()?;
child_module.add_class::<relations::ThreadAggregation>()?;
child_module.add_class::<serialize::EventFormat>()?;
child_module.add_class::<serialize::SerializeEventConfig>()?;
child_module.add_function(wrap_pyfunction!(filter::event_visible_to_server_py, m)?)?;
child_module.add_function(wrap_pyfunction!(redact_event_py, m)?)?;
child_module.add_function(wrap_pyfunction!(redact_event_dict, m)?)?;
child_module.add_function(wrap_pyfunction!(serialize::serialize_events, m)?)?;

m.add_submodule(&child_module)?;

Expand All @@ -129,7 +137,11 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()>
/// metadata, rejection reason, and a reference to the room version that
/// produced this event). See the module-level docs for the high-level
/// design.
#[pyclass(frozen, weakref)]
///
/// `Clone` is shallow (see [`FormattedEvent`]) and lets an `Event` be held by
/// value, e.g. inside [`BundledAggregations`](crate::events::relations::BundledAggregations).
#[pyclass(frozen, weakref, skip_from_py_object)]
#[derive(Clone)]
pub struct Event {
/// The parsed event JSON.
parsed_event: FormattedEvent,
Expand Down Expand Up @@ -593,17 +605,26 @@ impl Event {
}
}

#[getter]
fn redacts<'py>(&self, py: Python<'py>) -> PyResult<Option<Bound<'py, PyAny>>> {
/// Returns the `redacts` field of this event, if it has one.
#[getter(redacts)]
fn redacts_py<'py>(&self, py: Python<'py>) -> PyResult<Option<Bound<'py, PyAny>>> {
let value = self.redacts();
value
.map(|v| pythonize(py, v).map_err(Into::into))
.transpose()
}
}

impl Event {
/// Returns the `redacts` field of this event, if it has one.
pub fn redacts(&self) -> Option<&Value> {
let common = &self.parsed_event.common_fields;
let value = if self.room_version.updated_redaction_rules {
common.content.get_field(REDACTS)
} else {
common.other_fields.get(REDACTS)
};
value
.map(|v| pythonize(py, v).map_err(Into::into))
.transpose()
}
}

Expand Down
Loading
Loading