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, 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	// MSC4380: when m.invite_permission_config blocks invites, omit invited
39	// rooms from the sliding-sync window; an unblock re-exposes them.
40	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	// Source retractions from tracked rooms, not a full left-state scan.
49	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			// MSC4380: suppress invited-room subscriptions when invites are blocked.
379			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}