Skip to main content

tuwunel_service/sending/sender/select/
receipts.rs

1use std::{
2	collections::BTreeMap,
3	sync::atomic::{AtomicU64, AtomicUsize, Ordering},
4};
5
6use futures::{Stream, StreamExt, pin_mut};
7use ruma::{
8	OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, ServerName, UserId,
9	api::federation::transactions::edu::{Edu, ReceiptContent, ReceiptData, ReceiptMap},
10	events::{
11		AnySyncEphemeralRoomEvent,
12		receipt::{Receipt, ReceiptEventContent, ReceiptType},
13	},
14	serde::Raw,
15};
16use tuwunel_core::{
17	error, implement,
18	smallvec::SmallVec,
19	utils::{BoolExt, ReadyExt, stream::BroadbandExt},
20};
21
22use super::{Selected, edu_buf};
23use crate::sending::{EduBuf, Service};
24
25#[cfg(test)]
26mod tests;
27
28/// The `receipts` field of one `Edu::Receipt`, keyed by room.
29///
30/// Each room carries at most one `ReceiptData` per user.
31type Receipts = BTreeMap<OwnedRoomId, ReceiptMap>;
32
33/// Per-rank slice of receipt EDU output.
34///
35/// Each entry becomes one `Edu::Receipt` buffer; rank 0 carries each user's
36/// earliest receipt in the window, rank 1 the next, and so on. Most windows
37/// produce a single rank.
38type RankedReceipts = SmallVec<[ReceiptMap; 1]>;
39
40/// The ranks of every room gathered for one federation EDU window.
41///
42/// Most windows produce a single rank, so inline-1 avoids a heap touch.
43type RankReceipts = SmallVec<[Receipts; 1]>;
44
45const USER_LIMIT: usize = 256;
46
47/// Select read-receipt EDUs across every room shared with the server.
48///
49/// MSC3771 lets a user emit multiple receipts in the same EDU window, one
50/// per thread context. The federation EDU shape allows only one
51/// `ReceiptData` per `(room, user)` slot, so a user with N parallel
52/// thread receipts ships across N parallel `Edu::Receipt` buffers within
53/// the same transaction. Each buffer is shape-compliant; receivers
54/// process them as independent receipt EDUs and our storage keeps each
55/// thread distinct.
56#[implement(Service)]
57#[tracing::instrument(
58	name = "receipts",
59	level = "trace",
60	skip(self, server_name, max_edu_count, events_len)
61)]
62pub(super) async fn select_edus_receipts(
63	&self,
64	server_name: &ServerName,
65	since: (u64, u64),
66	max_edu_count: &AtomicU64,
67	events_len: &AtomicUsize,
68) -> Selected {
69	let num = AtomicUsize::new(0);
70	let by_rank = self
71		.services
72		.state_cache
73		.server_rooms(server_name)
74		.map(ToOwned::to_owned)
75		.broad_filter_map(async |room_id| {
76			let ranked = self
77				.select_edus_receipts_room(&room_id, since, max_edu_count, &num)
78				.await;
79
80			ranked
81				.is_empty()
82				.is_false()
83				.then_some((room_id, ranked))
84		})
85		.ready_fold(RankReceipts::new(), |mut by_rank, (room_id, ranked)| {
86			for (rank, map) in ranked.into_iter().enumerate() {
87				if rank >= by_rank.len() {
88					by_rank.push(Receipts::new());
89				}
90
91				by_rank[rank].insert(room_id.clone(), map);
92			}
93
94			by_rank
95		})
96		.await;
97
98	// Ranks reserve from the shared budget in order; those past the cap
99	// overflow to the queue instead of truncating the tail.
100	by_rank
101		.into_iter()
102		.map(serialize_edu)
103		.fold(Selected::default(), |mut selected, edu| {
104			selected.push(edu, events_len);
105			selected
106		})
107}
108
109/// Look for read receipts in this room.
110///
111/// The receipt-limit budget bounds distinct users only; subsequent thread
112/// receipts for an already-counted user do not consume additional budget.
113#[implement(Service)]
114#[tracing::instrument(
115	name = "receipts",
116	level = "trace",
117	skip(self, since, max_edu_count)
118)]
119async fn select_edus_receipts_room(
120	&self,
121	room_id: &RoomId,
122	since: (u64, u64),
123	max_edu_count: &AtomicU64,
124	num: &AtomicUsize,
125) -> RankedReceipts {
126	let receipts = self
127		.services
128		.read_receipt
129		.readreceipts_since(room_id, since.0, Some(since.1))
130		.ready_filter_map(|(user_id, count, raw)| {
131			debug_assert!(count <= since.1, "exceeds upper-bound");
132			max_edu_count.fetch_max(count, Ordering::Relaxed);
133
134			if !self.services.globals.user_is_local(user_id) {
135				return None;
136			}
137
138			parse_receipt(user_id, count, &raw).map(|receipt| (user_id.to_owned(), receipt))
139		});
140
141	rank_receipts(receipts, num).await
142}
143
144/// Pivot a room's count-ordered receipts into rank-major order.
145///
146/// A user's k-th receipt in the window lands in rank k, so rank 0 is the first
147/// receipt of every user and the budget counts a user once, on that rank. The
148/// ranks holding a user are therefore a prefix, which is what lets the next
149/// free rank be found by bisection.
150async fn rank_receipts(
151	receipts: impl Stream<Item = (OwnedUserId, ReceiptData)>,
152	num: &AtomicUsize,
153) -> RankedReceipts {
154	pin_mut!(receipts);
155	let mut ranked = RankedReceipts::new();
156
157	while let Some((user_id, receipt)) = receipts.next().await {
158		let rank = ranked.partition_point(|map| map.read.contains_key(&user_id));
159
160		if rank == ranked.len() {
161			ranked.push(ReceiptMap { read: BTreeMap::new() });
162		}
163
164		let prior = ranked[rank].read.insert(user_id, receipt);
165		debug_assert!(prior.is_none(), "rank already holds this user");
166
167		// The crossing user still ships: its count already advanced the watermark.
168		if rank == 0 && num.fetch_add(1, Ordering::Relaxed) >= USER_LIMIT {
169			break;
170		}
171	}
172
173	ranked
174}
175
176fn serialize_edu(receipts: Receipts) -> EduBuf {
177	edu_buf(&Edu::Receipt(ReceiptContent { receipts }))
178}
179
180fn parse_receipt(
181	user_id: &UserId,
182	count: u64,
183	raw: &Raw<AnySyncEphemeralRoomEvent>,
184) -> Option<ReceiptData> {
185	let Ok(event) = raw.deserialize() else {
186		error!(?user_id, ?count, ?raw, "Invalid edu event in read_receipts.");
187		return None;
188	};
189
190	let AnySyncEphemeralRoomEvent::Receipt(receipt) = event else {
191		error!(?user_id, ?count, ?event, "Invalid event type in read_receipts");
192		return None;
193	};
194
195	let Some((event_id, data)) = own_read_receipt(receipt.content, user_id) else {
196		error!(?user_id, ?count, "Read receipt event lacks the user's own receipt.");
197		return None;
198	};
199
200	Some(ReceiptData { data, event_ids: vec![event_id] })
201}
202
203/// The event and receipt a user's own read receipt names.
204///
205/// Our stored receipts carry one event with one `Read` entry for the user;
206/// anything else is malformed.
207fn own_read_receipt(
208	content: ReceiptEventContent,
209	user_id: &UserId,
210) -> Option<(OwnedEventId, Receipt)> {
211	let (event_id, receipts) = content.0.into_iter().next()?;
212	let data = receipts
213		.into_iter()
214		.find_map(|(kind, users)| (kind == ReceiptType::Read).then_some(users))?
215		.into_iter()
216		.find_map(|(id, data)| (id == user_id).then_some(data))?;
217
218	Some((event_id, data))
219}