atagen

macltok

public
Star0
Watch0

Commit 74befbc72baf

tarball

atagen <boss@atagen.co>2026-10-25 13:25 UTC

20 files changed+1650-539
search: fix the review findings in the index, store and feed

The manifest was read before committed() and so saved one commit stale, losing the last flush and merge on restart and failing the whole load once a delete generation moved, so Changes now carries the manifest as it stands after the commit and a load purges blobs nothing points at. QueryParser read any word before a colon as a field and let a bare exclusion match everything, so queries are built from analysed words, phrases and exclusions with no field syntax. SimpleTokenizer made a CJK or Thai run one token and RemoveLongFilter counted bytes, so unspaced scripts now index singles and neighbouring pairs and the word limit is 64 chars. The head pass counted events indexed live this run as overlap and stopped on its first page, so it now only stops at what was indexed when the engine started.
Modified file: src/engine/context.rs+2 additions
@@ -418,6 +418,7 @@ pub(crate) async fn preview(
418418 current_user_id: Option<&ruma::UserId>,
419419 values: &[Value],
420420 highlight: bool,
421+ media_prefix: &str,
421422 ) -> Option<(TimelineEvent, Vec<MediaRegistration>)> {
422423 let mut page = Assembly::new(
423424 room,
@@ -425,6 +426,7 @@ pub(crate) async fn preview(
425426 values,
426427 &vec![highlight; values.len()],
427428 Some(0),
429+ media_prefix,
428430 )
429431 .await;
430432 match page.take(0..1).pop()? {
Modified file: src/engine/mod.rs+7 additions−2 deletions
@@ -422,8 +422,6 @@ impl Engine {
422422 .await
423423 // a missing user id is all it still refuses, and that was turned away above
424424 .map_err(|_| EngineError::Unauthenticated)?;
425- let (search, search_service) =
426- crate::search::Service::new(client.clone(), media.clone(), search, &config);
427425 let sync = Arc::new(
428426 SyncService::builder(client.clone())
429427 .with_offline_mode()
@@ -444,6 +442,13 @@ impl Engine {
444442 config.command_capacity,
445443 config.event_capacity,
446444 );
445+ let (search, search_service) = crate::search::Service::new(
446+ client.clone(),
447+ media.clone(),
448+ search,
449+ port.event_sender(),
450+ &config,
451+ );
447452 client.send_queue().enable_upload_progress(true);
448453 client.send_queue().enable_global_upload_progress(true);
449454 let (note_sender, notes) = mpsc::unbounded_channel();
Modified file: src/lib.rs+2 additions−2 deletions
@@ -82,8 +82,8 @@ pub use repair_model::{
8282 };
8383 pub use ruma;
8484 pub use search_model::{
85- RoomSearchCoverage, SearchBackfill, SearchCommand, SearchCoverage, SearchHit, SearchMatch,
86- SearchPage, SearchScope, SearchSession,
85+ RoomSearchCoverage, SearchBackfill, SearchCloseReason, SearchCommand, SearchCoverage,
86+ SearchHit, SearchInMemory, SearchMatch, SearchPage, SearchScope, SearchSession,
8787 };
8888 #[doc(hidden)]
8989 pub use transport::PublicationAck;
Modified file: src/mirror.rs+7 additions
@@ -162,6 +162,10 @@ pub enum StateChange {
162162 context_id: String,
163163 reason: EventContextCloseReason,
164164 },
165+ SearchClosed {
166+ search_id: String,
167+ reason: crate::SearchCloseReason,
168+ },
165169 Upload {
166170 transaction_id: OwnedTransactionId,
167171 upload: UploadState,
@@ -442,6 +446,9 @@ impl Mirror {
442446 Event::EventContextClosed { context_id, reason } => {
443447 self.publish(|_| Some(StateChange::EventContextClosed { context_id, reason }));
444448 }
449+ Event::SearchClosed { search_id, reason } => {
450+ self.publish(|_| Some(StateChange::SearchClosed { search_id, reason }));
451+ }
445452 Event::ThreadListReset { room_id, entries } => self.publish(|state| {
446453 let history = state
447454 .thread_lists
Modified file: src/model.rs+6 additions
@@ -983,6 +983,12 @@ pub enum Event {
983983 room_id: OwnedRoomId,
984984 state: HistoryState,
985985 },
986+ /// a search closed by something other than its own `Close`. its id answers
987+ /// `SearchNotOpen` from now on
988+ SearchClosed {
989+ search_id: String,
990+ reason: crate::SearchCloseReason,
991+ },
986992 /// nothing more comes for it until another `ThreadListReset`
987993 ThreadListClosed {
988994 room_id: OwnedRoomId,
Modified file: src/search/feed.rs+58 additions−53 deletions
@@ -3,7 +3,10 @@ use std::collections::{HashSet, VecDeque};
33 use ruma::events::room::message::sanitize::remove_plain_reply_fallback;
44 use serde_json::Value;
55
6-use super::{MessageIndex, index::Doc};
6+use super::{
7+ MessageIndex,
8+ index::{Doc, provisional_key},
9+};
710 use crate::normalise::poll_question;
811
912 const REMEMBERED_REDACTIONS: usize = 10_000;
@@ -45,6 +48,8 @@ pub(crate) enum Observed {
4548 pub(crate) enum Effect {
4649 Indexed(u64),
4750 Removed,
51+ /// written, but nothing searchable changed
52+ Held,
4853 /// an edit was redacted out from under its original, which has to be read again
4954 Refetch(String),
5055 Nothing,
@@ -110,7 +115,7 @@ fn edit_of(value: &Value, event_type: &str) -> Option<Edit> {
110115 .to_owned(),
111116 sender: value.get("sender")?.as_str()?.to_owned(),
112117 ts: value.get("origin_server_ts")?.as_u64()?,
113- text: text_of(event_type, content.get("m.new_content")?)?,
118+ text: text_of(event_type, content.get("m.new_content")?).unwrap_or_default(),
114119 })
115120 }
116121
@@ -169,8 +174,8 @@ fn newer(ts: u64, id: &str, than: &Doc) -> bool {
169174 }
170175
171176 /// what `observed` does to `room`'s part of the index. edits land on their original under
172-/// its key, the latest by its own sender wins, and one arriving first holds the key
173-/// provisionally until the original turns up
177+/// its key, the latest by its own sender wins, and one that arrives first waits under
178+/// `provisional_key` until the original turns up
174179 pub(crate) fn apply(
175180 index: &mut dyn MessageIndex,
176181 redactions: &mut Redactions,
@@ -205,21 +210,18 @@ pub(crate) fn apply(
205210 doc.content_ts = edit.ts;
206211 doc.text = edit.text;
207212 }
208- match index.get(&key) {
209- Some(existing) if !existing.provisional => {
210- if !newer(doc.content_ts, &doc.content, &existing) {
211- return Effect::Nothing;
212- }
213- }
214- Some(existing) => {
215- if existing.sender == sender && !newer(doc.content_ts, &doc.content, &existing)
216- {
217- doc.content = existing.content;
218- doc.content_ts = existing.content_ts;
219- doc.text = existing.text;
220- }
221- }
222- None => {}
213+ if let Some(waiting) = index.get(&provisional_key(&key, &sender))
214+ && newer(waiting.content_ts, &waiting.content, &doc)
215+ {
216+ doc.content = waiting.content;
217+ doc.content_ts = waiting.content_ts;
218+ doc.text = waiting.text;
219+ }
220+ let held = index.remove_provisional(&key);
221+ if let Some(existing) = index.get(&key)
222+ && !newer(doc.content_ts, &doc.content, &existing)
223+ {
224+ return if held { Effect::Held } else { Effect::Nothing };
223225 }
224226 index.put(doc);
225227 Effect::Indexed(ts)
@@ -228,40 +230,51 @@ pub(crate) fn apply(
228230 if redactions.contains(&edit.target) || redactions.contains(&edit.id) {
229231 return Effect::Nothing;
230232 }
231- let doc = match index.get(&edit.target) {
232- Some(existing) => {
233- if existing.sender != edit.sender || !newer(edit.ts, &edit.id, &existing) {
234- return Effect::Nothing;
235- }
236- Doc {
237- content: edit.id,
238- content_ts: edit.ts,
239- text: edit.text,
240- ..existing
241- }
233+ if index
234+ .by_content(&edit.target)
235+ .is_some_and(|doc| doc.key != edit.target)
236+ {
237+ return Effect::Nothing;
238+ }
239+ if let Some(existing) = index.get(&edit.target) {
240+ if existing.sender != edit.sender || !newer(edit.ts, &edit.id, &existing) {
241+ return Effect::Nothing;
242242 }
243- None => Doc {
244- key: edit.target,
245- room: room.to_owned(),
246- sender: edit.sender,
247- ts: edit.ts,
243+ let ts = existing.ts;
244+ index.put(Doc {
248245 content: edit.id,
249246 content_ts: edit.ts,
250247 text: edit.text,
251- provisional: true,
252- },
253- };
254- let ts = doc.ts;
255- index.put(doc);
256- Effect::Indexed(ts)
248+ ..existing
249+ });
250+ return Effect::Indexed(ts);
251+ }
252+ let key = provisional_key(&edit.target, &edit.sender);
253+ if let Some(waiting) = index.get(&key)
254+ && !newer(edit.ts, &edit.id, &waiting)
255+ {
256+ return Effect::Nothing;
257+ }
258+ index.put(Doc {
259+ key,
260+ room: room.to_owned(),
261+ sender: edit.sender,
262+ ts: edit.ts,
263+ content: edit.id,
264+ content_ts: edit.ts,
265+ text: edit.text,
266+ provisional: true,
267+ });
268+ Effect::Held
257269 }
258- Observed::Redaction { target } => {
270+ Observed::Redaction { target } | Observed::Redacted { key: target } => {
259271 redactions.insert(target.clone());
260- if index.remove(&target) {
272+ let removed = index.remove(&target);
273+ if index.remove_provisional(&target) || removed {
261274 return Effect::Removed;
262275 }
263276 match index.by_content(&target) {
264- Some(doc) => {
277+ Some(doc) if doc.key != target => {
265278 index.remove(&doc.key);
266279 if doc.provisional {
267280 Effect::Removed
@@ -269,15 +282,7 @@ pub(crate) fn apply(
269282 Effect::Refetch(doc.key)
270283 }
271284 }
272- None => Effect::Nothing,
273- }
274- }
275- Observed::Redacted { key } => {
276- redactions.insert(key.clone());
277- if index.remove(&key) {
278- Effect::Removed
279- } else {
280- Effect::Nothing
285+ _ => Effect::Nothing,
281286 }
282287 }
283288 Observed::Encrypted { .. } | Observed::Other => Effect::Nothing,
Modified file: src/search/feed_tests.rs+67 additions−2 deletions
@@ -1,7 +1,7 @@
11 use serde_json::json;
22
33 use super::*;
4-use crate::search::index::KeywordIndex;
4+use crate::search::index::{KeywordIndex, provisional_key};
55
66 const ROOM: &str = "!room:x";
77
@@ -60,9 +60,10 @@ fn an_edit_before_its_original_keeps_the_edit_and_takes_the_original_timestamp()
6060 &mut redactions,
6161 edit("$e", "@a:x", 20, "$o", "fixed words"),
6262 );
63- let provisional = index.get("$o").unwrap();
63+ let provisional = index.get(&provisional_key("$o", "@a:x")).unwrap();
6464 assert!(provisional.provisional);
6565 assert_eq!(provisional.ts, 20);
66+ assert!(keys(&mut index, "fixed").is_empty());
6667 feed(
6768 &mut index,
6869 &mut redactions,
@@ -185,3 +186,67 @@ fn reply_fallbacks_are_not_indexed() {
185186 assert_eq!(keys(&mut index, "answer"), vec!["$r"]);
186187 assert!(keys(&mut index, "quoted").is_empty());
187188 }
189+
190+#[test]
191+fn a_strangers_early_edit_cannot_shut_out_the_author() {
192+ let mut index = KeywordIndex::new(false);
193+ let mut redactions = Redactions::default();
194+ feed(
195+ &mut index,
196+ &mut redactions,
197+ edit("$h", "@b:x", 30, "$o", "hijack"),
198+ );
199+ feed(
200+ &mut index,
201+ &mut redactions,
202+ edit("$e", "@a:x", 20, "$o", "genuine edit"),
203+ );
204+ feed(
205+ &mut index,
206+ &mut redactions,
207+ message("$o", "@a:x", 10, "original"),
208+ );
209+ assert_eq!(keys(&mut index, "genuine"), vec!["$o"]);
210+ assert!(keys(&mut index, "hijack").is_empty());
211+ assert!(index.get(&provisional_key("$o", "@b:x")).is_none());
212+}
213+
214+#[test]
215+fn an_edit_of_an_edit_is_ignored() {
216+ let mut index = KeywordIndex::new(false);
217+ let mut redactions = Redactions::default();
218+ feed(
219+ &mut index,
220+ &mut redactions,
221+ message("$o", "@a:x", 10, "original"),
222+ );
223+ feed(
224+ &mut index,
225+ &mut redactions,
226+ edit("$e", "@a:x", 20, "$o", "first"),
227+ );
228+ assert_eq!(
229+ feed(
230+ &mut index,
231+ &mut redactions,
232+ edit("$ee", "@a:x", 30, "$e", "nested")
233+ ),
234+ Effect::Nothing
235+ );
236+ assert!(index.get(&provisional_key("$e", "@a:x")).is_none());
237+}
238+
239+#[test]
240+fn an_edit_to_unreadable_content_clears_the_old_text() {
241+ let mut index = KeywordIndex::new(false);
242+ let mut redactions = Redactions::default();
243+ feed(
244+ &mut index,
245+ &mut redactions,
246+ message("$o", "@a:x", 10, "secret plans"),
247+ );
248+ let mut blank = edit("$e", "@a:x", 20, "$o", "x");
249+ blank["content"]["m.new_content"] = json!({ "msgtype": "m.unknown" });
250+ feed(&mut index, &mut redactions, blank);
251+ assert!(keys(&mut index, "secret").is_empty());
252+}
Modified file: src/search/index.rs+127 additions−121 deletions
@@ -8,25 +8,25 @@ use tantivy::{
88 directory::{Directory, OwnedBytes, RamDirectory},
99 fastfield::{AliveBitSet, write_alive_bitset},
1010 indexer::{SingleSegmentIndexWriter, merge_filtered_segments},
11- query::{BooleanQuery, Occur, Query, QueryParser, TermSetQuery},
11+ query::{BooleanQuery, Occur, Query, TermSetQuery},
1212 schema::{
1313 FAST, Field, IndexRecordOption, STORED, STRING, Schema, TextFieldIndexing, TextOptions,
1414 Value,
1515 },
16- tokenizer::{
17- AsciiFoldingFilter, LowerCaser, RemoveLongFilter, SimpleTokenizer, TextAnalyzer,
18- TokenStream,
19- },
2016 };
2117 use tantivy_common::BitSet;
2218
23-use super::MessageIndex;
19+use super::{
20+ MessageIndex,
21+ text::{TOKENIZER, analyzer, build_query},
22+};
2423 use crate::{Error, ErrorKind};
2524
26-const TOKENIZER: &str = "macltok";
2725 const WRITER_BUDGET: usize = 4_000_000;
2826 /// segments of a size class merge once there are this many of them
2927 const MERGE_FACTOR: usize = 6;
28+/// decompressed docstore blocks a searcher keeps per segment, tantivy's default is 100
29+const DOCSTORE_BLOCKS: usize = 2;
3030
3131 #[derive(Clone, Debug, Eq, PartialEq)]
3232 pub(crate) struct Doc {
@@ -40,10 +40,16 @@ pub(crate) struct Doc {
4040 pub(crate) content_ts: u64,
4141 /// only kept for provisional docs, read back empty otherwise
4242 pub(crate) text: String,
43- /// an edit seen before its original
43+ /// an edit seen before its original, under `provisional_key`. it's kept out of results,
44+ /// since its timestamp moves to the original's once that turns up
4445 pub(crate) provisional: bool,
4546 }
4647
48+/// one per target and edit sender, so an edit from anyone else can't crowd out the author's
49+pub(crate) fn provisional_key(target: &str, sender: &str) -> String {
50+ format!("{target}\n{sender}")
51+}
52+
4753 #[derive(Clone, Debug, Eq, PartialEq)]
4854 pub(crate) struct Hit {
4955 pub(crate) key: String,
@@ -63,6 +69,8 @@ struct SegmentRecord {
6369 pub(crate) struct Changes {
6470 pub(crate) puts: Vec<(String, Vec<u8>)>,
6571 pub(crate) deletes: Vec<String>,
72+ /// the index as it stands once these land, which is what has to be saved with them
73+ pub(crate) manifest: serde_json::Value,
6674 files: Vec<u64>,
6775 dels: Vec<(u64, u64)>,
6876 removed: Vec<u64>,
@@ -79,6 +87,7 @@ struct Fields {
7987 tie: Field,
8088 content_ts: Field,
8189 pending: Field,
90+ target: Field,
8291 }
8392
8493 struct Segment {
@@ -103,7 +112,6 @@ pub(crate) struct KeywordIndex {
103112 schema: Schema,
104113 fields: Fields,
105114 settings: IndexSettings,
106- parser: QueryParser,
107115 segments: Vec<Segment>,
108116 buffer: HashMap<String, Doc>,
109117 next_id: u64,
@@ -112,31 +120,6 @@ pub(crate) struct KeywordIndex {
112120 removed: Vec<(u64, bool, u64)>,
113121 }
114122
115-pub(crate) fn analyzer() -> TextAnalyzer {
116- TextAnalyzer::builder(SimpleTokenizer::default())
117- .filter(RemoveLongFilter::limit(40))
118- .filter(LowerCaser)
119- .filter(AsciiFoldingFilter)
120- .build()
121-}
122-
123-/// the byte ranges in `text` whose analysed token is one of `terms`
124-pub(crate) fn match_ranges(text: &str, terms: &[String]) -> Vec<(usize, usize)> {
125- if terms.is_empty() {
126- return Vec::new();
127- }
128- let mut analyzer = analyzer();
129- let mut stream = analyzer.token_stream(text);
130- let mut ranges = Vec::new();
131- while stream.advance() {
132- let token = stream.token();
133- if terms.iter().any(|term| *term == token.text) {
134- ranges.push((token.offset_from, token.offset_to));
135- }
136- }
137- ranges
138-}
139-
140123 fn schema() -> (Schema, Fields) {
141124 let mut builder = Schema::builder();
142125 let text_options = TextOptions::default().set_indexing_options(
@@ -154,6 +137,7 @@ fn schema() -> (Schema, Fields) {
154137 tie: builder.add_u64_field("tie", FAST),
155138 content_ts: builder.add_u64_field("content_ts", STORED),
156139 pending: builder.add_text_field("pending", STORED),
140+ target: builder.add_text_field("target", STRING),
157141 };
158142 (builder.build(), fields)
159143 }
@@ -185,6 +169,7 @@ fn open_segment(id: u64, raw: RamDirectory, max_doc_hint: Option<u32>) -> Result
185169 let reader = index
186170 .reader_builder()
187171 .reload_policy(ReloadPolicy::Manual)
172+ .doc_store_cache_num_blocks(DOCSTORE_BLOCKS)
188173 .try_into()
189174 .map_err(|error| tantivy_error("reading a search segment", error))?;
190175 let searcher = reader.searcher();
@@ -212,24 +197,25 @@ fn open_segment(id: u64, raw: RamDirectory, max_doc_hint: Option<u32>) -> Result
212197 })
213198 }
214199
215-fn pack(files: &[(String, Vec<u8>)]) -> Vec<u8> {
200+fn pack(files: &[(String, OwnedBytes)]) -> Vec<u8> {
216201 let mut packed = Vec::with_capacity(
217202 files
218203 .iter()
219204 .map(|(name, bytes)| name.len() + bytes.len() + 12)
220- .sum(),
205+ .sum::<usize>()
206+ + 68,
221207 );
222208 packed.extend_from_slice(&(files.len() as u32).to_le_bytes());
223209 for (name, bytes) in files {
224210 packed.extend_from_slice(&(name.len() as u32).to_le_bytes());
225211 packed.extend_from_slice(name.as_bytes());
226212 packed.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
227- packed.extend_from_slice(bytes);
213+ packed.extend_from_slice(bytes.as_slice());
228214 }
229215 packed
230216 }
231217
232-fn unpack(mut bytes: &[u8]) -> Option<Vec<(String, Vec<u8>)>> {
218+fn unpack(mut bytes: &[u8]) -> Option<Vec<(String, &[u8])>> {
233219 fn take<'a>(bytes: &mut &'a [u8], len: usize) -> Option<&'a [u8]> {
234220 if bytes.len() < len {
235221 return None;
@@ -247,7 +233,7 @@ fn unpack(mut bytes: &[u8]) -> Option<Vec<(String, Vec<u8>)>> {
247233 .to_owned();
248234 let len =
249235 usize::try_from(u64::from_le_bytes(take(&mut bytes, 8)?.try_into().ok()?)).ok()?;
250- files.push((name, take(&mut bytes, len)?.to_vec()));
236+ files.push((name, take(&mut bytes, len)?));
251237 }
252238 bytes.is_empty().then_some(files)
253239 }
@@ -260,18 +246,20 @@ pub(crate) fn deletes_key(id: u64, generation: u64) -> String {
260246 format!("del/{id}/{generation}")
261247 }
262248
249+fn manifest(records: impl Iterator<Item = SegmentRecord>, next_id: u64) -> serde_json::Value {
250+ let segments: Vec<_> = records
251+ .map(|record| serde_json::json!([record.id, record.del_gen]))
252+ .collect();
253+ serde_json::json!({ "segments": segments, "next": next_id })
254+}
255+
263256 impl KeywordIndex {
264257 pub(crate) fn new(persistent: bool) -> Self {
265258 let (schema, fields) = schema();
266- let parser_index = Index::create_in_ram(schema.clone());
267- parser_index.tokenizers().register(TOKENIZER, analyzer());
268- let mut parser = QueryParser::for_index(&parser_index, vec![fields.text]);
269- parser.set_conjunction_by_default();
270259 Self {
271260 schema,
272261 fields,
273262 settings: settings(),
274- parser,
275263 segments: Vec::new(),
276264 buffer: HashMap::new(),
277265 next_id: 1,
@@ -280,7 +268,8 @@ impl KeywordIndex {
280268 }
281269 }
282270
283- /// `blob` hands back what an earlier commit put, any of it missing or unreadable fails the load
271+ /// `blob` hands over what an earlier commit put, once each, and any of it missing or
272+ /// unreadable fails the load
284273 pub(crate) fn load(
285274 manifest: &serde_json::Value,
286275 mut blob: impl FnMut(&str) -> Option<Vec<u8>>,
@@ -307,14 +296,13 @@ impl KeywordIndex {
307296 )
308297 };
309298 for record in &records {
310- let files = blob(&segment_key(record.id))
311- .and_then(|bytes| unpack(&bytes))
312- .ok_or_else(missing)?;
299+ let packed = blob(&segment_key(record.id)).ok_or_else(missing)?;
313300 let raw = RamDirectory::create();
314- for (name, bytes) in files {
315- raw.atomic_write(&PathBuf::from(name), &bytes)
301+ for (name, bytes) in unpack(&packed).ok_or_else(missing)? {
302+ raw.atomic_write(&PathBuf::from(name), bytes)
316303 .map_err(|error| tantivy_error("loading a search segment", error))?;
317304 }
305+ drop(packed);
318306 let mut segment = open_segment(record.id, raw, None)?;
319307 if segment.index.schema() != index.schema {
320308 return Err(Error::new(
@@ -345,15 +333,17 @@ impl KeywordIndex {
345333 Ok(index)
346334 }
347335
348- fn records(&self) -> Vec<SegmentRecord> {
349- self.segments
350- .iter()
351- .filter(|segment| segment.persisted)
352- .map(|segment| SegmentRecord {
353- id: segment.id,
354- del_gen: segment.del_gen,
355- })
356- .collect()
336+ /// the blob names what is persisted points at
337+ #[cfg(test)]
338+ pub(crate) fn referenced(&self) -> Vec<String> {
339+ let mut keys = Vec::new();
340+ for segment in self.segments.iter().filter(|segment| segment.persisted) {
341+ keys.push(segment_key(segment.id));
342+ if segment.del_gen > 0 {
343+ keys.push(deletes_key(segment.id, segment.del_gen));
344+ }
345+ }
346+ keys
357347 }
358348
359349 fn document(&self, doc: &Doc) -> TantivyDocument {
@@ -363,12 +353,18 @@ impl KeywordIndex {
363353 document.add_text(fields.room, &doc.room);
364354 document.add_text(fields.sender, &doc.sender);
365355 document.add_text(fields.content, &doc.content);
366- document.add_text(fields.text, &doc.text);
367356 document.add_u64(fields.ts, doc.ts);
368357 document.add_u64(fields.tie, tie(&doc.key));
369358 document.add_u64(fields.content_ts, doc.content_ts);
370359 if doc.provisional {
371360 document.add_text(fields.pending, &doc.text);
361+ let target = doc
362+ .key
363+ .split_once('\n')
364+ .map_or(doc.key.as_str(), |(target, _)| target);
365+ document.add_text(fields.target, target);
366+ } else {
367+ document.add_text(fields.text, &doc.text);
372368 }
373369 document
374370 }
@@ -426,6 +422,25 @@ impl KeywordIndex {
426422 })
427423 }
428424
425+ /// tombstones every live doc in a segment whose `field` holds `value`
426+ fn remove_where(&mut self, field: Field, value: &str) -> bool {
427+ let mut removed = false;
428+ for position in 0..self.segments.len() {
429+ let docs = self.find(&self.segments[position], field, value);
430+ if docs.is_empty() {
431+ continue;
432+ }
433+ let segment = &mut self.segments[position];
434+ let alive = Arc::make_mut(&mut segment.alive);
435+ for doc in docs {
436+ alive.remove(doc);
437+ }
438+ segment.deletes_dirty = true;
439+ removed = true;
440+ }
441+ removed
442+ }
443+
429444 fn flush_buffer(&mut self) -> Result<(), Error> {
430445 if self.buffer.is_empty() {
431446 return Ok(());
@@ -457,16 +472,22 @@ impl KeywordIndex {
457472 live.max(1).ilog(MERGE_FACTOR)
458473 }
459474
475+ fn forget(&mut self, segment: &Segment) {
476+ if self.persistent {
477+ self.removed
478+ .push((segment.id, segment.persisted, segment.del_gen));
479+ }
480+ }
481+
460482 /// merges a size class once it fills up, and rewrites a segment that's mostly deleted
461483 fn compact(&mut self) -> Result<(), Error> {
462- self.segments.retain(|segment| {
463- let empty = segment.live() == 0;
464- if empty && self.persistent {
465- self.removed
466- .push((segment.id, segment.persisted, segment.del_gen));
467- }
468- !empty
469- });
484+ let (empty, kept): (Vec<_>, Vec<_>) = std::mem::take(&mut self.segments)
485+ .into_iter()
486+ .partition(|segment| segment.live() == 0);
487+ self.segments = kept;
488+ for segment in &empty {
489+ self.forget(segment);
490+ }
470491 loop {
471492 let mut classes: HashMap<u32, Vec<usize>> = HashMap::new();
472493 for (position, segment) in self.segments.iter().enumerate() {
@@ -504,26 +525,26 @@ impl KeywordIndex {
504525 let mut bytes = Vec::new();
505526 write_alive_bitset(&segment.alive, &mut bytes)
506527 .map_err(|error| tantivy_error("merging search segments", error))?;
528+ let bytes = OwnedBytes::new(bytes);
507529 for tantivy_segment in searchable {
508530 inputs.push(tantivy_segment);
509- alive.push(Some(AliveBitSet::open(OwnedBytes::new(bytes.clone()))));
531+ alive.push(Some(AliveBitSet::open(bytes.clone())));
510532 }
511533 }
512534 let raw = RamDirectory::create();
513535 merge_filtered_segments(&inputs, self.settings.clone(), alive, raw.clone())
514536 .map_err(|error| tantivy_error("merging search segments", error))?;
537+ drop(inputs);
515538 let merged = open_segment(self.next_id, raw, None)?;
516539 self.next_id += 1;
517- let mut position = 0;
518- self.segments.retain(|segment| {
519- let merging = positions.contains(&position);
520- position += 1;
521- if merging && self.persistent {
522- self.removed
523- .push((segment.id, segment.persisted, segment.del_gen));
524- }
525- !merging
526- });
540+ let (merging, kept): (Vec<_>, Vec<_>) = std::mem::take(&mut self.segments)
541+ .into_iter()
542+ .enumerate()
543+ .partition(|(position, _)| positions.contains(position));
544+ self.segments = kept.into_iter().map(|(_, segment)| segment).collect();
545+ for (_, segment) in &merging {
546+ self.forget(segment);
547+ }
527548 self.segments.push(merged);
528549 Ok(())
529550 }
@@ -548,10 +569,7 @@ impl KeywordIndex {
548569 .map_err(|error| tantivy_error("reading a search segment", error))?
549570 .read_bytes()
550571 .map_err(|error| tantivy_error("reading a search segment", error))?;
551- files.push((
552- name.to_string_lossy().into_owned(),
553- bytes.as_slice().to_vec(),
554- ));
572+ files.push((name.to_string_lossy().into_owned(), bytes));
555573 }
556574 Ok(pack(&files))
557575 }
@@ -578,22 +596,19 @@ impl MessageIndex for KeywordIndex {
578596 }
579597
580598 fn remove(&mut self, key: &str) -> bool {
581- let mut removed = self.buffer.remove(key).is_some();
599+ let buffered = self.buffer.remove(key).is_some();
582600 let key_field = self.fields.key;
583- for position in 0..self.segments.len() {
584- let docs = self.find(&self.segments[position], key_field, key);
585- if docs.is_empty() {
586- continue;
587- }
588- let segment = &mut self.segments[position];
589- let alive = Arc::make_mut(&mut segment.alive);
590- for doc in docs {
591- alive.remove(doc);
592- }
593- segment.deletes_dirty = true;
594- removed = true;
595- }
596- removed
601+ self.remove_where(key_field, key) || buffered
602+ }
603+
604+ fn remove_provisional(&mut self, target: &str) -> bool {
605+ let prefix = format!("{target}\n");
606+ let before = self.buffer.len();
607+ self.buffer
608+ .retain(|key, doc| !(doc.provisional && key.starts_with(&prefix)));
609+ let buffered = self.buffer.len() != before;
610+ let target_field = self.fields.target;
611+ self.remove_where(target_field, target) || buffered
597612 }
598613
599614 fn buffered(&self) -> usize {
@@ -607,16 +622,8 @@ impl MessageIndex for KeywordIndex {
607622 before: Option<(u64, u64)>,
608623 limit: usize,
609624 ) -> Result<(Vec<Hit>, Vec<String>), Error> {
625+ let (text_query, terms) = build_query(self.fields.text, query)?;
610626 self.flush_buffer()?;
611- let (text_query, _) = self.parser.parse_query_lenient(query);
612- let mut terms = Vec::new();
613- text_query.query_terms(&mut |term, _| {
614- if term.field() == self.fields.text
615- && let Some(text) = term.value().as_str()
616- {
617- terms.push(text.to_owned());
618- }
619- });
620627 let query: Box<dyn Query> = match rooms {
621628 None => text_query,
622629 Some(rooms) => Box::new(BooleanQuery::new(vec![
@@ -671,8 +678,6 @@ impl MessageIndex for KeywordIndex {
671678 tie,
672679 });
673680 }
674- terms.sort();
675- terms.dedup();
676681 Ok((hits, terms))
677682 }
678683
@@ -693,6 +698,7 @@ impl MessageIndex for KeywordIndex {
693698 }
694699 changes.removed.push(*id);
695700 }
701+ let mut records = Vec::with_capacity(self.segments.len());
696702 for segment in &self.segments {
697703 if !segment.persisted {
698704 changes
@@ -700,22 +706,26 @@ impl MessageIndex for KeywordIndex {
700706 .push((segment_key(segment.id), Self::segment_files(segment)?));
701707 changes.files.push(segment.id);
702708 }
709+ let mut del_gen = segment.del_gen;
703710 if segment.deletes_dirty && segment.live() < segment.max_doc as usize {
704- let generation = segment.del_gen + 1;
711+ del_gen = segment.del_gen + 1;
705712 let mut bytes = Vec::new();
706713 write_alive_bitset(&segment.alive, &mut bytes)
707714 .map_err(|error| tantivy_error("saving search deletes", error))?;
708- changes
709- .puts
710- .push((deletes_key(segment.id, generation), bytes));
715+ changes.puts.push((deletes_key(segment.id, del_gen), bytes));
711716 if segment.del_gen > 0 {
712717 changes
713718 .deletes
714719 .push(deletes_key(segment.id, segment.del_gen));
715720 }
716- changes.dels.push((segment.id, generation));
721+ changes.dels.push((segment.id, del_gen));
717722 }
723+ records.push(SegmentRecord {
724+ id: segment.id,
725+ del_gen,
726+ });
718727 }
728+ changes.manifest = manifest(records.into_iter(), self.next_id);
719729 Ok(changes)
720730 }
721731
@@ -733,13 +743,9 @@ impl MessageIndex for KeywordIndex {
733743 }
734744 }
735745
736- fn manifest(&self) -> serde_json::Value {
737- let segments: Vec<_> = self
738- .records()
739- .into_iter()
740- .map(|record| serde_json::json!([record.id, record.del_gen]))
741- .collect();
742- serde_json::json!({ "segments": segments, "next": self.next_id })
746+ fn detach(&mut self) {
747+ self.persistent = false;
748+ self.removed.clear();
743749 }
744750 }
745751
Modified file: src/search/index_tests.rs+59 additions−41 deletions
@@ -19,16 +19,29 @@ fn keys(hits: &[Hit]) -> Vec<&str> {
1919 hits.iter().map(|hit| hit.key.as_str()).collect()
2020 }
2121
22-fn commit(index: &mut KeywordIndex, blobs: &mut BTreeMap<String, Vec<u8>>) -> serde_json::Value {
22+/// the order `Service::commit` uses, the manifest saved with the blobs before `committed`
23+fn commit(index: &mut KeywordIndex, store: &mut BTreeMap<String, Vec<u8>>) {
2324 let changes = index.changes().unwrap();
2425 for key in &changes.deletes {
25- blobs.remove(key);
26+ store.remove(key);
2627 }
2728 for (key, value) in &changes.puts {
28- blobs.insert(key.clone(), value.clone());
29+ store.insert(key.clone(), value.clone());
2930 }
31+ store.insert("meta".to_owned(), changes.manifest.to_string().into_bytes());
3032 index.committed(&changes);
31- index.manifest()
33+}
34+
35+fn reload(store: &BTreeMap<String, Vec<u8>>) -> KeywordIndex {
36+ let manifest: serde_json::Value = serde_json::from_slice(&store["meta"]).unwrap();
37+ KeywordIndex::load(&manifest, |key| store.get(key).cloned()).unwrap()
38+}
39+
40+fn found(index: &mut KeywordIndex, query: &str) -> Vec<String> {
41+ let (hits, _) = index.search(query, None, None, 100).unwrap();
42+ let mut found: Vec<_> = keys(&hits).into_iter().map(str::to_owned).collect();
43+ found.sort();
44+ found
3245 }
3346
3447 #[test]
@@ -57,11 +70,8 @@ fn a_replaced_doc_is_found_once_by_its_new_text() {
5770 index.put(doc("$a", "!r:x", 1, "before"));
5871 index.maintain().unwrap();
5972 index.put(doc("$a", "!r:x", 1, "after"));
60- assert!(index.search("before", None, None, 10).unwrap().0.is_empty());
61- assert_eq!(
62- keys(&index.search("after", None, None, 10).unwrap().0),
63- vec!["$a"]
64- );
73+ assert!(found(&mut index, "before").is_empty());
74+ assert_eq!(found(&mut index, "after"), vec!["$a"]);
6575 }
6676
6777 #[test]
@@ -83,50 +93,58 @@ fn rooms_filter_the_results() {
8393 );
8494 }
8595
86-/// a lost delete brings a redacted message back, and a lost segment loses messages
8796 #[test]
88-fn deletes_and_merges_survive_a_reload() {
89- let mut blobs = BTreeMap::new();
97+fn provisional_docs_stay_out_of_results() {
98+ let mut index = KeywordIndex::new(false);
99+ let mut pending = doc(&provisional_key("$o", "@a:x"), "!r:x", 5, "early edit");
100+ pending.provisional = true;
101+ index.put(pending);
102+ assert!(found(&mut index, "early").is_empty());
103+ assert!(index.remove_provisional("$o"));
104+ assert!(index.get(&provisional_key("$o", "@a:x")).is_none());
105+}
106+
107+/// the manifest used to be read before `committed`, one commit stale, so a restart lost
108+/// the last flush and merge, and a bumped delete generation failed the whole load
109+#[test]
110+fn every_commit_reloads_to_what_was_committed() {
111+ let mut store = BTreeMap::new();
90112 let mut index = KeywordIndex::new(true);
91- for ts in 0..(MERGE_FACTOR as u64 + 2) {
113+ let count = MERGE_FACTOR as u64 + 2;
114+ for ts in 0..count {
92115 index.put(doc(&format!("$e{ts}"), "!r:x", ts, "kept"));
93- commit(&mut index, &mut blobs);
116+ commit(&mut index, &mut store);
117+ assert_eq!(found(&mut reload(&store), "kept").len(), ts as usize + 1);
94118 }
95- index.put(doc("$gone", "!r:x", 100, "kept"));
96- commit(&mut index, &mut blobs);
97- index.remove("$gone");
98119 index.remove("$e0");
99- let manifest = commit(&mut index, &mut blobs);
100- let mut reloaded = KeywordIndex::load(&manifest, |key| blobs.get(key).cloned()).unwrap();
101- let (hits, _) = reloaded.search("kept", None, None, 50).unwrap();
102- let mut found = keys(&hits);
103- found.sort();
104- let mut expected: Vec<String> = (1..(MERGE_FACTOR as u64 + 2))
105- .map(|ts| format!("$e{ts}"))
106- .collect();
107- expected.sort();
108- assert_eq!(
109- found,
110- expected.iter().map(String::as_str).collect::<Vec<_>>()
111- );
112- let referenced: Vec<_> = blobs.keys().filter(|key| key.starts_with("seg/")).collect();
113- assert!(
114- referenced.len() < MERGE_FACTOR,
115- "merged segments were not dropped: {referenced:?}"
116- );
120+ commit(&mut index, &mut store);
121+ index.remove("$e1");
122+ commit(&mut index, &mut store);
123+ index.put(doc("$e2", "!r:x", 2, "edited"));
124+ commit(&mut index, &mut store);
125+ let mut reloaded = reload(&store);
126+ let expected: Vec<String> = (3..count).map(|ts| format!("$e{ts}")).collect();
127+ assert_eq!(found(&mut reloaded, "kept"), expected);
128+ assert_eq!(found(&mut reloaded, "edited"), vec!["$e2"]);
129+ let mut referenced = reloaded.referenced();
130+ referenced.push("meta".to_owned());
131+ referenced.sort();
132+ let stored: Vec<_> = store.keys().cloned().collect();
133+ assert_eq!(stored, referenced, "a commit left blobs nothing points at");
117134 }
118135
119136 #[test]
120-fn match_ranges_follow_the_analyser() {
121- assert_eq!(
122- match_ranges("Café, CAFE and cafés", &["cafe".to_owned()]),
123- vec![(0, 5), (7, 11)]
124- );
137+fn unspaced_text_matches_inside_a_run() {
138+ let mut index = KeywordIndex::new(false);
139+ index.put(doc("$a", "!r:x", 1, "明日東京都庁に行きます"));
140+ assert_eq!(found(&mut index, "東京"), vec!["$a"]);
141+ assert_eq!(found(&mut index, "\"都庁\""), vec!["$a"]);
142+ assert!(found(&mut index, "大阪").is_empty());
125143 }
126144
127145 #[test]
128146 fn packed_segments_reject_truncation() {
129- let packed = pack(&[("a".to_owned(), vec![1, 2, 3])]);
147+ let packed = pack(&[("a".to_owned(), OwnedBytes::new(vec![1, 2, 3]))]);
130148 assert!(unpack(&packed).is_some());
131149 assert!(unpack(&packed[..packed.len() - 1]).is_none());
132150 }
Modified file: src/search/mod.rs+546 additions−221 deletions
@@ -3,35 +3,38 @@ mod feed;
33 mod index;
44 #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
55 pub(crate) mod store;
6+mod text;
67
78 use std::{
8- collections::{BTreeMap, HashMap, HashSet, VecDeque},
9+ collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
910 sync::Arc,
1011 time::Duration,
1112 };
1213
1314 use futures_util::{
1415 StreamExt,
15- future::join_all,
16+ future::Abortable,
1617 stream::{AbortHandle, FuturesUnordered, SelectAll, abortable},
1718 };
1819 use matrix_sdk::{
1920 Client, Room, RoomState, SendOutsideWasm,
2021 deserialized_responses::TimelineEvent as SdkTimelineEvent,
2122 event_cache::{RoomEventCacheSubscriber, RoomEventCacheUpdate},
22- room::MessagesOptions,
23+ room::{Messages, MessagesOptions},
2324 };
2425 use matrix_sdk_base::RoomInfoNotableUpdateReasons;
2526 use matrix_sdk_ui::eyeball_im::VectorDiff;
2627 use ruma::{
2728 OwnedRoomId, OwnedUserId, UInt,
29+ api::client::filter::RoomEventFilter,
2830 events::{
29- ignored_user_list::IgnoredUserListEventContent,
31+ ignored_user_list::IgnoredUserListEventContent, relation::RelationType,
3032 room::encrypted::OriginalSyncRoomEncryptedEvent,
3133 },
3234 serde::Raw,
3335 };
3436 use serde_json::Value;
37+use sha2::{Digest, Sha256};
3538 use tokio::sync::{Mutex, broadcast, mpsc, oneshot};
3639
3740 use self::{
@@ -39,24 +42,41 @@ use self::{
3942 index::{Changes, Doc, Hit, KeywordIndex},
4043 };
4144 use crate::{
42- CommandOutput, EngineConfig, Error, ErrorKind, RoomSearchCoverage, SearchBackfill,
43- SearchCommand, SearchCoverage, SearchHit, SearchMatch, SearchPage, SearchScope, SearchSession,
45+ CommandOutput, EngineConfig, Error, ErrorKind, Event, RoomSearchCoverage, SearchBackfill,
46+ SearchCloseReason, SearchCommand, SearchCoverage, SearchHit, SearchInMemory, SearchMatch,
47+ SearchPage, SearchScope, SearchSession,
4448 engine::{MediaService, SearchStore, context, executor::EngineBoxFuture},
4549 normalise::{MediaRegistration, MediaScope},
50+ transport::EventSender,
4651 };
4752
4853 const MAX_LIMIT: u16 = 50;
4954 /// a consumer that never closes its searches still holds at most this many
5055 const MAX_SEARCHES: usize = 8;
56+/// hits read back at once while a page is put together
57+const PREVIEWS_AT_ONCE: usize = 8;
58+const DECRYPTS_AT_ONCE: usize = 8;
5159 const COMMIT_AFTER_MS: u64 = 5_000;
5260 const COMMIT_BUFFERED: usize = 2_000;
5361 #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
5462 const COMMIT_RETRY_MS: u64 = 60_000;
63+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]
64+const COMMIT_ATTEMPTS: u32 = 5;
5565 const IDLE_SUBSCRIPTION_MS: u64 = 5 * 60_000;
5666 const BACKFILL_RETRY_MS: u64 = 60_000;
5767 const BACKFILL_RETRY_MAX_MS: u64 = 15 * 60_000;
5868 #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
59-const SCHEMA: u64 = 1;
69+const SCHEMA: u64 = 2;
70+/// waiting undecryptable events are saved in this many blobs, so one arriving rewrites a sixteenth
71+const UTD_SHARDS: u8 = 16;
72+const BACKFILL_TYPES: [&str; 6] = [
73+ "m.room.message",
74+ "m.room.encrypted",
75+ "m.room.redaction",
76+ "m.sticker",
77+ "m.poll.start",
78+ "org.matrix.msc3381.poll.start",
79+];
6080
6181 #[cfg(not(target_family = "wasm"))]
6282 type BoxStream<T> = futures_util::stream::BoxStream<'static, T>;
@@ -72,6 +92,8 @@ pub(crate) trait MessageIndex: SendOutsideWasm {
7292 /// replaces whatever `doc.key` held
7393 fn put(&mut self, doc: Doc);
7494 fn remove(&mut self, key: &str) -> bool;
95+ /// every provisional doc waiting on `target`, whoever sent its edit
96+ fn remove_provisional(&mut self, target: &str) -> bool;
7597 fn buffered(&self) -> usize;
7698 /// newest first, strictly older than `before`. also hands back the analysed query terms
7799 fn search(
@@ -84,7 +106,8 @@ pub(crate) trait MessageIndex: SendOutsideWasm {
84106 fn maintain(&mut self) -> Result<(), Error>;
85107 fn changes(&mut self) -> Result<Changes, Error>;
86108 fn committed(&mut self, changes: &Changes);
87- fn manifest(&self) -> Value;
109+ /// stops tracking what a commit would need, once nothing will be committed again
110+ fn detach(&mut self);
88111 }
89112
90113 #[derive(Clone, Debug, Default)]
@@ -160,46 +183,70 @@ enum Work {
160183 Backfill {
161184 room: OwnedRoomId,
162185 head: bool,
163- result: Result<matrix_sdk::room::Messages, Error>,
186+ from: Option<String>,
187+ result: Result<Messages, Error>,
164188 },
189+ /// the room was left while its page was in flight
190+ BackfillCancelled,
165191 Decrypted {
166192 key: String,
167193 room: OwnedRoomId,
168194 value: Option<Value>,
169195 },
196+ /// the original first, then whatever edits of it the cache or server still has
170197 Refetched {
171198 room: OwnedRoomId,
172- value: Option<Value>,
199+ values: Vec<Value>,
173200 },
174201 }
175202
203+enum RoomUpdate {
204+ Events(Vec<SdkTimelineEvent>),
205+ Lagged,
206+ Closed,
207+}
208+
176209 struct Failure {
177210 message: String,
178211 retry_at_ms: u64,
212+ delay_ms: u64,
213+}
214+
215+enum Persistence {
216+ Store(SearchStore),
217+ Memory(SearchInMemory),
179218 }
180219
181220 pub(crate) struct Service {
182221 client: Client,
183222 index: Box<dyn MessageIndex>,
184- store: Option<Result<SearchStore, Error>>,
223+ persistence: Persistence,
224+ #[cfg_attr(not(any(feature = "sqlite", feature = "indexeddb")), allow(dead_code))]
225+ commit_failures: u32,
226+ /// storage keys a load found nothing pointing at, dropped with the next commit
227+ #[cfg_attr(not(any(feature = "sqlite", feature = "indexeddb")), allow(dead_code))]
228+ orphans: Vec<String>,
185229 rooms: BTreeMap<String, RoomProgress>,
230+ /// each room's newest indexed message when this engine started, what a head pass looks for
231+ baselines: HashMap<String, u64>,
186232 pending: BTreeMap<String, Pending>,
187- pending_dirty: bool,
233+ dirty_shards: BTreeSet<u8>,
234+ decrypt_queue: VecDeque<String>,
235+ decrypting: usize,
188236 redactions: Redactions,
189237 dirty_since: Option<u64>,
190238 commit_after: u64,
191239 budget: Budget,
192240 requests: mpsc::Receiver<Request>,
193241 subscriptions: HashMap<OwnedRoomId, (AbortHandle, u64)>,
194- updates: SelectAll<BoxStream<(OwnedRoomId, Option<Vec<SdkTimelineEvent>>)>>,
242+ updates: SelectAll<BoxStream<(OwnedRoomId, RoomUpdate)>>,
195243 work: FuturesUnordered<EngineBoxFuture<'static, Work>>,
196244 pages: HashMap<OwnedRoomId, u16>,
197245 heads: VecDeque<OwnedRoomId>,
198246 headed: HashSet<OwnedRoomId>,
199247 head_tokens: HashMap<OwnedRoomId, String>,
200- backfilling: Option<OwnedRoomId>,
248+ backfilling: Option<(OwnedRoomId, AbortHandle)>,
201249 next_backfill: u64,
202- retry_delay: u64,
203250 failures: HashMap<OwnedRoomId, Failure>,
204251 turn: usize,
205252 }
@@ -224,6 +271,18 @@ fn decode_cursor(cursor: &str) -> Option<(u64, u64)> {
224271 ))
225272 }
226273
274+fn shard(key: &str) -> u8 {
275+ Sha256::digest(key.as_bytes())[0] % UTD_SHARDS
276+}
277+
278+/// a 400 to a page with a saved token means the server no longer takes the token
279+fn token_rejected(error: &Error) -> bool {
280+ error
281+ .server
282+ .as_ref()
283+ .is_some_and(|server| server.status == 400 || server.errcode == "M_INVALID_PARAM")
284+}
285+
227286 impl Handle {
228287 pub(crate) async fn execute(&self, command: SearchCommand) -> Result<CommandOutput, Error> {
229288 match command {
@@ -232,18 +291,30 @@ impl Handle {
232291 scope,
233292 limit,
234293 } => {
235- let (page, media) = self.search(&query, &scope, None, limit).await?;
236- let search_id = self.sessions.open(&self.media, query, scope, media).await;
237- Ok(CommandOutput::SearchSession(SearchSession { search_id, page }))
294+ let media_scope = self.media.context_scope();
295+ let prefix = media_prefix(&media_scope);
296+ let (page, media) = self.search(&query, &scope, None, limit, &prefix).await?;
297+ let search_id = self
298+ .sessions
299+ .open(&self.media, query, scope, media_scope, media)
300+ .await;
301+ Ok(CommandOutput::SearchSession(SearchSession {
302+ search_id,
303+ page,
304+ }))
238305 }
239306 SearchCommand::More {
240307 search_id,
241308 cursor,
242309 limit,
243310 } => {
244- let (query, scope) = self.sessions.get(&search_id).await?;
245- let (page, media) = self.search(&query, &scope, Some(cursor.as_str()), limit).await?;
246- self.sessions.register(&self.media, &search_id, media).await?;
311+ let (query, scope, prefix) = self.sessions.get(&search_id).await?;
312+ let (page, media) = self
313+ .search(&query, &scope, Some(cursor.as_str()), limit, &prefix)
314+ .await?;
315+ self.sessions
316+ .register(&self.media, &search_id, media)
317+ .await?;
247318 Ok(CommandOutput::SearchPage(page))
248319 }
249320 SearchCommand::Close { search_id } => {
@@ -291,6 +362,7 @@ impl Handle {
291362 scope: &SearchScope,
292363 cursor: Option<&str>,
293364 limit: u16,
365+ media_prefix: &str,
294366 ) -> Result<(SearchPage, Vec<MediaRegistration>), Error> {
295367 if limit == 0 || limit > MAX_LIMIT {
296368 return Err(Error::new(
@@ -298,9 +370,6 @@ impl Handle {
298370 "search limit must be between 1 and 50",
299371 ));
300372 }
301- if query.trim().is_empty() {
302- return Err(Error::new(ErrorKind::Invalid, "search query is empty"));
303- }
304373 let before = match cursor {
305374 Some(cursor) => Some(
306375 decode_cursor(cursor)
@@ -329,12 +398,15 @@ impl Handle {
329398 .flatten();
330399 let ignored = self.ignored_users().await;
331400 let current_user_id = self.client.user_id().map(ToOwned::to_owned);
332- let previews = join_all(
401+ let terms = &raw.terms;
402+ let previews: Vec<_> = futures_util::stream::iter(
333403 raw.hits
334404 .into_iter()
335- .filter(|hit| !ignored.iter().any(|user| user.as_str() == hit.sender))
336- .map(|hit| self.preview(hit, current_user_id.clone(), &raw.terms)),
405+ .filter(|hit| !ignored.iter().any(|user| user.as_str() == hit.sender)),
337406 )
407+ .map(|hit| self.preview(hit, current_user_id.clone(), terms, media_prefix))
408+ .buffered(PREVIEWS_AT_ONCE)
409+ .collect()
338410 .await;
339411 let mut hits = Vec::new();
340412 let mut media = Vec::new();
@@ -375,6 +447,7 @@ impl Handle {
375447 hit: Hit,
376448 current_user_id: Option<OwnedUserId>,
377449 terms: &[String],
450+ media_prefix: &str,
378451 ) -> Preview {
379452 let (Ok(room_id), Ok(event_id)) = (
380453 OwnedRoomId::try_from(hit.room.as_str()),
@@ -385,20 +458,33 @@ impl Handle {
385458 let Some(room) = self.client.get_room(&room_id) else {
386459 return Preview::Unavailable;
387460 };
388- let Some((original, highlight)) = load(&room, &hit.key).await else {
461+ let Some((mut original, highlight)) = load(&room, &hit.key).await else {
389462 return Preview::Unavailable;
390463 };
391464 if original.pointer("/unsigned/redacted_because").is_some() {
392465 return Preview::Redacted(hit.key);
393466 }
467+ // the index already settled which edit is newest, and a bundled one can be older
468+ if let Some(relations) = original
469+ .pointer_mut("/unsigned/m.relations")
470+ .and_then(Value::as_object_mut)
471+ {
472+ relations.remove("m.replace");
473+ }
394474 let mut values = vec![original];
395475 if hit.content != hit.key
396476 && let Some((edit, _)) = load(&room, &hit.content).await
397477 {
398478 values.push(edit);
399479 }
400- let Some((event, registrations)) =
401- context::preview(&room, current_user_id.as_deref(), &values, highlight).await
480+ let Some((event, registrations)) = context::preview(
481+ &room,
482+ current_user_id.as_deref(),
483+ &values,
484+ highlight,
485+ media_prefix,
486+ )
487+ .await
402488 else {
403489 return Preview::Unavailable;
404490 };
@@ -409,7 +495,7 @@ impl Handle {
409495 _ => None,
410496 };
411497 let matches = text
412- .map(|text| index::match_ranges(text, terms))
498+ .map(|text| text::match_ranges(text, terms))
413499 .unwrap_or_default()
414500 .into_iter()
415501 .filter_map(|(start, end)| {
@@ -439,9 +525,14 @@ enum Preview {
439525 }
440526
441527 /// open searches, oldest first, each owning the media of the hits it handed out
442-#[derive(Default)]
443528 struct Sessions {
444529 open: Mutex<VecDeque<Session>>,
530+ events: EventSender,
531+}
532+
533+/// media ids carry the search, so two owners of one event never share an entry
534+fn media_prefix(scope: &MediaScope) -> String {
535+ format!("{}#", scope.nonce())
445536 }
446537
447538 struct Session {
@@ -457,30 +548,51 @@ impl Sessions {
457548 media: &MediaService,
458549 query: String,
459550 scope: SearchScope,
551+ media_scope: MediaScope,
460552 registrations: Vec<MediaRegistration>,
461553 ) -> String {
462- let media_scope = media.context_scope();
463554 let id = format!("search-{}", media_scope.nonce());
464- let mut open = self.open.lock().await;
465- media.register(&media_scope, registrations).await;
466- open.push_back(Session {
467- id: id.clone(),
468- query,
469- scope,
470- media: media_scope,
471- });
472- while open.len() > MAX_SEARCHES {
473- if let Some(oldest) = open.pop_front() {
474- media.release(&oldest.media).await;
555+ let mut evicted = Vec::new();
556+ {
557+ let mut open = self.open.lock().await;
558+ media.register(&media_scope, registrations).await;
559+ open.push_back(Session {
560+ id: id.clone(),
561+ query,
562+ scope,
563+ media: media_scope,
564+ });
565+ while open.len() > MAX_SEARCHES {
566+ if let Some(oldest) = open.pop_front() {
567+ media.release(&oldest.media).await;
568+ evicted.push(oldest.id);
569+ }
570+ }
571+ }
572+ for search_id in evicted {
573+ let event = Event::SearchClosed {
574+ search_id,
575+ reason: SearchCloseReason::Evicted,
576+ };
577+ if self.events.send(event).await.is_err() {
578+ break;
475579 }
476580 }
477581 id
478582 }
479583
480- async fn get(&self, id: &str) -> Result<(String, SearchScope), Error> {
584+ /// the query, scope and media prefix the search's pages use
585+ async fn get(&self, id: &str) -> Result<(String, SearchScope, String), Error> {
481586 let open = self.open.lock().await;
482- let session = open.iter().find(|session| session.id == id).ok_or_else(not_open)?;
483- Ok((session.query.clone(), session.scope.clone()))
587+ let session = open
588+ .iter()
589+ .find(|session| session.id == id)
590+ .ok_or_else(not_open)?;
591+ Ok((
592+ session.query.clone(),
593+ session.scope.clone(),
594+ media_prefix(&session.media),
595+ ))
484596 }
485597
486598 /// under the lock, so a page that lands after its close registers nothing
@@ -491,7 +603,10 @@ impl Sessions {
491603 registrations: Vec<MediaRegistration>,
492604 ) -> Result<(), Error> {
493605 let open = self.open.lock().await;
494- let session = open.iter().find(|session| session.id == id).ok_or_else(not_open)?;
606+ let session = open
607+ .iter()
608+ .find(|session| session.id == id)
609+ .ok_or_else(not_open)?;
495610 media.register(&session.media, registrations).await;
496611 Ok(())
497612 }
@@ -510,7 +625,10 @@ impl Sessions {
510625 }
511626
512627 fn not_open() -> Error {
513- Error::new(ErrorKind::SearchNotOpen, "search is closed or was never opened")
628+ Error::new(
629+ ErrorKind::SearchNotOpen,
630+ "search is closed or was never opened",
631+ )
514632 }
515633
516634 async fn load(room: &Room, event_id: &str) -> Option<(Value, bool)> {
@@ -526,25 +644,25 @@ async fn load(room: &Room, event_id: &str) -> Option<(Value, bool)> {
526644 Some((value_of(&event)?, highlight))
527645 }
528646
529-#[cfg(any(feature = "sqlite", feature = "indexeddb"))]
530-fn decode_meta(
531- store: &SearchStore,
532- blobs: &BTreeMap<String, Vec<u8>>,
533-) -> Option<(
534- Value,
535- BTreeMap<String, RoomProgress>,
536- BTreeMap<String, Pending>,
537-)> {
538- let meta: Value = serde_json::from_slice(&store.open_blob("meta", blobs.get("meta")?)?).ok()?;
539- if meta["schema"].as_u64() != Some(SCHEMA) {
540- return None;
541- }
542- let rooms = serde_json::from_value(meta["rooms"].clone()).ok()?;
543- let pending = match blobs.get("utd") {
544- Some(sealed) => serde_json::from_slice(&store.open_blob("utd", sealed)?).ok()?,
545- None => BTreeMap::new(),
647+/// the original and every edit of it still around, so a redacted latest edit falls back to
648+/// the one before it rather than to the original
649+async fn load_with_edits(room: &Room, event_id: &str) -> Vec<Value> {
650+ let Ok(event_id) = ruma::EventId::parse(event_id) else {
651+ return Vec::new();
546652 };
547- Some((meta["index"].clone(), rooms, pending))
653+ match room
654+ .load_or_fetch_event_with_relations(&event_id, Some(vec![RelationType::Replacement]), None)
655+ .await
656+ {
657+ Ok((original, edits)) => std::iter::once(original)
658+ .chain(edits)
659+ .filter_map(|event| value_of(&event))
660+ .collect(),
661+ Err(error) => {
662+ tracing::debug!(%event_id, %error, "reading an original again failed");
663+ Vec::new()
664+ }
665+ }
548666 }
549667
550668 impl Service {
@@ -552,16 +670,33 @@ impl Service {
552670 client: Client,
553671 media: Arc<MediaService>,
554672 store: Option<Result<SearchStore, Error>>,
673+ events: EventSender,
555674 config: &EngineConfig,
556675 ) -> (Handle, Self) {
557676 let (sender, requests) = mpsc::channel(config.command_capacity.max(1));
677+ let persistence = match store {
678+ Some(Ok(store)) => Persistence::Store(store),
679+ Some(Err(error)) => {
680+ tracing::error!(%error, "search store unavailable, the index lives in memory");
681+ Persistence::Memory(SearchInMemory::OpenFailed)
682+ }
683+ None => Persistence::Memory(SearchInMemory::NoStore),
684+ };
558685 let service = Self {
559686 client: client.clone(),
560- index: Box::new(KeywordIndex::new(store.as_ref().is_some_and(Result::is_ok))),
561- store,
687+ index: Box::new(KeywordIndex::new(matches!(
688+ persistence,
689+ Persistence::Store(_)
690+ ))),
691+ persistence,
692+ commit_failures: 0,
693+ orphans: Vec::new(),
562694 rooms: BTreeMap::new(),
695+ baselines: HashMap::new(),
563696 pending: BTreeMap::new(),
564- pending_dirty: false,
697+ dirty_shards: BTreeSet::new(),
698+ decrypt_queue: VecDeque::new(),
699+ decrypting: 0,
565700 redactions: Redactions::default(),
566701 dirty_since: None,
567702 commit_after: 0,
@@ -576,7 +711,6 @@ impl Service {
576711 head_tokens: HashMap::new(),
577712 backfilling: None,
578713 next_backfill: 0,
579- retry_delay: BACKFILL_RETRY_MS,
580714 failures: HashMap::new(),
581715 turn: 0,
582716 };
@@ -585,129 +719,181 @@ impl Service {
585719 requests: sender,
586720 client,
587721 media,
588- sessions: Arc::default(),
722+ sessions: Arc::new(Sessions {
723+ open: Mutex::default(),
724+ events,
725+ }),
589726 },
590727 service,
591728 )
592729 }
593730
594- fn persistent(&self) -> bool {
595- matches!(self.store, Some(Ok(_)))
731+ fn in_memory(&self) -> Option<SearchInMemory> {
732+ match self.persistence {
733+ Persistence::Store(_) => None,
734+ Persistence::Memory(reason) => Some(reason),
735+ }
736+ }
737+
738+ #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
739+ fn fall_back(&mut self, reason: SearchInMemory) {
740+ self.persistence = Persistence::Memory(reason);
741+ self.index.detach();
742+ self.orphans.clear();
596743 }
597744
598745 #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
599746 async fn load(&mut self) {
600- let Some(Ok(store)) = &self.store else {
601- if let Some(Err(error)) = &self.store {
602- tracing::error!(%error, "search store unavailable, the index lives in memory");
603- }
747+ let Persistence::Store(store) = &self.persistence else {
604748 return;
605749 };
606- let blobs = match store.load().await {
750+ let mut blobs = match store.load().await {
607751 Ok(blobs) => blobs,
608752 Err(error) => {
609753 tracing::error!(%error, "reading the search store failed, the index lives in memory");
610- self.store = Some(Err(error));
611- self.index = Box::new(KeywordIndex::new(false));
754+ self.fall_back(SearchInMemory::ReadFailed);
612755 return;
613756 }
614757 };
615758 if blobs.is_empty() {
616759 return;
617760 }
618- let restored = decode_meta(store, &blobs).and_then(|(manifest, rooms, pending)| {
619- let index = KeywordIndex::load(&manifest, |key| {
620- blobs
621- .get(key)
622- .and_then(|sealed| store.open_blob(key, sealed))
623- })
624- .inspect_err(|error| tracing::warn!(%error, "search segments are unreadable"))
625- .ok()?;
761+ let restored = (|| {
762+ let meta: Value = serde_json::from_slice(&store.take(&mut blobs, "meta")?).ok()?;
763+ if meta["schema"].as_u64() != Some(SCHEMA) {
764+ return None;
765+ }
766+ let rooms: BTreeMap<String, RoomProgress> =
767+ serde_json::from_value(meta["rooms"].clone()).ok()?;
768+ let mut pending = BTreeMap::new();
769+ for shard in 0..UTD_SHARDS {
770+ if let Some(bytes) = store.take(&mut blobs, &format!("utd/{shard}")) {
771+ let part: BTreeMap<String, Pending> = serde_json::from_slice(&bytes).ok()?;
772+ pending.extend(part);
773+ }
774+ }
775+ let index = KeywordIndex::load(&meta["index"], |name| store.take(&mut blobs, name))
776+ .inspect_err(|error| tracing::warn!(%error, "search segments are unreadable"))
777+ .ok()?;
626778 Some((index, rooms, pending))
627- });
628- drop(blobs);
779+ })();
629780 match restored {
630781 Some((index, rooms, pending)) => {
631782 self.index = Box::new(index);
783+ self.baselines = rooms
784+ .iter()
785+ .filter_map(|(room, progress)| Some((room.clone(), progress.newest?)))
786+ .collect();
632787 self.rooms = rooms;
633788 self.pending = pending;
789+ self.orphans = blobs.leftover();
790+ if !self.orphans.is_empty() {
791+ self.touch();
792+ }
634793 }
635794 None => {
636795 tracing::warn!("resetting an unreadable search index");
637796 if let Err(error) = store.reset().await {
638797 tracing::error!(%error, "search store unavailable, the index lives in memory");
639- self.store = Some(Err(error));
640- self.index = Box::new(KeywordIndex::new(false));
798+ self.fall_back(SearchInMemory::ReadFailed);
641799 }
642800 }
643801 }
644802 }
645803
646804 #[cfg(not(any(feature = "sqlite", feature = "indexeddb")))]
647- async fn load(&mut self) {
648- if let Some(Err(error)) = &self.store {
649- tracing::error!(%error, "search store unavailable, the index lives in memory");
650- }
651- }
805+ async fn load(&mut self) {}
652806
653807 #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
654808 async fn commit(&mut self) {
655- if !self.persistent() {
809+ if !matches!(self.persistence, Persistence::Store(_)) {
656810 if let Err(error) = self.index.maintain() {
657811 tracing::warn!(%error, "maintaining the search index failed");
658812 }
659813 self.dirty_since = None;
814+ self.dirty_shards.clear();
660815 return;
661816 }
662- let changes = match self.index.changes() {
817+ let mut changes = match self.index.changes() {
663818 Ok(changes) => changes,
664819 Err(error) => {
665820 tracing::warn!(%error, "preparing a search commit failed");
666- self.commit_after = now() + COMMIT_RETRY_MS;
821+ self.commit_failed();
667822 return;
668823 }
669824 };
670- let mut puts = changes.puts.clone();
825+ let mut puts = std::mem::take(&mut changes.puts);
826+ let mut deletes = changes.deletes.clone();
671827 let meta = serde_json::json!({
672828 "schema": SCHEMA,
673- "index": self.index.manifest(),
829+ "index": changes.manifest,
674830 "rooms": self.rooms,
675831 });
676832 puts.push(("meta".to_owned(), meta.to_string().into_bytes()));
677- if self.pending_dirty {
678- puts.push((
679- "utd".to_owned(),
680- serde_json::to_vec(&self.pending).unwrap_or_default(),
681- ));
833+ for shard in &self.dirty_shards {
834+ let part: BTreeMap<&String, &Pending> = self
835+ .pending
836+ .iter()
837+ .filter(|(key, _)| self::shard(key) == *shard)
838+ .collect();
839+ let name = format!("utd/{shard}");
840+ if part.is_empty() {
841+ deletes.push(name);
842+ } else {
843+ puts.push((name, serde_json::to_vec(&part).unwrap_or_default()));
844+ }
682845 }
683- let Some(Ok(store)) = &self.store else {
846+ let Persistence::Store(store) = &self.persistence else {
684847 return;
685848 };
686- match store.commit(puts, changes.deletes.clone()).await {
849+ match store.commit(puts, deletes, self.orphans.clone()).await {
687850 Ok(()) => {
688851 self.index.committed(&changes);
689- self.pending_dirty = false;
852+ self.dirty_shards.clear();
853+ self.orphans.clear();
690854 self.dirty_since = None;
855+ self.commit_failures = 0;
691856 }
692857 Err(error) => {
693- tracing::warn!(%error, "saving the search index failed, retrying later");
694- self.commit_after = now() + COMMIT_RETRY_MS;
858+ tracing::warn!(%error, "saving the search index failed");
859+ self.commit_failed();
695860 }
696861 }
697862 }
698863
864+ #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
865+ fn commit_failed(&mut self) {
866+ self.commit_failures += 1;
867+ if self.commit_failures >= COMMIT_ATTEMPTS {
868+ tracing::error!("search commits keep failing, the index lives in memory from here");
869+ self.fall_back(SearchInMemory::CommitFailed);
870+ self.dirty_since = None;
871+ self.dirty_shards.clear();
872+ } else {
873+ self.commit_after = now() + COMMIT_RETRY_MS;
874+ }
875+ }
876+
699877 #[cfg(not(any(feature = "sqlite", feature = "indexeddb")))]
700878 async fn commit(&mut self) {
701879 if let Err(error) = self.index.maintain() {
702880 tracing::warn!(%error, "maintaining the search index failed");
703881 }
704882 self.dirty_since = None;
883+ self.dirty_shards.clear();
705884 }
706885
707886 fn touch(&mut self) {
708887 self.dirty_since.get_or_insert_with(now);
709888 }
710889
890+ fn unpend(&mut self, key: &str) {
891+ if self.pending.remove(key).is_some() {
892+ self.dirty_shards.insert(shard(key));
893+ self.touch();
894+ }
895+ }
896+
711897 fn observe(&mut self, room: &OwnedRoomId, value: &Value) {
712898 let observed = feed::observe(value);
713899 let key = match &observed {
@@ -733,6 +919,7 @@ impl Service {
733919 raw: value.to_string(),
734920 },
735921 );
922+ self.dirty_shards.insert(shard(key));
736923 while self.pending.len() > self.budget.max_pending {
737924 let Some(oldest) = self
738925 .pending
@@ -742,18 +929,14 @@ impl Service {
742929 else {
743930 break;
744931 };
745- self.pending.remove(&oldest);
932+ self.unpend(&oldest);
746933 }
747- self.pending_dirty = true;
748934 self.touch();
749935 }
750936 return;
751937 }
752- if let Some(key) = key
753- && self.pending.remove(&key).is_some()
754- {
755- self.pending_dirty = true;
756- self.touch();
938+ if let Some(key) = key {
939+ self.unpend(&key);
757940 }
758941 match feed::apply(
759942 self.index.as_mut(),
@@ -767,19 +950,19 @@ impl Service {
767950 progress.newest = Some(progress.newest.map_or(ts, |newest| newest.max(ts)));
768951 self.touch();
769952 }
770- Effect::Removed => self.touch(),
953+ Effect::Removed | Effect::Held => self.touch(),
771954 Effect::Refetch(key) => {
772955 self.touch();
773956 let room_id = room.clone();
774957 let client = self.client.clone();
775958 self.work.push(Box::pin(async move {
776- let value = match client.get_room(&room_id) {
777- Some(room) => load(&room, &key).await.map(|(value, _)| value),
778- None => None,
959+ let values = match client.get_room(&room_id) {
960+ Some(room) => load_with_edits(&room, &key).await,
961+ None => Vec::new(),
779962 };
780963 Work::Refetched {
781964 room: room_id,
782- value,
965+ values,
783966 }
784967 }));
785968 }
@@ -817,11 +1000,66 @@ impl Service {
8171000 let (stream, abort) = abortable(stream);
8181001 self.updates.push(Box::pin(stream));
8191002 self.subscriptions.insert(room_id.clone(), (abort, now()));
820- if self.headed.insert(room_id.clone()) {
1003+ let started = self
1004+ .rooms
1005+ .get(room_id.as_str())
1006+ .is_some_and(|progress| progress.started);
1007+ if started
1008+ && self.baselines.contains_key(room_id.as_str())
1009+ && self.headed.insert(room_id.clone())
1010+ {
8211011 self.heads.push_back(room_id);
8221012 }
8231013 }
8241014
1015+ /// the generic channel holds 128 updates, and a missed one could be a room we never
1016+ /// follow, so a lag follows every joined room
1017+ async fn generic(&mut self, update: Result<OwnedRoomId, broadcast::error::RecvError>) -> bool {
1018+ match update {
1019+ Ok(room_id) => self.subscribe(room_id).await,
1020+ Err(broadcast::error::RecvError::Lagged(_)) => {
1021+ for room in self.client.joined_rooms() {
1022+ self.subscribe(room.room_id().to_owned()).await;
1023+ }
1024+ }
1025+ Err(broadcast::error::RecvError::Closed) => return false,
1026+ }
1027+ true
1028+ }
1029+
1030+ async fn room_update(&mut self, room_id: OwnedRoomId, update: RoomUpdate) {
1031+ match update {
1032+ RoomUpdate::Events(events) => {
1033+ if let Some((_, seen)) = self.subscriptions.get_mut(&room_id) {
1034+ *seen = now();
1035+ }
1036+ self.observe_all(&room_id, &events);
1037+ }
1038+ RoomUpdate::Lagged => {
1039+ if let Some((abort, _)) = self.subscriptions.remove(&room_id) {
1040+ abort.abort();
1041+ }
1042+ self.subscribe(room_id).await;
1043+ }
1044+ RoomUpdate::Closed => {
1045+ self.subscriptions.remove(&room_id);
1046+ }
1047+ }
1048+ }
1049+
1050+ fn left(&mut self, room_id: &OwnedRoomId) {
1051+ if let Some((abort, _)) = self.subscriptions.remove(room_id) {
1052+ abort.abort();
1053+ }
1054+ self.heads.retain(|head| head != room_id);
1055+ self.head_tokens.remove(room_id);
1056+ if let Some((backfilling, abort)) = &self.backfilling
1057+ && backfilling == room_id
1058+ {
1059+ abort.abort();
1060+ }
1061+ }
1062+
8251063 fn drop_idle(&mut self) {
8261064 let cutoff = now().saturating_sub(IDLE_SUBSCRIPTION_MS);
8271065 self.subscriptions.retain(|_, (abort, seen)| {
@@ -834,6 +1072,12 @@ impl Service {
8341072 }
8351073
8361074 fn coverage(&self, rooms: &[OwnedRoomId]) -> SearchCoverage {
1075+ let scoped: HashSet<&str> = rooms.iter().map(|room| room.as_str()).collect();
1076+ let undecrypted = self
1077+ .pending
1078+ .values()
1079+ .filter(|pending| scoped.contains(pending.room.as_str()))
1080+ .count();
8371081 let rooms = rooms
8381082 .iter()
8391083 .map(|room_id| {
@@ -846,16 +1090,16 @@ impl Service {
8461090 SearchBackfill::Complete
8471091 } else if self.budget.pages_per_room == 0 {
8481092 SearchBackfill::Disabled
849- } else if self.backfilling.as_ref() == Some(room_id) {
850- SearchBackfill::Running
851- } else if let Some(Failure {
852- message,
853- retry_at_ms,
854- }) = self.failures.get(room_id)
1093+ } else if self
1094+ .backfilling
1095+ .as_ref()
1096+ .is_some_and(|(backfilling, _)| backfilling == room_id)
8551097 {
1098+ SearchBackfill::Running
1099+ } else if let Some(failure) = self.failures.get(room_id) {
8561100 SearchBackfill::Failed {
857- message: message.clone(),
858- retry_at_ms: *retry_at_ms,
1101+ message: failure.message.clone(),
1102+ retry_at_ms: failure.retry_at_ms,
8591103 }
8601104 } else if self.pages.get(room_id).copied().unwrap_or(0)
8611105 >= self.budget.pages_per_room
@@ -873,40 +1117,47 @@ impl Service {
8731117 }
8741118 })
8751119 .collect();
1120+ let in_memory = self.in_memory();
8761121 SearchCoverage {
8771122 rooms,
878- persistent: self.persistent(),
879- undecrypted: self.pending.len(),
1123+ persistent: in_memory.is_none(),
1124+ in_memory,
1125+ undecrypted,
8801126 }
8811127 }
8821128
1129+ fn spent(&self, room_id: &OwnedRoomId) -> bool {
1130+ self.pages.get(room_id).copied().unwrap_or(0) >= self.budget.pages_per_room
1131+ }
1132+
1133+ fn blocked(&self, room_id: &OwnedRoomId, at: u64) -> bool {
1134+ self.failures
1135+ .get(room_id)
1136+ .is_some_and(|failure| failure.retry_at_ms > at)
1137+ }
1138+
8831139 /// head passes first, so what arrived while we were away is found before anything older
8841140 fn schedule_backfill(&mut self) {
885- if self.backfilling.is_some()
886- || self.budget.pages_per_room == 0
887- || now() < self.next_backfill
1141+ let at = now();
1142+ if self.backfilling.is_some() || self.budget.pages_per_room == 0 || at < self.next_backfill
8881143 {
8891144 return;
8901145 }
891- let budget = self.budget.pages_per_room;
892- let spent = |pages: &HashMap<OwnedRoomId, u16>, room: &OwnedRoomId| {
893- pages.get(room).copied().unwrap_or(0) >= budget
894- };
895- let blocked = |failures: &HashMap<OwnedRoomId, Failure>, room: &OwnedRoomId| {
896- failures
897- .get(room)
898- .is_some_and(|failure| failure.retry_at_ms > now())
899- };
9001146 let mut chosen = None;
901- while let Some(room_id) = self.heads.pop_front() {
902- let started = self
903- .rooms
904- .get(room_id.as_str())
905- .is_some_and(|progress| progress.started);
906- if started && !spent(&self.pages, &room_id) && !blocked(&self.failures, &room_id) {
907- chosen = Some((room_id, true));
1147+ for _ in 0..self.heads.len() {
1148+ let Some(room_id) = self.heads.pop_front() else {
9081149 break;
1150+ };
1151+ if self.spent(&room_id) {
1152+ self.head_tokens.remove(&room_id);
1153+ continue;
9091154 }
1155+ if self.blocked(&room_id, at) {
1156+ self.heads.push_back(room_id);
1157+ continue;
1158+ }
1159+ chosen = Some((room_id, true));
1160+ break;
9101161 }
9111162 if chosen.is_none() {
9121163 let mut joined: Vec<_> = self
@@ -923,7 +1174,7 @@ impl Service {
9231174 .rooms
9241175 .get(room_id.as_str())
9251176 .is_some_and(|progress| progress.start_reached);
926- if !done && !spent(&self.pages, room_id) && !blocked(&self.failures, room_id) {
1177+ if !done && !self.spent(room_id) && !self.blocked(room_id, at) {
9271178 self.turn = (self.turn + offset + 1) % count;
9281179 chosen = Some((room_id.clone(), false));
9291180 break;
@@ -944,63 +1195,99 @@ impl Service {
9441195 .and_then(|progress| progress.tail.clone())
9451196 };
9461197 *self.pages.entry(room_id.clone()).or_default() += 1;
947- self.backfilling = Some(room_id.clone());
9481198 let mut options = MessagesOptions::backward();
949- options.from = from;
1199+ options.from = from.clone();
9501200 options.limit = UInt::from(self.budget.page_size);
1201+ let mut filter = RoomEventFilter::default();
1202+ filter.types = Some(
1203+ BACKFILL_TYPES
1204+ .iter()
1205+ .map(|kind| (*kind).to_owned())
1206+ .collect(),
1207+ );
1208+ options.filter = filter;
1209+ let (abort, registration) = AbortHandle::new_pair();
1210+ self.backfilling = Some((room_id.clone(), abort));
1211+ let page = Abortable::new(
1212+ async move {
1213+ room.messages(options)
1214+ .await
1215+ .map_err(|error| Error::from_sdk("backfilling search history failed", error))
1216+ },
1217+ registration,
1218+ );
9511219 self.work.push(Box::pin(async move {
952- let result = room
953- .messages(options)
954- .await
955- .map_err(|error| Error::from_sdk("backfilling search history failed", error));
956- Work::Backfill {
957- room: room_id,
958- head,
959- result,
1220+ match page.await {
1221+ Ok(result) => Work::Backfill {
1222+ room: room_id,
1223+ head,
1224+ from,
1225+ result,
1226+ },
1227+ Err(_) => Work::BackfillCancelled,
9601228 }
9611229 }));
9621230 }
9631231
1232+ fn fail_backfill(&mut self, room_id: OwnedRoomId, message: String, delay_ms: u64) {
1233+ self.failures.insert(
1234+ room_id,
1235+ Failure {
1236+ message,
1237+ retry_at_ms: now() + delay_ms,
1238+ delay_ms,
1239+ },
1240+ );
1241+ }
1242+
9641243 fn backfilled(
9651244 &mut self,
9661245 room_id: OwnedRoomId,
9671246 head: bool,
968- result: Result<matrix_sdk::room::Messages, Error>,
1247+ from: Option<String>,
1248+ result: Result<Messages, Error>,
9691249 ) {
9701250 self.backfilling = None;
1251+ self.next_backfill = now() + self.budget.interval_ms;
9711252 let messages = match result {
9721253 Ok(messages) => messages,
1254+ Err(error) if from.is_some() && token_rejected(&error) => {
1255+ if head {
1256+ self.head_tokens.remove(&room_id);
1257+ } else {
1258+ let progress = self.rooms.entry(room_id.to_string()).or_default();
1259+ progress.tail = None;
1260+ progress.started = false;
1261+ self.touch();
1262+ }
1263+ let message = format!("{}, starting again from the newest", error.message);
1264+ self.fail_backfill(room_id, message, self.budget.interval_ms);
1265+ return;
1266+ }
9731267 Err(error) => {
974- let retry_at_ms = now() + self.retry_delay;
975- self.next_backfill = retry_at_ms;
976- self.retry_delay = (self.retry_delay * 2).min(BACKFILL_RETRY_MAX_MS);
977- self.failures.insert(
978- room_id,
979- Failure {
980- message: error.message.clone(),
981- retry_at_ms,
982- },
983- );
1268+ let delay_ms = self
1269+ .failures
1270+ .get(&room_id)
1271+ .map_or(BACKFILL_RETRY_MS, |failure| {
1272+ (failure.delay_ms * 2).min(BACKFILL_RETRY_MAX_MS)
1273+ });
1274+ self.fail_backfill(room_id, error.message.clone(), delay_ms);
9841275 return;
9851276 }
9861277 };
987- self.retry_delay = BACKFILL_RETRY_MS;
9881278 self.failures.remove(&room_id);
989- self.next_backfill = now() + self.budget.interval_ms;
1279+ let baseline = self.baselines.get(room_id.as_str()).copied();
9901280 let mut overlapped = false;
9911281 for event in &messages.chunk {
9921282 let Some(value) = value_of(event) else {
9931283 continue;
9941284 };
995- if head
996- && let Some(key) = value.get("event_id").and_then(Value::as_str)
997- && self.index.get(key).is_some_and(|doc| !doc.provisional)
998- {
1285+ if head && head_overlap(self.index.as_ref(), baseline, &value) {
9991286 overlapped = true;
10001287 }
10011288 self.observe(&room_id, &value);
10021289 }
1003- let ended = messages.end.is_none() || messages.chunk.is_empty();
1290+ let ended = messages.end.is_none() || (messages.chunk.is_empty() && messages.end == from);
10041291 if head {
10051292 match messages.end {
10061293 Some(end) if !overlapped && !ended => {
@@ -1024,22 +1311,36 @@ impl Service {
10241311 }
10251312
10261313 fn retry_pending(&mut self, room_id: Option<&OwnedRoomId>, session_id: Option<&str>) {
1314+ let queued: HashSet<String> = self.decrypt_queue.iter().cloned().collect();
10271315 let due: Vec<_> = self
10281316 .pending
10291317 .iter()
1030- .filter(|(_, pending)| {
1031- room_id.is_none_or(|room_id| pending.room == room_id.as_str())
1318+ .filter(|(key, pending)| {
1319+ !queued.contains(*key)
1320+ && room_id.is_none_or(|room_id| pending.room == room_id.as_str())
10321321 && session_id.is_none_or(|session_id| pending.session_id == session_id)
10331322 })
1034- .map(|(key, pending)| (key.clone(), pending.clone()))
1323+ .map(|(key, _)| key.clone())
10351324 .collect();
1036- for (key, pending) in due {
1325+ self.decrypt_queue.extend(due);
1326+ self.pump_decrypts();
1327+ }
1328+
1329+ fn pump_decrypts(&mut self) {
1330+ while self.decrypting < DECRYPTS_AT_ONCE {
1331+ let Some(key) = self.decrypt_queue.pop_front() else {
1332+ return;
1333+ };
1334+ let Some(pending) = self.pending.get(&key).cloned() else {
1335+ continue;
1336+ };
10371337 let Ok(room_id) = OwnedRoomId::try_from(pending.room.as_str()) else {
10381338 continue;
10391339 };
10401340 let Some(room) = self.client.get_room(&room_id) else {
10411341 continue;
10421342 };
1343+ self.decrypting += 1;
10431344 self.work.push(Box::pin(async move {
10441345 let value = async {
10451346 let raw: Raw<OriginalSyncRoomEncryptedEvent> =
@@ -1059,17 +1360,26 @@ impl Service {
10591360
10601361 fn complete(&mut self, work: Work) {
10611362 match work {
1062- Work::Backfill { room, head, result } => self.backfilled(room, head, result),
1363+ Work::Backfill {
1364+ room,
1365+ head,
1366+ from,
1367+ result,
1368+ } => self.backfilled(room, head, from, result),
1369+ Work::BackfillCancelled => {
1370+ self.backfilling = None;
1371+ self.next_backfill = now() + self.budget.interval_ms;
1372+ }
10631373 Work::Decrypted { key, room, value } => {
1374+ self.decrypting -= 1;
10641375 if let Some(value) = value {
1065- self.pending.remove(&key);
1066- self.pending_dirty = true;
1376+ self.unpend(&key);
10671377 self.observe(&room, &value);
1068- self.touch();
10691378 }
1379+ self.pump_decrypts();
10701380 }
1071- Work::Refetched { room, value } => {
1072- if let Some(value) = value {
1381+ Work::Refetched { room, values } => {
1382+ for value in values {
10731383 self.observe(&room, &value);
10741384 }
10751385 }
@@ -1096,15 +1406,15 @@ impl Service {
10961406 }
10971407
10981408 pub(crate) async fn run(mut self) {
1099- self.load().await;
11001409 let client = self.client.clone();
11011410 let mut generic = client.event_cache().subscribe_to_room_generic_updates();
1411+ let mut room_info = client.room_info_notable_update_receiver();
1412+ self.load().await;
11021413 let mut keys = client
11031414 .encryption()
11041415 .room_keys_received_stream()
11051416 .await
11061417 .map(Box::pin);
1107- let mut room_info = client.room_info_notable_update_receiver();
11081418 self.retry_pending(None, None);
11091419 let mut tick = Box::pin(matrix_sdk::sleep::sleep(Duration::from_secs(1)));
11101420 loop {
@@ -1137,25 +1447,14 @@ impl Service {
11371447 None => return,
11381448 },
11391449 Some(work) = self.work.next(), if !self.work.is_empty() => self.complete(work),
1140- Some((room_id, events)) = self.updates.next(), if !self.updates.is_empty() => {
1141- if let Some((_, seen)) = self.subscriptions.get_mut(&room_id) {
1142- *seen = now();
1143- }
1144- match events {
1145- Some(events) => self.observe_all(&room_id, &events),
1146- None => {
1147- if let Some((abort, _)) = self.subscriptions.remove(&room_id) {
1148- abort.abort();
1149- }
1150- self.subscribe(room_id).await;
1151- }
1450+ Some((room_id, update)) = self.updates.next(), if !self.updates.is_empty() => {
1451+ self.room_update(room_id, update).await;
1452+ }
1453+ update = generic.recv() => {
1454+ if !self.generic(update.map(|update| update.room_id)).await {
1455+ return;
11521456 }
11531457 }
1154- update = generic.recv() => match update {
1155- Ok(update) => self.subscribe(update.room_id).await,
1156- Err(broadcast::error::RecvError::Lagged(_)) => {}
1157- Err(broadcast::error::RecvError::Closed) => return,
1158- },
11591458 Some(update) = async { keys.as_mut()?.next().await }, if keys.is_some() => match update {
11601459 Ok(infos) => {
11611460 for info in infos {
@@ -1167,8 +1466,8 @@ impl Service {
11671466 update = room_info.recv() => match update {
11681467 Ok(update) if update.reasons.contains(RoomInfoNotableUpdateReasons::MEMBERSHIP) => {
11691468 let left = self.client.get_room(&update.room_id).is_none_or(|room| room.state() != RoomState::Joined);
1170- if left && let Some((abort, _)) = self.subscriptions.remove(&update.room_id) {
1171- abort.abort();
1469+ if left {
1470+ self.left(&update.room_id);
11721471 }
11731472 }
11741473 Ok(_) | Err(broadcast::error::RecvError::Lagged(_)) => {}
@@ -1188,27 +1487,49 @@ impl Service {
11881487 }
11891488 }
11901489
1490+/// a head pass stops at what this engine found already indexed, and live events indexed
1491+/// since it started don't count, or the first page would always stop it
1492+fn head_overlap(index: &dyn MessageIndex, baseline: Option<u64>, value: &Value) -> bool {
1493+ let (Some(baseline), Some(key), Some(ts)) = (
1494+ baseline,
1495+ value.get("event_id").and_then(Value::as_str),
1496+ value.get("origin_server_ts").and_then(Value::as_u64),
1497+ ) else {
1498+ return false;
1499+ };
1500+ ts <= baseline && index.get(key).is_some_and(|doc| !doc.provisional)
1501+}
1502+
11911503 fn updates(
11921504 room_id: OwnedRoomId,
11931505 subscriber: RoomEventCacheSubscriber,
11941506 drop_handles: Arc<matrix_sdk::event_cache::EventCacheDropHandles>,
1195-) -> BoxStream<(OwnedRoomId, Option<Vec<SdkTimelineEvent>>)> {
1507+) -> BoxStream<(OwnedRoomId, RoomUpdate)> {
11961508 Box::pin(futures_util::stream::unfold(
1197- (subscriber, drop_handles),
1198- move |(mut subscriber, drop_handles)| {
1509+ Some((subscriber, drop_handles)),
1510+ move |state| {
11991511 let room_id = room_id.clone();
12001512 async move {
1513+ let (mut subscriber, drop_handles) = state?;
12011514 loop {
12021515 match subscriber.recv().await {
12031516 Ok(RoomEventCacheUpdate::UpdateTimelineEvents(update)) => {
12041517 let events = update.diffs.into_iter().flat_map(added).collect();
1205- return Some(((room_id, Some(events)), (subscriber, drop_handles)));
1518+ return Some((
1519+ (room_id, RoomUpdate::Events(events)),
1520+ Some((subscriber, drop_handles)),
1521+ ));
12061522 }
12071523 Ok(_) => {}
12081524 Err(broadcast::error::RecvError::Lagged(_)) => {
1209- return Some(((room_id, None), (subscriber, drop_handles)));
1525+ return Some((
1526+ (room_id, RoomUpdate::Lagged),
1527+ Some((subscriber, drop_handles)),
1528+ ));
1529+ }
1530+ Err(broadcast::error::RecvError::Closed) => {
1531+ return Some(((room_id, RoomUpdate::Closed), None));
12101532 }
1211- Err(broadcast::error::RecvError::Closed) => return None,
12121533 }
12131534 }
12141535 }
@@ -1232,3 +1553,7 @@ fn added(diff: VectorDiff<SdkTimelineEvent>) -> Vec<SdkTimelineEvent> {
12321553 | VectorDiff::Truncate { .. } => Vec::new(),
12331554 }
12341555 }
1556+
1557+#[cfg(all(test, feature = "sqlite"))]
1558+#[path = "service_tests.rs"]
1559+mod tests;
Added file: src/search/service_tests.rs+141 additions
@@ -0,0 +1,141 @@
1+use matrix_sdk::{
2+ deserialized_responses::TimelineEvent as SdkTimelineEvent,
3+ ruma::{events::AnySyncTimelineEvent, room_id},
4+ test_utils::mocks::MatrixMockServer,
5+};
6+use matrix_sdk_test::{JoinedRoomBuilder, event_factory::EventFactory};
7+use serde_json::json;
8+
9+use super::*;
10+
11+async fn joined(server: &MatrixMockServer, room_id: &ruma::RoomId) -> Client {
12+ let client = server.client_builder().build().await;
13+ client.event_cache().subscribe().unwrap();
14+ let own_user = client.user_id().unwrap().to_owned();
15+ let room_id = room_id.to_owned();
16+ server
17+ .mock_sync()
18+ .ok_and_run(&client, move |builder| {
19+ let factory = EventFactory::new().room(&room_id);
20+ builder.add_joined_room(
21+ JoinedRoomBuilder::new(&room_id).add_state_event(factory.member(&own_user)),
22+ );
23+ })
24+ .await;
25+ client
26+}
27+
28+fn service(client: &Client) -> Service {
29+ let media = Arc::new(MediaService::new(client.clone(), 1 << 20, 64));
30+ let (events, _) = EventSender::detached(16);
31+ Service::new(
32+ client.clone(),
33+ media,
34+ None,
35+ events,
36+ &EngineConfig::default(),
37+ )
38+ .1
39+}
40+
41+fn message(id: &str, ts: u64, body: &str) -> Value {
42+ json!({
43+ "type": "m.room.message",
44+ "event_id": id,
45+ "sender": "@a:x",
46+ "origin_server_ts": ts,
47+ "content": { "msgtype": "m.text", "body": body },
48+ })
49+}
50+
51+fn page(events: &[Value], end: Option<&str>) -> Result<Messages, Error> {
52+ Ok(Messages {
53+ start: "start".to_owned(),
54+ end: end.map(ToOwned::to_owned),
55+ chunk: events
56+ .iter()
57+ .map(|value| {
58+ SdkTimelineEvent::from_plaintext(
59+ Raw::<AnySyncTimelineEvent>::from_json_string(value.to_string()).unwrap(),
60+ )
61+ })
62+ .collect(),
63+ state: Vec::new(),
64+ })
65+}
66+
67+/// live events indexed this run used to count as overlap, so the first head page always
68+/// ended the pass and an offline gap past one page was never filled
69+#[tokio::test]
70+async fn a_head_pass_walks_past_what_this_run_indexed_live() {
71+ let server = MatrixMockServer::new().await;
72+ let room_id = room_id!("!head:x");
73+ let client = joined(&server, room_id).await;
74+ let mut service = service(&client);
75+ let room = room_id.to_owned();
76+ service.rooms.insert(
77+ room.to_string(),
78+ RoomProgress {
79+ newest: Some(100),
80+ started: true,
81+ ..RoomProgress::default()
82+ },
83+ );
84+ service.baselines.insert(room.to_string(), 100);
85+ service.observe(&room, &message("$old", 100, "old"));
86+ service.observe(&room, &message("$live", 1_000, "live"));
87+ service.backfilled(
88+ room.clone(),
89+ true,
90+ None,
91+ page(
92+ &[message("$live", 1_000, "live"), message("$gap", 500, "gap")],
93+ Some("t1"),
94+ ),
95+ );
96+ assert_eq!(
97+ service.head_tokens.get(&room).map(String::as_str),
98+ Some("t1")
99+ );
100+ assert_eq!(service.heads.front(), Some(&room));
101+ service.backfilled(
102+ room.clone(),
103+ true,
104+ Some("t1".to_owned()),
105+ page(&[message("$old", 100, "old")], Some("t2")),
106+ );
107+ assert!(!service.head_tokens.contains_key(&room));
108+ assert!(service.index.get("$gap").is_some());
109+}
110+
111+/// the generic channel is bounded, and a room whose only update was lost was never followed
112+#[tokio::test]
113+async fn a_lagged_generic_channel_follows_every_joined_room() {
114+ let server = MatrixMockServer::new().await;
115+ let room_id = room_id!("!lag:x");
116+ let client = joined(&server, room_id).await;
117+ let mut service = service(&client);
118+ assert!(
119+ service
120+ .generic(Err(broadcast::error::RecvError::Lagged(3)))
121+ .await
122+ );
123+ assert!(service.subscriptions.contains_key(room_id));
124+}
125+
126+/// a closed stream left its entry behind, and the room was never followed again
127+#[tokio::test]
128+async fn a_closed_room_stream_can_be_followed_again() {
129+ let server = MatrixMockServer::new().await;
130+ let room_id = room_id!("!closed:x");
131+ let client = joined(&server, room_id).await;
132+ let mut service = service(&client);
133+ service.subscribe(room_id.to_owned()).await;
134+ assert!(service.subscriptions.contains_key(room_id));
135+ service
136+ .room_update(room_id.to_owned(), RoomUpdate::Closed)
137+ .await;
138+ assert!(!service.subscriptions.contains_key(room_id));
139+ assert!(service.generic(Ok(room_id.to_owned())).await);
140+ assert!(service.subscriptions.contains_key(room_id));
141+}
Modified file: src/search/store/indexeddb.rs+19 additions−4 deletions
@@ -136,19 +136,34 @@ impl Backend {
136136 .map_err(|error| storage_error("writing the search database", error))
137137 }
138138
139- pub(super) async fn clear(&self) -> Result<(), Error> {
139+ /// everything goes and `puts` arrive in the same transaction
140+ pub(super) async fn replace(&self, puts: Vec<(String, Vec<u8>)>) -> Result<(), Error> {
140141 let database = self.database().await?;
141- let clear = async {
142+ let replace = async {
142143 let transaction = database
143144 .transaction(BLOBS)
144145 .with_mode(TransactionMode::Readwrite)
145146 .build()?;
146147 transaction.object_store(BLOBS)?.clear()?.await?;
148+ let requests = (|| -> Result<(), IndexedDbError> {
149+ let store = transaction.object_store(BLOBS)?;
150+ for (key, value) in &puts {
151+ store
152+ .put(Uint8ArraySlice::new(value))
153+ .with_key(key.as_str())
154+ .build()?;
155+ }
156+ Ok(())
157+ })();
158+ if let Err(error) = requests {
159+ let _ = transaction.abort().await;
160+ return Err(error);
161+ }
147162 transaction.commit().await
148163 };
149- clear
164+ replace
150165 .await
151- .map_err(|error| storage_error("clearing the search database", error))
166+ .map_err(|error| storage_error("replacing the search database", error))
152167 }
153168 }
154169
Modified file: src/search/store/mod.rs+71 additions−48 deletions
@@ -24,13 +24,30 @@ struct Owner {
2424 user_id: String,
2525 }
2626
27-/// blobs sealed with a `StoreCipher` under the store's passphrase. every blob carries its
28-/// own key inside the seal, so one can't be swapped for another
27+/// blobs sealed with a `StoreCipher` under the store's passphrase, stored under a keyed hash
28+/// of their name like the mentions store's rows. every blob carries its own name inside the
29+/// seal, so one can't be swapped for another
2930 pub(crate) struct SearchStore {
3031 backend: Backend,
3132 cipher: StoreCipher,
3233 }
3334
35+/// what a load found, still sealed. `take` hands each over once, and what's never taken
36+/// is nothing the index points at
37+pub(crate) struct Blobs {
38+ sealed: BTreeMap<String, Vec<u8>>,
39+}
40+
41+impl Blobs {
42+ pub(crate) fn is_empty(&self) -> bool {
43+ self.sealed.is_empty()
44+ }
45+
46+ pub(crate) fn leftover(self) -> Vec<String> {
47+ self.sealed.into_keys().collect()
48+ }
49+}
50+
3451 impl std::fmt::Debug for SearchStore {
3552 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3653 formatter
@@ -51,7 +68,7 @@ impl SearchStore {
5168 let opened = blobs.remove(CIPHER).and_then(|exported| {
5269 let cipher = StoreCipher::import(config.passphrase(), &exported).ok()?;
5370 let stored: Owner =
54- serde_json::from_slice(&unseal(&cipher, ACCOUNT, &blobs.remove(ACCOUNT)?)?).ok()?;
71+ serde_json::from_slice(&unseal(&cipher, ACCOUNT, blobs.remove(ACCOUNT)?)?).ok()?;
5572 (stored.homeserver == owner.homeserver && stored.user_id == owner.user_id)
5673 .then_some(cipher)
5774 });
@@ -63,78 +80,84 @@ impl SearchStore {
6380 let exported = cipher
6481 .export(config.passphrase())
6582 .map_err(|error| storage_error("sealing the search cipher", error))?;
66- let account = serde_json::to_vec(&owner)
83+ let owner = serde_json::to_vec(&owner)
6784 .map_err(|error| storage_error("encoding the search owner", error))?;
68- let store = Self { backend, cipher };
69- store.backend.clear().await?;
70- let sealed = store.seal(ACCOUNT, account)?;
71- store
72- .backend
73- .commit(
74- vec![(CIPHER.to_owned(), exported), (ACCOUNT.to_owned(), sealed)],
75- Vec::new(),
76- )
85+ let sealed = seal(&cipher, ACCOUNT, owner)?;
86+ backend
87+ .replace(vec![
88+ (CIPHER.to_owned(), exported),
89+ (ACCOUNT.to_owned(), sealed),
90+ ])
7791 .await?;
78- Ok(store)
92+ Ok(Self { backend, cipher })
7993 }
8094
81- /// every blob but the store's own, still sealed
82- pub(crate) async fn load(&self) -> Result<BTreeMap<String, Vec<u8>>, Error> {
83- let mut blobs = self.backend.load_all().await?;
84- blobs.remove(CIPHER);
85- blobs.remove(ACCOUNT);
86- Ok(blobs)
95+ pub(crate) async fn load(&self) -> Result<Blobs, Error> {
96+ let mut sealed = self.backend.load_all().await?;
97+ sealed.remove(CIPHER);
98+ sealed.remove(ACCOUNT);
99+ Ok(Blobs { sealed })
87100 }
88101
89- pub(crate) fn open_blob(&self, key: &str, sealed: &[u8]) -> Option<Vec<u8>> {
90- unseal(&self.cipher, key, sealed)
102+ pub(crate) fn take(&self, blobs: &mut Blobs, name: &str) -> Option<Vec<u8>> {
103+ let sealed = blobs.sealed.remove(&self.storage_key(name))?;
104+ unseal(&self.cipher, name, sealed)
91105 }
92106
107+ /// `purge` are storage keys straight from `Blobs::leftover`
93108 pub(crate) async fn commit(
94109 &self,
95110 puts: Vec<(String, Vec<u8>)>,
96111 deletes: Vec<String>,
112+ purge: Vec<String>,
97113 ) -> Result<(), Error> {
98114 let mut sealed = Vec::with_capacity(puts.len());
99- for (key, value) in puts {
100- let value = self.seal(&key, value)?;
101- sealed.push((key, value));
115+ for (name, value) in puts {
116+ let value = seal(&self.cipher, &name, value)?;
117+ sealed.push((self.storage_key(&name), value));
102118 }
103- self.backend.commit(sealed, deletes).await
119+ let mut removed: Vec<String> = deletes.iter().map(|name| self.storage_key(name)).collect();
120+ removed.extend(purge);
121+ self.backend.commit(sealed, removed).await
104122 }
105123
106- /// keeps the cipher and owner, drops everything else
124+ /// keeps the cipher and owner and drops everything else, in one transaction
107125 pub(crate) async fn reset(&self) -> Result<(), Error> {
108126 let keep = self.backend.load(&[CIPHER, ACCOUNT]).await?;
109- self.backend.clear().await?;
110- self.backend
111- .commit(keep.into_iter().collect(), Vec::new())
112- .await
127+ self.backend.replace(keep.into_iter().collect()).await
113128 }
114129
115- fn seal(&self, key: &str, value: Vec<u8>) -> Result<Vec<u8>, Error> {
116- let mut plain = Vec::with_capacity(key.len() + 1 + value.len());
117- plain.extend_from_slice(key.as_bytes());
118- plain.push(0);
119- plain.extend_from_slice(&value);
120- drop(value);
121- let encrypted = self
122- .cipher
123- .encrypt_value_base64_data(plain)
124- .map_err(|error| storage_error("sealing a search blob", error))?;
125- serde_json::to_vec(&encrypted)
126- .map_err(|error| storage_error("encoding a search blob", error))
130+ fn storage_key(&self, name: &str) -> String {
131+ self.cipher
132+ .hash_key("macltok-search", name.as_bytes())
133+ .iter()
134+ .map(|byte| format!("{byte:02x}"))
135+ .collect()
127136 }
128137 }
129138
130-fn unseal(cipher: &StoreCipher, key: &str, sealed: &[u8]) -> Option<Vec<u8>> {
131- let encrypted: EncryptedValueBase64 = serde_json::from_slice(sealed).ok()?;
139+/// the name goes after the value, so a value packed with a little spare capacity seals
140+/// without being copied first
141+fn seal(cipher: &StoreCipher, name: &str, mut value: Vec<u8>) -> Result<Vec<u8>, Error> {
142+ value.push(0);
143+ value.extend_from_slice(name.as_bytes());
144+ let encrypted = cipher
145+ .encrypt_value_base64_data(value)
146+ .map_err(|error| storage_error("sealing a search blob", error))?;
147+ serde_json::to_vec(&encrypted).map_err(|error| storage_error("encoding a search blob", error))
148+}
149+
150+fn unseal(cipher: &StoreCipher, name: &str, sealed: Vec<u8>) -> Option<Vec<u8>> {
151+ let encrypted: EncryptedValueBase64 = serde_json::from_slice(&sealed).ok()?;
152+ drop(sealed);
132153 let mut plain = cipher.decrypt_value_base64_data(encrypted).ok()?;
133- let prefix = key.len() + 1;
134- if plain.len() < prefix || &plain[..key.len()] != key.as_bytes() || plain[key.len()] != 0 {
154+ let suffix = name.len() + 1;
155+ let split = plain.len().checked_sub(suffix)?;
156+ if plain[split] != 0 || &plain[split + 1..] != name.as_bytes() {
135157 return None;
136158 }
137- Some(plain.split_off(prefix))
159+ plain.truncate(split);
160+ Some(plain)
138161 }
139162
140163 pub(super) fn storage_error(
Modified file: src/search/store/sqlite.rs+70 additions−27 deletions
@@ -1,6 +1,7 @@
11 use std::{
22 collections::BTreeMap,
3- fs,
3+ fs::{self, OpenOptions},
4+ io,
45 path::{Path, PathBuf},
56 time::Duration,
67 };
@@ -74,42 +75,54 @@ impl Backend {
7475 ) -> Result<(), Error> {
7576 let path = self.path.clone();
7677 blocking("writing the search store", move || {
77- let mut connection = connect(&path)?;
78- let transaction = connection
79- .transaction_with_behavior(TransactionBehavior::Immediate)
80- .map_err(|error| storage_error("writing the search store", error))?;
81- for key in deletes {
82- transaction
83- .execute("DELETE FROM blobs WHERE key = ?1", [key])
84- .map_err(|error| storage_error("writing the search store", error))?;
85- }
86- for (key, value) in puts {
87- transaction
88- .execute(
89- "INSERT INTO blobs (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
90- params![key, value],
91- )
92- .map_err(|error| storage_error("writing the search store", error))?;
93- }
94- transaction
95- .commit()
96- .map_err(|error| storage_error("writing the search store", error))
78+ write(&path, false, puts, deletes)
9779 })
9880 .await
9981 }
10082
101- pub(super) async fn clear(&self) -> Result<(), Error> {
83+ /// everything goes and `puts` arrive in the same transaction
84+ pub(super) async fn replace(&self, puts: Vec<(String, Vec<u8>)>) -> Result<(), Error> {
10285 let path = self.path.clone();
103- blocking("clearing the search store", move || {
104- connect(&path)?
105- .execute("DELETE FROM blobs", [])
106- .map(drop)
107- .map_err(|error| storage_error("clearing the search store", error))
86+ blocking("replacing the search store", move || {
87+ write(&path, true, puts, Vec::new())
10888 })
10989 .await
11090 }
11191 }
11292
93+fn write(
94+ path: &Path,
95+ clear: bool,
96+ puts: Vec<(String, Vec<u8>)>,
97+ deletes: Vec<String>,
98+) -> Result<(), Error> {
99+ let mut connection = connect(path)?;
100+ let transaction = connection
101+ .transaction_with_behavior(TransactionBehavior::Immediate)
102+ .map_err(|error| storage_error("writing the search store", error))?;
103+ if clear {
104+ transaction
105+ .execute("DELETE FROM blobs", [])
106+ .map_err(|error| storage_error("clearing the search store", error))?;
107+ }
108+ for key in deletes {
109+ transaction
110+ .execute("DELETE FROM blobs WHERE key = ?1", [key])
111+ .map_err(|error| storage_error("writing the search store", error))?;
112+ }
113+ for (key, value) in puts {
114+ transaction
115+ .execute(
116+ "INSERT INTO blobs (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
117+ params![key, value],
118+ )
119+ .map_err(|error| storage_error("writing the search store", error))?;
120+ }
121+ transaction
122+ .commit()
123+ .map_err(|error| storage_error("writing the search store", error))
124+}
125+
113126 async fn blocking<T: Send + 'static>(
114127 context: &'static str,
115128 work: impl FnOnce() -> Result<T, Error> + Send + 'static,
@@ -127,6 +140,8 @@ fn initialise(path: &Path) -> Result<(), Error> {
127140 fs::create_dir_all(parent)
128141 .map_err(|error| storage_error("creating the search store directory", error))?;
129142 }
143+ create_private_file(path)
144+ .map_err(|error| storage_error("creating the search store file", error))?;
130145 connect(path)?
131146 .execute(
132147 "CREATE TABLE IF NOT EXISTS blobs (key TEXT PRIMARY KEY, value BLOB NOT NULL)",
@@ -136,6 +151,34 @@ fn initialise(path: &Path) -> Result<(), Error> {
136151 .map_err(|error| storage_error("initialising the search store", error))
137152 }
138153
154+/// sqlite gives its wal and shm files the database's own mode, so this covers all three
155+#[cfg(unix)]
156+fn create_private_file(path: &Path) -> io::Result<()> {
157+ use std::os::unix::fs::OpenOptionsExt;
158+
159+ match OpenOptions::new()
160+ .write(true)
161+ .create_new(true)
162+ .mode(0o600)
163+ .open(path)
164+ {
165+ Ok(file) => drop(file),
166+ Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
167+ Err(error) => return Err(error),
168+ }
169+ Ok(())
170+}
171+
172+#[cfg(not(unix))]
173+fn create_private_file(path: &Path) -> io::Result<()> {
174+ match OpenOptions::new().write(true).create_new(true).open(path) {
175+ Ok(file) => drop(file),
176+ Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
177+ Err(error) => return Err(error),
178+ }
179+ Ok(())
180+}
181+
139182 fn connect(path: &Path) -> Result<Connection, Error> {
140183 let connection =
141184 Connection::open(path).map_err(|error| storage_error("opening the search store", error))?;
Added file: src/search/text.rs+328 additions
@@ -0,0 +1,328 @@
1+use tantivy::{
2+ Term,
3+ query::{BooleanQuery, Occur, PhraseQuery, Query, TermQuery},
4+ schema::{Field, IndexRecordOption},
5+ tokenizer::{
6+ AsciiFoldingFilter, LowerCaser, RawTokenizer, TextAnalyzer, Token, TokenStream, Tokenizer,
7+ },
8+};
9+
10+use crate::{Error, ErrorKind};
11+
12+pub(crate) const TOKENIZER: &str = "macltok";
13+/// longer runs are hashes, keys or noise, and in chars so cyrillic and greek words fit
14+const MAX_WORD_CHARS: usize = 64;
15+
16+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17+enum Kind {
18+ Word,
19+ /// one char of a han, kana, hangul or thai run, or an emoji
20+ Single,
21+ /// two neighbouring chars of such a run, sharing the first one's position
22+ Pair,
23+}
24+
25+#[derive(Clone, Debug, Eq, PartialEq)]
26+struct Piece {
27+ from: usize,
28+ to: usize,
29+ position: usize,
30+ kind: Kind,
31+ /// a single inside a run of two or more, which its pairs already cover in a query
32+ paired: bool,
33+}
34+
35+/// scripts written without spaces, where a char is the only boundary there is
36+fn unspaced(c: char) -> bool {
37+ matches!(c,
38+ '\u{3040}'..='\u{30FF}' | '\u{31F0}'..='\u{31FF}' | '\u{3400}'..='\u{4DBF}'
39+ | '\u{4E00}'..='\u{9FFF}' | '\u{F900}'..='\u{FAFF}' | '\u{FF66}'..='\u{FF9F}'
40+ | '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{AC00}'..='\u{D7AF}'
41+ | '\u{0E00}'..='\u{0E7F}' | '\u{20000}'..='\u{2FA1F}')
42+}
43+
44+fn emoji(c: char) -> bool {
45+ matches!(c, '\u{1F300}'..='\u{1FAFF}' | '\u{2600}'..='\u{27BF}')
46+}
47+
48+fn pieces(text: &str) -> Vec<Piece> {
49+ let mut pieces = Vec::new();
50+ let mut position = 0;
51+ let mut chars = text.char_indices().peekable();
52+ while let Some((from, c)) = chars.next() {
53+ if unspaced(c) {
54+ let mut run = vec![(from, from + c.len_utf8())];
55+ while let Some(&(at, next)) = chars.peek() {
56+ if !unspaced(next) {
57+ break;
58+ }
59+ run.push((at, at + next.len_utf8()));
60+ chars.next();
61+ }
62+ let paired = run.len() > 1;
63+ for (index, &(from, to)) in run.iter().enumerate() {
64+ pieces.push(Piece {
65+ from,
66+ to,
67+ position,
68+ kind: Kind::Single,
69+ paired,
70+ });
71+ if let Some(&(_, next_to)) = run.get(index + 1) {
72+ pieces.push(Piece {
73+ from,
74+ to: next_to,
75+ position,
76+ kind: Kind::Pair,
77+ paired: false,
78+ });
79+ }
80+ position += 1;
81+ }
82+ } else if emoji(c) {
83+ pieces.push(Piece {
84+ from,
85+ to: from + c.len_utf8(),
86+ position,
87+ kind: Kind::Single,
88+ paired: false,
89+ });
90+ position += 1;
91+ } else if c.is_alphanumeric() {
92+ let mut to = from + c.len_utf8();
93+ let mut count = 1;
94+ while let Some(&(at, next)) = chars.peek() {
95+ if !next.is_alphanumeric() || unspaced(next) {
96+ break;
97+ }
98+ to = at + next.len_utf8();
99+ count += 1;
100+ chars.next();
101+ }
102+ if count <= MAX_WORD_CHARS {
103+ pieces.push(Piece {
104+ from,
105+ to,
106+ position,
107+ kind: Kind::Word,
108+ paired: false,
109+ });
110+ position += 1;
111+ }
112+ }
113+ }
114+ pieces
115+}
116+
117+#[derive(Clone)]
118+pub(crate) struct MacltokTokenizer;
119+
120+pub(crate) struct PieceStream {
121+ tokens: std::vec::IntoIter<Token>,
122+ token: Token,
123+}
124+
125+impl TokenStream for PieceStream {
126+ fn advance(&mut self) -> bool {
127+ match self.tokens.next() {
128+ Some(token) => {
129+ self.token = token;
130+ true
131+ }
132+ None => false,
133+ }
134+ }
135+
136+ fn token(&self) -> &Token {
137+ &self.token
138+ }
139+
140+ fn token_mut(&mut self) -> &mut Token {
141+ &mut self.token
142+ }
143+}
144+
145+impl Tokenizer for MacltokTokenizer {
146+ type TokenStream<'a> = PieceStream;
147+
148+ fn token_stream<'a>(&'a mut self, text: &'a str) -> PieceStream {
149+ let tokens: Vec<_> = pieces(text)
150+ .into_iter()
151+ .map(|piece| Token {
152+ offset_from: piece.from,
153+ offset_to: piece.to,
154+ position: piece.position,
155+ text: text[piece.from..piece.to].to_owned(),
156+ position_length: 1,
157+ })
158+ .collect();
159+ PieceStream {
160+ tokens: tokens.into_iter(),
161+ token: Token::default(),
162+ }
163+ }
164+}
165+
166+pub(crate) fn analyzer() -> TextAnalyzer {
167+ TextAnalyzer::builder(MacltokTokenizer)
168+ .filter(LowerCaser)
169+ .filter(AsciiFoldingFilter)
170+ .build()
171+}
172+
173+fn fold(text: &str) -> String {
174+ let mut folder = TextAnalyzer::builder(RawTokenizer::default())
175+ .filter(LowerCaser)
176+ .filter(AsciiFoldingFilter)
177+ .build();
178+ let mut stream = folder.token_stream(text);
179+ if stream.advance() {
180+ stream.token().text.clone()
181+ } else {
182+ String::new()
183+ }
184+}
185+
186+/// the byte ranges in `text` whose analysed token is one of `terms`, overlaps joined
187+pub(crate) fn match_ranges(text: &str, terms: &[String]) -> Vec<(usize, usize)> {
188+ if terms.is_empty() {
189+ return Vec::new();
190+ }
191+ let mut analyzer = analyzer();
192+ let mut stream = analyzer.token_stream(text);
193+ let mut ranges: Vec<(usize, usize)> = Vec::new();
194+ while stream.advance() {
195+ let token = stream.token();
196+ if !terms.iter().any(|term| *term == token.text) {
197+ continue;
198+ }
199+ match ranges.last_mut() {
200+ Some(last) if token.offset_from < last.1 => last.1 = last.1.max(token.offset_to),
201+ _ => ranges.push((token.offset_from, token.offset_to)),
202+ }
203+ }
204+ ranges
205+}
206+
207+enum Clause {
208+ Words(String),
209+ Phrase(String),
210+}
211+
212+/// quotes make a phrase, a leading `-` excludes, anything else is a word that must match.
213+/// no field syntax, so `re: deploy` or a url is just its words
214+fn clauses(query: &str) -> Vec<(bool, Clause)> {
215+ let mut clauses = Vec::new();
216+ let mut rest = query;
217+ loop {
218+ rest = rest.trim_start();
219+ if rest.is_empty() {
220+ return clauses;
221+ }
222+ let (excluded, body) = match rest.strip_prefix('-') {
223+ Some(body) if body.starts_with(|c: char| !c.is_whitespace()) => (true, body),
224+ _ => (false, rest),
225+ };
226+ if let Some(quoted) = body.strip_prefix('"') {
227+ let end = quoted.find('"').unwrap_or(quoted.len());
228+ clauses.push((excluded, Clause::Phrase(quoted[..end].to_owned())));
229+ rest = quoted.get(end + 1..).unwrap_or("");
230+ } else {
231+ let end = body.find(char::is_whitespace).unwrap_or(body.len());
232+ clauses.push((excluded, Clause::Words(body[..end].to_owned())));
233+ rest = &body[end..];
234+ }
235+ }
236+}
237+
238+fn term(field: Field, text: &str) -> Box<dyn Query> {
239+ Box::new(TermQuery::new(
240+ Term::from_field_text(field, text),
241+ IndexRecordOption::WithFreqs,
242+ ))
243+}
244+
245+/// every term the clause needs, and its query, `None` when nothing in it is searchable
246+fn clause_query(field: Field, clause: &Clause) -> Option<(Box<dyn Query>, Vec<String>)> {
247+ let text = match clause {
248+ Clause::Words(text) | Clause::Phrase(text) => text,
249+ };
250+ let found = pieces(text);
251+ if let Clause::Phrase(_) = clause {
252+ let ordered: Vec<_> = found
253+ .iter()
254+ .filter(|piece| piece.kind != Kind::Pair)
255+ .map(|piece| (piece.position, fold(&text[piece.from..piece.to])))
256+ .filter(|(_, folded)| !folded.is_empty())
257+ .collect();
258+ let terms: Vec<String> = ordered.iter().map(|(_, folded)| folded.clone()).collect();
259+ return match ordered.len() {
260+ 0 => None,
261+ 1 => Some((term(field, &ordered[0].1), terms)),
262+ _ => {
263+ let first = ordered[0].0;
264+ let offsets = ordered
265+ .iter()
266+ .map(|(position, folded)| {
267+ (position - first, Term::from_field_text(field, folded))
268+ })
269+ .collect();
270+ Some((Box::new(PhraseQuery::new_with_offset(offsets)), terms))
271+ }
272+ };
273+ }
274+ let mut terms: Vec<String> = found
275+ .iter()
276+ .filter(|piece| !piece.paired)
277+ .map(|piece| fold(&text[piece.from..piece.to]))
278+ .filter(|folded| !folded.is_empty())
279+ .collect();
280+ terms.dedup();
281+ match terms.len() {
282+ 0 => None,
283+ 1 => Some((term(field, &terms[0]), terms)),
284+ _ => {
285+ let musts = terms
286+ .iter()
287+ .map(|text| (Occur::Must, term(field, text)))
288+ .collect();
289+ Some((Box::new(BooleanQuery::new(musts)), terms))
290+ }
291+ }
292+}
293+
294+/// the query and the terms to highlight. one with nothing left to find is `Invalid`,
295+/// rather than everything in scope
296+pub(crate) fn build_query(
297+ field: Field,
298+ query: &str,
299+) -> Result<(Box<dyn Query>, Vec<String>), Error> {
300+ let mut subqueries = Vec::new();
301+ let mut highlights = Vec::new();
302+ let mut positive = false;
303+ for (excluded, clause) in clauses(query) {
304+ let Some((subquery, terms)) = clause_query(field, &clause) else {
305+ continue;
306+ };
307+ if excluded {
308+ subqueries.push((Occur::MustNot, subquery));
309+ } else {
310+ positive = true;
311+ highlights.extend(terms);
312+ subqueries.push((Occur::Must, subquery));
313+ }
314+ }
315+ if !positive {
316+ return Err(Error::new(
317+ ErrorKind::Invalid,
318+ "search needs at least one word that isn't excluded",
319+ ));
320+ }
321+ highlights.sort();
322+ highlights.dedup();
323+ Ok((Box::new(BooleanQuery::new(subqueries)), highlights))
324+}
325+
326+#[cfg(test)]
327+#[path = "text_tests.rs"]
328+mod tests;
Added file: src/search/text_tests.rs+68 additions
@@ -0,0 +1,68 @@
1+use super::*;
2+
3+fn tokens(text: &str) -> Vec<String> {
4+ let mut analyzer = analyzer();
5+ let mut stream = analyzer.token_stream(text);
6+ let mut found = Vec::new();
7+ while stream.advance() {
8+ found.push(stream.token().text.clone());
9+ }
10+ found
11+}
12+
13+fn highlights(query: &str) -> Result<Vec<String>, ErrorKind> {
14+ build_query(Field::from_field_id(0), query)
15+ .map(|(_, terms)| terms)
16+ .map_err(|error| error.kind)
17+}
18+
19+#[test]
20+fn unspaced_scripts_become_singles_and_pairs() {
21+ assert_eq!(tokens("東京都"), vec!["東", "東京", "京", "京都", "都"]);
22+ assert_eq!(tokens("ok 日本"), vec!["ok", "日", "日本", "本"]);
23+}
24+
25+#[test]
26+fn the_word_limit_counts_chars_not_bytes() {
27+ let sha = "a".repeat(40);
28+ let cyrillic = "щ".repeat(40);
29+ assert_eq!(tokens(&format!("{sha} {cyrillic}")).len(), 2);
30+ assert!(tokens(&"x".repeat(65)).is_empty());
31+}
32+
33+/// a colon used to mean a field to the query parser, so "re: deploy" lost both words
34+#[test]
35+fn queries_have_no_field_syntax() {
36+ assert_eq!(highlights("re: deploy").unwrap(), vec!["deploy", "re"]);
37+ assert_eq!(
38+ highlights("https://example.org/x").unwrap(),
39+ vec!["example", "https", "org", "x"]
40+ );
41+ assert_eq!(highlights("sender:@a").unwrap(), vec!["a", "sender"]);
42+}
43+
44+#[test]
45+fn nothing_but_exclusions_or_wildcards_is_invalid() {
46+ assert_eq!(highlights("-spam"), Err(ErrorKind::Invalid));
47+ assert_eq!(highlights("*"), Err(ErrorKind::Invalid));
48+ assert_eq!(highlights(" "), Err(ErrorKind::Invalid));
49+ assert_eq!(highlights("ham -spam").unwrap(), vec!["ham"]);
50+}
51+
52+#[test]
53+fn a_cjk_word_query_needs_its_pairs() {
54+ assert_eq!(highlights("東京").unwrap(), vec!["東京"]);
55+ assert_eq!(highlights("東").unwrap(), vec!["東"]);
56+}
57+
58+#[test]
59+fn match_ranges_join_overlapping_pairs() {
60+ assert_eq!(
61+ match_ranges("Café, CAFE and cafés", &["cafe".to_owned()]),
62+ vec![(0, 5), (7, 11)]
63+ );
64+ assert_eq!(
65+ match_ranges("東京都", &["東京".to_owned(), "京都".to_owned()]),
66+ vec![(0, 9)]
67+ );
68+}
Modified file: src/search_model.rs+24 additions−2 deletions
@@ -34,7 +34,9 @@ pub enum SearchCommand {
3434 limit: u16,
3535 },
3636 /// frees the search's media. its hits' media ids stop resolving
37- Close { search_id: String },
37+ Close {
38+ search_id: String,
39+ },
3840 Coverage(SearchScope),
3941 }
4042
@@ -47,6 +49,13 @@ pub struct SearchSession {
4749 pub page: SearchPage,
4850 }
4951
52+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
53+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
54+pub enum SearchCloseReason {
55+ /// opening a ninth search closed the oldest
56+ Evicted,
57+}
58+
5059 #[derive(Clone, Debug, Eq, PartialEq)]
5160 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
5261 pub struct SearchPage {
@@ -82,10 +91,23 @@ pub struct SearchCoverage {
8291 pub rooms: Vec<RoomSearchCoverage>,
8392 /// false when the index lives in memory and starts over with the next engine
8493 pub persistent: bool,
85- /// messages that couldn't be decrypted yet, so can't be found yet
94+ /// why it lives in memory, `None` exactly when `persistent`
95+ pub in_memory: Option<SearchInMemory>,
96+ /// messages in scope that couldn't be decrypted yet, so can't be found yet
8697 pub undecrypted: usize,
8798 }
8899
100+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
101+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
102+pub enum SearchInMemory {
103+ /// the engine was built without a store, `Engine::from_client` or no store feature
104+ NoStore,
105+ OpenFailed,
106+ ReadFailed,
107+ /// five commits in a row failed, so this engine stopped trying
108+ CommitFailed,
109+}
110+
89111 #[derive(Clone, Debug, Eq, PartialEq)]
90112 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
91113 pub struct RoomSearchCoverage {
Modified file: src/transport.rs+12 additions−2 deletions
@@ -37,8 +37,8 @@ use crate::{
3737 PowerLevelChanges, Profile, PushNotification, PushNotificationEvent, PushNotificationStatus,
3838 RoomCommand, RoomListFilter, RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels,
3939 RoomSummary, SavedMentionContext, SearchCommand, SearchCoverage, SearchPage, SearchScope,
40- SearchSession, SpaceChild, ThreadListCommand, TimelineCommand, TimelineId,
41- VerificationAction, VerificationTarget,
40+ SearchSession, SpaceChild, ThreadListCommand, TimelineCommand, TimelineId, VerificationAction,
41+ VerificationTarget,
4242 encryption::encryption_state,
4343 engine::{MediaService, StoreLock},
4444 model::{PushNotificationRoom, PushNotificationSender},
@@ -1660,6 +1660,16 @@ pub(crate) struct EventSender {
16601660 }
16611661
16621662 impl EventSender {
1663+ #[cfg(all(test, feature = "sqlite"))]
1664+ pub(crate) fn detached(capacity: usize) -> (Self, mpsc::Receiver<Event>) {
1665+ let (events, receiver) = mpsc::channel(capacity);
1666+ let sender = Self {
1667+ events,
1668+ publication: Arc::new(PublicationRegistry::default()),
1669+ };
1670+ (sender, receiver)
1671+ }
1672+
16631673 pub(crate) async fn send(&self, event: Event) -> Result<(), SendEventError> {
16641674 self.events.send(event).await.map_err(|_| SendEventError)
16651675 }
Modified file: tests/history_repair.rs+6 additions−2 deletions
@@ -40,7 +40,9 @@ async fn start_room(room_id: &RoomId) -> TestRoom {
4040 server.mock_room_state_encryption().plain().mount().await;
4141 server.sync_joined_room(&client, room_id).await;
4242 let sync_client = client.clone();
43- let config = EngineConfig::default().with_initial_history_limit(0);
43+ let config = EngineConfig::default()
44+ .with_initial_history_limit(0)
45+ .with_search_backfill_pages_per_room(0);
4446 let (engine, handle, events) = Engine::from_client(client, config).await.unwrap();
4547 let running = tokio::spawn(engine.run());
4648 TestRoom {
@@ -304,7 +306,9 @@ async fn fill_gap_returns_after_the_mirror_contains_the_committed_rows() {
304306 let middle_id = event_id!("$gap-middle:localhost");
305307 let (engine, handle, events) = Engine::from_client(
306308 client,
307- EngineConfig::default().with_initial_history_limit(0),
309+ EngineConfig::default()
310+ .with_initial_history_limit(0)
311+ .with_search_backfill_pages_per_room(0),
308312 )
309313 .await
310314 .unwrap();
Modified file: tests/mentions_inbox.rs+30 additions−12 deletions
@@ -334,7 +334,9 @@ async fn unopened_room_mentions_are_deduplicated_and_read_through_uses_a_revisio
334334
335335 let (engine, handle, mut events) = Engine::from_client(
336336 client,
337- EngineConfig::default().with_initial_history_limit(0),
337+ EngineConfig::default()
338+ .with_initial_history_limit(0)
339+ .with_search_backfill_pages_per_room(0),
338340 )
339341 .await
340342 .unwrap();
@@ -448,7 +450,9 @@ async fn edits_can_remove_and_restore_a_mention_until_redaction_removes_its_targ
448450
449451 let (engine, handle, mut events) = Engine::from_client(
450452 client,
451- EngineConfig::default().with_initial_history_limit(0),
453+ EngineConfig::default()
454+ .with_initial_history_limit(0)
455+ .with_search_backfill_pages_per_room(0),
452456 )
453457 .await
454458 .unwrap();
@@ -591,7 +595,9 @@ async fn room_mentions_require_power_while_muted_direct_mentions_remain_and_igno
591595
592596 let (engine, handle, mut events) = Engine::from_client(
593597 client,
594- EngineConfig::default().with_initial_history_limit(0),
598+ EngineConfig::default()
599+ .with_initial_history_limit(0)
600+ .with_search_backfill_pages_per_room(0),
595601 )
596602 .await
597603 .unwrap();
@@ -679,7 +685,9 @@ async fn event_context_uses_independent_direction_limits_and_saved_mention_conte
679685 feed.mount(&server).await;
680686 let (engine, handle, mut events) = Engine::from_client(
681687 client,
682- EngineConfig::default().with_initial_history_limit(0),
688+ EngineConfig::default()
689+ .with_initial_history_limit(0)
690+ .with_search_backfill_pages_per_room(0),
683691 )
684692 .await
685693 .unwrap();
@@ -836,7 +844,9 @@ async fn highlighting_keywords_are_mentions_and_quiet_ones_are_not() {
836844
837845 let (engine, handle, mut events) = Engine::from_client(
838846 client,
839- EngineConfig::default().with_initial_history_limit(0),
847+ EngineConfig::default()
848+ .with_initial_history_limit(0)
849+ .with_search_backfill_pages_per_room(0),
840850 )
841851 .await
842852 .unwrap();
@@ -917,9 +927,12 @@ async fn adding_a_keyword_gives_its_rule_a_highlight() {
917927 .mount(&server)
918928 .await;
919929
920- let (engine, handle, mut events) = Engine::from_client(client, EngineConfig::default())
921- .await
922- .unwrap();
930+ let (engine, handle, mut events) = Engine::from_client(
931+ client,
932+ EngineConfig::default().with_search_backfill_pages_per_room(0),
933+ )
934+ .await
935+ .unwrap();
923936 let running = tokio::spawn(engine.run());
924937 handle.add_notification_keyword("deploy").await.unwrap();
925938 shut_down(&handle, &mut events, running).await;
@@ -963,9 +976,12 @@ async fn a_quiet_keyword_rule_only_gains_the_highlight() {
963976 .mount(&server)
964977 .await;
965978
966- let (engine, handle, mut events) = Engine::from_client(client, EngineConfig::default())
967- .await
968- .unwrap();
979+ let (engine, handle, mut events) = Engine::from_client(
980+ client,
981+ EngineConfig::default().with_search_backfill_pages_per_room(0),
982+ )
983+ .await
984+ .unwrap();
969985 let running = tokio::spawn(engine.run());
970986 handle.add_notification_keyword("deploy").await.unwrap();
971987 shut_down(&handle, &mut events, running).await;
@@ -1022,7 +1038,9 @@ async fn sqlite_mentions_survive_engine_shutdown_and_session_resume() {
10221038 );
10231039 feed.mount(&server).await;
10241040
1025- let config = EngineConfig::default().with_initial_history_limit(0);
1041+ let config = EngineConfig::default()
1042+ .with_initial_history_limit(0)
1043+ .with_search_backfill_pages_per_room(0);
10261044 let session = Session::login_password(
10271045 PasswordLogin::new(server.uri(), "example", "wordpass").allow_insecure_loopback(true),
10281046 sessions.clone(),