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,
13 matrix::PduCount,
14 trace,
15 utils::{
16 BoolExt,
17 math::usize_from_ruma,
18 option::OptionExt,
19 stream::{BroadbandExt, IterStream},
20 },
21};
22use tuwunel_service::{sync::Connection, users::InviteFilter};
23
24use super::{
25 super::invite_permitted_room,
26 ListIds, ResponseLists, SyncInfo, Window, WindowRoom,
27 filter::{filter_room, filter_room_meta},
28};
29
30#[tracing::instrument(level = "debug", skip_all)]
31pub(super) async fn selector(
32 conn: &mut Connection,
33 sync_info: SyncInfo<'_>,
34) -> (Window, ResponseLists) {
35 use MembershipState::*;
36
37 let SyncInfo { services, sender_user, .. } = sync_info;
38
39 let invite_filter = services.users.invite_filter(sender_user).await;
42
43 let memberships = services
44 .state_cache
45 .user_memberships(sender_user, Some(&[Join, Invite, Knock]))
46 .map(|(membership, room_id)| (room_id.to_owned(), membership));
47
48 let actives = match invite_filter.is_permissive() {
51 | true => memberships
52 .map(|(room_id, membership)| (room_id, Some(membership)))
53 .left_stream(),
54 | false => memberships
55 .broad_filter_map(async |(room_id, membership)| {
56 let permitted = !matches!(membership, Invite)
57 || invite_permitted_room(services, sender_user, &room_id, &invite_filter)
58 .await;
59
60 permitted.then_some((room_id, Some(membership)))
61 })
62 .right_stream(),
63 };
64
65 let retracted = conn
67 .rooms
68 .keys()
69 .stream()
70 .broad_filter_map(async |room_id| {
71 services
72 .state_cache
73 .is_left(sender_user, room_id)
74 .await
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(rooms.iter());
93
94 trace!(?lists);
95 let window = window(sync_info, conn, rooms.iter(), &lists, &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 = matched
134 || conn.subscriptions.contains_key(&room_id)
135 || matches!(membership, Some(MembershipState::Leave | MembershipState::Ban));
136
137 if !selected {
138 return None;
139 }
140
141 let membership_only = membership_only(membership.as_ref());
142
143 let last_notification = async {
144 if membership_only {
145 return ActivityProbe::default();
146 }
147
148 let result = services
149 .pusher
150 .last_notification_read(sender_user, &room_id)
151 .await;
152
153 activity_probe(result, &room_id, "notification read", true)
154 };
155
156 let last_timeline = async {
157 if membership_only {
158 return ActivityProbe::default();
159 }
160
161 let result = services
162 .timeline
163 .last_timeline_count(None, &room_id, Some(conn.next_batch.into()))
164 .await
165 .map(PduCount::into_unsigned);
166
167 activity_probe(result, &room_id, "timeline", false)
168 };
169
170 let last_membership = async {
171 let result = match &membership {
172 | Some(MembershipState::Join) => Some(
173 services
174 .state_cache
175 .get_joined_count(&room_id, sender_user)
176 .await,
177 ),
178 | Some(MembershipState::Invite) => Some(
179 services
180 .state_cache
181 .get_invite_count(&room_id, sender_user)
182 .await,
183 ),
184 | Some(MembershipState::Knock) => Some(
185 services
186 .state_cache
187 .get_knock_count(&room_id, sender_user)
188 .await,
189 ),
190 | Some(MembershipState::Leave | MembershipState::Ban) => Some(
191 services
192 .state_cache
193 .get_left_count(&room_id, sender_user)
194 .await,
195 ),
196 | _ => None,
197 };
198
199 result.map_or_else(ActivityProbe::default, |result| {
200 activity_probe(result, &room_id, "membership", false)
201 })
202 };
203
204 let (last_timeline, last_notification, last_membership) =
205 join3(last_timeline, last_notification, last_membership).await;
206
207 let (event_count, payload_count) =
208 activity_counts(membership.as_ref(), conn.next_batch, ActivityCounts {
209 timeline: last_timeline.count,
210 notification: last_notification.count,
211 membership: last_membership.count,
212 payload_probe_failed: last_timeline.failed
213 || last_notification.failed
214 || last_membership.failed,
215 });
216
217 Some(WindowRoom {
218 room_id,
219 membership,
220 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
303#[tracing::instrument(
304 level = "debug",
305 skip_all,
306 fields(rooms = rooms.clone().count())
307)]
308async fn window<'a, Rooms>(
309 sync_info: SyncInfo<'_>,
310 conn: &Connection,
311 rooms: Rooms,
312 lists: &ResponseLists,
313 invite_filter: &InviteFilter,
314) -> Window
315where
316 Rooms: Iterator<Item = &'a WindowRoom> + Clone + Send + Sync,
317{
318 static FULL_RANGE: (UInt, UInt) = (UInt::MIN, UInt::MAX);
319
320 let SyncInfo { services, sender_user, .. } = sync_info;
321
322 let selections = lists
323 .keys()
324 .filter_map(|id| conn.lists.get(id).map(|list| (id, list)))
325 .flat_map(|(id, list)| {
326 let full_range = list
327 .ranges
328 .is_empty()
329 .then_some(&FULL_RANGE)
330 .into_iter();
331
332 list.ranges
333 .iter()
334 .chain(full_range)
335 .map(apply!(2, usize_from_ruma))
336 .map(move |range| (id, range))
337 })
338 .flat_map(|(id, (start, end))| {
339 select_list_range(rooms.clone(), id, start, end)
340 .map(|room| (room.room_id.clone(), room.clone()))
341 })
342 .stream();
343
344 let indexed_subscriptions = (conn.subscriptions.len() > 1).then(|| {
345 rooms
346 .clone()
347 .filter(|room| conn.subscriptions.contains_key(&room.room_id))
348 .map(|room| (&room.room_id, room))
349 .collect::<BTreeMap<_, _>>()
350 });
351
352 let retractions = rooms
353 .clone()
354 .filter(|room| {
355 matches!(room.membership, Some(MembershipState::Leave | MembershipState::Ban))
356 && conn
357 .rooms
358 .get(&room.room_id)
359 .is_some_and(|conn_room| {
360 conn_room.roomsince != 0 && room.payload_count > conn_room.roomsince
361 })
362 })
363 .map(|room| (room.room_id.clone(), detached_room(room.clone())))
364 .stream();
365
366 let subscriptions = conn
367 .subscriptions
368 .iter()
369 .stream()
370 .broad_filter_map(async |(room_id, _)| {
371 let room = indexed_subscriptions
372 .as_ref()
373 .map_or_else(
374 || {
375 rooms
376 .clone()
377 .find(|room| room.room_id == *room_id)
378 },
379 |indexed| indexed.get(&room_id).copied(),
380 )
381 .cloned();
382
383 let (membership, filter) = if let Some(room) = &room {
384 (room.membership.clone(), filter_room_meta(sync_info, room_id).await)
385 } else {
386 join(
387 services
388 .state_cache
389 .user_membership(sender_user, room_id),
390 filter_room_meta(sync_info, room_id),
391 )
392 .await
393 };
394
395 if !filter {
396 return None;
397 }
398
399 if let Some(room) = room {
400 return Some(detached_room(room));
401 }
402
403 if matches!(membership, Some(MembershipState::Invite))
406 && !invite_permitted_room(services, sender_user, room_id, invite_filter).await
407 {
408 return None;
409 }
410
411 matcher(sync_info, conn, room_id.clone(), membership)
412 .await
413 .map(detached_room)
414 })
415 .map(|room| (room.room_id.clone(), room));
416
417 retractions
418 .chain(subscriptions)
419 .chain(selections)
420 .collect()
421 .await
422}
423
424fn detached_room(room: WindowRoom) -> WindowRoom { WindowRoom { lists: ListIds::new(), ..room } }
425
426fn select_list_range<'a, Rooms>(
427 rooms: Rooms,
428 id: &ListId,
429 start: usize,
430 end: usize,
431) -> impl Iterator<Item = &'a WindowRoom>
432where
433 Rooms: Iterator<Item = &'a WindowRoom>,
434{
435 rooms
436 .filter(move |room| room.lists.contains(id))
437 .filter(|room| {
438 !matches!(room.membership, Some(MembershipState::Leave | MembershipState::Ban))
439 })
440 .skip(start)
441 .take(end.saturating_add(1).saturating_sub(start))
442}
443
444fn response_lists<'a, Rooms>(rooms: Rooms) -> ResponseLists
445where
446 Rooms: Iterator<Item = &'a WindowRoom>,
447{
448 rooms
449 .filter(|room| {
450 !matches!(room.membership, Some(MembershipState::Leave | MembershipState::Ban))
451 })
452 .flat_map(|room| room.lists.iter())
453 .fold(ResponseLists::default(), |mut lists, id| {
454 let list = lists.entry(id.clone()).or_default();
455 list.count = list
456 .count
457 .checked_add(uint!(1))
458 .expect("list count must not overflow JsInt");
459
460 lists
461 })
462}
463
464fn room_sort(a: &WindowRoom, b: &WindowRoom) -> Ordering {
465 b.event_count
466 .cmp(&a.event_count)
467 .then_with(|| a.room_id.cmp(&b.room_id))
468}
469
470#[cfg(test)]
471mod tests {
472 use ruma::{
473 OwnedRoomId, api::client::sync::sync_events::v5::ListId,
474 events::room::member::MembershipState, room_id,
475 };
476
477 use super::{
478 ActivityCounts, ListIds, WindowRoom, activity_counts, detached_room, room_sort,
479 select_list_range,
480 };
481
482 const NEXT_BATCH: u64 = 10;
483
484 #[test]
485 fn notification_activity_refreshes_payload_without_reordering() {
486 let (event_count, payload_count) =
487 activity_counts(Some(&MembershipState::Join), NEXT_BATCH, ActivityCounts {
488 timeline: Some(2),
489 notification: Some(8),
490 membership: Some(1),
491 payload_probe_failed: false,
492 });
493
494 assert_eq!((event_count, payload_count), (2, 8));
495
496 let mut rooms = [
497 room(room_id!("!current:example.com").to_owned(), 3, 3, None),
498 room(
499 room_id!("!notification:example.com").to_owned(),
500 event_count,
501 payload_count,
502 None,
503 ),
504 ];
505
506 rooms.sort_by(room_sort);
507 assert_eq!(rooms[0].room_id, room_id!("!current:example.com"));
508 }
509
510 #[test]
511 fn stripped_membership_rooms_use_only_membership_activity() {
512 for membership in [
513 MembershipState::Invite,
514 MembershipState::Knock,
515 MembershipState::Leave,
516 MembershipState::Ban,
517 ] {
518 let counts = activity_counts(Some(&membership), NEXT_BATCH, ActivityCounts {
519 timeline: Some(9),
520 notification: Some(8),
521 membership: Some(4),
522 payload_probe_failed: false,
523 });
524
525 assert_eq!(counts, (4, 4));
526 }
527 }
528
529 #[test]
530 fn activity_probe_failure_forces_payload_without_reordering() {
531 let counts = activity_counts(Some(&MembershipState::Join), NEXT_BATCH, ActivityCounts {
532 timeline: Some(2),
533 notification: None,
534 membership: Some(1),
535 payload_probe_failed: true,
536 });
537
538 assert_eq!(counts, (2, NEXT_BATCH));
539 }
540
541 #[test]
542 fn full_list_range_ignores_payload_freshness_and_departed_rooms() {
543 let list = ListId::from("main");
544 let mut rooms = [
545 room(
546 room_id!("!departed:example.com").to_owned(),
547 40,
548 40,
549 Some(MembershipState::Leave),
550 ),
551 room(room_id!("!stale:example.com").to_owned(), 30, 1, None),
552 room(room_id!("!fresh:example.com").to_owned(), 20, 20, None),
553 room(room_id!("!tail:example.com").to_owned(), 10, 10, None),
554 ];
555
556 for room in &mut rooms {
557 room.lists.push(list.clone());
558 }
559
560 let selected = select_list_range(rooms.iter(), &list, 0, 1)
561 .map(|room| room.room_id.clone())
562 .collect::<Vec<_>>();
563
564 assert_eq!(selected, [
565 room_id!("!stale:example.com").to_owned(),
566 room_id!("!fresh:example.com").to_owned(),
567 ]);
568 }
569
570 #[test]
571 fn equal_activity_rooms_have_deterministic_order() {
572 let mut rooms = [
573 room(room_id!("!z:example.com").to_owned(), 5, 5, None),
574 room(room_id!("!a:example.com").to_owned(), 5, 5, None),
575 ];
576
577 rooms.sort_by(room_sort);
578
579 assert_eq!(rooms.map(|room| room.room_id), [
580 room_id!("!a:example.com").to_owned(),
581 room_id!("!z:example.com").to_owned(),
582 ]);
583 }
584
585 #[test]
586 fn subscription_keeps_computed_payload_freshness() {
587 let mut room = room(room_id!("!subscribed:example.com").to_owned(), 7, 9, None);
588 room.lists.push(ListId::from("outside-range"));
589
590 let room = detached_room(room);
591
592 assert_eq!(room.payload_count, 9);
593 assert!(room.lists.is_empty());
594 }
595
596 #[test]
597 fn retraction_has_no_list_provenance() {
598 let mut room = room(
599 room_id!("!departed:example.com").to_owned(),
600 7,
601 9,
602 Some(MembershipState::Leave),
603 );
604
605 room.lists
606 .push(ListId::from("matched-before-leave"));
607
608 let room = detached_room(room);
609
610 assert!(room.lists.is_empty());
611 assert_eq!(room.membership, Some(MembershipState::Leave));
612 }
613
614 fn room(
615 room_id: OwnedRoomId,
616 event_count: u64,
617 payload_count: u64,
618 membership: Option<MembershipState>,
619 ) -> WindowRoom {
620 WindowRoom {
621 room_id,
622 membership,
623 lists: ListIds::new(),
624 event_count,
625 payload_count,
626 }
627 }
628}