offline_protocol/protocol/data.rs
1//! The replicated-document store: persistence, caps, and compaction.
2//!
3//! The engine lives in `offline-protocol-data` and knows nothing about
4//! storage. This module is the other half: it decides when a document is
5//! read, when its changes reach disk, and when history is folded away. It
6//! owns no cryptography of its own — records go through the same sealing
7//! chokepoint every other protocol-state category uses, which is what makes
8//! a swapped-in storage backend unable to change the at-rest posture.
9//!
10//! # Record layout
11//!
12//! | Category | Key id | Contents |
13//! |---|---|---|
14//! | `data_docs` | `{space}/{doc}` | The compacted document |
15//! | `data_delta_log` | `{space}/{doc}/{seq:016x}` | One persisted commit |
16//! | `data_spaces` | `{space}` | The document index for the space |
17//!
18//! # Why the listing is the truth and the index is a cache
19//!
20//! Everything needed to re-open a document is derived from what is actually
21//! on disk: `list_keys` names the delta records, and their zero-padded hex
22//! sequence sorts into apply order. The space record only remembers document
23//! names, so a document created and never written still lists. Any crash
24//! therefore leaves a readable store rather than a bookkeeping claim that
25//! has to be reconciled against reality.
26
27use std::collections::{BTreeMap, BTreeSet};
28use std::sync::Arc;
29
30use offline_protocol_data::{
31 policy, CatchUp, DataDoc, DataError, DataValue, RemoteImport, VersionToken,
32};
33use serde::{Deserialize, Serialize};
34use tracing::{debug, warn};
35
36use crate::error::{Error, Result};
37use crate::events::Event;
38use crate::protocol::storage::StateRecord;
39use crate::protocol::types::{storage_keys, MAX_PROTOCOL_STATE_RECORD_BYTES};
40use crate::protocol::OfflineProtocol;
41use crate::protocol_state_storage::ProtocolStateStorage;
42
43/// The document index for one space.
44///
45/// Deliberately thin. Sizes and sequence numbers are derived from the
46/// records themselves at open, so this record can never disagree with the
47/// store about what exists; it only remembers names, which nothing else
48/// records for a document that has never been written.
49#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50struct SpaceRecord {
51 #[serde(default)]
52 docs: BTreeSet<String>,
53}
54
55/// A document held open in memory.
56struct LoadedDoc {
57 doc: DataDoc,
58 /// Sequence number the next persisted commit will use.
59 next_seq: u64,
60 /// Total size of the delta records currently on disk for this document.
61 log_bytes: usize,
62 /// Size of the last compacted record written for this document.
63 compacted_bytes: usize,
64 /// Set when the document's compacted encoding passed the cap.
65 ///
66 /// Growth is refused while it is set; deletions are not, so an
67 /// application that hit the cap can shrink its way back out. Cleared by
68 /// the next flush that measures under the cap.
69 over_cap: bool,
70 /// Whether every delta record on disk actually applied at open.
71 ///
72 /// False when a delta was unreadable, or when one imported but parked
73 /// behind a predecessor that never arrived. Both mean the document in
74 /// memory is missing changes the records still describe, and both make
75 /// compaction destructive: folding the log would write a snapshot without
76 /// those changes and then delete the records holding them. A growing log
77 /// is the cheaper failure, so compaction is simply switched off until an
78 /// open sees a complete log again.
79 history_complete: bool,
80}
81
82/// In-memory state for the data layer.
83#[derive(Default)]
84pub(crate) struct DataLayer {
85 docs: BTreeMap<(String, String), LoadedDoc>,
86 spaces: BTreeMap<String, SpaceRecord>,
87}
88
89impl std::fmt::Debug for DataLayer {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 f.debug_struct("DataLayer")
92 .field("open_docs", &self.docs.len())
93 .field("loaded_spaces", &self.spaces.len())
94 .finish()
95 }
96}
97
98fn map_data_error(err: DataError) -> Error {
99 match err {
100 DataError::DocTooLarge { actual, limit } => Error::DocTooLarge { actual, limit },
101 DataError::Corrupt(detail) | DataError::Engine(detail) => Error::DataCorrupted(detail),
102 DataError::Poisoned => {
103 Error::DataCorrupted("document handle poisoned by an earlier decode failure".into())
104 }
105 DataError::InvalidName { name, reason } => {
106 Error::InvalidArgument(format!("invalid name {name:?}: {reason}"))
107 }
108 DataError::ValueTooLarge { actual, limit } => Error::InvalidArgument(format!(
109 "value is {actual} bytes, over the {limit} byte limit"
110 )),
111 DataError::InvalidAttachment { reason } => {
112 Error::InvalidArgument(format!("invalid attachment: {reason}"))
113 }
114 DataError::OutOfRange { position, length } => Error::InvalidArgument(format!(
115 "position {position} is out of range for length {length}"
116 )),
117 // Forced by `#[non_exhaustive]` on `DataError`, which the data crate
118 // carries so a new variant is not a semver break for published
119 // consumers. That trade costs the compile error that would otherwise
120 // catch an unmapped variant, so it is replaced with a `warn!`: an
121 // unmapped variant shows up in logs instead of vanishing. When you add
122 // a variant, add its arm here — this is a tripwire, not a destination.
123 ref other => {
124 warn!(
125 error = %other,
126 "unmapped DataError variant reached the protocol boundary; \
127 add an explicit arm in protocol/data.rs"
128 );
129 Error::Other(other.to_string())
130 }
131 }
132}
133
134/// Compose the record key for a document's compacted snapshot.
135fn doc_key(space: &str, doc: &str) -> String {
136 format!("{space}/{doc}")
137}
138
139/// Compose the record key for one persisted commit.
140///
141/// The sequence is zero-padded hex so that lexicographic order — which is
142/// the only order `list_keys` promises anything about — is apply order.
143fn delta_key(space: &str, doc: &str, seq: u64) -> String {
144 format!("{space}/{doc}/{seq:016x}")
145}
146
147/// Parse the sequence out of a delta record key belonging to this document.
148fn delta_seq(key: &str, prefix: &str) -> Option<u64> {
149 let suffix = key.strip_prefix(prefix)?;
150 u64::from_str_radix(suffix, 16).ok()
151}
152
153impl OfflineProtocol {
154 /// The backend documents are stored in.
155 ///
156 /// The application's override when it set one, and otherwise the store
157 /// protocol state already uses — which is what makes the layer work with
158 /// no storage configuration at all.
159 fn data_storage(&self) -> Option<Arc<dyn ProtocolStateStorage>> {
160 self.config
161 .data
162 .storage
163 .clone()
164 .or_else(|| self.protocol_state_storage.clone())
165 }
166
167 /// The backend, or the typed error explaining which precondition failed.
168 fn require_data_storage(&self) -> Result<Arc<dyn ProtocolStateStorage>> {
169 if !self.config.data.enabled {
170 return Err(Error::DataDisabled);
171 }
172 self.data_storage().ok_or(Error::DataStorageUnavailable)
173 }
174
175 fn validate_ids(space: &str, doc: &str) -> Result<()> {
176 offline_protocol_data::validate_space_name(space).map_err(map_data_error)?;
177 offline_protocol_data::validate_name(doc).map_err(map_data_error)?;
178 Ok(())
179 }
180
181 /// Read a space's document index, loading it on first touch.
182 fn load_space(&mut self, storage: &dyn ProtocolStateStorage, space: &str) -> Result<()> {
183 if self.data.spaces.contains_key(space) {
184 return Ok(());
185 }
186 let mut record = SpaceRecord::default();
187 match self.read_state_record(storage, storage_keys::DATA_SPACES, space) {
188 Ok(Some(bytes)) => match serde_json::from_slice::<SpaceRecord>(&bytes) {
189 Ok(parsed) => record = parsed,
190 Err(err) => {
191 // The index is a cache: a damaged one costs the names of
192 // documents that have no records yet, never a document.
193 warn!(space, error = %err, "Unreadable data space index; rebuilding from records");
194 }
195 },
196 Ok(None) => {}
197 Err(err) => {
198 warn!(space, error = %err, "Failed to read data space index");
199 }
200 }
201
202 // Reconcile against what is actually stored, so the index can never
203 // hide a document that exists.
204 for key_type in [storage_keys::DATA_DOCS, storage_keys::DATA_DELTA_LOG] {
205 if let Ok(keys) = storage.list_keys(key_type) {
206 for key in keys {
207 if let Some(rest) = key.strip_prefix(&format!("{space}/")) {
208 let name = rest.split('/').next().unwrap_or(rest);
209 if !name.is_empty() {
210 record.docs.insert(name.to_string());
211 }
212 }
213 }
214 }
215 }
216
217 self.data.spaces.insert(space.to_string(), record);
218 Ok(())
219 }
220
221 fn persist_space(&mut self, storage: &dyn ProtocolStateStorage, space: &str) -> Result<()> {
222 let Some(record) = self.data.spaces.get(space) else {
223 return Ok(());
224 };
225 let bytes =
226 serde_json::to_vec(record).map_err(|err| Error::Serialization(err.to_string()))?;
227 self.write_state_record(storage, storage_keys::DATA_SPACES, space, &bytes)
228 .map_err(|err| Error::Other(format!("failed to persist data space index: {err}")))
229 }
230
231 /// Open a document, loading its compacted record and delta log.
232 ///
233 /// Applying the compacted record and then every delta is safe in any
234 /// order and safe to repeat: duplicates are absorbed by the merge, which
235 /// is exactly why a crash at any point during compaction leaves a
236 /// recoverable store.
237 fn open_doc(
238 &mut self,
239 storage: &dyn ProtocolStateStorage,
240 space: &str,
241 doc: &str,
242 ) -> Result<()> {
243 if self
244 .data
245 .docs
246 .contains_key(&(space.to_string(), doc.to_string()))
247 {
248 return Ok(());
249 }
250 self.load_space(storage, space)?;
251
252 let mut document = DataDoc::new();
253 let mut compacted_bytes = 0usize;
254 let mut history_complete = true;
255
256 match self.read_state_record_detailed(
257 storage,
258 storage_keys::DATA_DOCS,
259 &doc_key(space, doc),
260 ) {
261 Ok(StateRecord::Present(bytes)) => {
262 compacted_bytes = bytes.len();
263 // A snapshot is self-contained, so parking here should not be
264 // reachable. Recorded rather than assumed away: if it ever is,
265 // the document is missing its own base and compaction would
266 // write that gap over the delta log.
267 if document.import(&bytes).map_err(map_data_error)?.is_parked() {
268 history_complete = false;
269 warn!(space, doc, "Document snapshot did not fully apply");
270 }
271 }
272 // `Unavailable` means it is still on disk and may be fine, so
273 // refusing here keeps a transient failure from looking like data
274 // loss to the application.
275 Ok(StateRecord::Unavailable) => {
276 return Err(Error::Other(format!(
277 "document {space}/{doc} could not be read this session"
278 )));
279 }
280 // `Unreadable` means the record was examined and is permanently
281 // gone; the delta log may still carry the document forward. The
282 // document has lost its base either way, so compaction stays off:
283 // folding now would write whatever partial state survived over the
284 // records that still hold the rest.
285 Ok(StateRecord::Unreadable) => {
286 history_complete = false;
287 warn!(space, doc, "Document snapshot is permanently unreadable");
288 }
289 Ok(StateRecord::Missing) => {}
290 Err(err) => {
291 return Err(Error::Other(format!("failed to read document: {err}")));
292 }
293 }
294
295 let prefix = format!("{space}/{doc}/");
296 let mut sequences: Vec<u64> = storage
297 .list_keys(storage_keys::DATA_DELTA_LOG)
298 .unwrap_or_default()
299 .iter()
300 .filter_map(|key| delta_seq(key, &prefix))
301 .collect();
302 sequences.sort_unstable();
303
304 let mut log_bytes = 0usize;
305 let mut next_seq = 0u64;
306 let mut replayed = 0u32;
307 for seq in sequences {
308 next_seq = next_seq.max(seq.saturating_add(1));
309 let key = delta_key(space, doc, seq);
310 match self.read_state_record_detailed(storage, storage_keys::DATA_DELTA_LOG, &key) {
311 Ok(StateRecord::Present(bytes)) => {
312 log_bytes = log_bytes.saturating_add(bytes.len());
313 replayed = replayed.saturating_add(1);
314 match document.import(&bytes) {
315 // The engine accepted the delta but cannot apply it:
316 // its predecessor is missing, so it and everything
317 // after it are invisible. Recording that is what stops
318 // compaction from writing a snapshot without these
319 // changes and then deleting the records holding them.
320 Ok(outcome) if outcome.is_parked() => {
321 history_complete = false;
322 warn!(
323 space,
324 doc,
325 seq,
326 "Document delta is waiting on a predecessor that never \
327 arrived; compaction is disabled for this document"
328 );
329 }
330 Ok(_) => {}
331 Err(err) => {
332 // A permanently undecodable delta is a hole. The
333 // document still opens with everything that did
334 // apply, but the log must not be folded away: F3
335 // resend is what closes a hole, and compaction
336 // would destroy what is left to resend against.
337 history_complete = false;
338 warn!(space, doc, seq, error = %err, "Skipping undecodable document delta");
339 }
340 }
341 }
342 // Still on disk and possibly perfectly good next launch.
343 // Refusing the open keeps a transient read failure from
344 // looking like data loss, exactly as the snapshot path above
345 // does, and keeps compaction away from records that
346 // everything after this delta depends on.
347 Ok(StateRecord::Unavailable) => {
348 return Err(Error::Other(format!(
349 "delta {seq} of document {space}/{doc} could not be read this session"
350 )));
351 }
352 // Listed but gone or permanently unreadable: a hole, not a
353 // reason to refuse the document.
354 Ok(_) => {
355 history_complete = false;
356 warn!(space, doc, seq, "Document delta is missing or unreadable");
357 }
358 Err(err) => {
359 return Err(Error::Other(format!(
360 "failed to read delta {seq} of document {space}/{doc}: {err}"
361 )));
362 }
363 }
364 }
365
366 document.mark_persisted();
367 if compacted_bytes == 0 {
368 compacted_bytes = document.measure().map_err(map_data_error)?;
369 }
370 // Hand the document what its own bookkeeping cannot know after a
371 // fresh open: how big the record it came from was, how much delta
372 // log sits on top, and how many commits that log represents. Skip
373 // this and a reopened document looks empty to the size check and to
374 // the compaction trigger, both of which then under-fire.
375 document.restore_bookkeeping(compacted_bytes, log_bytes, replayed);
376
377 if let Some(record) = self.data.spaces.get_mut(space) {
378 record.docs.insert(doc.to_string());
379 }
380 self.data.docs.insert(
381 (space.to_string(), doc.to_string()),
382 LoadedDoc {
383 doc: document,
384 next_seq,
385 log_bytes,
386 compacted_bytes,
387 over_cap: false,
388 history_complete,
389 },
390 );
391 Ok(())
392 }
393
394 /// Run an edit against an open document.
395 ///
396 /// `growth` marks operations that can make the document bigger. Those are
397 /// refused once the cap has been passed; deletions are not, so an
398 /// application that filled a document can still empty it.
399 fn with_doc<F>(&mut self, space: &str, doc: &str, growth: bool, edit: F) -> Result<()>
400 where
401 F: FnOnce(&mut DataDoc) -> std::result::Result<(), DataError>,
402 {
403 Self::validate_ids(space, doc)?;
404 let storage = self.require_data_storage()?;
405 self.open_doc(storage.as_ref(), space, doc)?;
406
407 let entry = self
408 .data
409 .docs
410 .get_mut(&(space.to_string(), doc.to_string()))
411 .ok_or_else(|| Error::Other("document vanished after open".to_string()))?;
412
413 if growth && entry.over_cap {
414 return Err(Error::DocTooLarge {
415 actual: entry.compacted_bytes,
416 limit: policy::MAX_DOC_BYTES,
417 });
418 }
419 edit(&mut entry.doc).map_err(map_data_error)
420 }
421
422 /// Read from an open document.
423 fn read_doc<F, T>(&mut self, space: &str, doc: &str, read: F) -> Result<T>
424 where
425 F: FnOnce(&DataDoc) -> std::result::Result<T, DataError>,
426 {
427 Self::validate_ids(space, doc)?;
428 let storage = self.require_data_storage()?;
429 self.open_doc(storage.as_ref(), space, doc)?;
430 let entry = self
431 .data
432 .docs
433 .get(&(space.to_string(), doc.to_string()))
434 .ok_or_else(|| Error::Other("document vanished after open".to_string()))?;
435 read(&entry.doc).map_err(map_data_error)
436 }
437
438 // ---- public surface ---------------------------------------------
439
440 /// Point documents at a backend the application supplies.
441 ///
442 /// Documents move; protocol secrets do not. Applied immediately, so a
443 /// store opened afterwards reads and writes through the new backend while
444 /// everything else stays where it is.
445 ///
446 /// Any document already open is written into the new backend as a
447 /// self-contained snapshot before this returns. That is not a
448 /// convenience, it is the whole correctness of a mid-session swap: a
449 /// delta record only describes the change *since* the previous one, so a
450 /// document that merely kept writing deltas into the new backend would
451 /// leave every earlier delta behind in the old one. The engine accepts
452 /// such an orphan delta and parks it, which means the document reads
453 /// **empty** from the new backend and the next compaction deletes the
454 /// orphans for good.
455 ///
456 /// If the migration cannot be written the swap does not happen at all and
457 /// the error is returned, because a half-swapped layer is precisely the
458 /// stranding this method exists to prevent.
459 ///
460 /// Documents that are not open are not migrated: they live wholly in the
461 /// old backend, which is what switching backends means.
462 pub fn set_data_storage(&mut self, storage: Arc<dyn ProtocolStateStorage>) -> Result<()> {
463 let previous = self.config.data.storage.take();
464 self.config.data.storage = Some(storage);
465
466 match self.migrate_open_docs_to_data_storage() {
467 Ok(()) => Ok(()),
468 Err(err) => {
469 self.config.data.storage = previous;
470 Err(err)
471 }
472 }
473 }
474
475 /// Write every open document into the current backend as a self-contained
476 /// snapshot, so nothing depends on history that lives somewhere else.
477 ///
478 /// Two phases, and the split is the correctness. Every write happens
479 /// first; only once all of them are durable does any document's
480 /// bookkeeping move. Interleaving the two would leave a document that
481 /// migrated before the failure claiming a fresh empty log, while the
482 /// caller rolls the swap back to the old backend: its next flush would
483 /// then write sequence zero over the delta already there and park
484 /// everything that followed it, which is permanent loss of exactly the
485 /// kind this migration exists to prevent.
486 fn migrate_open_docs_to_data_storage(&mut self) -> Result<()> {
487 let Some(storage) = self.data_storage() else {
488 return Ok(());
489 };
490
491 let keys: Vec<(String, String)> = self.data.docs.keys().cloned().collect();
492 let mut committed: Vec<(String, String)> = Vec::new();
493 let mut migrated: Vec<((String, String), usize)> = Vec::new();
494
495 if let Err(err) =
496 self.write_open_docs_into(storage.as_ref(), &keys, &mut committed, &mut migrated)
497 {
498 // Nothing durable is being claimed, so every export marker this
499 // advanced goes back. Leaving one advanced would drop the edits
500 // it covers from every future delta, which is the same silent
501 // loss a failed delta write already rewinds for.
502 for map_key in committed {
503 if let Some(entry) = self.data.docs.get_mut(&map_key) {
504 entry.doc.rewind_last_commit();
505 }
506 }
507 return Err(err);
508 }
509
510 // Phase two. Every record above is durable, so the swap is going to
511 // stand and the bookkeeping can follow it.
512 for (map_key, compacted_bytes) in migrated {
513 if let Some(entry) = self.data.docs.get_mut(&map_key) {
514 // The new backend holds one snapshot and no delta log.
515 entry.next_seq = 0;
516 entry.log_bytes = 0;
517 entry.compacted_bytes = compacted_bytes;
518 entry.doc.mark_compacted(compacted_bytes);
519 }
520 }
521 Ok(())
522 }
523
524 /// Phase one of a backend swap: every open document and space index
525 /// written into the new backend, with nothing in memory moved yet.
526 ///
527 /// `committed` collects the documents whose export marker this advanced,
528 /// so a failure can put every one of them back. `migrated` collects the
529 /// bookkeeping to apply once every write has succeeded.
530 fn write_open_docs_into(
531 &mut self,
532 storage: &dyn ProtocolStateStorage,
533 keys: &[(String, String)],
534 committed: &mut Vec<(String, String)>,
535 migrated: &mut Vec<((String, String), usize)>,
536 ) -> Result<()> {
537 for (space, doc) in keys {
538 let map_key = (space.clone(), doc.clone());
539
540 // Fold pending edits in first: the snapshot below has to be the
541 // whole document, including what has not been flushed yet.
542 {
543 let Some(entry) = self.data.docs.get_mut(&map_key) else {
544 continue;
545 };
546 if entry.doc.commit().map_err(map_data_error)?.is_some() {
547 committed.push(map_key.clone());
548 }
549 }
550 let compacted = {
551 let Some(entry) = self.data.docs.get(&map_key) else {
552 continue;
553 };
554 entry.doc.export_compacted().map_err(map_data_error)?
555 };
556
557 self.write_state_record(
558 storage,
559 storage_keys::DATA_DOCS,
560 &doc_key(space, doc),
561 &compacted,
562 )
563 .map_err(|err| {
564 Error::Other(format!(
565 "failed to move document {space}/{doc} into the new backend: {err}"
566 ))
567 })?;
568
569 // Snapshot first, then drop the log, in that order and for the
570 // same reason as compaction. A delta record already sitting under
571 // this name in the new backend belongs to some earlier document
572 // that held it: the snapshot just written does not descend from
573 // it, so at the next open it parks and switches compaction off
574 // for good. Best-effort, again as compaction: the snapshot is
575 // already durable, and a leftover costs a growing log rather than
576 // a wrong document.
577 let prefix = format!("{space}/{doc}/");
578 if let Ok(stale) = storage.list_keys(storage_keys::DATA_DELTA_LOG) {
579 for key in stale.iter().filter(|key| key.starts_with(&prefix)) {
580 let _ = storage.delete(storage_keys::DATA_DELTA_LOG, key);
581 }
582 }
583
584 migrated.push((map_key, compacted.len()));
585 }
586
587 // The space indexes carry document names that have no records of
588 // their own yet; without them the new backend forgets those names.
589 let spaces: Vec<String> = self.data.spaces.keys().cloned().collect();
590 for space in spaces {
591 self.persist_space(storage, &space)?;
592 }
593 Ok(())
594 }
595
596 /// Create a document, or do nothing if it already exists.
597 pub fn data_create_doc(&mut self, space: &str, doc: &str) -> Result<()> {
598 Self::validate_ids(space, doc)?;
599 let storage = self.require_data_storage()?;
600 self.open_doc(storage.as_ref(), space, doc)?;
601 self.persist_space(storage.as_ref(), space)
602 }
603
604 // ---- replication seams ------------------------------------------
605 //
606 // The sync module owns the frames and the peer bookkeeping; these are
607 // the four things it needs from the store, and they are here rather
608 // than there so nothing outside this module touches a document handle.
609
610 /// The backend documents live in, when the layer is on.
611 ///
612 /// Unlike `require_data_storage` this answers `None` rather than an
613 /// error: the sync path treats a switched-off layer as "nothing to do",
614 /// not as a failure to report.
615 pub(crate) fn data_storage_for_sync(&self) -> Option<Arc<dyn ProtocolStateStorage>> {
616 if !self.config.data.enabled {
617 return None;
618 }
619 self.data_storage()
620 }
621
622 /// Every document in a space with the version we hold of it.
623 ///
624 /// A document whose version cannot be read is left out rather than
625 /// failing the whole space. Propagating the error costs every other
626 /// document in the space its replication, and does it silently: one
627 /// unreadable record on either leg would leave the offer empty and both
628 /// replicas believing they had nothing to say to each other. Leaving it
629 /// out instead means the peer reads that document as one we have never
630 /// seen and offers it back, which is the recovery this layer already
631 /// has, and the rest of the space keeps converging meanwhile.
632 pub(crate) fn data_sync_versions(&mut self, space: &str) -> Result<BTreeMap<String, String>> {
633 let docs = self.data_list_docs(space)?;
634 let mut versions = BTreeMap::new();
635 for doc in docs {
636 match self.data_doc_version(space, &doc) {
637 Ok(encoded) => {
638 versions.insert(doc, encoded);
639 }
640 Err(err) => {
641 warn!(space, doc, error = %err, "Leaving a document out of the version offer: its version cannot be read");
642 }
643 }
644 }
645 Ok(versions)
646 }
647
648 /// The version we hold of one document, encoded as a frame carries it.
649 pub(crate) fn data_doc_version(&mut self, space: &str, doc: &str) -> Result<String> {
650 use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
651
652 let token = self.read_doc(space, doc, |document| Ok(document.version()))?;
653 Ok(BASE64.encode(token.as_bytes()))
654 }
655
656 /// The names a space holds, or `None` if there is nothing to read.
657 ///
658 /// Answered off the space index, which `load_space` has already
659 /// reconciled against the records themselves and cached. Opening a
660 /// document to find out would defeat both callers, whose whole question
661 /// is whether opening one is allowed.
662 fn space_docs(&mut self, space: &str) -> Option<&BTreeSet<String>> {
663 if offline_protocol_data::validate_space_name(space).is_err() {
664 return None;
665 }
666 let storage = self.data_storage_for_sync()?;
667 self.load_space(storage.as_ref(), space).ok()?;
668 self.data.spaces.get(space).map(|record| &record.docs)
669 }
670
671 /// Whether `space` already holds `doc`.
672 pub(crate) fn data_holds_doc(&mut self, space: &str, doc: &str) -> bool {
673 self.space_docs(space)
674 .is_some_and(|docs| docs.contains(doc))
675 }
676
677 /// Whether a document a peer named may be stored in `space`.
678 ///
679 /// One already held always may; a new one only while the space is under
680 /// `cap`. The cap is a parameter rather than a constant here because it
681 /// bounds what a *peer* can talk this device into storing, which is a
682 /// replication policy: an application creating its own documents is not
683 /// subject to it.
684 ///
685 /// A space that cannot be read admits, rather than refusing on a storage
686 /// hiccup: whatever follows fails on the same storage and reports it
687 /// where the failure actually is.
688 pub(crate) fn data_space_admits_doc(&mut self, space: &str, doc: &str, cap: usize) -> bool {
689 self.space_docs(space)
690 .is_none_or(|docs| docs.contains(doc) || docs.len() < cap)
691 }
692
693 /// What a replica at `theirs` is missing from a document.
694 pub(crate) fn data_catch_up(
695 &mut self,
696 space: &str,
697 doc: &str,
698 theirs: &VersionToken,
699 ) -> Result<CatchUp> {
700 self.read_doc(space, doc, |document| document.export_since(theirs))
701 }
702
703 /// A self-contained encoding of a document, for a peer no run of
704 /// changes can catch up.
705 pub(crate) fn data_export_snapshot(&mut self, space: &str, doc: &str) -> Result<Vec<u8>> {
706 self.read_doc(space, doc, |document| document.export_raw())
707 }
708
709 /// Apply a blob that arrived from a peer, and persist what it changed.
710 ///
711 /// The import goes through the engine's remote path, which judges the
712 /// blob before touching it. A change that applies is flushed here rather
713 /// than left in memory: an unflushed remote change would be lost to a
714 /// restart, and the sender has already been told (by the acknowledgement
715 /// the frame rides on) that it arrived.
716 ///
717 /// `Err` means the change is not durable. A document held over its cap
718 /// still answers `Applied`, because the cap refuses growth *after* the
719 /// change is on disk; the caller cannot tell the two apart from an error
720 /// alone, and one of them is a change it must not report as refused.
721 pub(crate) fn data_apply_remote(
722 &mut self,
723 space: &str,
724 doc: &str,
725 blob: &[u8],
726 ) -> Result<RemoteImport> {
727 Self::validate_ids(space, doc)?;
728 let storage = self.require_data_storage()?;
729 self.open_doc(storage.as_ref(), space, doc)?;
730
731 let map_key = (space.to_string(), doc.to_string());
732 let outcome = {
733 let entry = self
734 .data
735 .docs
736 .get_mut(&map_key)
737 .ok_or_else(|| Error::Other("document vanished after open".to_string()))?;
738 let outcome = entry.doc.import_remote(blob).map_err(map_data_error)?;
739 // A parked change is invisible to the engine's own version, so
740 // nothing downstream may fold history while one is outstanding:
741 // compaction would write a snapshot without it and then delete
742 // the records it is waiting for.
743 //
744 // It stays false for the rest of this document's time in memory,
745 // deliberately. The engine reports what one import left pending,
746 // never whether a document still holds anything parked, so a
747 // later import that applies cleanly is not evidence that the
748 // earlier gap closed. Clearing it on that evidence would re-arm
749 // compaction while a change is still waiting, which is the loss
750 // this flag exists to prevent; leaving it set costs a document
751 // its compaction until it is next opened, where the flag is
752 // recomputed from the records themselves.
753 if outcome == RemoteImport::Parked {
754 entry.history_complete = false;
755 }
756 outcome
757 };
758
759 if outcome == RemoteImport::Applied {
760 // Remote work is not this replica's to push onward, so the flush
761 // is told where the change came from.
762 //
763 // `DocTooLarge` is not an import failure and is not reported as
764 // one. It is raised after the delta record was written and the
765 // push decided, so the imported change is already durable; what
766 // failed is the size verdict that follows, and what it refuses is
767 // further *growth*. Propagating it would say the change was
768 // refused when it applied, skip the space record below, and cost
769 // the caller the one signal that a local edit may have been
770 // folded into this import and suppressed with it, because that
771 // compensation is gated on hearing `Applied`. A document over its
772 // cap is exactly where that matters: the pending edit it still
773 // accepts is a deletion, which is the route back under.
774 match self.flush_doc_from(storage.as_ref(), space, doc, Some(space)) {
775 Ok(()) => {}
776 Err(Error::DocTooLarge { .. }) => {
777 debug!(
778 space,
779 doc, "Applied a remote change into a document that is over its cap"
780 );
781 }
782 Err(err) => return Err(err),
783 }
784 self.persist_space(storage.as_ref(), space)?;
785 }
786 Ok(outcome)
787 }
788
789 /// Delete a document and every record belonging to it.
790 pub fn data_delete_doc(&mut self, space: &str, doc: &str) -> Result<()> {
791 Self::validate_ids(space, doc)?;
792 let storage = self.require_data_storage()?;
793 self.load_space(storage.as_ref(), space)?;
794
795 self.data.docs.remove(&(space.to_string(), doc.to_string()));
796
797 // Every record is attempted even after one fails, so a partial
798 // failure still removes as much as it can; the first error is what
799 // the caller hears. Reporting matters because a record that survives
800 // a delete is replayed into the *next* document of the same name,
801 // which resurrects the deleted incarnation's contents.
802 let mut first_error: Option<String> = None;
803 let mut record_error = |err: crate::protocol_state_storage::ProtocolStateError| {
804 if first_error.is_none() {
805 first_error = Some(err.to_string());
806 }
807 };
808
809 if let Err(err) = storage.delete(storage_keys::DATA_DOCS, &doc_key(space, doc)) {
810 record_error(err);
811 }
812 let prefix = format!("{space}/{doc}/");
813 match storage.list_keys(storage_keys::DATA_DELTA_LOG) {
814 Ok(keys) => {
815 for key in keys.iter().filter(|key| key.starts_with(&prefix)) {
816 if let Err(err) = storage.delete(storage_keys::DATA_DELTA_LOG, key) {
817 record_error(err);
818 }
819 }
820 }
821 Err(err) => record_error(err),
822 }
823 if let Some(record) = self.data.spaces.get_mut(space) {
824 record.docs.remove(doc);
825 }
826 self.persist_space(storage.as_ref(), space)?;
827
828 match first_error {
829 Some(detail) => Err(Error::Other(format!(
830 "failed to delete every record of document {space}/{doc}: {detail}"
831 ))),
832 None => Ok(()),
833 }
834 }
835
836 /// The documents in a space.
837 pub fn data_list_docs(&mut self, space: &str) -> Result<Vec<String>> {
838 offline_protocol_data::validate_space_name(space).map_err(map_data_error)?;
839 let storage = self.require_data_storage()?;
840 self.load_space(storage.as_ref(), space)?;
841 Ok(self
842 .data
843 .spaces
844 .get(space)
845 .map(|record| record.docs.iter().cloned().collect())
846 .unwrap_or_default())
847 }
848
849 /// Every space the store knows of.
850 ///
851 /// Derived from what is on disk, so a space whose last document was
852 /// deleted still lists while its index record survives.
853 pub fn data_list_spaces(&mut self) -> Result<Vec<String>> {
854 let storage = self.require_data_storage()?;
855 let mut spaces = BTreeSet::new();
856 for key_type in [
857 storage_keys::DATA_SPACES,
858 storage_keys::DATA_DOCS,
859 storage_keys::DATA_DELTA_LOG,
860 ] {
861 if let Ok(keys) = storage.list_keys(key_type) {
862 for key in keys {
863 let name = key.split('/').next().unwrap_or(&key);
864 if !name.is_empty() {
865 spaces.insert(name.to_string());
866 }
867 }
868 }
869 }
870 for space in self.data.spaces.keys() {
871 spaces.insert(space.clone());
872 }
873 Ok(spaces.into_iter().collect())
874 }
875
876 /// Set a key in a map collection.
877 pub fn data_map_set(
878 &mut self,
879 space: &str,
880 doc: &str,
881 collection: &str,
882 key: &str,
883 value: DataValue,
884 ) -> Result<()> {
885 self.with_doc(space, doc, true, |document| {
886 document.map_set(collection, key, value)
887 })
888 }
889
890 /// Remove a key from a map collection.
891 pub fn data_map_delete(
892 &mut self,
893 space: &str,
894 doc: &str,
895 collection: &str,
896 key: &str,
897 ) -> Result<()> {
898 self.with_doc(space, doc, false, |document| {
899 document.map_delete(collection, key)
900 })
901 }
902
903 /// Read a key from a map collection.
904 pub fn data_map_get(
905 &mut self,
906 space: &str,
907 doc: &str,
908 collection: &str,
909 key: &str,
910 ) -> Result<Option<DataValue>> {
911 self.read_doc(space, doc, |document| document.map_get(collection, key))
912 }
913
914 /// Append to a list collection.
915 pub fn data_list_push(
916 &mut self,
917 space: &str,
918 doc: &str,
919 collection: &str,
920 value: DataValue,
921 ) -> Result<()> {
922 self.with_doc(space, doc, true, |document| {
923 document.list_push(collection, value)
924 })
925 }
926
927 /// Insert into a list collection.
928 pub fn data_list_insert(
929 &mut self,
930 space: &str,
931 doc: &str,
932 collection: &str,
933 index: u32,
934 value: DataValue,
935 ) -> Result<()> {
936 self.with_doc(space, doc, true, |document| {
937 document.list_insert(collection, index as usize, value)
938 })
939 }
940
941 /// Delete entries from a list collection.
942 pub fn data_list_delete(
943 &mut self,
944 space: &str,
945 doc: &str,
946 collection: &str,
947 index: u32,
948 count: u32,
949 ) -> Result<()> {
950 self.with_doc(space, doc, false, |document| {
951 document.list_delete(collection, index as usize, count as usize)
952 })
953 }
954
955 /// The number of entries in a list collection.
956 pub fn data_list_len(&mut self, space: &str, doc: &str, collection: &str) -> Result<u32> {
957 let len = self.read_doc(space, doc, |document| document.list_len(collection))?;
958 Ok(len as u32)
959 }
960
961 /// Insert into a text collection at a character position.
962 pub fn data_text_insert(
963 &mut self,
964 space: &str,
965 doc: &str,
966 collection: &str,
967 position: u32,
968 text: &str,
969 ) -> Result<()> {
970 self.with_doc(space, doc, true, |document| {
971 document.text_insert(collection, position as usize, text)
972 })
973 }
974
975 /// Delete characters from a text collection.
976 pub fn data_text_delete(
977 &mut self,
978 space: &str,
979 doc: &str,
980 collection: &str,
981 position: u32,
982 count: u32,
983 ) -> Result<()> {
984 self.with_doc(space, doc, false, |document| {
985 document.text_delete(collection, position as usize, count as usize)
986 })
987 }
988
989 /// The contents of a text collection.
990 pub fn data_text_value(&mut self, space: &str, doc: &str, collection: &str) -> Result<String> {
991 self.read_doc(space, doc, |document| document.text_value(collection))
992 }
993
994 /// Add to a counter collection. Negative values subtract.
995 pub fn data_counter_increment(
996 &mut self,
997 space: &str,
998 doc: &str,
999 collection: &str,
1000 amount: f64,
1001 ) -> Result<()> {
1002 self.with_doc(space, doc, true, |document| {
1003 document.counter_increment(collection, amount)
1004 })
1005 }
1006
1007 /// The value of a counter collection.
1008 pub fn data_counter_value(&mut self, space: &str, doc: &str, collection: &str) -> Result<f64> {
1009 self.read_doc(space, doc, |document| document.counter_value(collection))
1010 }
1011
1012 /// The document's current state as plain JSON.
1013 pub fn data_doc_json(&mut self, space: &str, doc: &str) -> Result<String> {
1014 self.read_doc(space, doc, |document| document.export_json())
1015 }
1016
1017 /// The document's full history, engine-encoded.
1018 ///
1019 /// Half of the escape hatch: this and [`Self::data_doc_json`] are what
1020 /// let an application take its data and leave.
1021 pub fn data_export_raw(&mut self, space: &str, doc: &str) -> Result<Vec<u8>> {
1022 self.read_doc(space, doc, |document| document.export_raw())
1023 }
1024
1025 /// Persist any pending edits to a document.
1026 ///
1027 /// Writes one delta record, then compacts if the log has outgrown the
1028 /// document. The order is load-bearing: the delta is durable before any
1029 /// bookkeeping assumes it, and at compaction the fresh snapshot is
1030 /// durable before the deltas it folded are deleted. A crash at any point
1031 /// leaves duplicate history, which the merge absorbs, rather than a gap,
1032 /// which it cannot.
1033 pub fn data_flush(&mut self, space: &str, doc: &str) -> Result<()> {
1034 Self::validate_ids(space, doc)?;
1035 let storage = self.require_data_storage()?;
1036 self.open_doc(storage.as_ref(), space, doc)?;
1037 self.flush_doc(storage.as_ref(), space, doc)
1038 }
1039
1040 fn flush_doc(
1041 &mut self,
1042 storage: &dyn ProtocolStateStorage,
1043 space: &str,
1044 doc: &str,
1045 ) -> Result<()> {
1046 self.flush_doc_from(storage, space, doc, None)
1047 }
1048
1049 /// [`Self::flush_doc`], told which peer the change came from.
1050 ///
1051 /// `origin` is `None` for a local edit and the peer's address for one
1052 /// applied from a sync frame. It exists so replication does not send a
1053 /// change straight back to whoever sent it: the merge would absorb the
1054 /// echo, but on a mesh it is a frame nobody needed.
1055 fn flush_doc_from(
1056 &mut self,
1057 storage: &dyn ProtocolStateStorage,
1058 space: &str,
1059 doc: &str,
1060 origin: Option<&str>,
1061 ) -> Result<()> {
1062 let map_key = (space.to_string(), doc.to_string());
1063
1064 let Some(entry) = self.data.docs.get_mut(&map_key) else {
1065 return Ok(());
1066 };
1067 let Some(delta) = entry.doc.commit().map_err(map_data_error)? else {
1068 return Ok(());
1069 };
1070 let seq = entry.next_seq;
1071 let delta_bytes = delta.bytes.len();
1072 let history_complete = entry.history_complete;
1073
1074 // A commit too large for one record can never be stored as a delta,
1075 // and refusing it is not a recovery: the rewind below puts the change
1076 // back in the pending set, so the *next* commit exports a strictly
1077 // larger delta and fails the same way forever. A compacted snapshot
1078 // is bounded by the document cap rather than by history, so it is the
1079 // route that still works, and the commit's changes are inside it.
1080 let oversized = delta_bytes > MAX_PROTOCOL_STATE_RECORD_BYTES;
1081
1082 let rewind = |protocol: &mut Self| {
1083 if let Some(entry) = protocol.data.docs.get_mut(&map_key) {
1084 entry.doc.rewind_last_commit();
1085 }
1086 };
1087
1088 if oversized && !history_complete {
1089 // Compaction is the only way out and it is unavailable: folding a
1090 // log with a hole in it would delete records the document still
1091 // needs. Say so rather than looping on an unwritable delta.
1092 rewind(self);
1093 return Err(Error::Other(format!(
1094 "commit for document {space}/{doc} is {delta_bytes} bytes, over the \
1095 {MAX_PROTOCOL_STATE_RECORD_BYTES} byte record limit, and its delta log \
1096 is incomplete so it cannot be compacted instead"
1097 )));
1098 }
1099
1100 if oversized {
1101 warn!(
1102 space,
1103 doc, delta_bytes, "Commit is too large for one delta record; compacting instead"
1104 );
1105 if let Err(err) = self.compact_doc(storage, space, doc) {
1106 rewind(self);
1107 return Err(err);
1108 }
1109 } else if let Err(err) = self.write_state_record(
1110 storage,
1111 storage_keys::DATA_DELTA_LOG,
1112 &delta_key(space, doc, seq),
1113 &delta.bytes,
1114 ) {
1115 // Put the export marker back. The commit already advanced it, so
1116 // leaving it advanced would mean this change is never handed out
1117 // again: the document keeps it in memory, the next commit exports
1118 // only what came after, and the change is simply gone at the next
1119 // restart. Re-exporting it later costs at worst a duplicate
1120 // record, which the merge absorbs.
1121 rewind(self);
1122 return Err(Error::Other(format!(
1123 "failed to persist document delta: {err}"
1124 )));
1125 }
1126
1127 let (verdict, measured, should_compact, compacted_before) = {
1128 let entry = self
1129 .data
1130 .docs
1131 .get_mut(&map_key)
1132 .ok_or_else(|| Error::Other("document vanished during flush".to_string()))?;
1133 if !oversized {
1134 entry.next_seq = seq.saturating_add(1);
1135 entry.log_bytes = entry.log_bytes.saturating_add(delta_bytes);
1136 }
1137
1138 let (verdict, measured) = entry.doc.check_size().map_err(map_data_error)?;
1139 if let Some(bytes) = measured {
1140 entry.compacted_bytes = bytes;
1141 }
1142 // Never fold a log that did not fully apply, and never compact
1143 // twice for one flush.
1144 let should_compact = !oversized
1145 && entry.history_complete
1146 && policy::should_compact(
1147 entry.log_bytes,
1148 entry.compacted_bytes,
1149 entry.doc.commits_since_compaction(),
1150 );
1151 (verdict, measured, should_compact, entry.compacted_bytes)
1152 };
1153
1154 // Replicated here for the same reason the event fires here: the
1155 // claim being made is that the change is durable, and that became
1156 // true at the write above. Pushing before it would advertise a
1157 // change that a crash could still take back, and pushing after
1158 // compaction would let a compaction failure suppress replication of
1159 // something already on disk.
1160 //
1161 // The oversized branch wrote a compacted snapshot instead of a delta
1162 // record, so there is no delta to push. Offering versions is how the
1163 // peer finds the gap: waiting for its next offer would leave both
1164 // replicas believing they agree for as long as the link holds, since
1165 // a link that never drops never fires a trigger.
1166 if oversized {
1167 self.nudge_data_sync(space, origin, "oversized_commit");
1168 } else {
1169 self.push_data_delta(space, doc, &delta.bytes, origin);
1170 }
1171
1172 // Emitted here, before compaction: the event's claim is that the
1173 // change reached storage, and that became true at the write above.
1174 // Emitting after compaction would let a compaction failure suppress
1175 // the notification for a change that is already durable.
1176 self.emit_event(Event::DataChanged {
1177 space_id: space.to_string(),
1178 doc_id: doc.to_string(),
1179 delta_bytes: delta_bytes as u64,
1180 });
1181
1182 if should_compact {
1183 self.compact_doc(storage, space, doc)?;
1184 }
1185
1186 match verdict {
1187 policy::SizeVerdict::TooLarge => {
1188 let actual = measured.unwrap_or(compacted_before);
1189 if let Some(entry) = self.data.docs.get_mut(&map_key) {
1190 entry.over_cap = true;
1191 }
1192 // The change is already durable: refusing to persist it would
1193 // lose work the application believed it had made. What is
1194 // refused is further growth, and deletions stay open so the
1195 // document can be brought back under the cap.
1196 Err(Error::DocTooLarge {
1197 actual,
1198 limit: policy::MAX_DOC_BYTES,
1199 })
1200 }
1201 policy::SizeVerdict::Warn => {
1202 if let Some(entry) = self.data.docs.get_mut(&map_key) {
1203 entry.over_cap = false;
1204 }
1205 self.emit_event(Event::DataDocSizeWarning {
1206 space_id: space.to_string(),
1207 doc_id: doc.to_string(),
1208 compacted_bytes: measured.unwrap_or(compacted_before) as u64,
1209 cap_bytes: policy::MAX_DOC_BYTES as u64,
1210 });
1211 Ok(())
1212 }
1213 policy::SizeVerdict::Ok => {
1214 if let Some(entry) = self.data.docs.get_mut(&map_key) {
1215 entry.over_cap = false;
1216 }
1217 Ok(())
1218 }
1219 }
1220 }
1221
1222 /// Fold a document's delta log into a fresh compacted record.
1223 fn compact_doc(
1224 &mut self,
1225 storage: &dyn ProtocolStateStorage,
1226 space: &str,
1227 doc: &str,
1228 ) -> Result<()> {
1229 let map_key = (space.to_string(), doc.to_string());
1230 let compacted = {
1231 let Some(entry) = self.data.docs.get(&map_key) else {
1232 return Ok(());
1233 };
1234 // Folding a log that did not fully apply would write a snapshot
1235 // missing those changes and then delete the records that still
1236 // hold them, turning a recoverable gap into permanent loss. An
1237 // oversized delta log is the cheaper failure.
1238 if !entry.history_complete {
1239 warn!(
1240 space,
1241 doc, "Skipping compaction: the document's delta log did not fully apply"
1242 );
1243 return Ok(());
1244 }
1245 entry.doc.export_compacted().map_err(map_data_error)?
1246 };
1247
1248 // Durable first. Everything after this point is cleanup, and a crash
1249 // in the middle of cleanup costs disk, not data.
1250 self.write_state_record(
1251 storage,
1252 storage_keys::DATA_DOCS,
1253 &doc_key(space, doc),
1254 &compacted,
1255 )
1256 .map_err(|err| Error::Other(format!("failed to persist compacted document: {err}")))?;
1257
1258 let prefix = format!("{space}/{doc}/");
1259 if let Ok(keys) = storage.list_keys(storage_keys::DATA_DELTA_LOG) {
1260 for key in keys.iter().filter(|key| key.starts_with(&prefix)) {
1261 let _ = storage.delete(storage_keys::DATA_DELTA_LOG, key);
1262 }
1263 }
1264
1265 if let Some(entry) = self.data.docs.get_mut(&map_key) {
1266 entry.log_bytes = 0;
1267 entry.compacted_bytes = compacted.len();
1268 entry.doc.mark_compacted(compacted.len());
1269 }
1270 debug!(space, doc, bytes = compacted.len(), "Compacted document");
1271 Ok(())
1272 }
1273
1274 /// Persist every open document with pending edits.
1275 ///
1276 /// Called on shutdown, so the debounce window between an edit and its
1277 /// record can never be the reason a change is lost.
1278 pub fn data_flush_all(&mut self) -> Result<()> {
1279 let Ok(storage) = self.require_data_storage() else {
1280 return Ok(());
1281 };
1282 let keys: Vec<(String, String)> = self.data.docs.keys().cloned().collect();
1283 for (space, doc) in keys {
1284 match self.flush_doc(storage.as_ref(), &space, &doc) {
1285 Ok(()) => {}
1286 // Not a failed flush, and not reported as one: the record was
1287 // written and only the size verdict after it refused further
1288 // growth. On a shutdown path the distinction is the whole
1289 // question an operator is asking, which is whether anything
1290 // was lost.
1291 Err(Error::DocTooLarge { .. }) => {
1292 debug!(
1293 space,
1294 doc, "Flushed a document that is over its cap on shutdown"
1295 );
1296 }
1297 Err(err) => {
1298 warn!(space, doc, error = %err, "Failed to flush document");
1299 }
1300 }
1301 }
1302 Ok(())
1303 }
1304
1305 /// Delete every record the data layer owns.
1306 ///
1307 /// The logout path for an application that pointed documents at its own
1308 /// backend. `wipePersistedState` clears the account directory of the
1309 /// *default* provider, which a custom backend is not inside, so without
1310 /// this call those documents would outlive the account that made them.
1311 ///
1312 /// **Only durable once replication has stopped.** There are no deletion
1313 /// tombstones in this release, so nothing distinguishes a space this
1314 /// device wiped from one it has never seen. Called while the engine is
1315 /// running with live sessions, every document comes back: the peer's next
1316 /// version offer names them and they are recreated and refilled from the
1317 /// peer's copy, and an offer of our own naming nothing reads as a replica
1318 /// that has never seen the space. On the logout path the engine is being
1319 /// torn down anyway. Anywhere else, stop it first, and only for as long
1320 /// as it stays stopped: the peer still holds the documents, so they return
1321 /// when replication resumes with it. The call clears this device, it does
1322 /// not delete content.
1323 pub fn data_wipe_all(&mut self) -> Result<()> {
1324 let Some(storage) = self.data_storage() else {
1325 return Ok(());
1326 };
1327 self.data.docs.clear();
1328 self.data.spaces.clear();
1329 // Every window and every outstanding question goes with the content
1330 // they were about. Left behind, a window suppresses the first offer
1331 // or the first blob request made after the wipe, which is the same
1332 // shape [`Self::forget_data_sync_peer`] exists to prevent: a window
1333 // that survives the thing it was measuring.
1334 //
1335 // The pending fetches are not bookkeeping. An entry there is what
1336 // admits arriving attachment bytes, so one surviving a wipe would let
1337 // bytes land for a space this device no longer has, and hand them to
1338 // the application as an answer to a question the wipe erased.
1339 self.last_data_sync_offer.clear();
1340 self.pending_attachment_fetches.clear();
1341 self.blob_request_windows.clear();
1342
1343 // Attempt every record and report the first failure, as
1344 // [`Self::data_delete_doc`] does. Answering `Ok` for a wipe that left
1345 // records behind is the worst shape this call can take: it is the
1346 // logout path, so the records it failed to remove outlive the account
1347 // that made them, and the application has no symptom to notice it by.
1348 let mut first_error: Option<String> = None;
1349 let mut record_error = |err: crate::protocol_state_storage::ProtocolStateError| {
1350 if first_error.is_none() {
1351 first_error = Some(err.to_string());
1352 }
1353 };
1354
1355 // Every key type the layer owns, replication bookkeeping included.
1356 // A key type missing from this list is not a partial wipe that
1357 // reports itself, it is a silent one: the records are keyed by peer
1358 // address, so what survives a logout is who this account replicated
1359 // with.
1360 for key_type in [
1361 storage_keys::DATA_DOCS,
1362 storage_keys::DATA_DELTA_LOG,
1363 storage_keys::DATA_SPACES,
1364 storage_keys::DATA_SYNC,
1365 ] {
1366 match storage.list_keys(key_type) {
1367 Ok(keys) => {
1368 for key in keys {
1369 if let Err(err) = storage.delete(key_type, &key) {
1370 record_error(err);
1371 }
1372 }
1373 }
1374 Err(err) => record_error(err),
1375 }
1376 }
1377
1378 match first_error {
1379 Some(detail) => Err(Error::Other(format!(
1380 "failed to delete every data record: {detail}"
1381 ))),
1382 None => Ok(()),
1383 }
1384 }
1385
1386 /// The compacted size of a document, in bytes.
1387 pub fn data_doc_size(&mut self, space: &str, doc: &str) -> Result<u64> {
1388 Self::validate_ids(space, doc)?;
1389 let storage = self.require_data_storage()?;
1390 self.open_doc(storage.as_ref(), space, doc)?;
1391 let entry = self
1392 .data
1393 .docs
1394 .get_mut(&(space.to_string(), doc.to_string()))
1395 .ok_or_else(|| Error::Other("document vanished after open".to_string()))?;
1396 let bytes = entry.doc.measure().map_err(map_data_error)?;
1397 entry.compacted_bytes = bytes;
1398 Ok(bytes as u64)
1399 }
1400}