mirror of
https://github.com/element-hq/synapse.git
synced 2026-11-04 15:37:49 +00:00
Store the event metadata in the Rust persist transaction
The third commit of phase 2. The Rust transaction now also runs `_update_metadata_tables_txn`, apart from the event cache prefill at its end (`_add_to_cache`), which stays in Python: - `txn/metadata.rs`: push actions move from the staging table, and the chunk's staging rows go. A redaction removes the push actions and the relation of the event it redacts, and moves or deletes its thread. Then search (with the plain text topic from `m.topic`), redactions, retention, relations and threads, labels, expiry, and room members. An out-of-band membership of a local user also updates `local_current_membership` and the user's sliding sync snapshot, with the stripped state of an invite or knock. - `txn/edges.rs`: `event_edges` with the backward extremities, and the MSC4242 state DAG edges. - `txn/caches.rs`: `_invalidate_cache_and_stream`. The rows go into `cache_invalidation_stream_by_instance` inside the transaction, with IDs from the cache stream's tracker. The committed attempt hands its IDs to Python in `PersistEffects.cache_stream_ids`. Python finishes them after invalidating the caches, as `get_next_txn` does. A failed attempt's IDs are finished when its steps fail, or for a failed commit, by the next attempt or when the persist ends. A fetch of an ID that is dropped before the sequence answers still ends, so a cancelled persist cannot hold the stream's position back. Where Python and Rust differ, the input is malformed: - Python stores labels from any value it can iterate, and non-string labels. Rust stores only string labels from a list. - An integer lifetime or expiry beyond `u64` is a float to serde, so Rust ignores it where Python's insert fails. Within `u64` but beyond `bigint`, both fail. The persister reads `enable_search` and `enable_ephemeral_messages` from the server config. `persist_events_txn_for_test` takes `inhibit_local_membership_updates`. New twin scenarios: each kind of relation and its redaction, which moves the thread back and then deletes it, a relation redacted in its own chunk, two thread replies in one chunk, search, retention (also two policies in one chunk), labels and expiry, member rows, push actions and their redaction, an out-of-band invite and its rejection, a knock, and a backfilled invite. The out-of-band ones are skipped in state DAG rooms, as a hand-built event has no state DAG context. A redaction before its target is now stored with a recheck still needed. The test harness compared `event_edges` by its `room_id` column, which is no longer filled in, so it compared no rows. It now compares by `event_id`. It also names the cache behind each invalidation, compares the cache stream rows' times, and finishes the cache stream IDs Rust hands back. Fifteen mutants of the new steps each fail the comparison. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXMkcZVkcZhi21augBff4a
This commit is contained in:
1 parent
b5216c757b
commit
41c7808711
15 files changed
+1866
-33
No files matched your search
@@ -50,6 +50,8 @@ pub struct AuthConfig {
|
||||
}
|
||||
#[derive(FromPyObject, Clone)]
|
||||
pub struct ServerConfig {
|
||||
pub enable_search: bool,
|
||||
pub enable_ephemeral_messages: bool,
|
||||
pub msc4140_enabled: bool,
|
||||
pub public_baseurl: String,
|
||||
pub include_profile_updates_in_sync: bool,
|
||||
|
||||
@@ -21,6 +21,18 @@ pub mod event_type {
|
||||
pub const M_ROOM_HISTORY_VISIBILITY: &str = "m.room.history_visibility";
|
||||
/// Event type: m.room.redaction
|
||||
pub const M_ROOM_REDACTION: &str = "m.room.redaction";
|
||||
/// Event type: m.room.name
|
||||
pub const M_ROOM_NAME: &str = "m.room.name";
|
||||
/// Event type: m.room.topic
|
||||
pub const M_ROOM_TOPIC: &str = "m.room.topic";
|
||||
/// Event type: m.room.message
|
||||
pub const M_ROOM_MESSAGE: &str = "m.room.message";
|
||||
/// Event type: m.room.retention
|
||||
pub const M_ROOM_RETENTION: &str = "m.room.retention";
|
||||
/// Event type: m.room.encryption
|
||||
pub const M_ROOM_ENCRYPTION: &str = "m.room.encryption";
|
||||
/// Event type: m.room.tombstone
|
||||
pub const M_ROOM_TOMBSTONE: &str = "m.room.tombstone";
|
||||
}
|
||||
|
||||
/// Event Fields
|
||||
|
||||
@@ -720,7 +720,7 @@ impl EventInternalMetadata {
|
||||
Ok(self.read_inner()?.is_redacted())
|
||||
}
|
||||
|
||||
fn is_notifiable(&self) -> PyResult<bool> {
|
||||
pub fn is_notifiable(&self) -> PyResult<bool> {
|
||||
Ok(self.read_inner()?.is_notifiable())
|
||||
}
|
||||
|
||||
|
||||
@@ -309,6 +309,18 @@ impl Unsigned {
|
||||
Ok(self.py_read()?.persisted_fields.age_ts.clone())
|
||||
}
|
||||
|
||||
/// The `invite_room_state` field: the stripped state of the room an
|
||||
/// invite is for.
|
||||
pub fn invite_room_state(&self) -> PyResult<Option<Vec<serde_json::Value>>> {
|
||||
Ok(self.py_read()?.persisted_fields.invite_room_state.clone())
|
||||
}
|
||||
|
||||
/// The `knock_room_state` field: the stripped state of the room a knock
|
||||
/// is for.
|
||||
pub fn knock_room_state(&self) -> PyResult<Option<Vec<serde_json::Value>>> {
|
||||
Ok(self.py_read()?.persisted_fields.knock_room_state.clone())
|
||||
}
|
||||
|
||||
/// Get the `prev_content` field: the content of the state this event
|
||||
/// replaced, if it is a state event and the content is known.
|
||||
pub fn prev_content(&self) -> PyResult<Option<Box<serde_json::Value>>> {
|
||||
|
||||
@@ -123,6 +123,10 @@ pub struct PersistEffects {
|
||||
/// `cache.invalidate(keys)` for the named cache: the local half of
|
||||
/// `_invalidate_cache_and_stream`.
|
||||
cache_invalidations: Vec<(String, Vec<String>)>,
|
||||
/// The IDs of the cache stream rows the transaction wrote, to mark
|
||||
/// finished once the caches are invalidated, then
|
||||
/// `ReplicationNotifier.notify_replication()`.
|
||||
cache_stream_ids: Vec<i64>,
|
||||
/// `_get_state_group_for_event.prefill((event_id,), state_group)`.
|
||||
state_group_prefills: Vec<(String, i64)>,
|
||||
/// `get_latest_event_ids_in_room.prefill((room_id,), extremities)`.
|
||||
@@ -157,6 +161,14 @@ impl PersistEffects {
|
||||
self.cache_invalidations.push((cache_name.to_owned(), keys));
|
||||
}
|
||||
|
||||
pub fn add_cache_stream_id(&mut self, stream_id: i64) {
|
||||
self.cache_stream_ids.push(stream_id);
|
||||
}
|
||||
|
||||
pub fn cache_stream_ids(&self) -> &[i64] {
|
||||
&self.cache_stream_ids
|
||||
}
|
||||
|
||||
pub fn add_state_group_prefill(&mut self, event_id: String, state_group: i64) {
|
||||
self.state_group_prefills.push((event_id, state_group));
|
||||
}
|
||||
@@ -194,6 +206,7 @@ impl PersistEffects {
|
||||
event_cache_invalidations = Vec::new(),
|
||||
state_caches = Vec::new(),
|
||||
cache_invalidations = Vec::new(),
|
||||
cache_stream_ids = Vec::new(),
|
||||
state_group_prefills = Vec::new(),
|
||||
latest_event_ids = None,
|
||||
stream_changes = Vec::new(),
|
||||
@@ -207,6 +220,7 @@ impl PersistEffects {
|
||||
event_cache_invalidations: Vec<String>,
|
||||
state_caches: Vec<(String, Vec<String>)>,
|
||||
cache_invalidations: Vec<(String, Vec<String>)>,
|
||||
cache_stream_ids: Vec<i64>,
|
||||
state_group_prefills: Vec<(String, i64)>,
|
||||
latest_event_ids: Option<(String, Vec<String>)>,
|
||||
stream_changes: Vec<(String, String, i64)>,
|
||||
@@ -219,6 +233,7 @@ impl PersistEffects {
|
||||
event_cache_invalidations,
|
||||
state_caches,
|
||||
cache_invalidations,
|
||||
cache_stream_ids,
|
||||
state_group_prefills,
|
||||
latest_event_ids,
|
||||
stream_changes: stream_changes
|
||||
@@ -267,6 +282,12 @@ impl PersistEffects {
|
||||
self.cache_invalidations.clone()
|
||||
}
|
||||
|
||||
#[getter]
|
||||
#[pyo3(name = "cache_stream_ids")]
|
||||
fn py_cache_stream_ids(&self) -> Vec<i64> {
|
||||
self.cache_stream_ids.clone()
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn state_group_prefills(&self) -> Vec<(String, i64)> {
|
||||
self.state_group_prefills.clone()
|
||||
|
||||
@@ -34,7 +34,7 @@ use super::{
|
||||
prepare::{self, PrepareInput, PrepareOptions, Prepared},
|
||||
reader::{self, PersistReader, PyDelta, PySlidingSync},
|
||||
state_delta::NativeStateResolver,
|
||||
txn::{self, json, PartialStateConflict, TxnEvent, TxnInput},
|
||||
txn::{self, caches::CacheStream, json, PartialStateConflict, TxnEvent, TxnInput},
|
||||
};
|
||||
use crate::{
|
||||
database::postgres::write::{
|
||||
@@ -87,6 +87,10 @@ pub(super) struct PersisterInner {
|
||||
pub conflict_cache: Arc<ConflictCache>,
|
||||
/// `experimental_features.msc4354_enabled`.
|
||||
pub msc4354_enabled: bool,
|
||||
/// `server.enable_search`.
|
||||
pub enable_search: bool,
|
||||
/// `server.enable_ephemeral_messages`.
|
||||
pub enable_ephemeral_messages: bool,
|
||||
}
|
||||
|
||||
impl Persister {
|
||||
@@ -151,6 +155,8 @@ impl Persister {
|
||||
metrics: PersistMetrics::default(),
|
||||
conflict_cache,
|
||||
msc4354_enabled: config.experimental.msc4354_enabled,
|
||||
enable_search: config.server.enable_search,
|
||||
enable_ephemeral_messages: config.server.enable_ephemeral_messages,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -276,17 +282,21 @@ impl Persister {
|
||||
/// `prepared` is what Rust prepared for it. For
|
||||
/// `tests/storage/test_rust_persist_txn.py`.
|
||||
///
|
||||
/// The cache stream IDs it wrote come back unfinished in the effects.
|
||||
///
|
||||
/// Returns what it collected for Python to apply, and for the Python
|
||||
/// steps after it, by position in the chunk: the events left, the
|
||||
/// events stored redacted with the event that redacts each, and the
|
||||
/// events that stopped being outliers.
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[pyo3(signature = (room_id, events_and_contexts, prepared, *, inhibit_local_membership_updates))]
|
||||
fn persist_events_txn_for_test(
|
||||
&self,
|
||||
py: Python<'_>,
|
||||
room_id: String,
|
||||
events_and_contexts: Vec<(Py<Event>, PersistContext)>,
|
||||
prepared: &PreparedChunk,
|
||||
inhibit_local_membership_updates: bool,
|
||||
) -> PyResult<(
|
||||
PersistEffects,
|
||||
Vec<usize>,
|
||||
@@ -308,12 +318,18 @@ impl Persister {
|
||||
let Prepared::Chunk(chunk) = &*prepared else {
|
||||
unreachable!("checked above");
|
||||
};
|
||||
let cache_stream = CacheStream::new(Arc::clone(&inner.cache_stream));
|
||||
let input = TxnInput {
|
||||
room_id: &room_id,
|
||||
events: &events,
|
||||
prepared: chunk,
|
||||
instance_name: &inner.instance_name,
|
||||
server_name: &inner.server_name,
|
||||
clock: inner.runtime.clock(),
|
||||
inhibit_local_membership_updates,
|
||||
enable_search: inner.enable_search,
|
||||
enable_ephemeral_messages: inner.enable_ephemeral_messages,
|
||||
cache_stream: &cache_stream,
|
||||
};
|
||||
let position = |event: &TxnEvent| {
|
||||
input
|
||||
@@ -322,7 +338,7 @@ impl Persister {
|
||||
.position(|x| std::ptr::eq(x, event))
|
||||
.expect("an event of the chunk")
|
||||
};
|
||||
run_write_interaction(&pool, "persist_events", |txn, _| {
|
||||
let result = run_write_interaction(&pool, "persist_events", |txn, _| {
|
||||
let (input, position) = (&input, &position);
|
||||
async move {
|
||||
let mut effects = PersistEffects::default();
|
||||
@@ -345,7 +361,9 @@ impl Persister {
|
||||
.boxed()
|
||||
})
|
||||
.await
|
||||
.map_err(write_error_to_py)
|
||||
.map_err(write_error_to_py)?;
|
||||
cache_stream.hand_over(&result.0);
|
||||
Ok(result)
|
||||
};
|
||||
self.block_on(py, future.boxed())
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
//! The write transaction treats both as sets.
|
||||
//!
|
||||
//! `_get_sliding_sync_insert_values_from_stripped_state` belongs to the
|
||||
//! write transaction, which calls it for out-of-band memberships, and is
|
||||
//! ported with it.
|
||||
//! write transaction, which calls it for out-of-band memberships. It is in
|
||||
//! [`super::txn::metadata`].
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* This file is licensed under the Affero General Public License (AGPL) version 3.
|
||||
*
|
||||
* Copyright (C) 2026 Element Creations Ltd
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* See the GNU Affero General Public License for more details:
|
||||
* <https://www.gnu.org/licenses/agpl-3.0.html>.
|
||||
*
|
||||
*/
|
||||
|
||||
//! The cache stream: `_invalidate_cache_and_stream`, for the caches that
|
||||
//! other workers cannot tell they must invalidate from the other streams.
|
||||
//!
|
||||
//! Each invalidation writes a `cache_invalidation_stream_by_instance` row
|
||||
//! inside the transaction, with an ID from the cache stream's
|
||||
//! [`StreamIdTracker`], and asks Python to invalidate the cache locally.
|
||||
//! The IDs stay unfinished until Python has done so, as Python's
|
||||
//! `get_next_txn` finishes them only after the transaction's earlier
|
||||
//! `call_after` callbacks.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::anyhow;
|
||||
|
||||
use super::TxnInput;
|
||||
use crate::{
|
||||
database::postgres::write::{WriteError, WriteTxn},
|
||||
persist::effects::PersistEffects,
|
||||
storage::stream_ids::StreamIdTracker,
|
||||
};
|
||||
|
||||
/// The cache stream IDs of one persist, over all its attempts. An ID the
|
||||
/// committed attempt wrote is handed to Python with the effects. The rest
|
||||
/// are marked finished here: at the start of the next attempt, or when
|
||||
/// this is dropped.
|
||||
pub struct CacheStream {
|
||||
tracker: Arc<StreamIdTracker>,
|
||||
/// The IDs allocated and neither finished nor handed to Python.
|
||||
pending: Mutex<Vec<i64>>,
|
||||
}
|
||||
|
||||
impl CacheStream {
|
||||
pub fn new(tracker: Arc<StreamIdTracker>) -> Self {
|
||||
CacheStream {
|
||||
tracker,
|
||||
pending: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn pending(&self) -> std::sync::MutexGuard<'_, Vec<i64>> {
|
||||
self.pending.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// `_load_next_id_txn`: the next ID on the stream, unfinished.
|
||||
async fn next_id(&self, txn: &WriteTxn) -> Result<i64, WriteError> {
|
||||
let fetch = Fetch::begin(self);
|
||||
let rows = txn
|
||||
.query("SELECT nextval('cache_invalidation_stream_seq')", &[])
|
||||
.await?;
|
||||
let row = rows
|
||||
.first()
|
||||
.ok_or_else(|| anyhow!("nextval returned no rows"))?;
|
||||
let id = row.try_get(0).map_err(anyhow::Error::new)?;
|
||||
fetch.allocated(id);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Mark finished the IDs of an attempt that failed, as Python's
|
||||
/// `call_on_exception` does.
|
||||
pub fn finish_pending(&self) {
|
||||
let ids = std::mem::take(&mut *self.pending());
|
||||
if !ids.is_empty() {
|
||||
self.tracker.mark_finished(&ids);
|
||||
}
|
||||
}
|
||||
|
||||
/// The transaction that wrote `effects` committed: Python marks its IDs
|
||||
/// finished once it has applied them.
|
||||
pub fn hand_over(&self, effects: &PersistEffects) {
|
||||
let handed = effects.cache_stream_ids();
|
||||
self.pending().retain(|id| !handed.contains(id));
|
||||
}
|
||||
}
|
||||
|
||||
/// A fetch of an ID from the sequence, between the tracker's `begin_fetch`
|
||||
/// and `end_fetch`. Python's `try`/`finally` cannot be interrupted, but a
|
||||
/// future can be dropped at any await, so the fetch ends on drop if it did
|
||||
/// not allocate. Otherwise the stream's position would stay behind it.
|
||||
struct Fetch<'a> {
|
||||
stream: &'a CacheStream,
|
||||
token: Option<i64>,
|
||||
}
|
||||
|
||||
impl<'a> Fetch<'a> {
|
||||
fn begin(stream: &'a CacheStream) -> Self {
|
||||
Fetch {
|
||||
stream,
|
||||
token: Some(stream.tracker.begin_fetch()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The fetch allocated `id`, which stays unfinished.
|
||||
fn allocated(mut self, id: i64) {
|
||||
if let Some(token) = self.token.take() {
|
||||
self.stream.tracker.end_fetch(token, Some(&[id]));
|
||||
self.stream.pending().push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fetch<'_> {
|
||||
fn drop(&mut self) {
|
||||
if let Some(token) = self.token.take() {
|
||||
self.stream.tracker.end_fetch(token, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CacheStream {
|
||||
fn drop(&mut self) {
|
||||
self.finish_pending();
|
||||
}
|
||||
}
|
||||
|
||||
/// `_invalidate_cache_and_stream`: invalidate `keys` in the named cache
|
||||
/// here once the transaction commits, and on the other workers through the
|
||||
/// cache stream.
|
||||
pub async fn invalidate_cache_and_stream(
|
||||
txn: &WriteTxn,
|
||||
input: &TxnInput<'_>,
|
||||
effects: &mut PersistEffects,
|
||||
cache_name: &str,
|
||||
keys: Vec<String>,
|
||||
) -> Result<(), WriteError> {
|
||||
let stream_id = input.cache_stream.next_id(txn).await?;
|
||||
txn.execute(
|
||||
"INSERT INTO cache_invalidation_stream_by_instance
|
||||
(stream_id, instance_name, cache_func, keys, invalidation_ts)
|
||||
VALUES ($1, $2, $3, $4, $5)",
|
||||
&[
|
||||
&stream_id,
|
||||
&input.instance_name,
|
||||
&cache_name,
|
||||
&keys,
|
||||
&input.now_ms()?,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
effects.add_cache_invalidation(cache_name, keys);
|
||||
effects.add_cache_stream_id(stream_id);
|
||||
effects.set_new_replication_data();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Allocate the next ID, as `next_id` does without the sequence.
|
||||
fn allocate(stream: &CacheStream, id: i64) {
|
||||
Fetch::begin(stream).allocated(id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_finished_unless_handed_over() {
|
||||
let tracker = Arc::new(StreamIdTracker::new(
|
||||
"caches".to_owned(),
|
||||
"master".to_owned(),
|
||||
true,
|
||||
));
|
||||
let stream = CacheStream::new(Arc::clone(&tracker));
|
||||
|
||||
// An attempt that failed: the next one finishes its IDs.
|
||||
allocate(&stream, 2);
|
||||
allocate(&stream, 3);
|
||||
assert_eq!(tracker.get_persisted_upto_position(), 1);
|
||||
stream.finish_pending();
|
||||
assert_eq!(tracker.get_persisted_upto_position(), 3);
|
||||
|
||||
// The attempt that committed hands its IDs to Python.
|
||||
allocate(&stream, 4);
|
||||
let mut effects = PersistEffects::default();
|
||||
effects.add_cache_stream_id(4);
|
||||
stream.hand_over(&effects);
|
||||
// Allocated after the hand-over, so not Python's: finished on drop.
|
||||
allocate(&stream, 5);
|
||||
drop(stream);
|
||||
assert_eq!(tracker.get_persisted_upto_position(), 3);
|
||||
|
||||
// Python finishes the IDs it was handed.
|
||||
tracker.mark_finished(&[4]);
|
||||
assert_eq!(tracker.get_persisted_upto_position(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_fetch_ends() {
|
||||
let tracker = Arc::new(StreamIdTracker::new(
|
||||
"caches".to_owned(),
|
||||
"master".to_owned(),
|
||||
true,
|
||||
));
|
||||
let stream = CacheStream::new(Arc::clone(&tracker));
|
||||
allocate(&stream, 2);
|
||||
// Dropped before the sequence answered, as a cancelled future is.
|
||||
drop(Fetch::begin(&stream));
|
||||
stream.finish_pending();
|
||||
// A fetch still in flight would hold the position at 1.
|
||||
assert_eq!(tracker.get_persisted_upto_position(), 2);
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,18 @@
|
||||
*
|
||||
*/
|
||||
|
||||
//! The room's extremities: `_update_forward_extremities_txn`,
|
||||
//! `_set_state_dag_extremities_txn` and `_update_backward_extremeties`.
|
||||
//! The room's DAG: the extremities (`_update_forward_extremities_txn`,
|
||||
//! `_set_state_dag_extremities_txn` and `_update_backward_extremeties`) and
|
||||
//! the edges (`_handle_mult_prev_events` and `_store_state_dag_edges`).
|
||||
|
||||
use std::{collections::HashSet, sync::Arc};
|
||||
|
||||
use anyhow::anyhow;
|
||||
|
||||
use super::Persisting;
|
||||
use crate::{
|
||||
database::postgres::write::{Column, WriteError, WriteTxn},
|
||||
events::Event,
|
||||
events::{constants::event_type, Event},
|
||||
};
|
||||
|
||||
/// `_update_forward_extremities_txn`: replace the room's forward
|
||||
@@ -193,3 +197,89 @@ pub async fn update_backward_extremities(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `_handle_mult_prev_events`: the `event_edges` of `events`, then their
|
||||
/// backward extremities. `events` must be of one room.
|
||||
pub async fn handle_mult_prev_events(
|
||||
txn: &WriteTxn,
|
||||
instance_name: &str,
|
||||
events: &[Persisting<'_>],
|
||||
) -> Result<(), WriteError> {
|
||||
let mut event_ids = Vec::new();
|
||||
let mut prev_event_ids = Vec::new();
|
||||
let mut backward = Vec::with_capacity(events.len());
|
||||
for persisting in events {
|
||||
let event = persisting.event();
|
||||
for prev in event.prev_event_ids() {
|
||||
event_ids.push(event.event_id());
|
||||
prev_event_ids.push(prev);
|
||||
}
|
||||
backward.push(BackwardEvent {
|
||||
event,
|
||||
outlier: persisting.source.is_outlier()?,
|
||||
stream_ordering: persisting.stream_ordering(),
|
||||
});
|
||||
}
|
||||
txn.insert_many(
|
||||
"event_edges",
|
||||
&[
|
||||
Column::new("event_id", "text", &event_ids),
|
||||
Column::new("prev_event_id", "text", &prev_event_ids),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
update_backward_extremities(txn, instance_name, &backward).await
|
||||
}
|
||||
|
||||
/// `_store_state_dag_edges`: the MSC4242 state DAG edges of the state
|
||||
/// events of `events` in a room version with a state DAG.
|
||||
pub async fn store_state_dag_edges(
|
||||
txn: &WriteTxn,
|
||||
events: &[Persisting<'_>],
|
||||
) -> Result<(), WriteError> {
|
||||
let mut room_ids = Vec::new();
|
||||
let mut event_ids = Vec::new();
|
||||
let mut prev_state_event_ids: Vec<Option<&str>> = Vec::new();
|
||||
for persisting in events {
|
||||
let event = persisting.event();
|
||||
if !event.room_version().msc4242_state_dags || event.state_key().is_none() {
|
||||
continue;
|
||||
}
|
||||
let prevs = event.prev_state_event_ids().unwrap_or_default();
|
||||
if prevs.is_empty() {
|
||||
// `get_state_dag` reads every row, so the create event, which has
|
||||
// no edge, gets one to nothing.
|
||||
if event.type_() != event_type::M_ROOM_CREATE {
|
||||
return Err(anyhow!(
|
||||
"state DAG event {} has no prev_state_events",
|
||||
event.event_id()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
room_ids.push(event.room_id());
|
||||
event_ids.push(event.event_id());
|
||||
prev_state_event_ids.push(None);
|
||||
continue;
|
||||
}
|
||||
for prev in prevs {
|
||||
room_ids.push(event.room_id());
|
||||
event_ids.push(event.event_id());
|
||||
prev_state_event_ids.push(Some(prev));
|
||||
}
|
||||
}
|
||||
// Python inserts the create event's row and upserts the rest. Only the
|
||||
// create event has a NULL edge, and the key of a NULL never conflicts,
|
||||
// so one upsert does both.
|
||||
txn.upsert_many(
|
||||
"msc4242_state_dag_edges",
|
||||
&[
|
||||
Column::new("room_id", "text", &room_ids),
|
||||
Column::new("event_id", "text", &event_ids),
|
||||
Column::new("prev_state_event_id", "text", &prev_state_event_ids),
|
||||
],
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -21,15 +21,19 @@
|
||||
//! cache updates Python queues with `txn.call_after`.
|
||||
//!
|
||||
//! This is being ported a group of steps at a time, from the start of the
|
||||
//! transaction. So far it covers the steps up to storing the rejections:
|
||||
//! the events themselves ([`events`]) and the room's forward extremities
|
||||
//! ([`edges`]). It returns the events left at that point. The Python steps after it run
|
||||
//! only in `tests/storage/test_rust_persist_txn.py`, which persists the
|
||||
//! rest of a chunk in Python and compares the rows with Python's.
|
||||
//! transaction. So far it covers the steps up to `_update_metadata_tables_txn`:
|
||||
//! the events themselves ([`events`]), the room's DAG ([`edges`]) and the
|
||||
//! tables about the events ([`metadata`]), with the cache stream rows
|
||||
//! ([`caches`]). It returns the events left at that point. The Python steps
|
||||
//! after it run only in `tests/storage/test_rust_persist_txn.py`, which
|
||||
//! persists the rest of a chunk in Python and compares the rows with
|
||||
//! Python's.
|
||||
|
||||
pub mod caches;
|
||||
pub mod edges;
|
||||
pub mod events;
|
||||
pub mod json;
|
||||
pub mod metadata;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -37,6 +41,7 @@ use anyhow::anyhow;
|
||||
use pyo3::PyErr;
|
||||
use serde_json::Value;
|
||||
|
||||
use self::caches::CacheStream;
|
||||
use super::{
|
||||
context::PersistContext,
|
||||
effects::{EventInvalidation, PersistEffects},
|
||||
@@ -118,7 +123,17 @@ pub struct TxnInput<'a> {
|
||||
pub prepared: &'a PreparedChunk,
|
||||
/// This instance, which writes the events.
|
||||
pub instance_name: &'a str,
|
||||
/// This server's name, for which users are ours.
|
||||
pub server_name: &'a str,
|
||||
pub clock: &'a Clock,
|
||||
/// The chunk was backfilled, so its memberships are not current.
|
||||
pub inhibit_local_membership_updates: bool,
|
||||
/// `server.enable_search`.
|
||||
pub enable_search: bool,
|
||||
/// `server.enable_ephemeral_messages`.
|
||||
pub enable_ephemeral_messages: bool,
|
||||
/// The cache stream IDs of this persist, over its attempts.
|
||||
pub cache_stream: &'a CacheStream,
|
||||
}
|
||||
|
||||
impl TxnInput<'_> {
|
||||
@@ -160,11 +175,28 @@ fn metadata_error(e: PyErr) -> WriteError {
|
||||
/// Python would queue with `txn.call_after`. Returns what the steps still
|
||||
/// to be ported take.
|
||||
///
|
||||
/// `effects` must start empty: a retried transaction starts again.
|
||||
/// `effects` must start empty: a retried transaction starts again. Once
|
||||
/// it commits, hand `effects` to `input.cache_stream`.
|
||||
pub async fn persist_events_txn<'a>(
|
||||
txn: &WriteTxn,
|
||||
input: &TxnInput<'a>,
|
||||
effects: &mut PersistEffects,
|
||||
) -> Result<TxnPrefix<'a>, WriteError> {
|
||||
// An earlier attempt whose commit failed left its cache stream IDs.
|
||||
input.cache_stream.finish_pending();
|
||||
let result = persist_events_txn_steps(txn, input, effects).await;
|
||||
if result.is_err() {
|
||||
// The rows are rolled back, so their IDs are done with, as Python's
|
||||
// `call_on_exception` has it.
|
||||
input.cache_stream.finish_pending();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn persist_events_txn_steps<'a>(
|
||||
txn: &WriteTxn,
|
||||
input: &TxnInput<'a>,
|
||||
effects: &mut PersistEffects,
|
||||
) -> Result<TxnPrefix<'a>, WriteError> {
|
||||
let room_id = input.room_id;
|
||||
|
||||
@@ -239,6 +271,8 @@ pub async fn persist_events_txn<'a>(
|
||||
// From here on, only events that were not rejected.
|
||||
let events = events::store_rejections(txn, input, events).await?;
|
||||
|
||||
metadata::update_metadata_tables(txn, input, &events, effects).await?;
|
||||
|
||||
Ok(TxnPrefix {
|
||||
events,
|
||||
de_outliered,
|
||||
|
||||
@@ -222,6 +222,10 @@ class DataStore(
|
||||
|
||||
for cache_name, keys in effects.cache_invalidations:
|
||||
self._attempt_to_invalidate_cache(cache_name, tuple(keys))
|
||||
if effects.cache_stream_ids:
|
||||
assert self._cache_id_gen is not None
|
||||
self._cache_id_gen._mark_ids_as_finished(effects.cache_stream_ids)
|
||||
self.hs.get_replication_notifier().notify_replication()
|
||||
|
||||
stream_change_caches: dict[str, StreamChangeCache] = {
|
||||
"events": self._events_stream_cache,
|
||||
|
||||
@@ -56,10 +56,13 @@ class Persister:
|
||||
room_id: str,
|
||||
events_and_contexts: list[tuple[EventBase, EventContext]],
|
||||
prepared: PreparedChunk,
|
||||
*,
|
||||
inhibit_local_membership_updates: bool,
|
||||
) -> tuple[PersistEffects, list[int], dict[int, str], list[int]]:
|
||||
"""Run the part of the write transaction ported to Rust so far over
|
||||
the chunk, whose events have their stream orderings set, and commit
|
||||
it. `prepared` is what Rust prepared for it.
|
||||
it. `prepared` is what Rust prepared for it. The cache stream IDs it
|
||||
wrote come back unfinished in the effects.
|
||||
|
||||
Returns what it collected for Python to apply, and for the Python
|
||||
steps after it, by position in the chunk: the events left, the
|
||||
@@ -120,6 +123,7 @@ class PersistEffects:
|
||||
event_cache_invalidations: list[str] = ...,
|
||||
state_caches: list[tuple[str, list[str]]] = ...,
|
||||
cache_invalidations: list[tuple[str, list[str]]] = ...,
|
||||
cache_stream_ids: list[int] = ...,
|
||||
state_group_prefills: list[tuple[str, int]] = ...,
|
||||
latest_event_ids: tuple[str, list[str]] | None = None,
|
||||
stream_changes: list[tuple[str, str, int]] = ...,
|
||||
@@ -150,6 +154,12 @@ class PersistEffects:
|
||||
"""`(cache name, key)`: the local half of
|
||||
`_invalidate_cache_and_stream`."""
|
||||
|
||||
@property
|
||||
def cache_stream_ids(self) -> list[int]:
|
||||
"""The IDs of the cache stream rows the transaction wrote, still
|
||||
unfinished. Python marks them finished once the caches are
|
||||
invalidated."""
|
||||
|
||||
@property
|
||||
def state_group_prefills(self) -> list[tuple[str, int]]:
|
||||
"""`(event ID, state group)` for `_get_state_group_for_event`."""
|
||||
|
||||
@@ -568,6 +568,54 @@ class PersistEffectsTestCase(unittest.HomeserverTestCase):
|
||||
self.assertEqual(seen_by_cache, [True])
|
||||
self.assertEqual(id_gen.get_current_token(), stream)
|
||||
|
||||
def test_cache_stream_ids_finish_after_the_caches(self) -> None:
|
||||
"""As Python's `get_next_txn`: a cache stream ID is finished once the
|
||||
cache is invalidated locally."""
|
||||
id_gen = self.store._cache_id_gen
|
||||
if id_gen is None:
|
||||
raise SkipTest("the cache stream needs Postgres")
|
||||
allocation = id_gen.get_next()
|
||||
stream_id = self.get_success(allocation.__aenter__())
|
||||
self.assertLess(
|
||||
id_gen.get_current_token_for_writer(id_gen._instance_name), stream_id
|
||||
)
|
||||
|
||||
calls: list[str] = []
|
||||
original_finish = id_gen._mark_ids_as_finished
|
||||
original_invalidate = self.store._attempt_to_invalidate_cache
|
||||
|
||||
def finish(ids: list[int]) -> None:
|
||||
calls.append("finish")
|
||||
original_finish(ids)
|
||||
|
||||
def invalidate(cache_name: str, key: Collection[Any] | None) -> bool:
|
||||
calls.append("invalidate")
|
||||
return original_invalidate(cache_name, key)
|
||||
|
||||
notifier = self.hs.get_replication_notifier()
|
||||
original_notify = notifier.notify_replication
|
||||
|
||||
def notify() -> None:
|
||||
calls.append("notify")
|
||||
original_notify()
|
||||
|
||||
with (
|
||||
patch.object(id_gen, "_mark_ids_as_finished", finish),
|
||||
patch.object(self.store, "_attempt_to_invalidate_cache", invalidate),
|
||||
patch.object(notifier, "notify_replication", notify),
|
||||
):
|
||||
self.store._apply_rust_persist_effects(
|
||||
PersistEffects(
|
||||
cache_invalidations=[("get_threads", [self.room_id])],
|
||||
cache_stream_ids=[stream_id],
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(calls, ["invalidate", "finish", "notify"])
|
||||
self.assertEqual(
|
||||
id_gen.get_current_token_for_writer(id_gen._instance_name), stream_id
|
||||
)
|
||||
|
||||
|
||||
class RustPersistConfigTestCase(unittest.TestCase):
|
||||
def _parse(self, level: Any) -> ExperimentalConfig:
|
||||
|
||||
@@ -60,6 +60,7 @@ from tests import unittest
|
||||
|
||||
# The tables `_persist_events_txn` writes. Each is compared on the rows of
|
||||
# the room, by its `room_id` column if it has one, else by `event_id`.
|
||||
# `event_edges` is also compared by `event_id`, see `_UNFILLED_ROOM_ID`.
|
||||
_TABLES = (
|
||||
"current_state_delta_stream",
|
||||
"current_state_events",
|
||||
@@ -102,6 +103,10 @@ _TABLES = (
|
||||
"timeline_gaps",
|
||||
)
|
||||
|
||||
# Tables with a `room_id` column that is no longer filled in, compared by
|
||||
# `event_id` instead.
|
||||
_UNFILLED_ROOM_ID = {"event_edges"}
|
||||
|
||||
# Integer columns holding a position on a stream or a state group, by
|
||||
# column name or `(table, column)`, and which sequence the value is from.
|
||||
# They differ between the twins, so they are compared by their rank among
|
||||
@@ -245,6 +250,8 @@ class _TwinTestBase(unittest.HomeserverTestCase):
|
||||
experimental = config.setdefault("experimental_features", {})
|
||||
experimental["rust_event_persistence"] = "prepare"
|
||||
experimental["msc4354_enabled"] = True
|
||||
# For `event_expiry`.
|
||||
config["enable_ephemeral_messages"] = True
|
||||
return config
|
||||
|
||||
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
|
||||
@@ -343,10 +350,19 @@ class _TwinTestBase(unittest.HomeserverTestCase):
|
||||
)
|
||||
effects, left, redacted, de_outliered = (
|
||||
self.persister.persist_events_txn_for_test(
|
||||
room_id, events_and_contexts, prepared
|
||||
room_id,
|
||||
events_and_contexts,
|
||||
prepared,
|
||||
inhibit_local_membership_updates=kwargs[
|
||||
"inhibit_local_membership_updates"
|
||||
],
|
||||
)
|
||||
)
|
||||
twin.effects.extend(_rust_effects(effects))
|
||||
# As applying the effects would, so the cache stream moves on.
|
||||
if effects.cache_stream_ids:
|
||||
assert self.store._cache_id_gen is not None
|
||||
self.store._cache_id_gen._mark_ids_as_finished(effects.cache_stream_ids)
|
||||
# The events left, as the steps after Rust's see them.
|
||||
left_events_and_contexts = []
|
||||
for i in left:
|
||||
@@ -374,7 +390,8 @@ class _TwinTestBase(unittest.HomeserverTestCase):
|
||||
for callback, args, kwargs in (txn.after_callbacks or [])[start:]:
|
||||
name = callback.__name__
|
||||
owner = getattr(callback, "__self__", None)
|
||||
# A single-key cache's `prefill` is a lambda over the cache.
|
||||
# A single-key cache's `prefill` and `invalidate` are lambdas
|
||||
# over the cache.
|
||||
cache = next(
|
||||
(
|
||||
cell.cell_contents
|
||||
@@ -385,18 +402,32 @@ class _TwinTestBase(unittest.HomeserverTestCase):
|
||||
)
|
||||
if owner is self.store:
|
||||
owner_name = None
|
||||
elif isinstance(owner, DeferredCache):
|
||||
owner_name = self._cache_names().get(id(owner), "DeferredCache")
|
||||
elif isinstance(owner, MultiWriterIdGenerator):
|
||||
owner_name = owner._stream_name
|
||||
elif owner is not None:
|
||||
owner_name = getattr(owner, "name", None) or type(owner).__name__
|
||||
elif name == "<lambda>" and cache is not None:
|
||||
# The transaction prefills only `_get_state_group_for_event`.
|
||||
name, owner_name = "prefill", "_get_state_group_for_event"
|
||||
# A single-key cache's `prefill` or `invalidate`, by the
|
||||
# method the lambda calls on the cache.
|
||||
code_names = callback.__code__.co_names
|
||||
name = "prefill" if "prefill" in code_names else "invalidate"
|
||||
owner_name = self._cache_names().get(id(cache), "DeferredCache")
|
||||
else:
|
||||
owner_name = callback.__qualname__
|
||||
effects.append((name, owner_name, args, kwargs))
|
||||
return effects
|
||||
|
||||
def _cache_names(self) -> dict[int, str]:
|
||||
"""The store's cached functions' names, by the ID of their cache."""
|
||||
names = {}
|
||||
for name in dir(type(self.store)):
|
||||
cache = getattr(getattr(self.store, name, None), "cache", None)
|
||||
if isinstance(cache, DeferredCache):
|
||||
names[id(cache)] = name
|
||||
return names
|
||||
|
||||
def _cache_stream_position(self) -> int:
|
||||
rows = self.get_success(
|
||||
self.store.db_pool.execute(
|
||||
@@ -410,7 +441,7 @@ class _TwinTestBase(unittest.HomeserverTestCase):
|
||||
return self.get_success(
|
||||
self.store.db_pool.execute(
|
||||
"test",
|
||||
"""SELECT stream_id, instance_name, cache_func, keys
|
||||
"""SELECT stream_id, instance_name, cache_func, keys, invalidation_ts
|
||||
FROM cache_invalidation_stream_by_instance
|
||||
WHERE stream_id > ? ORDER BY stream_id""",
|
||||
after,
|
||||
@@ -431,10 +462,12 @@ class _TwinTestBase(unittest.HomeserverTestCase):
|
||||
sender: str | None = None,
|
||||
txn_id: str | None = None,
|
||||
sticky_ms: int | None = None,
|
||||
push: bool = False,
|
||||
) -> EventPersistencePair:
|
||||
"""An event of the twin's, not yet persisted, added to its events.
|
||||
`prev` and `redacts` are indices of the twin's events. `content` can
|
||||
be a function of the twin, for content that names its events."""
|
||||
be a function of the twin, for content that names its events. With
|
||||
`push`, its push actions are staged, as sending it would."""
|
||||
sender = sender or self.alice
|
||||
if callable(content):
|
||||
content = content(twin)
|
||||
@@ -464,6 +497,12 @@ class _TwinTestBase(unittest.HomeserverTestCase):
|
||||
)
|
||||
)
|
||||
context = self.get_success(unpersisted_context.persist(event))
|
||||
if push:
|
||||
self.get_success(
|
||||
self.hs.get_bulk_push_rule_evaluator().action_for_events_by_user(
|
||||
[(event, context)]
|
||||
)
|
||||
)
|
||||
twin.events.append(event.event_id)
|
||||
return event, context
|
||||
|
||||
@@ -519,7 +558,7 @@ class _TwinTestBase(unittest.HomeserverTestCase):
|
||||
raw: dict[str, tuple[list[str], list[tuple]]] = {}
|
||||
for table in _TABLES:
|
||||
columns = self._columns(table)
|
||||
if "room_id" in columns:
|
||||
if "room_id" in columns and table not in _UNFILLED_ROOM_ID:
|
||||
where = "room_id = ?"
|
||||
elif table == "event_auth_chain_links":
|
||||
where = """origin_chain_id IN (
|
||||
@@ -556,8 +595,9 @@ class _TwinTestBase(unittest.HomeserverTestCase):
|
||||
key=repr,
|
||||
)
|
||||
dump["cache_invalidation_stream_by_instance"] = [
|
||||
(instance_name, cache_func, normaliser.value("", "", keys))
|
||||
for _, instance_name, cache_func, keys in twin.cache_rows
|
||||
# The twins share the clock, so the times compare as they are.
|
||||
(instance_name, cache_func, normaliser.value("", "", keys), ts)
|
||||
for _, instance_name, cache_func, keys, ts in twin.cache_rows
|
||||
]
|
||||
dump["effects"] = sorted(
|
||||
(
|
||||
@@ -700,6 +740,15 @@ def _rust_effects(
|
||||
)
|
||||
for event_id in effects.event_cache_invalidations:
|
||||
calls.append(("_invalidate_local_get_event_cache", None, (event_id,), {}))
|
||||
for cache_name, keys in effects.cache_invalidations:
|
||||
calls.append(("invalidate", cache_name, (tuple(keys),), {}))
|
||||
# Python's `get_next_txn` queues these per cache stream row. Applying
|
||||
# the effects finishes the IDs at once, and notifies once.
|
||||
for stream_id in effects.cache_stream_ids:
|
||||
calls.append(("_mark_ids_as_finished", "caches", ([stream_id],), {}))
|
||||
calls.append(("notify_replication", "ReplicationNotifier", (), {}))
|
||||
if effects.new_replication_data:
|
||||
calls.append(("on_new_replication_data", "Notifier", (), {}))
|
||||
for cache, entity, stream_id in effects.stream_changes:
|
||||
calls.append(
|
||||
(
|
||||
@@ -759,12 +808,8 @@ def _python_steps_after_rust(
|
||||
txn, [event for event in de_outliered if event.sticky_duration()]
|
||||
)
|
||||
|
||||
store._update_metadata_tables_txn(
|
||||
txn,
|
||||
events_and_contexts=events_and_contexts,
|
||||
all_events_and_contexts=all_events_and_contexts,
|
||||
inhibit_local_membership_updates=inhibit_local_membership_updates,
|
||||
)
|
||||
# The end of `_update_metadata_tables_txn`, a Python cache prefill.
|
||||
store._add_to_cache(txn, events_and_contexts)
|
||||
if state_delta_for_room:
|
||||
assert sliding_sync_table_changes is not None
|
||||
assert min_stream_order is not None
|
||||
@@ -841,7 +886,17 @@ class PersistTxnTestCase(_TwinTestBase):
|
||||
|
||||
self._step(target)
|
||||
target_index = len(self.twins[0].events) - 1
|
||||
self._send(EventTypes.Redaction, {}, redacts=target_index, prev=[base])
|
||||
|
||||
def redaction(twin: _Twin) -> list[EventPersistencePair]:
|
||||
event, context = self._build(
|
||||
twin, EventTypes.Redaction, {}, redacts=target_index, prev=[base]
|
||||
)
|
||||
# Without the target, the recheck cannot pass yet, so it is
|
||||
# stored as still needed.
|
||||
event.internal_metadata.recheck_redaction = True
|
||||
return [(event, context)]
|
||||
|
||||
self._step(redaction)
|
||||
self._step(lambda twin: [built[twin.name]])
|
||||
self._assert_twins_match()
|
||||
|
||||
@@ -1044,6 +1099,304 @@ class PersistTxnTestCase(_TwinTestBase):
|
||||
)
|
||||
self._assert_twins_match()
|
||||
|
||||
def _join_bob(self) -> None:
|
||||
self._send(EventTypes.Member, {"membership": "invite"}, state_key=self.bob)
|
||||
self._send(
|
||||
EventTypes.Member,
|
||||
{"membership": "join", "displayname": "Bob", "avatar_url": "mxc://a/b"},
|
||||
state_key=self.bob,
|
||||
sender=self.bob,
|
||||
)
|
||||
|
||||
def test_relations_and_their_redaction(self) -> None:
|
||||
"""Each kind of relation, then redactions of them that clear what
|
||||
was computed from them. Redacting the thread's latest reply moves
|
||||
the thread back, and redacting its last reply deletes it."""
|
||||
root = self._message("root")
|
||||
|
||||
def relates(rel_type: str, **extra: Any) -> int:
|
||||
return self._send(
|
||||
EventTypes.Message,
|
||||
lambda twin: {
|
||||
"msgtype": "m.text",
|
||||
"body": rel_type,
|
||||
"m.relates_to": {
|
||||
"rel_type": rel_type,
|
||||
"event_id": twin.events[root],
|
||||
**extra,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
edit = relates("m.replace")
|
||||
reference = relates("m.reference")
|
||||
annotation = relates("m.annotation", key="\U0001f44d")
|
||||
first_reply = relates("m.thread")
|
||||
second_reply = relates("m.thread")
|
||||
for target in (edit, reference, annotation, second_reply, first_reply):
|
||||
self._send(EventTypes.Redaction, {}, redacts=target)
|
||||
self._assert_twins_match()
|
||||
|
||||
def test_relation_and_redaction_in_one_chunk(self) -> None:
|
||||
"""A redaction runs its relation cleanup before the chunk's
|
||||
relations are stored, so the relation it redacts in the same chunk
|
||||
is kept."""
|
||||
root = self._message("root")
|
||||
|
||||
def chunk(twin: _Twin) -> list[EventPersistencePair]:
|
||||
reply = self._build(
|
||||
twin,
|
||||
EventTypes.Message,
|
||||
{
|
||||
"msgtype": "m.text",
|
||||
"body": "reply",
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.thread",
|
||||
"event_id": twin.events[root],
|
||||
},
|
||||
},
|
||||
)
|
||||
redaction = self._build(
|
||||
twin,
|
||||
EventTypes.Redaction,
|
||||
{},
|
||||
redacts=len(twin.events) - 1,
|
||||
prev=[root],
|
||||
)
|
||||
return [reply, redaction]
|
||||
|
||||
self._step(chunk)
|
||||
self._assert_twins_match()
|
||||
|
||||
def test_thread_replies_in_one_chunk(self) -> None:
|
||||
"""Each reply compares with the one before, so the thread's latest
|
||||
event is the later reply."""
|
||||
root = self._message("root")
|
||||
|
||||
def replies(twin: _Twin) -> list[EventPersistencePair]:
|
||||
return [
|
||||
self._build(
|
||||
twin,
|
||||
EventTypes.Message,
|
||||
{
|
||||
"msgtype": "m.text",
|
||||
"body": body,
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.thread",
|
||||
"event_id": twin.events[root],
|
||||
},
|
||||
},
|
||||
prev=[root],
|
||||
)
|
||||
for body in ("first", "second")
|
||||
]
|
||||
|
||||
self._step(replies)
|
||||
self._assert_twins_match()
|
||||
|
||||
def test_retention_in_one_chunk(self) -> None:
|
||||
"""Two policies, whose cache stream rows keep the events' order."""
|
||||
|
||||
def policies(twin: _Twin) -> list[EventPersistencePair]:
|
||||
# Different state keys, so state resolution has nothing to break
|
||||
# a tie on.
|
||||
return [
|
||||
self._build(
|
||||
twin,
|
||||
EventTypes.Retention,
|
||||
{"max_lifetime": lifetime},
|
||||
state_key=state_key,
|
||||
prev=[0],
|
||||
)
|
||||
for state_key, lifetime in (("", 1000), ("other", 2000))
|
||||
]
|
||||
|
||||
self._message("base")
|
||||
self._step(policies)
|
||||
self._assert_twins_match()
|
||||
|
||||
def test_search(self) -> None:
|
||||
self._send(EventTypes.Name, {"name": "Name"}, state_key="")
|
||||
self._send(
|
||||
EventTypes.Topic,
|
||||
{
|
||||
"topic": "plain",
|
||||
"m.topic": {
|
||||
"m.text": [
|
||||
{"mimetype": "text/html", "body": "<b>rich</b>"},
|
||||
{"body": "from m.topic"},
|
||||
]
|
||||
},
|
||||
},
|
||||
state_key="",
|
||||
)
|
||||
self._message("searchable words")
|
||||
self._assert_twins_match()
|
||||
|
||||
def test_retention(self) -> None:
|
||||
self._send(
|
||||
EventTypes.Retention,
|
||||
{"min_lifetime": 1000, "max_lifetime": 86_400_000},
|
||||
state_key="",
|
||||
)
|
||||
# No lifetimes, so no policy.
|
||||
self._send(EventTypes.Retention, {}, state_key="")
|
||||
self._assert_twins_match()
|
||||
|
||||
def test_labels_and_expiry(self) -> None:
|
||||
self._send(
|
||||
EventTypes.Message,
|
||||
{
|
||||
"msgtype": "m.text",
|
||||
"body": "labelled",
|
||||
"org.matrix.labels": ["#fun", "#work"],
|
||||
"org.matrix.self_destruct_after": 2_000_000_000_000,
|
||||
},
|
||||
)
|
||||
# Not an integer, so no expiry.
|
||||
self._send(
|
||||
EventTypes.Message,
|
||||
{
|
||||
"msgtype": "m.text",
|
||||
"body": "x",
|
||||
"org.matrix.self_destruct_after": "1",
|
||||
},
|
||||
)
|
||||
self._assert_twins_match()
|
||||
|
||||
def test_members(self) -> None:
|
||||
self._join_bob()
|
||||
self._send(
|
||||
EventTypes.Member,
|
||||
# A NUL is not allowed in the database, so it is not stored.
|
||||
{"membership": "join", "displayname": "B\u0000b", "avatar_url": 1},
|
||||
state_key=self.bob,
|
||||
sender=self.bob,
|
||||
)
|
||||
self._assert_twins_match()
|
||||
|
||||
def test_push_actions(self) -> None:
|
||||
"""Push actions move from the staging table, and a redaction
|
||||
removes them."""
|
||||
self._join_bob()
|
||||
target = self._send(
|
||||
EventTypes.Message,
|
||||
{"msgtype": "m.text", "body": "hello bob"},
|
||||
push=True,
|
||||
)
|
||||
self._assert_twins_match()
|
||||
for twin in self.twins:
|
||||
# Bob is notified.
|
||||
self.assertTrue(
|
||||
self.get_success(
|
||||
self.store.db_pool.simple_select_onecol(
|
||||
"event_push_actions",
|
||||
{"room_id": twin.room_id, "user_id": self.bob},
|
||||
"event_id",
|
||||
)
|
||||
)
|
||||
)
|
||||
self._send(EventTypes.Redaction, {}, redacts=target, push=True)
|
||||
self._assert_twins_match()
|
||||
rows = self.get_success(
|
||||
self.store.db_pool.simple_select_onecol(
|
||||
"event_push_actions",
|
||||
{"room_id": self.twins[1].room_id},
|
||||
"event_id",
|
||||
)
|
||||
)
|
||||
# The redaction itself does not notify.
|
||||
self.assertEqual(rows, [])
|
||||
self.assertEqual(
|
||||
self.get_success(
|
||||
self.store.db_pool.simple_select_onecol(
|
||||
"event_push_actions_staging", {}, "event_id"
|
||||
)
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
def _skip_hand_built_in_state_dag_rooms(self) -> None:
|
||||
if KNOWN_ROOM_VERSIONS[self.room_version].msc4242_state_dags:
|
||||
raise SkipTest("a hand-built event has no state DAG context")
|
||||
|
||||
def _out_of_band(
|
||||
self,
|
||||
twin: _Twin,
|
||||
membership: str,
|
||||
stripped_state: list[dict[str, Any]] | None,
|
||||
) -> EventPersistencePair:
|
||||
"""An out-of-band membership of Bob's, as a remote invite or knock,
|
||||
or its rejection, arrives over federation: an outlier."""
|
||||
event, _ = self._build(
|
||||
twin, EventTypes.Member, {"membership": "invite"}, state_key=self.bob
|
||||
)
|
||||
pdu = event.get_pdu_json()
|
||||
pdu["content"] = {"membership": membership}
|
||||
if membership != "invite":
|
||||
pdu["sender"] = self.bob
|
||||
pdu["unsigned"] = {}
|
||||
if stripped_state is not None:
|
||||
pdu["unsigned"][f"{membership}_room_state"] = stripped_state
|
||||
copy = make_event_from_dict(pdu, event.room_version, {})
|
||||
copy.internal_metadata.outlier = True
|
||||
copy.internal_metadata.out_of_band_membership = True
|
||||
copy.internal_metadata.stream_ordering = None
|
||||
twin.events[-1] = copy.event_id
|
||||
return copy, EventContext.for_outlier(self.hs.get_storage_controllers())
|
||||
|
||||
def test_out_of_band_invite_and_rejection(self) -> None:
|
||||
self._skip_hand_built_in_state_dag_rooms()
|
||||
|
||||
def stripped(event_type: str, content: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"type": event_type,
|
||||
"state_key": "",
|
||||
"sender": "@remote:other",
|
||||
"content": content,
|
||||
}
|
||||
|
||||
state = [
|
||||
stripped(EventTypes.Create, {"type": "m.space"}),
|
||||
stripped(EventTypes.RoomEncryption, {"algorithm": "m.megolm.v1"}),
|
||||
stripped(EventTypes.Name, {"name": "Remote"}),
|
||||
# Malformed, so ignored.
|
||||
{"type": EventTypes.Tombstone, "content": {}},
|
||||
]
|
||||
self._step(lambda twin: [self._out_of_band(twin, "invite", state)])
|
||||
self._step(lambda twin: [self._out_of_band(twin, "leave", None)])
|
||||
self._assert_twins_match()
|
||||
|
||||
def test_out_of_band_knock(self) -> None:
|
||||
self._skip_hand_built_in_state_dag_rooms()
|
||||
state = [
|
||||
{
|
||||
"type": EventTypes.Create,
|
||||
"state_key": "",
|
||||
"sender": "@remote:other",
|
||||
"content": {},
|
||||
},
|
||||
{
|
||||
"type": EventTypes.Name,
|
||||
"state_key": "",
|
||||
"sender": "@remote:other",
|
||||
# A NUL leaves the column out.
|
||||
"content": {"name": "a\u0000b"},
|
||||
},
|
||||
]
|
||||
self._step(lambda twin: [self._out_of_band(twin, "knock", state)])
|
||||
# No stripped state.
|
||||
self._step(lambda twin: [self._out_of_band(twin, "invite", None)])
|
||||
self._assert_twins_match()
|
||||
|
||||
def test_out_of_band_backfilled(self) -> None:
|
||||
"""A backfilled membership is not current."""
|
||||
self._skip_hand_built_in_state_dag_rooms()
|
||||
self._step(
|
||||
lambda twin: [self._out_of_band(twin, "invite", [])], backfilled=True
|
||||
)
|
||||
self._assert_twins_match()
|
||||
|
||||
def test_mismatch_is_found(self) -> None:
|
||||
"""The comparison sees a row that differs."""
|
||||
self._message("hello")
|
||||
@@ -1061,7 +1414,10 @@ class PersistTxnTestCase(_TwinTestBase):
|
||||
def test_missing_room(self) -> None:
|
||||
with self.assertRaisesRegex(RuntimeError, "Room does not exist !nope:test"):
|
||||
self.persister.persist_events_txn_for_test(
|
||||
"!nope:test", [], self.persister.last_prepared
|
||||
"!nope:test",
|
||||
[],
|
||||
self.persister.last_prepared,
|
||||
inhibit_local_membership_updates=False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user