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