Commit 82d37c369846
tarball12 files changed+705-266
@@ -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"@@ -102,9 +102,11 @@ records a `SyncDiscontinuity` gap.
102102
103103
The hook never fails sync. A failed write keeps the inbox in memory, records a104104
`StoreFailed` gap, acks, and retries the write a minute later, so a restart105
-before a write lands shows up as a discontinuity. The hook also stops waiting106
-after ten seconds and records `HookTimedOut`, so a stuck actor cannot hold the107
-position lock.105
+before a write lands shows up as a discontinuity. The gap goes again once a106
+later write lands, since nothing was lost. The hook gives up after ten seconds107
+waiting for room in the actor's queue, drops the response and records108
+`HookTimedOut`. Once queued, it waits another ten seconds for the commit and109
+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. Missing199201
authorisation context remains unresolved for reevaluation. An event that200202
notifies and highlights with no other reason, such as a loud keyword, enters the201
-inbox as `Keyword`. `add_notification_keyword` gives keyword rules a highlight202
-tweak, and upgrades an existing rule for the pattern that lacks one, since the203
-SDK's keyword rules only notify and play a sound. The classifier excludes the signed-in203
+inbox as `Keyword`. The SDK's keyword rules only notify and play a sound, so204
+`add_notification_keyword` reads the content rule the SDK names after the205
+keyword and adds a highlight tweak when it lacks one, leaving its other actions206
+alone. A rule the user made quiet stays quiet apart from the highlight. A207
+change of reason alone keeps an entry's saved context. The classifier excludes the signed-in204208
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, and262266
authenticated encrypted records include schema, account identity, and logical263
-key. Restore treats mismatched accounts, unsupported schemas, corrupt records,264
-and inconsistent record counts or byte totals as unreadable, empties the store265
-and records a `StoreReset` gap rather than failing `Account::build`. A store266
-that cannot be opened at all leaves the inbox in memory behind `StoreFailed`.267
+key. Restore treats mismatched accounts, older schemas, corrupt records, and268
+inconsistent record counts or byte totals as unreadable, empties the store and269
+records a `StoreReset` gap rather than failing `Account::build`. A busy or270
+failing store and one written by a newer macltok are left alone, and the inbox271
+runs in memory behind `StoreFailed`. The busy or failing case is retried every272
+minute, and when it opens, what it held and what was built in memory since are273
+merged.267274
Atomic transactions compare the previous encrypted header before publishing a268275
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 across283290
session resume.284291
285
-The store holds two records. `inbox` carries entries, saved events, context292
+The store holds three records. `inbox` carries entries, saved events, context286293
windows, candidates, edits, redactions, coverage and recovery cursors. `sync`287
-carries the connection checkpoints, observed rooms and the inbox288
-revision it belongs to. Every response writes `sync`, and289
-`inbox` only when the response changed it, both in one commit. A quiet290
-response therefore costs one small record, while a changed inbox is still291
-reserialised and re-encrypted whole. Background completions are applied in292
-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. Every296
+response writes `sync`, and `inbox` or `rooms` only when the response changed297
+them, all in one commit. Whether the inbox changed is decided by comparing the298
+rooms the response touched, so a quiet response costs those rooms and one small299
+record, while a changed inbox is still reserialised and re-encrypted whole, in300
+one serialisation pass. Background completions are applied in memory and301
+written together at most once a second, and published on that tick even while302
+writes back off. When the budget is short, eviction runs against a size303
+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 stay296306
inside the authenticated encrypted payloads. SDK SQLite custom values remain plaintext and are not used@@ -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,@@ -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);@@ -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 succeed50
+ #[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 events6498
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)) => page143
+ .notifications144
+ .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 sync97154
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
- handle105
- .requests106
- .send(Request::Batch(batch, reply))107
- .await108
- .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 nothing174
+ // unless the process dies first, which the next start sees as a175
+ // discontinuity176
+ if matrix_sdk::timeout::timeout(response, HOOK_TIMEOUT)177
+ .await178
+ .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
media238288
.register(239289
&media_scope,240290
page.entries241291
.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 a311
- /// checkpoint never lands without the inbox changes it covers366
+ /// writes the sync record, with the inbox when `dirty` and observed rooms when367
+ /// `rooms_dirty`, in one commit so a checkpoint never lands without the changes it368
+ /// covers312369
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
+ None390
+ };329391
let mut writes = Vec::new();330392
if self.dirty {393
+ self.data394
+ .coverage395
+ .gaps396
+ .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 = store332
- .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
+ rooms407
+ .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 in471
+ /// memory since are merged so nothing is lost472
+ #[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.checkpoints506
+ .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<_> = batch627
+ .raw_rooms628
+ .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<_> = batch525
- .raw_rooms526
- .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.candidates580710
.retain(|_, candidate| &candidate.room_id != room_id);@@ -586,35 +716,30 @@ impl Service {
586716
entry.revision = revision;587717
}588718
}589
- let reevaluate: Vec<_> = next590
- .entries591
- .values()592
- .filter(|entry| room_ids.contains(&entry.key.room_id))593
- .filter_map(|entry| {594
- next.events595
- .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_ids720
+ .iter()721
+ .flat_map(|room_id| {722
+ next.entries723
+ .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
+ true642770
}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 != item798
- || entry.reasons != reasons799
- || entry.notification_suppressed != suppressed800
- });924
+ let event_changed = previous.is_none_or(|entry| entry.event != item);925
+ let changed = event_changed926
+ || previous.is_some_and(|entry| {927
+ entry.reasons != reasons || entry.notification_suppressed != suppressed928
+ });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::Pending818946
} 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 write1031
- 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).await1162
+ && 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
}@@ -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
before240240
.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
+ .await263
+ .unwrap();264
+ let mut first = service(&client, Some(Ok(store))).await;265
+ first266
+ .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
+ connection278
+ .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
+ first284
+ .command(MentionsCommand::SetRead {285
+ keys: vec![key],286
+ read: true,287
+ })288
+ .await289
+ .is_err()290
+ );291
+ connection292
+ .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
+ .await300
+ .unwrap();301
+ let restarted = service(&client, Some(Ok(store))).await;302
+ assert_eq!(restarted.data.entries.len(), 1);303
+ assert!(304
+ restarted305
+ .data306
+ .coverage307
+ .gaps308
+ .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");@@ -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 an97
+/// unchanged inbox costs only the rooms it touched98
+#[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: rooms188
+ .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 the215
+ /// store held, keeping the newer side where both have a key216
+ 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 = self229
+ .coverage230
+ .earliest_timestamp_ms231
+ .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>,@@ -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@@ -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
.await4545
.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
- .await52
- .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
@@ -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 alone66
+#[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 wiped89
+ Unavailable(Error),90
+ /// written by a newer macltok, left as it is91
+ Newer(Error),92
+}93
+94
+/// everything needed to open the store again after a failed attempt95
+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).await119
+ }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 refused79143
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().await149
+ }150
+151
+ /// a corrupt or foreign store is emptied, one that is busy, failing or newer is152
+ /// left alone153
+ async fn open_with(opener: &Opener) -> Result<Self, OpenError> {154
+ let (backend, mut reset) = Backend::open(opener.location.clone())155
+ .await156
+ .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
+ None166
+ }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
+ None96174
}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
+ .await181
+ .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 = self212293
.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 = cipher370
+ .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 = cipher279379
.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 = cipher289387
.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 = cipher308
- .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
backend318411
.commit(319412
None,@@ -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 happened23
+ 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
- .await26
- .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
+ .await34
+ .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
.await34
- .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
- .await51
- .map_err(|error| storage_error("resetting the mentions store", error))?49
+ tokio::task::spawn_blocking(move || replace(&path))50
+ .await51
+ .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 = path7297
.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 = connection104129
.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 = transaction109134
.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 = statement112137
.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
transaction123147
.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 = rows154
+ .remove(CIPHER_KEY)155
+ .ok_or_else(|| missing("mentions store is missing cipher metadata"))?;134156
let head = rows135157
.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,@@ -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
.await389392
.unwrap();390
- assert!(391
- MentionsStore::open(&config, account(), MAX_BYTES)392
- .await393
- .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
}