Skip to main content

tuwunel_service/rooms/read_receipt/
data.rs

1use std::{collections::BTreeMap, sync::Arc};
2
3use futures::{
4	Stream, TryStreamExt,
5	future::{join, try_join},
6};
7use ruma::{
8	CanonicalJsonObject, EventId, OwnedEventId, RoomId, UserId,
9	events::{AnySyncEphemeralRoomEvent, receipt::ReceiptEvent},
10	serde::Raw,
11};
12use serde::{Deserialize, de::IgnoredAny};
13use tuwunel_core::{
14	Result, error,
15	matrix::pdu::PduCount,
16	result::NotFound,
17	smallvec::SmallVec,
18	trace,
19	utils::{ReadyExt, TryReadyExt, stream::TryIgnore},
20};
21use tuwunel_database::{Deserialized, Interfix, Json, KeyBuf, Map, Txn, serialize_key};
22
23use super::{PrivateRead, ThreadKind};
24
25pub(super) struct Data {
26	roomuserid_privateread: Arc<Map>,
27	roomuserid_lastprivatereadupdate: Arc<Map>,
28	roomuserid_privatereadsync: Arc<Map>,
29	services: Arc<crate::services::OnceServices>,
30	readreceiptid_readreceipt: Arc<Map>,
31}
32
33pub(super) type ReceiptItem<'a> = (&'a UserId, u64, Raw<AnySyncEphemeralRoomEvent>);
34
35/// Row shape shared by the active and mirrored private read maps.
36type RowKv<'a> = ((&'a RoomId, &'a UserId, &'a str), (u64, Option<u64>));
37
38/// Receipt rows an accepted update replaces.
39///
40/// A user normally holds one row per thread context; an unthreaded sweep can
41/// also catch a pre-MSC3771 row, and that second key spills to the heap.
42type Superseded = SmallVec<[KeyBuf; 1]>;
43
44/// Minimal read-back of a stored receipt row.
45///
46/// Only the content's event ids are read, so the reject path never
47/// materializes the receipts themselves. The wire shape is a JSON object,
48/// which no set type can express, hence the zero-sized value.
49#[derive(Deserialize)]
50#[expect(clippy::zero_sized_map_values)]
51struct StoredContent {
52	content: BTreeMap<OwnedEventId, IgnoredAny>,
53}
54
55impl Data {
56	pub(super) fn new(args: &crate::Args<'_>) -> Self {
57		let db = &args.db;
58		Self {
59			roomuserid_privateread: db["roomuserid_privateread"].clone(),
60			roomuserid_lastprivatereadupdate: db["roomuserid_lastprivatereadupdate"].clone(),
61			roomuserid_privatereadsync: db["roomuserid_privatereadsync"].clone(),
62			readreceiptid_readreceipt: db["readreceiptid_readreceipt"].clone(),
63			services: args.services.clone(),
64		}
65	}
66
67	/// Stores `event` as the user's receipt for its thread context, reporting
68	/// whether it advanced.
69	///
70	/// A receipt naming the stored event, or an earlier one, is rejected
71	/// without allocating a stream position or writing anything. An accepted
72	/// receipt replaces every superseded row in one transaction.
73	#[inline]
74	pub(super) async fn readreceipt_update(
75		&self,
76		user_id: &UserId,
77		room_id: &RoomId,
78		event: &ReceiptEvent,
79	) -> bool {
80		// Remote-supplied content reaches this sink over federation, so an
81		// empty receipt is rejected rather than stored as an unreadable row.
82		let Some(event_id) = event.content.keys().next() else {
83			return false;
84		};
85
86		let thread_kind = event_thread_kind(event);
87		// MSC3771: storage key suffix is `user_id || 0xFF || thread_kind` so
88		// each (user, thread-context) tuple lives in its own row. Pre-MSC3771
89		// rows have no kind tail; on an Unthreaded sweep also match the
90		// bare-user-id ending so legacy rows are superseded rather than
91		// orphaned. Kind tails ("main", `$root`) never end in `@user:host`,
92		// so the legacy match cannot collide with thread-aware rows.
93		let suffix = serialize_key((user_id, thread_kind))
94			.expect("failed to serialize receipt key suffix");
95
96		let user_id_bytes = user_id.as_bytes();
97		let legacy_match = thread_kind.is_empty();
98
99		// A bare room-id prefix also matches longer room ids, whose rows sort
100		// below ours in reverse iteration and would be reaped by the sweep.
101		let room_prefix =
102			serialize_key((room_id, Interfix)).expect("failed to serialize receipt room prefix");
103
104		let last_possible_key = (room_id, u64::MAX);
105		let (superseded, current) = self
106			.readreceiptid_readreceipt
107			.rev_stream_from_raw(&last_possible_key)
108			.ignore_err()
109			.ready_take_while(|(key, _)| key.starts_with(room_prefix.as_slice()))
110			.ready_filter_map(|(key, val)| {
111				(key.ends_with(suffix.as_slice())
112					|| (legacy_match && key.ends_with(user_id_bytes)))
113				.then_some((key, val))
114			})
115			.ready_fold((Superseded::new(), None), |(mut superseded, current), (key, val)| {
116				let current = superseded
117					.is_empty()
118					.then_some(val)
119					.and_then(stored_event_id)
120					.or(current);
121
122				superseded.push(key.into());
123
124				(superseded, current)
125			})
126			.await;
127
128		if !self
129			.receipt_advanced(current.as_deref(), event_id)
130			.await
131		{
132			return false;
133		}
134
135		let count = self.services.globals.next_count();
136		let latest_id = (room_id, *count, user_id, thread_kind);
137
138		let mut txn = superseded
139			.iter()
140			.fold(self.services.db.txn(), |mut txn, key| {
141				txn.del_raw(&self.readreceiptid_readreceipt, key);
142				txn
143			});
144
145		txn.put(&self.readreceiptid_readreceipt, latest_id, Json(event));
146		txn.execute();
147
148		true
149	}
150
151	/// Whether a receipt for `incoming` supersedes the stored one at
152	/// `current`.
153	///
154	/// An identical event id never advances. A position that does not resolve
155	/// to a known PDU falls through to acceptance, so a receipt this server
156	/// cannot order is never silently dropped.
157	async fn receipt_advanced(&self, current: Option<&EventId>, incoming: &EventId) -> bool {
158		match current {
159			| None => true,
160			| Some(current) if current == incoming => false,
161			| Some(current) => {
162				let (current, incoming) = join(
163					self.services.timeline.get_pdu_count(current),
164					self.services.timeline.get_pdu_count(incoming),
165				)
166				.await;
167
168				position_advances(current.ok(), incoming.ok())
169			},
170		}
171	}
172
173	#[inline]
174	pub(super) fn readreceipts_since<'a>(
175		&'a self,
176		room_id: &'a RoomId,
177		since: u64,
178		to: Option<u64>,
179	) -> impl Stream<Item = ReceiptItem<'_>> + Send + 'a {
180		self.readreceipts_since_fallible(room_id, since, to)
181			.ignore_err()
182	}
183
184	#[inline]
185	pub(super) fn readreceipts_since_fallible<'a>(
186		&'a self,
187		room_id: &'a RoomId,
188		since: u64,
189		to: Option<u64>,
190	) -> impl Stream<Item = Result<ReceiptItem<'_>>> + Send + 'a {
191		// 4-tuple key: pre-MSC3771 rows deserialize with `&str` tail empty.
192		type Key<'a> = (&'a RoomId, u64, &'a UserId, &'a str);
193		type KeyVal<'a> = (Key<'a>, CanonicalJsonObject);
194
195		let after_since = since.saturating_add(1); // +1 so we don't send the event at since
196		let first_possible_edu = (room_id, after_since);
197
198		self.readreceiptid_readreceipt
199			.stream_from(&first_possible_edu)
200			.ready_try_take_while(move |((r, c, ..), _): &KeyVal<'_>| {
201				Ok(*r == room_id && to.is_none_or(|to| *c <= to))
202			})
203			.ready_and_then(move |((_, count, user_id, _), mut json): KeyVal<'_>| {
204				json.remove("room_id");
205
206				let event = serde_json::value::to_raw_value(&json)?;
207
208				Ok((user_id, count, Raw::from_json(event)))
209			})
210	}
211
212	/// Sets the private read marker for `(room, user, thread)`, reporting
213	/// whether it advanced.
214	///
215	/// Unthreaded writes use the legacy 2-tuple `(room, user)` key shape
216	/// and sweep any pre-existing per-thread rows so the room-wide receipt
217	/// supersedes prior thread state. Threaded writes (Main, Thread, custom)
218	/// use a 3-tuple `(room, user, thread_kind)` key disjoint from the
219	/// legacy row by trailing separator. The sync gate
220	/// (`roomuserid_lastprivatereadupdate`) stays 2-tuple and bumps only when
221	/// `announce` is set, keeping it a single point query. Announced state is
222	/// mirrored separately so notification-only writes cannot alter a sync
223	/// snapshot without changing its version.
224	#[inline]
225	pub(super) async fn private_read_set(
226		&self,
227		PrivateRead {
228			room_id,
229			user_id,
230			count,
231			ts,
232			thread,
233			announce,
234		}: PrivateRead<'_>,
235	) -> bool {
236		let thread_kind = thread.as_str().unwrap_or_default();
237
238		if self
239			.private_read_position(room_id, user_id, thread_kind)
240			.await
241			.is_ok_and(|(stored, _)| count <= stored)
242		{
243			return false;
244		}
245
246		let reset_sync = if announce && !thread_kind.is_empty() {
247			let versions = try_join(
248				self.last_privateread_update_fallible(user_id, room_id),
249				self.private_read_sync_update_fallible(user_id, room_id),
250			)
251			.await;
252
253			match versions {
254				| Ok((gate, snapshot)) => gate != snapshot,
255				| Err(error) => {
256					error!(?error, "Failed to inspect the private read sync snapshot.");
257					return false;
258				},
259			}
260		} else {
261			false
262		};
263
264		let mut txn = self
265			.sweep_thread_private_reads(
266				&self.roomuserid_privateread,
267				room_id,
268				user_id,
269				thread_kind,
270				self.services.db.txn(),
271			)
272			.await;
273
274		if announce && (thread_kind.is_empty() || reset_sync) {
275			txn = match self
276				.sweep_private_read_sync(room_id, user_id, txn)
277				.await
278			{
279				| Ok(txn) => txn,
280				| Err(error) => {
281					error!(?error, "Failed to reset the private read sync snapshot.");
282					return false;
283				},
284			};
285		}
286
287		// The permit retires the sequence number on drop, so it outlives execute().
288		let next_count = announce.then(|| self.services.globals.next_count());
289		let ts = u64::from(ts.get());
290
291		if let Some(next_count) = next_count.as_deref() {
292			txn.put(&self.roomuserid_lastprivatereadupdate, (room_id, user_id), *next_count);
293			txn.put(&self.roomuserid_privatereadsync, (room_id, user_id), *next_count);
294			txn.put(
295				&self.roomuserid_privatereadsync,
296				(room_id, user_id, thread_kind),
297				(count, ts),
298			);
299		}
300
301		// Additive value tail: ts (millis); old bare-count rows read back None.
302		match thread_kind.is_empty() {
303			| true => txn.put(&self.roomuserid_privateread, (room_id, user_id), (count, ts)),
304			| false => txn.put(
305				&self.roomuserid_privateread,
306				(room_id, user_id, thread_kind),
307				(count, ts),
308			),
309		}
310
311		txn.execute();
312
313		true
314	}
315
316	/// Private read position for an exact `(room, user, thread)` context.
317	///
318	/// An unthreaded context reads the legacy 2-tuple row; a threaded one
319	/// reads its own 3-tuple row.
320	#[inline]
321	async fn private_read_position(
322		&self,
323		room_id: &RoomId,
324		user_id: &UserId,
325		thread_kind: &str,
326	) -> Result<(u64, Option<u64>)> {
327		match thread_kind.is_empty() {
328			| true =>
329				self.private_read_get_count(room_id, user_id)
330					.await,
331			| false => self
332				.roomuserid_privateread
333				.qry(&(room_id, user_id, thread_kind))
334				.await
335				.deserialized(),
336		}
337	}
338
339	/// Latest unthreaded (legacy 2-tuple) private read: `(pdu count, receipt ts
340	/// millis)`. `ts` is `None` for rows written before the ts tail was added.
341	#[inline]
342	pub(super) async fn private_read_get_count(
343		&self,
344		room_id: &RoomId,
345		user_id: &UserId,
346	) -> Result<(u64, Option<u64>)> {
347		let key = (room_id, user_id);
348		self.roomuserid_privateread
349			.qry(&key)
350			.await
351			.deserialized()
352	}
353
354	#[inline]
355	pub(super) async fn private_read_sync_get_count(
356		&self,
357		room_id: &RoomId,
358		user_id: &UserId,
359	) -> Result<(u64, Option<u64>)> {
360		let key = (room_id, user_id, "");
361		self.roomuserid_privatereadsync
362			.qry(&key)
363			.await
364			.deserialized()
365	}
366
367	#[inline]
368	pub(super) fn private_read_threaded_stream<'a>(
369		&'a self,
370		room_id: &'a RoomId,
371		user_id: &'a UserId,
372	) -> impl Stream<Item = (ThreadKind, u64, Option<u64>)> + Send + 'a {
373		private_read_row_stream(&self.roomuserid_privateread, room_id, user_id).ignore_err()
374	}
375
376	/// Queues deletion of the per-thread private read rows for `(room, user)`.
377	///
378	/// Only an unthreaded write sweeps, since its room-wide receipt supersedes
379	/// prior thread state; a threaded write touches only its own row.
380	#[inline]
381	async fn sweep_thread_private_reads(
382		&self,
383		map: &Arc<Map>,
384		room_id: &RoomId,
385		user_id: &UserId,
386		thread_kind: &str,
387		txn: Txn,
388	) -> Txn {
389		if !thread_kind.is_empty() {
390			return txn;
391		}
392
393		let prefix = (room_id, user_id, Interfix);
394
395		map.keys_prefix_raw(&prefix)
396			.ignore_err()
397			.ready_fold(txn, |mut txn, key| {
398				txn.del_raw(map, key);
399				txn
400			})
401			.await
402	}
403
404	#[inline]
405	async fn sweep_private_read_sync(
406		&self,
407		room_id: &RoomId,
408		user_id: &UserId,
409		txn: Txn,
410	) -> Result<Txn> {
411		let prefix = (room_id, user_id, Interfix);
412
413		self.roomuserid_privatereadsync
414			.keys_prefix_raw(&prefix)
415			.ready_try_fold(txn, |mut txn, key| {
416				txn.del_raw(&self.roomuserid_privatereadsync, key);
417				Ok(txn)
418			})
419			.await
420	}
421
422	#[inline]
423	pub(super) fn private_read_sync_stream_fallible<'a>(
424		&'a self,
425		room_id: &'a RoomId,
426		user_id: &'a UserId,
427	) -> impl Stream<Item = Result<(ThreadKind, u64, Option<u64>)>> + Send + 'a {
428		private_read_row_stream(&self.roomuserid_privatereadsync, room_id, user_id)
429	}
430
431	#[inline]
432	pub(super) async fn private_read_sync_update_fallible(
433		&self,
434		user_id: &UserId,
435		room_id: &RoomId,
436	) -> Result<u64> {
437		let key = (room_id, user_id);
438		self.roomuserid_privatereadsync
439			.qry(&key)
440			.await
441			.deserialized()
442			.optional()
443			.map(|count| count.unwrap_or(0))
444	}
445
446	#[inline]
447	pub(super) async fn last_privateread_update(
448		&self,
449		user_id: &UserId,
450		room_id: &RoomId,
451	) -> u64 {
452		self.last_privateread_update_fallible(user_id, room_id)
453			.await
454			.unwrap_or_default()
455	}
456
457	#[inline]
458	pub(super) async fn last_privateread_update_fallible(
459		&self,
460		user_id: &UserId,
461		room_id: &RoomId,
462	) -> Result<u64> {
463		let key = (room_id, user_id);
464		self.roomuserid_lastprivatereadupdate
465			.qry(&key)
466			.await
467			.deserialized()
468			.optional()
469			.map(|count| count.unwrap_or(0))
470	}
471
472	#[inline]
473	pub(super) async fn delete_all_read_receipts(&self, room_id: &RoomId) -> Result {
474		let prefix = (room_id, Interfix);
475
476		self.roomuserid_privateread
477			.keys_prefix_raw(&prefix)
478			.ignore_err()
479			.ready_for_each(|key| {
480				trace!("Removing key: {key:?}");
481				self.roomuserid_privateread.remove(key);
482			})
483			.await;
484
485		self.roomuserid_lastprivatereadupdate
486			.keys_prefix_raw(&prefix)
487			.ignore_err()
488			.ready_for_each(|key| {
489				trace!("Removing key: {key:?}");
490				self.roomuserid_lastprivatereadupdate.remove(key);
491			})
492			.await;
493
494		self.roomuserid_privatereadsync
495			.keys_prefix_raw(&prefix)
496			.ignore_err()
497			.ready_for_each(|key| {
498				trace!("Removing key: {key:?}");
499				self.roomuserid_privatereadsync.remove(key);
500			})
501			.await;
502
503		self.readreceiptid_readreceipt
504			.keys_prefix_raw(&prefix)
505			.ignore_err()
506			.ready_for_each(|key| {
507				trace!("Removing key: {key:?}");
508				self.readreceiptid_readreceipt.remove(key);
509			})
510			.await;
511
512		Ok(())
513	}
514}
515
516/// Per-thread marker rows under `(room, user)` in `map`.
517///
518/// Active markers in the live store and announced markers in the sync mirror
519/// share one row shape, keyed by thread kind.
520fn private_read_row_stream<'a>(
521	map: &'a Arc<Map>,
522	room_id: &'a RoomId,
523	user_id: &'a UserId,
524) -> impl Stream<Item = Result<(ThreadKind, u64, Option<u64>)>> + Send + 'a {
525	let prefix = (room_id, user_id, Interfix);
526
527	map.stream_prefix(&prefix)
528		.map_ok(|((_, _, kind), (count, ts)): RowKv<'_>| (ThreadKind::from(kind), count, ts))
529}
530
531/// Tag string used in the storage key to discriminate receipts per thread.
532/// Empty for `Unthreaded`, `"main"` for `Main`, the event-id string for
533/// `Thread(...)` (event ids start with `$`, so the values are mutually
534/// exclusive). Custom variants reuse their string form; the C/S boundary
535/// rejects them, but federation receipts may still carry them through.
536///
537/// Reads only the first `(event_id, type, user)` triple. All callers
538/// build single-entry receipts (one event id, one type, one user); a
539/// debug assertion catches future regressions. An entirely empty event
540/// or one whose only receipt lacks a thread field falls back to `""`.
541///
542/// Appended to the receipt-row key as a tolerant trailing field. Pre-
543/// MSC3771 rows have no trailing kind; they round-trip as `""`.
544fn event_thread_kind(event: &ReceiptEvent) -> &str {
545	debug_assert!(
546		event
547			.content
548			.values()
549			.all(|by_type| by_type.len() == 1
550				&& by_type.values().all(|by_user| by_user.len() == 1))
551			&& event.content.len() == 1,
552		"receipt event must carry exactly one (event_id, type, user) triple"
553	);
554
555	event
556		.content
557		.values()
558		.next()
559		.and_then(|by_type| by_type.values().next())
560		.and_then(|by_user| by_user.values().next())
561		.and_then(|receipt| receipt.thread.as_str())
562		.unwrap_or_default()
563}
564
565/// First event id named by a stored receipt row.
566///
567/// `None` when the row does not deserialize, which the caller treats as an
568/// unknown position and accepts, replacing the row.
569fn stored_event_id(val: &[u8]) -> Option<OwnedEventId> {
570	serde_json::from_slice::<StoredContent>(val)
571		.ok()?
572		.content
573		.into_keys()
574		.next()
575}
576
577/// Whether an incoming receipt position strictly advances the stored one.
578///
579/// An unresolved position on either side accepts. `PduCount` ordering places
580/// backfilled events below normal ones, so no separate branch is needed.
581pub(super) fn position_advances(current: Option<PduCount>, incoming: Option<PduCount>) -> bool {
582	current
583		.zip(incoming)
584		.is_none_or(|(current, incoming)| incoming > current)
585}