Skip to main content

tuwunel_service/rooms/search/
mod.rs

1use std::sync::Arc;
2
3use futures::{Stream, StreamExt};
4use ruma::{RoomId, UserId, api::client::search::search_events::v3::Criteria};
5use tuwunel_core::{
6	PduCount, Result,
7	arrayvec::ArrayVec,
8	implement,
9	matrix::event::{Event, Matches},
10	trace,
11	utils::{
12		ArrayVecExt, IterStream, ReadyExt, set,
13		stream::{TryIgnore, WidebandExt},
14	},
15};
16use tuwunel_database::{Map, Txn, keyval::Val};
17
18use crate::rooms::{
19	short::ShortRoomId,
20	timeline::{PduId, RawPduId},
21};
22
23pub struct Service {
24	db: Data,
25	services: Arc<crate::services::OnceServices>,
26}
27
28struct Data {
29	tokenids: Arc<Map>,
30}
31
32#[derive(Clone, Debug)]
33pub struct RoomQuery<'a> {
34	pub room_id: &'a RoomId,
35	pub user_id: Option<&'a UserId>,
36	pub criteria: &'a Criteria,
37	pub limit: usize,
38	pub skip: usize,
39}
40
41type TokenId = ArrayVec<u8, TOKEN_ID_MAX_LEN>;
42
43const TOKEN_ID_MAX_LEN: usize =
44	size_of::<ShortRoomId>() + WORD_MAX_LEN + 1 + size_of::<RawPduId>();
45const WORD_MAX_LEN: usize = 50;
46
47impl crate::Service for Service {
48	fn build(args: &crate::Args<'_>) -> Result<Arc<Self>> {
49		Ok(Arc::new(Self {
50			db: Data { tokenids: args.db["tokenids"].clone() },
51			services: args.services.clone(),
52		}))
53	}
54
55	fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
56}
57
58#[implement(Service)]
59pub fn index_pdu(&self, shortroomid: ShortRoomId, pdu_id: &RawPduId, message_body: &str) {
60	let items = tokenize(message_body).map(|word| {
61		let mut key = shortroomid.to_be_bytes().to_vec();
62		key.extend_from_slice(word.as_bytes());
63		key.push(0xFF);
64		key.extend_from_slice(pdu_id.as_ref()); // TODO: currently we save the room id a second time here
65
66		(key, [])
67	});
68
69	Txn::insert(&self.db.tokenids, items).execute();
70}
71
72#[implement(Service)]
73pub fn deindex_pdu(&self, shortroomid: ShortRoomId, pdu_id: &RawPduId, message_body: &str) {
74	let batch = tokenize(message_body).map(|word| {
75		let mut key = shortroomid.to_be_bytes().to_vec();
76		key.extend_from_slice(word.as_bytes());
77		key.push(0xFF);
78		key.extend_from_slice(pdu_id.as_ref()); // TODO: currently we save the room id a second time here
79		key
80	});
81
82	for token in batch {
83		self.db.tokenids.remove(&token);
84	}
85}
86
87#[implement(Service)]
88pub async fn search_pdus<'a>(
89	&'a self,
90	query: &'a RoomQuery<'a>,
91) -> Result<(usize, impl Stream<Item = impl Event + use<>> + Send + '_)> {
92	let pdu_ids: Vec<_> = self.search_pdu_ids(query).await?.collect().await;
93
94	let filter = &query.criteria.filter;
95	let count = pdu_ids.len();
96	let pdus = pdu_ids
97		.into_iter()
98		.stream()
99		.wide_filter_map(async |result_pdu_id: RawPduId| {
100			self.services
101				.timeline
102				.get_pdu_from_id(&result_pdu_id)
103				.await
104				.ok()
105		})
106		.ready_filter(|pdu| !pdu.is_redacted())
107		.ready_filter(move |pdu| filter.matches(pdu))
108		.wide_filter_map(async |pdu| {
109			self.services
110				.state_accessor
111				.user_can_see_event(query.user_id?, pdu.room_id(), pdu.event_id())
112				.await
113				.then_some(pdu)
114		})
115		.skip(query.skip)
116		.take(query.limit);
117
118	Ok((count, pdus))
119}
120
121// result is modeled as a stream such that callers don't have to be refactored
122// though an additional async/wrap still exists for now
123#[implement(Service)]
124pub async fn search_pdu_ids(
125	&self,
126	query: &RoomQuery<'_>,
127) -> Result<impl Stream<Item = RawPduId> + Send + '_ + use<'_>> {
128	let shortroomid = self
129		.services
130		.short
131		.get_shortroomid(query.room_id)
132		.await?;
133
134	let pdu_ids = self
135		.search_pdu_ids_query_room(query, shortroomid)
136		.await;
137
138	let iters = pdu_ids.into_iter().map(IntoIterator::into_iter);
139
140	Ok(set::intersection(iters).stream())
141}
142
143#[implement(Service)]
144async fn search_pdu_ids_query_room(
145	&self,
146	query: &RoomQuery<'_>,
147	shortroomid: ShortRoomId,
148) -> Vec<Vec<RawPduId>> {
149	tokenize(&query.criteria.search_term)
150		.stream()
151		.wide_then(async |word| {
152			self.search_pdu_ids_query_words(shortroomid, &word)
153				.collect::<Vec<_>>()
154				.await
155		})
156		.collect::<Vec<_>>()
157		.await
158}
159
160/// Iterate over PduId's containing a word
161#[implement(Service)]
162fn search_pdu_ids_query_words<'a>(
163	&'a self,
164	shortroomid: ShortRoomId,
165	word: &'a str,
166) -> impl Stream<Item = RawPduId> + Send + '_ {
167	self.search_pdu_ids_query_word(shortroomid, word)
168		.map(move |key| -> RawPduId {
169			let key = &key[prefix_len(word)..];
170			key.into()
171		})
172}
173
174/// Iterate over raw database results for a word
175#[implement(Service)]
176fn search_pdu_ids_query_word(
177	&self,
178	shortroomid: ShortRoomId,
179	word: &str,
180) -> impl Stream<Item = Val<'_>> + Send + '_ + use<'_> {
181	// rustc says const'ing this not yet stable
182	let end_id: RawPduId = PduId { shortroomid, count: PduCount::max() }.into();
183
184	// Newest pdus first
185	let end = make_tokenid(shortroomid, word, &end_id);
186	let prefix = make_prefix(shortroomid, word);
187	self.db
188		.tokenids
189		.rev_raw_keys_from(&end)
190		.ignore_err()
191		.ready_take_while(move |key| key.starts_with(&prefix))
192}
193
194#[implement(Service)]
195pub async fn delete_all_search_tokenids_for_room(&self, room_id: &RoomId) -> Result {
196	let Ok(shortroomid) = self.services.short.get_shortroomid(room_id).await else {
197		return Ok(());
198	};
199
200	let txn = self
201		.db
202		.tokenids
203		.keys_prefix_raw(&shortroomid)
204		.ignore_err()
205		.ready_fold(self.services.db.txn(), |mut txn, key| {
206			trace!("Removing key: {key:?}");
207			txn.del_raw(&self.db.tokenids, key);
208			txn
209		})
210		.await;
211
212	txn.execute();
213
214	Ok(())
215}
216
217/// Splits a string into tokens used as keys in the search inverted index
218///
219/// This may be used to tokenize both message bodies (for indexing) or search
220/// queries (for querying).
221fn tokenize(body: &str) -> impl Iterator<Item = String> + Send + '_ {
222	body.split_terminator(|c: char| !c.is_alphanumeric())
223		.filter(|s| !s.is_empty())
224		.filter(|word| word.len() <= WORD_MAX_LEN)
225		.map(str::to_lowercase)
226}
227
228fn make_tokenid(shortroomid: ShortRoomId, word: &str, pdu_id: &RawPduId) -> TokenId {
229	let mut key = make_prefix(shortroomid, word);
230	key.extend_from_slice(pdu_id.as_ref());
231	key
232}
233
234fn make_prefix(shortroomid: ShortRoomId, word: &str) -> TokenId {
235	let mut key = TokenId::new();
236	key.extend_from_slice(&shortroomid.to_be_bytes());
237	key.extend_from_slice(word.as_bytes());
238	key.push(tuwunel_database::SEP);
239	key
240}
241
242fn prefix_len(word: &str) -> usize {
243	size_of::<ShortRoomId>()
244		.saturating_add(word.len())
245		.saturating_add(1)
246}