Add a native federation store for the transmission loop

`FederationStore` ports the store methods the federation transmission
loop calls around each transaction to the native Postgres driver:
`get_new_device_msgs_for_remote`, `delete_device_msgs_for_remote`,
`get_device_updates_by_remote` (with the cross-signing key updates and
cross-signatures), `mark_as_sent_devices_by_remote` and
`set_destination_last_successful_stream_ordering`. The stream change
cache checks those start with are left to the caller.

None of the writes has a Python cache to invalidate. The cross-signing
reads skip Python's caches.

`tests/storage/test_federation_store.py` runs each against the Python
method on one database, through a `FederationStoreForTest` pyclass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
This commit is contained in:
Erik JohnstonandClaude Opus 5.5 committed 2026-10-15 15:42:50 +00:00
1 parent 9709ba66fb
commit 1ffaf26696
5 files changed
+1309

No files matched your search

+1
View File
@@ -95,6 +95,7 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()>
child_module.add_class::<transaction::python::RustTransactionManager>()?;
child_module.add_class::<queue::python::RustDestinationQueues>()?;
child_module.add_class::<queue::python::RustDestinationQueue>()?;
child_module.add_class::<crate::storage::federation_store::PyFederationStoreForTest>()?;
m.add_submodule(&child_module)?;
+917
View File
@@ -0,0 +1,917 @@
/*
* 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>.
*
*/
//! What the federation transmission loop reads and writes, through the
//! native Postgres driver.
//!
//! Each method is a port of the store method it is named after, and
//! `tests/storage/test_federation_store.py` runs both against the same
//! database. The stream change cache checks those methods start with are
//! not here: the loop keeps its own, see `federation::queue::changes`.
//!
//! None of these writes has a Python cache to invalidate.
use std::collections::HashMap;
use anyhow::Context;
use base64::Engine;
use log::warn;
use pyo3::{exceptions::PyRuntimeError, prelude::*};
use serde_json::{Map, Value};
use tokio::runtime::Handle;
use tokio_postgres::{types::ToSql, Row};
use crate::database::postgres::pool::{ConnectionPool, PooledConnection, PyConnectionPool};
use crate::federation::queue::Edu;
/// `EduTypes.DEVICE_LIST_UPDATE`.
const DEVICE_LIST_UPDATE: &str = "m.device_list_update";
/// `EduTypes.SIGNING_KEY_UPDATE`.
const SIGNING_KEY_UPDATE: &str = "m.signing_key_update";
/// The unstable name of `m.signing_key_update`, which is sent as well.
const UNSTABLE_SIGNING_KEY_UPDATE: &str = "org.matrix.signing_key_update";
/// The parts of the homeserver config the queries apply.
#[derive(Debug, Clone, Copy, Default)]
pub struct FederationStoreConfig {
/// `allow_device_name_lookup_over_federation`.
pub allow_device_name_lookup_over_federation: bool,
}
/// The transmission loop's queries.
pub struct FederationStore {
config: FederationStoreConfig,
}
impl FederationStore {
pub fn new(config: FederationStoreConfig) -> Self {
FederationStore { config }
}
/// `get_new_device_msgs_for_remote`, less its stream change cache check:
/// the contents of the to-device messages queued for `destination`
/// after `last_stream_id`, and how far through the stream they go.
pub async fn get_new_device_msgs_for_remote(
&self,
pool: &ConnectionPool,
destination: &str,
last_stream_id: i64,
current_stream_id: i64,
limit: usize,
) -> anyhow::Result<(Vec<Value>, i64)> {
if last_stream_id == current_stream_id {
return Ok((Vec::new(), current_stream_id));
}
if limit == 0 {
// We ran out of room for EDUs in the transaction.
return Ok((Vec::new(), last_stream_id));
}
let conn = Conn::borrow(pool).await?;
let rows = conn
.query(
"SELECT stream_id, messages_json FROM device_federation_outbox
WHERE destination = $1 AND $2 < stream_id AND stream_id <= $3
ORDER BY stream_id ASC
LIMIT $4",
&[
&destination,
&last_stream_id,
&current_stream_id,
&(limit as i64),
],
)
.await?;
let mut messages = Vec::with_capacity(rows.len());
let mut stream_pos = current_stream_id;
for row in &rows {
stream_pos = row.try_get(0)?;
let json: &str = row.try_get(1)?;
messages.push(serde_json::from_str(json).context("parsing messages_json")?);
}
// Short of the limit, there is nothing more up to the current
// position.
if messages.len() < limit {
stream_pos = current_stream_id;
}
Ok((messages, stream_pos))
}
/// `delete_device_msgs_for_remote`: the destination has the to-device
/// messages up to `up_to_stream_id`.
pub async fn delete_device_msgs_for_remote(
&self,
pool: &ConnectionPool,
destination: &str,
up_to_stream_id: i64,
) -> anyhow::Result<()> {
Conn::borrow(pool)
.await?
.query(
"DELETE FROM device_federation_outbox
WHERE destination = $1 AND stream_id <= $2",
&[&destination, &up_to_stream_id],
)
.await?;
Ok(())
}
/// `get_device_updates_by_remote`, less its stream change cache check:
/// the device list and cross-signing key updates to send to
/// `destination`, at most `limit`, and the stream ID they go up to.
///
/// The EDUs of a user come in stream order, with the prev ID chain
/// through them. The users come in the order of their first update,
/// where Python has the order the database returns them in.
pub async fn get_device_updates_by_remote(
&self,
pool: &ConnectionPool,
destination: &str,
from_stream_id: i64,
now_stream_id: i64,
limit: usize,
) -> anyhow::Result<(i64, Vec<Edu>)> {
if from_stream_id == now_stream_id {
return Ok((now_stream_id, Vec::new()));
}
let conn = Conn::borrow(pool).await?;
let rows = conn
.query(
"SELECT user_id, device_id, stream_id, opentracing_context
FROM device_lists_outbound_pokes
WHERE destination = $1 AND $2 < stream_id AND stream_id <= $3
ORDER BY stream_id
LIMIT $4",
&[
&destination,
&from_stream_id,
&now_stream_id,
&(limit as i64),
],
)
.await?;
if rows.is_empty() {
return Ok((now_stream_id, Vec::new()));
}
let updates = rows
.iter()
.map(|row| -> anyhow::Result<Poke> {
Ok(Poke {
user_id: row.try_get(0)?,
device_id: row.try_get(1)?,
stream_id: row.try_get(2)?,
context: row.try_get(3)?,
})
})
.collect::<anyhow::Result<Vec<_>>>()?;
// The users' cross-signing keys, to tell which updates were of them.
let mut users: Vec<&str> = Vec::new();
for update in &updates {
if !users.contains(&update.user_id.as_str()) {
users.push(&update.user_id);
}
}
let keys = cross_signing_keys(&conn, &users).await?;
let key_device = |user_id: &str, key_type: &str| -> anyhow::Result<Option<String>> {
keys.get(&(user_id.to_owned(), key_type.to_owned()))
.filter(|key| is_truthy(key))
.map(cross_signing_key_device_id)
.transpose()
};
let mut master_keys = HashMap::new();
let mut self_signing_keys = HashMap::new();
for user_id in &users {
if let Some(device_id) = key_device(user_id, "master")? {
master_keys.insert(*user_id, device_id);
}
if let Some(device_id) = key_device(user_id, "self_signing")? {
self_signing_keys.insert(*user_id, device_id);
}
}
// The latest update per device, and the cross-signing key updates
// per user, within the budget. A cross-signing key update becomes
// two EDUs, so it costs two.
let mut last_processed_stream_id = from_stream_id;
let mut devices: OrderedDevices = OrderedDevices::default();
let mut cross_signing: Vec<(&str, Map<String, Value>)> = Vec::new();
for update in &updates {
let remaining = limit - (devices.len() + 2 * cross_signing.len());
let user_id = update.user_id.as_str();
let is_master = master_keys.get(user_id) == Some(&update.device_id);
let is_self_signing = self_signing_keys.get(user_id) == Some(&update.device_id);
let user_index = cross_signing.iter().position(|(user, _)| *user == user_id);
if (is_master || is_self_signing) && user_index.is_none() && remaining < 2 {
break;
}
if is_master || is_self_signing {
let index = user_index.unwrap_or_else(|| {
cross_signing.push((user_id, Map::new()));
cross_signing.len() - 1
});
let (field, key_type) = if is_master {
("master_key", "master")
} else {
("self_signing_key", "self_signing")
};
let key = keys
.get(&(user_id.to_owned(), key_type.to_owned()))
.cloned()
.unwrap_or(Value::Null);
cross_signing[index].1.insert(field.to_owned(), key);
} else {
let key = (update.user_id.clone(), update.device_id.clone());
if !devices.contains(&key) && remaining < 1 {
break;
}
devices.update(key, update.stream_id, update.context.clone());
}
last_processed_stream_id = update.stream_id;
}
let mut edus = self
.device_update_edus(&conn, destination, from_stream_id, &devices)
.await?;
for (user_id, mut content) in cross_signing {
content.insert("user_id".to_owned(), Value::from(user_id));
let content = Value::Object(content);
edus.push(Edu {
edu_type: SIGNING_KEY_UPDATE.to_owned(),
content: content.clone(),
});
edus.push(Edu {
edu_type: UNSTABLE_SIGNING_KEY_UPDATE.to_owned(),
content,
});
}
Ok((last_processed_stream_id, edus))
}
/// `_get_device_update_edus_by_remote`: an `m.device_list_update` EDU
/// per device, with its keys, or marked deleted.
async fn device_update_edus(
&self,
conn: &Conn,
destination: &str,
from_stream_id: i64,
devices: &OrderedDevices,
) -> anyhow::Result<Vec<Edu>> {
if devices.is_empty() {
return Ok(Vec::new());
}
let found = device_keys(conn, devices.keys()).await?;
let mut users: Vec<&str> = Vec::new();
for (user_id, _) in devices.keys() {
if !users.contains(&user_id.as_str()) {
users.push(user_id);
}
}
let prev_ids =
last_device_updates_for_remote(conn, destination, &users, from_stream_id).await?;
let mut edus = Vec::with_capacity(devices.len());
for user_id in users {
// The prev ID of the first update is the last one sent before
// `from_stream_id`.
let mut prev_id = prev_ids.get(user_id).copied().unwrap_or(0);
let mut user_devices: Vec<(&DeviceKey, &DeviceUpdate)> = devices
.entries
.iter()
.filter(|(key, _)| key.0 == user_id)
.map(|(key, value)| (key, value))
.collect();
user_devices.sort_by_key(|(_, (stream_id, _))| *stream_id);
for (key, (stream_id, context)) in user_devices {
let mut content = Map::new();
content.insert("user_id".to_owned(), Value::from(user_id));
content.insert("device_id".to_owned(), Value::from(key.1.as_str()));
content.insert(
"prev_id".to_owned(),
if prev_id != 0 {
Value::from(vec![prev_id])
} else {
Value::Array(Vec::new())
},
);
content.insert("stream_id".to_owned(), Value::from(*stream_id));
if context.as_deref() != Some("{}") {
content.insert(
"org.matrix.opentracing_context".to_owned(),
context.clone().map_or(Value::Null, Value::from),
);
}
prev_id = *stream_id;
match found.get(key) {
Some(device) => {
if let Some(keys) = device.keys.as_ref().filter(|keys| is_truthy(keys)) {
content.insert("keys".to_owned(), keys.clone());
}
if self.config.allow_device_name_lookup_over_federation {
if let Some(name) =
device.display_name.as_ref().filter(|name| !name.is_empty())
{
content.insert(
"device_display_name".to_owned(),
Value::from(name.as_str()),
);
}
}
}
None => {
content.insert("deleted".to_owned(), Value::Bool(true));
}
}
edus.push(Edu {
edu_type: DEVICE_LIST_UPDATE.to_owned(),
content: Value::Object(content),
});
}
}
Ok(edus)
}
/// `mark_as_sent_devices_by_remote`: the destination has the device
/// list updates up to `stream_id`. Deletes the pokes and records, per
/// user, the last update sent, in one transaction.
pub async fn mark_as_sent_devices_by_remote(
&self,
pool: &ConnectionPool,
destination: &str,
stream_id: i64,
) -> anyhow::Result<()> {
let mut conn = Conn::borrow(pool).await?;
conn.begin().await?;
let rows = conn
.query(
"DELETE FROM device_lists_outbound_pokes
WHERE destination = $1 AND stream_id <= $2
RETURNING user_id, stream_id",
&[&destination, &stream_id],
)
.await?;
let mut max_stream_id_by_user: HashMap<String, i64> = HashMap::new();
for row in &rows {
let user_id: String = row.try_get(0)?;
let poke_stream_id: i64 = row.try_get(1)?;
let max = max_stream_id_by_user.entry(user_id).or_insert(0);
*max = (*max).max(poke_stream_id);
}
if !max_stream_id_by_user.is_empty() {
let (user_ids, stream_ids): (Vec<String>, Vec<i64>) =
max_stream_id_by_user.into_iter().unzip();
conn.query(
"INSERT INTO device_lists_outbound_last_success (destination, user_id, stream_id)
SELECT $1, user_id, stream_id FROM unnest($2::text[], $3::bigint[])
AS u(user_id, stream_id)
ON CONFLICT (destination, user_id) DO UPDATE SET stream_id = EXCLUDED.stream_id",
&[&destination, &user_ids, &stream_ids],
)
.await?;
}
conn.commit().await
}
/// `set_destination_last_successful_stream_ordering`.
pub async fn set_destination_last_successful_stream_ordering(
&self,
pool: &ConnectionPool,
destination: &str,
last_successful_stream_ordering: i64,
) -> anyhow::Result<()> {
Conn::borrow(pool)
.await?
.query(
"INSERT INTO destinations (destination, last_successful_stream_ordering)
VALUES ($1, $2)
ON CONFLICT (destination) DO UPDATE
SET last_successful_stream_ordering = EXCLUDED.last_successful_stream_ordering",
&[&destination, &last_successful_stream_ordering],
)
.await?;
Ok(())
}
}
/// A row of `device_lists_outbound_pokes`.
struct Poke {
user_id: String,
device_id: String,
stream_id: i64,
context: Option<String>,
}
/// `(user ID, device ID)`.
type DeviceKey = (String, String);
/// `(stream ID, tracing context)` of a device's update.
type DeviceUpdate = (i64, Option<String>);
/// The latest update of each device, in the order the devices were first
/// seen.
#[derive(Default)]
struct OrderedDevices {
entries: Vec<(DeviceKey, DeviceUpdate)>,
}
impl OrderedDevices {
fn len(&self) -> usize {
self.entries.len()
}
fn is_empty(&self) -> bool {
self.entries.is_empty()
}
fn contains(&self, key: &(String, String)) -> bool {
self.entries.iter().any(|(k, _)| k == key)
}
fn keys(&self) -> impl Iterator<Item = &(String, String)> {
self.entries.iter().map(|(key, _)| key)
}
/// Keep the update with the higher stream ID.
fn update(&mut self, key: (String, String), stream_id: i64, context: Option<String>) {
match self.entries.iter_mut().find(|(k, _)| *k == key) {
Some((_, existing)) => {
if stream_id > existing.0 {
*existing = (stream_id, context);
}
}
None => self.entries.push((key, (stream_id, context))),
}
}
}
/// A device's keys and name: `DeviceKeyLookupResult`.
struct DeviceKeys {
display_name: Option<String>,
keys: Option<Value>,
}
/// Whether Python would take `value` as true.
fn is_truthy(value: &Value) -> bool {
match value {
Value::Null => false,
Value::Bool(b) => *b,
Value::Number(n) => n.as_f64() != Some(0.0),
Value::String(s) => !s.is_empty(),
Value::Array(a) => !a.is_empty(),
Value::Object(o) => !o.is_empty(),
}
}
/// The latest cross-signing keys of each user, keyed by `(user ID, key
/// type)`: `_get_bare_e2e_cross_signing_keys_bulk`.
async fn cross_signing_keys(
conn: &Conn,
users: &[&str],
) -> anyhow::Result<HashMap<(String, String), Value>> {
let rows = conn
.query(
"SELECT DISTINCT ON (user_id, keytype) user_id, keytype, keydata
FROM e2e_cross_signing_keys
WHERE user_id = ANY($1)
ORDER BY user_id, keytype, stream_id DESC",
&[&users],
)
.await?;
let mut keys = HashMap::with_capacity(rows.len());
for row in &rows {
let user_id: String = row.try_get(0)?;
let key_type: String = row.try_get(1)?;
let key_data: &str = row.try_get(2)?;
keys.insert(
(user_id, key_type),
serde_json::from_str(key_data).context("parsing keydata")?,
);
}
Ok(keys)
}
/// The device ID of a cross-signing key: the part of its one key ID after
/// the algorithm. `get_verify_key_from_cross_signing_key` fails on anything
/// but a single valid ed25519 key, and so does this.
fn cross_signing_key_device_id(key: &Value) -> anyhow::Result<String> {
let keys = key
.get("keys")
.and_then(Value::as_object)
.context("Invalid key")?;
let mut entries = keys.iter();
let (Some((key_id, key_data)), None) = (entries.next(), entries.next()) else {
anyhow::bail!("Invalid key");
};
let (algorithm, version) = key_id.split_once(':').context("Invalid key")?;
if algorithm != "ed25519" {
anyhow::bail!("Unsupported algorithm {algorithm}");
}
let key_data = key_data.as_str().context("Invalid key")?;
let bytes = base64::engine::general_purpose::STANDARD_NO_PAD
.decode(key_data.trim_end_matches('='))
.context("Invalid key")?;
if bytes.len() != 32 {
anyhow::bail!("Invalid key");
}
Ok(version.to_owned())
}
/// The devices' keys, with the owners' cross-signatures on them, and names:
/// `get_e2e_device_keys_and_signatures` with `include_all_devices`. Hidden
/// devices are left out. A device that is not in the result has been
/// deleted.
async fn device_keys(
conn: &Conn,
devices: impl Iterator<Item = &(String, String)>,
) -> anyhow::Result<HashMap<(String, String), DeviceKeys>> {
let (user_ids, device_ids): (Vec<&str>, Vec<&str>) = devices
.map(|(user_id, device_id)| (user_id.as_str(), device_id.as_str()))
.unzip();
let rows = conn
.query(
"SELECT user_id, device_id, d.display_name, k.key_json
FROM devices d
LEFT JOIN e2e_device_keys_json k USING (user_id, device_id)
WHERE (user_id, device_id) IN (
SELECT * FROM unnest($1::text[], $2::text[])
) AND NOT d.hidden",
&[&user_ids, &device_ids],
)
.await?;
let mut found = HashMap::with_capacity(rows.len());
for row in &rows {
let user_id: String = row.try_get(0)?;
let device_id: String = row.try_get(1)?;
let display_name: Option<String> = row.try_get(2)?;
let key_json: Option<&str> = row.try_get(3)?;
let keys = key_json
.filter(|json| !json.is_empty())
.map(serde_json::from_str::<Value>)
.transpose()
.context("parsing key_json")?;
found.insert((user_id, device_id), DeviceKeys { display_name, keys });
}
// Add each owner's cross-signatures on the devices with keys.
let (user_ids, device_ids): (Vec<&str>, Vec<&str>) = found
.iter()
.filter(|(_, device)| device.keys.is_some())
.map(|((user_id, device_id), _)| (user_id.as_str(), device_id.as_str()))
.unzip();
if user_ids.is_empty() {
return Ok(found);
}
let rows = conn
.query(
"SELECT user_id, key_id, target_device_id, signature
FROM e2e_cross_signing_signatures
WHERE (target_user_id, target_device_id, user_id) IN (
SELECT u, d, u FROM unnest($1::text[], $2::text[]) AS t(u, d)
)",
&[&user_ids, &device_ids],
)
.await?;
let mut signatures: Vec<(String, String, String, String)> = Vec::with_capacity(rows.len());
for row in &rows {
signatures.push((
row.try_get(0)?,
row.try_get(1)?,
row.try_get(2)?,
row.try_get(3)?,
));
}
for (user_id, key_id, device_id, signature) in signatures {
let Some(keys) = found
.get_mut(&(user_id.clone(), device_id))
.and_then(|device| device.keys.as_mut())
.and_then(Value::as_object_mut)
else {
continue;
};
let by_user = keys
.entry("signatures")
.or_insert_with(|| Value::Object(Map::new()));
let Some(by_user) = by_user.as_object_mut() else {
continue;
};
let by_key = by_user
.entry(user_id)
.or_insert_with(|| Value::Object(Map::new()));
if let Some(by_key) = by_key.as_object_mut() {
by_key.insert(key_id, Value::from(signature));
}
}
Ok(found)
}
/// The last device list update sent to `destination` for each user, at or
/// before `from_stream_id`: `_get_last_device_update_for_remote_user`, for
/// several users at once. A user with none is missing.
async fn last_device_updates_for_remote(
conn: &Conn,
destination: &str,
users: &[&str],
from_stream_id: i64,
) -> anyhow::Result<HashMap<String, i64>> {
let rows = conn
.query(
"SELECT user_id, max(stream_id) FROM device_lists_outbound_last_success
WHERE destination = $1 AND user_id = ANY($2) AND stream_id <= $3
GROUP BY user_id",
&[&destination, &users, &from_stream_id],
)
.await?;
let mut last = HashMap::with_capacity(rows.len());
for row in &rows {
let stream_id: Option<i64> = row.try_get(1)?;
last.insert(row.try_get(0)?, stream_id.unwrap_or(0));
}
Ok(last)
}
/// A connection borrowed for a group of statements, rolled back if it is
/// dropped inside a transaction.
struct Conn {
conn: Option<PooledConnection>,
in_transaction: bool,
}
impl Conn {
async fn borrow(pool: &ConnectionPool) -> anyhow::Result<Conn> {
let conn = pool
.get()
.await
.map_err(|e| anyhow::anyhow!("checking out a database connection: {e}"))?;
Ok(Conn {
conn: Some(conn),
in_transaction: false,
})
}
fn conn(&self) -> &PooledConnection {
self.conn.as_ref().expect("held until dropped")
}
/// Run a statement through the connection's prepared statement cache.
async fn query(&self, sql: &str, params: &[&(dyn ToSql + Sync)]) -> anyhow::Result<Vec<Row>> {
let conn = self.conn();
let statement = conn
.prepare_cached(sql, false)
.await
.with_context(|| format!("preparing {}", sql.trim()))?;
conn.query(&statement, params)
.await
.with_context(|| format!("running {}", sql.trim()))
}
/// Open a `READ COMMITTED` transaction. Its writes are upserts and
/// deletes on rows only this sender writes, so it needs no retry loop.
async fn begin(&mut self) -> anyhow::Result<()> {
self.conn()
.batch_execute("BEGIN ISOLATION LEVEL READ COMMITTED")
.await
.context("opening a transaction")?;
self.in_transaction = true;
Ok(())
}
async fn commit(mut self) -> anyhow::Result<()> {
self.conn()
.batch_execute("COMMIT")
.await
.context("committing")?;
self.in_transaction = false;
Ok(())
}
}
impl Drop for Conn {
fn drop(&mut self) {
let Some(conn) = self.conn.take() else { return };
if !self.in_transaction {
return;
}
// Roll back before the connection goes back to the pool, so the next
// user doesn't find it inside our transaction. Without a runtime to
// do that on, the connection is dropped rather than returned.
match Handle::try_current() {
Ok(handle) => {
handle.spawn(async move {
if let Err(e) = conn.batch_execute("ROLLBACK").await {
warn!("Failed to roll back a federation transaction: {e}");
}
drop(conn);
});
}
Err(_) => {
drop(PooledConnection::take(conn));
}
}
}
}
/// The store, for tests that compare it with Python's on one database.
/// Each method blocks until its query is done.
#[pyclass(frozen, name = "FederationStoreForTest")]
pub struct PyFederationStoreForTest {
store: FederationStore,
pool: ConnectionPool,
handle: Handle,
}
impl PyFederationStoreForTest {
fn run<T: Send>(
&self,
py: Python<'_>,
f: impl std::future::Future<Output = anyhow::Result<T>> + Send,
) -> PyResult<T> {
py.detach(|| self.handle.block_on(f))
.map_err(|err| PyRuntimeError::new_err(format!("{err:#}")))
}
}
#[pymethods]
impl PyFederationStoreForTest {
#[new]
fn new(
pool: &Bound<'_, PyConnectionPool>,
allow_device_name_lookup_over_federation: bool,
) -> Self {
let pool = pool.get();
PyFederationStoreForTest {
store: FederationStore::new(FederationStoreConfig {
allow_device_name_lookup_over_federation,
}),
pool: pool.pool.clone(),
handle: pool.handle.clone(),
}
}
fn get_new_device_msgs_for_remote<'py>(
&self,
py: Python<'py>,
destination: &str,
last_stream_id: i64,
current_stream_id: i64,
limit: usize,
) -> PyResult<(Bound<'py, PyAny>, i64)> {
let (messages, stream_id) = self.run(
py,
self.store.get_new_device_msgs_for_remote(
&self.pool,
destination,
last_stream_id,
current_stream_id,
limit,
),
)?;
Ok((pythonize::pythonize(py, &messages)?, stream_id))
}
fn delete_device_msgs_for_remote(
&self,
py: Python<'_>,
destination: &str,
up_to_stream_id: i64,
) -> PyResult<()> {
self.run(
py,
self.store
.delete_device_msgs_for_remote(&self.pool, destination, up_to_stream_id),
)
}
#[allow(clippy::type_complexity)]
fn get_device_updates_by_remote<'py>(
&self,
py: Python<'py>,
destination: &str,
from_stream_id: i64,
now_stream_id: i64,
limit: usize,
) -> PyResult<(i64, Vec<(String, Bound<'py, PyAny>)>)> {
let (stream_id, edus) = self.run(
py,
self.store.get_device_updates_by_remote(
&self.pool,
destination,
from_stream_id,
now_stream_id,
limit,
),
)?;
let edus = edus
.into_iter()
.map(|edu| Ok((edu.edu_type, pythonize::pythonize(py, &edu.content)?)))
.collect::<PyResult<_>>()?;
Ok((stream_id, edus))
}
fn mark_as_sent_devices_by_remote(
&self,
py: Python<'_>,
destination: &str,
stream_id: i64,
) -> PyResult<()> {
self.run(
py,
self.store
.mark_as_sent_devices_by_remote(&self.pool, destination, stream_id),
)
}
fn set_destination_last_successful_stream_ordering(
&self,
py: Python<'_>,
destination: &str,
stream_ordering: i64,
) -> PyResult<()> {
self.run(
py,
self.store.set_destination_last_successful_stream_ordering(
&self.pool,
destination,
stream_ordering,
),
)
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn device_id_of_a_cross_signing_key() {
let key = json!({
"user_id": "@a:test",
"usage": ["master"],
"keys": {"ed25519:AbCdEfGhIjKlMnOpQrStUvWxYz0123456789+/AbCdE": "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789+/AbCdE"},
});
assert_eq!(
cross_signing_key_device_id(&key).unwrap(),
"AbCdEfGhIjKlMnOpQrStUvWxYz0123456789+/AbCdE"
);
for bad in [
json!({}),
json!({"keys": {}}),
json!({"keys": {"ed25519:a": "AAAA", "ed25519:b": "AAAA"}}),
json!({"keys": {"curve25519:a": "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789+/AbCdE"}}),
json!({"keys": {"ed25519:a": "too short"}}),
] {
assert!(cross_signing_key_device_id(&bad).is_err(), "{bad}");
}
}
#[test]
fn truthiness() {
assert!(!is_truthy(&json!({})));
assert!(!is_truthy(&Value::Null));
assert!(is_truthy(&json!({"a": 1})));
}
#[test]
fn latest_update_per_device() {
let mut devices = OrderedDevices::default();
let a = ("@a:test".to_owned(), "A".to_owned());
let b = ("@a:test".to_owned(), "B".to_owned());
devices.update(a.clone(), 1, None);
devices.update(b.clone(), 2, Some("x".to_owned()));
devices.update(a.clone(), 3, Some("y".to_owned()));
assert_eq!(devices.len(), 2);
assert_eq!(devices.entries[0], (a, (3, Some("y".to_owned()))));
assert_eq!(devices.entries[1], (b, (2, Some("x".to_owned()))));
}
}
+1
View File
@@ -16,6 +16,7 @@
pub mod caches;
pub mod db;
pub mod events;
pub mod federation_store;
pub mod python;
pub mod store;
pub mod sync_store;
+23
View File
@@ -252,3 +252,26 @@ class RustDestinationQueue:
def start_catching_up(self) -> None: ...
def pending_pdu_ids(self) -> list[str]:
"""The event IDs of the queued PDUs, oldest first. For tests."""
class FederationStoreForTest:
"""The Rust federation store, for tests that compare it with Python's.
Each method blocks until its query is done."""
def __init__(
self, pool: Any, allow_device_name_lookup_over_federation: bool
) -> None: ...
def get_new_device_msgs_for_remote(
self, destination: str, last_stream_id: int, current_stream_id: int, limit: int
) -> tuple[list[JsonDict], int]: ...
def delete_device_msgs_for_remote(
self, destination: str, up_to_stream_id: int
) -> None: ...
def get_device_updates_by_remote(
self, destination: str, from_stream_id: int, now_stream_id: int, limit: int
) -> tuple[int, list[tuple[str, JsonDict]]]: ...
def mark_as_sent_devices_by_remote(
self, destination: str, stream_id: int
) -> None: ...
def set_destination_last_successful_stream_ordering(
self, destination: str, stream_ordering: int
) -> None: ...
+367
View File
@@ -0,0 +1,367 @@
#
# 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>.
#
"""The Rust federation store (`rust/src/storage/federation_store.rs`) against
the Python store methods it ports, on the same database. These need the native
Rust Postgres driver:
SYNAPSE_TEST_RUST_DRIVER=1 POSTGRES=1 run-trial.sh tests.storage.test_federation_store
"""
from typing import Any
from unittest import SkipTest
from signedjson.key import (
encode_verify_key_base64,
generate_signing_key,
get_verify_key,
)
from twisted.internet.testing import MemoryReactor
from synapse.handlers.e2e_keys import SignatureListItem
from synapse.server import HomeServer
from synapse.storage.rust_pool import RustConnectionPool
from synapse.synapse_rust.federation import FederationStoreForTest
from synapse.types import JsonDict
from synapse.util.clock import Clock
from tests import unittest
DEST = "remote.example.com"
OTHER = "other.example.com"
ROOM = "!room:test"
def _sorted_edus(edus: list[tuple[str, JsonDict]]) -> list[tuple[str, JsonDict]]:
"""The EDUs in a fixed order. Python orders the users as the database
returns them, and Rust by their first update."""
return sorted(
edus,
key=lambda edu: (edu[0], edu[1]["user_id"], edu[1].get("stream_id", 0)),
)
class FederationStoreTestCase(unittest.HomeserverTestCase):
"""Each test runs a Rust query and the Python store method against the
same fixture and compares."""
def default_config(self) -> dict[str, Any]:
config = super().default_config()
# `get_device_updates_by_remote` only works on a federation sender.
config["federation_sender_instances"] = ["master"]
return config
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
hs = self.setup_test_homeserver()
if not isinstance(
hs.get_datastores().main.db_pool._db_pool, RustConnectionPool
):
raise SkipTest("needs the native Rust Postgres driver")
return hs
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.store = hs.get_datastores().main
db_pool = self.store.db_pool._db_pool
assert isinstance(db_pool, RustConnectionPool)
assert db_pool._pool is not None
self.rust = FederationStoreForTest(db_pool._pool, False)
self.rust_with_names = FederationStoreForTest(db_pool._pool, True)
# To-device messages.
def _to_device_edu(self, n: int) -> JsonDict:
return {
"sender": "@alice:test",
"type": "m.test",
"message_id": str(n),
"messages": {"@u:remote": {"D": {"n": n}}},
}
def _queue_to_device(self, destination: str, n: int) -> None:
self.get_success(
self.store.add_remote_messages_from_client_to_device_inbox(
{destination: self._to_device_edu(n)}
)
)
def test_new_device_msgs_for_remote(self) -> None:
for n in range(3):
self._queue_to_device(DEST, n)
self._queue_to_device(OTHER, n)
current = self.store.get_to_device_stream_token()
for last in (0, current - 3, current):
for limit in (0, 1, 2, 10):
self.assertEqual(
self.rust.get_new_device_msgs_for_remote(
DEST, last, current, limit
),
self.get_success(
self.store.get_new_device_msgs_for_remote(
DEST, last, current, limit
)
),
(last, limit),
)
def test_delete_device_msgs_for_remote(self) -> None:
for n in range(3):
self._queue_to_device(DEST, n)
self._queue_to_device(OTHER, 0)
current = self.store.get_to_device_stream_token()
self.rust.delete_device_msgs_for_remote(DEST, current - 2)
messages, _ = self.get_success(
self.store.get_new_device_msgs_for_remote(DEST, 0, current, 10)
)
self.assertEqual(messages, [self._to_device_edu(2)])
messages, _ = self.get_success(
self.store.get_new_device_msgs_for_remote(OTHER, 0, current, 10)
)
self.assertEqual(len(messages), 1)
# Device list updates.
def _poke(self, user_id: str, device_id: str, host: str = DEST) -> None:
self.get_success(
self.store.add_device_change_to_streams(user_id, [device_id], [ROOM])
)
self.get_success(
self.store.add_device_list_outbound_pokes(
user_id=user_id,
device_id=device_id,
room_id=ROOM,
hosts=[host],
context={},
)
)
def _cross_signing_key(self, user_id: str, key_type: str) -> str:
"""Give the user a cross-signing key, and return its device ID."""
public = encode_verify_key_base64(get_verify_key(generate_signing_key("x")))
key = {
"user_id": user_id,
"usage": [key_type],
"keys": {f"ed25519:{public}": public},
}
self.get_success(self.store.set_e2e_cross_signing_key(user_id, key_type, key))
return public
def _device_keys(self, user_id: str, device_id: str) -> None:
self.get_success(
self.store.set_e2e_device_keys(
user_id,
device_id,
self.clock.time_msec(),
{
"user_id": user_id,
"device_id": device_id,
"algorithms": ["m.olm.v1.curve25519-aes-sha2"],
"keys": {f"ed25519:{device_id}": "key"},
"signatures": {user_id: {f"ed25519:{device_id}": "self"}},
},
)
)
def _device_list_fixture(self) -> None:
"""Pokes to DEST for: devices with and without keys and names, a
device's repeated update, a deleted device, a hidden device, a
cross-signed device, and both kinds of cross-signing key update. And
one poke for another destination."""
alice = "@alice:test"
bob = "@bob:test"
self.get_success(self.store.store_device(alice, "A1", "Alice's phone"))
self.get_success(self.store.store_device(alice, "A2", None))
self.get_success(self.store.store_device(bob, "B1", "Bob's laptop"))
self._device_keys(alice, "A1")
self._device_keys(bob, "B1")
master = self._cross_signing_key(alice, "master")
self_signing = self._cross_signing_key(alice, "self_signing")
self.get_success(
self.store.store_e2e_cross_signing_signatures(
alice,
[
SignatureListItem(
signing_key_id=f"ed25519:{self_signing}",
target_user_id=alice,
target_device_id="A1",
signature="cross", # type: ignore[arg-type]
)
],
)
)
self._poke(alice, "A1")
self._poke(bob, "B1")
self._poke(alice, "A2")
self._poke(alice, master)
self._poke(alice, "A1")
self._poke(bob, "GONE")
self._poke(alice, self_signing)
self._poke(bob, "B1", host=OTHER)
# A hidden device is sent as deleted.
self.get_success(self.store.store_device(bob, "HIDDEN", None))
self.get_success(
self.store.db_pool.simple_update_one(
"devices", {"user_id": bob, "device_id": "HIDDEN"}, {"hidden": True}
)
)
self._poke(bob, "HIDDEN")
def _assert_device_updates(self, from_stream_id: int, limit: int) -> None:
now = self.store.get_device_stream_token().stream
python = self.get_success(
self.store.get_device_updates_by_remote(DEST, from_stream_id, limit)
)
for rust, names in ((self.rust, False), (self.rust_with_names, True)):
self.hs.config.federation.allow_device_name_lookup_over_federation = names
python = self.get_success(
self.store.get_device_updates_by_remote(DEST, from_stream_id, limit)
)
stream_id, edus = rust.get_device_updates_by_remote(
DEST, from_stream_id, now, limit
)
self.assertEqual(stream_id, python[0], (from_stream_id, limit, names))
self.assertEqual(
_sorted_edus(edus),
_sorted_edus(python[1]),
(from_stream_id, limit, names),
)
def test_device_updates_by_remote(self) -> None:
self._device_list_fixture()
for limit in (1, 2, 3, 100):
self._assert_device_updates(0, limit)
# The fixture has every kind of update.
_, edus = self.rust_with_names.get_device_updates_by_remote(
DEST, 0, self.store.get_device_stream_token().stream, 100
)
types = [edu_type for edu_type, _ in edus]
self.assertEqual(types.count("m.signing_key_update"), 1)
self.assertEqual(types.count("org.matrix.signing_key_update"), 1)
updates = {
content["device_id"]: content
for edu_type, content in edus
if edu_type == "m.device_list_update"
}
self.assertEqual(set(updates), {"A1", "A2", "B1", "GONE", "HIDDEN"})
self.assertEqual(updates["A1"]["device_display_name"], "Alice's phone")
self.assertIn("cross", str(updates["A1"]["keys"]["signatures"]))
self.assertNotIn("keys", updates["A2"])
self.assertTrue(updates["GONE"]["deleted"])
self.assertTrue(updates["HIDDEN"]["deleted"])
# A1 was updated again after A2, so it follows A2.
self.assertEqual(updates["A1"]["prev_id"], [updates["A2"]["stream_id"]])
def test_device_updates_by_remote_paginates(self) -> None:
"""Walk the stream a few updates at a time, marking each batch sent,
so the prev IDs come from the last batch."""
self._device_list_fixture()
position = 0
for _ in range(10):
self._assert_device_updates(position, 3)
stream_id, edus = self.rust.get_device_updates_by_remote(
DEST, position, self.store.get_device_stream_token().stream, 3
)
if not edus:
break
self.rust.mark_as_sent_devices_by_remote(DEST, stream_id)
position = stream_id
else:
self.fail("never caught up")
def test_mark_as_sent_devices_by_remote(self) -> None:
"""The pokes sent are deleted, and each user's last one recorded."""
self._device_list_fixture()
stream_id, _ = self.get_success(
self.store.get_device_updates_by_remote(DEST, 0, limit=4)
)
pokes = self.get_success(
self.store.db_pool.simple_select_list(
"device_lists_outbound_pokes",
{"destination": DEST},
["user_id", "stream_id"],
)
)
expected: dict[str, int] = {}
for user_id, poke_stream_id in pokes:
if poke_stream_id <= stream_id:
expected[user_id] = max(expected.get(user_id, 0), poke_stream_id)
self.rust.mark_as_sent_devices_by_remote(DEST, stream_id)
remaining = self.get_success(
self.store.db_pool.simple_select_list(
"device_lists_outbound_pokes",
{"destination": DEST},
["stream_id"],
)
)
self.assertTrue(remaining)
self.assertTrue(all(row[0] > stream_id for row in remaining))
last_success = self.get_success(
self.store.db_pool.simple_select_list(
"device_lists_outbound_last_success",
{"destination": DEST},
["user_id", "stream_id"],
)
)
self.assertEqual(dict(last_success), expected)
# Another destination's pokes are left alone.
other = self.get_success(
self.store.db_pool.simple_select_list(
"device_lists_outbound_pokes", {"destination": OTHER}, ["stream_id"]
)
)
self.assertEqual(len(other), 1)
# Marking again moves a user's last success on.
self.rust.mark_as_sent_devices_by_remote(
DEST, self.store.get_device_stream_token().stream
)
last_success = self.get_success(
self.store.db_pool.simple_select_list(
"device_lists_outbound_last_success",
{"destination": DEST},
["user_id", "stream_id"],
)
)
self.assertEqual(
{user_id for user_id, _ in last_success}, {"@alice:test", "@bob:test"}
)
self.assertTrue(
all(stream > expected.get(user_id, 0) for user_id, stream in last_success)
)
def test_set_destination_last_successful_stream_ordering(self) -> None:
self.rust.set_destination_last_successful_stream_ordering(DEST, 5)
self.assertEqual(
self.get_success(
self.store.get_destination_last_successful_stream_ordering(DEST)
),
5,
)
self.rust.set_destination_last_successful_stream_ordering(DEST, 7)
self.assertEqual(
self.get_success(
self.store.get_destination_last_successful_stream_ordering(DEST)
),
7,
)