mirror of
https://github.com/element-hq/synapse.git
synced 2026-11-04 06:57:20 +00:00
Persist each queued batch in Rust at rust_event_persistence: full
At `full`, `_persist_event_batch` hands the batch to `Persister.persist_batch` and steps through it with `batch_next`: - The first step drops the events already persisted under the same transaction ID (`get_already_persisted_events`). - Each step prepares the next chunk of up to 100 events, marks the batch's state groups as being persisted (`persisting_state_group_references`, two short native transactions), takes the chunk's stream orderings and writes it. - Python applies what the chunk asks for, runs `_after_persisting_events` and finishes the orderings before the next step, which first releases the state groups. So each chunk is prepared against current caches. - If Python cannot apply a chunk, `batch_abort` releases the state groups as a failed persist does. A chunk Rust cannot prepare (a state resolution fallback, or an error) is handed back with the rest of the batch to the chunk-by-chunk path, which prepares it again and falls back or raises as at lower levels. Python drives the steps, rather than Rust calling back into Python, so every step has a `_blocking` twin for tests and nothing waits on the reactor from Rust. That costs the same hops as a callback: one in and one out per chunk. The prepare inputs are now built from `Event`s that share their data with the Python events, so a chunk's input can be built without the GIL once the batch has been deduplicated. The persister's Deferreds already follow the logcontext rules, so they are awaited directly. Wrapping them in `make_deferred_yieldable` again, as the `prepare_chunk`, `persist_events_txn` and `persist_chunk` call sites did, resumed the caller in the sentinel context. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXMkcZVkcZhi21augBff4a
This commit is contained in:
1 parent
68cc9e1b64
commit
ea11b6bb2d
11 files changed
+1434
-224
No files matched your search
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
* 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>.
|
||||
*
|
||||
*/
|
||||
|
||||
//! A queued batch of one room's events, as `_persist_event_batch` persists
|
||||
//! it: the events already persisted under the same transaction ID are
|
||||
//! dropped, the rest go in chunks of [`CHUNK_SIZE`], and each chunk is
|
||||
//! written while its state groups are held back from deletion.
|
||||
//!
|
||||
//! The loop itself is [`super::python::Persister::persist_batch`], which
|
||||
//! hands each written chunk to Python. This holds its steps.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use futures::FutureExt;
|
||||
|
||||
use super::context::PersistContext;
|
||||
use crate::{
|
||||
clock::Clock,
|
||||
database::postgres::{
|
||||
pool::ConnectionPool,
|
||||
write::{run_write_interaction, Column, WriteError},
|
||||
},
|
||||
events::Event,
|
||||
storage::events::Querier,
|
||||
};
|
||||
|
||||
/// How many events `_persist_event_batch` writes in one transaction.
|
||||
pub const CHUNK_SIZE: usize = 100;
|
||||
|
||||
/// `get_already_persisted_events`: for each event sent with a transaction
|
||||
/// ID from a device, the event already persisted with the same room,
|
||||
/// sender, device and transaction ID, if there is one. An event that
|
||||
/// repeats an earlier one of the batch maps to that one.
|
||||
pub async fn already_persisted_events(
|
||||
querier: &impl Querier,
|
||||
events: &[(Event, PersistContext)],
|
||||
) -> anyhow::Result<HashMap<String, String>> {
|
||||
let mut mapping = HashMap::new();
|
||||
let mut by_txn_id: HashMap<(String, String, String, String), String> = HashMap::new();
|
||||
for (event, _) in events {
|
||||
let metadata = event.internal_metadata();
|
||||
let (Some(device_id), Some(txn_id)) = (metadata.device_id()?, metadata.txn_id()?) else {
|
||||
continue;
|
||||
};
|
||||
// Python skips an empty device or transaction ID.
|
||||
if device_id.is_empty() || txn_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let key = (
|
||||
event.room_id().to_owned(),
|
||||
event.sender().to_owned(),
|
||||
device_id,
|
||||
txn_id,
|
||||
);
|
||||
if let Some(existing) = by_txn_id.get(&key) {
|
||||
mapping.insert(event.event_id().to_owned(), existing.clone());
|
||||
continue;
|
||||
}
|
||||
let rows = querier
|
||||
.query(
|
||||
"SELECT event_id FROM event_txn_id_device_id
|
||||
WHERE room_id = $1 AND user_id = $2 AND device_id = $3 AND txn_id = $4",
|
||||
&[&key.0, &key.1, &key.2, &key.3],
|
||||
)
|
||||
.await?;
|
||||
let existing: Option<String> = rows.first().map(|row| row.try_get(0)).transpose()?;
|
||||
match existing {
|
||||
Some(existing) => {
|
||||
mapping.insert(event.event_id().to_owned(), existing.clone());
|
||||
by_txn_id.insert(key, existing);
|
||||
}
|
||||
None => {
|
||||
by_txn_id.insert(key, event.event_id().to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(mapping)
|
||||
}
|
||||
|
||||
/// The state groups `persisting_state_group_references` holds back from
|
||||
/// deletion for `events`: those of each event that is neither rejected nor
|
||||
/// an outlier, and the groups before them.
|
||||
pub fn referenced_state_groups(
|
||||
events: &[(Event, PersistContext)],
|
||||
) -> anyhow::Result<BTreeSet<i64>> {
|
||||
let mut groups = BTreeSet::new();
|
||||
for (event, context) in events {
|
||||
if context.is_rejected() || event.internal_metadata().is_outlier()? {
|
||||
continue;
|
||||
}
|
||||
let group = context
|
||||
.state_group
|
||||
.ok_or_else(|| anyhow!("{} has no state group", event.event_id()))?;
|
||||
groups.insert(group);
|
||||
// Python skips a falsy group before the event.
|
||||
if let Some(before) = context.state_group_before_event.filter(|g| *g != 0) {
|
||||
groups.insert(before);
|
||||
}
|
||||
}
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
/// The state groups were deleted before they could be marked.
|
||||
#[derive(Debug)]
|
||||
pub struct StateGroupsDeleted(pub Vec<i64>);
|
||||
|
||||
impl std::fmt::Display for StateGroupsDeleted {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// As Python's `shortstr`.
|
||||
let shown: Vec<String> = self.0.iter().take(5).map(i64::to_string).collect();
|
||||
let more = if self.0.len() > 5 { ", ..." } else { "" };
|
||||
write!(
|
||||
f,
|
||||
"state groups have been deleted: [{}{more}]",
|
||||
shown.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StateGroupsDeleted {}
|
||||
|
||||
/// `_mark_state_groups_as_persisting_txn`: check the groups still exist,
|
||||
/// locking them against a concurrent delete, and record that this instance
|
||||
/// is persisting them.
|
||||
pub async fn mark_state_groups_as_persisting(
|
||||
pool: &ConnectionPool,
|
||||
clock: &Clock,
|
||||
instance_name: &str,
|
||||
groups: &BTreeSet<i64>,
|
||||
) -> Result<(), WriteError> {
|
||||
let groups: Vec<i64> = groups.iter().copied().collect();
|
||||
run_write_interaction(pool, "mark_state_groups_as_persisting", |txn, _| {
|
||||
let groups = &groups;
|
||||
async move {
|
||||
let rows = txn
|
||||
.query(
|
||||
"SELECT id FROM state_groups WHERE id = ANY($1) FOR KEY SHARE",
|
||||
&[groups],
|
||||
)
|
||||
.await?;
|
||||
let existing = rows
|
||||
.iter()
|
||||
.map(|row| row.try_get(0))
|
||||
.collect::<Result<HashSet<i64>, _>>()
|
||||
.map_err(anyhow::Error::new)?;
|
||||
let missing: Vec<i64> = groups
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|g| !existing.contains(g))
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
return Err(anyhow::Error::new(StateGroupsDeleted(missing)).into());
|
||||
}
|
||||
let now = i64::try_from(clock.now_millis()).map_err(anyhow::Error::new)?;
|
||||
let instance_names = vec![instance_name; groups.len()];
|
||||
let inserted = vec![now; groups.len()];
|
||||
txn.upsert_many(
|
||||
"state_groups_persisting",
|
||||
&[
|
||||
Column::new("state_group", "bigint", groups),
|
||||
Column::new("instance_name", "text", &instance_names),
|
||||
],
|
||||
&[Column::new("inserted_ts", "bigint", &inserted)],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
.boxed()
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// `_finish_persisting_txn`: this instance no longer persists the groups.
|
||||
/// If the persist failed, any pending deletion of them is pushed back, as
|
||||
/// they may now be referenced. Otherwise it is dropped, as they are.
|
||||
pub async fn finish_persisting(
|
||||
pool: &ConnectionPool,
|
||||
clock: &Clock,
|
||||
instance_name: &str,
|
||||
groups: &BTreeSet<i64>,
|
||||
error: bool,
|
||||
) -> Result<(), WriteError> {
|
||||
let groups: Vec<i64> = groups.iter().copied().collect();
|
||||
run_write_interaction(pool, "finish_persisting", |txn, _| {
|
||||
let groups = &groups;
|
||||
async move {
|
||||
txn.execute(
|
||||
"DELETE FROM state_groups_persisting
|
||||
WHERE instance_name = $1 AND state_group = ANY($2)",
|
||||
&[&instance_name, groups],
|
||||
)
|
||||
.await?;
|
||||
if error {
|
||||
// `_bump_deletion_txn`.
|
||||
let now = i64::try_from(clock.now_millis()).map_err(anyhow::Error::new)?;
|
||||
txn.execute(
|
||||
"UPDATE state_groups_pending_deletion
|
||||
SET sequence_number = DEFAULT, insertion_ts = $1
|
||||
WHERE state_group = ANY($2)",
|
||||
&[&now, groups],
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
txn.execute(
|
||||
"DELETE FROM state_groups_pending_deletion WHERE state_group = ANY($1)",
|
||||
&[groups],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.boxed()
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deleted_groups_are_shown_as_python_shows_them() {
|
||||
assert_eq!(
|
||||
StateGroupsDeleted(vec![1, 2]).to_string(),
|
||||
"state groups have been deleted: [1, 2]"
|
||||
);
|
||||
assert_eq!(
|
||||
StateGroupsDeleted((1..=6).collect()).to_string(),
|
||||
"state groups have been deleted: [1, 2, 3, 4, 5, ...]"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
//! This is being built up from the inside out (see
|
||||
//! `renders/event-persistence-rust-plan.md`). So far:
|
||||
//!
|
||||
//! - [`batch`]: the steps around a queued batch's chunks.
|
||||
//! - [`chain_cover`]: the chain cover index of a chunk's state events.
|
||||
//! - [`context`]: what persisting needs from an `EventContext`.
|
||||
//! - [`effects`]: the Python cache updates a Rust transaction asks for once
|
||||
@@ -38,6 +39,7 @@
|
||||
|
||||
use pyo3::prelude::*;
|
||||
|
||||
pub mod batch;
|
||||
pub mod chain_cover;
|
||||
pub mod context;
|
||||
pub mod effects;
|
||||
@@ -56,6 +58,9 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()>
|
||||
let child_module = PyModule::new(py, "persist")?;
|
||||
child_module.add_class::<python::Persister>()?;
|
||||
child_module.add_class::<python::PreparedChunk>()?;
|
||||
child_module.add_class::<python::PersistBatch>()?;
|
||||
child_module.add_class::<python::PersistedBatch>()?;
|
||||
child_module.add_class::<python::WrittenChunk>()?;
|
||||
child_module.add_class::<effects::PersistEffects>()?;
|
||||
child_module.add_class::<reader::PyPersistReaderForTest>()?;
|
||||
child_module.add_function(wrap_pyfunction!(
|
||||
|
||||
@@ -40,8 +40,6 @@ use std::{
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use super::{
|
||||
chain_cover::{self, ChainCoverEvent, NewEventChainLinks},
|
||||
context::PersistContext,
|
||||
@@ -59,8 +57,7 @@ use crate::{
|
||||
state::EventMap,
|
||||
};
|
||||
|
||||
/// A chunk of one room's events, in the shapes the steps read. Built while
|
||||
/// the GIL is held, so that the steps run without it.
|
||||
/// A chunk of one room's events, in the shapes the steps read.
|
||||
pub struct PrepareInput {
|
||||
pub room_id: String,
|
||||
pub backfilled: bool,
|
||||
@@ -74,17 +71,16 @@ pub struct PrepareInput {
|
||||
}
|
||||
|
||||
impl PrepareInput {
|
||||
/// The chunk `events_and_contexts`, which share their data with the
|
||||
/// Python events they were taken from.
|
||||
pub fn new(
|
||||
py: Python<'_>,
|
||||
room_id: String,
|
||||
events_and_contexts: &[(Py<Event>, PersistContext)],
|
||||
events_and_contexts: &[(Event, PersistContext)],
|
||||
backfilled: bool,
|
||||
) -> PyResult<Self> {
|
||||
let (events, event_map) = reader::chunk(py, events_and_contexts)?;
|
||||
let chunk_events: Vec<&Event> = events_and_contexts
|
||||
.iter()
|
||||
.map(|(event, _)| event.bind(py).get())
|
||||
.collect();
|
||||
) -> anyhow::Result<Self> {
|
||||
let (events, event_map) = reader::chunk(events_and_contexts)?;
|
||||
let chunk_events: Vec<&Event> =
|
||||
events_and_contexts.iter().map(|(event, _)| event).collect();
|
||||
Ok(PrepareInput {
|
||||
room_id,
|
||||
backfilled,
|
||||
|
||||
+555
-116
@@ -17,7 +17,7 @@
|
||||
//! `RustHandlers.persister` hands out, and helpers for tests.
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
collections::{BTreeSet, HashMap, HashSet},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ use http::StatusCode;
|
||||
use pyo3::{exceptions::PyRuntimeError, intern, prelude::*, types::PyDict};
|
||||
|
||||
use super::{
|
||||
batch,
|
||||
context::PersistContext,
|
||||
effects::PersistEffects,
|
||||
extremities::MissingPrevStateEvents,
|
||||
@@ -186,83 +187,25 @@ impl Persister {
|
||||
if !matches!(&*prepared, Prepared::Chunk(_)) {
|
||||
return Err(PyRuntimeError::new_err("the chunk was prepared in Python"));
|
||||
}
|
||||
let events: Vec<(Event, PersistContext)> = events_and_contexts
|
||||
.into_iter()
|
||||
.map(|(event, context)| (event.bind(py).get().shallow_clone(), context))
|
||||
.collect();
|
||||
let events = shallow_events(py, events_and_contexts);
|
||||
let pool = self.inner.pool.get()?;
|
||||
let inner = Arc::clone(&self.inner);
|
||||
let future = async move {
|
||||
Ok(async move {
|
||||
let Prepared::Chunk(chunk) = &*prepared else {
|
||||
unreachable!("checked above");
|
||||
};
|
||||
// Taken as late as possible, as Python takes them: until the
|
||||
// chunk is written, no later event on the stream can be
|
||||
// announced.
|
||||
let event_stream = match orderings {
|
||||
StreamOrderings::Set => None,
|
||||
StreamOrderings::Allocate { backfilled } => {
|
||||
let stream = if backfilled {
|
||||
TxnStream::backfill(Arc::clone(&inner.backfill_stream))
|
||||
} else {
|
||||
TxnStream::events(Arc::clone(&inner.events_stream))
|
||||
};
|
||||
let ids = allocate_stream_orderings(&pool, &stream, events.len())
|
||||
.await
|
||||
.map_err(persist_error_to_py)?;
|
||||
set_persisted_positions(&inner, &events, &ids, backfilled)
|
||||
.map_err(persist_error_to_py)?;
|
||||
Some((stream, ids, backfilled))
|
||||
}
|
||||
};
|
||||
let events = events
|
||||
.into_iter()
|
||||
.map(|(event, context)| TxnEvent::new(event, context))
|
||||
.collect::<anyhow::Result<Vec<_>>>()
|
||||
.map_err(persist_error_to_py)?;
|
||||
|
||||
let cache_stream = TxnStream::caches(Arc::clone(&inner.cache_stream));
|
||||
let sticky_stream = TxnStream::sticky_events(Arc::clone(&inner.sticky_stream));
|
||||
let profile_stream = TxnStream::profile_updates(Arc::clone(&inner.profile_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(),
|
||||
write_chunk(
|
||||
&inner,
|
||||
&pool,
|
||||
&room_id,
|
||||
events,
|
||||
chunk,
|
||||
orderings,
|
||||
inhibit_local_membership_updates,
|
||||
enable_search: inner.enable_search,
|
||||
enable_ephemeral_messages: inner.enable_ephemeral_messages,
|
||||
msc4354_enabled: inner.msc4354_enabled,
|
||||
include_profile_updates_in_sync: inner.include_profile_updates_in_sync,
|
||||
cache_stream: &cache_stream,
|
||||
sticky_stream: &sticky_stream,
|
||||
profile_stream: &profile_stream,
|
||||
};
|
||||
let mut effects = run_write_interaction(&pool, "persist_events", |txn, _| {
|
||||
let input = &input;
|
||||
async move {
|
||||
let mut effects = PersistEffects::default();
|
||||
txn::persist_events_txn(txn, input, &mut effects).await?;
|
||||
Ok(effects)
|
||||
}
|
||||
.boxed()
|
||||
})
|
||||
)
|
||||
.await
|
||||
.map_err(write_error_to_py)?;
|
||||
cache_stream.hand_over(effects.cache_stream_ids());
|
||||
sticky_stream.hand_over(effects.sticky_stream_ids());
|
||||
profile_stream.hand_over(effects.profile_stream_ids());
|
||||
// Finished on drop if the transaction failed, as `get_next_mult`
|
||||
// finishes them when its block raises.
|
||||
if let Some((stream, ids, backfilled)) = event_stream {
|
||||
stream.hand_over(&ids);
|
||||
effects.add_stream_ids(ids, backfilled);
|
||||
}
|
||||
Ok(effects)
|
||||
};
|
||||
Ok(future.boxed())
|
||||
}
|
||||
.boxed())
|
||||
}
|
||||
|
||||
/// Convert the chunk while the GIL is held, and return the future that
|
||||
@@ -275,59 +218,39 @@ impl Persister {
|
||||
backfilled: bool,
|
||||
shadow: bool,
|
||||
) -> PyResult<BoxFuture<'static, PyResult<PreparedChunk>>> {
|
||||
let input = PrepareInput::new(py, room_id, &events_and_contexts, backfilled)?;
|
||||
let events = shallow_events(py, events_and_contexts);
|
||||
let input = PrepareInput::new(room_id, &events, backfilled).map_err(persist_error_to_py)?;
|
||||
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,
|
||||
)
|
||||
let (prepared, counts) = prepare_chunk(&inner, &pool, &input, shadow)
|
||||
.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)),
|
||||
}
|
||||
}
|
||||
.map_err(persist_error_to_py)?;
|
||||
Ok(PreparedChunk {
|
||||
prepared: Arc::new(prepared),
|
||||
counts: Mutex::new(Some(counts)),
|
||||
})
|
||||
}
|
||||
.boxed())
|
||||
}
|
||||
|
||||
/// The future of the batch's next step. See [`Self::batch_next`].
|
||||
fn start_batch_next(&self, batch: &PersistBatch) -> BoxFuture<'static, PyResult<BatchStep>> {
|
||||
let state = Arc::clone(&batch.state);
|
||||
async move { batch_next(&mut *state.lock().await).await }.boxed()
|
||||
}
|
||||
|
||||
/// The future that releases the batch. See [`Self::batch_abort`].
|
||||
fn start_batch_abort(&self, batch: &PersistBatch) -> BoxFuture<'static, PyResult<()>> {
|
||||
let state = Arc::clone(&batch.state);
|
||||
async move {
|
||||
let mut state = state.lock().await;
|
||||
state.done = true;
|
||||
state.release_guard(true).await
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
|
||||
/// Run `future` to completion on the runtime, releasing the GIL. For the
|
||||
/// `_blocking` entry points.
|
||||
fn block_on<T: Send>(
|
||||
@@ -481,6 +404,74 @@ impl Persister {
|
||||
self.block_on(py, future)
|
||||
}
|
||||
|
||||
/// `_persist_event_batch` for a queued batch of `room_id`'s events,
|
||||
/// one chunk at a time: Python awaits [`Self::batch_next`] until it
|
||||
/// resolves with a [`PersistedBatch`], applying the effects of each
|
||||
/// [`WrittenChunk`] before the next step, and calls
|
||||
/// [`Self::batch_abort`] if it cannot. Converts the events, without
|
||||
/// I/O.
|
||||
#[pyo3(signature = (room_id, events_and_contexts, *, backfilled))]
|
||||
fn persist_batch(
|
||||
&self,
|
||||
py: Python<'_>,
|
||||
room_id: String,
|
||||
events_and_contexts: Vec<(Py<Event>, PersistContext)>,
|
||||
backfilled: bool,
|
||||
) -> PyResult<PersistBatch> {
|
||||
let state = BatchState {
|
||||
inner: Arc::clone(&self.inner),
|
||||
pool: self.inner.pool.get()?,
|
||||
room_id,
|
||||
backfilled,
|
||||
events: shallow_events(py, events_and_contexts),
|
||||
deduplicated: None,
|
||||
next: 0,
|
||||
guard_held: false,
|
||||
done: false,
|
||||
};
|
||||
Ok(PersistBatch {
|
||||
state: Arc::new(tokio::sync::Mutex::new(state)),
|
||||
})
|
||||
}
|
||||
|
||||
/// The batch's next step: a Deferred resolving to the [`WrittenChunk`]
|
||||
/// written, or to the [`PersistedBatch`] once the batch is done, or
|
||||
/// failing as the Python path would. The chunk's stream IDs are handed
|
||||
/// to Python unfinished, so Python must not cancel the Deferred: if it
|
||||
/// is cancelled, the step runs on and the IDs are lost. If the step
|
||||
/// fails, Rust finishes them and releases the state groups.
|
||||
fn batch_next<'py>(
|
||||
&self,
|
||||
py: Python<'py>,
|
||||
batch: &PersistBatch,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let future = self.start_batch_next(batch);
|
||||
create_deferred_with(py, &self.inner.runtime, future, OnCancel::RunOn)
|
||||
}
|
||||
|
||||
/// As [`Self::batch_next`], but blocks.
|
||||
fn batch_next_blocking(&self, py: Python<'_>, batch: &PersistBatch) -> PyResult<BatchStep> {
|
||||
let future = self.start_batch_next(batch);
|
||||
self.block_on(py, future)
|
||||
}
|
||||
|
||||
/// Give up the batch, as Python could not apply a chunk's effects:
|
||||
/// release its state groups as a failed persist does.
|
||||
fn batch_abort<'py>(
|
||||
&self,
|
||||
py: Python<'py>,
|
||||
batch: &PersistBatch,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let future = self.start_batch_abort(batch);
|
||||
create_deferred_with(py, &self.inner.runtime, future, OnCancel::RunOn)
|
||||
}
|
||||
|
||||
/// As [`Self::batch_abort`], but blocks.
|
||||
fn batch_abort_blocking(&self, py: Python<'_>, batch: &PersistBatch) -> PyResult<()> {
|
||||
let future = self.start_batch_abort(batch);
|
||||
self.block_on(py, future)
|
||||
}
|
||||
|
||||
/// As [`Self::prepare_chunk`], but blocks.
|
||||
#[pyo3(signature = (room_id, events_and_contexts, backfilled, *, shadow))]
|
||||
fn prepare_chunk_blocking(
|
||||
@@ -497,6 +488,416 @@ impl Persister {
|
||||
}
|
||||
}
|
||||
|
||||
/// The events of `events_and_contexts`, sharing their data, internal
|
||||
/// metadata included, with the Python events, so that Rust can read them
|
||||
/// without the GIL and set their stream orderings.
|
||||
fn shallow_events(
|
||||
py: Python<'_>,
|
||||
events_and_contexts: Vec<(Py<Event>, PersistContext)>,
|
||||
) -> Vec<(Event, PersistContext)> {
|
||||
events_and_contexts
|
||||
.into_iter()
|
||||
.map(|(event, context)| (event.bind(py).get().shallow_clone(), context))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Prepare the chunk `input`, with what it counted.
|
||||
async fn prepare_chunk(
|
||||
inner: &PersisterInner,
|
||||
pool: &ConnectionPool,
|
||||
input: &PrepareInput,
|
||||
shadow: bool,
|
||||
) -> anyhow::Result<(Prepared, PersistCounts)> {
|
||||
let resolver = NativeStateResolver {
|
||||
pool: pool.clone(),
|
||||
handle: inner.runtime.tokio_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((prepared, 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(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the chunk `events`, which Rust prepared as `chunk`, taking its
|
||||
/// stream orderings first if `orderings` says so. Resolves with the
|
||||
/// effects, whose stream IDs are unfinished.
|
||||
async fn write_chunk(
|
||||
inner: &PersisterInner,
|
||||
pool: &ConnectionPool,
|
||||
room_id: &str,
|
||||
events: Vec<(Event, PersistContext)>,
|
||||
chunk: &prepare::PreparedChunk,
|
||||
orderings: StreamOrderings,
|
||||
inhibit_local_membership_updates: bool,
|
||||
) -> PyResult<PersistEffects> {
|
||||
// Taken as late as possible, as Python takes them: until the chunk is
|
||||
// written, no later event on the stream can be announced.
|
||||
let event_stream = match orderings {
|
||||
StreamOrderings::Set => None,
|
||||
StreamOrderings::Allocate { backfilled } => {
|
||||
let stream = if backfilled {
|
||||
TxnStream::backfill(Arc::clone(&inner.backfill_stream))
|
||||
} else {
|
||||
TxnStream::events(Arc::clone(&inner.events_stream))
|
||||
};
|
||||
let ids = allocate_stream_orderings(pool, &stream, events.len())
|
||||
.await
|
||||
.map_err(persist_error_to_py)?;
|
||||
set_persisted_positions(inner, &events, &ids, backfilled)
|
||||
.map_err(persist_error_to_py)?;
|
||||
Some((stream, ids, backfilled))
|
||||
}
|
||||
};
|
||||
let events = events
|
||||
.into_iter()
|
||||
.map(|(event, context)| TxnEvent::new(event, context))
|
||||
.collect::<anyhow::Result<Vec<_>>>()
|
||||
.map_err(persist_error_to_py)?;
|
||||
|
||||
let cache_stream = TxnStream::caches(Arc::clone(&inner.cache_stream));
|
||||
let sticky_stream = TxnStream::sticky_events(Arc::clone(&inner.sticky_stream));
|
||||
let profile_stream = TxnStream::profile_updates(Arc::clone(&inner.profile_stream));
|
||||
let input = TxnInput {
|
||||
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,
|
||||
msc4354_enabled: inner.msc4354_enabled,
|
||||
include_profile_updates_in_sync: inner.include_profile_updates_in_sync,
|
||||
cache_stream: &cache_stream,
|
||||
sticky_stream: &sticky_stream,
|
||||
profile_stream: &profile_stream,
|
||||
};
|
||||
let mut effects = run_write_interaction(pool, "persist_events", |txn, _| {
|
||||
let input = &input;
|
||||
async move {
|
||||
let mut effects = PersistEffects::default();
|
||||
txn::persist_events_txn(txn, input, &mut effects).await?;
|
||||
Ok(effects)
|
||||
}
|
||||
.boxed()
|
||||
})
|
||||
.await
|
||||
.map_err(write_error_to_py)?;
|
||||
cache_stream.hand_over(effects.cache_stream_ids());
|
||||
sticky_stream.hand_over(effects.sticky_stream_ids());
|
||||
profile_stream.hand_over(effects.profile_stream_ids());
|
||||
// Finished on drop if the transaction failed, as `get_next_mult`
|
||||
// finishes them when its block raises.
|
||||
if let Some((stream, ids, backfilled)) = event_stream {
|
||||
stream.hand_over(&ids);
|
||||
effects.add_stream_ids(ids, backfilled);
|
||||
}
|
||||
Ok(effects)
|
||||
}
|
||||
|
||||
/// A queued batch of one room's events, persisted chunk by chunk by
|
||||
/// [`batch_next`]. See [`Persister::persist_batch`].
|
||||
struct BatchState {
|
||||
inner: Arc<PersisterInner>,
|
||||
pool: ConnectionPool,
|
||||
room_id: String,
|
||||
backfilled: bool,
|
||||
events: Vec<(Event, PersistContext)>,
|
||||
/// Set by the first step.
|
||||
deduplicated: Option<Deduplicated>,
|
||||
/// The position in `kept` of the next chunk.
|
||||
next: usize,
|
||||
/// The state groups are marked as being persisted for the chunk last
|
||||
/// handed to Python, which the next step or [`batch_abort`] releases.
|
||||
guard_held: bool,
|
||||
/// The batch has been persisted, handed back or failed.
|
||||
done: bool,
|
||||
}
|
||||
|
||||
/// The batch once the events already persisted are dropped.
|
||||
struct Deduplicated {
|
||||
replaced_events: HashMap<String, String>,
|
||||
/// The positions in the batch of the events to persist.
|
||||
kept: Vec<usize>,
|
||||
/// What `persisting_state_group_references` holds back from deletion
|
||||
/// around each chunk: the groups of the whole batch, as in Python.
|
||||
state_groups: BTreeSet<i64>,
|
||||
}
|
||||
|
||||
impl BatchState {
|
||||
fn shallow(&self, i: usize) -> (Event, PersistContext) {
|
||||
(self.events[i].0.shallow_clone(), self.events[i].1.clone())
|
||||
}
|
||||
|
||||
/// `_finish_persisting_txn` for the chunk last written, if its state
|
||||
/// groups are still marked.
|
||||
async fn release_guard(&mut self, error: bool) -> PyResult<()> {
|
||||
if !std::mem::take(&mut self.guard_held) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(deduplicated) = &self.deduplicated else {
|
||||
return Ok(());
|
||||
};
|
||||
batch::finish_persisting(
|
||||
&self.pool,
|
||||
self.inner.runtime.clock(),
|
||||
&self.inner.instance_name,
|
||||
&deduplicated.state_groups,
|
||||
error,
|
||||
)
|
||||
.await
|
||||
.map_err(write_error_to_py)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BatchState {
|
||||
/// A batch dropped with its guard held, which Python should not let
|
||||
/// happen, releases it as a failure, as Python's `finally` would.
|
||||
fn drop(&mut self) {
|
||||
if !self.guard_held {
|
||||
return;
|
||||
}
|
||||
let (Some(deduplicated), Ok(handle)) =
|
||||
(self.deduplicated.take(), self.inner.runtime.tokio_handle())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let inner = Arc::clone(&self.inner);
|
||||
let pool = self.pool.clone();
|
||||
handle.spawn(async move {
|
||||
let result = batch::finish_persisting(
|
||||
&pool,
|
||||
inner.runtime.clock(),
|
||||
&inner.instance_name,
|
||||
&deduplicated.state_groups,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
log::warn!("Failed to release the state groups of a dropped batch: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// What one [`batch_next`] step did.
|
||||
enum BatchStep {
|
||||
/// A chunk was written, and its effects are Python's to apply.
|
||||
Written(WrittenChunk),
|
||||
/// The batch is done.
|
||||
Done(PersistedBatch),
|
||||
}
|
||||
|
||||
impl<'py> IntoPyObject<'py> for BatchStep {
|
||||
type Target = PyAny;
|
||||
type Output = Bound<'py, PyAny>;
|
||||
type Error = PyErr;
|
||||
|
||||
fn into_pyobject(self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
|
||||
match self {
|
||||
BatchStep::Written(chunk) => Ok(Bound::new(py, chunk)?.into_any()),
|
||||
BatchStep::Done(batch) => Ok(Bound::new(py, batch)?.into_any()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One step of `_persist_event_batch` for `state`. The first step drops the
|
||||
/// events already persisted under the same transaction ID
|
||||
/// (`get_already_persisted_events`). Each step releases the state groups
|
||||
/// of the chunk before it, then prepares the next chunk, marks the batch's
|
||||
/// state groups as being persisted, takes the chunk's stream orderings and
|
||||
/// writes it.
|
||||
///
|
||||
/// A chunk Rust cannot prepare, because its state resolution falls back to
|
||||
/// Python or preparing it fails, is handed back with the rest of the batch
|
||||
/// in [`PersistedBatch::remaining`], unwritten.
|
||||
async fn batch_next(state: &mut BatchState) -> PyResult<BatchStep> {
|
||||
if state.done {
|
||||
return Err(PyRuntimeError::new_err("the batch is done"));
|
||||
}
|
||||
let result = batch_step(state).await;
|
||||
if !matches!(result, Ok(BatchStep::Written(_))) {
|
||||
state.done = true;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn batch_step(state: &mut BatchState) -> PyResult<BatchStep> {
|
||||
if state.deduplicated.is_none() {
|
||||
// Retried as Python's `runInteraction`s are.
|
||||
let mut attempt = 1;
|
||||
let replaced_events = loop {
|
||||
let result = async {
|
||||
let querier = PoolQuerier::checkout(&state.pool).await?;
|
||||
batch::already_persisted_events(&querier, &state.events).await
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Err(e) if attempt < MAX_ATTEMPTS && is_retryable_error(&e) => {
|
||||
log::warn!(
|
||||
"[TXN RETRY] {{get_already_persisted_events}} {e:#} {attempt}/{MAX_ATTEMPTS}"
|
||||
);
|
||||
attempt += 1;
|
||||
}
|
||||
result => break result.map_err(persist_error_to_py)?,
|
||||
}
|
||||
};
|
||||
let kept: Vec<usize> = (0..state.events.len())
|
||||
.filter(|&i| !replaced_events.contains_key(state.events[i].0.event_id()))
|
||||
.collect();
|
||||
let kept_events: Vec<_> = kept.iter().map(|&i| state.shallow(i)).collect();
|
||||
let state_groups =
|
||||
batch::referenced_state_groups(&kept_events).map_err(persist_error_to_py)?;
|
||||
state.deduplicated = Some(Deduplicated {
|
||||
replaced_events,
|
||||
kept,
|
||||
state_groups,
|
||||
});
|
||||
}
|
||||
state.release_guard(false).await?;
|
||||
|
||||
let deduplicated = state.deduplicated.as_ref().expect("set above");
|
||||
let start = state.next;
|
||||
if start >= deduplicated.kept.len() {
|
||||
return Ok(BatchStep::Done(PersistedBatch {
|
||||
replaced_events: deduplicated.replaced_events.clone(),
|
||||
remaining: Vec::new(),
|
||||
fallback_reason: None,
|
||||
}));
|
||||
}
|
||||
let end = (start + batch::CHUNK_SIZE).min(deduplicated.kept.len());
|
||||
let indices = deduplicated.kept[start..end].to_vec();
|
||||
let chunk: Vec<(Event, PersistContext)> = indices.iter().map(|&i| state.shallow(i)).collect();
|
||||
let hand_back = |reason: String| {
|
||||
BatchStep::Done(PersistedBatch {
|
||||
replaced_events: deduplicated.replaced_events.clone(),
|
||||
remaining: deduplicated.kept[start..].to_vec(),
|
||||
fallback_reason: Some(reason),
|
||||
})
|
||||
};
|
||||
|
||||
let inner = &state.inner;
|
||||
let pool = &state.pool;
|
||||
let prepared = async {
|
||||
let input = PrepareInput::new(state.room_id.clone(), &chunk, state.backfilled)?;
|
||||
prepare_chunk(inner, pool, &input, false).await
|
||||
}
|
||||
.await;
|
||||
let (prepared, counts) = match prepared {
|
||||
Ok((Prepared::StateResFallback(reason), _)) => return Ok(hand_back(reason)),
|
||||
// The chunk path prepares it again, and raises or falls back to
|
||||
// Python as the error and the strictness say.
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to prepare events for {} in Rust, handing them back: {e:#}",
|
||||
state.room_id
|
||||
);
|
||||
return Ok(hand_back(format!("{e:#}")));
|
||||
}
|
||||
Ok(prepared) => prepared,
|
||||
};
|
||||
inner.metrics.record(|total| total.add(counts));
|
||||
let prepared = Arc::new(prepared);
|
||||
let Prepared::Chunk(prepared_chunk) = &*prepared else {
|
||||
unreachable!("handed back above");
|
||||
};
|
||||
|
||||
let guarded = !deduplicated.state_groups.is_empty();
|
||||
if guarded {
|
||||
batch::mark_state_groups_as_persisting(
|
||||
pool,
|
||||
inner.runtime.clock(),
|
||||
&inner.instance_name,
|
||||
&deduplicated.state_groups,
|
||||
)
|
||||
.await
|
||||
.map_err(write_error_to_py)?;
|
||||
state.guard_held = true;
|
||||
}
|
||||
let written = write_chunk(
|
||||
inner,
|
||||
pool,
|
||||
&state.room_id,
|
||||
chunk,
|
||||
prepared_chunk,
|
||||
StreamOrderings::Allocate {
|
||||
backfilled: state.backfilled,
|
||||
},
|
||||
state.backfilled,
|
||||
)
|
||||
.await;
|
||||
let effects = match written {
|
||||
Ok(effects) => effects,
|
||||
Err(e) => {
|
||||
// As Python's `finally`, whose error replaces the persist's.
|
||||
state.release_guard(true).await?;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
state.next = end;
|
||||
Ok(BatchStep::Written(WrittenChunk {
|
||||
effects,
|
||||
prepared,
|
||||
indices,
|
||||
}))
|
||||
}
|
||||
|
||||
/// [`batch_next`]'s chunk, written, whose effects Python must apply.
|
||||
#[pyclass(frozen)]
|
||||
pub struct WrittenChunk {
|
||||
effects: PersistEffects,
|
||||
prepared: Arc<Prepared>,
|
||||
indices: Vec<usize>,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl WrittenChunk {
|
||||
/// What Python applies with `apply_rust_persist_effects`, with the
|
||||
/// chunk's stream IDs unfinished.
|
||||
#[getter]
|
||||
fn effects(&self) -> PersistEffects {
|
||||
self.effects.clone()
|
||||
}
|
||||
|
||||
/// What Rust prepared for the chunk.
|
||||
#[getter]
|
||||
fn prepared(&self) -> PreparedChunk {
|
||||
PreparedChunk {
|
||||
prepared: Arc::clone(&self.prepared),
|
||||
// Counted when it was prepared.
|
||||
counts: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The chunk's positions in the batch.
|
||||
#[getter]
|
||||
fn indices(&self) -> Vec<usize> {
|
||||
self.indices.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the stream orderings of a chunk come from.
|
||||
#[derive(Clone, Copy)]
|
||||
enum StreamOrderings {
|
||||
@@ -560,6 +961,44 @@ fn set_persisted_positions(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A batch being persisted. See [`Persister::persist_batch`].
|
||||
#[pyclass(frozen)]
|
||||
pub struct PersistBatch {
|
||||
state: Arc<tokio::sync::Mutex<BatchState>>,
|
||||
}
|
||||
|
||||
/// What [`Persister::persist_batch`] did, once the batch is done.
|
||||
#[pyclass(frozen)]
|
||||
pub struct PersistedBatch {
|
||||
replaced_events: HashMap<String, String>,
|
||||
remaining: Vec<usize>,
|
||||
fallback_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PersistedBatch {
|
||||
/// The events not persisted as they repeat an event already persisted
|
||||
/// with the same transaction ID, mapped to that event.
|
||||
#[getter]
|
||||
fn replaced_events(&self) -> HashMap<String, String> {
|
||||
self.replaced_events.clone()
|
||||
}
|
||||
|
||||
/// The positions in the batch of the events left for Python to
|
||||
/// persist, in order: the chunk Rust could not prepare and those after
|
||||
/// it. Empty if Rust persisted them all.
|
||||
#[getter]
|
||||
fn remaining(&self) -> Vec<usize> {
|
||||
self.remaining.clone()
|
||||
}
|
||||
|
||||
/// Why Rust left the `remaining` events.
|
||||
#[getter]
|
||||
fn fallback_reason(&self) -> Option<String> {
|
||||
self.fallback_reason.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -727,7 +727,7 @@ impl PyPersistReaderForTest {
|
||||
now_ms: i64,
|
||||
state_dag: bool,
|
||||
) -> PyResult<(StateAndExtremities, PersistCounts)> {
|
||||
let (events, event_map) = chunk(py, &events_and_contexts)?;
|
||||
let (events, event_map) = py_chunk(py, &events_and_contexts)?;
|
||||
let resolver = self.resolver();
|
||||
self.run(py, |reader| {
|
||||
async move {
|
||||
@@ -1199,7 +1199,7 @@ impl PyPersistReaderForTest {
|
||||
HashSet<String>,
|
||||
Bound<'py, PyDict>,
|
||||
)> {
|
||||
let (events, event_map) = chunk(py, &events_and_contexts)?;
|
||||
let (events, event_map) = py_chunk(py, &events_and_contexts)?;
|
||||
let old: HashSet<Arc<str>> = arcs(old_latest_event_ids).into_iter().collect();
|
||||
let new: HashSet<Arc<str>> = arcs(new_latest_event_ids).into_iter().collect();
|
||||
let resolver = self.resolver();
|
||||
@@ -1266,7 +1266,7 @@ impl PyPersistReaderForTest {
|
||||
to_delete: Vec<(String, String)>,
|
||||
to_insert: HashMap<(String, String), String>,
|
||||
) -> PyResult<bool> {
|
||||
let (events, _) = chunk(py, &events_and_contexts)?;
|
||||
let (events, _) = py_chunk(py, &events_and_contexts)?;
|
||||
let delta = DeltaState {
|
||||
to_delete: to_delete
|
||||
.iter()
|
||||
@@ -1523,14 +1523,15 @@ fn state_map(map: HashMap<(String, String), String>) -> StateMap {
|
||||
/// The chunk's events as the computation reads them, and as state
|
||||
/// resolution's map of events in flight.
|
||||
pub(super) fn chunk(
|
||||
py: Python<'_>,
|
||||
events_and_contexts: &[(Py<Event>, PersistContext)],
|
||||
) -> PyResult<(Vec<NewEvent>, EventMap)> {
|
||||
let events = new_events(py, events_and_contexts)?;
|
||||
events_and_contexts: &[(Event, PersistContext)],
|
||||
) -> anyhow::Result<(Vec<NewEvent>, EventMap)> {
|
||||
let events = events_and_contexts
|
||||
.iter()
|
||||
.map(|(event, context)| NewEvent::new(event, context))
|
||||
.collect::<anyhow::Result<_>>()?;
|
||||
let event_map = events_and_contexts
|
||||
.iter()
|
||||
.map(|(event, _)| {
|
||||
let event = event.bind(py).get();
|
||||
(
|
||||
Arc::from(event.event_id()),
|
||||
StateResEvent::new(event.shallow_clone()),
|
||||
@@ -1540,6 +1541,18 @@ pub(super) fn chunk(
|
||||
Ok((events, event_map))
|
||||
}
|
||||
|
||||
/// [`chunk`] of the Python events, for the test entry points.
|
||||
fn py_chunk(
|
||||
py: Python<'_>,
|
||||
events_and_contexts: &[(Py<Event>, PersistContext)],
|
||||
) -> PyResult<(Vec<NewEvent>, EventMap)> {
|
||||
let events: Vec<(Event, PersistContext)> = events_and_contexts
|
||||
.iter()
|
||||
.map(|(event, context)| (event.bind(py).get().shallow_clone(), context.clone()))
|
||||
.collect();
|
||||
chunk(&events).map_err(persist_error_to_py)
|
||||
}
|
||||
|
||||
fn new_events(
|
||||
py: Python<'_>,
|
||||
events_and_contexts: &[(Py<Event>, PersistContext)],
|
||||
|
||||
@@ -415,8 +415,9 @@ class ExperimentalConfig(Config):
|
||||
# - "prepare": Rust's results for that are used.
|
||||
# - "transaction": Rust also runs the write transaction.
|
||||
# - "full": Rust persists each queued batch.
|
||||
# The levels are being built up in that order. So far "full" only
|
||||
# has Rust take the stream orderings of each chunk as well.
|
||||
# The levels are being built up in that order. So far at "full",
|
||||
# Python still applies what each chunk Rust writes asks for and runs
|
||||
# what follows it, and recalculates current state on request.
|
||||
rust_event_persistence = experimental.get("rust_event_persistence", "off")
|
||||
# YAML reads an unquoted `off` as a boolean.
|
||||
if rust_event_persistence is False:
|
||||
|
||||
@@ -82,7 +82,13 @@ from synapse.storage.databases.main.events import (
|
||||
persist_event_counter,
|
||||
)
|
||||
from synapse.storage.databases.main.events_worker import EventRedactBehaviour
|
||||
from synapse.synapse_rust.persist import Persister, PreparedChunk
|
||||
from synapse.storage.databases.main.rust_persist import finish_rust_persist_stream_ids
|
||||
from synapse.synapse_rust.persist import (
|
||||
PersistBatch,
|
||||
Persister,
|
||||
PreparedChunk,
|
||||
WrittenChunk,
|
||||
)
|
||||
from synapse.types import (
|
||||
PersistedEventPosition,
|
||||
RoomStreamToken,
|
||||
@@ -90,7 +96,11 @@ from synapse.types import (
|
||||
get_domain_from_id,
|
||||
)
|
||||
from synapse.types.state import StateFilter
|
||||
from synapse.util.async_helpers import ObservableDeferred, yieldable_gather_results
|
||||
from synapse.util.async_helpers import (
|
||||
ObservableDeferred,
|
||||
delay_cancellation,
|
||||
yieldable_gather_results,
|
||||
)
|
||||
from synapse.util.metrics import Measure
|
||||
from synapse.util.stringutils import shortstr
|
||||
|
||||
@@ -871,6 +881,14 @@ class EventsPersistenceStorageController:
|
||||
if not events_and_contexts:
|
||||
return replaced_events
|
||||
|
||||
if (
|
||||
self._rust_persister is not None
|
||||
and self.hs.config.experimental.rust_event_persistence_at_least("full")
|
||||
):
|
||||
return await self._persist_event_batch_in_rust(
|
||||
room_id, events_and_contexts, backfilled
|
||||
)
|
||||
|
||||
# Check if any of the events have a transaction ID that has already been
|
||||
# persisted, and if so we don't persist it again.
|
||||
#
|
||||
@@ -892,6 +910,16 @@ class EventsPersistenceStorageController:
|
||||
if not events_and_contexts:
|
||||
return replaced_events
|
||||
|
||||
await self._persist_chunks(room_id, events_and_contexts, backfilled)
|
||||
return replaced_events
|
||||
|
||||
async def _persist_chunks(
|
||||
self,
|
||||
room_id: str,
|
||||
events_and_contexts: list[EventPersistencePair],
|
||||
backfilled: bool,
|
||||
) -> None:
|
||||
"""Persist the events, deduplicated, in chunks of 100."""
|
||||
chunks = [
|
||||
events_and_contexts[x : x + 100]
|
||||
for x in range(0, len(events_and_contexts), 100)
|
||||
@@ -924,7 +952,98 @@ class EventsPersistenceStorageController:
|
||||
rust_transaction=self._rust_transaction(prepared),
|
||||
)
|
||||
|
||||
return replaced_events
|
||||
async def _persist_event_batch_in_rust(
|
||||
self,
|
||||
room_id: str,
|
||||
events_and_contexts: list[EventPersistencePair],
|
||||
backfilled: bool,
|
||||
) -> dict[str, str]:
|
||||
"""`_persist_event_batch` at `rust_event_persistence: full`: Rust
|
||||
deduplicates the batch, then prepares and writes it chunk by chunk,
|
||||
and Python applies what each chunk Rust wrote asks for before the
|
||||
next. A chunk Rust cannot prepare is handed back with the rest of
|
||||
the batch and persisted chunk by chunk, as at lower levels."""
|
||||
assert self._rust_persister is not None
|
||||
persister = self._rust_persister
|
||||
handle = persister.persist_batch(
|
||||
room_id, events_and_contexts, backfilled=backfilled
|
||||
)
|
||||
|
||||
async def persist() -> dict[str, str]:
|
||||
while True:
|
||||
try:
|
||||
# The Deferred follows the logcontext rules already.
|
||||
step = await persister.batch_next(handle)
|
||||
except BaseException:
|
||||
# Rust finished the stream orderings of a chunk it
|
||||
# failed to write. Tell replication, as `get_next_mult`
|
||||
# does when its block raises.
|
||||
self.hs.get_replication_notifier().notify_replication()
|
||||
raise
|
||||
if not isinstance(step, WrittenChunk):
|
||||
break
|
||||
await self._apply_rust_batch_chunk(
|
||||
room_id, events_and_contexts, backfilled, step, handle
|
||||
)
|
||||
|
||||
if step.remaining:
|
||||
logger.debug(
|
||||
"Persisting %d events of %s chunk by chunk: %s",
|
||||
len(step.remaining),
|
||||
room_id,
|
||||
step.fallback_reason,
|
||||
)
|
||||
await self._persist_chunks(
|
||||
room_id,
|
||||
[events_and_contexts[i] for i in step.remaining],
|
||||
backfilled,
|
||||
)
|
||||
return step.replaced_events
|
||||
|
||||
# Rust carries on with a step if this is cancelled, and its effects
|
||||
# must be applied. Waiting also keeps the room's queue from starting
|
||||
# its next batch while this one, or the rest Rust handed back, is
|
||||
# still being written.
|
||||
return await delay_cancellation(persist())
|
||||
|
||||
async def _apply_rust_batch_chunk(
|
||||
self,
|
||||
room_id: str,
|
||||
events_and_contexts: list[EventPersistencePair],
|
||||
backfilled: bool,
|
||||
step: WrittenChunk,
|
||||
handle: PersistBatch,
|
||||
) -> None:
|
||||
"""Apply what a chunk of the batch Rust wrote asks for, and finish
|
||||
its stream orderings. If that fails, release the batch."""
|
||||
assert self._rust_persister is not None
|
||||
effects = step.effects
|
||||
try:
|
||||
try:
|
||||
prepared = step.prepared
|
||||
chunk = [events_and_contexts[i] for i in step.indices]
|
||||
# Counted as the chunk path counts, once prepared.
|
||||
self._count_rust_prepare("full", "rust")
|
||||
if prepared.state_res_stats is not None:
|
||||
self._state_resolution_handler.record_rust_state_res_metrics(
|
||||
room_id, prepared.state_res_stats
|
||||
)
|
||||
except BaseException:
|
||||
# Only the apply finishes the orderings otherwise.
|
||||
finish_rust_persist_stream_ids(
|
||||
self.persist_events_store.store, effects, succeeded=False
|
||||
)
|
||||
raise
|
||||
await self.persist_events_store._apply_rust_chunk(
|
||||
room_id,
|
||||
chunk,
|
||||
effects,
|
||||
backfilled=backfilled,
|
||||
new_forward_extremities=prepared.new_forward_extremities,
|
||||
)
|
||||
except BaseException:
|
||||
await self._rust_persister.batch_abort(handle)
|
||||
raise
|
||||
|
||||
def _rust_transaction(self, prepared: _PreparedChunk) -> RustTransaction | None:
|
||||
"""Whether to write the chunk in Rust: at `rust_event_persistence:
|
||||
@@ -1071,10 +1190,9 @@ class EventsPersistenceStorageController:
|
||||
with Measure(
|
||||
self._clock, name="rust_prepare_chunk", server_name=self.server_name
|
||||
):
|
||||
rust = await make_deferred_yieldable(
|
||||
self._rust_persister.prepare_chunk(
|
||||
room_id, chunk, backfilled, shadow=shadow
|
||||
)
|
||||
# The Deferred follows the logcontext rules already.
|
||||
rust = await self._rust_persister.prepare_chunk(
|
||||
room_id, chunk, backfilled, shadow=shadow
|
||||
)
|
||||
if rust.state_res_fallback is not None:
|
||||
logger.info(
|
||||
|
||||
@@ -57,7 +57,6 @@ from synapse.events import (
|
||||
)
|
||||
from synapse.events.py_protocol import MSC4242Event, supports_msc4242_state_dag
|
||||
from synapse.events.snapshot import EventPersistencePair
|
||||
from synapse.logging.context import make_deferred_yieldable
|
||||
from synapse.logging.opentracing import trace
|
||||
from synapse.metrics import SERVER_NAME_LABEL
|
||||
from synapse.storage._base import db_to_json, make_in_list_sql_clause
|
||||
@@ -98,7 +97,7 @@ from synapse.util.stringutils import non_null_str_or_none
|
||||
if TYPE_CHECKING:
|
||||
from synapse.server import HomeServer
|
||||
from synapse.storage.databases.main import DataStore
|
||||
from synapse.synapse_rust.persist import Persister, PreparedChunk
|
||||
from synapse.synapse_rust.persist import PersistEffects, Persister, PreparedChunk
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1195,18 +1194,17 @@ class PersistEventsStore:
|
||||
# caller waits for both, as Python's transaction cannot be cancelled.
|
||||
|
||||
async def persist_and_apply() -> None:
|
||||
# The persister's Deferreds follow the logcontext rules already.
|
||||
persister = rust_transaction.persister
|
||||
allocate = rust_transaction.allocate_stream_orderings
|
||||
if allocate:
|
||||
try:
|
||||
effects = await make_deferred_yieldable(
|
||||
persister.persist_chunk(
|
||||
room_id,
|
||||
events_and_contexts,
|
||||
rust_transaction.prepared,
|
||||
backfilled=backfilled,
|
||||
inhibit_local_membership_updates=inhibit_local_membership_updates,
|
||||
)
|
||||
effects = await persister.persist_chunk(
|
||||
room_id,
|
||||
events_and_contexts,
|
||||
rust_transaction.prepared,
|
||||
backfilled=backfilled,
|
||||
inhibit_local_membership_updates=inhibit_local_membership_updates,
|
||||
)
|
||||
except BaseException:
|
||||
# Rust finished the stream orderings it took. Tell
|
||||
@@ -1215,44 +1213,71 @@ class PersistEventsStore:
|
||||
self.hs.get_replication_notifier().notify_replication()
|
||||
raise
|
||||
else:
|
||||
effects = await make_deferred_yieldable(
|
||||
persister.persist_events_txn(
|
||||
room_id,
|
||||
events_and_contexts,
|
||||
rust_transaction.prepared,
|
||||
inhibit_local_membership_updates=inhibit_local_membership_updates,
|
||||
)
|
||||
)
|
||||
# The copy of a duplicated event that Rust wrote, as Python's
|
||||
# `_add_to_cache` reads the deduplicated chunk.
|
||||
written = self._filter_events_and_contexts_for_duplicates(
|
||||
events_and_contexts
|
||||
)
|
||||
events = {event.event_id: event for event, _ in written}
|
||||
if not allocate:
|
||||
apply_rust_persist_effects(self.store, effects, events)
|
||||
return
|
||||
# What follows the write runs before the stream orderings are
|
||||
# finished, as it runs inside `get_next_mult` in Python: the
|
||||
# events must not be announced before, say, the MSC4293
|
||||
# redactions they start are recorded.
|
||||
try:
|
||||
apply_rust_persist_effects(
|
||||
self.store, effects, events, finish_stream_ids=False
|
||||
)
|
||||
await self._after_persisting_events(
|
||||
effects = await persister.persist_events_txn(
|
||||
room_id,
|
||||
events_and_contexts,
|
||||
use_negative_stream_ordering=backfilled,
|
||||
rust_transaction.prepared,
|
||||
inhibit_local_membership_updates=inhibit_local_membership_updates,
|
||||
)
|
||||
if allocate:
|
||||
await self._apply_rust_chunk(
|
||||
room_id,
|
||||
events_and_contexts,
|
||||
effects,
|
||||
backfilled=backfilled,
|
||||
new_forward_extremities=new_forward_extremities,
|
||||
)
|
||||
except BaseException:
|
||||
finish_rust_persist_stream_ids(self.store, effects, succeeded=False)
|
||||
raise
|
||||
finish_rust_persist_stream_ids(self.store, effects, succeeded=True)
|
||||
else:
|
||||
apply_rust_persist_effects(
|
||||
self.store, effects, self._events_written(events_and_contexts)
|
||||
)
|
||||
|
||||
await delay_cancellation(persist_and_apply())
|
||||
|
||||
def _events_written(
|
||||
self, events_and_contexts: list[EventPersistencePair]
|
||||
) -> dict[str, EventBase]:
|
||||
"""The copy of each event that a write of the chunk stores, as
|
||||
Python's `_add_to_cache` reads the deduplicated chunk."""
|
||||
written = self._filter_events_and_contexts_for_duplicates(events_and_contexts)
|
||||
return {event.event_id: event for event, _ in written}
|
||||
|
||||
async def _apply_rust_chunk(
|
||||
self,
|
||||
room_id: str,
|
||||
events_and_contexts: list[EventPersistencePair],
|
||||
effects: "PersistEffects",
|
||||
*,
|
||||
backfilled: bool,
|
||||
new_forward_extremities: set[str] | None,
|
||||
) -> None:
|
||||
"""Apply the effects of a chunk Rust wrote, having taken its stream
|
||||
orderings, then run `_after_persisting_events`, then finish the
|
||||
orderings.
|
||||
|
||||
What follows the write runs before the orderings are finished, as
|
||||
it runs inside `get_next_mult` in Python: the events must not be
|
||||
announced before, say, the MSC4293 redactions they start are
|
||||
recorded. The orderings are finished whatever happens.
|
||||
"""
|
||||
try:
|
||||
apply_rust_persist_effects(
|
||||
self.store,
|
||||
effects,
|
||||
self._events_written(events_and_contexts),
|
||||
finish_stream_ids=False,
|
||||
)
|
||||
await self._after_persisting_events(
|
||||
room_id,
|
||||
events_and_contexts,
|
||||
use_negative_stream_ordering=backfilled,
|
||||
new_forward_extremities=new_forward_extremities,
|
||||
)
|
||||
except BaseException:
|
||||
finish_rust_persist_stream_ids(self.store, effects, succeeded=False)
|
||||
raise
|
||||
finish_rust_persist_stream_ids(self.store, effects, succeeded=True)
|
||||
|
||||
def _persist_events_txn(
|
||||
self,
|
||||
txn: LoggingTransaction,
|
||||
|
||||
@@ -106,6 +106,71 @@ class Persister:
|
||||
) -> PersistEffects:
|
||||
"""As `persist_chunk`, but blocks. For tests."""
|
||||
|
||||
def persist_batch(
|
||||
self,
|
||||
room_id: str,
|
||||
events_and_contexts: list[tuple[EventBase, EventContext]],
|
||||
*,
|
||||
backfilled: bool,
|
||||
) -> PersistBatch:
|
||||
"""`_persist_event_batch` for a queued batch of `room_id`'s events,
|
||||
one chunk at a time: await `batch_next` until it resolves with a
|
||||
`PersistedBatch`, applying the effects of each `WrittenChunk` before
|
||||
the next step, and call `batch_abort` if they cannot be applied.
|
||||
Converts the events, without I/O."""
|
||||
def batch_next(
|
||||
self, batch: PersistBatch
|
||||
) -> Deferred[WrittenChunk | PersistedBatch]:
|
||||
"""The batch's next step. The first drops the events already
|
||||
persisted under the same transaction ID. Each releases the state
|
||||
groups of the chunk before, then prepares the next chunk, marks the
|
||||
batch's state groups as being persisted, takes the chunk's stream
|
||||
orderings and writes it.
|
||||
|
||||
The chunk's stream IDs are handed over unfinished: must not be
|
||||
cancelled. If the step fails, Rust finishes them and releases the
|
||||
state groups."""
|
||||
def batch_next_blocking(self, batch: PersistBatch) -> WrittenChunk | PersistedBatch:
|
||||
"""As `batch_next`, but blocks. For tests."""
|
||||
def batch_abort(self, batch: PersistBatch) -> Deferred[None]:
|
||||
"""Give up the batch, releasing its state groups as a failed
|
||||
persist does."""
|
||||
def batch_abort_blocking(self, batch: PersistBatch) -> None:
|
||||
"""As `batch_abort`, but blocks. For tests."""
|
||||
|
||||
class PersistBatch:
|
||||
"""A batch being persisted. See `Persister.persist_batch`."""
|
||||
|
||||
class WrittenChunk:
|
||||
"""A chunk `Persister.batch_next` wrote."""
|
||||
|
||||
@property
|
||||
def effects(self) -> PersistEffects:
|
||||
"""To apply with `apply_rust_persist_effects`, with the chunk's
|
||||
stream IDs unfinished."""
|
||||
@property
|
||||
def prepared(self) -> PreparedChunk:
|
||||
"""What Rust prepared for the chunk."""
|
||||
@property
|
||||
def indices(self) -> list[int]:
|
||||
"""The chunk's positions in the batch."""
|
||||
|
||||
class PersistedBatch:
|
||||
"""What `Persister.persist_batch` did, once the batch is done."""
|
||||
|
||||
@property
|
||||
def replaced_events(self) -> dict[str, str]:
|
||||
"""The events not persisted as they repeat an event already
|
||||
persisted with the same transaction ID, mapped to that event."""
|
||||
@property
|
||||
def remaining(self) -> list[int]:
|
||||
"""The positions in the batch of the events left for Python to
|
||||
persist, in order: the chunk Rust could not prepare and those after
|
||||
it. Empty if Rust persisted them all."""
|
||||
@property
|
||||
def fallback_reason(self) -> str | None:
|
||||
"""Why Rust left the `remaining` events."""
|
||||
|
||||
class PreparedChunk:
|
||||
"""What `Persister.prepare_chunk` computed. If `state_res_fallback` is
|
||||
set, the rest is empty and Python should compute the chunk itself."""
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
#
|
||||
|
||||
"""How `_persist_event_batch` prepares chunks in Rust
|
||||
(`rust/src/persist/prepare.rs`) at the `shadow` and `prepare` levels of
|
||||
(`rust/src/persist/prepare.rs`) at the `shadow`, `prepare` and `full` levels of
|
||||
`rust_event_persistence`. These need the native Rust Postgres driver:
|
||||
|
||||
SYNAPSE_TEST_RUST_DRIVER=1 POSTGRES=1 run-trial.sh tests.storage.test_rust_persist_prepare
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
from typing import Any
|
||||
from unittest import SkipTest
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import attr
|
||||
|
||||
@@ -118,6 +118,30 @@ class _PrepareTestBase(unittest.HomeserverTestCase):
|
||||
if after[result] != self._counts_before[result]
|
||||
}
|
||||
|
||||
def _mock_persister(self, **kwargs: Any) -> Mock:
|
||||
"""A mock persister. At `full` it hands each batch back to be
|
||||
persisted chunk by chunk, so the mocked `prepare_chunk` is used."""
|
||||
|
||||
def persist_batch(
|
||||
room_id: str, events_and_contexts: list, **kwargs: Any
|
||||
) -> Mock:
|
||||
return Mock(events=len(events_and_contexts))
|
||||
|
||||
def batch_next(handle: Mock) -> defer.Deferred:
|
||||
return defer.succeed(
|
||||
Mock(
|
||||
replaced_events={},
|
||||
remaining=list(range(handle.events)),
|
||||
fallback_reason="mocked",
|
||||
)
|
||||
)
|
||||
|
||||
return Mock(
|
||||
persist_batch=Mock(side_effect=persist_batch),
|
||||
batch_next=Mock(side_effect=batch_next),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _send(self, body: str, expect_code: int = 200) -> str | None:
|
||||
return self.helper.send(
|
||||
self.room_id, body, tok=self.alice_tok, expect_code=expect_code
|
||||
@@ -308,7 +332,7 @@ class ShadowTestCase(_PrepareTestBase):
|
||||
|
||||
def test_rust_rejection_python_accepts(self) -> None:
|
||||
self.persistence._rust_persist_strict = False
|
||||
self.persistence._rust_persister = Mock(
|
||||
self.persistence._rust_persister = self._mock_persister(
|
||||
prepare_chunk=Mock(return_value=defer.fail(SynapseError(500, "no")))
|
||||
)
|
||||
with self.assertLogs("synapse.storage.controllers.persist_events", "WARNING"):
|
||||
@@ -317,7 +341,7 @@ class ShadowTestCase(_PrepareTestBase):
|
||||
|
||||
def test_rust_error_is_logged(self) -> None:
|
||||
self.persistence._rust_persist_strict = False
|
||||
self.persistence._rust_persister = Mock(
|
||||
self.persistence._rust_persister = self._mock_persister(
|
||||
prepare_chunk=Mock(return_value=defer.fail(RuntimeError("boom")))
|
||||
)
|
||||
with self.assertLogs("synapse.storage.controllers.persist_events", "ERROR"):
|
||||
@@ -326,7 +350,7 @@ class ShadowTestCase(_PrepareTestBase):
|
||||
|
||||
|
||||
class PrepareTestCase(_PrepareTestBase):
|
||||
level = "prepare"
|
||||
level: RustEventPersistenceLevel = "prepare"
|
||||
|
||||
def test_room_lifecycle(self) -> None:
|
||||
chunks = self._room_lifecycle()
|
||||
@@ -424,7 +448,7 @@ class PrepareTestCase(_PrepareTestBase):
|
||||
def test_rejection_is_raised(self) -> None:
|
||||
"""A chunk Rust rejects is not computed again in Python."""
|
||||
self.persistence._rust_persist_strict = False
|
||||
self.persistence._rust_persister = Mock(
|
||||
self.persistence._rust_persister = self._mock_persister(
|
||||
prepare_chunk=Mock(return_value=defer.fail(SynapseError(500, "no")))
|
||||
)
|
||||
in_python = Mock(side_effect=AssertionError("computed in Python"))
|
||||
@@ -434,7 +458,7 @@ class PrepareTestCase(_PrepareTestBase):
|
||||
in_python.assert_not_called()
|
||||
|
||||
def test_fallback(self) -> None:
|
||||
self.persistence._rust_persister = Mock(
|
||||
self.persistence._rust_persister = self._mock_persister(
|
||||
prepare_chunk=Mock(
|
||||
return_value=defer.succeed(Mock(state_res_fallback="no index"))
|
||||
)
|
||||
@@ -448,7 +472,7 @@ class PrepareTestCase(_PrepareTestBase):
|
||||
|
||||
def test_error_uses_python(self) -> None:
|
||||
self.persistence._rust_persist_strict = False
|
||||
self.persistence._rust_persister = Mock(
|
||||
self.persistence._rust_persister = self._mock_persister(
|
||||
prepare_chunk=Mock(return_value=defer.fail(RuntimeError("boom")))
|
||||
)
|
||||
with self.assertLogs("synapse.storage.controllers.persist_events", "ERROR"):
|
||||
@@ -460,8 +484,55 @@ class PrepareTestCase(_PrepareTestBase):
|
||||
)
|
||||
|
||||
def test_error_fails_strict_mode(self) -> None:
|
||||
self.persistence._rust_persister = Mock(
|
||||
self.persistence._rust_persister = self._mock_persister(
|
||||
prepare_chunk=Mock(return_value=defer.fail(RuntimeError("boom")))
|
||||
)
|
||||
self._send("hello", expect_code=500)
|
||||
self.assertEqual(self._counted(), {"error": 1})
|
||||
|
||||
|
||||
class FullTestCase(PrepareTestCase):
|
||||
"""At `full`, Rust persists each batch, chunk by chunk, and hands back
|
||||
to the chunk path any chunk it cannot prepare. The prepare counter
|
||||
counts the same."""
|
||||
|
||||
level: RustEventPersistenceLevel = "full"
|
||||
|
||||
def test_hand_back_in_the_middle(self) -> None:
|
||||
"""A batch whose second chunk Rust cannot prepare: Rust writes the
|
||||
first and hands back the second alone, which Python persists."""
|
||||
room_id = self.helper.create_room_as(
|
||||
self.alice, room_version=RoomVersions.V1.identifier, tok=self.alice_tok
|
||||
)
|
||||
# Siblings, so the messages need no state resolution and the topics,
|
||||
# in the second chunk, need it on state resolution v1.
|
||||
messages = [
|
||||
self._new_event(
|
||||
room_id, EventTypes.Message, {"msgtype": "m.text", "body": str(i)}
|
||||
)
|
||||
for i in range(100)
|
||||
]
|
||||
topics = [
|
||||
self._new_event(room_id, EventTypes.Topic, {"topic": t}, state_key="")
|
||||
for t in ("A", "B")
|
||||
]
|
||||
before = self._counts()
|
||||
persist_chunks = self.persistence._persist_chunks
|
||||
handed_back: list[list[str]] = []
|
||||
|
||||
async def recording(
|
||||
room_id: str, events_and_contexts: list, backfilled: bool
|
||||
) -> None:
|
||||
handed_back.append([event.event_id for event, _ in events_and_contexts])
|
||||
await persist_chunks(room_id, events_and_contexts, backfilled)
|
||||
|
||||
with patch.object(self.persistence, "_persist_chunks", side_effect=recording):
|
||||
self.get_success(self.persistence.persist_events([*messages, *topics]))
|
||||
self.assertEqual(handed_back, [[event.event_id for event, _ in topics]])
|
||||
after = self._counts()
|
||||
counted = {r: after[r] - before[r] for r in _RESULTS if after[r] != before[r]}
|
||||
self.assertEqual(counted, {"rust": 1, "fallback": 1})
|
||||
for event, _ in [*messages, *topics]:
|
||||
self.assertIsNotNone(
|
||||
self.get_success(self.store.get_event(event.event_id, allow_none=True))
|
||||
)
|
||||
@@ -40,7 +40,11 @@ from synapse.config.experimental import ExperimentalConfig
|
||||
from synapse.config.homeserver import HomeServerConfig
|
||||
from synapse.events import EventBase, make_event_from_dict
|
||||
from synapse.events.snapshot import EventContext
|
||||
from synapse.logging.context import make_deferred_yieldable
|
||||
from synapse.logging.context import (
|
||||
LoggingContext,
|
||||
current_context,
|
||||
make_deferred_yieldable,
|
||||
)
|
||||
from synapse.metrics import SERVER_NAME_LABEL, _PreCollectHooks
|
||||
from synapse.rest import admin
|
||||
from synapse.rest.client import login, room
|
||||
@@ -52,6 +56,7 @@ from synapse.storage.engines import PostgresEngine
|
||||
from synapse.storage.rust_pool import RustConnectionPool
|
||||
from synapse.synapse_rust.persist import (
|
||||
PersistEffects,
|
||||
WrittenChunk,
|
||||
persist_context_for_test,
|
||||
persist_gil_stats,
|
||||
persistence_json_for_test,
|
||||
@@ -871,6 +876,8 @@ class _CountingPersister:
|
||||
self.transactions = 0
|
||||
# Chunks written with Rust taking their stream orderings.
|
||||
self.chunks = 0
|
||||
# Batches persisted in Rust.
|
||||
self.batches = 0
|
||||
self.last_effects: PersistEffects | None = None
|
||||
|
||||
def prepare_chunk(self, *args: Any, **kwargs: Any) -> Any:
|
||||
@@ -895,6 +902,19 @@ class _CountingPersister:
|
||||
|
||||
return self._persister.persist_chunk(*args, **kwargs).addCallback(keep)
|
||||
|
||||
def persist_batch(self, *args: Any, **kwargs: Any) -> Any:
|
||||
self.batches += 1
|
||||
return self._persister.persist_batch(*args, **kwargs)
|
||||
|
||||
def batch_next(self, *args: Any, **kwargs: Any) -> Any:
|
||||
def keep(step: Any) -> Any:
|
||||
if isinstance(step, WrittenChunk):
|
||||
self.chunks += 1
|
||||
self.last_effects = step.effects
|
||||
return step
|
||||
|
||||
return self._persister.batch_next(*args, **kwargs).addCallback(keep)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._persister, name)
|
||||
|
||||
@@ -1031,7 +1051,8 @@ class RustTransactionTestCase(unittest.HomeserverTestCase):
|
||||
def persist_events_txn(self, *args: Any, **kwargs: Any) -> Any:
|
||||
d: defer.Deferred = defer.Deferred()
|
||||
gate.append((args, kwargs, d))
|
||||
return d
|
||||
# As the persister's Deferreds do.
|
||||
return make_deferred_yieldable(d)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(blocking, name)
|
||||
@@ -1125,16 +1146,20 @@ class RustFullTestCase(unittest.HomeserverTestCase):
|
||||
self.token = self.login("alice", "pass")
|
||||
self.room_id = self.helper.create_room_as(self.user, tok=self.token)
|
||||
|
||||
def _build(self, body: str) -> tuple[EventBase, EventContext]:
|
||||
def _build(
|
||||
self, body: str, *, txn_id: str | None = None
|
||||
) -> tuple[EventBase, EventContext]:
|
||||
"""A message, sent with `txn_id` from `DEVICE` if given."""
|
||||
event, unpersisted_context = self.get_success(
|
||||
self.hs.get_event_creation_handler().create_event(
|
||||
create_requester(self.user),
|
||||
create_requester(self.user, device_id="DEVICE" if txn_id else None),
|
||||
{
|
||||
"type": EventTypes.Message,
|
||||
"content": {"msgtype": "m.text", "body": body},
|
||||
"room_id": self.room_id,
|
||||
"sender": self.user,
|
||||
},
|
||||
txn_id=txn_id,
|
||||
)
|
||||
)
|
||||
context = self.get_success(unpersisted_context.persist(event))
|
||||
@@ -1263,6 +1288,207 @@ class RustFullTestCase(unittest.HomeserverTestCase):
|
||||
assert failed is not None
|
||||
self.assertGreaterEqual(id_gen.get_current_token(), failed)
|
||||
|
||||
def test_batch_is_one_rust_call(self) -> None:
|
||||
"""A batch of 150 events is one Rust call, written in two chunks,
|
||||
with none of it in Python's store entry point."""
|
||||
# Siblings on the room's extremity: an event cannot be built on
|
||||
# one not yet persisted.
|
||||
pairs = [self._build(f"message {i}") for i in range(150)]
|
||||
batches, chunks = self.persister.batches, self.persister.chunks
|
||||
with patch.object(
|
||||
PersistEventsStore,
|
||||
"_persist_events_and_state_updates",
|
||||
side_effect=AssertionError("persisted in Python"),
|
||||
):
|
||||
self.get_success(self.persistence.persist_events(pairs))
|
||||
self.assertEqual(self.persister.batches, batches + 1)
|
||||
self.assertEqual(self.persister.chunks, chunks + 2)
|
||||
effects = self.persister.last_effects
|
||||
assert effects is not None
|
||||
self.assertEqual(len(effects.stream_ids), 50)
|
||||
|
||||
orderings = [self._event_row(event.event_id)[0] for event, _ in pairs]
|
||||
self.assertEqual(orderings, sorted(orderings))
|
||||
self.assertGreaterEqual(
|
||||
self.store._stream_id_gen.get_current_token(), orderings[-1]
|
||||
)
|
||||
|
||||
def test_transaction_ids(self) -> None:
|
||||
"""An event repeating the transaction ID of an earlier one, in the
|
||||
same batch or an earlier one, is not persisted and maps to it."""
|
||||
self.login("alice", "pass", device_id="DEVICE")
|
||||
first = self._build("one", txn_id="txn")
|
||||
again = self._build("one again", txn_id="txn")
|
||||
persisted, _ = self.get_success(self.persistence.persist_events([first, again]))
|
||||
self.assertEqual(
|
||||
[event.event_id for event in persisted], [first[0].event_id] * 2
|
||||
)
|
||||
|
||||
later = self._build("one later", txn_id="txn")
|
||||
persisted, _ = self.get_success(self.persistence.persist_events([later]))
|
||||
self.assertEqual([event.event_id for event in persisted], [first[0].event_id])
|
||||
for event, _ in (again, later):
|
||||
self.assertIsNone(
|
||||
self.get_success(self.store.get_event(event.event_id, allow_none=True))
|
||||
)
|
||||
|
||||
def test_state_groups_held_while_writing(self) -> None:
|
||||
"""The batch's state groups are marked as being persisted while each
|
||||
chunk is written, and no longer once it is."""
|
||||
held: list[list[tuple[int, str]]] = []
|
||||
persist_events = self.hs.get_datastores().persist_events
|
||||
assert persist_events is not None
|
||||
apply_chunk = persist_events._apply_rust_chunk
|
||||
|
||||
async def checking(*args: Any, **kwargs: Any) -> None:
|
||||
held.append(
|
||||
await self.store.db_pool.simple_select_list(
|
||||
"state_groups_persisting",
|
||||
None,
|
||||
("state_group", "instance_name"),
|
||||
)
|
||||
)
|
||||
await apply_chunk(*args, **kwargs)
|
||||
|
||||
event, context = self._build("hello")
|
||||
with patch.object(persist_events, "_apply_rust_chunk", side_effect=checking):
|
||||
self.get_success(self.persistence.persist_events([(event, context)]))
|
||||
assert context.state_group is not None
|
||||
assert context.state_group_before_event is not None
|
||||
groups = {context.state_group, context.state_group_before_event}
|
||||
self.assertEqual(
|
||||
[sorted(rows) for rows in held],
|
||||
[sorted((group, "master") for group in groups)],
|
||||
)
|
||||
rows = self.get_success(
|
||||
self.store.db_pool.simple_select_list(
|
||||
"state_groups_persisting", None, ("state_group",)
|
||||
)
|
||||
)
|
||||
self.assertEqual(rows, [])
|
||||
|
||||
def test_deleted_state_group_fails(self) -> None:
|
||||
"""A chunk whose state group was deleted meanwhile is not written."""
|
||||
event, context = self._build("hello")
|
||||
self.get_success(
|
||||
self.store.db_pool.simple_delete_one(
|
||||
"state_groups", {"id": context.state_group}
|
||||
)
|
||||
)
|
||||
failure = self.get_failure(
|
||||
self.persistence.persist_events([(event, context)]), RuntimeError
|
||||
)
|
||||
self.assertIn("state groups have been deleted", str(failure.value))
|
||||
self.assertIsNone(
|
||||
self.get_success(self.store.get_event(event.event_id, allow_none=True))
|
||||
)
|
||||
|
||||
def _pending_deletion(self, state_group: int) -> tuple[int, int] | None:
|
||||
"""`(sequence_number, insertion_ts)` of the group's pending deletion."""
|
||||
row = self.get_success(
|
||||
self.store.db_pool.simple_select_one(
|
||||
"state_groups_pending_deletion",
|
||||
{"state_group": state_group},
|
||||
("sequence_number", "insertion_ts"),
|
||||
allow_none=True,
|
||||
)
|
||||
)
|
||||
return (row[0], row[1]) if row is not None else None
|
||||
|
||||
def test_pending_deletion_after_persisting(self) -> None:
|
||||
"""Once a chunk is written, its state groups are no longer pending
|
||||
deletion. If the write fails, their pending deletion is pushed
|
||||
back, as they may now be referenced."""
|
||||
event, context = self._build("hello")
|
||||
assert context.state_group is not None
|
||||
self.get_success(
|
||||
self.store.db_pool.simple_insert(
|
||||
"state_groups_pending_deletion",
|
||||
{"state_group": context.state_group, "insertion_ts": 0},
|
||||
)
|
||||
)
|
||||
before = self._pending_deletion(context.state_group)
|
||||
assert before is not None
|
||||
|
||||
failing, failing_context = self._build("partial")
|
||||
failing_context.partial_state = True
|
||||
self.reactor.advance(1)
|
||||
self.get_failure(
|
||||
self.persistence.persist_events([(failing, failing_context)]),
|
||||
PartialStateConflictError,
|
||||
)
|
||||
after = self._pending_deletion(context.state_group)
|
||||
assert after is not None
|
||||
self.assertGreater(after[0], before[0])
|
||||
self.assertEqual(after[1], self.clock.time_msec())
|
||||
|
||||
self.get_success(self.persistence.persist_events([(event, context)]))
|
||||
self.assertIsNone(self._pending_deletion(context.state_group))
|
||||
|
||||
def test_replaced_event_and_chunks(self) -> None:
|
||||
"""A batch whose first event repeats a persisted transaction ID, and
|
||||
whose other 150 go in two chunks: each chunk hands Python the events
|
||||
it wrote."""
|
||||
self.login("alice", "pass", device_id="DEVICE")
|
||||
original = self._build("original", txn_id="txn")
|
||||
self.get_success(self.persistence.persist_events([original]))
|
||||
|
||||
repeat = self._build("repeat", txn_id="txn")
|
||||
new = [self._build(f"message {i}") for i in range(150)]
|
||||
chunks = self.persister.chunks
|
||||
persisted, _ = self.get_success(self.persistence.persist_events([repeat, *new]))
|
||||
self.assertEqual(self.persister.chunks, chunks + 2)
|
||||
effects = self.persister.last_effects
|
||||
assert effects is not None
|
||||
self.assertEqual(len(effects.stream_ids), 50)
|
||||
self.assertEqual(
|
||||
[event.event_id for event in persisted],
|
||||
[original[0].event_id] + [event.event_id for event, _ in new],
|
||||
)
|
||||
self.assertIsNone(
|
||||
self.get_success(self.store.get_event(repeat[0].event_id, allow_none=True))
|
||||
)
|
||||
orderings = [self._event_row(event.event_id)[0] for event, _ in new]
|
||||
self.assertEqual(orderings, sorted(orderings))
|
||||
for i, (event, _) in enumerate(new):
|
||||
stored = self.get_success(self.store.get_event(event.event_id))
|
||||
self.assertEqual(stored.content["body"], f"message {i}")
|
||||
|
||||
def test_same_event_in_two_chunks(self) -> None:
|
||||
"""An event in two chunks of a batch is written once, and every
|
||||
ordering the batch took is finished."""
|
||||
first = self._build("first")
|
||||
rest = [self._build(f"message {i}") for i in range(100)]
|
||||
self.get_success(self.persistence.persist_events([first, *rest, first]))
|
||||
rows = self.get_success(
|
||||
self.store.db_pool.simple_select_onecol(
|
||||
"events", {"event_id": first[0].event_id}, "stream_ordering"
|
||||
)
|
||||
)
|
||||
self.assertEqual(len(rows), 1)
|
||||
effects = self.persister.last_effects
|
||||
assert effects is not None
|
||||
self.assertEqual(len(effects.stream_ids), 2)
|
||||
self.assertGreaterEqual(
|
||||
self.store._stream_id_gen.get_current_token(), max(effects.stream_ids)
|
||||
)
|
||||
|
||||
def test_logcontext_kept(self) -> None:
|
||||
"""Awaiting the batch resumes in the caller's logcontext, so what
|
||||
follows it is accounted to the caller."""
|
||||
event, context = self._build("hello")
|
||||
seen = []
|
||||
|
||||
async def persist() -> None:
|
||||
with LoggingContext(name="persist", server_name="test") as ctx:
|
||||
await self.persistence._persist_event_batch_in_rust(
|
||||
self.room_id, [(event, context)], False
|
||||
)
|
||||
seen.append(current_context() is ctx)
|
||||
|
||||
self.get_success(defer.ensureDeferred(persist()))
|
||||
self.assertEqual(seen, [True])
|
||||
|
||||
def test_gil_count(self) -> None:
|
||||
"""Taking the stream orderings and setting them on the events takes
|
||||
the GIL at no persist site."""
|
||||
@@ -1360,64 +1586,70 @@ class RustFullTestCase(unittest.HomeserverTestCase):
|
||||
self.assertGreaterEqual(
|
||||
self.store._stream_id_gen.get_current_token(), stream_ordering
|
||||
)
|
||||
# `batch_abort` released the state groups.
|
||||
rows = self.get_success(
|
||||
self.store.db_pool.simple_select_list(
|
||||
"state_groups_persisting", None, ("state_group",)
|
||||
)
|
||||
)
|
||||
self.assertEqual(rows, [])
|
||||
|
||||
def test_cancelled_persist_applies_its_effects(self) -> None:
|
||||
"""Cancelling the persist while Rust takes the orderings and writes
|
||||
the chunk waits for both, and the orderings are finished."""
|
||||
"""Cancelling the batch while Rust writes a chunk waits for Rust,
|
||||
so the room's queue cannot start another batch alongside, and the
|
||||
chunk's effects are applied and its orderings finished."""
|
||||
gate: list[tuple[Any, Any, defer.Deferred]] = []
|
||||
blocking = self.persister._persister
|
||||
|
||||
class Gated:
|
||||
def persist_chunk(self, *args: Any, **kwargs: Any) -> Any:
|
||||
def batch_next(self, *args: Any, **kwargs: Any) -> Any:
|
||||
# Only the first step, which writes the chunk.
|
||||
if gate:
|
||||
return blocking.batch_next(*args, **kwargs)
|
||||
d: defer.Deferred = defer.Deferred()
|
||||
gate.append((args, kwargs, d))
|
||||
return d
|
||||
# As the persister's Deferreds do.
|
||||
return make_deferred_yieldable(d)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(blocking, name)
|
||||
|
||||
self.persister._persister = Gated()
|
||||
|
||||
persist_events = self.hs.get_datastores().persist_events
|
||||
assert persist_events is not None
|
||||
original = persist_events._persist_events_in_rust
|
||||
original = self.persistence._persist_event_batch_in_rust
|
||||
cancelled = []
|
||||
|
||||
async def cancel_then_wait(*args: Any, **kwargs: Any) -> None:
|
||||
d = defer.ensureDeferred(original(*args, **kwargs))
|
||||
async def cancel_then_wait(*args: Any) -> dict[str, str]:
|
||||
d = defer.ensureDeferred(original(*args))
|
||||
d.cancel()
|
||||
try:
|
||||
await make_deferred_yieldable(d)
|
||||
return await make_deferred_yieldable(d)
|
||||
except defer.CancelledError:
|
||||
cancelled.append(True)
|
||||
return {}
|
||||
|
||||
event, context = self._build("hello")
|
||||
with patch.object(
|
||||
persist_events, "_persist_events_in_rust", side_effect=cancel_then_wait
|
||||
self.persistence,
|
||||
"_persist_event_batch_in_rust",
|
||||
side_effect=cancel_then_wait,
|
||||
):
|
||||
channel = make_request(
|
||||
self.reactor,
|
||||
self.site,
|
||||
"PUT",
|
||||
f"/rooms/{self.room_id}/send/m.room.message/txn1",
|
||||
{"msgtype": "m.text", "body": "hello"},
|
||||
access_token=self.token,
|
||||
await_result=False,
|
||||
persisted = defer.ensureDeferred(
|
||||
self.persistence.persist_events([(event, context)])
|
||||
)
|
||||
self.pump()
|
||||
self.assertEqual(len(gate), 1)
|
||||
# Cancelled, but waiting for the write.
|
||||
# Cancelled, but waiting for Rust.
|
||||
self.assertEqual(cancelled, [])
|
||||
args, kwargs, d = gate[0]
|
||||
blocking.persist_chunk(*args, **kwargs).chainDeferred(d)
|
||||
channel.await_result()
|
||||
self.assertEqual(channel.code, 200, channel.json_body)
|
||||
event_id = channel.json_body["event_id"]
|
||||
blocking.batch_next(*args, **kwargs).chainDeferred(d)
|
||||
self.get_success(persisted)
|
||||
self.assertEqual(cancelled, [True])
|
||||
|
||||
effects = self.persister.last_effects
|
||||
assert effects is not None
|
||||
(stream_ordering,) = effects.stream_ids
|
||||
self.assertEqual(self._event_row(event_id)[0], stream_ordering)
|
||||
self.assertEqual(self._event_row(event.event_id)[0], stream_ordering)
|
||||
self.assertGreaterEqual(
|
||||
self.store._stream_id_gen.get_current_token(), stream_ordering
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user