Files
synapse/rust/src/persist/python.rs
T
Erik JohnstonandClaude Opus 5.5 e4e22f63e5 Start the Rust persist transaction, with a twin-room test
The first commit of phase 2 of the event persistence plan
(`transaction` level). `rust/src/persist/txn/` will hold the port of
`_persist_events_txn`. This commit adds its driver: the share lock on
the room's `rooms` row, the cache updates Python queues for every event
up front, and dropping duplicate events. It returns the events left for
the steps still in Python.

`tests/storage/test_rust_persist_txn.py` builds the same DAG in two
rooms. Room A persists in Python. Room B prepares in Rust, runs the
Rust part of the transaction in its own committed transaction through
`Persister.persist_events_txn_for_test`, and then runs the Python steps
after it. The test compares the rows of every table the transaction
writes, after normalising room and event IDs, stream positions, state
groups and chain IDs, and compares the cache updates both rooms asked
for. Each later commit moves more steps from the Python half to the
Rust half, in transaction order, until the facade runs the whole
transaction. Mutating the driver (dropping the redaction
invalidation, the relation, the state key, or the outlier rule for
duplicates) fails the comparison.

Two open items from phase 1:

- Reads before the transaction now retry. Python runs each read in a
  `runInteraction` that retries a lost connection or a cancelled
  statement. `Persister.prepare_chunk` now prepares the whole chunk
  again on such an error, up to five attempts. That is safe because its
  only write, bumping the state groups it uses, can be repeated. The
  reader and the state resolution store keep the pool's error as the
  cause, so `is_retryable_error` can classify it. A `WriteError` does
  not count, as its write interaction has already retried, and
  `WriteError::from_pg` shares the classification.
- The Rust persister needs the state tables in the main database, as
  it reads and writes both in one transaction. `Persister::for_homeserver`
  now checks for that, and the persister is off when they are apart.

Also fixed in the dispatch: at `off`, `_prepare_chunk` ran Rust when a
persister existed. That only happens when a test changes the level at
runtime. The twin test does exactly that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXMkcZVkcZhi21augBff4a
2026-10-17 11:42:25 +00:00

