Files
synapse/rust/src/federation/transaction/mod.rs
T
Erik JohnstonandClaude Opus 5.5 b8d8d5e015 Fix review findings in the Rust transaction sender
- Only build the Rust sender from a real `RustFederationClient`. A test
  that mocks the federation client no longer fails with a `TypeError`.
- Log the `TX` and response lines under Python's logger names, so log
  configs that name those loggers still apply.
- Move the PDUs and EDUs into the request body, so a transaction holds
  one copy of them while it is retried, besides the canonical JSON.
- Drop the unused `time_now` from `Event::to_pdu_value`.
- Set the compare mode per `TransactionManager` in the tests that use
  it, not on the class.
- Test that the tracing headers reach the request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 14:54:44 +00:00

400 lines
14 KiB
Rust

/*
* 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>.
*
*/
//! Transactions: Python's `TransactionManager.send_new_transaction` and the
//! `Transaction` unit of `synapse.federation.units`.
//!
//! [`TransactionSender::build`] gives a transaction its id and timestamp.
//! [`TransactionSender::send`] PUTs it to `/send/<txn_id>` through the
//! [`FederationClient`] with the options Python's transport layer uses, reads
//! the JSON response and works out which PDUs the remote accepted. None of
//! it needs the GIL. [`python`] is the facade the Python `TransactionManager`
//! calls.
use std::{
sync::{
atomic::{AtomicU64, Ordering},
Arc,
},
time::Duration,
};
use bytes::{Bytes, BytesMut};
use http::Method;
use serde_json::{Map, Value};
use super::client::{
check_content_type,
python::{python_type_name, MAX_JSON_RESPONSE_SIZE},
ClientError, FailureCause, FederationClient, FederationRequest, RequestOptions, RequestStats,
SentResponse,
};
pub mod python;
#[cfg(test)]
mod tests;
/// `CANONICALJSON_MAX_INT`: the largest integer canonical JSON allows.
const CANONICALJSON_MAX_INT: i64 = (1 << 53) - 1;
/// `CANONICALJSON_MIN_INT`.
const CANONICALJSON_MIN_INT: i64 = -CANONICALJSON_MAX_INT;
/// The logger of Python's `TX [dest] {txn}` lines. Rust logs them under the
/// same name, so log configs that set its level still apply.
const TX_LOG: &str = "synapse.federation.sender.transaction_manager";
/// The logger of Python's lines about reading the response.
const CLIENT_LOG: &str = "synapse.http.matrixfederationclient";
/// The EDU types whose contents the issue 8631 debug log records.
const DEVICE_UPDATE_EDUS: [&str; 2] = ["m.device_list_update", "m.signing_key_update"];
/// A transaction to send: `synapse.federation.units.Transaction`.
#[derive(Clone, Debug)]
pub struct Transaction {
pub transaction_id: String,
pub origin: String,
pub destination: String,
pub origin_server_ts: u64,
/// The PDUs, as `get_pdu_json` gives them.
pub pdus: Vec<Value>,
/// The EDUs, as `Edu.get_dict` gives them.
pub edus: Vec<Value>,
}
impl Transaction {
/// The request body: `Transaction.get_dict`. PDUs with a depth canonical
/// JSON cannot hold are dropped, and `edus` is left out when empty.
pub fn body(&self) -> Value {
self.clone().into_body()
}
/// As [`Transaction::body`], moving the PDUs and EDUs into it.
pub fn into_body(self) -> Value {
let mut pdus = self.pdus;
pdus.retain(has_valid_depth);
let mut body = Map::new();
body.insert("origin".to_owned(), Value::from(self.origin));
body.insert(
"origin_server_ts".to_owned(),
Value::from(self.origin_server_ts),
);
body.insert("pdus".to_owned(), Value::Array(pdus));
if !self.edus.is_empty() {
body.insert("edus".to_owned(), Value::Array(self.edus));
}
Value::Object(body)
}
/// `/_matrix/federation/v1/send/<txn_id>`. The id is a number, so the
/// quoting `_create_v1_path` applies changes nothing.
pub fn path(&self) -> String {
format!("/_matrix/federation/v1/send/{}", self.transaction_id)
}
/// The prefix of the transaction's log lines, `TX [dest] {txn}`.
fn log_prefix(&self) -> String {
log_prefix(&self.destination, &self.transaction_id)
}
}
/// `TX [dest] {txn}`.
fn log_prefix(destination: &str, transaction_id: &str) -> String {
format!("TX [{destination}] {{{transaction_id}}}")
}
/// `filter_pdus_for_valid_depth`: keep the PDUs whose `depth` is an integer
/// that canonical JSON allows.
pub fn filter_pdus_for_valid_depth(pdus: &[Value]) -> Vec<Value> {
pdus.iter()
.filter(|pdu| has_valid_depth(pdu))
.cloned()
.collect()
}
/// Whether the PDU has a `depth` that canonical JSON allows.
fn has_valid_depth(pdu: &Value) -> bool {
let Some(depth) = pdu.get("depth") else {
return false;
};
// Python compares whatever is there. An event's depth is always an
// integer, but compare a float as Python would.
if let Some(depth) = depth.as_i64() {
(CANONICALJSON_MIN_INT..=CANONICALJSON_MAX_INT).contains(&depth)
} else if let Some(depth) = depth.as_f64() {
(CANONICALJSON_MIN_INT as f64..=CANONICALJSON_MAX_INT as f64).contains(&depth)
} else {
false
}
}
/// The remote's answer to a transaction.
#[derive(Debug)]
pub enum TransactionResponse {
/// The ids of the PDUs the remote echoed back without an error, in the
/// order of the response.
Acknowledged(Vec<String>),
/// A 2xx body that serde_json refused, such as one with a lone surrogate
/// in a string. Python's decoder may accept it, so the caller decides,
/// and logs the `got 200 response` line if it does.
Unparsed(Bytes),
}
/// Log the PDUs the remote reported errors for, and return the ids of the
/// ones it accepted: the response handling of `send_new_transaction`.
pub fn acknowledged_pdus(log_prefix: &str, response: &Map<String, Value>) -> Vec<String> {
let Some(pdus) = response.get("pdus") else {
return Vec::new();
};
let Some(pdus) = pdus.as_object() else {
log::warn!(target: TX_LOG, "{log_prefix} Remote returned invalid type for `pdus`");
return Vec::new();
};
let mut acknowledged = Vec::with_capacity(pdus.len());
for (event_id, result) in pdus {
match result.as_object() {
Some(result) if !result.contains_key("error") => {
acknowledged.push(event_id.clone());
}
_ => log::warn!(
target: TX_LOG,
"{log_prefix} Remote returned error for {event_id}: {result}"
),
}
}
acknowledged
}
/// Builds and sends transactions.
pub struct TransactionSender {
client: Arc<FederationClient>,
/// The next transaction id. Seeded with the time, as Python does, so ids
/// do not repeat across restarts.
next_txn_id: AtomicU64,
}
impl TransactionSender {
pub fn new(client: Arc<FederationClient>) -> Self {
let next_txn_id = AtomicU64::new(client.now_ms());
TransactionSender {
client,
next_txn_id,
}
}
pub fn client(&self) -> &FederationClient {
&self.client
}
/// Give the PDUs and EDUs for `destination` an id and a timestamp.
pub fn build(&self, destination: &str, pdus: Vec<Value>, edus: Vec<Value>) -> Transaction {
let transaction_id = self.next_txn_id.fetch_add(1, Ordering::Relaxed).to_string();
log::debug!(
target: TX_LOG,
"TX [{destination}] {{{transaction_id}}} Attempting new transaction (pdus: {}, edus: {})",
pdus.len(),
edus.len()
);
Transaction {
transaction_id,
origin: self.client.config().server_name.clone(),
destination: destination.to_owned(),
origin_server_ts: self.client.now_ms(),
pdus,
edus,
}
}
/// Send `transaction` and read the remote's answer.
///
/// The PDUs and EDUs move into the request body, so the transaction holds
/// one copy of them while it is retried, besides the canonical JSON.
/// `extra_headers` carries the tracing span. The request's attempts and
/// response codes go to `stats`, for the Python counters.
pub async fn send(
&self,
transaction: Transaction,
extra_headers: Vec<(String, String)>,
stats: &mut RequestStats,
) -> Result<TransactionResponse, ClientError> {
let log_prefix = transaction.log_prefix();
log::info!(
target: TX_LOG,
"{log_prefix} Sending transaction [{}], (PDUs: {}, EDUs: {})",
transaction.transaction_id,
transaction.pdus.len(),
transaction.edus.len(),
);
log_device_list_updates(&transaction);
let transaction_id = transaction.transaction_id.clone();
let destination = transaction.destination.clone();
let request = FederationRequest {
method: Method::PUT,
destination: destination.clone(),
path: transaction.path(),
query: String::new(),
body: Some(transaction.into_body()),
rewrite_age_ts: false,
extra_headers,
};
// `TransportLayerClient.send_transaction`'s options.
let options = RequestOptions {
long_retries: true,
try_trailing_slash_on_400: true,
// A transaction should always succeed. If it does not, something
// is wrong, so back off.
backoff_on_all_error_codes: true,
..RequestOptions::default()
};
let response = match self.client.send(&request, &options, stats).await {
Ok(response) => response,
Err(err) => {
if let ClientError::Http(http) = &err {
log::info!(target: TX_LOG, "{log_prefix} got {} response", http.code);
}
return Err(err);
}
};
let body = self
.read_json(response, &destination, self.client.config().default_timeout)
.await?;
let value = match serde_json::from_slice::<Value>(&body) {
Ok(value) => value,
Err(_) => return Ok(TransactionResponse::Unparsed(body)),
};
let Value::Object(value) = value else {
log::warn!(
target: CLIENT_LOG,
"{{{transaction_id}}} [{destination}] Failed to parse response"
);
return Err(ClientError::SendFailed {
cause: FailureCause::InvalidBody(format!(
"Received incorrect JSON value: {}",
python_type_name(&value)
)),
can_retry: false,
});
};
log::info!(target: TX_LOG, "{log_prefix} got 200 response");
Ok(TransactionResponse::Acknowledged(acknowledged_pdus(
&log_prefix,
&value,
)))
}
/// Read a 2xx response's JSON body, as `_handle_response` does with a
/// `JsonParser`.
async fn read_json(
&self,
response: SentResponse,
destination: &str,
timeout: Duration,
) -> Result<Bytes, ClientError> {
let log_prefix = format!("{{{}}} [{destination}]", response.txn_id);
let description = response.description.clone();
let start_ms = response.start_ms;
let status = response.status.as_u16();
let phrase = response.phrase.clone();
if let Err(err) = check_content_type(&response.headers, "application/json") {
log::warn!(
target: CLIENT_LOG,
"{log_prefix} Error reading response {description}: {err}"
);
return Err(err);
}
let mut collected = BytesMut::new();
let read = self
.client
.read_body(
response.body,
Some(MAX_JSON_RESPONSE_SIZE),
timeout,
|chunk| {
collected.extend_from_slice(&chunk);
Ok(())
},
)
.await;
let length = match read {
Ok(length) => length,
Err(err) => {
match &err {
ClientError::SendFailed {
cause: FailureCause::TooLarge { limit },
..
} => log::warn!(
target: CLIENT_LOG,
"{log_prefix} JSON response exceeded max size {limit} - {description}"
),
ClientError::SendFailed {
cause: FailureCause::BodyTimeout,
..
} => log::warn!(
target: CLIENT_LOG,
"{log_prefix} Timed out reading response - {description}"
),
_ => log::warn!(
target: CLIENT_LOG,
"{log_prefix} Failed to read response - {description}"
),
}
return Err(err);
}
};
let secs = self.client.now_ms().saturating_sub(start_ms) as f64 / 1000.0;
log::info!(
target: CLIENT_LOG,
"{log_prefix} Completed request: {status} {phrase} in {secs:.2} secs, got {length} bytes - {description}"
);
Ok(collected.freeze())
}
}
/// The debug log for issue 8631: the device list updates a transaction
/// carries.
fn log_device_list_updates(transaction: &Transaction) {
const TARGET: &str = "synapse.8631_debug";
if !log::log_enabled!(target: TARGET, log::Level::Debug) {
return;
}
let updates: Vec<&Value> = transaction
.edus
.iter()
.filter(|edu| {
edu.get("edu_type")
.and_then(Value::as_str)
.is_some_and(|edu_type| DEVICE_UPDATE_EDUS.contains(&edu_type))
})
.filter_map(|edu| edu.get("content"))
.collect();
if !updates.is_empty() {
log::debug!(
target: TARGET,
"about to send txn [{}] including device list updates: {}",
transaction.transaction_id,
Value::from(updates.into_iter().cloned().collect::<Vec<_>>()),
);
}
}