mirror of
https://github.com/element-hq/synapse.git
synced 2026-11-04 09:07:22 +00:00
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
246 lines
8.4 KiB
Rust
246 lines
8.4 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>.
|
|
*
|
|
*/
|
|
|
|
//! 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, ...]"
|
|
);
|
|
}
|
|
}
|