atagen

macltok

public
Star0
Watch0

Commit 6b04099c763c

tarball

atagen <boss@atagen.co>2026-10-25 14:18 UTC

5 files changed+162-69
mentions: never wait on the event cache or the state store while observing

The offline reply and profile lookups awaited the event cache and the state store inside the mentions actor, which the sync commit hook waits on, and a resumed hosted inbox stopped answering mentions_page. Observing now settles a reply only from events the inbox already holds, and profiles and the rest come from the capture, which runs beside the actor.
Modified file: src/engine/context.rs+1 additions−47 deletions
@@ -18,7 +18,7 @@ use crate::{
1818 engine::MediaService,
1919 normalise::{
2020 MediaRegistration, MediaScope, apply_profiles, member_sender, normalise_context,
21- normalise_observed, resolve_replies, stored_member_sender,
21+ normalise_observed, resolve_replies,
2222 },
2323 transport::EventSender,
2424 };
@@ -550,52 +550,6 @@ async fn fetch_reply(
550550 }
551551 }
552552
553-/// settles an event built outside any page from what the client already holds, so it never
554-/// waits on the network. the sender gets its stored profile and the reply comes from `known`
555-/// or the event cache, or is unavailable
556-pub(crate) async fn settle_offline(
557- room: &Room,
558- current_user_id: Option<&ruma::UserId>,
559- item: &mut TimelineItem,
560- known: impl Fn(&ruma::EventId) -> Option<TimelineEvent>,
561-) {
562- let TimelineItem::Event(event) = item else {
563- return;
564- };
565- if let Some(sender) = stored_member_sender(room, &event.sender.user_id).await {
566- event.sender = sender;
567- }
568- let Some(reply) = &event.reply_to else {
569- return;
570- };
571- let target_id = reply.event_id.clone();
572- let mut target = known(&target_id);
573- if target.is_none()
574- && let Ok((cache, _handles)) = room.event_cache().await
575- && let Ok(Some(cached)) = cache.find_event(&target_id).await
576- {
577- target = serde_json::from_str::<Value>(cached.raw().json().get())
578- .ok()
579- .and_then(|value| {
580- let identity = format!("{}\n{target_id}", room.room_id());
581- match normalise_observed(&value, current_user_id, &identity)? {
582- (TimelineItem::Event(event), _) => Some(*event),
583- _ => None,
584- }
585- });
586- }
587- if let Some(target) = &mut target
588- && let Some(sender) = stored_member_sender(room, &target.sender.user_id).await
589- {
590- target.sender = sender;
591- }
592- let targets: HashMap<_, _> = target
593- .map(|target| (target_id, target))
594- .into_iter()
595- .collect();
596- resolve_replies(std::iter::once(&mut **event), &targets);
597-}
598-
599553 /// under lazy-loaded members a sender may be unknown locally, and `get_member` fetches the
600554 /// member list once for the room when it is
601555 async fn profiles<'a>(
Modified file: src/mentions/service.rs+16 additions−12 deletions
@@ -1,5 +1,5 @@
11 use std::{
2- collections::{BTreeMap, BTreeSet},
2+ collections::{BTreeMap, BTreeSet, HashMap},
33 sync::{
44 Arc,
55 atomic::{AtomicBool, Ordering},
@@ -32,7 +32,7 @@ use crate::{
3232 MentionGapReason, MentionKey, MentionReason, MentionsCommand, MentionsPage, MentionsUpdate,
3333 TimelineContent, TimelineItem,
3434 engine::{MediaService, MentionsStore, context, executor::EngineBoxFuture},
35- normalise::{MediaRegistration, MediaScope, normalise_observed},
35+ normalise::{MediaRegistration, MediaScope, normalise_observed, resolve_replies},
3636 transport::EventSender,
3737 };
3838
@@ -897,18 +897,22 @@ impl Service {
897897 else {
898898 return;
899899 };
900- if let Some(room) = self.client.get_room(room_id) {
901- let known = |event_id: &ruma::EventId| {
902- let key = id(&MentionKey {
903- room_id: room_id.clone(),
904- event_id: event_id.to_owned(),
905- });
906- match &data.events.get(&key)?.item {
907- TimelineItem::Event(event) => Some((**event).clone()),
908- _ => None,
900+ // the commit hook waits on this actor, so a reply settles here only from what the
901+ // inbox already holds, and the capture fetches profiles and anything else later
902+ if let TimelineItem::Event(event) = &mut item
903+ && let Some(reply) = &event.reply_to
904+ {
905+ let target = id(&MentionKey {
906+ room_id: room_id.clone(),
907+ event_id: reply.event_id.clone(),
908+ });
909+ let targets: HashMap<_, _> = match data.events.get(&target).map(|saved| &saved.item) {
910+ Some(TimelineItem::Event(saved)) => {
911+ HashMap::from([(reply.event_id.clone(), (**saved).clone())])
909912 }
913+ _ => HashMap::new(),
910914 };
911- context::settle_offline(&room, Some(&self.account), &mut item, known).await;
915+ resolve_replies(std::iter::once(&mut **event), &targets);
912916 }
913917 self.supplement(data, &key, &item, &media, &value);
914918 let (reasons, suppressed, unresolved) = self.classify(data, room_id, &value).await;
Modified file: src/normalise/member.rs+1 additions−1 deletions
@@ -33,7 +33,7 @@ pub(crate) async fn member_sender(room: &Room, user_id: &OwnedUserId) -> Option<
3333 }
3434
3535 /// what the state store already knows, without asking the server
36-pub(crate) async fn stored_member_sender(room: &Room, user_id: &OwnedUserId) -> Option<Sender> {
36+async fn stored_member_sender(room: &Room, user_id: &OwnedUserId) -> Option<Sender> {
3737 let member = room.get_member_no_sync(user_id).await.ok().flatten()?;
3838 Some(sender(user_id, &member))
3939 }
Modified file: src/normalise/mod.rs+1 additions−1 deletions
@@ -15,7 +15,7 @@ pub(crate) use image_pack::{
1515 };
1616 pub(crate) use link_preview::{ROOM_PREVIEW_URLS, server_preview, url_previews_allowed};
1717 pub(crate) use media::{MediaRegistration, MediaScope, MediaSourceInfo};
18-pub(crate) use member::{member_sender, member_summary, stored_member_sender};
18+pub(crate) use member::{member_sender, member_summary};
1919 pub(crate) use observed::{normalise_observed, poll_question};
2020 pub(crate) use room::notification_mode;
2121 pub(crate) use room::{room_summaries, space_child, stored_space_order};
Modified file: tests/mentions_inbox.rs+143 additions−8 deletions
@@ -1154,14 +1154,43 @@ async fn inbox_rows_carry_sender_profiles_and_never_leave_replies_pending() {
11541154 event["content"]["m.relates_to"] = json!({ "m.in_reply_to": { "event_id": target } });
11551155 event
11561156 };
1157- feed.replace(
1158- room_id,
1159- vec![
1160- reply("$answer", "$parent", 2_000),
1161- reply("$orphan", "$missing", 2_100),
1162- ],
1163- );
1164- let page = mentions_page(&mut events, room_id, |page| page.entries.len() == 2).await;
1157+ let answer = reply("$answer", "$parent", 2_000);
1158+ let orphan = reply("$orphan", "$missing", 2_100);
1159+ for event in [&answer, &orphan] {
1160+ let mut event = event.clone();
1161+ event["room_id"] = json!(room_id);
1162+ server
1163+ .mock_room_event_context()
1164+ .room(room_id.to_owned())
1165+ .match_event_id()
1166+ .ok(RoomContextResponseTemplate::new(
1167+ matrix_sdk::deserialized_responses::TimelineEvent::from_plaintext(
1168+ ruma::serde::Raw::new(&event).unwrap().cast_unchecked(),
1169+ ),
1170+ ))
1171+ .mount()
1172+ .await;
1173+ }
1174+ feed.replace(room_id, vec![answer, orphan]);
1175+ let settled = |page: &MentionsPage| {
1176+ page.entries.len() == 2
1177+ && page.entries.iter().all(|entry| {
1178+ matches!(&entry.event, macltok::TimelineItem::Event(event)
1179+ if event.sender.display_name.is_some()
1180+ && event.reply_to.as_ref().is_some_and(|reply| {
1181+ !matches!(reply.details, macltok::ReplyDetails::Pending)
1182+ }))
1183+ })
1184+ };
1185+ let page = mentions_page(&mut events, room_id, |page| {
1186+ settled(page)
1187+ && page.entries.iter().any(|entry| {
1188+ matches!(&entry.event, macltok::TimelineItem::Event(event)
1189+ if matches!(event.reply_to.as_ref().unwrap().details,
1190+ macltok::ReplyDetails::Ready { .. }))
1191+ })
1192+ })
1193+ .await;
11651194 for entry in &page.entries {
11661195 let macltok::TimelineItem::Event(event) = &entry.event else {
11671196 panic!("a mention should be an event");
@@ -1179,3 +1208,109 @@ async fn inbox_rows_carry_sender_profiles_and_never_leave_replies_pending() {
11791208 }
11801209 shut_down(&handle, &mut events, running).await;
11811210 }
1211+
1212+#[cfg(feature = "sqlite")]
1213+#[tokio::test]
1214+async fn a_resumed_inbox_with_threaded_replies_still_answers_a_page() {
1215+ static NEXT_TEMPORARY_PATH: AtomicUsize = AtomicUsize::new(0);
1216+
1217+ let server = MatrixMockServer::new().await;
1218+ server
1219+ .mock_versions()
1220+ .with_simplified_sliding_sync()
1221+ .ok()
1222+ .mount()
1223+ .await;
1224+ server.mock_login().ok().mount().await;
1225+ let root = std::env::temp_dir().join(format!(
1226+ "macltok-mentions-threads-{}-{}",
1227+ std::process::id(),
1228+ NEXT_TEMPORARY_PATH.fetch_add(1, Ordering::Relaxed)
1229+ ));
1230+ let sessions = Arc::new(EncryptedSessionStore::file(
1231+ root.join("records").join("session"),
1232+ StoreConfig::sqlite(root.join("store"), "mentions thread test passphrase"),
1233+ ));
1234+ let room_id = room_id!("!mentions-threads:matrix.org");
1235+ let own_user = user_id!("@cheeky_monkey:matrix.org");
1236+ let alice = "@alice:matrix.org";
1237+ let threaded = |event_id: &str, timestamp| {
1238+ let mut event = message(
1239+ event_id,
1240+ alice,
1241+ timestamp,
1242+ "threaded mention",
1243+ Some(own_user.as_str()),
1244+ );
1245+ event["content"]["m.relates_to"] = json!({
1246+ "rel_type": "m.thread",
1247+ "event_id": "$root",
1248+ "is_falling_back": true,
1249+ "m.in_reply_to": { "event_id": "$root" },
1250+ });
1251+ event
1252+ };
1253+ let feed = SlidingFeed::new(
1254+ room_id.to_owned(),
1255+ vec![
1256+ message("$root", alice, 6_000, "thread root", None),
1257+ threaded("$thread-a", 7_000),
1258+ threaded("$thread-b", 7_100),
1259+ message(
1260+ "$plain",
1261+ alice,
1262+ 7_200,
1263+ "plain mention",
1264+ Some(own_user.as_str()),
1265+ ),
1266+ ],
1267+ );
1268+ feed.set_required_state(
1269+ room_id,
1270+ vec![json!({
1271+ "type": "m.room.member",
1272+ "event_id": "$joined-member",
1273+ "sender": own_user,
1274+ "state_key": own_user,
1275+ "origin_server_ts": 1,
1276+ "content": { "membership": "join" },
1277+ })],
1278+ );
1279+ feed.mount(&server).await;
1280+
1281+ let config = EngineConfig::default()
1282+ .with_initial_history_limit(0)
1283+ .with_search_backfill_pages_per_room(0);
1284+ let session = Session::login_password(
1285+ PasswordLogin::new(server.uri(), "example", "wordpass").allow_insecure_loopback(true),
1286+ sessions.clone(),
1287+ )
1288+ .await
1289+ .unwrap();
1290+ let (engine, handle, mut events) = session.build(config.clone()).await.unwrap();
1291+ let running = tokio::spawn(engine.run());
1292+ mentions_page(&mut events, room_id, |page| page.entries.len() == 3).await;
1293+ shut_down(&handle, &mut events, running).await;
1294+ drop(handle);
1295+
1296+ let Some(Restore::Resumed(session)) = Session::restore(sessions.clone()).await.unwrap() else {
1297+ panic!("the saved session did not resume");
1298+ };
1299+ let (engine, handle, mut events) = session.build(config).await.unwrap();
1300+ let running = tokio::spawn(engine.run());
1301+ receive_until(&mut events, |event| {
1302+ matches!(event, Event::Authenticated { .. })
1303+ })
1304+ .await;
1305+ wait_for_requests(&feed, 4).await;
1306+ let resumed = tokio::time::timeout(Duration::from_secs(5), handle.mentions_page(None, 50))
1307+ .await
1308+ .expect("the resumed inbox did not answer a page")
1309+ .unwrap();
1310+ assert_eq!(resumed.entries.len(), 3);
1311+ shut_down(&handle, &mut events, running).await;
1312+ drop(handle);
1313+
1314+ sessions.discard().await.unwrap();
1315+ std::fs::remove_dir_all(root).unwrap();
1316+}