495 lines
18 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>.
*
*/
//! The Python face of event persistence: the [`Persister`] that
//! `RustHandlers.persister` hands out, and helpers for tests.
use std::{
collections::{HashMap, HashSet},
sync::{Arc, Mutex},
};
use futures::{future::BoxFuture, FutureExt};
use http::StatusCode;
use pyo3::{exceptions::PyRuntimeError, intern, prelude::*, types::PyDict};
use super::{
context::PersistContext,
effects::PersistEffects,
extremities::MissingPrevStateEvents,
metrics::{PersistCounts, PersistMetrics},
prepare::{self, PrepareInput, PrepareOptions, Prepared},
reader::{self, PersistReader, PyDelta, PySlidingSync},
state_delta::NativeStateResolver,
txn::{self, TxnEvent, TxnInput},
};
use crate::{
database::postgres::write::{is_retryable_error, run_write_interaction, MAX_ATTEMPTS},
deferred::{create_deferred_with, OnCancel},
errors::SynapseError,
event_auth::AuthFailure,
events::Event,
homeserver::HomeServer,
runtime::RustRuntime,
state::{ConflictCache, StateResStats},
storage::{
native_pool::NativePool,
store::Store,
stream_ids::{PyStreamIdTracker, StreamIdTracker},
sync_store::{SyncStore, SyncStoreConfig},
},
types::state_filter::state_map_to_py,
};
/// Persists events in Rust, on the events writer, over the native driver.
///
/// Holds what every level needs: the pool, the shared caches, the stream ID
/// trackers of the events, backfill and cache streams (shared with their
/// Python `MultiWriterIdGenerator`s) and the metrics.
///
/// Each entry point returns a Deferred and has a `_blocking` twin for
/// tests (see `_BlockingRustCalls`).
#[pyclass(frozen)]
pub struct Persister {
pub(super) inner: Arc<PersisterInner>,
}
#[allow(dead_code)] // Read by the levels to come.
pub(super) struct PersisterInner {
pub pool: NativePool,
pub runtime: RustRuntime,
pub store: Arc<Store>,
/// The sync store over the same caches, which the
/// [`super::reader::PersistReader`] builds on.
pub sync: Arc<SyncStore>,
pub events_stream: Arc<StreamIdTracker>,
pub backfill_stream: Arc<StreamIdTracker>,
pub cache_stream: Arc<StreamIdTracker>,
pub server_name: String,
pub instance_name: String,
pub metrics: PersistMetrics,
/// The Rust state resolution handler's conflict cache.
pub conflict_cache: Arc<ConflictCache>,
/// `experimental_features.msc4354_enabled`.
pub msc4354_enabled: bool,
}
impl Persister {
/// Build the persister for `homeserver`, whose main database uses the
/// native driver through `connection_pool` (a `RustConnectionPool`).
/// `None` if this instance does not write events, or if the state tables
/// are in another database: persisting reads and writes them in the
/// main database's transactions.
pub fn for_homeserver(
py: Python<'_>,
homeserver: &HomeServer,
connection_pool: Py<PyAny>,
runtime: RustRuntime,
store: Arc<Store>,
conflict_cache: Arc<ConflictCache>,
) -> PyResult<Option<Persister>> {
let instance_name = homeserver.instance_name(py)?;
if !homeserver.event_writers(py)?.contains(&instance_name) {
return Ok(None);
}
if !homeserver
.state_database_pool(py)?
.is(&homeserver.main_database_pool(py)?)
{
log::warn!(
"The state tables are not in the main database: persisting events in Python"
);
return Ok(None);
}
let config = homeserver.config(py)?;
let main = homeserver.main_datastore(py)?;
let tracker = |attr: &Bound<'_, pyo3::types::PyString>| -> PyResult<Arc<StreamIdTracker>> {
let id_gen = main.getattr(attr)?;
if id_gen.is_none() {
return Err(PyRuntimeError::new_err(format!(
"the main datastore has no {attr}"
)));
}
Ok(id_gen
.getattr(intern!(py, "_tracker"))?
.cast::<PyStreamIdTracker>()?
.get()
.tracker())
};
Ok(Some(Persister {
inner: Arc::new(PersisterInner {
pool: NativePool::new(connection_pool, "persist_db_pool"),
runtime,
sync: Arc::new(SyncStore::new(
Arc::clone(&store.caches),
SyncStoreConfig::from_config(&config),
)),
store,
events_stream: tracker(intern!(py, "_stream_id_gen"))?,
backfill_stream: tracker(intern!(py, "_backfill_id_gen"))?,
cache_stream: tracker(intern!(py, "_cache_id_gen"))?,
server_name: homeserver.hostname(py)?,
instance_name,
metrics: PersistMetrics::default(),
conflict_cache,
msc4354_enabled: config.experimental.msc4354_enabled,
}),
}))
}
/// Convert the chunk while the GIL is held, and return the future that
/// prepares it without the GIL.
fn start_prepare_chunk(
&self,
py: Python<'_>,
room_id: String,
events_and_contexts: Vec<(Py<Event>, PersistContext)>,
backfilled: bool,
shadow: bool,
) -> PyResult<BoxFuture<'static, PyResult<PreparedChunk>>> {
let input = PrepareInput::new(py, room_id, &events_and_contexts, backfilled)?;
let pool = self.inner.pool.get()?;
let handle = self.inner.runtime.tokio_handle()?;
let inner = Arc::clone(&self.inner);
Ok(async move {
let resolver = NativeStateResolver {
pool: pool.clone(),
handle,
caches: Arc::clone(&inner.store.caches),
conflict_cache: Arc::clone(&inner.conflict_cache),
};
let reader = PersistReader::new(inner.sync.reader(pool.clone()));
let options = PrepareOptions {
server_name: &inner.server_name,
clock: inner.runtime.clock(),
sticky: inner.msc4354_enabled,
dry_run: shadow,
};
// Python reads in `runInteraction`s, which retry on a lost
// connection or a cancelled statement. Here the whole chunk is
// prepared again. That is safe as its only write, bumping the
// state groups it uses, can be repeated, and runs in a write
// interaction with its own retries.
let mut attempt = 1;
loop {
let mut counts = PersistCounts::default();
match prepare::prepare_chunk(
&reader,
&resolver,
&pool,
&input,
&options,
&mut counts,
)
.await
{
Ok(prepared) => {
return Ok(PreparedChunk {
prepared,
counts: Mutex::new(Some(counts)),
})
}
Err(e) if attempt < MAX_ATTEMPTS && is_retryable_error(&e) => {
log::warn!("[TXN RETRY] {{prepare_chunk}} {e:#} {attempt}/{MAX_ATTEMPTS}");
attempt += 1;
}
Err(e) => return Err(persist_error_to_py(e)),
}
}
}
.boxed())
}
/// Run `future` to completion on the runtime, releasing the GIL. For the
/// `_blocking` entry points.
fn block_on<T: Send>(
&self,
py: Python<'_>,
future: BoxFuture<'static, PyResult<T>>,
) -> PyResult<T> {
let handle = self.inner.runtime.tokio_handle()?;
py.detach(|| handle.block_on(future))
}
}
#[pymethods]
impl Persister {
/// What persisting has counted since the last call, for
/// `flush_rust_persist_metrics`.
fn take_metrics<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
self.inner.metrics.take().to_py(py)
}
/// What `_persist_event_batch` computes for a chunk of one room's events
/// before the write transaction: a Deferred resolving to a
/// [`PreparedChunk`]. In `shadow` mode the chain cover step's write is
/// rolled back. Nothing is counted until [`Self::record_prepare_counts`].
/// Cancelling the Deferred stops the computation.
#[pyo3(signature = (room_id, events_and_contexts, backfilled, *, shadow))]
fn prepare_chunk<'py>(
&self,
py: Python<'py>,
room_id: String,
events_and_contexts: Vec<(Py<Event>, PersistContext)>,
backfilled: bool,
shadow: bool,
) -> PyResult<Bound<'py, PyAny>> {
let future =
self.start_prepare_chunk(py, room_id, events_and_contexts, backfilled, shadow)?;
create_deferred_with(py, &self.inner.runtime, future, OnCancel::Abort)
}
/// Add what preparing `chunk` counted to the metrics, once Python has
/// decided to use it. Python counts the chunks it computes itself. Does
/// nothing the second time.
fn record_prepare_counts(&self, chunk: &PreparedChunk) {
let counts = chunk
.counts
.lock()
.unwrap_or_else(|e| e.into_inner())
.take();
if let Some(counts) = counts {
self.inner.metrics.record(|total| total.add(counts));
}
}
/// Run the part of the write transaction ported so far over the chunk,
/// whose events must have their stream orderings set, and commit it.
/// Returns what it collected for Python to apply and the positions in
/// the chunk of the events left for the Python steps after it. For
/// `tests/storage/test_rust_persist_txn.py`.
fn persist_events_txn_for_test(
&self,
py: Python<'_>,
room_id: String,
events_and_contexts: Vec<(Py<Event>, PersistContext)>,
) -> PyResult<(PersistEffects, Vec<usize>)> {
let events = events_and_contexts
.into_iter()
.map(|(event, context)| TxnEvent::new(event.bind(py).get().shallow_clone(), context))
.collect::<anyhow::Result<Vec<_>>>()
.map_err(persist_error_to_py)?;
let pool = self.inner.pool.get()?;
let future = async move {
let input = TxnInput {
room_id: &room_id,
events: &events,
};
run_write_interaction(&pool, "persist_events", |txn, _| {
let input = &input;
async move {
let mut effects = PersistEffects::default();
let left = txn::persist_events_txn(txn, input, &mut effects).await?;
let left = left
.iter()
.filter_map(|e| input.events.iter().position(|x| std::ptr::eq(x, *e)))
.collect();
Ok((effects, left))
}
.boxed()
})
.await
.map_err(|e| persist_error_to_py(anyhow::Error::new(e)))
};
self.block_on(py, future.boxed())
}
/// As [`Self::prepare_chunk`], but blocks.
#[pyo3(signature = (room_id, events_and_contexts, backfilled, *, shadow))]
fn prepare_chunk_blocking(
&self,
py: Python<'_>,
room_id: String,
events_and_contexts: Vec<(Py<Event>, PersistContext)>,
backfilled: bool,
shadow: bool,
) -> PyResult<PreparedChunk> {
let future =
self.start_prepare_chunk(py, room_id, events_and_contexts, backfilled, shadow)?;
self.block_on(py, future)
}
}
/// What [`Persister::prepare_chunk`] computed, in the shapes Python builds
/// its own types from. If `state_res_fallback` is set, the rest is empty
/// and Python should compute the chunk itself.
#[pyclass(frozen)]
pub struct PreparedChunk {
prepared: Prepared,
/// What preparing counted, until it is recorded.
counts: Mutex<Option<PersistCounts>>,
}
impl PreparedChunk {
fn chunk(&self) -> Option<&prepare::PreparedChunk> {
match &self.prepared {
Prepared::Chunk(chunk) => Some(chunk),
Prepared::StateResFallback(_) => None,
}
}
}
#[pymethods]
impl PreparedChunk {
/// Why the Rust state resolution could not resolve the room.
#[getter]
fn state_res_fallback(&self) -> Option<&str> {
match &self.prepared {
Prepared::Chunk(_) => None,
Prepared::StateResFallback(reason) => Some(reason),
}
}
#[getter]
fn new_forward_extremities(&self) -> Option<HashSet<String>> {
let ids = self.chunk()?.state.new_forward_extremities.clone()?;
Some(reader::strings(ids))
}
/// `(to_delete, to_insert, no_longer_in_room)`.
#[getter]
fn state_delta<'py>(&self, py: Python<'py>) -> PyResult<Option<PyDelta<'py>>> {
self.chunk()
.and_then(|chunk| chunk.state.state_delta.as_ref())
.map(|delta| reader::delta_to_py(py, delta))
.transpose()
}
#[getter]
fn new_state_dag_extremities(&self) -> Option<HashSet<String>> {
let ids = self.chunk()?.state.new_state_dag_extremities.clone()?;
Some(reader::strings(ids))
}
#[getter]
fn state_res_stats(&self) -> Option<StateResStats> {
self.chunk()?.state.state_res_stats.clone()
}
/// Event ID to `(chain_id, sequence_number, links)`.
#[getter]
#[allow(clippy::type_complexity)]
fn new_event_links(&self) -> HashMap<String, (i64, i64, Vec<(i64, i64)>)> {
self.chunk()
.map(|chunk| {
chunk
.new_event_links
.iter()
.map(|(event_id, links)| {
(
event_id.to_string(),
(links.chain_id, links.sequence_number, links.links.clone()),
)
})
.collect()
})
.unwrap_or_default()
}
/// As `PersistReaderForTest.calculate_sliding_sync_table_changes`
/// returns it. `None` when the state does not change.
#[getter]
fn sliding_sync_table_changes<'py>(
&self,
py: Python<'py>,
) -> PyResult<Option<PySlidingSync<'py>>> {
self.chunk()
.and_then(|chunk| chunk.sliding_sync.as_ref())
.map(|changes| reader::sliding_sync_to_py(py, changes))
.transpose()
}
#[getter]
fn sticky_events_to_un_soft_fail(&self) -> HashSet<String> {
self.chunk()
.map(|chunk| reader::strings(chunk.sticky_events_to_un_soft_fail.iter().cloned()))
.unwrap_or_default()
}
}
/// Convert an error from persisting to the Python exception the Python
/// path would raise: a Python exception as it was raised, an auth
/// failure as the auth rules raise it (from any step, state resolution
/// included), a 500 `SynapseError` for missing
/// prev state events, as `_calculate_new_state_dag_extremities` raises,
/// and a `RuntimeError` otherwise.
pub fn persist_error_to_py(e: anyhow::Error) -> PyErr {
let e = match e.downcast::<PyErr>() {
Ok(err) => return err,
Err(e) => e,
};
if e.downcast_ref::<AuthFailure>().is_some() {
// The Python exception carries only the failure's message, so log
// the context, which names the event.
log::warn!("Auth checks failed while persisting: {e:#}");
}
let e = match e.downcast::<AuthFailure>() {
Ok(failure) => return failure.into_py_err(),
Err(e) => e,
};
if let Some(missing) = e.downcast_ref::<MissingPrevStateEvents>() {
return SynapseError::new(
StatusCode::INTERNAL_SERVER_ERROR,
missing.to_string(),
"M_UNKNOWN",
None,
None,
);
}
PyRuntimeError::new_err(format!("{e:#}"))
}
/// `(internal_metadata, json)`: the two `event_json` columns as Rust would
/// write them for `event`, for tests to compare with Python's.
#[pyfunction]
pub fn persistence_json_for_test(event: &Bound<'_, Event>) -> PyResult<(String, String)> {
let event = event.get();
let internal_metadata = event.internal_metadata().to_persistence_value()?;
let json = event.to_persistence_value()?;
Ok((internal_metadata.to_string(), json.to_string()))
}
/// The [`PersistContext`] of `context` as a dict, for tests to check the
/// conversion.
#[pyfunction]
pub fn persist_context_for_test<'py>(
py: Python<'py>,
context: PersistContext,
) -> PyResult<Bound<'py, PyDict>> {
let dict = PyDict::new(py);
dict.set_item("rejected", context.rejected)?;
dict.set_item("state_group", context.state_group)?;
dict.set_item("state_group_before_event", context.state_group_before_event)?;
dict.set_item(
"state_delta_due_to_event",
context
.state_delta_due_to_event
.as_ref()
.map(|delta| state_map_to_py(py, delta))
.transpose()?,
)?;
let deltas = PyDict::new(py);
for (key, delta) in context.state_group_deltas.iter() {
deltas.set_item(key, state_map_to_py(py, delta)?)?;
}
dict.set_item("state_group_deltas", deltas)?;
dict.set_item("partial_state", context.partial_state)?;
dict.set_item("app_service", context.app_service)?;
Ok(dict)
}