1use std::{cmp::Ordering, collections::BTreeMap};
2
3use futures::{
4 FutureExt, StreamExt,
5 future::{join, join3},
6};
7use ruma::{
8 OwnedRoomId, RoomId, UInt, api::client::sync::sync_events::v5::ListId,
9 events::room::member::MembershipState, uint,
10};
11use tuwunel_core::{
12 Result, apply, debug_error, is_true, trace,
13 utils::{
14 BoolExt,
15 math::usize_from_ruma,
16 option::OptionExt,
17 stream::{BroadbandExt, IterStream},
18 },
19};
20use tuwunel_matrix::PduCount;
21use tuwunel_service::{sync::Connection, users::InviteFilter};
22
23use super::{
24 super::invite_permitted_room,
25 ListIds, ResponseLists, SyncInfo, Window, WindowRoom, departed,
26 filter::{filter_room, filter_room_meta, is_forgotten},
27};
28
29#[tracing::instrument(level = "debug", skip_all)]
30pub(super) async fn selector(
31 conn: &mut Connection,
32 sync_info: SyncInfo<'_>,
33) -> (Window, ResponseLists) {
34 use MembershipState::*;
35
36 let SyncInfo { services, sender_user, .. } = sync_info;
37
38 let invite_filter = services.users.invite_filter(sender_user).await;
41
42 let memberships = services
43 .state_cache
44 .user_memberships(sender_user, Some(&[Join, Invite, Knock]))
45 .map(|(membership, room_id)| (room_id.to_owned(), membership));
46
47 let actives = match invite_filter.is_permissive() {
50 | true => memberships
51 .map(|(room_id, membership)| (room_id, Some(membership)))
52 .left_stream(),
53 | false => memberships
54 .broad_filter_map(async |(room_id, membership)| {
55 let permitted = !matches!(membership, Invite)
56 || invite_permitted_room(services, sender_user, &room_id, &invite_filter)
57 .await;
58
59 permitted.then_some((room_id, Some(membership)))
60 })
61 .right_stream(),
62 };
63
64 let retracted = conn
66 .rooms
67 .keys()
68 .stream()
69 .broad_filter_map(async |room_id| {
70 services
71 .state_cache
72 .get_departure_count(room_id, sender_user)
73 .await
74 .is_ok()
75 .then_some((room_id.clone(), Some(Leave)))
76 });
77
78 let mut rooms = actives
79 .chain(retracted)
80 .broad_filter_map(|(room_id, membership)| matcher(sync_info, conn, room_id, membership))
81 .collect::<Vec<_>>()
82 .await;
83
84 rooms.sort_unstable_by(room_sort);
85 for room in &rooms {
86 conn.rooms
87 .entry(room.room_id.clone())
88 .or_default();
89 }
90
91 trace!(?rooms);
92 let lists = response_lists(conn.lists.keys(), rooms.iter());
93
94 trace!(?lists);
95 let window = window(sync_info, conn, rooms.iter(), &invite_filter).await;
96
97 trace!(?window);
98 (window, lists)
99}
100
101#[tracing::instrument(
102 name = "matcher",
103 level = "trace",
104 skip_all,
105 fields(?room_id, ?membership)
106)]
107async fn matcher(
108 sync_info: SyncInfo<'_>,
109 conn: &Connection,
110 room_id: OwnedRoomId,
111 membership: Option<MembershipState>,
112) -> Option<WindowRoom> {
113 let SyncInfo { services, sender_user, .. } = sync_info;
114
115 let (matched, lists) = conn
116 .lists
117 .iter()
118 .stream()
119 .filter_map(async |(id, list)| {
120 list.filters
121 .clone()
122 .map_async(async |filters| {
123 filter_room(sync_info, &filters, &room_id, membership.as_ref()).await
124 })
125 .await
126 .is_none_or(is_true!())
127 .then(|| id.clone())
128 })
129 .collect::<ListIds>()
130 .map(|lists| (lists.is_empty().is_false(), lists))
131 .await;
132
133 let selected =
134 matched || conn.subscriptions.contains_key(&room_id) || departed(membership.as_ref());
135
136 if !selected {
137 return None;
138 }
139
140 let membership_only = membership_only(membership.as_ref());
141
142 let last_notification = async {
143 if membership_only {
144 return ActivityProbe::default();
145 }
146
147 let result = services
148 .pusher
149 .last_notification_read(sender_user, &room_id)
150 .await;
151
152 activity_probe(result, &room_id, "notification read", true)
153 };
154
155 let last_timeline = async {
156 if membership_only {
157 return ActivityProbe::default();
158 }
159
160 let result = services
161 .timeline
162 .last_timeline_count(None, &room_id, Some(conn.next_batch.into()))
163 .await
164 .map(PduCount::into_unsigned);
165
166 activity_probe(result, &room_id, "timeline", false)
167 };
168
169 let last_membership = async {
170 let result = match &membership {
171 | Some(MembershipState::Join) => Some(
172 services
173 .state_cache
174 .get_joined_count(&room_id, sender_user)
175 .await,
176 ),
177 | Some(MembershipState::Invite) => Some(
178 services
179 .state_cache
180 .get_invite_count(&room_id, sender_user)
181 .await,
182 ),
183 | Some(MembershipState::Knock) => Some(
184 services
185 .state_cache
186 .get_knock_count(&room_id, sender_user)
187 .await,
188 ),
189 | Some(MembershipState::Leave | MembershipState::Ban) => Some(
190 services
191 .state_cache
192 .get_departure_count(&room_id, sender_user)
193 .await,
194 ),
195 | _ => None,
196 };
197
198 result.map_or_else(ActivityProbe::default, |result| {
199 activity_probe(result, &room_id, "membership", false)
200 })
201 };
202
203 let (last_timeline, last_notification, last_membership) =
204 join3(last_timeline, last_notification, last_membership).await;
205
206 let (event_count, payload_count) =
207 activity_counts(membership.as_ref(), conn.next_batch, ActivityCounts {
208 timeline: last_timeline.count,
209 notification: last_notification.count,
210 membership: last_membership.count,
211 payload_probe_failed: last_timeline.failed
212 || last_notification.failed
213 || last_membership.failed,
214 });
215
216 Some(WindowRoom {
217 room_id,
218 membership,
219 forgotten: false, lists,
221 event_count,
222 payload_count,
223 })
224}
225
226#[derive(Copy, Clone, Default)]
227struct ActivityProbe {
228 count: Option<u64>,
229 failed: bool,
230}
231
232#[derive(Copy, Clone)]
233struct ActivityCounts {
234 timeline: Option<u64>,
235 notification: Option<u64>,
236 membership: Option<u64>,
237 payload_probe_failed: bool,
238}
239
240fn activity_counts(
241 membership: Option<&MembershipState>,
242 next_batch: u64,
243 counts: ActivityCounts,
244) -> (u64, u64) {
245 let timeline = bounded_count(next_batch, counts.timeline);
246 let notification = bounded_count(next_batch, counts.notification);
247 let membership_count = bounded_count(next_batch, counts.membership);
248 let membership_only = membership_only(membership);
249 let event_count = if membership_only {
250 membership_count
251 } else {
252 timeline.max(membership_count)
253 };
254 let fresh_count = if membership_only {
255 membership_count
256 } else {
257 timeline.max(notification).max(membership_count)
258 };
259 let payload_count = if counts.payload_probe_failed {
260 next_batch
261 } else {
262 fresh_count
263 };
264
265 (event_count, payload_count)
266}
267
268fn activity_probe(
269 result: Result<u64>,
270 room_id: &RoomId,
271 source: &'static str,
272 missing_is_zero: bool,
273) -> ActivityProbe {
274 match result {
275 | Ok(count) => ActivityProbe { count: Some(count), failed: false },
276 | Err(error) if missing_is_zero && error.is_not_found() => ActivityProbe::default(),
277 | Err(error) => {
278 debug_error!(%room_id, %source, "Failed to read sliding sync activity: {error}");
279 ActivityProbe { count: None, failed: true }
280 },
281 }
282}
283
284#[inline]
285fn membership_only(membership: Option<&MembershipState>) -> bool {
286 matches!(
287 membership,
288 Some(
289 MembershipState::Invite
290 | MembershipState::Knock
291 | MembershipState::Leave
292 | MembershipState::Ban
293 )
294 )
295}
296
297fn bounded_count(next_batch: u64, count: Option<u64>) -> u64 {
298 count
299 .filter(|count| next_batch.ge(count))
300 .unwrap_or_default()
301}
302
303fn room_sort(a: &WindowRoom, b: &WindowRoom) -> Ordering {
304 b.event_count
305 .cmp(&a.event_count)
306 .then_with(|| a.room_id.cmp(&b.room_id))
307}
308
309fn response_lists<'a, Ids, Rooms>(ids: Ids, rooms: Rooms) -> ResponseLists
310where
311 Ids: Iterator<Item = &'a ListId>,
312 Rooms: Iterator<Item = &'a WindowRoom>,
313{
314 let lists: ResponseLists = ids
316 .map(|id| (id.clone(), Default::default()))
317 .collect();
318
319 rooms
320 .filter(|room| !departed(room.membership.as_ref()))
321 .flat_map(|room| room.lists.iter())
322 .fold(lists, |mut lists, id| {
323 let list = lists.entry(id.clone()).or_default();
324
325 list.count = list
326 .count
327 .checked_add(uint!(1))
328 .expect("list count must not overflow JsInt");
329
330 lists
331 })
332}
333
334#[tracing::instrument(
335 level = "debug",
336 skip_all,
337 fields(rooms = rooms.clone().count())
338)]
339async fn window<'a, Rooms>(
340 sync_info: SyncInfo<'_>,
341 conn: &Connection,
342 rooms: Rooms,
343 invite_filter: &InviteFilter,
344) -> Window
345where
346 Rooms: Iterator<Item = &'a WindowRoom> + Clone + Send + Sync,
347{
348 static FULL_RANGE: (UInt, UInt) = (UInt::MIN, UInt::MAX);
349
350 let SyncInfo { services, sender_user, .. } = sync_info;
351
352 let selections = conn
353 .lists
354 .iter()
355 .flat_map(|(id, list)| {
356 let full_range = list
357 .ranges
358 .is_empty()
359 .then_some(&FULL_RANGE)
360 .into_iter();
361
362 list.ranges
363 .iter()
364 .chain(full_range)
365 .map(apply!(2, usize_from_ruma))
366 .map(move |range| (id, range))
367 })
368 .flat_map(|(id, (start, end))| select_list_range(rooms.clone(), id, start, end).cloned())
369 .stream();
370
371 let indexed_subscriptions = (conn.subscriptions.len() > 1).then(|| {
372 rooms
373 .clone()
374 .filter(|room| conn.subscriptions.contains_key(&room.room_id))
375 .map(|room| (&room.room_id, room))
376 .collect::<BTreeMap<_, _>>()
377 });
378
379 let retractions = rooms
380 .clone()
381 .filter(|room| {
382 departed(room.membership.as_ref())
383 && conn
384 .rooms
385 .get(&room.room_id)
386 .is_some_and(|conn_room| {
387 conn_room.roomsince != 0 && room.payload_count > conn_room.roomsince
388 })
389 })
390 .cloned()
391 .map(detached_room)
392 .stream()
393 .broad_then(async |room| WindowRoom {
394 forgotten: is_forgotten(sync_info, &room.room_id).await,
395 ..room
396 });
397
398 let subscriptions = conn
399 .subscriptions
400 .iter()
401 .stream()
402 .broad_filter_map(async |(room_id, _)| {
403 let room = indexed_subscriptions
404 .as_ref()
405 .map_or_else(
406 || {
407 rooms
408 .clone()
409 .find(|room| room.room_id == *room_id)
410 },
411 |indexed| indexed.get(&room_id).copied(),
412 )
413 .cloned();
414
415 let (membership, filter) = if let Some(room) = &room {
416 (room.membership.clone(), filter_room_meta(sync_info, room_id).await)
417 } else {
418 join(
419 services
420 .state_cache
421 .user_membership(sender_user, room_id),
422 filter_room_meta(sync_info, room_id),
423 )
424 .await
425 };
426
427 if !filter {
428 return None;
429 }
430
431 if let Some(room) = room {
432 return Some(detached_room(room));
433 }
434
435 if matches!(membership, Some(MembershipState::Invite))
438 && !invite_permitted_room(services, sender_user, room_id, invite_filter).await
439 {
440 return None;
441 }
442
443 matcher(sync_info, conn, room_id.clone(), membership)
444 .await
445 .map(detached_room)
446 });
447
448 retractions
449 .chain(subscriptions)
450 .chain(selections)
451 .map(|room| (room.room_id.clone(), room))
452 .collect()
453 .await
454}
455
456fn detached_room(room: WindowRoom) -> WindowRoom { WindowRoom { lists: ListIds::new(), ..room } }
457
458fn select_list_range<'a, Rooms>(
459 rooms: Rooms,
460 id: &ListId,
461 start: usize,
462 end: usize,
463) -> impl Iterator<Item = &'a WindowRoom>
464where
465 Rooms: Iterator<Item = &'a WindowRoom>,
466{
467 rooms
468 .filter(move |room| room.lists.contains(id))
469 .filter(|room| !departed(room.membership.as_ref()))
470 .skip(start)
471 .take(end.saturating_add(1).saturating_sub(start))
472}
473
474#[cfg(test)]
475mod tests {
476 use ruma::{
477 OwnedRoomId, api::client::sync::sync_events::v5::ListId,
478 events::room::member::MembershipState, room_id, uint,
479 };
480
481 use super::{
482 ActivityCounts, ListIds, WindowRoom, activity_counts, detached_room, response_lists,
483 room_sort, select_list_range,
484 };
485
486 const NEXT_BATCH: u64 = 10;
487
488 #[test]
489 fn notification_activity_refreshes_payload_without_reordering() {
490 let (event_count, payload_count) =
491 activity_counts(Some(&MembershipState::Join), NEXT_BATCH, ActivityCounts {
492 timeline: Some(2),
493 notification: Some(8),
494 membership: Some(1),
495 payload_probe_failed: false,
496 });
497
498 assert_eq!((event_count, payload_count), (2, 8));
499
500 let mut rooms = [
501 room(room_id!("!current:example.com").to_owned(), 3, 3, None),
502 room(
503 room_id!("!notification:example.com").to_owned(),
504 event_count,
505 payload_count,
506 None,
507 ),
508 ];
509
510 rooms.sort_by(room_sort);
511 assert_eq!(rooms[0].room_id, room_id!("!current:example.com"));
512 }
513
514 #[test]
515 fn stripped_membership_rooms_use_only_membership_activity() {
516 for membership in [
517 MembershipState::Invite,
518 MembershipState::Knock,
519 MembershipState::Leave,
520 MembershipState::Ban,
521 ] {
522 let counts = activity_counts(Some(&membership), NEXT_BATCH, ActivityCounts {
523 timeline: Some(9),
524 notification: Some(8),
525 membership: Some(4),
526 payload_probe_failed: false,
527 });
528
529 assert_eq!(counts, (4, 4));
530 }
531 }
532
533 #[test]
534 fn activity_probe_failure_forces_payload_without_reordering() {
535 let counts = activity_counts(Some(&MembershipState::Join), NEXT_BATCH, ActivityCounts {
536 timeline: Some(2),
537 notification: None,
538 membership: Some(1),
539 payload_probe_failed: true,
540 });
541
542 assert_eq!(counts, (2, NEXT_BATCH));
543 }
544
545 #[test]
546 fn full_list_range_ignores_payload_freshness_and_departed_rooms() {
547 let list = ListId::from("main");
548 let mut rooms = [
549 room(
550 room_id!("!departed:example.com").to_owned(),
551 40,
552 40,
553 Some(MembershipState::Leave),
554 ),
555 room(room_id!("!stale:example.com").to_owned(), 30, 1, None),
556 room(room_id!("!fresh:example.com").to_owned(), 20, 20, None),
557 room(room_id!("!tail:example.com").to_owned(), 10, 10, None),
558 ];
559
560 for room in &mut rooms {
561 room.lists.push(list.clone());
562 }
563
564 let selected = select_list_range(rooms.iter(), &list, 0, 1)
565 .map(|room| room.room_id.clone())
566 .collect::<Vec<_>>();
567
568 assert_eq!(selected, [
569 room_id!("!stale:example.com").to_owned(),
570 room_id!("!fresh:example.com").to_owned(),
571 ]);
572 }
573
574 #[test]
575 fn equal_activity_rooms_have_deterministic_order() {
576 let mut rooms = [
577 room(room_id!("!z:example.com").to_owned(), 5, 5, None),
578 room(room_id!("!a:example.com").to_owned(), 5, 5, None),
579 ];
580
581 rooms.sort_by(room_sort);
582
583 assert_eq!(rooms.map(|room| room.room_id), [
584 room_id!("!a:example.com").to_owned(),
585 room_id!("!z:example.com").to_owned(),
586 ]);
587 }
588
589 #[test]
590 fn subscription_keeps_computed_payload_freshness() {
591 let mut room = room(room_id!("!subscribed:example.com").to_owned(), 7, 9, None);
592 room.lists.push(ListId::from("outside-range"));
593
594 let room = detached_room(room);
595
596 assert_eq!(room.payload_count, 9);
597 assert!(room.lists.is_empty());
598 }
599
600 #[test]
601 fn retraction_has_no_list_provenance() {
602 let mut room = room(
603 room_id!("!departed:example.com").to_owned(),
604 7,
605 9,
606 Some(MembershipState::Leave),
607 );
608
609 room.lists
610 .push(ListId::from("matched-before-leave"));
611
612 let room = detached_room(room);
613
614 assert!(room.lists.is_empty());
615 assert_eq!(room.membership, Some(MembershipState::Leave));
616 }
617
618 #[test]
619 fn unmatched_list_reports_zero() {
620 let all = ListId::from("all");
621 let invites = ListId::from("invites");
622 let joined = WindowRoom {
623 lists: [all.clone()].into(),
624 ..room(room_id!("!joined:example.com").to_owned(), 1, 1, Some(MembershipState::Join))
625 };
626
627 let lists = response_lists([&all, &invites].into_iter(), [joined].iter());
628
629 assert_eq!(lists.len(), 2);
630 assert_eq!(lists[&all].count, uint!(1));
631 assert_eq!(lists[&invites].count, uint!(0));
632 }
633
634 fn room(
635 room_id: OwnedRoomId,
636 event_count: u64,
637 payload_count: u64,
638 membership: Option<MembershipState>,
639 ) -> WindowRoom {
640 WindowRoom {
641 room_id,
642 membership,
643 forgotten: false,
644 lists: ListIds::new(),
645 event_count,
646 payload_count,
647 }
648 }
649}