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, 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	// MSC4155: a stored invite whose sender the recipient ignores or blocks is
39	// kept out of the window, and relaxing the configuration re-exposes it.
40	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	// A recipient filtering nothing is the common case, and judging an invite
48	// costs a stripped-state load, so only the filtering path buffers futures.
49	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	// Source retractions from tracked rooms, not a full left-state scan.
65	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, // Subscriptions filter these, and selections exclude departures.
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
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	// matrix-rust-sdk polls with `timeout=0` until a list reports its count.
315	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			// MSC4155: a subscription the window never admitted has no verdict
436			// yet, so an ignored or blocked invite is judged here instead.
437			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}