atagen

macltok

public
Star0
Watch0

Commit 3eee54c8ed63

tarball

atagen <boss@atagen.co>2026-10-27 03:38 UTC

3 files changed+232-9
mentions: give saved rows a sender profile after their capture

observe normalises live events with no profile, since it must not wait on the state store under the commit hook (6b04099), and supplement adds them to windows whose capture already finished, so they kept bare user ids for good. A pass outside the actor now looks bare senders up per room through the capture's profiles(), once per sender a run. profiles() also marks a room's members missing once when a sender isn't found, since stores have kept members_synced over a partial roster.
Modified file: src/engine/context.rs+27 additions−9 deletions
@@ -552,22 +552,40 @@ async fn fetch_reply(
552552
553553 /// under lazy-loaded members a sender may be unknown locally, and `get_member` fetches the
554554 /// member list once for the room when it is
555-async fn profiles<'a>(
555+pub(crate) async fn profiles<'a>(
556556 room: &Room,
557557 user_ids: impl Iterator<Item = &'a OwnedUserId>,
558558 ) -> HashMap<OwnedUserId, Sender> {
559559 let mut unique: Vec<&OwnedUserId> = user_ids.collect();
560560 unique.sort();
561561 unique.dedup();
562- join_all(
563- unique.into_iter().map(|user_id| async move {
562+ let lookup = |users: Vec<&'a OwnedUserId>| {
563+ join_all(users.into_iter().map(|user_id| async move {
564564 Some((user_id.clone(), member_sender(room, user_id).await?))
565- }),
566- )
567- .await
568- .into_iter()
569- .flatten()
570- .collect()
565+ }))
566+ };
567+ let mut found: HashMap<_, _> = lookup(unique.clone()).await.into_iter().flatten().collect();
568+ let missing: Vec<_> = unique
569+ .into_iter()
570+ .filter(|user| !found.contains_key(*user))
571+ .collect();
572+ // stores have been seen with members_synced persisted over a partial roster, after which
573+ // the sdk never asks /members again, so a sender it can't find marks it stale once a run
574+ if !missing.is_empty() && members_rechecked(room) {
575+ room.mark_members_missing();
576+ found.extend(lookup(missing).await.into_iter().flatten());
577+ }
578+ found
579+}
580+
581+fn members_rechecked(room: &Room) -> bool {
582+ static CHECKED: std::sync::LazyLock<
583+ std::sync::Mutex<std::collections::HashSet<(Option<OwnedUserId>, ruma::OwnedRoomId)>>,
584+ > = std::sync::LazyLock::new(Default::default);
585+ CHECKED.lock().unwrap().insert((
586+ room.client().user_id().map(ToOwned::to_owned),
587+ room.room_id().to_owned(),
588+ ))
571589 }
572590
573591 fn validate_limit(limit: u16) -> Result<(), Error> {
Modified file: src/mentions/service.rs+120 additions
@@ -118,6 +118,10 @@ pub(crate) struct Service {
118118 recovery_budget: usize,
119119 latest_scan: bool,
120120 recovery_retry: u64,
121+ /// senders a profile pass already looked up, found or not, so one without a profile isn't
122+ /// asked for again every second
123+ profiled: BTreeSet<(OwnedRoomId, OwnedUserId)>,
124+ profiling: bool,
121125 }
122126
123127 enum Work {
@@ -132,6 +136,11 @@ enum Work {
132136 Option<String>,
133137 Result<ruma::api::client::push::get_notifications::v3::Response, Error>,
134138 ),
139+ Profiles(
140+ OwnedRoomId,
141+ Vec<OwnedUserId>,
142+ HashMap<OwnedUserId, crate::Sender>,
143+ ),
135144 }
136145
137146 impl Work {
@@ -148,6 +157,7 @@ impl Work {
148157 .map(|notification| notification.room_id.clone())
149158 .collect(),
150159 Self::History(..) => BTreeSet::new(),
160+ Self::Profiles(room_id, ..) => BTreeSet::from([room_id.clone()]),
151161 }
152162 }
153163 }
@@ -326,6 +336,8 @@ impl Service {
326336 recovery_budget: 20,
327337 latest_scan: true,
328338 recovery_retry: 0,
339+ profiled: BTreeSet::new(),
340+ profiling: false,
329341 };
330342 service.ignored = service.ignored_users().await;
331343 Ok((
@@ -1208,6 +1220,7 @@ impl Service {
12081220 }
12091221 () = &mut tick => {
12101222 tick = Box::pin(matrix_sdk::sleep::sleep(Duration::from_secs(1)));
1223+ self.schedule_profiles(&mut work);
12111224 if now() >= next_scan && !history { self.recovery_budget = 20; self.latest_scan = true; next_scan = now() + 60_000; }
12121225 let direct = direct_rooms(&self.client);
12131226 if direct != self.direct { self.direct = direct; self.dirty = true; self.unpublished = true; }
@@ -1306,6 +1319,47 @@ impl Service {
13061319 }
13071320 }
13081321 }
1322+ Work::Profiles(room_id, users, found) => {
1323+ self.profiling = false;
1324+ self.profiled
1325+ .extend(users.into_iter().map(|user| (room_id.clone(), user)));
1326+ let bare = |item: &TimelineItem| match item {
1327+ TimelineItem::Event(event)
1328+ if event.sender.display_name.is_none() && event.sender.avatar.is_none() =>
1329+ {
1330+ found.get(&event.sender.user_id).cloned()
1331+ }
1332+ _ => None,
1333+ };
1334+ let prefix = format!("{room_id}\n");
1335+ let mut repaired = Vec::new();
1336+ for (key, saved) in next.events.range_mut(prefix.clone()..) {
1337+ if !key.starts_with(&prefix) {
1338+ break;
1339+ }
1340+ if let Some(sender) = bare(&saved.item)
1341+ && let TimelineItem::Event(event) = &mut saved.item
1342+ {
1343+ event.sender = sender;
1344+ repaired.push(key.clone());
1345+ }
1346+ }
1347+ let revision = next.revision + 1;
1348+ for (key, entry) in next.entries.range_mut(prefix.clone()..) {
1349+ if !key.starts_with(&prefix) {
1350+ break;
1351+ }
1352+ if let Some(sender) = bare(&entry.event)
1353+ && let TimelineItem::Event(event) = &mut entry.event
1354+ {
1355+ event.sender = sender;
1356+ entry.revision = revision;
1357+ }
1358+ }
1359+ for key in &repaired {
1360+ next.touch_contexts(key);
1361+ }
1362+ }
13091363 Work::History(latest, from, result) => {
13101364 *history = false;
13111365 self.recovery_budget = self.recovery_budget.saturating_sub(1);
@@ -1387,6 +1441,72 @@ impl Service {
13871441 true
13881442 }
13891443
1444+ /// rows observed after their window's capture never got a profile, so one room at a time
1445+ /// looks its bare senders up the way a capture does, outside the actor
1446+ fn schedule_profiles(&mut self, work: &mut FuturesUnordered<EngineBoxFuture<'static, Work>>) {
1447+ if self.profiling {
1448+ return;
1449+ }
1450+ let mut wanted: Option<(OwnedRoomId, BTreeSet<OwnedUserId>)> = None;
1451+ let items = self
1452+ .data
1453+ .events
1454+ .iter()
1455+ .map(|(key, saved)| (key, &saved.item))
1456+ .chain(
1457+ self.data
1458+ .entries
1459+ .iter()
1460+ .map(|(key, entry)| (key, &entry.event)),
1461+ );
1462+ for (key, item) in items {
1463+ let TimelineItem::Event(event) = item else {
1464+ continue;
1465+ };
1466+ if event.sender.display_name.is_some() || event.sender.avatar.is_some() {
1467+ continue;
1468+ }
1469+ let Some(room_id) = key
1470+ .split_once('\n')
1471+ .and_then(|(room, _)| OwnedRoomId::try_from(room).ok())
1472+ else {
1473+ continue;
1474+ };
1475+ if self.direct.contains(&room_id)
1476+ || self
1477+ .profiled
1478+ .contains(&(room_id.clone(), event.sender.user_id.clone()))
1479+ {
1480+ continue;
1481+ }
1482+ match &mut wanted {
1483+ None => wanted = Some((room_id, BTreeSet::from([event.sender.user_id.clone()]))),
1484+ Some((room, users)) if *room == room_id && users.len() < 50 => {
1485+ users.insert(event.sender.user_id.clone());
1486+ }
1487+ Some(_) => {}
1488+ }
1489+ }
1490+ let Some((room_id, users)) = wanted else {
1491+ return;
1492+ };
1493+ let Some(room) = self
1494+ .client
1495+ .get_room(&room_id)
1496+ .filter(|room| room.state() == RoomState::Joined)
1497+ else {
1498+ self.profiled
1499+ .extend(users.into_iter().map(|user| (room_id.clone(), user)));
1500+ return;
1501+ };
1502+ self.profiling = true;
1503+ let users: Vec<_> = users.into_iter().collect();
1504+ work.push(Box::pin(async move {
1505+ let found = context::profiles(&room, users.iter()).await;
1506+ Work::Profiles(room_id, users, found)
1507+ }));
1508+ }
1509+
13901510 fn schedule(
13911511 &mut self,
13921512 work: &mut FuturesUnordered<EngineBoxFuture<'static, Work>>,
Modified file: src/mentions/service_tests.rs+85 additions
@@ -778,3 +778,88 @@ async fn observing_again_keeps_the_profiles_a_capture_fetched() {
778778 );
779779 assert_eq!(data.entries[&ping].capture, capture);
780780 }
781+
782+#[tokio::test]
783+async fn a_profile_pass_names_rows_observed_after_their_capture() {
784+ let server = MatrixMockServer::new().await;
785+ let room_id = room_id!("!mentions:localhost");
786+ let (client, own_user) = joined_client(&server, room_id).await;
787+ let bob = user_id!("@bob:localhost");
788+ server
789+ .sync_room(
790+ &client,
791+ JoinedRoomBuilder::new(room_id).add_state_event(
792+ EventFactory::new()
793+ .room(room_id)
794+ .member(bob)
795+ .display_name("Bob"),
796+ ),
797+ )
798+ .await;
799+ let raw = |event: matrix_sdk::ruma::serde::Raw<_>, timestamp: u64| {
800+ let mut value: serde_json::Value = serde_json::from_str(event.json().get()).unwrap();
801+ value["origin_server_ts"] = json!(timestamp);
802+ value
803+ };
804+ let factory = EventFactory::new().room(room_id).sender(bob);
805+ let mention = raw(
806+ factory
807+ .text_msg("ping")
808+ .mentions(Mentions::with_user_ids([own_user]))
809+ .event_id(event_id!("$ping:localhost"))
810+ .into_raw_sync(),
811+ 10,
812+ );
813+ let following = raw(
814+ factory
815+ .text_msg("later")
816+ .event_id(event_id!("$later:localhost"))
817+ .into_raw_sync(),
818+ 11,
819+ );
820+ let mut service = service(&client, None).await;
821+ let room_id = room_id.to_owned();
822+ let mut data = Data::default();
823+ service.observe(&mut data, &room_id, mention).await;
824+ let ping = format!("{room_id}\n$ping:localhost");
825+ let revision = data.entries[&ping].revision;
826+ let target = data.entries[&ping].event.clone();
827+ service.captured(
828+ &mut data,
829+ ping.clone(),
830+ revision,
831+ Ok((
832+ crate::EventContext {
833+ events_before: Vec::new(),
834+ event: target,
835+ events_after: Vec::new(),
836+ start_token: None,
837+ end_token: None,
838+ },
839+ Vec::new(),
840+ Vec::new(),
841+ )),
842+ );
843+ service.observe(&mut data, &room_id, following).await;
844+ let later = format!("{room_id}\n$later:localhost");
845+ let name = |item: &TimelineItem| match item {
846+ TimelineItem::Event(event) => event.sender.display_name.clone(),
847+ _ => None,
848+ };
849+ assert_eq!(name(&data.events[&later].item), None);
850+
851+ service.data = data;
852+ let mut work = FuturesUnordered::new();
853+ service.schedule_profiles(&mut work);
854+ let result = work.next().await.expect("a profile pass was scheduled");
855+ let mut next = std::mem::take(&mut service.data);
856+ let (mut captures, mut retries, mut history) = (BTreeSet::new(), BTreeSet::new(), false);
857+ service
858+ .complete(&mut next, result, &mut captures, &mut retries, &mut history)
859+ .await;
860+ assert_eq!(name(&next.events[&later].item).as_deref(), Some("Bob"));
861+ assert_eq!(name(&next.entries[&ping].event).as_deref(), Some("Bob"));
862+ service.data = next;
863+ service.schedule_profiles(&mut work);
864+ assert!(work.is_empty());
865+}