atagen

macltok

public
Star0
Watch0

Commit 7cdaa86b9489

tarball

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

12 files changed+1269-565
mentions: write the sync checkpoint apart from the inbox and never fail sync

Every response used to serialise, encrypt and commit the whole inbox inside the sync hook, and any failure there stopped room list sync or Account::build. The checkpoint, observed rooms and repair coverage now live in a small sync record written with every response, the inbox body only when it changed, both in one head compare-and-swap commit. Write failures, hook timeouts and unreadable stores become typed MentionGapReason gaps while the inbox carries on in memory, and a replay from the previous checkpoint is a superset rather than a discontinuity. Background completions are batched per tick, candidates expire after a week, decryption reports retry only their room, and mentions in a room left within the same response still classify.
Modified file: src/account.rs+1 additions−2 deletions
@@ -859,8 +859,7 @@ impl Session {
859859 account,
860860 config.max_mentions_bytes,
861861 )
862- .await
863- .map_err(|error| account_error("opening the mentions store", error))?;
862+ .await;
864863 Engine::with_persistence(
865864 self.client,
866865 config,
Modified file: src/engine/mod.rs+1 additions−1 deletions
@@ -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);
Modified file: src/lib.rs+3 additions−2 deletions
@@ -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::{
Modified file: src/mentions/service.rs+584 additions−309 deletions
@@ -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 sync
79101 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- handle
86- .0
87- .send(Request::Batch(batch, reply))
88- .await
89- .map_err(|_| "mentions service stopped".to_owned())?;
90- response
91- .await
92- .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+ handle
109+ .requests
110+ .send(Request::Batch(batch, reply))
111+ .await
112+ .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.0
130+ self.requests
101131 .send(Request::Command(command, reply))
102132 .await
103133 .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.0
139+ self.requests
110140 .send(Request::GetRepairPolicy(reply))
111141 .await
112142 .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.0
148+ self.requests
119149 .send(Request::SetRepairPolicy(policy, reply))
120150 .await
121151 .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.0
160+ self.requests
131161 .send(Request::GetRepairCoverage(room_id, reply))
132162 .await
133163 .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.0
169+ self.requests
140170 .send(Request::RepairResolved(room_id, reply))
141171 .await
142172 .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 = store
172- .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 store
181- .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 = self
222- .data
223- .revision
224- .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 store
242- .commit(
243- self.data.revision,
244- vec![super::store::Write::Set("inbox".to_owned(), value)],
245- )
246- .await
247- {
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.client
323+ .account()
324+ .account_data::<IgnoredUserListEventContent>()
325+ .await
326+ .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.media
259338 .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 a
350+ /// checkpoint never lands without the inbox changes it covers
351+ 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 = store
371+ .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 = self
397+ .data
398+ .revision
399+ .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+ result
426+ }
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).await
439+ self.apply(Some(next), None).await
275440 }
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).await
449+ sync.observed_rooms.insert(room_id);
450+ self.apply(None, Some(sync)).await
286451 }
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 !data
381- .coverage
382- .gaps
383- .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.client
546+ .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 checkpoint
553+ .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 the
567+ // previous position, and that response is a superset of the one committed
568+ 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.previous
573+ }
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_rooms
434593 .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 !self
440- .client
441- .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_coverage
628+ sync.repair_coverage
474629 .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 = batch
634+ .response
635+ .rooms
636+ .joined
637+ .get(room_id)
638+ .map(|update| &update.timeline.events)
639+ .into_iter()
640+ .chain(
641+ batch
642+ .response
643+ .rooms
644+ .left
645+ .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.candidates
503666 .retain(|_, candidate| &candidate.room_id != room_id);
504- for entry in next
505- .entries
506- .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::Unavailable
670+ }) {
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).await
530711 }
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<_> = next
724+ .entries
725+ .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.candidates
827+ .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.candidates
859+ .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.candidates
701870 .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.account
863- || self.client.is_user_ignored(&sender).await
1026+ || self.ignored.contains(&sender)
8641027 || value["type"] != "m.room.message"
8651028 {
8661029 return (vec![], false, false);
8671030 }
868- let Some(room) = self
869- .client
870- .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 write
1126+ 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 = result
1162+ .as_ref()
1163+ .ok()
1164+ .map(|(_, _, observed)| observed.clone())
1165+ .unwrap_or_default();
1166+ let room_id = next
1167+ .entries
1168+ .get(&key)
1169+ .filter(|entry| entry.revision == revision)
1170+ .map(|entry| entry.key.room_id.clone())
1171+ .filter(|room_id| {
1172+ self.client
1173+ .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.events
1184+ .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::Forbidden
1211+ ) || error
1212+ .server
1213+ .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(&notification.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, &notification.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) = notification
1241+ .event
1242+ .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 self
1256+ .client
1257+ .get_room(&notification.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, &notification.room_id, value).await;
1263+ }
1264+ }
1265+ let token = page
1266+ .next_token
1267+ .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 error
1282+ .server
1283+ .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 error
1290+ .server
1291+ .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 > 0
1306+ && (self.latest_scan || !next.history_exhausted)
1307+ && next.coverage.historical_supported != Some(false);
10341308 }
10351309 }
1310+ true
10361311 }
10371312
10381313 fn schedule(
Modified file: src/mentions/service_tests.rs+264 additions−87 deletions
@@ -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- .await
102- .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- .await
111- .unwrap();
118+ let (_, mut service, _) = Service::new(client.clone(), media, store, EngineConfig::default())
119+ .await
120+ .unwrap();
121+ service.recovery_budget = 0;
122+ service.latest_scan = false;
123+ service
124+}
125+
126+fn rows(connection: &Connection) -> BTreeMap<String, Vec<u8>> {
127+ let mut statement = connection
128+ .prepare("SELECT key, value FROM records WHERE key NOT IN ('!cipher', '!head')")
129+ .unwrap();
130+ statement
131+ .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+ service
139+ .data
140+ .coverage
141+ .gaps
142+ .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+ .await
155+ .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- .await
130- .unwrap();
131- assert_eq!(reopened.revision(), 0);
132- assert!(reopened.read::<Data>("inbox").unwrap().is_none());
133- drop(reopened);
166+ service
167+ .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+ service
179+ .data
180+ .coverage
181+ .gaps
182+ .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 connection
136189 .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+ .await
201+ .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+ .await
220+ .unwrap();
221+ let mut service = service(&client, Some(Ok(store))).await;
222+ service
223+ .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+ before
240+ .iter()
241+ .filter(|(key, value)| after.get(*key) != Some(value))
242+ .count(),
243+ 1
244+ );
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+ .await
263+ .unwrap();
264+ let mut first = service(&client, Some(Ok(store))).await;
265+ first
266+ .batch(mention_batch(room_id, &own_user, "one", None, "position-1"))
267+ .await;
268+ first
269+ .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-1
280+ let store = MentionsStore::open(&config, account(&client), MAX_BYTES)
154281 .await
155282 .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+ restarted
285+ .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+ restarted
304+ .batch(quiet_batch("position-3", "position-4"))
305+ .await;
306+ assert_eq!(discontinuities(&restarted), 0);
307+ restarted
308+ .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+ .await
323+ .unwrap();
324+ let mut first = service(&client, Some(Ok(store))).await;
325+ first
326+ .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+ connection
331+ .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+ .await
340+ .unwrap();
341+ let restarted = service(&client, Some(Ok(store))).await;
342+ assert!(restarted.data.entries.is_empty());
343+ assert!(
344+ restarted
345+ .data
346+ .coverage
347+ .gaps
348+ .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- .await
202- .unwrap();
369+ let (handle, mut service, _) = Service::new(
370+ client.clone(),
371+ media,
372+ Some(Ok(store)),
373+ EngineConfig::default(),
374+ )
375+ .await
376+ .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 .await
250425 .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 service
257- .data
435+ .sync
258436 .repair_coverage
259437 .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- .await
378- .unwrap();
553+ let (handle, service, mut updates) = Service::new(
554+ client.clone(),
555+ media,
556+ Some(Ok(store)),
557+ EngineConfig::default(),
558+ )
559+ .await
560+ .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 .await
399581 .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- service
476- .observe(&mut data, &room_id, following)
477- .await
478- .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- service
546- .observe(&mut data, &room_id, redaction)
547- .await
548- .unwrap();
725+ service.observe(&mut data, &room_id, redaction).await;
549726
550727 let saved_following = data.events.get(&following_id).unwrap();
551728 assert!(matches!(
Modified file: src/mentions/state.rs+166 additions−34 deletions
@@ -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.item
25+ && self.raw == other.raw
26+ && self
27+ .media
28+ .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.0
154+}
155+
156+#[cfg(not(feature = "serde"))]
157+pub(super) fn json_len<T>(_: &T) -> usize {
158+ 0
159+}
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 self
173+ .coverage
174+ .gaps
175+ .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<_> = self
193+ .candidates
194+ .iter()
195+ .filter(|(_, candidate)| {
196+ now_ms.saturating_sub(candidate.first_seen_ms) > CANDIDATE_LIFETIME_MS
197+ })
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+ keep
340+ });
223341 self.capture_attempts
224342 .retain(|key, _| self.entries.contains_key(key));
343+ freed
225344 }
226345
227- pub fn evict_one(&mut self) -> bool {
346+ /// returns roughly how many plaintext JSON bytes the eviction freed
347+ 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.windows
234354 .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) = self
248369 .entries
249370 .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) = self
376+ .candidates
377+ .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() + 24
388+ };
263389 self.coverage.truncated = true;
264- self.collect_events();
265- true
390+ 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<_> = self
410+ .candidates
411+ .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] {
Modified file: src/mentions/state_tests.rs+3 additions−22 deletions
@@ -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.checkpoints
369- .insert("sync-connection".to_owned(), "position-1".to_owned());
370368 data.history_cursor = Some("history-position".to_owned());
371369 data
372370 }
@@ -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_data
407- .checkpoints
408- .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_data
429- .checkpoints
430- .insert("sync-connection".to_owned(), "position-2".to_owned());
431419 restored
432420 .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_data
450- .checkpoints
451- .get("sync-connection")
452- .map(String::as_str),
453- Some("position-2")
454- );
455436 drop(restored);
456437 config.delete().await.unwrap();
457438 }
Modified file: src/mentions/store/indexeddb.rs+19 additions
@@ -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+ .await
68+ .map_err(|error| super::storage_error("opening the mentions database", error))?;
69+ clear(&database)
70+ .await
71+ .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 = database
114+ .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),
Modified file: src/mentions/store/mod.rs+148 additions−68 deletions
@@ -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 refused
7779 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 = cipher
89- .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 = cipher
99- .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 = cipher
113- .export(config.passphrase())
114- .map_err(|error| storage_error("sealing the mentions cipher", error))?;
115- let head = cipher
116- .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- backend
131- .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.backend
117+ .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.reset
133+ }
134+
152135 pub(crate) fn revision(&self) -> u64 {
153136 self.revision
154137 }
155138
139+ pub(crate) fn available(&self, keys: &[&str]) -> usize {
140+ let replaced: Vec<_> = keys
141+ .iter()
142+ .map(|key| storage_key(&self.cipher, key))
143+ .collect();
144+ let others = self
145+ .snapshot
146+ .rows
147+ .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_bytes
153+ .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, at
158+ /// most four characters each
159+ pub(crate) fn sealed_bound(&self, key: &str, value_len: usize) -> usize {
160+ let record = value_len
161+ + key.len()
162+ + self.account.homeserver.len()
163+ + self.account.user_id.as_str().len()
164+ + 128;
165+ (record + 16 + 24) * 4 + 64
166+ }
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 = self
226- .cipher
227- .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 = cipher
279+ .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 = cipher
289+ .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 = cipher
308+ .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+ backend
318+ .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+ cipher
336+ .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"));
Modified file: src/mentions/store/sqlite.rs+26 additions−3 deletions
@@ -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 .await
2626 .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+ .await
51+ .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 = path
5572 .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+}
Modified file: src/mentions/store/tests.rs+34 additions−20 deletions
@@ -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 .await
258- .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 .await
267- .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 .await
277- .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- store
289- .commit(
290- 0,
291- vec![set("one", json!("first")), set("two", json!("other"))],
292- )
293- .await
294- .unwrap();
290+ let seeded = async || {
291+ let mut store = open(&config).await;
292+ store
293+ .commit(
294+ 0,
295+ vec![set("one", json!("first")), set("two", json!("other"))],
296+ )
297+ .await
298+ .unwrap();
299+ store
300+ };
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 .await
318- .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 .await
342- .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 = store
358371 .cipher
359372 .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 .await
380- .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 }
Modified file: src/mentions_model.rs+20 additions−17 deletions
@@ -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 dropped
77+ CandidateExpired,
78+ StoreReset,
79+ /// a write failed, the inbox carries on in memory and a restart loses the unwritten part
80+ 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),