Files
synapse/rust/src/persist/python.rs
T
Erik JohnstonandClaude Opus 5.5 41c7808711 Store the event metadata in the Rust persist transaction
The third commit of phase 2. The Rust transaction now also runs
`_update_metadata_tables_txn`, apart from the event cache prefill at its
end (`_add_to_cache`), which stays in Python:

- `txn/metadata.rs`: push actions move from the staging table, and the
  chunk's staging rows go. A redaction removes the push actions and the
  relation of the event it redacts, and moves or deletes its thread.
  Then search (with the plain text topic from `m.topic`), redactions,
  retention, relations and threads, labels, expiry, and room members.
  An out-of-band membership of a local user also updates
  `local_current_membership` and the user's sliding sync snapshot, with
  the stripped state of an invite or knock.
- `txn/edges.rs`: `event_edges` with the backward extremities, and the
  MSC4242 state DAG edges.
- `txn/caches.rs`: `_invalidate_cache_and_stream`. The rows go into
  `cache_invalidation_stream_by_instance` inside the transaction, with
  IDs from the cache stream's tracker. The committed attempt hands its
  IDs to Python in `PersistEffects.cache_stream_ids`. Python finishes
  them after invalidating the caches, as `get_next_txn` does. A failed
  attempt's IDs are finished when its steps fail, or for a failed
  commit, by the next attempt or when the persist ends. A fetch of an ID
  that is dropped before the sequence answers still ends, so a cancelled
  persist cannot hold the stream's position back.

Where Python and Rust differ, the input is malformed:

- Python stores labels from any value it can iterate, and non-string
  labels. Rust stores only string labels from a list.
- An integer lifetime or expiry beyond `u64` is a float to serde, so
  Rust ignores it where Python's insert fails. Within `u64` but beyond
  `bigint`, both fail.

The persister reads `enable_search` and `enable_ephemeral_messages`
from the server config. `persist_events_txn_for_test` takes
`inhibit_local_membership_updates`.

New twin scenarios: each kind of relation and its redaction, which
moves the thread back and then deletes it, a relation redacted in its
own chunk, two thread replies in one chunk, search, retention (also two
policies in one chunk), labels and expiry, member rows, push actions and
their redaction, an out-of-band invite and its rejection, a knock, and a
backfilled invite. The out-of-band ones are skipped in state DAG rooms,
as a hand-built event has no state DAG context. A redaction before its
target is now stored with a recheck still needed.

The test harness compared `event_edges` by its `room_id` column, which
is no longer filled in, so it compared no rows. It now compares by
`event_id`. It also names the cache behind each invalidation, compares
the cache stream rows' times, and finishes the cache stream IDs Rust
hands back. Fifteen mutants of the new steps each fail the comparison.

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

581 lines
21 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 anyhow::Context;
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, caches::CacheStream, json, PartialStateConflict, TxnEvent, TxnInput},
};
use crate::{
database::postgres::write::{
is_retryable_error, run_write_interaction, WriteError, 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,
/// `server.enable_search`.
pub enable_search: bool,
/// `server.enable_ephemeral_messages`.
pub enable_ephemeral_messages: 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,
enable_search: config.server.enable_search,
enable_ephemeral_messages: config.server.enable_ephemeral_messages,
}),
}))
}
/// 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: Arc::new(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.
/// `prepared` is what Rust prepared for it. For
/// `tests/storage/test_rust_persist_txn.py`.
///
/// The cache stream IDs it wrote come back unfinished in the effects.
///
/// Returns what it collected for Python to apply, and for the Python
/// steps after it, by position in the chunk: the events left, the
/// events stored redacted with the event that redacts each, and the
/// events that stopped being outliers.
#[allow(clippy::type_complexity)]
#[pyo3(signature = (room_id, events_and_contexts, prepared, *, inhibit_local_membership_updates))]
fn persist_events_txn_for_test(
&self,
py: Python<'_>,
room_id: String,
events_and_contexts: Vec<(Py<Event>, PersistContext)>,
prepared: &PreparedChunk,
inhibit_local_membership_updates: bool,
) -> PyResult<(
PersistEffects,
Vec<usize>,
HashMap<usize, String>,
Vec<usize>,
)> {
let prepared = Arc::clone(&prepared.prepared);
if !matches!(&*prepared, Prepared::Chunk(_)) {
return Err(PyRuntimeError::new_err("the chunk was prepared in Python"));
}
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 inner = Arc::clone(&self.inner);
let future = async move {
let Prepared::Chunk(chunk) = &*prepared else {
unreachable!("checked above");
};
let cache_stream = CacheStream::new(Arc::clone(&inner.cache_stream));
let input = TxnInput {
room_id: &room_id,
events: &events,
prepared: chunk,
instance_name: &inner.instance_name,
server_name: &inner.server_name,
clock: inner.runtime.clock(),
inhibit_local_membership_updates,
enable_search: inner.enable_search,
enable_ephemeral_messages: inner.enable_ephemeral_messages,
cache_stream: &cache_stream,
};
let position = |event: &TxnEvent| {
input
.events
.iter()
.position(|x| std::ptr::eq(x, event))
.expect("an event of the chunk")
};
let result = run_write_interaction(&pool, "persist_events", |txn, _| {
let (input, position) = (&input, &position);
async move {
let mut effects = PersistEffects::default();
let prefix = txn::persist_events_txn(txn, input, &mut effects).await?;
let left = prefix.events.iter().map(|e| position(e.source)).collect();
let mut redacted = HashMap::new();
for event in &prefix.events {
if let Some(copy) = &event.redacted {
let by = copy
.internal_metadata()
.redacted_by()
.map_err(anyhow::Error::new)?
.context("a redacted copy without redacted_by")?;
redacted.insert(position(event.source), by);
}
}
let de_outliered = prefix.de_outliered.iter().map(|e| position(e)).collect();
Ok((effects, left, redacted, de_outliered))
}
.boxed()
})
.await
.map_err(write_error_to_py)?;
cache_stream.hand_over(&result.0);
Ok(result)
};
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 {
/// Shared with the transaction that writes the chunk.
prepared: Arc<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 a persist transaction to the Python exception the
/// Python transaction would raise. See [`persist_error_to_py`].
pub fn write_error_to_py(e: WriteError) -> PyErr {
match e {
WriteError::Other(e) => persist_error_to_py(e),
e => persist_error_to_py(anyhow::Error::new(e)),
}
}
/// 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 e.downcast_ref::<PartialStateConflict>().is_some() {
return match py_partial_state_conflict_error() {
Ok(err) | Err(err) => err,
};
}
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:#}"))
}
/// A `PartialStateConflictError`, as the Python transaction raises.
fn py_partial_state_conflict_error() -> PyResult<PyErr> {
crate::gil::with_gil("persist_partial_state_conflict", |py| {
let error = py
.import("synapse.api.errors")?
.getattr("PartialStateConflictError")?
.call0()?;
Ok(PyErr::from_value(error))
})
}
/// `(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 = json::encode_object(&event.internal_metadata().to_persistence_pairs()?);
let json = event.with_persistence_dict(|dict| json::encode(dict))?;
let to_py = |e: serde_json::Error| PyRuntimeError::new_err(format!("encoding: {e}"));
Ok((internal_metadata.map_err(to_py)?, json.map_err(to_py)?))
}
/// 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)
}