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