Skip to main content

tuwunel_service/rooms/read_receipt/
mod.rs

1mod data;
2#[cfg(test)]
3mod tests;
4
5use std::{collections::BTreeMap, sync::Arc};
6
7use futures::{Stream, StreamExt, TryStreamExt};
8use ruma::{
9	MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomId, UInt, UserId,
10	api::appservice::event::push_events::v1::EphemeralData,
11	events::{
12		AnySyncEphemeralRoomEvent, SyncEphemeralRoomEvent,
13		receipt::{
14			Receipt, ReceiptEvent, ReceiptEventContent, ReceiptThread, ReceiptType, Receipts,
15		},
16	},
17	serde::Raw,
18};
19use serde_json::value::to_raw_value;
20use tuwunel_core::{
21	Result, debug,
22	debug::INFO_SPAN_LEVEL,
23	err,
24	matrix::{
25		Event,
26		pdu::{PduCount, PduId, RawPduId},
27	},
28	smallstr::SmallString,
29	smallvec::SmallVec,
30	utils::{BoolExt, IterStream},
31	warn,
32};
33
34use self::data::{Data, ReceiptItem};
35
36/// Private read receipts surfaced by `private_read_get`. One legacy
37/// unthreaded row plus zero or more per-thread rows; inline-1 catches the
38/// dominant case (a single unthreaded marker) without a heap alloc.
39pub type PrivateReadEvents = SmallVec<[Raw<AnySyncEphemeralRoomEvent>; 1]>;
40
41/// Stored thread-kind tag: `""` for `Unthreaded`, `"main"` for `Main`, or
42/// the event-id string for `Thread(...)`. v3+ event ids are 44 bytes
43/// including the leading `$`; 48 bytes inline matches the project's
44/// `StateKey` budget and stays inline for every realistic thread root.
45type ThreadKind = SmallString<[u8; 48]>;
46
47/// A private read marker write for one `(room, user, thread)` context.
48///
49/// `count` is the timeline position the marker addresses and `ts` the receipt
50/// timestamp. `announce` opens the sync gate, carrying the marker to the
51/// user's other devices; a marker the server writes on the user's behalf
52/// leaves it closed.
53#[derive(Clone, Copy, Debug)]
54pub struct PrivateRead<'a> {
55	pub room_id: &'a RoomId,
56	pub user_id: &'a UserId,
57	pub count: u64,
58	pub ts: MilliSecondsSinceUnixEpoch,
59	pub thread: &'a ReceiptThread,
60	pub announce: bool,
61}
62
63pub struct Service {
64	services: Arc<crate::services::OnceServices>,
65	db: Data,
66}
67
68impl crate::Service for Service {
69	fn build(args: &crate::Args<'_>) -> Result<Arc<Self>> {
70		Ok(Arc::new(Self {
71			services: args.services.clone(),
72			db: Data::new(args),
73		}))
74	}
75
76	fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
77}
78
79impl Service {
80	/// Replaces the previous read receipt when the incoming one advances.
81	///
82	/// Returns whether the receipt was stored. A re-posted marker allocates no
83	/// stream position, so appservice and federation delivery are both skipped.
84	#[tracing::instrument(
85		name = "receipt"
86		level = INFO_SPAN_LEVEL,
87		skip_all,
88		fields(
89			%room_id,
90			%user_id,
91			?event.content
92		)
93	)]
94	pub async fn readreceipt_update(
95		&self,
96		user_id: &UserId,
97		room_id: &RoomId,
98		event: &ReceiptEvent,
99	) -> bool {
100		if self
101			.db
102			.readreceipt_update(user_id, room_id, event)
103			.await
104			.is_false()
105		{
106			return false;
107		}
108
109		self.services
110			.sending
111			.send_edu_room_appservices(room_id, |buf| {
112				let edu = EphemeralData::Receipt(ReceiptEvent {
113					content: event.content.clone(),
114					room_id: room_id.to_owned(),
115				});
116
117				Ok(serde_json::to_writer(buf, &edu)?)
118			})
119			.await
120			.expect("edu serialization or flush failed");
121
122		if self.services.globals.user_is_local(user_id) {
123			self.services
124				.sending
125				.flush_room(room_id)
126				.await
127				.expect("room flush failed");
128		}
129
130		true
131	}
132
133	/// Gets every stored private read receipt for `(room, user)`. Returns
134	/// one ephemeral event per stored row (legacy unthreaded plus per-thread
135	/// rows). An empty result means no marker is set.
136	#[tracing::instrument(skip(self), level = "debug", name = "get_private")]
137	pub async fn private_read_get(
138		&self,
139		room_id: &RoomId,
140		user_id: &UserId,
141	) -> Result<PrivateReadEvents> {
142		let shortroomid = self
143			.services
144			.short
145			.get_shortroomid(room_id)
146			.await
147			.map_err(|e| {
148				err!(Database(warn!(
149					"Short room ID does not exist in database for {room_id}: {e}"
150				)))
151			})?;
152
153		let legacy = self
154			.private_read_get_count(room_id, user_id)
155			.await
156			.ok()
157			.map(|(count, ts)| (ThreadKind::new(), count, ts));
158
159		let events = legacy
160			.into_iter()
161			.stream()
162			.chain(
163				self.db
164					.private_read_threaded_stream(room_id, user_id),
165			)
166			.filter_map(async |(kind, count, ts)| {
167				self.build_private_read_event(shortroomid, count, ts, user_id, &kind)
168					.await
169			})
170			.collect()
171			.await;
172
173		Ok(events)
174	}
175
176	/// Gets the complete announced private read snapshot for `update` without
177	/// suppressing malformed rows.
178	///
179	/// A snapshot older than the gate predates this durable mirror, so those
180	/// rows fall back to the tolerant active-state read they always published
181	/// rather than withholding the whole room after an upgrade. A newer
182	/// snapshot means a concurrent announce; that fails the bounded room range
183	/// so its cursor remains pinned. A marker naming an event that no longer
184	/// resolves is skipped rather than failing the range, which would
185	/// otherwise repeat on every request and withhold the room indefinitely.
186	#[tracing::instrument(skip(self), level = "debug", name = "get_private_fallible")]
187	pub async fn private_read_get_fallible(
188		&self,
189		room_id: &RoomId,
190		user_id: &UserId,
191		update: u64,
192	) -> Result<PrivateReadEvents> {
193		let snapshot = self
194			.db
195			.private_read_sync_update_fallible(user_id, room_id)
196			.await?;
197
198		if snapshot < update {
199			debug!(%room_id, %user_id, "Serving pre-mirror private read from the active store.");
200			return self.private_read_get(room_id, user_id).await;
201		}
202
203		if snapshot > update {
204			return Err(err!(Database(
205				"Private read snapshot advanced while assembling a bounded sync range."
206			)));
207		}
208
209		let shortroomid = async {
210			self.services
211				.short
212				.get_shortroomid(room_id)
213				.await
214				.map_err(|e| {
215					err!(Database(warn!(
216						"Short room ID does not exist in database for {room_id}: {e}"
217					)))
218				})
219		};
220
221		let shortroomid = shortroomid.await?;
222		let events = self
223			.db
224			.private_read_sync_stream_fallible(room_id, user_id)
225			.try_filter_map(async |(kind, count, ts)| {
226				self.build_private_read_event_skippable(shortroomid, count, ts, user_id, &kind)
227					.await
228			})
229			.try_collect()
230			.await?;
231
232		let confirmed = self
233			.db
234			.private_read_sync_update_fallible(user_id, room_id)
235			.await?;
236
237		if confirmed != update {
238			return Err(err!(Database(
239				"Private read snapshot changed while assembling a bounded sync range."
240			)));
241		}
242
243		Ok(events)
244	}
245
246	/// Builds one announced private read row, skipping a marker whose event
247	/// no longer resolves.
248	///
249	/// The absent case returns `Ok(None)` so the bounded range assembles
250	/// without it. Decode failures and invalid timestamps still fail the
251	/// range as real inconsistencies.
252	async fn build_private_read_event_skippable(
253		&self,
254		shortroomid: u64,
255		count: u64,
256		ts: Option<u64>,
257		user_id: &UserId,
258		thread_kind: &str,
259	) -> Result<Option<Raw<AnySyncEphemeralRoomEvent>>> {
260		let skip = || {
261			debug!(
262				count,
263				thread_kind,
264				%user_id,
265				"Skipping a private read marker naming a missing event."
266			);
267
268			None
269		};
270
271		self.build_private_read_event_fallible(shortroomid, count, ts, user_id, thread_kind)
272			.await
273			.map(Some)
274			.or_else(|error| error.is_not_found().then(skip).ok_or(error))
275	}
276
277	async fn build_private_read_event(
278		&self,
279		shortroomid: u64,
280		count: u64,
281		ts: Option<u64>,
282		user_id: &UserId,
283		thread_kind: &str,
284	) -> Option<Raw<AnySyncEphemeralRoomEvent>> {
285		let thread = thread_kind_to_receipt(thread_kind).unwrap_or(ReceiptThread::Unthreaded);
286		let ts = ts
287			.and_then(UInt::new)
288			.map(MilliSecondsSinceUnixEpoch);
289
290		self.build_private_read_event_from(shortroomid, count, ts, user_id, thread)
291			.await
292			.ok()
293	}
294
295	async fn build_private_read_event_fallible(
296		&self,
297		shortroomid: u64,
298		count: u64,
299		ts: Option<u64>,
300		user_id: &UserId,
301		thread_kind: &str,
302	) -> Result<Raw<AnySyncEphemeralRoomEvent>> {
303		let thread = thread_kind_to_receipt(thread_kind)?;
304		let ts = ts
305			.map(|ts| {
306				UInt::new(ts)
307					.map(MilliSecondsSinceUnixEpoch)
308					.ok_or_else(|| err!(Database("Invalid private receipt timestamp {ts}.")))
309			})
310			.transpose()?;
311
312		self.build_private_read_event_from(shortroomid, count, ts, user_id, thread)
313			.await
314	}
315
316	async fn build_private_read_event_from(
317		&self,
318		shortroomid: u64,
319		count: u64,
320		ts: Option<MilliSecondsSinceUnixEpoch>,
321		user_id: &UserId,
322		thread: ReceiptThread,
323	) -> Result<Raw<AnySyncEphemeralRoomEvent>> {
324		let pdu_id: RawPduId = PduId {
325			shortroomid,
326			count: PduCount::Normal(count),
327		}
328		.into();
329		let pdu = self
330			.services
331			.timeline
332			.get_pdu_from_id(&pdu_id)
333			.await?;
334
335		let event_id: OwnedEventId = pdu.event_id().to_owned();
336		let user_id: OwnedUserId = user_id.to_owned();
337		let content: BTreeMap<OwnedEventId, Receipts> = BTreeMap::from_iter([(
338			event_id,
339			BTreeMap::from_iter([(
340				ReceiptType::ReadPrivate,
341				BTreeMap::from_iter([(user_id, Receipt { ts, thread })]),
342			)]),
343		)]);
344
345		let receipt_event_content = ReceiptEventContent(content);
346		let receipt_sync_event = SyncEphemeralRoomEvent { content: receipt_event_content };
347		let event = to_raw_value(&receipt_sync_event)?;
348
349		Ok(Raw::from_json(event))
350	}
351
352	/// Returns an iterator over the most recent read_receipts in a room that
353	/// happened after the event with id `since`.
354	#[tracing::instrument(skip(self), level = "debug")]
355	pub fn readreceipts_since<'a>(
356		&'a self,
357		room_id: &'a RoomId,
358		since: u64,
359		to: Option<u64>,
360	) -> impl Stream<Item = ReceiptItem<'_>> + Send + 'a {
361		self.db.readreceipts_since(room_id, since, to)
362	}
363
364	/// Returns read receipts in a bounded room range without suppressing
365	/// failures.
366	///
367	/// The lower bound is exclusive and the optional upper bound is inclusive.
368	/// Cursor, decode, and serialization failures remain in the stream for an
369	/// atomic caller to handle.
370	#[tracing::instrument(skip(self), level = "debug")]
371	pub fn readreceipts_since_fallible<'a>(
372		&'a self,
373		room_id: &'a RoomId,
374		since: u64,
375		to: Option<u64>,
376	) -> impl Stream<Item = Result<ReceiptItem<'_>>> + Send + 'a {
377		self.db
378			.readreceipts_since_fallible(room_id, since, to)
379	}
380
381	/// Sets a private read marker at PDU `count` for the given thread.
382	///
383	/// Unthreaded writes supersede prior per-thread rows so the room-wide
384	/// receipt subsumes thread state. Returns whether the marker advanced; a
385	/// position at or behind the stored one writes nothing.
386	#[tracing::instrument(skip(self), level = "debug", name = "set_private")]
387	pub async fn private_read_set(&self, private_read: PrivateRead<'_>) -> bool {
388		self.db.private_read_set(private_read).await
389	}
390
391	/// Returns the private read marker PDU count.
392	#[tracing::instrument(
393		name = "get_private_count",
394		level = "debug",
395		skip(self),
396		ret(level = "trace")
397	)]
398	pub async fn private_read_get_count(
399		&self,
400		room_id: &RoomId,
401		user_id: &UserId,
402	) -> Result<(u64, Option<u64>)> {
403		self.db
404			.private_read_get_count(room_id, user_id)
405			.await
406	}
407
408	/// Returns the announced unthreaded private read marker PDU count.
409	#[tracing::instrument(
410		name = "get_private_sync_count",
411		level = "debug",
412		skip(self),
413		ret(level = "trace")
414	)]
415	pub async fn private_read_sync_get_count(
416		&self,
417		room_id: &RoomId,
418		user_id: &UserId,
419	) -> Result<(u64, Option<u64>)> {
420		self.db
421			.private_read_sync_get_count(room_id, user_id)
422			.await
423	}
424
425	/// Returns the PDU count of the last private read update in this room.
426	///
427	/// Missing or unreadable update rows return zero for legacy callers.
428	/// Bounded sync callers use the fallible variant below so failures retain
429	/// the room cursor.
430	#[tracing::instrument(
431		name = "get_private_last",
432		level = "debug",
433		skip(self),
434		ret(level = "trace")
435	)]
436	pub async fn last_privateread_update(&self, user_id: &UserId, room_id: &RoomId) -> u64 {
437		self.db
438			.last_privateread_update(user_id, room_id)
439			.await
440	}
441
442	/// Returns the bounded-sync token for the last private read update.
443	///
444	/// A missing token is returned as zero. Database and decode failures are
445	/// preserved so a caller can retain its room cursor and retry the complete
446	/// range.
447	#[tracing::instrument(
448		name = "get_private_last_fallible",
449		level = "debug",
450		skip(self),
451		ret(level = "trace")
452	)]
453	pub async fn last_privateread_update_fallible(
454		&self,
455		user_id: &UserId,
456		room_id: &RoomId,
457	) -> Result<u64> {
458		self.db
459			.last_privateread_update_fallible(user_id, room_id)
460			.await
461	}
462
463	pub async fn delete_all_read_receipts(&self, room_id: &RoomId) -> Result {
464		self.db.delete_all_read_receipts(room_id).await
465	}
466}
467
468/// Reverse of `ReceiptThread::as_str`: parse a stored thread tag into the
469/// enum. Empty string maps to `Unthreaded`; `"main"` to `Main`; all other
470/// values must be valid event IDs.
471fn thread_kind_to_receipt(thread_kind: &str) -> Result<ReceiptThread> {
472	match thread_kind {
473		| "" => Ok(ReceiptThread::Unthreaded),
474		| "main" => Ok(ReceiptThread::Main),
475		| _ => OwnedEventId::try_from(thread_kind)
476			.map(ReceiptThread::Thread)
477			.map_err(|error| err!(Database("Invalid private receipt thread: {error}"))),
478	}
479}
480
481/// Packs read receipts into one sync event without suppressing malformed input.
482///
483/// Every input must deserialize as a receipt event. The caller receives any
484/// parse or serialization failure and can retain the bounded room cursor
485/// instead of publishing a partial event.
486pub fn pack_receipts_fallible<I>(
487	mut receipts: I,
488) -> Result<Raw<SyncEphemeralRoomEvent<ReceiptEventContent>>>
489where
490	I: Iterator<Item = Raw<AnySyncEphemeralRoomEvent>>,
491{
492	let json = receipts.try_fold(BTreeMap::new(), |mut json, value| -> Result<_> {
493		let value = serde_json::from_str::<SyncEphemeralRoomEvent<ReceiptEventContent>>(
494			value.json().get(),
495		)?;
496
497		for (event, receipt) in value.content {
498			json.insert(event, receipt);
499		}
500
501		Ok(json)
502	})?;
503
504	let content = ReceiptEventContent(json);
505	let event = to_raw_value(&SyncEphemeralRoomEvent { content })?;
506
507	Ok(Raw::from_json(event))
508}