Commit 7cdaa86b9489
tarball12 files changed+1269-565
@@ -859,8 +859,7 @@ impl Session {
859859
account,860860
config.max_mentions_bytes,861861
)862
- .await863
- .map_err(|error| account_error("opening the mentions store", error))?;862
+ .await;864863
Engine::with_persistence(865864
self.client,866865
config,@@ -346,7 +346,7 @@ impl Engine {
346346
session_changes: broadcast::Receiver<SessionChange>,347347
persistence: Option<Persistence>,348348
store_lock: Option<StoreLock>,349
- mentions: Option<MentionsStore>,349
+ mentions: Option<Result<MentionsStore, crate::Error>>,350350
) -> Result<(Self, ClientHandle, EventStream), EngineError> {351351
if client.user_id().is_none() {352352
return Err(EngineError::Unauthenticated);@@ -45,8 +45,9 @@ pub use engine::{Engine, EngineConfig, EngineError};
4545
pub use error::{Error, ErrorKind, ServerError};4646
pub use imbl;4747
pub use mentions_model::{48
- MentionCaptureState, MentionCoverage, MentionEntry, MentionGap, MentionKey, MentionReason,49
- MentionsBoundary, MentionsCommand, MentionsPage, MentionsUpdate, SavedMentionContext,48
+ MentionCaptureState, MentionCoverage, MentionEntry, MentionGap, MentionGapReason, MentionKey,49
+ MentionReason, MentionsBoundary, MentionsCommand, MentionsPage, MentionsUpdate,50
+ SavedMentionContext,5051
};5152
#[cfg(feature = "mirror")]5253
pub use mirror::{@@ -1,26 +1,35 @@
11
use std::{22
collections::{BTreeMap, BTreeSet},3
- sync::Arc,3
+ sync::{4
+ Arc,5
+ atomic::{AtomicBool, Ordering},6
+ },47
time::Duration,58
};69
710
use futures_util::{StreamExt, stream::FuturesUnordered};811
use matrix_sdk::{9
- Client, RoomState,12
+ Client, Room, RoomState,13
+ event_cache::RedecryptorReport,1014
sliding_sync::{ResponseCommitBatch, ResponseCommitHook},1115
};1216
use ruma::{13
- OwnedEventId, OwnedRoomId, OwnedUserId,14
- events::{AnySyncTimelineEvent, room::encrypted::OriginalSyncRoomEncryptedEvent},17
+ OwnedEventId, OwnedRoomId, OwnedUserId, RoomId,18
+ events::{19
+ AnySyncTimelineEvent, ignored_user_list::IgnoredUserListEventContent,20
+ room::encrypted::OriginalSyncRoomEncryptedEvent,21
+ },1522
serde::Raw,1623
};1724
use serde_json::Value;1825
use tokio::sync::{mpsc, oneshot, watch};1926
20
-use super::state::{Candidate, Data, RepairCoverage, SavedEvent, Window, id, invalid, now};27
+use super::state::{28
+ Candidate, Checkpoint, Data, RepairCoverage, SavedEvent, SyncState, Window, id, invalid, now,29
+};2130
use crate::{2231
CommandOutput, EngineConfig, Error, ErrorKind, Event, HistoryRepairPolicy, MentionCaptureState,23
- MentionEntry, MentionGap, MentionKey, MentionReason, MentionsCommand, MentionsPage,32
+ MentionEntry, MentionGapReason, MentionKey, MentionReason, MentionsCommand, MentionsPage,2433
MentionsUpdate, TimelineContent, TimelineItem,2534
engine::{MediaService, MentionsStore, context, executor::EngineBoxFuture},2635
normalise::{MediaRegistration, normalise_observed},@@ -31,11 +40,17 @@ use crate::{
3140
#[path = "service_tests.rs"]3241
mod tests;3342
43
+const HOOK_TIMEOUT: Duration = Duration::from_secs(10);44
+const FLUSH_RETRY_MS: u64 = 60_000;45
+3446
#[derive(Clone)]35
-pub(crate) struct Handle(mpsc::Sender<Request>);47
+pub(crate) struct Handle {48
+ requests: mpsc::Sender<Request>,49
+ timed_out: Arc<AtomicBool>,50
+}3651
3752
enum Request {38
- Batch(ResponseCommitBatch, oneshot::Sender<Result<(), Error>>),53
+ Batch(ResponseCommitBatch, oneshot::Sender<()>),3954
Command(4055
MentionsCommand,4156
oneshot::Sender<Result<CommandOutput, Error>>,@@ -53,6 +68,12 @@ pub(crate) struct Service {
5368
media: Arc<MediaService>,5469
store: Option<MentionsStore>,5570
data: Data,71
+ sync: SyncState,72
+ dirty: bool,73
+ flush_after: u64,74
+ ignored: BTreeSet<OwnedUserId>,75
+ leaving: BTreeSet<OwnedRoomId>,76
+ timed_out: Arc<AtomicBool>,5677
config: EngineConfig,5778
requests: mpsc::Receiver<Request>,5879
updates: watch::Sender<MentionsPage>,@@ -76,28 +97,37 @@ enum Work {
7697
}7798
7899
impl Handle {100
+ /// never fails, a mentions failure becomes a coverage gap rather than a stalled sync79101
pub(crate) fn hook(&self) -> ResponseCommitHook {80102
let handle = self.clone();81103
ResponseCommitHook::new(move |batch| {82104
let handle = handle.clone();83105
async move {84
- let (reply, response) = oneshot::channel();85
- handle86
- .087
- .send(Request::Batch(batch, reply))88
- .await89
- .map_err(|_| "mentions service stopped".to_owned())?;90
- response91
- .await92
- .map_err(|_| "mentions commit acknowledgement dropped".to_owned())?93
- .map_err(|error| error.to_string())106
+ let committed = async {107
+ let (reply, response) = oneshot::channel();108
+ handle109
+ .requests110
+ .send(Request::Batch(batch, reply))111
+ .await112
+ .ok()?;113
+ response.await.ok()114
+ };115
+ match matrix_sdk::timeout::timeout(committed, HOOK_TIMEOUT).await {116
+ Ok(Some(())) => {}117
+ Ok(None) => tracing::warn!("mentions service stopped, sync carries on"),118
+ Err(_) => {119
+ handle.timed_out.store(true, Ordering::Release);120
+ tracing::warn!("mentions commit timed out, sync carries on");121
+ }122
+ }123
+ Ok(())94124
}95125
})96126
}97127
98128
pub(crate) async fn execute(&self, command: MentionsCommand) -> Result<CommandOutput, Error> {99129
let (reply, response) = oneshot::channel();100
- self.0130
+ self.requests101131
.send(Request::Command(command, reply))102132
.await103133
.map_err(|_| Error::interrupted())?;@@ -106,7 +136,7 @@ impl Handle {
106136
107137
pub(crate) async fn repair_policy(&self) -> Result<HistoryRepairPolicy, Error> {108138
let (reply, response) = oneshot::channel();109
- self.0139
+ self.requests110140
.send(Request::GetRepairPolicy(reply))111141
.await112142
.map_err(|_| Error::interrupted())?;@@ -115,7 +145,7 @@ impl Handle {
115145
116146
pub(crate) async fn set_repair_policy(&self, policy: HistoryRepairPolicy) -> Result<(), Error> {117147
let (reply, response) = oneshot::channel();118
- self.0148
+ self.requests119149
.send(Request::SetRepairPolicy(policy, reply))120150
.await121151
.map_err(|_| Error::interrupted())?;@@ -127,7 +157,7 @@ impl Handle {
127157
room_id: OwnedRoomId,128158
) -> Result<RepairCoverage, Error> {129159
let (reply, response) = oneshot::channel();130
- self.0160
+ self.requests131161
.send(Request::GetRepairCoverage(room_id, reply))132162
.await133163
.map_err(|_| Error::interrupted())?;@@ -136,7 +166,7 @@ impl Handle {
136166
137167
pub(crate) async fn repair_resolved(&self, room_id: OwnedRoomId) -> Result<(), Error> {138168
let (reply, response) = oneshot::channel();139
- self.0169
+ self.requests140170
.send(Request::RepairResolved(room_id, reply))141171
.await142172
.map_err(|_| Error::interrupted())?;@@ -145,17 +175,91 @@ impl Handle {
145175
146176
pub(crate) async fn stop(&self) {147177
let (reply, response) = oneshot::channel();148
- if self.0.send(Request::Stop(reply)).await.is_ok() {178
+ if self.requests.send(Request::Stop(reply)).await.is_ok() {149179
let _ = response.await;150180
}151181
}152182
}153183
184
+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]185
+async fn open_store(186
+ store: Option<Result<MentionsStore, Error>>,187
+) -> (188
+ Option<MentionsStore>,189
+ Data,190
+ SyncState,191
+ Vec<MentionGapReason>,192
+) {193
+ let mut store = match store {194
+ None => return (None, Data::default(), SyncState::default(), Vec::new()),195
+ Some(Err(error)) => {196
+ tracing::error!(%error, "mentions store unavailable, the inbox runs in memory");197
+ return (198
+ None,199
+ Data::default(),200
+ SyncState::default(),201
+ vec![MentionGapReason::StoreFailed],202
+ );203
+ }204
+ Some(Ok(store)) => store,205
+ };206
+ let loaded = (|| {207
+ let data: Data = store.read("inbox")?.unwrap_or_default();208
+ let sync: SyncState = store.read("sync")?.unwrap_or_default();209
+ if sync.inbox_revision != data.revision {210
+ return Err(Error::new(211
+ ErrorKind::Store,212
+ "mentions checkpoint does not match its inbox",213
+ ));214
+ }215
+ Ok((data, sync))216
+ })();217
+ let (data, sync) = match loaded {218
+ Ok(loaded) => loaded,219
+ Err(error) => {220
+ tracing::warn!(%error, "resetting an unreadable mentions inbox");221
+ if let Err(error) = store.reset().await {222
+ tracing::error!(%error, "mentions store unavailable, the inbox runs in memory");223
+ return (224
+ None,225
+ Data::default(),226
+ SyncState::default(),227
+ vec![MentionGapReason::StoreFailed],228
+ );229
+ }230
+ (Data::default(), SyncState::default())231
+ }232
+ };233
+ let gaps = if store.was_reset() {234
+ vec![MentionGapReason::StoreReset]235
+ } else {236
+ Vec::new()237
+ };238
+ (Some(store), data, sync, gaps)239
+}240
+241
+#[cfg(not(any(feature = "sqlite", feature = "indexeddb")))]242
+async fn open_store(243
+ store: Option<Result<MentionsStore, Error>>,244
+) -> (245
+ Option<MentionsStore>,246
+ Data,247
+ SyncState,248
+ Vec<MentionGapReason>,249
+) {250
+ let gaps = match store {251
+ None => Vec::new(),252
+ Some(Err(_)) => vec![MentionGapReason::StoreFailed],253
+ Some(Ok(store)) => match store {},254
+ };255
+ (None, Data::default(), SyncState::default(), gaps)256
+}257
+154258
impl Service {155259
pub(crate) async fn new(156260
client: Client,157261
media: Arc<MediaService>,158
- store: Option<MentionsStore>,262
+ store: Option<Result<MentionsStore, Error>>,159263
config: EngineConfig,160264
) -> Result<(Handle, Self, watch::Receiver<MentionsPage>), Error> {161265
let account = client@@ -167,24 +271,9 @@ impl Service {
167271
)168272
})?169273
.to_owned();170
- #[cfg(any(feature = "sqlite", feature = "indexeddb"))]171
- let data: Data = store172
- .as_ref()173
- .map(|store| store.read("inbox"))174
- .transpose()?175
- .flatten()176
- .unwrap_or_default();177
- #[cfg(not(any(feature = "sqlite", feature = "indexeddb")))]178
- let data = Data::default();179
- #[cfg(any(feature = "sqlite", feature = "indexeddb"))]180
- if store181
- .as_ref()182
- .is_some_and(|store| store.revision() != data.revision)183
- {184
- return Err(Error::new(185
- ErrorKind::Store,186
- "mentions revision does not match its checkpoint",187
- ));274
+ let (store, mut data, sync, gaps) = open_store(store).await;275
+ for reason in &gaps {276
+ data.gap(None, *reason, None);188277
}189278
let page = data.page(&account, None, 50)?;190279
media@@ -198,63 +287,53 @@ impl Service {
198287
.await;199288
let (updates, receiver) = watch::channel(page);200289
let (sender, requests) = mpsc::channel(config.command_capacity.max(1));290
+ let timed_out = Arc::new(AtomicBool::new(false));291
+ let mut service = Self {292
+ client,293
+ account,294
+ media,295
+ store,296
+ data,297
+ sync,298
+ dirty: !gaps.is_empty(),299
+ flush_after: 0,300
+ ignored: BTreeSet::new(),301
+ leaving: BTreeSet::new(),302
+ timed_out: timed_out.clone(),303
+ config,304
+ requests,305
+ updates,306
+ recovery_budget: 20,307
+ latest_scan: true,308
+ recovery_retry: 0,309
+ };310
+ service.ignored = service.ignored_users().await;201311
Ok((202
- Handle(sender),203
- Self {204
- client,205
- account,206
- media,207
- store,208
- data,209
- config,210
- requests,211
- updates,212
- recovery_budget: 20,213
- latest_scan: true,214
- recovery_retry: 0,312
+ Handle {313
+ requests: sender,314
+ timed_out,215315
},316
+ service,216317
receiver,217318
))218319
}219320
220
- async fn save(&mut self, mut next: Data) -> Result<(), Error> {221
- next.revision = self222
- .data223
- .revision224
- .checked_add(1)225
- .ok_or_else(|| invalid("mentions revision exhausted"))?;226
- next.bound(227
- self.config.max_mentions_entries,228
- self.config.max_mention_context_events,229
- self.config.max_mention_candidates,230
- );231
- #[cfg(any(feature = "sqlite", feature = "indexeddb"))]232
- if let Some(store) = &mut self.store {233
- loop {234
- let value = serde_json::to_value(&next).map_err(|error| {235
- Error::with_source(236
- ErrorKind::Store,237
- "encoding the mentions inbox failed",238
- error,239
- )240
- })?;241
- match store242
- .commit(243
- self.data.revision,244
- vec![super::store::Write::Set("inbox".to_owned(), value)],245
- )246
- .await247
- {248
- Ok(_) => break,249
- Err(error)250
- if error.message == "mentions storage limit exceeded"251
- && next.evict_one() => {}252
- Err(error) => return Err(error),253
- }254
- }255
- }256
- self.data = next;257
- let page = self.data.page(&self.account, None, 50)?;321
+ async fn ignored_users(&self) -> BTreeSet<OwnedUserId> {322
+ self.client323
+ .account()324
+ .account_data::<IgnoredUserListEventContent>()325
+ .await326
+ .ok()327
+ .flatten()328
+ .and_then(|raw| raw.deserialize().ok())329
+ .map(|content| content.ignored_users.into_keys().collect())330
+ .unwrap_or_default()331
+ }332
+333
+ async fn publish(&mut self) {334
+ let Ok(page) = self.data.page(&self.account, None, 50) else {335
+ return;336
+ };258337
self.media259338
.register(260339
page.entries@@ -265,24 +344,110 @@ impl Service {
265344
)266345
.await;267346
self.updates.send_replace(page);347
+ }348
+349
+ /// writes the sync record, and the inbox with it when `dirty`, in one commit so a350
+ /// checkpoint never lands without the inbox changes it covers351
+ async fn flush(&mut self) -> Result<(), Error> {352
+ if self.dirty {353
+ self.advance()?;354
+ }355
+ #[cfg(any(feature = "sqlite", feature = "indexeddb"))]356
+ if let Some(store) = &mut self.store {357
+ use super::state::json_len;358
+ use super::store::Write;359
+360
+ let encode = |error| {361
+ Error::with_source(362
+ ErrorKind::Store,363
+ "encoding the mentions inbox failed",364
+ error,365
+ )366
+ };367
+ let sync = serde_json::to_value(&self.sync).map_err(encode)?;368
+ let mut writes = Vec::new();369
+ if self.dirty {370
+ let available = store371
+ .available(&["inbox", "sync"])372
+ .saturating_sub(store.sealed_bound("sync", json_len(&sync)));373
+ let mut size = store.sealed_bound("inbox", json_len(&self.data));374
+ while size > available {375
+ let Some(freed) = self.data.evict_one() else {376
+ break;377
+ };378
+ size = size.saturating_sub(freed * 3);379
+ }380
+ writes.push(Write::Set(381
+ "inbox".to_owned(),382
+ serde_json::to_value(&self.data).map_err(encode)?,383
+ ));384
+ }385
+ writes.push(Write::Set("sync".to_owned(), sync));386
+ store.commit(store.revision(), writes).await?;387
+ }388
+ if self.dirty {389
+ self.dirty = false;390
+ self.publish().await;391
+ }268392
Ok(())269393
}270394
395
+ fn advance(&mut self) -> Result<(), Error> {396
+ self.data.revision = self397
+ .data398
+ .revision399
+ .checked_add(1)400
+ .ok_or_else(|| invalid("mentions revision exhausted"))?;401
+ self.data.bound(402
+ self.config.max_mentions_entries,403
+ self.config.max_mention_context_events,404
+ self.config.max_mention_candidates,405
+ );406
+ self.sync.inbox_revision = self.data.revision;407
+ Ok(())408
+ }409
+410
+ async fn apply(&mut self, data: Option<Data>, sync: Option<SyncState>) -> Result<(), Error> {411
+ let dirty = self.dirty;412
+ let previous_data = data.map(|data| std::mem::replace(&mut self.data, data));413
+ let previous_sync = sync.map(|sync| std::mem::replace(&mut self.sync, sync));414
+ self.dirty |= previous_data.is_some();415
+ let result = self.flush().await;416
+ if result.is_err() {417
+ if let Some(data) = previous_data {418
+ self.data = data;419
+ }420
+ if let Some(sync) = previous_sync {421
+ self.sync = sync;422
+ }423
+ self.dirty = dirty;424
+ }425
+ result426
+ }427
+428
+ async fn store_failed(&mut self, error: Error) {429
+ tracing::error!(%error, "saving the mentions inbox failed");430
+ self.data.gap(None, MentionGapReason::StoreFailed, None);431
+ self.dirty = true;432
+ self.flush_after = now().saturating_add(FLUSH_RETRY_MS);433
+ self.publish().await;434
+ }435
+271436
async fn set_repair_policy(&mut self, policy: HistoryRepairPolicy) -> Result<(), Error> {272437
let mut next = self.data.clone();273438
next.repair_policy = policy;274
- self.save(next).await439
+ self.apply(Some(next), None).await275440
}276441
277442
async fn repair_resolved(&mut self, room_id: OwnedRoomId) -> Result<(), Error> {278
- let mut next = self.data.clone();279
- let coverage = next.repair_coverage.entry(room_id.clone()).or_default();443
+ let mut sync = self.sync.clone();444
+ let coverage = sync.repair_coverage.entry(room_id.clone()).or_default();280445
coverage.boundary_ms = None;281446
coverage.last_confirmed_ms = Some(now());282447
coverage.reliable = false;283448
coverage.unresolved = false;284
- next.observed_rooms.insert(room_id);285
- self.save(next).await449
+ sync.observed_rooms.insert(room_id);450
+ self.apply(None, Some(sync)).await286451
}287452
288453
async fn command(&mut self, command: MentionsCommand) -> Result<CommandOutput, Error> {@@ -331,7 +496,7 @@ impl Service {
331496
start: None,332497
end: None,333498
});334
- self.save(next).await?;499
+ self.apply(Some(next), None).await?;335500
let (context, media) = self.data.context(&key)?;336501
self.media.register(media).await;337502
Ok(CommandOutput::MentionContext(context))@@ -344,7 +509,7 @@ impl Service {
344509
entry.revision = self.data.revision + 1;345510
}346511
}347
- self.save(next).await?;512
+ self.apply(Some(next), None).await?;348513
Ok(CommandOutput::Done)349514
}350515
MentionsCommand::ReadThrough(boundary) => {@@ -360,7 +525,7 @@ impl Service {
360525
entry.read = true;361526
entry.revision = self.data.revision + 1;362527
}363
- self.save(next).await?;528
+ self.apply(Some(next), None).await?;364529
Ok(CommandOutput::Done)365530
}366531
MentionsCommand::Recover => {@@ -370,61 +535,55 @@ impl Service {
370535
let mut next = self.data.clone();371536
next.coverage.recovering = true;372537
next.coverage.historical_supported = None;373
- self.save(next).await?;538
+ self.apply(Some(next), None).await?;374539
Ok(CommandOutput::Done)375540
}376541
}377542
}378543
379
- fn gap(data: &mut Data, room_id: Option<OwnedRoomId>, reason: &str, token: Option<String>) {380
- if !data381
- .coverage382
- .gaps383
- .iter()384
- .any(|gap| gap.room_id == room_id && gap.reason == reason && gap.token == token)385
- {386
- data.coverage.gaps.push(MentionGap {387
- room_id,388
- reason: reason.to_owned(),389
- token,390
- });391
- if data.coverage.gaps.len() > 128 {392
- data.coverage.gaps.remove(0);393
- data.coverage.truncated = true;394
- }395
- }544
+ fn observable(&self, room_id: &RoomId) -> Option<Room> {545
+ self.client546
+ .get_room(room_id)547
+ .filter(|room| room.state() == RoomState::Joined || self.leaving.contains(room_id))396548
}397549
398
- async fn batch(&mut self, batch: ResponseCommitBatch) -> Result<(), Error> {550
+ async fn batch(&mut self, batch: ResponseCommitBatch) {551
+ let checkpoint = self.sync.checkpoints.get(&batch.connection_id).cloned();552
+ if checkpoint553
+ .as_ref()554
+ .is_some_and(|checkpoint| checkpoint.pos == batch.next_pos)555
+ {556
+ return;557
+ }558
+ self.ignored = self.ignored_users().await;559
+ self.leaving = batch.response.rooms.left.keys().cloned().collect();399560
let mut next = self.data.clone();400
- if next.checkpoints.get(&batch.connection_id) == Some(&batch.next_pos) {401
- return Ok(());561
+ let mut sync = self.sync.clone();562
+ if self.timed_out.swap(false, Ordering::AcqRel) {563
+ next.gap(None, MentionGapReason::HookTimedOut, None);402564
}403565
let observed_at_ms = now();404
- let checkpoint_discontinuity =405
- next.checkpoints.get(&batch.connection_id) != batch.previous_pos.as_ref();406
- if checkpoint_discontinuity {407
- Self::gap(408
- &mut next,409
- None,410
- "sync checkpoint continuity is unknown",411
- None,412
- );413
- let observed_rooms: Vec<_> = next.observed_rooms.iter().cloned().collect();566
+ // a crash after the commit but before the sdk saved `pos` replays from the567
+ // previous position, and that response is a superset of the one committed568
+ let continuous = match &checkpoint {569
+ None => batch.previous_pos.is_none(),570
+ Some(checkpoint) => {571
+ batch.previous_pos.as_ref() == Some(&checkpoint.pos)572
+ || batch.previous_pos == checkpoint.previous573
+ }574
+ };575
+ if !continuous {576
+ next.gap(None, MentionGapReason::SyncDiscontinuity, None);577
+ let observed_rooms: Vec<_> = sync.observed_rooms.iter().cloned().collect();414578
for room_id in observed_rooms {415
- Self::mark_unresolved(&mut next, &room_id, observed_at_ms);579
+ Self::mark_unresolved(&mut sync, &room_id, observed_at_ms);416580
}417581
}418582
for (room_id, raw) in &batch.raw_rooms {419
- if raw.limited || !next.observed_rooms.contains(room_id) {420
- Self::gap(421
- &mut next,583
+ if raw.limited && sync.observed_rooms.contains(room_id) {584
+ next.gap(422585
Some(room_id.clone()),423
- if raw.limited {424
- "limited sync timeline"425
- } else {426
- "initial room history is incomplete"427
- },586
+ MentionGapReason::LimitedTimeline,428587
raw.prev_batch.clone(),429588
);430589
}@@ -433,36 +592,32 @@ impl Service {
433592
.raw_rooms434593
.keys()435594
.chain(batch.response.rooms.joined.keys())595
+ .chain(batch.response.rooms.left.keys())436596
.cloned()437597
.collect();438598
for room_id in &room_ids {439
- if !self440
- .client441
- .get_room(room_id)442
- .is_some_and(|room| room.state() == RoomState::Joined)443
- {599
+ if self.observable(room_id).is_none() {444600
continue;445601
}446
- let previously_observed = next.observed_rooms.contains(room_id);602
+ let previously_observed = sync.observed_rooms.contains(room_id);447603
let limited = batch.raw_rooms.get(room_id).is_some_and(|raw| raw.limited);448
- if checkpoint_discontinuity || limited || !previously_observed {449
- Self::mark_unresolved(&mut next, room_id, observed_at_ms);604
+ if !continuous || limited || !previously_observed {605
+ Self::mark_unresolved(&mut sync, room_id, observed_at_ms);450606
} else {451
- let coverage = next.repair_coverage.entry(room_id.clone()).or_default();607
+ let coverage = sync.repair_coverage.entry(room_id.clone()).or_default();452608
if !coverage.unresolved {453609
coverage.last_confirmed_ms = Some(observed_at_ms);454610
}455611
coverage.reliable = false;456612
}457
- next.observed_rooms.insert(room_id.clone());613
+ sync.observed_rooms.insert(room_id.clone());458614
let mut observed = BTreeMap::new();459615
let mut latest_anchor = None;460616
if let Some(raw) = batch.raw_rooms.get(room_id) {461617
for event in &raw.timeline {462
- let value: Value =463
- serde_json::from_str(event.json().get()).map_err(|error| {464
- Error::with_source(ErrorKind::Invalid, "invalid observed event", error)465
- })?;618
+ let Ok(value) = serde_json::from_str::<Value>(event.json().get()) else {619
+ continue;620
+ };466621
if let Some(event_id) = value["event_id"].as_str() {467622
latest_anchor = event_id.parse::<OwnedEventId>().ok();468623
observed.insert(event_id.to_owned(), value);@@ -470,44 +625,51 @@ impl Service {
470625
}471626
}472627
if let Some(latest_anchor) = latest_anchor {473
- next.repair_coverage628
+ sync.repair_coverage474629
.entry(room_id.clone())475630
.or_default()476631
.latest_anchor = Some(latest_anchor);477632
}478
- if let Some(update) = batch.response.rooms.joined.get(room_id) {479
- for event in &update.timeline.events {480
- let value: Value =481
- serde_json::from_str(event.raw().json().get()).map_err(|error| {482
- Error::with_source(ErrorKind::Invalid, "invalid observed event", error)483
- })?;484
- if let Some(event_id) = value["event_id"].as_str() {485
- observed.insert(event_id.to_owned(), value);486
- }633
+ let processed = batch634
+ .response635
+ .rooms636
+ .joined637
+ .get(room_id)638
+ .map(|update| &update.timeline.events)639
+ .into_iter()640
+ .chain(641
+ batch642
+ .response643
+ .rooms644
+ .left645
+ .get(room_id)646
+ .map(|update| &update.timeline.events),647
+ )648
+ .flatten();649
+ for event in processed {650
+ let Ok(value) = serde_json::from_str::<Value>(event.raw().json().get()) else {651
+ continue;652
+ };653
+ if let Some(event_id) = value["event_id"].as_str() {654
+ observed.insert(event_id.to_owned(), value);487655
}488656
}489657
let mut observed: Vec<_> = observed.into_values().collect();490658
observed.sort_by_key(|value| value["origin_server_ts"].as_u64().unwrap_or(0));491659
for value in observed {492
- self.observe(&mut next, room_id, value).await?;660
+ self.observe(&mut next, room_id, value).await;493661
}494662
}495663
for room_id in batch.response.rooms.left.keys() {496
- Self::gap(497
- &mut next,498
- Some(room_id.clone()),499
- "room left, retained context is read only",500
- None,501
- );664
+ next.gap(Some(room_id.clone()), MentionGapReason::RoomLeft, None);502665
next.candidates503666
.retain(|_, candidate| &candidate.room_id != room_id);504
- for entry in next505
- .entries506
- .values_mut()507
- .filter(|entry| &entry.key.room_id == room_id)508
- {667
+ let revision = next.revision + 1;668
+ for entry in next.entries.values_mut().filter(|entry| {669
+ &entry.key.room_id == room_id && entry.capture != MentionCaptureState::Unavailable670
+ }) {509671
entry.capture = MentionCaptureState::Unavailable;510
- entry.revision = next.revision + 1;672
+ entry.revision = revision;511673
}512674
}513675
let reevaluate: Vec<_> = next@@ -522,15 +684,34 @@ impl Service {
522684
})523685
.collect();524686
for (room_id, raw) in reevaluate {525
- self.observe(&mut next, &room_id, raw).await?;687
+ self.observe(&mut next, &room_id, raw).await;688
+ }689
+ self.filter_ignored(&mut next);690
+ self.leaving.clear();691
+ sync.checkpoints.insert(692
+ batch.connection_id,693
+ Checkpoint {694
+ pos: batch.next_pos,695
+ previous: batch.previous_pos,696
+ },697
+ );698
+ let changed = next != self.data;699
+ if changed {700
+ self.data = next;701
+ self.dirty = true;702
+ }703
+ self.sync = sync;704
+ if now() < self.flush_after {705
+ if changed && self.advance().is_ok() {706
+ self.publish().await;707
+ }708
+ } else if let Err(error) = self.flush().await {709
+ self.store_failed(error).await;526710
}527
- self.filter_ignored(&mut next).await;528
- next.checkpoints.insert(batch.connection_id, batch.next_pos);529
- self.save(next).await530711
}531712
532
- fn mark_unresolved(data: &mut Data, room_id: &OwnedRoomId, observed_at_ms: u64) {533
- let coverage = data.repair_coverage.entry(room_id.clone()).or_default();713
+ fn mark_unresolved(sync: &mut SyncState, room_id: &OwnedRoomId, observed_at_ms: u64) {714
+ let coverage = sync.repair_coverage.entry(room_id.clone()).or_default();534715
if !coverage.unresolved {535716
coverage.boundary_ms = Some(observed_at_ms);536717
coverage.unresolved = true;@@ -538,33 +719,29 @@ impl Service {
538719
coverage.reliable = false;539720
}540721
541
- async fn filter_ignored(&self, next: &mut Data) {542
- let mut removed = Vec::new();543
- for (key, entry) in &next.entries {544
- if let TimelineItem::Event(event) = &entry.event {545
- if self.client.is_user_ignored(&event.sender.user_id).await {546
- removed.push(key.clone());547
- }548
- }549
- }550
- for key in removed {551
- next.entries.remove(&key);552
- next.windows.remove(&key);722
+ fn filter_ignored(&self, next: &mut Data) -> bool {723
+ let removed: Vec<_> = next724
+ .entries725
+ .iter()726
+ .filter(|(_, entry)| {727
+ matches!(&entry.event, TimelineItem::Event(event) if self.ignored.contains(&event.sender.user_id))728
+ })729
+ .map(|(key, _)| key.clone())730
+ .collect();731
+ for key in &removed {732
+ next.entries.remove(key);733
+ next.windows.remove(key);553734
}554735
next.collect_events();736
+ !removed.is_empty()555737
}556738
557
- async fn observe(558
- &self,559
- data: &mut Data,560
- room_id: &OwnedRoomId,561
- mut value: Value,562
- ) -> Result<(), Error> {739
+ async fn observe(&self, data: &mut Data, room_id: &OwnedRoomId, mut value: Value) {563740
let Some(event_id) = value["event_id"]564741
.as_str()565742
.and_then(|value| value.parse().ok())566743
else {567
- return Ok(());744
+ return;568745
};569746
let key = MentionKey {570747
room_id: room_id.clone(),@@ -572,7 +749,7 @@ impl Service {
572749
};573750
let identity = id(&key);574751
let Some(timestamp) = value["origin_server_ts"].as_u64() else {575
- return Ok(());752
+ return;576753
};577754
data.coverage.earliest_timestamp_ms = Some(578755
data.coverage@@ -604,7 +781,7 @@ impl Service {
604781
}605782
data.touch_contexts(&target);606783
}607
- return Ok(());784
+ return;608785
}609786
if data.redacted.contains_key(&identity)610787
|| value["unsigned"].get("redacted_because").is_some()@@ -617,7 +794,7 @@ impl Service {
617794
redact(saved);618795
}619796
data.touch_contexts(&identity);620
- return Ok(());797
+ return;621798
}622799
if value["content"]["m.relates_to"]["rel_type"] == "m.replace" {623800
if let Some(target) = value["content"]["m.relates_to"]["event_id"]@@ -629,7 +806,7 @@ impl Service {
629806
event_id: target,630807
});631808
if data.redacted.contains_key(&target) {632
- return Ok(());809
+ return;633810
}634811
let newer = data.edits.get(&target).is_none_or(|old| {635812
(old["origin_server_ts"].as_u64(), old["event_id"].as_str())@@ -640,24 +817,19 @@ impl Service {
640817
data.events.get(&target).and_then(|saved| saved.raw.clone())641818
{642819
if original["sender"] != value["sender"] {643
- return Ok(());820
+ return;644821
}645822
data.edits.insert(target.clone(), value.clone());646823
value = edited(original, &value);647824
return Box::pin(self.observe(data, room_id, value)).await;648825
}649
- data.candidates.insert(650
- identity,651
- Candidate {652
- room_id: room_id.clone(),653
- raw: value,654
- attempts: 0,655
- retry_at_ms: 0,656
- },657
- );826
+ data.candidates827
+ .entry(identity)828
+ .and_modify(|candidate| candidate.raw = value.clone())829
+ .or_insert_with(|| Candidate::new(room_id.clone(), value, 0));658830
}659831
}660
- return Ok(());832
+ return;661833
}662834
if let Some(edit) = value["unsigned"]["m.relations"].get("m.replace").cloned() {663835
if edit["sender"] == value["sender"]@@ -683,28 +855,20 @@ impl Service {
683855
data.edits.insert(identity.clone(), edit);684856
}685857
if value["type"] == "m.room.encrypted" {686
- data.candidates.entry(identity).or_insert(Candidate {687
- room_id: room_id.clone(),688
- raw: value,689
- attempts: 0,690
- retry_at_ms: 0,691
- });692
- return Ok(());858
+ data.candidates859
+ .entry(identity)860
+ .or_insert_with(|| Candidate::new(room_id.clone(), value, 0));861
+ return;693862
}694863
let Some((item, media)) = normalise_observed(&value, Some(&self.account), &identity) else {695
- return Ok(());864
+ return;696865
};697866
self.supplement(data, &key, &item, &media, &value);698867
let (reasons, suppressed, unresolved) = self.classify(data, room_id, &value).await;699868
if unresolved {700869
data.candidates701870
.entry(identity.clone())702
- .or_insert(Candidate {703
- room_id: room_id.clone(),704
- raw: value.clone(),705
- attempts: 0,706
- retry_at_ms: now() + 30_000,707
- });871
+ .or_insert_with(|| Candidate::new(room_id.clone(), value.clone(), now() + 30_000));708872
} else {709873
data.candidates.remove(&identity);710874
}@@ -721,7 +885,7 @@ impl Service {
721885
},722886
);723887
}724
- return Ok(());888
+ return;725889
}726890
let previous = data.entries.get(&identity);727891
let changed = previous.is_none_or(|entry| {@@ -769,7 +933,6 @@ impl Service {
769933
raw: Some(value),770934
},771935
);772
- Ok(())773936
}774937
775938
fn supplement(@@ -860,16 +1023,12 @@ impl Service {
8601023
return (vec![], false, false);8611024
};8621025
if sender == self.account863
- || self.client.is_user_ignored(&sender).await1026
+ || self.ignored.contains(&sender)8641027
|| value["type"] != "m.room.message"8651028
{8661029
return (vec![], false, false);8671030
}868
- let Some(room) = self869
- .client870
- .get_room(room_id)871
- .filter(|room| room.state() == RoomState::Joined)872
- else {1031
+ let Some(room) = self.observable(room_id) else {8731032
return (vec![], false, false);8741033
};8751034
let raw: Raw<AnySyncTimelineEvent> =@@ -939,100 +1098,216 @@ impl Service {
9391098
let mut retries = BTreeSet::new();9401099
let mut history = false;9411100
let mut next_scan = now() + 60_000;1101
+ let mut next_expiry = 0;9421102
let mut tick = Box::pin(matrix_sdk::sleep::sleep(Duration::from_secs(1)));9431103
loop {9441104
self.schedule(&mut work, &mut captures, &mut retries, &mut history);9451105
tokio::select! {9461106
biased;9471107
request = self.requests.recv() => match request {948
- Some(Request::Stop(reply)) => { let _ = reply.send(()); return; }949
- Some(Request::Batch(batch, reply)) => { let result = self.batch(batch).await; let _ = reply.send(result); }1108
+ Some(Request::Stop(reply)) => {1109
+ if self.dirty && let Err(error) = self.flush().await {1110
+ tracing::error!(%error, "saving the mentions inbox at shutdown failed");1111
+ }1112
+ let _ = reply.send(());1113
+ return;1114
+ }1115
+ Some(Request::Batch(batch, reply)) => { self.batch(batch).await; let _ = reply.send(()); }9501116
Some(Request::Command(command, reply)) => { let result = self.command(command).await; let _ = reply.send(result); }9511117
Some(Request::GetRepairPolicy(reply)) => { let _ = reply.send(Ok(self.data.repair_policy.clone())); }9521118
Some(Request::SetRepairPolicy(policy, reply)) => { let result = self.set_repair_policy(policy).await; let _ = reply.send(result); }953
- Some(Request::GetRepairCoverage(room_id, reply)) => { let coverage = self.data.repair_coverage.get(&room_id).cloned().unwrap_or_default(); let _ = reply.send(Ok(coverage)); }1119
+ Some(Request::GetRepairCoverage(room_id, reply)) => { let coverage = self.sync.repair_coverage.get(&room_id).cloned().unwrap_or_default(); let _ = reply.send(Ok(coverage)); }9541120
Some(Request::RepairResolved(room_id, reply)) => { let result = self.repair_resolved(room_id).await; let _ = reply.send(result); }9551121
None => return,9561122
},9571123
Some(result) = work.next(), if !work.is_empty() => {958
- let mut next = self.data.clone();959
- let recovery = (self.latest_scan, self.recovery_budget);1124
+ let mut next = std::mem::take(&mut self.data);1125
+ // a candidate that failed again only moved its retry time, which needs no write1126
+ let changed = self.complete(&mut next, result, &mut captures, &mut retries, &mut history).await;1127
+ self.data = next;1128
+ self.dirty |= changed;1129
+ }1130
+ Some(report) = reports.next() => {1131
+ let room = match report { Ok(RedecryptorReport::ResolvedUtds { room_id, .. }) => Some(room_id), _ => None };1132
+ for candidate in self.data.candidates.values_mut().filter(|candidate| candidate.encrypted() && room.as_ref().is_none_or(|room| *room == candidate.room_id)) { candidate.retry_at_ms = 0; }1133
+ }1134
+ Some(_) = ignores.next() => {1135
+ self.ignored = self.ignored_users().await;1136
+ let mut next = std::mem::take(&mut self.data);1137
+ self.dirty |= self.filter_ignored(&mut next);1138
+ self.data = next;1139
+ }1140
+ () = &mut tick => {1141
+ tick = Box::pin(matrix_sdk::sleep::sleep(Duration::from_secs(1)));1142
+ if now() >= next_scan && !history { self.recovery_budget = 20; self.latest_scan = true; next_scan = now() + 60_000; }1143
+ if now() >= next_expiry { self.dirty |= self.data.expire_candidates(now()); next_expiry = now() + 60_000; }1144
+ if self.dirty && now() >= self.flush_after && let Err(error) = self.flush().await { self.store_failed(error).await; }1145
+ }1146
+ }1147
+ }1148
+ }1149
+1150
+ async fn complete(1151
+ &mut self,1152
+ next: &mut Data,1153
+ result: Work,1154
+ captures: &mut BTreeSet<String>,1155
+ retries: &mut BTreeSet<String>,1156
+ history: &mut bool,1157
+ ) -> bool {1158
+ match result {1159
+ Work::Capture(key, revision, result) => {1160
+ captures.remove(&key);1161
+ let observed = result1162
+ .as_ref()1163
+ .ok()1164
+ .map(|(_, _, observed)| observed.clone())1165
+ .unwrap_or_default();1166
+ let room_id = next1167
+ .entries1168
+ .get(&key)1169
+ .filter(|entry| entry.revision == revision)1170
+ .map(|entry| entry.key.room_id.clone())1171
+ .filter(|room_id| {1172
+ self.client1173
+ .get_room(room_id)1174
+ .is_some_and(|room| room.state() == RoomState::Joined)1175
+ });1176
+ self.captured(next, key, revision, result);1177
+ if let Some(room_id) = room_id {1178
+ for value in observed {1179
+ let identity = value["event_id"]1180
+ .as_str()1181
+ .map(|event_id| format!("{room_id}\n{event_id}"));1182
+ if identity.as_ref().is_some_and(|key| {1183
+ next.events1184
+ .get(key)1185
+ .is_some_and(|event| event.raw.is_some())1186
+ }) {1187
+ continue;1188
+ }1189
+ self.observe(next, &room_id, value).await;1190
+ }1191
+ }1192
+ }1193
+ Work::Candidate(key, result) => {1194
+ retries.remove(&key);1195
+ if let Some(mut candidate) = next.candidates.remove(&key) {9601196
match result {961
- Work::Capture(key, revision, result) => {962
- captures.remove(&key);963
- let observed = result.as_ref().ok().map(|(_, _, observed)| observed.clone()).unwrap_or_default();964
- let room_id = next.entries.get(&key).filter(|entry| entry.revision == revision)965
- .map(|entry| entry.key.room_id.clone())966
- .filter(|room_id| self.client.get_room(room_id).is_some_and(|room| room.state() == RoomState::Joined));967
- self.captured(&mut next, key, revision, result);968
- if let Some(room_id) = room_id {969
- for value in observed {970
- let identity = value["event_id"].as_str().map(|event_id| format!("{room_id}\n{event_id}"));971
- if identity.as_ref().is_some_and(|key| next.events.get(key).is_some_and(|event| event.raw.is_some())) { continue; }972
- self.observe(&mut next, &room_id, value).await.ok();973
- }1197
+ Ok(value) => {1198
+ self.observe(next, &candidate.room_id, value).await;1199
+ if let Some(unresolved) = next.candidates.get_mut(&key) {1200
+ unresolved.attempts = candidate.attempts.saturating_add(1);1201
+ unresolved.first_seen_ms = candidate.first_seen_ms;1202
+ unresolved.retry_at_ms =1203
+ now() + (1000u64 << unresolved.attempts.min(8));9741204
}9751205
}976
- Work::Candidate(key, result) => {977
- retries.remove(&key);978
- if let Some(mut candidate) = next.candidates.remove(&key) {979
- match result {980
- Ok(value) => {981
- if let Err(error) = self.observe(&mut next, &candidate.room_id, value).await { tracing::warn!(%error, "mention retry failed"); }982
- if let Some(unresolved) = next.candidates.get_mut(&key) { unresolved.attempts = candidate.attempts.saturating_add(1); unresolved.retry_at_ms = now() + (1000u64 << unresolved.attempts.min(8)); }983
- }984
- Err(error) => {985
- if candidate.raw["type"] != "m.room.encrypted" && (matches!(error.kind, ErrorKind::EventNotFound | ErrorKind::Forbidden) || error.server.as_ref().is_some_and(|server| matches!(server.status, 403 | 404))) {986
- Self::gap(&mut next, Some(candidate.room_id), "mention notification context is unavailable", None);987
- } else { candidate.attempts = candidate.attempts.saturating_add(1); candidate.retry_at_ms = retry_at(&error, 1000u64 << candidate.attempts.min(8)); next.candidates.insert(key, candidate); }988
- }989
- }1206
+ Err(error) => {1207
+ if !candidate.encrypted()1208
+ && (matches!(1209
+ error.kind,1210
+ ErrorKind::EventNotFound | ErrorKind::Forbidden1211
+ ) || error1212
+ .server1213
+ .as_ref()1214
+ .is_some_and(|server| matches!(server.status, 403 | 404)))1215
+ {1216
+ next.gap(1217
+ Some(candidate.room_id),1218
+ MentionGapReason::ContextUnavailable,1219
+ None,1220
+ );1221
+ } else {1222
+ candidate.attempts = candidate.attempts.saturating_add(1);1223
+ candidate.retry_at_ms =1224
+ retry_at(&error, 1000u64 << candidate.attempts.min(8));1225
+ next.candidates.insert(key, candidate);1226
+ return false;9901227
}9911228
}992
- Work::History(latest, from, result) => {993
- history = false; self.recovery_budget = self.recovery_budget.saturating_sub(1);994
- match result {995
- Ok(page) => {996
- next.coverage.historical_supported = Some(true);997
- let mut reached_anchor = false;998
- for notification in page.notifications {999
- if let Some(event_id) = notification.event.get_field::<String>("event_id").ok().flatten() {1000
- let identity = format!("{}\n{}", notification.room_id, event_id);1001
- if latest && from.is_none() && next.latest_head.is_none() { next.latest_head = Some(identity.clone()); }1002
- if latest && next.latest_anchor.as_ref() == Some(&identity) { reached_anchor = true; break; }1003
- }1004
- if self.client.get_room(¬ification.room_id).is_some_and(|room| room.state() == RoomState::Joined) {1005
- if let Ok(value) = serde_json::from_str(notification.event.json().get()) { let _ = self.observe(&mut next, ¬ification.room_id, value).await; }1006
- }}1007
- let token = page.next_token.filter(|token| Some(token) != from.as_ref() && !reached_anchor);1008
- if latest { next.latest_cursor = token.clone(); if token.is_none() { self.latest_scan = false; next.latest_anchor = next.latest_head.take().or(next.latest_anchor); } }1009
- else { next.history_cursor = token.clone(); next.history_exhausted = token.is_none(); }1229
+ }1230
+ }1231
+ }1232
+ Work::History(latest, from, result) => {1233
+ *history = false;1234
+ self.recovery_budget = self.recovery_budget.saturating_sub(1);1235
+ match result {1236
+ Ok(page) => {1237
+ next.coverage.historical_supported = Some(true);1238
+ let mut reached_anchor = false;1239
+ for notification in page.notifications {1240
+ if let Some(event_id) = notification1241
+ .event1242
+ .get_field::<String>("event_id")1243
+ .ok()1244
+ .flatten()1245
+ {1246
+ let identity = format!("{}\n{}", notification.room_id, event_id);1247
+ if latest && from.is_none() && next.latest_head.is_none() {1248
+ next.latest_head = Some(identity.clone());10101249
}1011
- Err(error) => {1012
- if error.server.as_ref().is_some_and(|server| matches!(server.status, 404 | 405 | 501)) { next.coverage.historical_supported = Some(false); self.recovery_budget = 0; }1013
- if error.server.as_ref().is_some_and(|server| server.status == 400) {1014
- if latest { next.latest_cursor = None; next.latest_head = None; } else { next.history_cursor = None; }1015
- }1016
- Self::gap(&mut next, None, "notification history could not be recovered", from);1017
- self.recovery_retry = retry_at(&error, 60_000);1250
+ if latest && next.latest_anchor.as_ref() == Some(&identity) {1251
+ reached_anchor = true;1252
+ break;10181253
}10191254
}1020
- next.coverage.recovering = self.recovery_budget > 0 && (self.latest_scan || !next.history_exhausted) && next.coverage.historical_supported != Some(false);1255
+ if self1256
+ .client1257
+ .get_room(¬ification.room_id)1258
+ .is_some_and(|room| room.state() == RoomState::Joined)1259
+ && let Ok(value) =1260
+ serde_json::from_str(notification.event.json().get())1261
+ {1262
+ self.observe(next, ¬ification.room_id, value).await;1263
+ }1264
+ }1265
+ let token = page1266
+ .next_token1267
+ .filter(|token| Some(token) != from.as_ref() && !reached_anchor);1268
+ if latest {1269
+ next.latest_cursor = token.clone();1270
+ if token.is_none() {1271
+ self.latest_scan = false;1272
+ next.latest_anchor =1273
+ next.latest_head.take().or(next.latest_anchor.take());1274
+ }1275
+ } else {1276
+ next.history_cursor = token.clone();1277
+ next.history_exhausted = token.is_none();10211278
}10221279
}1023
- if let Err(error) = self.save(next).await { tracing::error!(%error, "saving mention background work failed"); self.latest_scan = recovery.0; self.recovery_budget = recovery.1; self.recovery_retry = now() + 60_000; }1024
- }1025
- Some(_) = reports.next() => { for candidate in self.data.candidates.values_mut() { candidate.retry_at_ms = 0; } }1026
- Some(_) = ignores.next() => {1027
- let mut next = self.data.clone(); self.filter_ignored(&mut next).await;1028
- if let Err(error) = self.save(next).await { tracing::error!(%error, "saving ignored mention removals failed"); }1029
- }1030
- () = &mut tick => {1031
- tick = Box::pin(matrix_sdk::sleep::sleep(Duration::from_secs(1)));1032
- if now() >= next_scan && !history { self.recovery_budget = 20; self.latest_scan = true; next_scan = now() + 60_000; }1280
+ Err(error) => {1281
+ if error1282
+ .server1283
+ .as_ref()1284
+ .is_some_and(|server| matches!(server.status, 404 | 405 | 501))1285
+ {1286
+ next.coverage.historical_supported = Some(false);1287
+ self.recovery_budget = 0;1288
+ }1289
+ if error1290
+ .server1291
+ .as_ref()1292
+ .is_some_and(|server| server.status == 400)1293
+ {1294
+ if latest {1295
+ next.latest_cursor = None;1296
+ next.latest_head = None;1297
+ } else {1298
+ next.history_cursor = None;1299
+ }1300
+ }1301
+ next.gap(None, MentionGapReason::NotificationHistoryUnavailable, from);1302
+ self.recovery_retry = retry_at(&error, 60_000);1303
+ }10331304
}1305
+ next.coverage.recovering = self.recovery_budget > 01306
+ && (self.latest_scan || !next.history_exhausted)1307
+ && next.coverage.historical_supported != Some(false);10341308
}10351309
}1310
+ true10361311
}10371312
10381313
fn schedule(@@ -72,43 +72,88 @@ async fn joined_client(
7272
(client, own_user)7373
}7474
75
-#[tokio::test]76
-async fn failed_batch_keeps_inbox_and_checkpoint_at_the_same_commit_boundary() {77
- let server = MatrixMockServer::new().await;78
- let room_id = room_id!("!mentions:localhost");79
- let (client, own_user) = joined_client(&server, room_id).await;75
+fn mention_batch(76
+ room_id: &ruma::RoomId,77
+ own_user: &ruma::UserId,78
+ event: &str,79
+ previous_pos: Option<&str>,80
+ next_pos: &str,81
+) -> ResponseCommitBatch {8082
let event = EventFactory::new()8183
.room(room_id)82
- .text_msg("commit boundary")83
- .mentions(Mentions::with_user_ids([own_user.clone()]))84
- .event_id(event_id!("$commit-boundary:localhost"))84
+ .text_msg(event)85
+ .mentions(Mentions::with_user_ids([own_user.to_owned()]))86
+ .event_id(&ruma::EventId::parse(format!("${event}:localhost")).unwrap())8587
.sender(user_id!("@bob:localhost"))8688
.into_raw_sync();8789
let raw_room = serde_json::from_value(json!({88
- "initial": true,8990
"timeline": [serde_json::to_value(event).unwrap()],9091
}))9192
.unwrap();92
- let batch = ResponseCommitBatch {93
+ ResponseCommitBatch {9394
connection_id: "mentions-connection".to_owned(),94
- previous_pos: None,95
- next_pos: "mentions-position-1".to_owned(),95
+ previous_pos: previous_pos.map(str::to_owned),96
+ next_pos: next_pos.to_owned(),9697
response: Default::default(),9798
raw_rooms: [(room_id.to_owned(), raw_room)].into(),98
- };99
- let config = config();100
- let store = MentionsStore::open(&config, account(&client), MAX_BYTES)101
- .await102
- .unwrap();99
+ }100
+}101
+102
+fn quiet_batch(previous_pos: &str, next_pos: &str) -> ResponseCommitBatch {103
+ ResponseCommitBatch {104
+ connection_id: "mentions-connection".to_owned(),105
+ previous_pos: Some(previous_pos.to_owned()),106
+ next_pos: next_pos.to_owned(),107
+ response: Default::default(),108
+ raw_rooms: Default::default(),109
+ }110
+}111
+112
+async fn service(client: &Client, store: Option<Result<MentionsStore, Error>>) -> Service {103113
let media = Arc::new(MediaService::new(104114
client.clone(),105115
EngineConfig::default().max_media_bytes,106116
EngineConfig::default().max_media_sources,107117
));108
- let (_handle, mut service, updates) =109
- Service::new(client, media, Some(store), EngineConfig::default())110
- .await111
- .unwrap();118
+ let (_, mut service, _) = Service::new(client.clone(), media, store, EngineConfig::default())119
+ .await120
+ .unwrap();121
+ service.recovery_budget = 0;122
+ service.latest_scan = false;123
+ service124
+}125
+126
+fn rows(connection: &Connection) -> BTreeMap<String, Vec<u8>> {127
+ let mut statement = connection128
+ .prepare("SELECT key, value FROM records WHERE key NOT IN ('!cipher', '!head')")129
+ .unwrap();130
+ statement131
+ .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))132
+ .unwrap()133
+ .map(Result::unwrap)134
+ .collect()135
+}136
+137
+fn discontinuities(service: &Service) -> usize {138
+ service139
+ .data140
+ .coverage141
+ .gaps142
+ .iter()143
+ .filter(|gap| gap.reason == MentionGapReason::SyncDiscontinuity)144
+ .count()145
+}146
+147
+#[tokio::test]148
+async fn failed_write_acks_and_keeps_the_inbox_in_memory_until_a_later_write() {149
+ let server = MatrixMockServer::new().await;150
+ let room_id = room_id!("!mentions:localhost");151
+ let (client, own_user) = joined_client(&server, room_id).await;152
+ let config = config();153
+ let store = MentionsStore::open(&config, account(&client), MAX_BYTES)154
+ .await155
+ .unwrap();156
+ let mut service = service(&client, Some(Ok(store))).await;112157
113158
let connection = Connection::open(config.mentions_location()).unwrap();114159
connection@@ -118,19 +163,27 @@ async fn failed_batch_keeps_inbox_and_checkpoint_at_the_same_commit_boundary() {
118163
)119164
.unwrap();120165
121
- assert!(service.batch(batch.clone()).await.is_err());122
- assert_eq!(service.data.revision, 0);123
- assert!(service.data.entries.is_empty());124
- assert!(service.data.checkpoints.is_empty());125
- assert_eq!(updates.borrow().revision, 0);126
- assert!(updates.borrow().entries.is_empty());127
-128
- let reopened = MentionsStore::open(&config, account(&service.client), MAX_BYTES)129
- .await130
- .unwrap();131
- assert_eq!(reopened.revision(), 0);132
- assert!(reopened.read::<Data>("inbox").unwrap().is_none());133
- drop(reopened);166
+ service167
+ .batch(mention_batch(168
+ room_id,169
+ &own_user,170
+ "first",171
+ None,172
+ "position-1",173
+ ))174
+ .await;175
+ assert!(service.dirty);176
+ assert_eq!(service.data.entries.len(), 1);177
+ assert!(178
+ service179
+ .data180
+ .coverage181
+ .gaps182
+ .iter()183
+ .any(|gap| gap.reason == MentionGapReason::StoreFailed)184
+ );185
+ assert_eq!(service.updates.borrow().entries.len(), 1);186
+ assert!(rows(&connection).is_empty());134187
135188
connection136189
.execute_batch(@@ -138,48 +191,165 @@ async fn failed_batch_keeps_inbox_and_checkpoint_at_the_same_commit_boundary() {
138191
DROP TRIGGER fail_mentions_inbox_update;",139192
)140193
.unwrap();194
+ service.flush_after = 0;195
+ service.batch(quiet_batch("position-1", "position-2")).await;196
+ assert!(!service.dirty);197
+ drop(service);198
+199
+ let reopened = MentionsStore::open(&config, account(&client), MAX_BYTES)200
+ .await201
+ .unwrap();202
+ let persisted: Data = reopened.read("inbox").unwrap().unwrap();203
+ let sync: SyncState = reopened.read("sync").unwrap().unwrap();204
+ assert_eq!(persisted.entries.len(), 1);205
+ assert_eq!(sync.inbox_revision, persisted.revision);206
+ assert_eq!(sync.checkpoints["mentions-connection"].pos, "position-2");207
+ drop(reopened);141208
drop(connection);209
+ config.delete().await.unwrap();210
+}211
+212
+#[tokio::test]213
+async fn a_quiet_response_writes_only_the_checkpoint() {214
+ let server = MatrixMockServer::new().await;215
+ let room_id = room_id!("!mentions:localhost");216
+ let (client, own_user) = joined_client(&server, room_id).await;217
+ let config = config();218
+ let store = MentionsStore::open(&config, account(&client), MAX_BYTES)219
+ .await220
+ .unwrap();221
+ let mut service = service(&client, Some(Ok(store))).await;222
+ service223
+ .batch(mention_batch(224
+ room_id,225
+ &own_user,226
+ "first",227
+ None,228
+ "position-1",229
+ ))230
+ .await;231
+ let connection = Connection::open(config.mentions_location()).unwrap();232
+ let before = rows(&connection);233
+ assert_eq!(before.len(), 2);142234
143
- service.batch(batch.clone()).await.unwrap();235
+ service.batch(quiet_batch("position-1", "position-2")).await;236
+ let after = rows(&connection);237
+ assert_eq!(after.len(), 2);238
+ assert_eq!(239
+ before240
+ .iter()241
+ .filter(|(key, value)| after.get(*key) != Some(value))242
+ .count(),243
+ 1244
+ );144245
assert_eq!(service.data.revision, 1);145
- assert_eq!(service.data.entries.len(), 1);146246
assert_eq!(147
- service.data.checkpoints.get("mentions-connection"),148
- Some(&batch.next_pos)247
+ service.sync.checkpoints["mentions-connection"].pos,248
+ "position-2"149249
);150
- assert_eq!(updates.borrow().revision, 1);151
- assert_eq!(updates.borrow().entries.len(), 1);250
+ drop(service);251
+ drop(connection);252
+ config.delete().await.unwrap();253
+}254
+255
+#[tokio::test]256
+async fn a_replay_from_the_previous_checkpoint_is_not_a_discontinuity() {257
+ let server = MatrixMockServer::new().await;258
+ let room_id = room_id!("!mentions:localhost");259
+ let (client, own_user) = joined_client(&server, room_id).await;260
+ let config = config();261
+ let store = MentionsStore::open(&config, account(&client), MAX_BYTES)262
+ .await263
+ .unwrap();264
+ let mut first = service(&client, Some(Ok(store))).await;265
+ first266
+ .batch(mention_batch(room_id, &own_user, "one", None, "position-1"))267
+ .await;268
+ first269
+ .batch(mention_batch(270
+ room_id,271
+ &own_user,272
+ "two",273
+ Some("position-1"),274
+ "position-2",275
+ ))276
+ .await;277
+ drop(first);152278
153
- let reopened = MentionsStore::open(&config, account(&service.client), MAX_BYTES)279
+ // the sdk never saved position-2, so it asks again from position-1280
+ let store = MentionsStore::open(&config, account(&client), MAX_BYTES)154281
.await155282
.unwrap();156
- let persisted: Data = reopened.read("inbox").unwrap().unwrap();157
- assert_eq!(reopened.revision(), 1);158
- assert_eq!(persisted.revision, 1);159
- assert_eq!(persisted.entries.len(), 1);283
+ let mut restarted = service(&client, Some(Ok(store))).await;284
+ restarted285
+ .batch(mention_batch(286
+ room_id,287
+ &own_user,288
+ "three",289
+ Some("position-1"),290
+ "position-3",291
+ ))292
+ .await;293
+ assert_eq!(discontinuities(&restarted), 0);294
+ assert_eq!(restarted.data.entries.len(), 3);160295
assert_eq!(161
- persisted.checkpoints.get("mentions-connection"),162
- Some(&batch.next_pos)296
+ restarted.sync.checkpoints["mentions-connection"],297
+ Checkpoint {298
+ pos: "position-3".to_owned(),299
+ previous: Some("position-1".to_owned()),300
+ }163301
);164
- drop(reopened);165302
166
- service.batch(batch).await.unwrap();167
- assert_eq!(service.data.revision, 1);168
- assert_eq!(service.store.as_ref().unwrap().revision(), 1);169
-170
- drop(service);303
+ restarted304
+ .batch(quiet_batch("position-3", "position-4"))305
+ .await;306
+ assert_eq!(discontinuities(&restarted), 0);307
+ restarted308
+ .batch(quiet_batch("position-1", "position-5"))309
+ .await;310
+ assert_eq!(discontinuities(&restarted), 1);311
+ drop(restarted);171312
config.delete().await.unwrap();172313
}173314
174
-#[test]175
-fn repair_fields_default_when_loading_an_older_inbox() {176
- let mut inbox = serde_json::to_value(Data::default()).unwrap();177
- inbox.as_object_mut().unwrap().remove("repair_policy");178
- inbox.as_object_mut().unwrap().remove("repair_coverage");315
+#[tokio::test]316
+async fn an_unreadable_store_resets_behind_a_gap() {317
+ let server = MatrixMockServer::new().await;318
+ let room_id = room_id!("!mentions:localhost");319
+ let (client, own_user) = joined_client(&server, room_id).await;320
+ let config = config();321
+ let store = MentionsStore::open(&config, account(&client), MAX_BYTES)322
+ .await323
+ .unwrap();324
+ let mut first = service(&client, Some(Ok(store))).await;325
+ first326
+ .batch(mention_batch(room_id, &own_user, "one", None, "position-1"))327
+ .await;328
+ drop(first);329
+ let connection = Connection::open(config.mentions_location()).unwrap();330
+ connection331
+ .execute(332
+ "UPDATE records SET value = x'00' WHERE key NOT IN ('!cipher', '!head')",333
+ [],334
+ )335
+ .unwrap();336
+ drop(connection);179337
180
- let restored: Data = serde_json::from_value(inbox).unwrap();181
- assert_eq!(restored.repair_policy, HistoryRepairPolicy::default());182
- assert!(restored.repair_coverage.is_empty());338
+ let store = MentionsStore::open(&config, account(&client), MAX_BYTES)339
+ .await340
+ .unwrap();341
+ let restarted = service(&client, Some(Ok(store))).await;342
+ assert!(restarted.data.entries.is_empty());343
+ assert!(344
+ restarted345
+ .data346
+ .coverage347
+ .gaps348
+ .iter()349
+ .any(|gap| gap.reason == MentionGapReason::StoreReset)350
+ );351
+ drop(restarted);352
+ config.delete().await.unwrap();183353
}184354
185355
#[tokio::test]@@ -196,10 +366,14 @@ async fn repair_policy_and_coverage_roundtrip_through_the_inbox() {
196366
EngineConfig::default().max_media_bytes,197367
EngineConfig::default().max_media_sources,198368
));199
- let (handle, mut service, _) =200
- Service::new(client.clone(), media, Some(store), EngineConfig::default())201
- .await202
- .unwrap();369
+ let (handle, mut service, _) = Service::new(370
+ client.clone(),371
+ media,372
+ Some(Ok(store)),373
+ EngineConfig::default(),374
+ )375
+ .await376
+ .unwrap();203377
service.recovery_budget = 0;204378
service.latest_scan = false;205379
let service_task = tokio::spawn(service.run());@@ -226,7 +400,8 @@ async fn repair_policy_and_coverage_roundtrip_through_the_inbox() {
226400
.unwrap();227401
let persisted: Data = reopened.read("inbox").unwrap().unwrap();228402
assert_eq!(persisted.repair_policy, stored_policy);229
- let coverage = persisted.repair_coverage.get(room_id).unwrap();403
+ let sync: SyncState = reopened.read("sync").unwrap().unwrap();404
+ let coverage = sync.repair_coverage.get(room_id).unwrap();230405
assert!(coverage.last_confirmed_ms.is_some());231406
assert!(coverage.boundary_ms.is_none());232407
assert!(!coverage.unresolved);@@ -248,13 +423,16 @@ async fn repair_limited_sync_freezes_the_last_room_checkpoint() {
248423
let (_handle, mut service, _) = Service::new(client, media, None, EngineConfig::default())249424
.await250425
.unwrap();251
- service.data.checkpoints.insert(426
+ service.sync.checkpoints.insert(252427
"mentions-connection".to_owned(),253
- "position-before".to_owned(),428
+ Checkpoint {429
+ pos: "position-before".to_owned(),430
+ previous: None,431
+ },254432
);255
- service.data.observed_rooms.insert(room_id.to_owned());433
+ service.sync.observed_rooms.insert(room_id.to_owned());256434
service257
- .data435
+ .sync258436
.repair_coverage259437
.entry(room_id.to_owned())260438
.or_default()@@ -292,9 +470,9 @@ async fn repair_limited_sync_freezes_the_last_room_checkpoint() {
292470
raw_rooms: [(room_id.to_owned(), raw_room)].into(),293471
};294472
295
- service.batch(batch).await.unwrap();473
+ service.batch(batch).await;296474
297
- let coverage = service.data.repair_coverage.get(room_id).unwrap();475
+ let coverage = service.sync.repair_coverage.get(room_id).unwrap();298476
assert_eq!(coverage.last_confirmed_ms, Some(1_000));299477
assert!(300478
coverage@@ -372,10 +550,14 @@ async fn service_recovers_notification_history_into_durable_unread_inbox() {
372550
EngineConfig::default().max_media_bytes,373551
EngineConfig::default().max_media_sources,374552
));375
- let (handle, service, mut updates) =376
- Service::new(client.clone(), media, Some(store), EngineConfig::default())377
- .await378
- .unwrap();553
+ let (handle, service, mut updates) = Service::new(554
+ client.clone(),555
+ media,556
+ Some(Ok(store)),557
+ EngineConfig::default(),558
+ )559
+ .await560
+ .unwrap();379561
let service_task = tokio::spawn(service.run());380562
381563
let discovered = tokio::time::timeout(Duration::from_secs(10), async {@@ -398,7 +580,8 @@ async fn service_recovers_notification_history_into_durable_unread_inbox() {
398580
.await399581
.unwrap();400582
let persisted: Data = reopened.read("inbox").unwrap().unwrap();401
- assert_eq!(reopened.revision(), persisted.revision);583
+ let sync: SyncState = reopened.read("sync").unwrap().unwrap();584
+ assert_eq!(sync.inbox_revision, persisted.revision);402585
assert_eq!(persisted.entries.len(), 2);403586
assert!(persisted.entries.values().all(|entry| !entry.read));404587
assert!(persisted.latest_cursor.is_none());@@ -471,11 +654,8 @@ async fn live_context_survives_capture_then_edit_and_redaction() {
471654
let following_id = format!("{room_id}\n$live-following:localhost");472655
let mut data = Data::default();473656
474
- service.observe(&mut data, &room_id, mention).await.unwrap();475
- service476
- .observe(&mut data, &room_id, following)477
- .await478
- .unwrap();657
+ service.observe(&mut data, &room_id, mention).await;658
+ service.observe(&mut data, &room_id, following).await;479659
480660
let mention_identity = id(&mention_key);481661
let entry = data.entries.get(&mention_identity).unwrap();@@ -521,7 +701,7 @@ async fn live_context_survives_capture_then_edit_and_redaction() {
521701
"sender": "@bob:localhost",522702
"type": "m.room.message"523703
});524
- service.observe(&mut data, &room_id, edit).await.unwrap();704
+ service.observe(&mut data, &room_id, edit).await;525705
let (saved, _) = data.context(&mention_key).unwrap();526706
let context = saved.context.unwrap();527707
assert_eq!(@@ -542,10 +722,7 @@ async fn live_context_survives_capture_then_edit_and_redaction() {
542722
"sender": "@bob:localhost",543723
"type": "m.room.redaction"544724
});545
- service546
- .observe(&mut data, &room_id, redaction)547
- .await548
- .unwrap();725
+ service.observe(&mut data, &room_id, redaction).await;549726
550727
let saved_following = data.events.get(&following_id).unwrap();551728
assert!(matches!(@@ -4,11 +4,13 @@ use ruma::{OwnedEventId, OwnedUserId};
44
use serde_json::Value;55
66
use crate::{7
- Error, ErrorKind, EventContext, HistoryRepairPolicy, MentionCaptureState, MentionCoverage,8
- MentionEntry, MentionKey, MentionsBoundary, MentionsPage, SavedMentionContext, TimelineItem,9
- normalise::MediaRegistration,7
+ Error, ErrorKind, EventContext, MentionCaptureState, MentionCoverage, MentionEntry, MentionGap,8
+ MentionGapReason, MentionKey, MentionsBoundary, MentionsPage, SavedMentionContext,9
+ TimelineItem, normalise::MediaRegistration,1010
};1111
12
+const CANDIDATE_LIFETIME_MS: u64 = 7 * 24 * 60 * 60 * 1000;13
+1214
#[derive(Clone)]1315
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]1416
pub(super) struct SavedEvent {@@ -17,16 +19,45 @@ pub(super) struct SavedEvent {
1719
pub raw: Option<Value>,1820
}1921
20
-#[derive(Clone)]22
+impl PartialEq for SavedEvent {23
+ fn eq(&self, other: &Self) -> bool {24
+ self.item == other.item25
+ && self.raw == other.raw26
+ && self27
+ .media28
+ .iter()29
+ .map(|media| &media.id)30
+ .eq(other.media.iter().map(|media| &media.id))31
+ }32
+}33
+34
+#[derive(Clone, PartialEq)]2135
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]2236
pub(super) struct Candidate {2337
pub room_id: ruma::OwnedRoomId,2438
pub raw: Value,2539
pub attempts: u32,2640
pub retry_at_ms: u64,41
+ pub first_seen_ms: u64,2742
}2843
29
-#[derive(Clone)]44
+impl Candidate {45
+ pub fn new(room_id: ruma::OwnedRoomId, raw: Value, retry_at_ms: u64) -> Self {46
+ Self {47
+ room_id,48
+ raw,49
+ attempts: 0,50
+ retry_at_ms,51
+ first_seen_ms: now(),52
+ }53
+ }54
+55
+ pub fn encrypted(&self) -> bool {56
+ self.raw["type"] == "m.room.encrypted"57
+ }58
+}59
+60
+#[derive(Clone, PartialEq)]3061
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]3162
pub(super) struct Window {3263
pub before: Vec<String>,@@ -36,7 +67,7 @@ pub(super) struct Window {
3667
pub end: Option<String>,3768
}3869
39
-#[derive(Clone, Debug, Default)]70
+#[derive(Clone, Debug, Default, PartialEq)]4071
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]4172
pub(crate) struct RepairCoverage {4273
pub(crate) last_confirmed_ms: Option<u64>,@@ -55,7 +86,7 @@ impl RepairCoverage {
5586
}5687
}5788
58
-#[derive(Clone, Default)]89
+#[derive(Clone, Default, PartialEq)]5990
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]6091
pub(super) struct Data {6192
pub revision: u64,@@ -67,19 +98,28 @@ pub(super) struct Data {
6798
pub redacted: BTreeMap<String, u64>,6899
pub edits: BTreeMap<String, Value>,69100
pub coverage: MentionCoverage,70
- pub checkpoints: BTreeMap<String, String>,71
- pub observed_rooms: BTreeSet<ruma::OwnedRoomId>,72
- #[cfg_attr(feature = "serde", serde(default))]73
- pub repair_policy: HistoryRepairPolicy,74
- #[cfg_attr(feature = "serde", serde(default))]75
- pub repair_coverage: BTreeMap<ruma::OwnedRoomId, RepairCoverage>,76101
pub history_cursor: Option<String>,77102
pub history_exhausted: bool,78103
pub latest_cursor: Option<String>,79
- #[cfg_attr(feature = "serde", serde(default))]80104
pub latest_anchor: Option<String>,81
- #[cfg_attr(feature = "serde", serde(default))]82105
pub latest_head: Option<String>,106
+ pub repair_policy: crate::HistoryRepairPolicy,107
+}108
+109
+#[derive(Clone, Default, PartialEq)]110
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]111
+pub(super) struct SyncState {112
+ pub inbox_revision: u64,113
+ pub checkpoints: BTreeMap<String, Checkpoint>,114
+ pub observed_rooms: BTreeSet<ruma::OwnedRoomId>,115
+ pub repair_coverage: BTreeMap<ruma::OwnedRoomId, RepairCoverage>,116
+}117
+118
+#[derive(Clone, Debug, PartialEq)]119
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]120
+pub(super) struct Checkpoint {121
+ pub pos: String,122
+ pub previous: Option<String>,83123
}84124
85125
pub(super) fn id(key: &MentionKey) -> String {@@ -96,7 +136,78 @@ pub(super) fn invalid(message: &'static str) -> Error {
96136
Error::new(ErrorKind::Invalid, message)97137
}98138
139
+#[cfg(feature = "serde")]140
+pub(super) fn json_len(value: &impl serde::Serialize) -> usize {141
+ struct Count(usize);142
+ impl std::io::Write for Count {143
+ fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {144
+ self.0 += bytes.len();145
+ Ok(bytes.len())146
+ }147
+ fn flush(&mut self) -> std::io::Result<()> {148
+ Ok(())149
+ }150
+ }151
+ let mut count = Count(0);152
+ let _ = serde_json::to_writer(&mut count, value);153
+ count.0154
+}155
+156
+#[cfg(not(feature = "serde"))]157
+pub(super) fn json_len<T>(_: &T) -> usize {158
+ 0159
+}160
+161
+fn timestamp(value: &Value) -> u64 {162
+ value["origin_server_ts"].as_u64().unwrap_or(0)163
+}164
+99165
impl Data {166
+ pub fn gap(167
+ &mut self,168
+ room_id: Option<ruma::OwnedRoomId>,169
+ reason: MentionGapReason,170
+ token: Option<String>,171
+ ) {172
+ if self173
+ .coverage174
+ .gaps175
+ .iter()176
+ .any(|gap| gap.room_id == room_id && gap.reason == reason && gap.token == token)177
+ {178
+ return;179
+ }180
+ self.coverage.gaps.push(MentionGap {181
+ room_id,182
+ reason,183
+ token,184
+ });185
+ if self.coverage.gaps.len() > 128 {186
+ self.coverage.gaps.remove(0);187
+ self.coverage.truncated = true;188
+ }189
+ }190
+191
+ pub fn expire_candidates(&mut self, now_ms: u64) -> bool {192
+ let expired: Vec<_> = self193
+ .candidates194
+ .iter()195
+ .filter(|(_, candidate)| {196
+ now_ms.saturating_sub(candidate.first_seen_ms) > CANDIDATE_LIFETIME_MS197
+ })198
+ .map(|(key, candidate)| (key.clone(), candidate.room_id.clone()))199
+ .collect();200
+ for (key, room_id) in &expired {201
+ self.candidates.remove(key);202
+ self.gap(203
+ Some(room_id.clone()),204
+ MentionGapReason::CandidateExpired,205
+ None,206
+ );207
+ }208
+ !expired.is_empty()209
+ }210
+100211
pub fn touch_contexts(&mut self, identity: &str) {101212
for (key, window) in &self.windows {102213
if window.target.as_deref() == Some(identity)@@ -213,23 +324,32 @@ impl Data {
213324
))214325
}215326
216
- pub fn collect_events(&mut self) {327
+ pub fn collect_events(&mut self) -> usize {217328
let mut used: BTreeSet<String> = self.entries.keys().cloned().collect();218329
for window in self.windows.values() {219330
used.extend(window.before.iter().chain(&window.after).cloned());220331
used.extend(window.target.iter().cloned());221332
}222
- self.events.retain(|key, _| used.contains(key));333
+ let mut freed = 0;334
+ self.events.retain(|key, event| {335
+ let keep = used.contains(key);336
+ if !keep {337
+ freed += key.len() + json_len(event);338
+ }339
+ keep340
+ });223341
self.capture_attempts224342
.retain(|key, _| self.entries.contains_key(key));343
+ freed225344
}226345
227
- pub fn evict_one(&mut self) -> bool {346
+ /// returns roughly how many plaintext JSON bytes the eviction freed347
+ pub fn evict_one(&mut self) -> Option<usize> {228348
let oldest = |keys: Vec<String>, entries: &BTreeMap<String, MentionEntry>| {229349
keys.into_iter()230350
.min_by_key(|key| entries.get(key).map_or(0, |entry| entry.timestamp_ms))231351
};232
- if let Some(key) = oldest(352
+ let freed = if let Some(key) = oldest(233353
self.windows234354
.keys()235355
.filter(|key| self.entries.get(*key).is_some_and(|entry| entry.read))@@ -239,30 +359,35 @@ impl Data {
239359
)240360
.or_else(|| oldest(self.windows.keys().cloned().collect(), &self.entries))241361
{242
- self.windows.remove(&key);362
+ let window = self.windows.remove(&key);243363
if let Some(entry) = self.entries.get_mut(&key) {244364
entry.capture = MentionCaptureState::Unavailable;245365
entry.revision = self.revision;246366
}367
+ key.len() + json_len(&window)247368
} else if let Some(key) = self248369
.entries249370
.iter()250371
.min_by_key(|(_, entry)| (!entry.read, entry.timestamp_ms))251372
.map(|(key, _)| key.clone())252373
{253
- self.entries.remove(&key);254
- } else if let Some(key) = self.candidates.keys().next().cloned() {255
- self.candidates.remove(&key);374
+ key.len() + json_len(&self.entries.remove(&key))375
+ } else if let Some(key) = self376
+ .candidates377
+ .iter()378
+ .min_by_key(|(_, candidate)| timestamp(&candidate.raw))379
+ .map(|(key, _)| key.clone())380
+ {381
+ key.len() + json_len(&self.candidates.remove(&key))256382
} else if let Some(key) = self.edits.keys().next().cloned() {257
- self.edits.remove(&key);258
- } else if let Some(key) = self.redacted.keys().next().cloned() {259
- self.redacted.remove(&key);383
+ key.len() + json_len(&self.edits.remove(&key))260384
} else {261
- return false;262
- }385
+ let key = self.redacted.keys().next().cloned()?;386
+ self.redacted.remove(&key);387
+ key.len() + 24388
+ };263389
self.coverage.truncated = true;264
- self.collect_events();265
- true390
+ Some(freed + self.collect_events())266391
}267392
268393
pub fn bound(&mut self, entries: usize, events: usize, candidates: usize) {@@ -279,10 +404,17 @@ impl Data {
279404
}280405
}281406
self.collect_events();282
- while self.events.len() > events && self.evict_one() {}283
- while self.candidates.len() > candidates {284
- let key = self.candidates.keys().next().cloned().unwrap();285
- self.candidates.remove(&key);407
+ while self.events.len() > events && self.evict_one().is_some() {}408
+ if self.candidates.len() > candidates {409
+ let mut oldest: Vec<_> = self410
+ .candidates411
+ .iter()412
+ .map(|(key, candidate)| (timestamp(&candidate.raw), key.clone()))413
+ .collect();414
+ oldest.sort();415
+ for (_, key) in oldest.into_iter().take(self.candidates.len() - candidates) {416
+ self.candidates.remove(&key);417
+ }286418
self.coverage.truncated = true;287419
}288420
for map in [&mut self.redacted] {@@ -196,7 +196,7 @@ async fn retention_discards_read_context_first_and_keeps_shared_events_until_unu
196196
},197197
);198198
199
- assert!(data.evict_one());199
+ assert!(data.evict_one().is_some());200200
assert!(!data.windows.contains_key(&read_id));201201
assert!(data.windows.contains_key(&unread_id));202202
assert_eq!(@@ -206,7 +206,7 @@ async fn retention_discards_read_context_first_and_keeps_shared_events_until_unu
206206
assert!(!data.entries[&unread_id].read);207207
assert!(data.events.contains_key(&shared_id));208208
209
- assert!(data.evict_one());209
+ assert!(data.evict_one().is_some());210210
assert!(data.windows.is_empty());211211
assert!(data.entries.contains_key(&read_id));212212
assert!(data.entries.contains_key(&unread_id));@@ -365,8 +365,6 @@ mod persistence {
365365
end: Some("end-token".to_owned()),366366
},367367
);368
- data.checkpoints369
- .insert("sync-connection".to_owned(), "position-1".to_owned());370368
data.history_cursor = Some("history-position".to_owned());371369
data372370
}@@ -379,7 +377,7 @@ mod persistence {
379377
380378
#[cfg_attr(not(target_family = "wasm"), tokio::test)]381379
#[cfg_attr(target_family = "wasm", wasm_bindgen_test::wasm_bindgen_test)]382
- async fn encrypted_store_roundtrips_inbox_context_read_state_and_checkpoints() {380
+ async fn encrypted_store_roundtrips_inbox_context_and_read_state() {383381
let config = config();384382
let data = saved_data();385383
let mut store = open(&config).await;@@ -402,13 +400,6 @@ mod persistence {
402400
let mut restored_data: Data = restored.read("inbox").unwrap().unwrap();403401
assert_eq!(restored.revision(), restored_data.revision);404402
assert!(restored_data.entries.values().next().unwrap().read);405
- assert_eq!(406
- restored_data407
- .checkpoints408
- .get("sync-connection")409
- .map(String::as_str),410
- Some("position-1")411
- );412403
let mention = restored_data.entries.values().next().unwrap().key.clone();413404
let (context, media) = restored_data.context(&mention).unwrap();414405
assert_eq!(@@ -425,9 +416,6 @@ mod persistence {
425416
let updated_entry = restored_data.entries.values_mut().next().unwrap();426417
updated_entry.read = false;427418
updated_entry.revision = 2;428
- restored_data429
- .checkpoints430
- .insert("sync-connection".to_owned(), "position-2".to_owned());431419
restored432420
.commit(433421
1,@@ -445,13 +433,6 @@ mod persistence {
445433
assert_eq!(restored.revision(), 2);446434
assert_eq!(restored_data.revision, 2);447435
assert!(!restored_data.entries.values().next().unwrap().read);448
- assert_eq!(449
- restored_data450
- .checkpoints451
- .get("sync-connection")452
- .map(String::as_str),453
- Some("position-2")454
- );455436
drop(restored);456437
config.delete().await.unwrap();457438
}@@ -62,6 +62,15 @@ impl Backend {
6262
}6363
}6464
65
+ pub(super) async fn reset(&self) -> Result<(), crate::Error> {66
+ let database = open_database(&self.name)67
+ .await68
+ .map_err(|error| super::storage_error("opening the mentions database", error))?;69
+ clear(&database)70
+ .await71
+ .map_err(|error| super::storage_error("clearing the mentions database", error))72
+ }73
+6574
pub(super) async fn commit(6675
&self,6776
expected: Option<Vec<u8>>,@@ -100,6 +109,16 @@ async fn open_database(name: &str) -> Result<DatabaseHandle, OpenDbError> {
100109
.map(DatabaseHandle)101110
}102111
112
+async fn clear(database: &Database) -> Result<(), IndexedDbError> {113
+ let transaction = database114
+ .transaction(RECORDS)115
+ .with_mode(TransactionMode::Readwrite)116
+ .build()?;117
+ transaction.object_store(RECORDS)?.clear()?.await?;118
+ transaction.commit().await?;119
+ Ok(())120
+}121
+103122
enum ReadError {104123
Invalid(&'static str),105124
Storage(IndexedDbError),@@ -17,7 +17,7 @@ mod indexeddb;
1717
#[cfg(feature = "indexeddb")]1818
use indexeddb::Backend;1919
20
-const SCHEMA: u32 = 1;20
+const SCHEMA: u32 = 2;2121
const MAX_RECORDS: usize = 35_128;2222
2323
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]@@ -61,6 +61,7 @@ pub(crate) struct MentionsStore {
6161
snapshot: Snapshot,6262
revision: u64,6363
max_bytes: usize,64
+ reset: bool,6465
}6566
6667
impl fmt::Debug for MentionsStore {@@ -74,71 +75,30 @@ impl fmt::Debug for MentionsStore {
7475
}7576
7677
impl MentionsStore {78
+ /// an unreadable, corrupt or foreign store is emptied rather than refused7779
pub(crate) async fn open(7880
config: &StoreConfig,7981
account: Account,8082
max_bytes: usize,8183
) -> Result<Self, Error> {8284
let backend = Backend::open(config.mentions_location()).await?;83
- let (cipher, snapshot, revision) = match backend.load().await? {84
- Some(snapshot) => {85
- validate_size(&snapshot, max_bytes)?;86
- let cipher = StoreCipher::import(config.passphrase(), &snapshot.exported)87
- .map_err(|error| storage_error("opening the mentions cipher", error))?;88
- let head: Head = cipher89
- .decrypt_value(&snapshot.head)90
- .map_err(|error| storage_error("opening the mentions header", error))?;91
- validate_account(head.schema, &head.account, &account)?;92
- if head.records != snapshot.rows.len() || head.bytes != row_bytes(&snapshot.rows)? {93
- return Err(invalid(94
- "mentions records do not match their committed header",95
- ));96
- }97
- for (key, encrypted) in &snapshot.rows {98
- let record: Record = cipher99
- .decrypt_value(encrypted)100
- .map_err(|error| storage_error("opening a mentions record", error))?;101
- validate_account(record.schema, &record.account, &account)?;102
- if storage_key(&cipher, &record.key) != *key {103
- return Err(invalid("a mentions record belongs to a different key"));104
- }105
- }106
- let revision = head.revision;107
- (cipher, snapshot, revision)108
- }109
- None => {110
- let cipher = StoreCipher::new()111
- .map_err(|error| storage_error("creating the mentions cipher", error))?;112
- let exported = cipher113
- .export(config.passphrase())114
- .map_err(|error| storage_error("sealing the mentions cipher", error))?;115
- let head = cipher116
- .encrypt_value(&Head {117
- schema: SCHEMA,118
- account: account.clone(),119
- revision: 0,120
- records: 0,121
- bytes: 0,122
- })123
- .map_err(|error| storage_error("sealing the mentions header", error))?;124
- let snapshot = Snapshot {125
- exported,126
- head,127
- rows: BTreeMap::new(),128
- };129
- validate_size(&snapshot, max_bytes)?;130
- backend131
- .commit(132
- None,133
- snapshot.exported.clone(),134
- snapshot.head.clone(),135
- BTreeMap::new(),136
- )137
- .await?;138
- (cipher, snapshot, 0)85
+ let loaded = match backend.load().await {86
+ Ok(Some(snapshot)) => restore(config, &account, snapshot, max_bytes).map(Some),87
+ Ok(None) => Ok(None),88
+ Err(error) => Err(error),89
+ };90
+ let (reset, restored) = match loaded {91
+ Ok(restored) => (false, restored),92
+ Err(error) => {93
+ tracing::warn!(%error, "resetting an unreadable mentions store");94
+ backend.reset().await?;95
+ (true, None)13996
}14097
};141
- validate_size(&snapshot, max_bytes)?;98
+ let (cipher, snapshot, revision) = match restored {99
+ Some(restored) => restored,100
+ None => create(&backend, config, &account, max_bytes).await?,101
+ };142102
Ok(Self {143103
backend,144104
cipher,@@ -146,13 +106,65 @@ impl MentionsStore {
146106
snapshot,147107
revision,148108
max_bytes,109
+ reset,149110
})150111
}151112
113
+ pub(crate) async fn reset(&mut self) -> Result<(), Error> {114
+ self.backend.reset().await?;115
+ let head = seal_head(&self.cipher, &self.account, 0, 0, 0)?;116
+ self.backend117
+ .commit(118
+ None,119
+ self.snapshot.exported.clone(),120
+ head.clone(),121
+ BTreeMap::new(),122
+ )123
+ .await?;124
+ self.snapshot.head = head;125
+ self.snapshot.rows.clear();126
+ self.revision = 0;127
+ self.reset = true;128
+ Ok(())129
+ }130
+131
+ pub(crate) fn was_reset(&self) -> bool {132
+ self.reset133
+ }134
+152135
pub(crate) fn revision(&self) -> u64 {153136
self.revision154137
}155138
139
+ pub(crate) fn available(&self, keys: &[&str]) -> usize {140
+ let replaced: Vec<_> = keys141
+ .iter()142
+ .map(|key| storage_key(&self.cipher, key))143
+ .collect();144
+ let others = self145
+ .snapshot146
+ .rows147
+ .iter()148
+ .filter(|(key, _)| !replaced.contains(key))149
+ .map(|(key, value)| key.len() + value.len())150
+ .sum::<usize>();151
+ let keys = replaced.iter().map(String::len).sum::<usize>();152
+ self.max_bytes153
+ .saturating_sub(others + keys + self.snapshot.exported.len() + self.snapshot.head.len())154
+ }155
+156
+ /// an upper bound on the sealed size of a record whose value encodes to `value_len`157
+ /// bytes, `StoreCipher` writes its ciphertext as a JSON array of decimal bytes, at158
+ /// most four characters each159
+ pub(crate) fn sealed_bound(&self, key: &str, value_len: usize) -> usize {160
+ let record = value_len161
+ + key.len()162
+ + self.account.homeserver.len()163
+ + self.account.user_id.as_str().len()164
+ + 128;165
+ (record + 16 + 24) * 4 + 64166
+ }167
+156168
pub(crate) fn read<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, Error> {157169
let Some(encrypted) = self.snapshot.rows.get(&storage_key(&self.cipher, key)) else {158170
return Ok(None);@@ -222,16 +234,7 @@ impl MentionsStore {
222234
records += 1;223235
}224236
}225
- let head = self226
- .cipher227
- .encrypt_value(&Head {228
- schema: SCHEMA,229
- account: self.account.clone(),230
- revision,231
- records,232
- bytes,233
- })234
- .map_err(|error| storage_error("sealing the mentions header", error))?;237
+ let head = seal_head(&self.cipher, &self.account, revision, records, bytes)?;235238
validate_budget(236239
bytes,237240
records,@@ -263,6 +266,83 @@ impl MentionsStore {
263266
}264267
}265268
269
+fn restore(270
+ config: &StoreConfig,271
+ account: &Account,272
+ snapshot: Snapshot,273
+ max_bytes: usize,274
+) -> Result<(StoreCipher, Snapshot, u64), Error> {275
+ validate_size(&snapshot, max_bytes)?;276
+ let cipher = StoreCipher::import(config.passphrase(), &snapshot.exported)277
+ .map_err(|error| storage_error("opening the mentions cipher", error))?;278
+ let head: Head = cipher279
+ .decrypt_value(&snapshot.head)280
+ .map_err(|error| storage_error("opening the mentions header", error))?;281
+ validate_account(head.schema, &head.account, account)?;282
+ if head.records != snapshot.rows.len() || head.bytes != row_bytes(&snapshot.rows)? {283
+ return Err(invalid(284
+ "mentions records do not match their committed header",285
+ ));286
+ }287
+ for (key, encrypted) in &snapshot.rows {288
+ let record: Record = cipher289
+ .decrypt_value(encrypted)290
+ .map_err(|error| storage_error("opening a mentions record", error))?;291
+ validate_account(record.schema, &record.account, account)?;292
+ if storage_key(&cipher, &record.key) != *key {293
+ return Err(invalid("a mentions record belongs to a different key"));294
+ }295
+ }296
+ Ok((cipher, snapshot, head.revision))297
+}298
+299
+async fn create(300
+ backend: &Backend,301
+ config: &StoreConfig,302
+ account: &Account,303
+ max_bytes: usize,304
+) -> Result<(StoreCipher, Snapshot, u64), Error> {305
+ let cipher =306
+ StoreCipher::new().map_err(|error| storage_error("creating the mentions cipher", error))?;307
+ let exported = cipher308
+ .export(config.passphrase())309
+ .map_err(|error| storage_error("sealing the mentions cipher", error))?;310
+ let head = seal_head(&cipher, account, 0, 0, 0)?;311
+ let snapshot = Snapshot {312
+ exported,313
+ head,314
+ rows: BTreeMap::new(),315
+ };316
+ validate_size(&snapshot, max_bytes)?;317
+ backend318
+ .commit(319
+ None,320
+ snapshot.exported.clone(),321
+ snapshot.head.clone(),322
+ BTreeMap::new(),323
+ )324
+ .await?;325
+ Ok((cipher, snapshot, 0))326
+}327
+328
+fn seal_head(329
+ cipher: &StoreCipher,330
+ account: &Account,331
+ revision: u64,332
+ records: usize,333
+ bytes: usize,334
+) -> Result<Vec<u8>, Error> {335
+ cipher336
+ .encrypt_value(&Head {337
+ schema: SCHEMA,338
+ account: account.clone(),339
+ revision,340
+ records,341
+ bytes,342
+ })343
+ .map_err(|error| storage_error("sealing the mentions header", error))344
+}345
+266346
fn validate_account(schema: u32, actual: &Account, expected: &Account) -> Result<(), Error> {267347
if schema != SCHEMA {268348
return Err(invalid("unsupported mentions schema"));@@ -21,7 +21,7 @@ pub(super) struct Backend {
2121
impl Backend {2222
pub(super) async fn open(path: PathBuf) -> Result<Self, Error> {2323
let setup_path = path.clone();24
- tokio::task::spawn_blocking(move || initialize(&setup_path))24
+ tokio::task::spawn_blocking(move || initialise(&setup_path))2525
.await2626
.map_err(|error| storage_error("opening the mentions store", error))??;2727
Ok(Self { path })@@ -34,6 +34,23 @@ impl Backend {
3434
.map_err(|error| storage_error("loading the mentions store", error))?3535
}3636
37
+ pub(super) async fn reset(&self) -> Result<(), Error> {38
+ let path = self.path.clone();39
+ tokio::task::spawn_blocking(move || {40
+ for file in [path.clone(), journal(&path)] {41
+ match fs::remove_file(&file) {42
+ Err(error) if error.kind() != io::ErrorKind::NotFound => {43
+ return Err(storage_error("removing the mentions database", error));44
+ }45
+ _ => {}46
+ }47
+ }48
+ initialise(&path)49
+ })50
+ .await51
+ .map_err(|error| storage_error("resetting the mentions store", error))?52
+ }53
+3754
pub(super) async fn commit(3855
&self,3956
expected: Option<Vec<u8>>,@@ -50,7 +67,7 @@ impl Backend {
5067
}5168
}5269
53
-fn initialize(path: &Path) -> Result<(), Error> {70
+fn initialise(path: &Path) -> Result<(), Error> {5471
let parent = path5572
.parent()5673
.filter(|parent| !parent.as_os_str().is_empty())@@ -65,7 +82,7 @@ fn initialize(path: &Path) -> Result<(), Error> {
6582
"CREATE TABLE IF NOT EXISTS records (key TEXT PRIMARY KEY, value BLOB NOT NULL)",6683
[],6784
)68
- .map_err(|error| storage_error("initializing the mentions database", error))?;85
+ .map_err(|error| storage_error("initialising the mentions database", error))?;6986
Ok(())7087
}7188
@@ -212,3 +229,9 @@ fn create_private_file(path: &Path) -> io::Result<()> {
212229
}213230
Ok(())214231
}232
+233
+fn journal(path: &Path) -> PathBuf {234
+ let mut journal = path.as_os_str().to_owned();235
+ journal.push("-journal");236
+ PathBuf::from(journal)237
+}@@ -3,7 +3,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
33
use ruma::user_id;44
use serde_json::{Value, json};55
6
-use super::{Account, Head, MentionsStore, Write, row_bytes, storage_key};6
+use super::{Account, Head, MentionsStore, SCHEMA, Write, row_bytes, storage_key};77
use crate::StoreConfig;88
99
#[cfg(target_family = "wasm")]@@ -244,7 +244,7 @@ async fn records_hide_content_keys_and_account_metadata() {
244244
245245
#[cfg_attr(not(target_family = "wasm"), tokio::test)]246246
#[cfg_attr(target_family = "wasm", wasm_bindgen_test::wasm_bindgen_test)]247
-async fn wrong_account_server_and_passphrase_cannot_open_the_store() {247
+async fn wrong_account_server_and_passphrase_reset_the_store() {248248
let config = config();249249
let store = open(&config).await;250250
drop(store);@@ -255,7 +255,8 @@ async fn wrong_account_server_and_passphrase_cannot_open_the_store() {
255255
assert!(256256
MentionsStore::open(&config, other, MAX_BYTES)257257
.await258
- .is_err()258
+ .unwrap()259
+ .was_reset()259260
);260261
let other_server = Account {261262
homeserver: "https://different.example/".to_owned(),@@ -264,7 +265,8 @@ async fn wrong_account_server_and_passphrase_cannot_open_the_store() {
264265
assert!(265266
MentionsStore::open(&config, other_server, MAX_BYTES)266267
.await267
- .is_err()268
+ .unwrap()269
+ .was_reset()268270
);269271
#[cfg(feature = "sqlite")]270272
let wrong_passphrase =@@ -274,7 +276,8 @@ async fn wrong_account_server_and_passphrase_cannot_open_the_store() {
274276
assert!(275277
MentionsStore::open(&wrong_passphrase, account(), MAX_BYTES)276278
.await277
- .is_err()279
+ .unwrap()280
+ .was_reset()278281
);279282
drop(open(&config).await);280283
config.delete().await.unwrap();@@ -282,16 +285,20 @@ async fn wrong_account_server_and_passphrase_cannot_open_the_store() {
282285
283286
#[cfg_attr(not(target_family = "wasm"), tokio::test)]284287
#[cfg_attr(target_family = "wasm", wasm_bindgen_test::wasm_bindgen_test)]285
-async fn ciphertext_corruption_and_record_swaps_are_rejected() {288
+async fn ciphertext_corruption_and_record_swaps_reset_the_store() {286289
let config = config();287
- let mut store = open(&config).await;288
- store289
- .commit(290
- 0,291
- vec![set("one", json!("first")), set("two", json!("other"))],292
- )293
- .await294
- .unwrap();290
+ let seeded = async || {291
+ let mut store = open(&config).await;292
+ store293
+ .commit(294
+ 0,295
+ vec![set("one", json!("first")), set("two", json!("other"))],296
+ )297
+ .await298
+ .unwrap();299
+ store300
+ };301
+ let store = seeded().await;295302
let one = storage_key(&store.cipher, "one");296303
let two = storage_key(&store.cipher, "two");297304
let mut swapped = store.snapshot.rows.clone();@@ -315,8 +322,13 @@ async fn ciphertext_corruption_and_record_swaps_are_rejected() {
315322
assert!(316323
MentionsStore::open(&config, account(), MAX_BYTES)317324
.await318
- .is_err()325
+ .unwrap()326
+ .was_reset()319327
);328
+ drop(store);329
+ let store = seeded().await;330
+ assert!(!store.was_reset());331
+ let one = storage_key(&store.cipher, "one");320332
let mut corrupt = store.snapshot.rows[&one].clone();321333
let middle = corrupt.len() / 2;322334
corrupt[middle] ^= 1;@@ -339,7 +351,8 @@ async fn ciphertext_corruption_and_record_swaps_are_rejected() {
339351
assert!(340352
MentionsStore::open(&config, account(), MAX_BYTES)341353
.await342
- .is_err()354
+ .unwrap()355
+ .was_reset()343356
);344357
drop(store);345358
config.delete().await.unwrap();@@ -347,7 +360,7 @@ async fn ciphertext_corruption_and_record_swaps_are_rejected() {
347360
348361
#[cfg_attr(not(target_family = "wasm"), tokio::test)]349362
#[cfg_attr(target_family = "wasm", wasm_bindgen_test::wasm_bindgen_test)]350
-async fn unsupported_schema_fails_without_resetting_data() {363
+async fn unsupported_schema_resets_the_store() {351364
let config = config();352365
let mut store = open(&config).await;353366
store@@ -357,7 +370,7 @@ async fn unsupported_schema_fails_without_resetting_data() {
357370
let header = store358371
.cipher359372
.encrypt_value(&Head {360
- schema: 2,373
+ schema: SCHEMA + 1,361374
account: account(),362375
revision: 1,363376
records: store.snapshot.rows.len(),@@ -377,9 +390,10 @@ async fn unsupported_schema_fails_without_resetting_data() {
377390
assert!(378391
MentionsStore::open(&config, account(), MAX_BYTES)379392
.await380
- .is_err()393
+ .unwrap()394
+ .was_reset()381395
);382
- assert_eq!(store.backend.load().await.unwrap().unwrap().head, header);396
+ assert_ne!(store.backend.load().await.unwrap().unwrap().head, header);383397
drop(store);384398
config.delete().await.unwrap();385399
}@@ -45,40 +45,42 @@ pub struct MentionEntry {
4545
pub notification_suppressed: bool,4646
}4747
48
-#[derive(Clone, Debug, Eq, PartialEq)]48
+#[derive(Clone, Debug, Default, Eq, PartialEq)]4949
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]5050
pub struct MentionCoverage {5151
pub earliest_timestamp_ms: Option<u64>,5252
pub historical_supported: Option<bool>,5353
pub recovering: bool,54
- pub incomplete_history: bool,5554
pub gaps: Vec<MentionGap>,5655
pub truncated: bool,5756
pub unresolved_candidates: usize,5857
}5958
60
-impl Default for MentionCoverage {61
- fn default() -> Self {62
- Self {63
- earliest_timestamp_ms: None,64
- historical_supported: None,65
- recovering: false,66
- incomplete_history: true,67
- gaps: Vec::new(),68
- truncated: false,69
- unresolved_candidates: 0,70
- }71
- }72
-}73
-7459
#[derive(Clone, Debug, Eq, PartialEq)]7560
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]7661
pub struct MentionGap {7762
pub room_id: Option<OwnedRoomId>,78
- pub reason: String,63
+ pub reason: MentionGapReason,7964
pub token: Option<String>,8065
}8166
67
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]68
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]69
+pub enum MentionGapReason {70
+ SyncDiscontinuity,71
+ /// an already observed room came back limited, `token` is its `prev_batch`72
+ LimitedTimeline,73
+ RoomLeft,74
+ ContextUnavailable,75
+ NotificationHistoryUnavailable,76
+ /// a candidate stayed undecryptable or unresolved for a week and was dropped77
+ CandidateExpired,78
+ StoreReset,79
+ /// a write failed, the inbox carries on in memory and a restart loses the unwritten part80
+ StoreFailed,81
+ HookTimedOut,82
+}83
+8284
#[derive(Clone, Debug, Eq, PartialEq)]8385
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]8486
pub struct MentionsBoundary {@@ -117,6 +119,7 @@ pub struct MentionsUpdate {
117119
118120
#[derive(Clone, Debug, Eq, PartialEq)]119121
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]122
+#[non_exhaustive]120123
pub enum MentionsCommand {121124
Page { cursor: Option<String>, limit: u16 },122125
Context(MentionKey),