atagen

macltok

public
Star0
Watch0

Commit 82d37c369846

tarball

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

12 files changed+705-266
mentions: keep the sync record small and gaps honest

A failed command write left the in-memory inbox_revision ahead of the inbox, so the next sync-only commit made the following start reset everything. inbox_revision is now taken from the inbox at write time, observed rooms move to their own record and drop out on leave, and whether a response or a background completion changed anything is decided by comparing only the rooms it touched instead of cloning the whole inbox. HookTimedOut now means a response was dropped on a full queue, StoreFailed clears once a write lands, and a busy store or one from a newer schema runs in memory and is retried or left alone rather than wiped.
Modified file: Cargo.toml+1 additions−1 deletions
@@ -33,7 +33,7 @@ matrix-sdk-store-encryption = { git = "ssh://brewery@pls.lobotomise.me:33/atagen
3333 matrix-sdk-ui = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "b04108dd83394442231b7418d017ec30af791676", default-features = false }
3434 ruma = { version = "0.16.0", default-features = false, features = ["unstable-msc2545", "unstable-msc4095"] }
3535 serde = { version = "1.0.219", features = ["derive", "rc"], optional = true }
36-serde_json = "1.0.143"
36+serde_json = { version = "1.0.143", features = ["raw_value"] }
3737 thiserror = "2.0.16"
3838 mime = "0.3.17"
3939 sha2 = "0.10.9"
Modified file: docs/mentions-discovery.md+28 additions−18 deletions
@@ -102,9 +102,11 @@ records a `SyncDiscontinuity` gap.
102102
103103 The hook never fails sync. A failed write keeps the inbox in memory, records a
104104 `StoreFailed` gap, acks, and retries the write a minute later, so a restart
105-before a write lands shows up as a discontinuity. The hook also stops waiting
106-after ten seconds and records `HookTimedOut`, so a stuck actor cannot hold the
107-position lock.
105+before a write lands shows up as a discontinuity. The gap goes again once a
106+later write lands, since nothing was lost. The hook gives up after ten seconds
107+waiting for room in the actor's queue, drops the response and records
108+`HookTimedOut`. Once queued, it waits another ten seconds for the commit and
109+then acks regardless, because a queued response still commits in order.
108110
109111 Sources are `crates/matrix-sdk/src/event_handler/mod.rs`,
110112 `crates/matrix-sdk/src/event_handler/static_events.rs`,
@@ -198,9 +200,11 @@ mentions in muted rooms and marks notification delivery as suppressed. Room-wide
198200 reasons require the relevant push conditions and sender power level. Missing
199201 authorisation context remains unresolved for reevaluation. An event that
200202 notifies and highlights with no other reason, such as a loud keyword, enters the
201-inbox as `Keyword`. `add_notification_keyword` gives keyword rules a highlight
202-tweak, and upgrades an existing rule for the pattern that lacks one, since the
203-SDK's keyword rules only notify and play a sound. The classifier excludes the signed-in
203+inbox as `Keyword`. The SDK's keyword rules only notify and play a sound, so
204+`add_notification_keyword` reads the content rule the SDK names after the
205+keyword and adds a highlight tweak when it lacks one, leaving its other actions
206+alone. A rule the user made quiet stays quiet apart from the highlight. A
207+change of reason alone keeps an entry's saved context. The classifier excludes the signed-in
204208 user's own events even when they contain explicit mention metadata.
205209
206210 The existing `NotificationClient` combines notification evaluation with
@@ -260,10 +264,13 @@ inbox commits and discovery checkpoints.
260264
261265 The passphrase wraps a random SDK `StoreCipher`. Logical keys are hashed, and
262266 authenticated encrypted records include schema, account identity, and logical
263-key. Restore treats mismatched accounts, unsupported schemas, corrupt records,
264-and inconsistent record counts or byte totals as unreadable, empties the store
265-and records a `StoreReset` gap rather than failing `Account::build`. A store
266-that cannot be opened at all leaves the inbox in memory behind `StoreFailed`.
267+key. Restore treats mismatched accounts, older schemas, corrupt records, and
268+inconsistent record counts or byte totals as unreadable, empties the store and
269+records a `StoreReset` gap rather than failing `Account::build`. A busy or
270+failing store and one written by a newer macltok are left alone, and the inbox
271+runs in memory behind `StoreFailed`. The busy or failing case is retried every
272+minute, and when it opens, what it held and what was built in memory since are
273+merged.
267274 Atomic transactions compare the previous encrypted header before publishing a
268275 new revision, so stale writers cannot overwrite a newer checkpoint.
269276
@@ -282,15 +289,18 @@ writes, replay from the previous checkpoint, reset of an unreadable inbox,
282289 notification recovery, live context updates, and capture persistence across
283290 session resume.
284291
285-The store holds two records. `inbox` carries entries, saved events, context
292+The store holds three records. `inbox` carries entries, saved events, context
286293 windows, candidates, edits, redactions, coverage and recovery cursors. `sync`
287-carries the connection checkpoints, observed rooms and the inbox
288-revision it belongs to. Every response writes `sync`, and
289-`inbox` only when the response changed it, both in one commit. A quiet
290-response therefore costs one small record, while a changed inbox is still
291-reserialised and re-encrypted whole. Background completions are applied in
292-memory and written together at most once a second. When the budget is short,
293-eviction runs against a size estimate and the inbox is written once.
294+carries the connection checkpoints and the inbox revision it belongs to.
295+`rooms` carries the rooms already observed, which drop out when left. Every
296+response writes `sync`, and `inbox` or `rooms` only when the response changed
297+them, all in one commit. Whether the inbox changed is decided by comparing the
298+rooms the response touched, so a quiet response costs those rooms and one small
299+record, while a changed inbox is still reserialised and re-encrypted whole, in
300+one serialisation pass. Background completions are applied in memory and
301+written together at most once a second, and published on that tick even while
302+writes back off. When the budget is short, eviction runs against a size
303+estimate and the inbox is written once.
294304 Decrypted previews, saved context, raw candidates, pending capture state,
295305 media source registrations, read state, coverage, and sync checkpoints stay
296306 inside the authenticated encrypted payloads. SDK SQLite custom values remain plaintext and are not used
Modified file: src/account.rs+14 additions−3 deletions
@@ -854,12 +854,23 @@ impl Session {
854854 .ok_or(AccountError::MissingSession)?
855855 .to_owned(),
856856 };
857- let mentions = crate::mentions::store::MentionsStore::open(
857+ let opener = crate::mentions::store::Opener::new(
858858 self.tracking.store.store(),
859859 account,
860860 config.max_mentions_bytes,
861- )
862- .await;
861+ );
862+ let mentions = match opener.open().await {
863+ Ok(store) => Ok(store),
864+ Err(crate::mentions::store::OpenError::Unavailable(error)) => {
865+ Err(crate::mentions::service::Unopened {
866+ error,
867+ retry: Some(opener),
868+ })
869+ }
870+ Err(crate::mentions::store::OpenError::Newer(error)) => {
871+ Err(crate::mentions::service::Unopened { error, retry: None })
872+ }
873+ };
863874 Engine::with_persistence(
864875 self.client,
865876 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<Result<MentionsStore, crate::Error>>,
349+ mentions: Option<Result<MentionsStore, crate::mentions::service::Unopened>>,
350350 ) -> Result<(Self, ClientHandle, EventStream), EngineError> {
351351 if client.user_id().is_none() {
352352 return Err(EngineError::Unauthenticated);
Modified file: src/mentions/service.rs+270 additions−130 deletions
@@ -25,6 +25,8 @@ use serde_json::Value;
2525 use tokio::sync::{mpsc, oneshot, watch};
2626
2727 use super::state::{Candidate, Checkpoint, Data, SavedEvent, SyncState, Window, id, invalid, now};
28+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]
29+use super::store::Opener;
2830 use crate::{
2931 CommandOutput, EngineConfig, Error, ErrorKind, Event, MentionCaptureState, MentionEntry,
3032 MentionGapReason, MentionKey, MentionReason, MentionsCommand, MentionsPage, MentionsUpdate,
@@ -41,6 +43,38 @@ mod tests;
4143 const HOOK_TIMEOUT: Duration = Duration::from_secs(10);
4244 const FLUSH_RETRY_MS: u64 = 60_000;
4345
46+#[cfg_attr(not(any(feature = "sqlite", feature = "indexeddb")), allow(dead_code))]
47+pub(crate) struct Unopened {
48+ pub(crate) error: Error,
49+ /// set when another attempt may succeed
50+ #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
51+ pub(crate) retry: Option<Opener>,
52+}
53+
54+struct Loaded {
55+ store: Option<MentionsStore>,
56+ #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
57+ reopen: Option<Opener>,
58+ data: Data,
59+ sync: SyncState,
60+ rooms: BTreeSet<OwnedRoomId>,
61+ gaps: Vec<MentionGapReason>,
62+}
63+
64+impl Loaded {
65+ fn empty(gaps: Vec<MentionGapReason>) -> Self {
66+ Self {
67+ store: None,
68+ #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
69+ reopen: None,
70+ data: Data::default(),
71+ sync: SyncState::default(),
72+ rooms: BTreeSet::new(),
73+ gaps,
74+ }
75+ }
76+}
77+
4478 #[derive(Clone)]
4579 pub(crate) struct Handle {
4680 requests: mpsc::Sender<Request>,
@@ -63,9 +97,14 @@ pub(crate) struct Service {
6397 /// as many media as the inbox keeps events
6498 media_scope: MediaScope,
6599 store: Option<MentionsStore>,
100+ #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
101+ reopen: Option<Opener>,
66102 data: Data,
67103 sync: SyncState,
104+ rooms: BTreeSet<OwnedRoomId>,
68105 dirty: bool,
106+ rooms_dirty: bool,
107+ unpublished: bool,
69108 flush_after: u64,
70109 ignored: BTreeSet<OwnedUserId>,
71110 leaving: BTreeSet<OwnedRoomId>,
@@ -92,6 +131,24 @@ enum Work {
92131 ),
93132 }
94133
134+impl Work {
135+ fn rooms(&self) -> BTreeSet<OwnedRoomId> {
136+ let room = |key: &str| {
137+ key.split_once('\n')
138+ .and_then(|(room, _)| OwnedRoomId::try_from(room).ok())
139+ };
140+ match self {
141+ Self::Capture(key, ..) | Self::Candidate(key, _) => room(key).into_iter().collect(),
142+ Self::History(_, _, Ok(page)) => page
143+ .notifications
144+ .iter()
145+ .map(|notification| notification.room_id.clone())
146+ .collect(),
147+ Self::History(..) => BTreeSet::new(),
148+ }
149+ }
150+}
151+
95152 impl Handle {
96153 /// never fails, a mentions failure becomes a coverage gap rather than a stalled sync
97154 pub(crate) fn hook(&self) -> ResponseCommitHook {
@@ -99,23 +156,29 @@ impl Handle {
99156 ResponseCommitHook::new(move |batch| {
100157 let handle = handle.clone();
101158 async move {
102- let committed = async {
103- let (reply, response) = oneshot::channel();
104- handle
105- .requests
106- .send(Request::Batch(batch, reply))
107- .await
108- .ok()?;
109- response.await.ok()
110- };
111- match matrix_sdk::timeout::timeout(committed, HOOK_TIMEOUT).await {
112- Ok(Some(())) => {}
113- Ok(None) => tracing::warn!("mentions service stopped, sync carries on"),
159+ let (reply, response) = oneshot::channel();
160+ let queued = handle.requests.send(Request::Batch(batch, reply));
161+ match matrix_sdk::timeout::timeout(queued, HOOK_TIMEOUT).await {
162+ Ok(Ok(())) => {}
163+ Ok(Err(_)) => {
164+ tracing::warn!("mentions service stopped, sync carries on");
165+ return Ok(());
166+ }
114167 Err(_) => {
115168 handle.timed_out.store(true, Ordering::Release);
116- tracing::warn!("mentions commit timed out, sync carries on");
169+ tracing::warn!("mentions queue stayed full, a response was dropped");
170+ return Ok(());
117171 }
118172 }
173+ // a queued batch still commits in order, so giving up here loses nothing
174+ // unless the process dies first, which the next start sees as a
175+ // discontinuity
176+ if matrix_sdk::timeout::timeout(response, HOOK_TIMEOUT)
177+ .await
178+ .is_err()
179+ {
180+ tracing::warn!("mentions commit is slow, sync carries on");
181+ }
119182 Ok(())
120183 }
121184 })
@@ -139,52 +202,40 @@ impl Handle {
139202 }
140203
141204 #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
142-async fn open_store(
143- store: Option<Result<MentionsStore, Error>>,
144-) -> (
145- Option<MentionsStore>,
146- Data,
147- SyncState,
148- Vec<MentionGapReason>,
149-) {
205+fn read_store(store: &MentionsStore) -> Result<(Data, SyncState, BTreeSet<OwnedRoomId>), Error> {
206+ let data: Data = store.read("inbox")?.unwrap_or_default();
207+ let sync: SyncState = store.read("sync")?.unwrap_or_default();
208+ let rooms = store.read("rooms")?.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, rooms))
216+}
217+
218+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]
219+async fn open_store(store: Option<Result<MentionsStore, Unopened>>) -> Loaded {
150220 let mut store = match store {
151- None => return (None, Data::default(), SyncState::default(), Vec::new()),
152- Some(Err(error)) => {
153- tracing::error!(%error, "mentions store unavailable, the inbox runs in memory");
154- return (
155- None,
156- Data::default(),
157- SyncState::default(),
158- vec![MentionGapReason::StoreFailed],
159- );
221+ None => return Loaded::empty(Vec::new()),
222+ Some(Err(unopened)) => {
223+ tracing::error!(error = %unopened.error, "mentions store unavailable, the inbox runs in memory");
224+ let mut loaded = Loaded::empty(vec![MentionGapReason::StoreFailed]);
225+ loaded.reopen = unopened.retry;
226+ return loaded;
160227 }
161228 Some(Ok(store)) => store,
162229 };
163- let loaded = (|| {
164- let data: Data = store.read("inbox")?.unwrap_or_default();
165- let sync: SyncState = store.read("sync")?.unwrap_or_default();
166- if sync.inbox_revision != data.revision {
167- return Err(Error::new(
168- ErrorKind::Store,
169- "mentions checkpoint does not match its inbox",
170- ));
171- }
172- Ok((data, sync))
173- })();
174- let (data, sync) = match loaded {
230+ let (data, sync, rooms) = match read_store(&store) {
175231 Ok(loaded) => loaded,
176232 Err(error) => {
177233 tracing::warn!(%error, "resetting an unreadable mentions inbox");
178234 if let Err(error) = store.reset().await {
179235 tracing::error!(%error, "mentions store unavailable, the inbox runs in memory");
180- return (
181- None,
182- Data::default(),
183- SyncState::default(),
184- vec![MentionGapReason::StoreFailed],
185- );
236+ return Loaded::empty(vec![MentionGapReason::StoreFailed]);
186237 }
187- (Data::default(), SyncState::default())
238+ Default::default()
188239 }
189240 };
190241 let gaps = if store.was_reset() {
@@ -192,31 +243,30 @@ async fn open_store(
192243 } else {
193244 Vec::new()
194245 };
195- (Some(store), data, sync, gaps)
246+ Loaded {
247+ store: Some(store),
248+ reopen: None,
249+ data,
250+ sync,
251+ rooms,
252+ gaps,
253+ }
196254 }
197255
198256 #[cfg(not(any(feature = "sqlite", feature = "indexeddb")))]
199-async fn open_store(
200- store: Option<Result<MentionsStore, Error>>,
201-) -> (
202- Option<MentionsStore>,
203- Data,
204- SyncState,
205- Vec<MentionGapReason>,
206-) {
207- let gaps = match store {
208- None => Vec::new(),
209- Some(Err(_)) => vec![MentionGapReason::StoreFailed],
257+async fn open_store(store: Option<Result<MentionsStore, Unopened>>) -> Loaded {
258+ match store {
259+ None => Loaded::empty(Vec::new()),
260+ Some(Err(_)) => Loaded::empty(vec![MentionGapReason::StoreFailed]),
210261 Some(Ok(store)) => match store {},
211- };
212- (None, Data::default(), SyncState::default(), gaps)
262+ }
213263 }
214264
215265 impl Service {
216266 pub(crate) async fn new(
217267 client: Client,
218268 media: Arc<MediaService>,
219- store: Option<Result<MentionsStore, Error>>,
269+ store: Option<Result<MentionsStore, Unopened>>,
220270 config: EngineConfig,
221271 ) -> Result<(Handle, Self, watch::Receiver<MentionsPage>), Error> {
222272 let account = client
@@ -228,18 +278,18 @@ impl Service {
228278 )
229279 })?
230280 .to_owned();
231- let (store, mut data, sync, gaps) = open_store(store).await;
232- for reason in &gaps {
233- data.gap(None, *reason, None);
281+ let mut loaded = open_store(store).await;
282+ for reason in &loaded.gaps {
283+ loaded.data.gap(None, *reason, None);
234284 }
235- let page = data.page(&account, None, 50)?;
285+ let page = loaded.data.page(&account, None, 50)?;
236286 let media_scope = MediaScope::bounded(config.max_mention_context_events);
237287 media
238288 .register(
239289 &media_scope,
240290 page.entries
241291 .iter()
242- .filter_map(|entry| data.events.get(&id(&entry.key)))
292+ .filter_map(|entry| loaded.data.events.get(&id(&entry.key)))
243293 .flat_map(|event| event.media.clone())
244294 .collect(),
245295 )
@@ -252,10 +302,15 @@ impl Service {
252302 account,
253303 media,
254304 media_scope,
255- store,
256- data,
257- sync,
258- dirty: !gaps.is_empty(),
305+ store: loaded.store,
306+ #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
307+ reopen: loaded.reopen,
308+ data: loaded.data,
309+ sync: loaded.sync,
310+ rooms: loaded.rooms,
311+ dirty: !loaded.gaps.is_empty(),
312+ rooms_dirty: false,
313+ unpublished: false,
259314 flush_after: 0,
260315 ignored: BTreeSet::new(),
261316 leaving: BTreeSet::new(),
@@ -291,6 +346,7 @@ impl Service {
291346 }
292347
293348 async fn publish(&mut self) {
349+ self.unpublished = false;
294350 let Ok(page) = self.data.page(&self.account, None, 50) else {
295351 return;
296352 };
@@ -307,15 +363,15 @@ impl Service {
307363 self.updates.send_replace(page);
308364 }
309365
310- /// writes the sync record, and the inbox with it when `dirty`, in one commit so a
311- /// checkpoint never lands without the inbox changes it covers
366+ /// writes the sync record, with the inbox when `dirty` and observed rooms when
367+ /// `rooms_dirty`, in one commit so a checkpoint never lands without the changes it
368+ /// covers
312369 async fn flush(&mut self) -> Result<(), Error> {
313370 if self.dirty {
314371 self.advance()?;
315372 }
316373 #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
317374 if let Some(store) = &mut self.store {
318- use super::state::json_len;
319375 use super::store::Write;
320376
321377 let encode = |error| {
@@ -325,27 +381,52 @@ impl Service {
325381 error,
326382 )
327383 };
328- let sync = serde_json::to_value(&self.sync).map_err(encode)?;
384+ self.sync.inbox_revision = self.data.revision;
385+ let sync = serde_json::value::to_raw_value(&self.sync).map_err(encode)?;
386+ let rooms = if self.rooms_dirty {
387+ Some(serde_json::value::to_raw_value(&self.rooms).map_err(encode)?)
388+ } else {
389+ None
390+ };
329391 let mut writes = Vec::new();
330392 if self.dirty {
393+ self.data
394+ .coverage
395+ .gaps
396+ .retain(|gap| gap.reason != MentionGapReason::StoreFailed);
397+ let replaced: &[&str] = if rooms.is_some() {
398+ &["inbox", "sync", "rooms"]
399+ } else {
400+ &["inbox", "sync"]
401+ };
331402 let available = store
332- .available(&["inbox", "sync"])
333- .saturating_sub(store.sealed_bound("sync", json_len(&sync)));
334- let mut size = store.sealed_bound("inbox", json_len(&self.data));
335- while size > available {
336- let Some(freed) = self.data.evict_one() else {
337- break;
338- };
339- size = size.saturating_sub(freed * 3);
403+ .available(replaced)
404+ .saturating_sub(store.sealed_bound("sync", sync.get().len()))
405+ .saturating_sub(
406+ rooms
407+ .as_ref()
408+ .map_or(0, |rooms| store.sealed_bound("rooms", rooms.get().len())),
409+ );
410+ let mut inbox = serde_json::value::to_raw_value(&self.data).map_err(encode)?;
411+ let mut size = store.sealed_bound("inbox", inbox.get().len());
412+ if size > available {
413+ while size > available {
414+ let Some(freed) = self.data.evict_one() else {
415+ break;
416+ };
417+ size = size.saturating_sub(freed * 3);
418+ }
419+ inbox = serde_json::value::to_raw_value(&self.data).map_err(encode)?;
340420 }
341- writes.push(Write::Set(
342- "inbox".to_owned(),
343- serde_json::to_value(&self.data).map_err(encode)?,
344- ));
421+ writes.push(Write::Set("inbox".to_owned(), inbox));
345422 }
346423 writes.push(Write::Set("sync".to_owned(), sync));
424+ if let Some(rooms) = rooms {
425+ writes.push(Write::Set("rooms".to_owned(), rooms));
426+ }
347427 store.commit(store.revision(), writes).await?;
348428 }
429+ self.rooms_dirty = false;
349430 if self.dirty {
350431 self.dirty = false;
351432 self.publish().await;
@@ -364,7 +445,6 @@ impl Service {
364445 self.config.max_mention_context_events,
365446 self.config.max_mention_candidates,
366447 );
367- self.sync.inbox_revision = self.data.revision;
368448 Ok(())
369449 }
370450
@@ -387,6 +467,54 @@ impl Service {
387467 self.publish().await;
388468 }
389469
470+ /// the store is back after failing at startup, what it held and what was built in
471+ /// memory since are merged so nothing is lost
472+ #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
473+ async fn reopen(&mut self) {
474+ let Some(opener) = &self.reopen else {
475+ return;
476+ };
477+ let store = match opener.open().await {
478+ Ok(store) => store,
479+ Err(super::store::OpenError::Unavailable(error)) => {
480+ tracing::warn!(%error, "the mentions store is still unavailable");
481+ self.flush_after = now().saturating_add(FLUSH_RETRY_MS);
482+ return;
483+ }
484+ Err(super::store::OpenError::Newer(error)) => {
485+ tracing::error!(%error, "the mentions store belongs to a newer macltok");
486+ self.reopen = None;
487+ return;
488+ }
489+ };
490+ let mut store = store;
491+ let (mut data, mut sync, mut rooms) = match read_store(&store) {
492+ Ok(loaded) => loaded,
493+ Err(error) => {
494+ tracing::warn!(%error, "resetting an unreadable mentions inbox");
495+ if let Err(error) = store.reset().await {
496+ tracing::warn!(%error, "the mentions store is still unavailable");
497+ self.flush_after = now().saturating_add(FLUSH_RETRY_MS);
498+ return;
499+ }
500+ self.data.gap(None, MentionGapReason::StoreReset, None);
501+ Default::default()
502+ }
503+ };
504+ data.absorb(std::mem::take(&mut self.data));
505+ sync.checkpoints
506+ .extend(std::mem::take(&mut self.sync.checkpoints));
507+ rooms.extend(std::mem::take(&mut self.rooms));
508+ self.data = data;
509+ self.sync = sync;
510+ self.rooms = rooms;
511+ self.store = Some(store);
512+ self.reopen = None;
513+ self.dirty = true;
514+ self.rooms_dirty = true;
515+ self.flush_after = 0;
516+ }
517+
390518 async fn command(&mut self, command: MentionsCommand) -> Result<CommandOutput, Error> {
391519 match command {
392520 MentionsCommand::Page { cursor, limit } => {
@@ -495,8 +623,16 @@ impl Service {
495623 }
496624 self.ignored = self.ignored_users().await;
497625 self.leaving = batch.response.rooms.left.keys().cloned().collect();
498- let mut next = self.data.clone();
499- let mut sync = self.sync.clone();
626+ let room_ids: BTreeSet<_> = batch
627+ .raw_rooms
628+ .keys()
629+ .chain(batch.response.rooms.joined.keys())
630+ .chain(batch.response.rooms.left.keys())
631+ .cloned()
632+ .collect();
633+ let before = self.data.scope(&room_ids);
634+ let mut next = std::mem::take(&mut self.data);
635+ let mut changed = false;
500636 if self.timed_out.swap(false, Ordering::AcqRel) {
501637 next.gap(None, MentionGapReason::HookTimedOut, None);
502638 }
@@ -513,7 +649,7 @@ impl Service {
513649 next.gap(None, MentionGapReason::SyncDiscontinuity, None);
514650 }
515651 for (room_id, raw) in &batch.raw_rooms {
516- if raw.limited && sync.observed_rooms.contains(room_id) {
652+ if raw.limited && self.rooms.contains(room_id) {
517653 next.gap(
518654 Some(room_id.clone()),
519655 MentionGapReason::LimitedTimeline,
@@ -521,18 +657,11 @@ impl Service {
521657 );
522658 }
523659 }
524- let room_ids: BTreeSet<_> = batch
525- .raw_rooms
526- .keys()
527- .chain(batch.response.rooms.joined.keys())
528- .chain(batch.response.rooms.left.keys())
529- .cloned()
530- .collect();
531660 for room_id in &room_ids {
532661 if self.observable(room_id).is_none() {
533662 continue;
534663 }
535- sync.observed_rooms.insert(room_id.clone());
664+ self.rooms_dirty |= self.rooms.insert(room_id.clone());
536665 let mut observed = BTreeMap::new();
537666 if let Some(raw) = batch.raw_rooms.get(room_id) {
538667 for event in &raw.timeline {
@@ -575,6 +704,7 @@ impl Service {
575704 }
576705 }
577706 for room_id in batch.response.rooms.left.keys() {
707+ self.rooms_dirty |= self.rooms.remove(room_id);
578708 next.gap(Some(room_id.clone()), MentionGapReason::RoomLeft, None);
579709 next.candidates
580710 .retain(|_, candidate| &candidate.room_id != room_id);
@@ -586,35 +716,30 @@ impl Service {
586716 entry.revision = revision;
587717 }
588718 }
589- let reevaluate: Vec<_> = next
590- .entries
591- .values()
592- .filter(|entry| room_ids.contains(&entry.key.room_id))
593- .filter_map(|entry| {
594- next.events
595- .get(&id(&entry.key))
596- .and_then(|event| event.raw.clone())
597- .map(|raw| (entry.key.room_id.clone(), raw))
719+ let reevaluate: Vec<_> = room_ids
720+ .iter()
721+ .flat_map(|room_id| {
722+ next.entries
723+ .range(format!("{room_id}\n")..format!("{room_id}\u{b}"))
724+ .filter_map(|(key, _)| next.events.get(key).and_then(|event| event.raw.clone()))
725+ .map(move |raw| (room_id.clone(), raw))
598726 })
599727 .collect();
600728 for (room_id, raw) in reevaluate {
601729 self.observe(&mut next, &room_id, raw).await;
602730 }
603- self.filter_ignored(&mut next);
731+ changed |= self.filter_ignored(&mut next);
604732 self.leaving.clear();
605- sync.checkpoints.insert(
733+ self.sync.checkpoints.insert(
606734 batch.connection_id,
607735 Checkpoint {
608736 pos: batch.next_pos,
609737 previous: batch.previous_pos,
610738 },
611739 );
612- let changed = next != self.data;
613- if changed {
614- self.data = next;
615- self.dirty = true;
616- }
617- self.sync = sync;
740+ changed |= next.scope(&room_ids) != before;
741+ self.data = next;
742+ self.dirty |= changed;
618743 if now() < self.flush_after {
619744 if changed && self.advance().is_ok() {
620745 self.publish().await;
@@ -633,12 +758,15 @@ impl Service {
633758 })
634759 .map(|(key, _)| key.clone())
635760 .collect();
761+ if removed.is_empty() {
762+ return false;
763+ }
636764 for key in &removed {
637765 next.entries.remove(key);
638766 next.windows.remove(key);
639767 }
640768 next.collect_events();
641- !removed.is_empty()
769+ true
642770 }
643771
644772 async fn observe(&self, data: &mut Data, room_id: &OwnedRoomId, mut value: Value) {
@@ -793,11 +921,11 @@ impl Service {
793921 return;
794922 }
795923 let previous = data.entries.get(&identity);
796- let changed = previous.is_none_or(|entry| {
797- entry.event != item
798- || entry.reasons != reasons
799- || entry.notification_suppressed != suppressed
800- });
924+ let event_changed = previous.is_none_or(|entry| entry.event != item);
925+ let changed = event_changed
926+ || previous.is_some_and(|entry| {
927+ entry.reasons != reasons || entry.notification_suppressed != suppressed
928+ });
801929 let entry = MentionEntry {
802930 key,
803931 timestamp_ms: timestamp,
@@ -813,7 +941,7 @@ impl Service {
813941 reasons,
814942 read: previous.is_some_and(|entry| entry.read),
815943 capture: previous.map_or(MentionCaptureState::Pending, |entry| {
816- if changed {
944+ if event_changed {
817945 MentionCaptureState::Pending
818946 } else {
819947 entry.capture.clone()
@@ -937,7 +1065,7 @@ impl Service {
9371065 return (vec![], false, false);
9381066 };
9391067 let raw: Raw<AnySyncTimelineEvent> =
940- Raw::from_json_string(value.to_string()).expect("serialized event");
1068+ Raw::from_json_string(value.to_string()).expect("serialised event");
9411069 let actions = room.event_push_actions(&raw).await.ok().flatten();
9421070 let notify = actions.as_ref().is_some_and(|actions| {
9431071 actions
@@ -1026,11 +1154,15 @@ impl Service {
10261154 None => return,
10271155 },
10281156 Some(result) = work.next(), if !work.is_empty() => {
1157+ let rooms = result.rooms();
1158+ let before = self.data.scope(&rooms);
10291159 let mut next = std::mem::take(&mut self.data);
10301160 // a candidate that failed again only moved its retry time, which needs no write
1031- let changed = self.complete(&mut next, result, &mut captures, &mut retries, &mut history).await;
1161+ let changed = self.complete(&mut next, result, &mut captures, &mut retries, &mut history).await
1162+ && next.scope(&rooms) != before;
10321163 self.data = next;
10331164 self.dirty |= changed;
1165+ self.unpublished |= changed;
10341166 }
10351167 Some(report) = reports.next() => {
10361168 let room = match report { Ok(RedecryptorReport::ResolvedUtds { room_id, .. }) => Some(room_id), _ => None };
@@ -1039,14 +1171,22 @@ impl Service {
10391171 Some(_) = ignores.next() => {
10401172 self.ignored = self.ignored_users().await;
10411173 let mut next = std::mem::take(&mut self.data);
1042- self.dirty |= self.filter_ignored(&mut next);
1174+ let changed = self.filter_ignored(&mut next);
10431175 self.data = next;
1176+ self.dirty |= changed;
1177+ self.unpublished |= changed;
10441178 }
10451179 () = &mut tick => {
10461180 tick = Box::pin(matrix_sdk::sleep::sleep(Duration::from_secs(1)));
10471181 if now() >= next_scan && !history { self.recovery_budget = 20; self.latest_scan = true; next_scan = now() + 60_000; }
1048- if now() >= next_expiry { self.dirty |= self.data.expire_candidates(now()); next_expiry = now() + 60_000; }
1049- if self.dirty && now() >= self.flush_after && let Err(error) = self.flush().await { self.store_failed(error).await; }
1182+ if now() >= next_expiry { let expired = self.data.expire_candidates(now()); self.dirty |= expired; self.unpublished |= expired; next_expiry = now() + 60_000; }
1183+ #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
1184+ if self.store.is_none() && self.reopen.is_some() && now() >= self.flush_after { self.reopen().await; }
1185+ if self.dirty && now() >= self.flush_after {
1186+ if let Err(error) = self.flush().await { self.store_failed(error).await; }
1187+ } else if self.unpublished && self.advance().is_ok() {
1188+ self.publish().await;
1189+ }
10501190 }
10511191 }
10521192 }
Modified file: src/mentions/service_tests.rs+67 additions−3 deletions
@@ -109,7 +109,7 @@ fn quiet_batch(previous_pos: &str, next_pos: &str) -> ResponseCommitBatch {
109109 }
110110 }
111111
112-async fn service(client: &Client, store: Option<Result<MentionsStore, Error>>) -> Service {
112+async fn service(client: &Client, store: Option<Result<MentionsStore, Unopened>>) -> Service {
113113 let media = Arc::new(MediaService::new(
114114 client.clone(),
115115 EngineConfig::default().max_media_bytes,
@@ -230,11 +230,11 @@ async fn a_quiet_response_writes_only_the_checkpoint() {
230230 .await;
231231 let connection = Connection::open(config.mentions_location()).unwrap();
232232 let before = rows(&connection);
233- assert_eq!(before.len(), 2);
233+ assert_eq!(before.len(), 3);
234234
235235 service.batch(quiet_batch("position-1", "position-2")).await;
236236 let after = rows(&connection);
237- assert_eq!(after.len(), 2);
237+ assert_eq!(after.len(), 3);
238238 assert_eq!(
239239 before
240240 .iter()
@@ -253,6 +253,70 @@ async fn a_quiet_response_writes_only_the_checkpoint() {
253253 }
254254
255255 #[tokio::test]
256+async fn a_failed_command_write_leaves_a_store_that_still_opens() {
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(
267+ room_id,
268+ &own_user,
269+ "first",
270+ None,
271+ "position-1",
272+ ))
273+ .await;
274+ let key = first.data.entries.values().next().unwrap().key.clone();
275+
276+ let connection = Connection::open(config.mentions_location()).unwrap();
277+ connection
278+ .execute_batch(
279+ "CREATE TRIGGER fail_mentions_update BEFORE UPDATE ON records WHEN OLD.key NOT IN ('!cipher', '!head') BEGIN SELECT RAISE(ABORT, 'injected write failure'); END;",
280+ )
281+ .unwrap();
282+ assert!(
283+ first
284+ .command(MentionsCommand::SetRead {
285+ keys: vec![key],
286+ read: true,
287+ })
288+ .await
289+ .is_err()
290+ );
291+ connection
292+ .execute_batch("DROP TRIGGER fail_mentions_update;")
293+ .unwrap();
294+ drop(connection);
295+ first.batch(quiet_batch("position-1", "position-2")).await;
296+ drop(first);
297+
298+ let store = MentionsStore::open(&config, account(&client), MAX_BYTES)
299+ .await
300+ .unwrap();
301+ let restarted = service(&client, Some(Ok(store))).await;
302+ assert_eq!(restarted.data.entries.len(), 1);
303+ assert!(
304+ restarted
305+ .data
306+ .coverage
307+ .gaps
308+ .iter()
309+ .all(|gap| gap.reason != MentionGapReason::StoreReset)
310+ );
311+ assert_eq!(
312+ restarted.sync.checkpoints["mentions-connection"].pos,
313+ "position-2"
314+ );
315+ drop(restarted);
316+ config.delete().await.unwrap();
317+}
318+
319+#[tokio::test]
256320 async fn a_replay_from_the_previous_checkpoint_is_not_a_discontinuity() {
257321 let server = MatrixMockServer::new().await;
258322 let room_id = room_id!("!mentions:localhost");
Modified file: src/mentions/state.rs+97 additions−1 deletions
@@ -91,7 +91,47 @@ pub(super) struct Data {
9191 pub(super) struct SyncState {
9292 pub inbox_revision: u64,
9393 pub checkpoints: BTreeMap<String, Checkpoint>,
94- pub observed_rooms: BTreeSet<ruma::OwnedRoomId>,
94+}
95+
96+/// what a set of rooms holds in the inbox, compared before and after a change so an
97+/// unchanged inbox costs only the rooms it touched
98+#[derive(PartialEq)]
99+pub(super) struct Scope {
100+ rooms: Vec<RoomScope>,
101+ coverage: MentionCoverage,
102+ cursors: (
103+ Option<String>,
104+ bool,
105+ Option<String>,
106+ Option<String>,
107+ Option<String>,
108+ ),
109+}
110+
111+#[derive(PartialEq)]
112+struct RoomScope {
113+ entries: Vec<MentionEntry>,
114+ events: Vec<SavedEvent>,
115+ windows: Vec<Window>,
116+ capture_attempts: Vec<u32>,
117+ candidates: Vec<Candidate>,
118+ redacted: Vec<u64>,
119+ edits: Vec<Value>,
120+ keys: Vec<String>,
121+}
122+
123+fn in_room<V: Clone>(
124+ map: &BTreeMap<String, V>,
125+ room: &ruma::RoomId,
126+ keys: &mut Vec<String>,
127+) -> Vec<V> {
128+ // no room id holds a newline, so a room's "room\nevent" keys sit below "room\x0b"
129+ map.range(format!("{room}\n")..format!("{room}\u{b}"))
130+ .map(|(key, value)| {
131+ keys.push(key.clone());
132+ value.clone()
133+ })
134+ .collect()
95135 }
96136
97137 #[derive(Clone, Debug, PartialEq)]
@@ -142,6 +182,62 @@ fn timestamp(value: &Value) -> u64 {
142182 }
143183
144184 impl Data {
185+ pub fn scope<'a>(&self, rooms: impl IntoIterator<Item = &'a ruma::OwnedRoomId>) -> Scope {
186+ Scope {
187+ rooms: rooms
188+ .into_iter()
189+ .map(|room| {
190+ let mut keys = Vec::new();
191+ RoomScope {
192+ entries: in_room(&self.entries, room, &mut keys),
193+ events: in_room(&self.events, room, &mut keys),
194+ windows: in_room(&self.windows, room, &mut keys),
195+ capture_attempts: in_room(&self.capture_attempts, room, &mut keys),
196+ candidates: in_room(&self.candidates, room, &mut keys),
197+ redacted: in_room(&self.redacted, room, &mut keys),
198+ edits: in_room(&self.edits, room, &mut keys),
199+ keys,
200+ }
201+ })
202+ .collect(),
203+ coverage: self.coverage.clone(),
204+ cursors: (
205+ self.history_cursor.clone(),
206+ self.history_exhausted,
207+ self.latest_cursor.clone(),
208+ self.latest_anchor.clone(),
209+ self.latest_head.clone(),
210+ ),
211+ }
212+ }
213+
214+ /// folds an inbox built in memory while the store was unreachable into the one the
215+ /// store held, keeping the newer side where both have a key
216+ pub fn absorb(&mut self, newer: Data) {
217+ self.revision = self.revision.max(newer.revision);
218+ self.entries.extend(newer.entries);
219+ self.events.extend(newer.events);
220+ self.windows.extend(newer.windows);
221+ self.capture_attempts.extend(newer.capture_attempts);
222+ self.candidates.extend(newer.candidates);
223+ self.redacted.extend(newer.redacted);
224+ self.edits.extend(newer.edits);
225+ for gap in newer.coverage.gaps {
226+ self.gap(gap.room_id, gap.reason, gap.token);
227+ }
228+ self.coverage.earliest_timestamp_ms = self
229+ .coverage
230+ .earliest_timestamp_ms
231+ .into_iter()
232+ .chain(newer.coverage.earliest_timestamp_ms)
233+ .min();
234+ self.coverage.truncated |= newer.coverage.truncated;
235+ self.history_cursor = newer.history_cursor.or(self.history_cursor.take());
236+ self.latest_cursor = newer.latest_cursor.or(self.latest_cursor.take());
237+ self.latest_anchor = newer.latest_anchor.or(self.latest_anchor.take());
238+ self.latest_head = newer.latest_head.or(self.latest_head.take());
239+ }
240+
145241 pub fn gap(
146242 &mut self,
147243 room_id: Option<ruma::OwnedRoomId>,
Modified file: src/mentions/state_tests.rs+2 additions−2 deletions
@@ -388,7 +388,7 @@ mod persistence {
388388 0,
389389 vec![Write::Set(
390390 "inbox".to_owned(),
391- serde_json::to_value(&data).unwrap(),
391+ serde_json::value::to_raw_value(&data).unwrap(),
392392 )],
393393 )
394394 .await
@@ -422,7 +422,7 @@ mod persistence {
422422 1,
423423 vec![Write::Set(
424424 "inbox".to_owned(),
425- serde_json::to_value(&restored_data).unwrap(),
425+ serde_json::value::to_raw_value(&restored_data).unwrap(),
426426 )],
427427 )
428428 .await
Modified file: src/mentions/store/indexeddb.rs+15 additions−13 deletions
@@ -9,7 +9,7 @@ use indexed_db_futures::{
99 typed_array::{Uint8Array, Uint8ArraySlice},
1010 };
1111
12-use super::Snapshot;
12+use super::{Failure, Snapshot};
1313
1414 const RECORDS: &str = "records";
1515 const CIPHER: &str = "!cipher";
@@ -39,26 +39,28 @@ pub(super) struct Backend {
3939 }
4040
4141 impl Backend {
42- pub(super) async fn open(name: String) -> Result<Self, crate::Error> {
42+ pub(super) async fn open(name: String) -> Result<(Self, bool), crate::Error> {
4343 let _database = open_database(&name)
4444 .await
4545 .map_err(|error| super::storage_error("opening the mentions database", error))?;
46- Ok(Self { name })
46+ Ok((Self { name }, false))
4747 }
4848
49- pub(super) async fn load(&self) -> Result<Option<Snapshot>, crate::Error> {
50- let database = open_database(&self.name)
51- .await
52- .map_err(|error| super::storage_error("opening the mentions database", error))?;
49+ pub(super) async fn load(&self) -> Result<Option<Snapshot>, Failure> {
50+ let database = open_database(&self.name).await.map_err(|error| {
51+ Failure::Unavailable(super::storage_error("opening the mentions database", error))
52+ })?;
5353 let snapshot = read_snapshot(&database).await;
5454 match snapshot {
5555 Ok(snapshot) => Ok(snapshot),
56- Err(ReadError::Invalid(message)) => {
57- Err(crate::Error::new(crate::ErrorKind::Store, message))
58- }
59- Err(ReadError::Storage(error)) => {
60- Err(super::storage_error("reading the mentions database", error))
61- }
56+ Err(ReadError::Invalid(message)) => Err(Failure::Corrupt(crate::Error::new(
57+ crate::ErrorKind::Store,
58+ message,
59+ ))),
60+ Err(ReadError::Storage(error)) => Err(Failure::Unavailable(super::storage_error(
61+ "reading the mentions database",
62+ error,
63+ ))),
6264 }
6365 }
6466
Modified file: src/mentions/store/mod.rs+140 additions−47 deletions
@@ -4,7 +4,7 @@ use matrix_sdk::{SendOutsideWasm, SyncOutsideWasm};
44 use matrix_sdk_store_encryption::StoreCipher;
55 use ruma::OwnedUserId;
66 use serde::{Deserialize, Serialize, de::DeserializeOwned};
7-use serde_json::Value;
7+use serde_json::{Value, value::RawValue};
88
99 use crate::{Error, ErrorKind, StoreConfig};
1010
@@ -35,7 +35,7 @@ struct Head {
3535 bytes: usize,
3636 }
3737
38-#[derive(Serialize, Deserialize)]
38+#[derive(Deserialize)]
3939 struct Record {
4040 schema: u32,
4141 account: Account,
@@ -43,6 +43,14 @@ struct Record {
4343 value: Value,
4444 }
4545
46+#[derive(Serialize)]
47+struct SealedRecord<'a> {
48+ schema: u32,
49+ account: &'a Account,
50+ key: &'a str,
51+ value: &'a RawValue,
52+}
53+
4654 pub(super) struct Snapshot {
4755 pub(super) exported: Vec<u8>,
4856 pub(super) head: Vec<u8>,
@@ -50,10 +58,67 @@ pub(super) struct Snapshot {
5058 }
5159
5260 pub(crate) enum Write {
53- Set(String, Value),
61+ Set(String, Box<RawValue>),
5462 Remove(String),
5563 }
5664
65+/// what a backend could not read, content can be wiped and anything else is left alone
66+#[derive(Debug)]
67+pub(super) enum Failure {
68+ Corrupt(Error),
69+ Unavailable(Error),
70+}
71+
72+impl Failure {
73+ fn into_error(self) -> Error {
74+ match self {
75+ Self::Corrupt(error) | Self::Unavailable(error) => error,
76+ }
77+ }
78+}
79+
80+impl From<Error> for Failure {
81+ fn from(error: Error) -> Self {
82+ Self::Unavailable(error)
83+ }
84+}
85+
86+#[derive(Debug)]
87+pub(crate) enum OpenError {
88+ /// worth another try later, nothing was wiped
89+ Unavailable(Error),
90+ /// written by a newer macltok, left as it is
91+ Newer(Error),
92+}
93+
94+/// everything needed to open the store again after a failed attempt
95+pub(crate) struct Opener {
96+ location: Location,
97+ passphrase: String,
98+ account: Account,
99+ max_bytes: usize,
100+}
101+
102+#[cfg(feature = "sqlite")]
103+type Location = std::path::PathBuf;
104+#[cfg(feature = "indexeddb")]
105+type Location = String;
106+
107+impl Opener {
108+ pub(crate) fn new(config: &StoreConfig, account: Account, max_bytes: usize) -> Self {
109+ Self {
110+ location: config.mentions_location(),
111+ passphrase: config.passphrase().to_owned(),
112+ account,
113+ max_bytes,
114+ }
115+ }
116+
117+ pub(crate) async fn open(&self) -> Result<MentionsStore, OpenError> {
118+ MentionsStore::open_with(self).await
119+ }
120+}
121+
57122 pub(crate) struct MentionsStore {
58123 backend: Backend,
59124 cipher: StoreCipher,
@@ -75,37 +140,53 @@ impl fmt::Debug for MentionsStore {
75140 }
76141
77142 impl MentionsStore {
78- /// an unreadable, corrupt or foreign store is emptied rather than refused
79143 pub(crate) async fn open(
80144 config: &StoreConfig,
81145 account: Account,
82146 max_bytes: usize,
83- ) -> Result<Self, Error> {
84- let backend = Backend::open(config.mentions_location()).await?;
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)
147+ ) -> Result<Self, OpenError> {
148+ Opener::new(config, account, max_bytes).open().await
149+ }
150+
151+ /// a corrupt or foreign store is emptied, one that is busy, failing or newer is
152+ /// left alone
153+ async fn open_with(opener: &Opener) -> Result<Self, OpenError> {
154+ let (backend, mut reset) = Backend::open(opener.location.clone())
155+ .await
156+ .map_err(OpenError::Unavailable)?;
157+ let restored = match backend.load().await {
158+ Ok(Some(snapshot)) => match restore(opener, snapshot) {
159+ Ok(restored) => Some(restored),
160+ Err(Rejected::Newer(error)) => return Err(OpenError::Newer(error)),
161+ Err(Rejected::Content(error)) => {
162+ tracing::warn!(%error, "resetting an unreadable mentions store");
163+ backend.reset().await.map_err(OpenError::Unavailable)?;
164+ reset = true;
165+ None
166+ }
167+ },
168+ Ok(None) => None,
169+ Err(Failure::Corrupt(error)) => {
170+ tracing::warn!(%error, "resetting a corrupt mentions store");
171+ backend.reset().await.map_err(OpenError::Unavailable)?;
172+ reset = true;
173+ None
96174 }
175+ Err(Failure::Unavailable(error)) => return Err(OpenError::Unavailable(error)),
97176 };
98177 let (cipher, snapshot, revision) = match restored {
99178 Some(restored) => restored,
100- None => create(&backend, config, &account, max_bytes).await?,
179+ None => create(&backend, opener)
180+ .await
181+ .map_err(OpenError::Unavailable)?,
101182 };
102183 Ok(Self {
103184 backend,
104185 cipher,
105- account,
186+ account: opener.account.clone(),
106187 snapshot,
107188 revision,
108- max_bytes,
189+ max_bytes: opener.max_bytes,
109190 reset,
110191 })
111192 }
@@ -202,11 +283,11 @@ impl MentionsStore {
202283 match write {
203284 Write::Set(key, value) => {
204285 let hashed = storage_key(&self.cipher, &key);
205- let record = Record {
286+ let record = SealedRecord {
206287 schema: SCHEMA,
207- account: self.account.clone(),
208- key,
209- value,
288+ account: &self.account,
289+ key: &key,
290+ value: &value,
210291 };
211292 let encrypted = self
212293 .cipher
@@ -266,54 +347,66 @@ impl MentionsStore {
266347 }
267348 }
268349
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)
350+enum Rejected {
351+ Content(Error),
352+ Newer(Error),
353+}
354+
355+impl From<Error> for Rejected {
356+ fn from(error: Error) -> Self {
357+ Self::Content(error)
358+ }
359+}
360+
361+#[derive(Deserialize)]
362+struct Schema {
363+ schema: u32,
364+}
365+
366+fn restore(opener: &Opener, snapshot: Snapshot) -> Result<(StoreCipher, Snapshot, u64), Rejected> {
367+ let cipher = StoreCipher::import(&opener.passphrase, &snapshot.exported)
277368 .map_err(|error| storage_error("opening the mentions cipher", error))?;
369+ let schema: Schema = cipher
370+ .decrypt_value(&snapshot.head)
371+ .map_err(|error| storage_error("opening the mentions header", error))?;
372+ if schema.schema > SCHEMA {
373+ return Err(Rejected::Newer(invalid(
374+ "mentions store was written by a newer macltok",
375+ )));
376+ }
377+ validate_size(&snapshot, opener.max_bytes)?;
278378 let head: Head = cipher
279379 .decrypt_value(&snapshot.head)
280380 .map_err(|error| storage_error("opening the mentions header", error))?;
281- validate_account(head.schema, &head.account, account)?;
381+ validate_account(head.schema, &head.account, &opener.account)?;
282382 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- ));
383+ return Err(invalid("mentions records do not match their committed header").into());
286384 }
287385 for (key, encrypted) in &snapshot.rows {
288386 let record: Record = cipher
289387 .decrypt_value(encrypted)
290388 .map_err(|error| storage_error("opening a mentions record", error))?;
291- validate_account(record.schema, &record.account, account)?;
389+ validate_account(record.schema, &record.account, &opener.account)?;
292390 if storage_key(&cipher, &record.key) != *key {
293- return Err(invalid("a mentions record belongs to a different key"));
391+ return Err(invalid("a mentions record belongs to a different key").into());
294392 }
295393 }
296394 Ok((cipher, snapshot, head.revision))
297395 }
298396
299-async fn create(
300- backend: &Backend,
301- config: &StoreConfig,
302- account: &Account,
303- max_bytes: usize,
304-) -> Result<(StoreCipher, Snapshot, u64), Error> {
397+async fn create(backend: &Backend, opener: &Opener) -> Result<(StoreCipher, Snapshot, u64), Error> {
305398 let cipher =
306399 StoreCipher::new().map_err(|error| storage_error("creating the mentions cipher", error))?;
307400 let exported = cipher
308- .export(config.passphrase())
401+ .export(&opener.passphrase)
309402 .map_err(|error| storage_error("sealing the mentions cipher", error))?;
310- let head = seal_head(&cipher, account, 0, 0, 0)?;
403+ let head = seal_head(&cipher, &opener.account, 0, 0, 0)?;
311404 let snapshot = Snapshot {
312405 exported,
313406 head,
314407 rows: BTreeMap::new(),
315408 };
316- validate_size(&snapshot, max_bytes)?;
409+ validate_size(&snapshot, opener.max_bytes)?;
317410 backend
318411 .commit(
319412 None,
Modified file: src/mentions/store/sqlite.rs+59 additions−37 deletions
@@ -8,7 +8,7 @@ use std::{
88
99 use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
1010
11-use super::{Snapshot, conflict, storage_error};
11+use super::{Failure, Snapshot, conflict, storage_error};
1212 use crate::{Error, ErrorKind};
1313
1414 const CIPHER_KEY: &str = "!cipher";
@@ -19,36 +19,36 @@ pub(super) struct Backend {
1919 }
2020
2121 impl Backend {
22- pub(super) async fn open(path: PathBuf) -> Result<Self, Error> {
22+ /// a file that is not a database is replaced, `true` when that happened
23+ pub(super) async fn open(path: PathBuf) -> Result<(Self, bool), Error> {
2324 let setup_path = path.clone();
24- tokio::task::spawn_blocking(move || initialise(&setup_path))
25- .await
26- .map_err(|error| storage_error("opening the mentions store", error))??;
27- Ok(Self { path })
25+ let reset = tokio::task::spawn_blocking(move || match initialise(&setup_path) {
26+ Ok(()) => Ok(false),
27+ Err(Failure::Corrupt(error)) => {
28+ tracing::warn!(%error, "replacing a corrupt mentions database");
29+ replace(&setup_path).map(|()| true)
30+ }
31+ Err(Failure::Unavailable(error)) => Err(error),
32+ })
33+ .await
34+ .map_err(|error| storage_error("opening the mentions store", error))??;
35+ Ok((Self { path }, reset))
2836 }
2937
30- pub(super) async fn load(&self) -> Result<Option<Snapshot>, Error> {
38+ pub(super) async fn load(&self) -> Result<Option<Snapshot>, Failure> {
3139 let path = self.path.clone();
3240 tokio::task::spawn_blocking(move || load_snapshot(&path))
3341 .await
34- .map_err(|error| storage_error("loading the mentions store", error))?
42+ .map_err(|error| {
43+ Failure::Unavailable(storage_error("loading the mentions store", error))
44+ })?
3545 }
3646
3747 pub(super) async fn reset(&self) -> Result<(), Error> {
3848 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))?
49+ tokio::task::spawn_blocking(move || replace(&path))
50+ .await
51+ .map_err(|error| storage_error("resetting the mentions store", error))?
5252 }
5353
5454 pub(super) async fn commit(
@@ -67,7 +67,32 @@ impl Backend {
6767 }
6868 }
6969
70-fn initialise(path: &Path) -> Result<(), Error> {
70+fn replace(path: &Path) -> Result<(), Error> {
71+ for file in [path.to_owned(), journal(path)] {
72+ match fs::remove_file(&file) {
73+ Err(error) if error.kind() != io::ErrorKind::NotFound => {
74+ return Err(storage_error("removing the mentions database", error));
75+ }
76+ _ => {}
77+ }
78+ }
79+ initialise(path).map_err(Failure::into_error)
80+}
81+
82+fn sql(context: &'static str, error: rusqlite::Error) -> Failure {
83+ let corrupt = matches!(
84+ error.sqlite_error_code(),
85+ Some(rusqlite::ErrorCode::NotADatabase | rusqlite::ErrorCode::DatabaseCorrupt)
86+ );
87+ let error = storage_error(context, error);
88+ if corrupt {
89+ Failure::Corrupt(error)
90+ } else {
91+ Failure::Unavailable(error)
92+ }
93+}
94+
95+fn initialise(path: &Path) -> Result<(), Failure> {
7196 let parent = path
7297 .parent()
7398 .filter(|parent| !parent.as_os_str().is_empty())
@@ -82,7 +107,7 @@ fn initialise(path: &Path) -> Result<(), Error> {
82107 "CREATE TABLE IF NOT EXISTS records (key TEXT PRIMARY KEY, value BLOB NOT NULL)",
83108 [],
84109 )
85- .map_err(|error| storage_error("initialising the mentions database", error))?;
110+ .map_err(|error| sql("initialising the mentions database", error))?;
86111 Ok(())
87112 }
88113
@@ -98,42 +123,39 @@ fn connect(path: &Path) -> Result<Connection, Error> {
98123 Ok(connection)
99124 }
100125
101-fn load_snapshot(path: &Path) -> Result<Option<Snapshot>, Error> {
126+fn load_snapshot(path: &Path) -> Result<Option<Snapshot>, Failure> {
102127 let mut connection = connect(path)?;
103128 let transaction = connection
104129 .transaction_with_behavior(TransactionBehavior::Deferred)
105- .map_err(|error| storage_error("starting a mentions snapshot", error))?;
130+ .map_err(|error| sql("starting a mentions snapshot", error))?;
106131 let mut rows = BTreeMap::new();
107132 {
108133 let mut statement = transaction
109134 .prepare("SELECT key, value FROM records")
110- .map_err(|error| storage_error("reading the mentions snapshot", error))?;
135+ .map_err(|error| sql("reading the mentions snapshot", error))?;
111136 let result = statement
112137 .query_map([], |row| {
113138 Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
114139 })
115- .map_err(|error| storage_error("reading the mentions snapshot", error))?;
140+ .map_err(|error| sql("reading the mentions snapshot", error))?;
116141 for row in result {
117- let (key, value) =
118- row.map_err(|error| storage_error("reading the mentions snapshot", error))?;
142+ let (key, value) = row.map_err(|error| sql("reading the mentions snapshot", error))?;
119143 rows.insert(key, value);
120144 }
121145 }
122146 transaction
123147 .commit()
124- .map_err(|error| storage_error("finishing the mentions snapshot", error))?;
148+ .map_err(|error| sql("finishing the mentions snapshot", error))?;
125149 if rows.is_empty() {
126150 return Ok(None);
127151 }
128- let exported = rows.remove(CIPHER_KEY).ok_or_else(|| {
129- Error::new(
130- ErrorKind::Store,
131- "mentions store is missing cipher metadata",
132- )
133- })?;
152+ let missing = |message| Failure::Corrupt(Error::new(ErrorKind::Store, message));
153+ let exported = rows
154+ .remove(CIPHER_KEY)
155+ .ok_or_else(|| missing("mentions store is missing cipher metadata"))?;
134156 let head = rows
135157 .remove(HEAD_KEY)
136- .ok_or_else(|| Error::new(ErrorKind::Store, "mentions store is missing head metadata"))?;
158+ .ok_or_else(|| missing("mentions store is missing head metadata"))?;
137159 Ok(Some(Snapshot {
138160 exported,
139161 head,
Modified file: src/mentions/store/tests.rs+11 additions−10 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, SCHEMA, Write, row_bytes, storage_key};
6+use super::{Account, Head, MentionsStore, OpenError, SCHEMA, Write, row_bytes, storage_key};
77 use crate::StoreConfig;
88
99 #[cfg(target_family = "wasm")]
@@ -34,7 +34,10 @@ fn config() -> StoreConfig {
3434 }
3535
3636 fn set(key: &str, value: Value) -> Write {
37- Write::Set(key.to_owned(), value)
37+ Write::Set(
38+ key.to_owned(),
39+ serde_json::value::to_raw_value(&value).unwrap(),
40+ )
3841 }
3942
4043 async fn open(config: &StoreConfig) -> MentionsStore {
@@ -360,7 +363,7 @@ async fn ciphertext_corruption_and_record_swaps_reset_the_store() {
360363
361364 #[cfg_attr(not(target_family = "wasm"), tokio::test)]
362365 #[cfg_attr(target_family = "wasm", wasm_bindgen_test::wasm_bindgen_test)]
363-async fn unsupported_schema_resets_the_store() {
366+async fn a_newer_schema_is_left_alone() {
364367 let config = config();
365368 let mut store = open(&config).await;
366369 store
@@ -387,13 +390,11 @@ async fn unsupported_schema_resets_the_store() {
387390 )
388391 .await
389392 .unwrap();
390- assert!(
391- MentionsStore::open(&config, account(), MAX_BYTES)
392- .await
393- .unwrap()
394- .was_reset()
395- );
396- assert_ne!(store.backend.load().await.unwrap().unwrap().head, header);
393+ assert!(matches!(
394+ MentionsStore::open(&config, account(), MAX_BYTES).await,
395+ Err(OpenError::Newer(_))
396+ ));
397+ assert_eq!(store.backend.load().await.unwrap().unwrap().head, header);
397398 drop(store);
398399 config.delete().await.unwrap();
399400 }