Skip to main content

tuwunel_api/client/sync/v5/
selector.rs

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	// MSC4155: a stored invite whose sender the recipient ignores or blocks is
40	// kept out of the window, and relaxing the configuration re-exposes it.
41	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	// A recipient filtering nothing is the common case, and judging an invite
49	// costs a stripped-state load, so only the filtering path buffers futures.
50	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	// Source retractions from tracked rooms, not a full left-state scan.
66	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			// MSC4155: a subscription the window never admitted has no verdict
404			// yet, so an ignored or blocked invite is judged here instead.
405			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}