Commit 74befbc72baf
tarball20 files changed+1650-539
@@ -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()? {@@ -422,8 +422,6 @@ impl Engine {
422422
.await423423
// a missing user id is all it still refuses, and that was turned away above424424
.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();@@ -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;@@ -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 = state447454
.thread_lists@@ -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 answers987
+ /// `SearchNotOpen` from now on988
+ 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,@@ -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 changed52
+ Held,4853
/// an edit was redacted out from under its original, which has to be read again4954
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 under172
-/// its key, the latest by its own sender wins, and one arriving first holds the key173
-/// provisionally until the original turns up177
+/// its key, the latest by its own sender wins, and one that arrives first waits under178
+/// `provisional_key` until the original turns up174179
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
- ..existing241
- }233
+ if index234
+ .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
+ ..existing249
+ });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::Held257269
}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::Removed279
- } else {280
- Effect::Nothing285
+ _ => Effect::Nothing,281286
}282287
}283288
Observed::Encrypted { .. } | Observed::Other => Effect::Nothing,@@ -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::Nothing235
+ );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
+}@@ -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 them2927
const MERGE_FACTOR: usize = 6;28
+/// decompressed docstore blocks a searcher keeps per segment, tantivy's default is 10029
+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 otherwise4242
pub(crate) text: String,43
- /// an edit seen before its original43
+ /// 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 up4445
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's49
+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 them73
+ 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
- ranges138
-}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 = index186170
.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
files218203
.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
packed230216
}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<_> = records251
+ .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 load271
+ /// `blob` hands over what an earlier commit put, once each, and any of it missing or272
+ /// unreadable fails the load284273
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.segments350
- .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 at337
+ #[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
+ keys357347
}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 = doc362
+ .key363
+ .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
document374370
}@@ -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
+ removed442
+ }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.removed478
+ .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 deleted461483
fn compact(&mut self) -> Result<(), Error> {462
- self.segments.retain(|segment| {463
- let empty = segment.live() == 0;464
- if empty && self.persistent {465
- self.removed466
- .push((segment.id, segment.persisted, segment.del_gen));467
- }468
- !empty469
- });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.removed523
- .push((segment.id, segment.persisted, segment.del_gen));524
- }525
- !merging526
- });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
- removed601
+ self.remove_where(key_field, key) || buffered602
+ }603
+604
+ fn remove_provisional(&mut self, target: &str) -> bool {605
+ let prefix = format!("{target}\n");606
+ let before = self.buffer.len();607
+ self.buffer608
+ .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) || buffered597612
}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.text615
- && 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
- changes709
- .puts710
- .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
changes713718
.deletes714719
.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<_> = self738
- .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
@@ -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
+ found3245
}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 messages8796
#[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 lost108
+/// the last flush and merge, and a bumped delete generation failed the whole load109
+#[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
}@@ -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 many5055
const MAX_SEARCHES: usize = 8;56
+/// hits read back at once while a page is put together57
+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 sixteenth71
+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` held7393
fn put(&mut self, doc: Doc);7494
fn remove(&mut self, key: &str) -> bool;95
+ /// every provisional doc waiting on `target`, whoever sent its edit96
+ 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 terms7799
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 again110
+ 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 flight190
+ 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 has170197
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 commit227
+ #[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 for231
+ 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_SHARDS276
+}277
+278
+/// a 400 to a page with a saved token means the server no longer takes the token279
+fn token_rejected(error: &Error) -> bool {280
+ error281
+ .server282
+ .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 = self298
+ .sessions299
+ .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) = self313
+ .search(&query, &scope, Some(cursor.as_str()), limit, &prefix)314
+ .await?;315
+ self.sessions316
+ .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.hits334404
.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 older468
+ if let Some(relations) = original469
+ .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.key396476
&& let Some((edit, _)) = load(&room, &hit.content).await397477
{398478
values.push(edit);399479
}400
- let Some((event, registrations)) =401
- context::preview(&room, current_user_id.as_deref(), &values, highlight).await480
+ let Some((event, registrations)) = context::preview(481
+ &room,482
+ current_user_id.as_deref(),483
+ &values,484
+ highlight,485
+ media_prefix,486
+ )487
+ .await402488
else {403489
return Preview::Unavailable;404490
};@@ -409,7 +495,7 @@ impl Handle {
409495
_ => None,410496
};411497
let matches = text412
- .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 out442
-#[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 entry534
+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
id478582
}479583
480
- async fn get(&self, id: &str) -> Result<(String, SearchScope), Error> {584
+ /// the query, scope and media prefix the search's pages use585
+ 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 = open588
+ .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 = open607
+ .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 to648
+/// the one before it rather than to the original649
+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 room654
+ .load_or_fetch_event_with_relations(&event_id, Some(vec![RelationType::Replacement]), None)655
+ .await656
+ {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
- blobs621
- .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 = rooms784
+ .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> = self835
+ .pending836
+ .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) = self738925
.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) = key753
- && 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 = self1004
+ .rooms1005
+ .get(room_id.as_str())1006
+ .is_some_and(|progress| progress.started);1007
+ if started1008
+ && 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 never1016
+ /// follow, so a lag follows every joined room1017
+ 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
+ true1028
+ }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.backfilling1057
+ && backfilling == room_id1058
+ {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 = self1077
+ .pending1078
+ .values()1079
+ .filter(|pending| scoped.contains(pending.room.as_str()))1080
+ .count();8371081
let rooms = rooms8381082
.iter()8391083
.map(|room_id| {@@ -846,16 +1090,16 @@ impl Service {
8461090
SearchBackfill::Complete8471091
} else if self.budget.pages_per_room == 0 {8481092
SearchBackfill::Disabled849
- } else if self.backfilling.as_ref() == Some(room_id) {850
- SearchBackfill::Running851
- } else if let Some(Failure {852
- message,853
- retry_at_ms,854
- }) = self.failures.get(room_id)1093
+ } else if self1094
+ .backfilling1095
+ .as_ref()1096
+ .is_some_and(|(backfilling, _)| backfilling == room_id)8551097
{1098
+ SearchBackfill::Running1099
+ } 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_room1131
+ }1132
+1133
+ fn blocked(&self, room_id: &OwnedRoomId, at: u64) -> bool {1134
+ self.failures1135
+ .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 older8841140
fn schedule_backfill(&mut self) {885
- if self.backfilling.is_some()886
- || self.budget.pages_per_room == 0887
- || now() < self.next_backfill1141
+ let at = now();1142
+ if self.backfilling.is_some() || self.budget.pages_per_room == 0 || at < self.next_backfill8881143
{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) >= budget894
- };895
- let blocked = |failures: &HashMap<OwnedRoomId, Failure>, room: &OwnedRoomId| {896
- failures897
- .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 = self903
- .rooms904
- .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
.rooms9241175
.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_TYPES1204
+ .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
+ .await1215
+ .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 = room953
- .messages(options)954
- .await955
- .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 = self1269
+ .failures1270
+ .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 head996
- && 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<_> = self10281316
.pending10291317
.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 = client11031414
.encryption()11041415
.room_keys_received_stream()11051416
.await11061417
.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 indexed1491
+/// since it started don't count, or the first page would always stop it1492
+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;@@ -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
+ server17
+ .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
+ client26
+}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
+ .139
+}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: events56
+ .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 always68
+/// ended the pass and an offline gap past one page was never filled69
+#[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 followed112
+#[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
+ service120
+ .generic(Err(broadcast::error::RecvError::Lagged(3)))121
+ .await122
+ );123
+ assert!(service.subscriptions.contains_key(room_id));124
+}125
+126
+/// a closed stream left its entry behind, and the room was never followed again127
+#[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
+ service136
+ .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
+}@@ -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 transaction140
+ 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 = database143144
.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
+ store152
+ .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().await148163
};149
- clear164
+ replace150165
.await151
- .map_err(|error| storage_error("clearing the search database", error))166
+ .map_err(|error| storage_error("replacing the search database", error))152167
}153168
}154169
@@ -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 its28
-/// own key inside the seal, so one can't be swapped for another27
+/// blobs sealed with a `StoreCipher` under the store's passphrase, stored under a keyed hash28
+/// of their name like the mentions store's rows. every blob carries its own name inside the29
+/// seal, so one can't be swapped for another2930
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 taken36
+/// is nothing the index points at37
+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 = cipher6481
.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
- store72
- .backend73
- .commit(74
- vec![(CIPHER.to_owned(), exported), (ACCOUNT.to_owned(), sealed)],75
- Vec::new(),76
- )85
+ let sealed = seal(&cipher, ACCOUNT, owner)?;86
+ backend87
+ .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 sealed82
- 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).await119
+ let mut removed: Vec<String> = deletes.iter().map(|name| self.storage_key(name)).collect();120
+ removed.extend(purge);121
+ self.backend.commit(sealed, removed).await104122
}105123
106
- /// keeps the cipher and owner, drops everything else124
+ /// keeps the cipher and owner and drops everything else, in one transaction107125
pub(crate) async fn reset(&self) -> Result<(), Error> {108126
let keep = self.backend.load(&[CIPHER, ACCOUNT]).await?;109
- self.backend.clear().await?;110
- self.backend111
- .commit(keep.into_iter().collect(), Vec::new())112
- .await127
+ self.backend.replace(keep.into_iter().collect()).await113128
}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 = self122
- .cipher123
- .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.cipher132
+ .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 seals140
+/// without being copied first141
+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 = cipher145
+ .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(@@ -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 = connection79
- .transaction_with_behavior(TransactionBehavior::Immediate)80
- .map_err(|error| storage_error("writing the search store", error))?;81
- for key in deletes {82
- transaction83
- .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
- transaction88
- .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
- transaction95
- .commit()96
- .map_err(|error| storage_error("writing the search store", error))78
+ write(&path, false, puts, deletes)9779
})9880
.await9981
}10082
101
- pub(super) async fn clear(&self) -> Result<(), Error> {83
+ /// everything goes and `puts` arrive in the same transaction84
+ 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
.await11090
}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 = connection101
+ .transaction_with_behavior(TransactionBehavior::Immediate)102
+ .map_err(|error| storage_error("writing the search store", error))?;103
+ if clear {104
+ transaction105
+ .execute("DELETE FROM blobs", [])106
+ .map_err(|error| storage_error("clearing the search store", error))?;107
+ }108
+ for key in deletes {109
+ transaction110
+ .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
+ transaction115
+ .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
+ transaction122
+ .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 three155
+#[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))?;@@ -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 fit14
+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 emoji20
+ Single,21
+ /// two neighbouring chars of such a run, sharing the first one's position22
+ 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 query32
+ paired: bool,33
+}34
+35
+/// scripts written without spaces, where a char is the only boundary there is36
+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
+ pieces115
+}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
+ true131
+ }132
+ None => false,133
+ }134
+ }135
+136
+ fn token(&self) -> &Token {137
+ &self.token138
+ }139
+140
+ fn token_mut(&mut self) -> &mut Token {141
+ &mut self.token142
+ }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 joined187
+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
+ ranges205
+}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 words214
+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 searchable246
+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<_> = found253
+ .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 = ordered265
+ .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> = found275
+ .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 = terms286
+ .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 scope296
+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;@@ -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
+ found11
+}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 words34
+#[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
+}@@ -34,7 +34,9 @@ pub enum SearchCommand {
3434
limit: u16,3535
},3636
/// frees the search's media. its hits' media ids stop resolving37
- 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 oldest56
+ 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 engine8493
pub persistent: bool,85
- /// messages that couldn't be decrypted yet, so can't be found yet94
+ /// 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 yet8697
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 feature104
+ NoStore,105
+ OpenFailed,106
+ ReadFailed,107
+ /// five commits in a row failed, so this engine stopped trying108
+ CommitFailed,109
+}110
+89111
#[derive(Clone, Debug, Eq, PartialEq)]90112
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]91113
pub struct RoomSearchCoverage {@@ -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
}@@ -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
.await310314
.unwrap();@@ -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
.await340342
.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
.await454458
.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
.await597603
.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
.await685693
.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
.await842852
.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
- .await922
- .unwrap();930
+ let (engine, handle, mut events) = Engine::from_client(931
+ client,932
+ EngineConfig::default().with_search_backfill_pages_per_room(0),933
+ )934
+ .await935
+ .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
- .await968
- .unwrap();979
+ let (engine, handle, mut events) = Engine::from_client(980
+ client,981
+ EngineConfig::default().with_search_backfill_pages_per_room(0),982
+ )983
+ .await984
+ .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(),