1use std::path::Path;
77use std::sync::Arc;
78
79use redb::ReadableTable;
80
81use crate::error::{Error, Result};
82
83const DOCUMENTS: redb::TableDefinition<'static, &str, &[u8]> =
85 redb::TableDefinition::new("documents");
86
87const BLOBS: redb::TableDefinition<'static, &[u8], &[u8]> = redb::TableDefinition::new("blobs");
90
91const DOC_HISTORY: redb::TableDefinition<'static, (&str, u64), &[u8]> =
99 redb::TableDefinition::new("document_history");
100
101const MARKS: redb::TableDefinition<'static, &str, u64> = redb::TableDefinition::new("box_marks");
104
105const PRINCIPAL_ACTIVITY: redb::TableDefinition<'static, &str, &[u8]> =
111 redb::TableDefinition::new("principal_activity");
112
113pub const TWIN_APPLIED: &str = "twin.applied";
120
121pub(crate) enum Txn<T> {
126 Commit(T),
128 CommitUnsynced(T),
131 Discard(T),
133}
134
135struct Group {
139 owner: std::thread::ThreadId,
140 txn: redb::WriteTransaction,
141 dirty: bool,
143 poisoned: bool,
145}
146
147pub const BLOB_ID_LEN: usize = 32;
149
150#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
158pub struct BlobId([u8; BLOB_ID_LEN]);
159
160impl BlobId {
161 pub fn of(bytes: &[u8]) -> Self {
163 use sha2::Digest;
164 let mut hasher = sha2::Sha256::new();
165 hasher.update(bytes);
166 BlobId(hasher.finalize().into())
167 }
168
169 pub fn as_bytes(&self) -> &[u8; BLOB_ID_LEN] {
171 &self.0
172 }
173
174 pub fn from_hex(text: &str) -> Option<Self> {
176 let mut out = [0u8; BLOB_ID_LEN];
177 hex::decode_to_slice(text, &mut out).ok()?;
178 Some(BlobId(out))
179 }
180
181 fn from_slice(bytes: &[u8]) -> Option<Self> {
183 Some(BlobId(bytes.try_into().ok()?))
184 }
185}
186
187impl std::fmt::Display for BlobId {
188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189 f.write_str(&hex::encode(self.0))
190 }
191}
192
193impl std::fmt::Debug for BlobId {
194 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195 write!(f, "BlobId({self})")
196 }
197}
198
199#[derive(Clone, PartialEq, Eq, Debug)]
201pub struct DocVersion {
202 pub seq: u64,
204 pub at: crate::model::UnixMillis,
206 pub blob: BlobId,
209}
210
211pub trait ControlStore: Send + Sync + 'static {
221 fn put_blob(&self, bytes: &[u8]) -> Result<BlobId>;
224
225 fn put_blob_unsynced(&self, bytes: &[u8]) -> Result<BlobId> {
236 self.put_blob(bytes)
237 }
238
239 fn get_blob(&self, id: &BlobId) -> Result<Option<Vec<u8>>>;
241
242 fn read_doc(&self, name: &str) -> Result<Option<BlobId>>;
249
250 fn commit_doc(&self, name: &str, expected: Option<BlobId>, new: BlobId) -> Result<bool>;
259
260 fn history(&self, name: &str) -> Result<Vec<DocVersion>>;
262}
263
264#[derive(Debug, Clone)]
273pub struct RamCopy {
274 pub why: String,
276 docs: std::collections::BTreeMap<String, BlobId>,
277 blobs: std::collections::BTreeMap<BlobId, Vec<u8>>,
278 secrets: std::collections::BTreeMap<String, Vec<u8>>,
279 pub taken_unix_ms: crate::model::UnixMillis,
281}
282
283impl RamCopy {
284 pub fn bytes(&self) -> u64 {
286 self.blobs.values().map(|b| b.len() as u64).sum::<u64>()
287 + self.secrets.values().map(|b| b.len() as u64).sum::<u64>()
288 }
289 pub fn documents(&self) -> usize {
291 self.docs.len()
292 }
293 pub fn pointers(&self) -> Vec<(String, String)> {
296 self.docs.iter().map(|(k, v)| (k.clone(), v.to_string())).collect()
297 }
298 pub(crate) fn secret_row(&self, key: &str) -> Option<&[u8]> {
300 self.secrets.get(key).map(|v| v.as_slice())
301 }
302}
303
304#[derive(Debug, Clone, Default)]
308pub struct ControlExport {
309 pub docs: std::collections::BTreeMap<String, BlobId>,
311 pub blobs: std::collections::BTreeMap<BlobId, Vec<u8>>,
313 pub secrets: std::collections::BTreeMap<String, Option<Vec<u8>>>,
315}
316
317impl ControlDb {
318 pub fn export(&self) -> Result<ControlExport> {
321 let raw = self.read(
322 "exporting the control database",
323 |txn| {
324 let mut docs = std::collections::BTreeMap::new();
325 let mut blobs = std::collections::BTreeMap::new();
326 let table = txn.open_table(DOCUMENTS).map_err(|err| faulted("opening the documents table", err))?;
327 for entry in table.iter().map_err(|err| faulted("scanning the documents table", err))? {
328 let (k, v) = entry.map_err(|err| faulted("reading a document pointer", err))?;
329 if let Some(id) = BlobId::from_slice(v.value()) {
330 docs.insert(k.value().to_owned(), id);
331 }
332 }
333 let table = txn.open_table(BLOBS).map_err(|err| faulted("opening the blobs table", err))?;
334 for entry in table.iter().map_err(|err| faulted("scanning the blobs table", err))? {
335 let (k, v) = entry.map_err(|err| faulted("reading a blob", err))?;
336 if let Some(id) = BlobId::from_slice(k.value()) {
337 blobs.insert(id, v.value().to_vec());
338 }
339 }
340 Ok((docs, blobs, crate::secrets::RedbSecretStore::rows(txn)?))
341 },
342 |ram| Ok((ram.docs.clone(), ram.blobs.clone(), ram.secrets.clone())),
343 )?;
344 let (docs, blobs, rows) = raw;
345 let mut secrets = std::collections::BTreeMap::new();
346 for (key, row) in rows {
347 let value = crate::secrets::RedbSecretStore::decode_row_of(&key, &row).map_err(|err| Error::ControlStore(err.to_string()))?;
348 secrets.insert(key, value);
349 }
350 Ok(ControlExport { docs, blobs, secrets })
351 }
352}
353
354#[derive(Debug, Clone, Copy)]
357pub enum ExportRow<'a> {
358 Blob(BlobId, &'a [u8]),
360 Doc(&'a str, BlobId),
362 Secret(&'a str, Option<&'a [u8]>),
364}
365
366impl ControlDb {
367 pub fn export_each<E>(&self, row: &mut dyn FnMut(ExportRow<'_>) -> std::result::Result<(), E>) -> Result<std::result::Result<(), E>> {
372 let row = std::cell::RefCell::new(row);
373 self.read(
374 "exporting the control database row by row",
375 |txn| export_txn(txn, &mut **row.borrow_mut()),
376 |ram| export_ram(ram, &mut **row.borrow_mut()),
377 )
378 }
379
380 pub fn view(&self) -> Result<ControlView> {
388 if let Some(copy) = read_lock(&self.ram).as_ref() {
389 return Ok(ControlView(View::Ram(Box::new(copy.clone()))));
390 }
391 let guard = read_lock(&self.live);
392 let Some(db) = guard.as_ref() else {
393 return Err(self.off_stripe());
394 };
395 let txn = db
396 .begin_read()
397 .map_err(|err| faulted("beginning a view of the control database", err))?;
398 Ok(ControlView(View::Live(Box::new(txn))))
399 }
400}
401
402pub struct ControlView(View);
404
405enum View {
406 Live(Box<redb::ReadTransaction>),
407 Ram(Box<RamCopy>),
408}
409
410impl ControlView {
411 pub fn principal_activity(&self) -> Result<Vec<(String, Vec<u8>)>> {
415 match &self.0 {
416 View::Live(txn) => activity_rows(txn),
417 View::Ram(_) => Ok(Vec::new()),
418 }
419 }
420
421 pub fn export_each<E>(&self, row: &mut dyn FnMut(ExportRow<'_>) -> std::result::Result<(), E>) -> Result<std::result::Result<(), E>> {
423 match &self.0 {
424 View::Live(txn) => export_txn(txn, row),
425 View::Ram(ram) => export_ram(ram, row),
426 }
427 }
428}
429
430fn activity_rows(txn: &redb::ReadTransaction) -> Result<Vec<(String, Vec<u8>)>> {
432 let table = match txn.open_table(PRINCIPAL_ACTIVITY) {
433 Ok(t) => t,
434 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
435 Err(err) => return Err(faulted("opening the last-seen table", err)),
436 };
437 let mut rows = Vec::new();
438 for entry in table.iter().map_err(|err| faulted("scanning the last-seen table", err))? {
439 let (k, v) = entry.map_err(|err| faulted("reading a last-seen row", err))?;
440 rows.push((k.value().to_owned(), v.value().to_vec()));
441 }
442 Ok(rows)
443}
444
445fn export_txn<E>(txn: &redb::ReadTransaction, row: &mut dyn FnMut(ExportRow<'_>) -> std::result::Result<(), E>) -> Result<std::result::Result<(), E>> {
446 let table = txn.open_table(BLOBS).map_err(|err| faulted("opening the blobs table", err))?;
447 for entry in table.iter().map_err(|err| faulted("scanning the blobs table", err))? {
448 let (k, v) = entry.map_err(|err| faulted("reading a blob", err))?;
449 if let Some(id) = BlobId::from_slice(k.value()) {
450 if let Err(e) = row(ExportRow::Blob(id, v.value())) {
451 return Ok(Err(e));
452 }
453 }
454 }
455 let table = txn.open_table(DOCUMENTS).map_err(|err| faulted("opening the documents table", err))?;
456 for entry in table.iter().map_err(|err| faulted("scanning the documents table", err))? {
457 let (k, v) = entry.map_err(|err| faulted("reading a document pointer", err))?;
458 if let Some(id) = BlobId::from_slice(v.value()) {
459 if let Err(e) = row(ExportRow::Doc(k.value(), id)) {
460 return Ok(Err(e));
461 }
462 }
463 }
464 for (key, raw) in crate::secrets::RedbSecretStore::rows(txn)? {
465 let value = crate::secrets::RedbSecretStore::decode_row_of(&key, &raw).map_err(|err| Error::ControlStore(err.to_string()))?;
466 if let Err(e) = row(ExportRow::Secret(&key, value.as_deref())) {
467 return Ok(Err(e));
468 }
469 }
470 Ok(Ok(()))
471}
472
473fn export_ram<E>(ram: &RamCopy, row: &mut dyn FnMut(ExportRow<'_>) -> std::result::Result<(), E>) -> Result<std::result::Result<(), E>> {
474 for (id, bytes) in &ram.blobs {
475 if let Err(e) = row(ExportRow::Blob(*id, bytes)) {
476 return Ok(Err(e));
477 }
478 }
479 for (name, id) in &ram.docs {
480 if let Err(e) = row(ExportRow::Doc(name, *id)) {
481 return Ok(Err(e));
482 }
483 }
484 for (key, raw) in &ram.secrets {
485 let value = crate::secrets::RedbSecretStore::decode_row_of(key, raw).map_err(|err| Error::ControlStore(err.to_string()))?;
486 if let Err(e) = row(ExportRow::Secret(key, value.as_deref())) {
487 return Ok(Err(e));
488 }
489 }
490 Ok(Ok(()))
491}
492
493pub const RAM_COPY_CEILING_BYTES: u64 = 512 << 20;
497
498#[derive(Debug, Clone, PartialEq, Eq)]
500pub struct ResumeReport {
501 pub documents_matched: usize,
503 pub drift: Vec<String>,
507 pub off_stripe_ms: i64,
509}
510
511pub struct ControlDb {
537 path: Option<std::path::PathBuf>,
538 live: std::sync::RwLock<Option<redb::Database>>,
539 ram: std::sync::RwLock<Option<RamCopy>>,
540 group: std::sync::Mutex<Option<Group>>,
542 durable_commits: std::sync::atomic::AtomicU64,
544}
545
546impl std::fmt::Debug for ControlDb {
547 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
548 f.debug_struct("ControlDb")
549 .field("path", &self.path)
550 .field("suspended", &self.is_suspended())
551 .finish()
552 }
553}
554
555fn read_lock<T>(lock: &std::sync::RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
556 lock.read().unwrap_or_else(|poison| poison.into_inner())
557}
558
559fn write_lock<T>(lock: &std::sync::RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
560 lock.write().unwrap_or_else(|poison| poison.into_inner())
561}
562
563impl ControlDb {
564 fn new(path: Option<std::path::PathBuf>, db: redb::Database) -> Self {
565 ControlDb {
566 path,
567 live: std::sync::RwLock::new(Some(db)),
568 ram: std::sync::RwLock::new(None),
569 group: std::sync::Mutex::new(None),
570 durable_commits: std::sync::atomic::AtomicU64::new(0),
571 }
572 }
573
574 #[cfg(test)]
577 pub(crate) fn in_memory_for_tests(db: redb::Database) -> Self {
578 Self::new(None, db)
579 }
580
581 pub fn is_suspended(&self) -> bool {
583 read_lock(&self.ram).is_some()
584 }
585
586 pub fn ram_copy(&self) -> Option<RamCopy> {
588 read_lock(&self.ram).clone()
589 }
590
591 fn off_stripe(&self) -> Error {
593 Error::OffStripe {
594 why: read_lock(&self.ram)
595 .as_ref()
596 .map(|r| r.why.clone())
597 .unwrap_or_else(|| "suspended".to_owned()),
598 }
599 }
600
601 pub(crate) fn read<T>(
605 &self,
606 what: &str,
607 live: impl FnOnce(&redb::ReadTransaction) -> Result<T>,
608 ram: impl FnOnce(&RamCopy) -> Result<T>,
609 ) -> Result<T> {
610 if let Some(copy) = read_lock(&self.ram).as_ref() {
611 return ram(copy);
612 }
613 let guard = read_lock(&self.live);
614 let Some(db) = guard.as_ref() else {
615 return Err(self.off_stripe());
616 };
617 let txn = db
618 .begin_read()
619 .map_err(|err| faulted(&format!("beginning {what}"), err))?;
620 live(&txn)
621 }
622
623 pub(crate) fn write<T>(
628 &self,
629 what: &str,
630 f: impl FnOnce(&redb::WriteTransaction) -> Result<Txn<T>>,
631 ) -> Result<T> {
632 if self.is_suspended() {
633 return Err(self.off_stripe());
634 }
635 {
636 let mut group = self.group.lock().unwrap_or_else(|p| p.into_inner());
637 if let Some(g) = group.as_mut().filter(|g| g.owner == std::thread::current().id()) {
638 return match f(&g.txn) {
639 Ok(Txn::Commit(t) | Txn::CommitUnsynced(t)) => {
640 g.dirty = true;
641 Ok(t)
642 }
643 Ok(Txn::Discard(t)) => Ok(t),
644 Err(err) => {
645 g.poisoned = true;
646 Err(err)
647 }
648 };
649 }
650 }
651 let guard = read_lock(&self.live);
652 let Some(db) = guard.as_ref() else {
653 return Err(self.off_stripe());
654 };
655 let mut txn = db
656 .begin_write()
657 .map_err(|err| faulted(&format!("beginning {what}"), err))?;
658 match f(&txn)? {
659 Txn::CommitUnsynced(t) => {
660 txn.set_durability(redb::Durability::None);
661 txn.commit()
662 .map_err(|err| faulted(&format!("committing {what}"), err))?;
663 Ok(t)
664 }
665 Txn::Commit(t) => {
666 txn.commit()
667 .map_err(|err| faulted(&format!("committing {what}"), err))?;
668 self.durable_commits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
669 Ok(t)
670 }
671 Txn::Discard(t) => Ok(t),
673 }
674 }
675
676 pub fn begin_group(&self) -> Result<()> {
682 if self.is_suspended() {
683 return Err(self.off_stripe());
684 }
685 let mut group = self.group.lock().unwrap_or_else(|p| p.into_inner());
686 if group.is_some() {
687 return Err(Error::ControlStore("a group transaction is already open on this control store".to_owned()));
688 }
689 let guard = read_lock(&self.live);
690 let Some(db) = guard.as_ref() else {
691 return Err(self.off_stripe());
692 };
693 let txn = db
694 .begin_write()
695 .map_err(|err| faulted("beginning a group of writes", err))?;
696 *group = Some(Group { owner: std::thread::current().id(), txn, dirty: false, poisoned: false });
697 Ok(())
698 }
699
700 pub fn commit_group(&self, key: &str, seq: u64, mark: bool) -> Result<bool> {
705 let g = self.take_group()?;
706 if g.poisoned {
707 drop(g.txn);
708 return Err(Error::ControlStore("a write inside the group failed; the whole group was rolled back".to_owned()));
709 }
710 if !g.dirty && !mark {
711 return Ok(false);
712 }
713 {
714 let mut marks = g
715 .txn
716 .open_table(MARKS)
717 .map_err(|err| faulted("opening the marks table", err))?;
718 marks
719 .insert(key, seq)
720 .map_err(|err| faulted("moving a marker", err))?;
721 }
722 g.txn
723 .commit()
724 .map_err(|err| faulted("committing a group of writes", err))?;
725 self.durable_commits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
726 Ok(true)
727 }
728
729 pub fn durable_commits(&self) -> u64 {
732 self.durable_commits.load(std::sync::atomic::Ordering::Relaxed)
733 }
734
735 pub fn abort_group(&self) {
737 let _ = self.take_group();
738 }
739
740 fn take_group(&self) -> Result<Group> {
741 let mut group = self.group.lock().unwrap_or_else(|p| p.into_inner());
742 match group.take() {
743 Some(g) if g.owner == std::thread::current().id() => Ok(g),
744 Some(g) => {
745 *group = Some(g);
746 Err(Error::ControlStore("the open group transaction belongs to another thread".to_owned()))
747 }
748 None => Err(Error::ControlStore("no group transaction is open".to_owned())),
749 }
750 }
751
752 pub fn set_mark(&self, key: &str, seq: u64) -> Result<()> {
756 self.write("a marker", |txn| {
757 let mut marks = txn
758 .open_table(MARKS)
759 .map_err(|err| faulted("opening the marks table", err))?;
760 marks
761 .insert(key, seq)
762 .map_err(|err| faulted("moving a marker", err))?;
763 Ok(Txn::Commit(()))
764 })
765 }
766
767 pub fn mark(&self, key: &str) -> Result<Option<u64>> {
769 self.read(
770 "a marker read",
771 |txn| {
772 let table = match txn.open_table(MARKS) {
773 Ok(t) => t,
774 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None),
775 Err(err) => return Err(faulted("opening the marks table", err)),
776 };
777 let found = table
778 .get(key)
779 .map_err(|err| faulted("reading a marker", err))?;
780 Ok(found.map(|v| v.value()))
781 },
782 |_ram| Ok(None),
783 )
784 }
785
786 pub fn principal_activity(&self) -> Result<Vec<(String, Vec<u8>)>> {
790 self.read("a last-seen read", activity_rows, |_ram| Ok(Vec::new()))
791 }
792
793 pub fn put_principal_activity(&self, rows: &[(String, Vec<u8>)], mark: Option<&str>) -> Result<()> {
798 self.write("last-seen rows", |txn| {
799 {
800 let mut table = txn
801 .open_table(PRINCIPAL_ACTIVITY)
802 .map_err(|err| faulted("opening the last-seen table", err))?;
803 for (id, value) in rows {
804 table
805 .insert(id.as_str(), value.as_slice())
806 .map_err(|err| faulted("writing a last-seen row", err))?;
807 }
808 }
809 if let Some(key) = mark {
810 let mut marks = txn
811 .open_table(MARKS)
812 .map_err(|err| faulted("opening the marks table", err))?;
813 marks
814 .insert(key, 1u64)
815 .map_err(|err| faulted("moving a marker", err))?;
816 }
817 Ok(Txn::Commit(()))
818 })
819 }
820
821 pub fn suspend(&self, why: &str) -> Result<RamCopy> {
826 if self.group.lock().unwrap_or_else(|p| p.into_inner()).is_some() {
827 return Err(Error::ControlStore(
828 "a group of writes is open on the control store; it is suspended after the group ends".to_owned(),
829 ));
830 }
831 let mut live = write_lock(&self.live);
832 if self.is_suspended() {
833 return Err(Error::ControlStore(
834 "the control store is already suspended".to_owned(),
835 ));
836 }
837 let Some(db) = live.as_ref() else {
838 return Err(Error::ControlStore(
839 "the control store has no database to suspend".to_owned(),
840 ));
841 };
842 let txn = db
843 .begin_read()
844 .map_err(|err| faulted("beginning the suspend snapshot", err))?;
845 let mut copy = RamCopy {
846 why: why.to_owned(),
847 docs: Default::default(),
848 blobs: Default::default(),
849 secrets: Default::default(),
850 taken_unix_ms: crate::model::now_ms(),
851 };
852 {
853 let table = txn
854 .open_table(DOCUMENTS)
855 .map_err(|err| faulted("opening the documents table", err))?;
856 for entry in table
857 .iter()
858 .map_err(|err| faulted("scanning the documents table", err))?
859 {
860 let (k, v) = entry.map_err(|err| faulted("reading a document pointer", err))?;
861 if let Some(id) = BlobId::from_slice(v.value()) {
862 copy.docs.insert(k.value().to_owned(), id);
863 }
864 }
865 let table = txn
866 .open_table(BLOBS)
867 .map_err(|err| faulted("opening the blobs table", err))?;
868 for entry in table
869 .iter()
870 .map_err(|err| faulted("scanning the blobs table", err))?
871 {
872 let (k, v) = entry.map_err(|err| faulted("reading a blob", err))?;
873 if let Some(id) = BlobId::from_slice(k.value()) {
874 copy.blobs.insert(id, v.value().to_vec());
875 }
876 }
877 copy.secrets = crate::secrets::RedbSecretStore::rows(&txn)?;
878 }
879 drop(txn);
880 let bytes = copy.bytes();
881 if bytes > RAM_COPY_CEILING_BYTES {
882 return Err(Error::ControlStore(format!(
883 "the control database would take {bytes} bytes of RAM to hold off the stripe, \
884 over the {RAM_COPY_CEILING_BYTES}-byte ceiling; not suspending"
885 )));
886 }
887 *write_lock(&self.ram) = Some(copy.clone());
892 if self.path.is_some() {
893 *live = None;
898 }
899 Ok(copy)
900 }
901
902 pub fn resume(&self) -> Result<ResumeReport> {
907 let mut live = write_lock(&self.live);
908 let Some(copy) = read_lock(&self.ram).clone() else {
909 return Err(Error::ControlStore(
910 "the control store is not suspended".to_owned(),
911 ));
912 };
913 if live.is_none() {
914 let path = self.path.as_ref().ok_or_else(|| {
915 Error::ControlStore("no path to reopen the control store from".to_owned())
916 })?;
917 let db = redb::Database::open(path).map_err(|err| {
918 faulted(&format!("reopening the control store {}", path.display()), err)
919 })?;
920 *live = Some(db);
921 }
922 let db = live.as_ref().expect("installed above");
923 let txn = db
924 .begin_read()
925 .map_err(|err| faulted("beginning the resume check", err))?;
926 let table = txn
927 .open_table(DOCUMENTS)
928 .map_err(|err| faulted("opening the documents table", err))?;
929 let mut matched = 0;
930 let mut drift = Vec::new();
931 for (name, ram_id) in ©.docs {
932 let disk = table
933 .get(name.as_str())
934 .map_err(|err| faulted("reading a document pointer", err))?
935 .and_then(|v| BlobId::from_slice(v.value()));
936 match disk {
937 Some(id) if id == *ram_id => matched += 1,
938 other => drift.push(format!(
939 "{name}: ram {ram_id} vs disk {}",
940 other.map(|i| i.to_string()).unwrap_or_else(|| "absent".to_owned())
941 )),
942 }
943 }
944 for entry in table
945 .iter()
946 .map_err(|err| faulted("scanning the documents table", err))?
947 {
948 let (k, v) = entry.map_err(|err| faulted("reading a document pointer", err))?;
949 if !copy.docs.contains_key(k.value()) {
950 drift.push(format!(
951 "{}: ram absent vs disk {}",
952 k.value(),
953 BlobId::from_slice(v.value())
954 .map(|i| i.to_string())
955 .unwrap_or_else(|| "?".to_owned())
956 ));
957 }
958 }
959 drop(table);
960 drop(txn);
961 *write_lock(&self.ram) = None;
962 Ok(ResumeReport {
963 documents_matched: matched,
964 drift,
965 off_stripe_ms: crate::model::now_ms() - copy.taken_unix_ms,
966 })
967 }
968}
969
970#[derive(Clone)]
978pub struct RedbControlStore {
979 db: Arc<ControlDb>,
980}
981
982impl std::fmt::Debug for RedbControlStore {
983 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
984 f.write_str("RedbControlStore")
985 }
986}
987
988fn faulted(what: &str, err: impl std::fmt::Display) -> Error {
989 Error::ControlStore(format!("{what}: {err}"))
990}
991
992impl RedbControlStore {
993 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
995 let path = path.as_ref();
996 let db = redb::Database::create(path).map_err(|err| {
997 faulted(
998 &format!("opening the control store {}", path.display()),
999 err,
1000 )
1001 })?;
1002 Self::prepared(Some(path.to_path_buf()), db)
1003 }
1004
1005 pub fn control_db(&self) -> Arc<ControlDb> {
1008 Arc::clone(&self.db)
1009 }
1010
1011 pub fn in_memory() -> Result<Self> {
1014 let db = redb::Database::builder()
1015 .create_with_backend(redb::backends::InMemoryBackend::new())
1016 .map_err(|err| faulted("opening an in-memory control store", err))?;
1017 Self::prepared(None, db)
1018 }
1019
1020 fn prepared(path: Option<std::path::PathBuf>, db: redb::Database) -> Result<Self> {
1027 let txn = db
1028 .begin_write()
1029 .map_err(|err| faulted("beginning the control store's first transaction", err))?;
1030 {
1031 txn.open_table(DOCUMENTS)
1032 .map_err(|err| faulted("creating the documents table", err))?;
1033 txn.open_table(BLOBS)
1034 .map_err(|err| faulted("creating the blobs table", err))?;
1035 txn.open_table(DOC_HISTORY)
1036 .map_err(|err| faulted("creating the document history table", err))?;
1037 txn.open_table(MARKS)
1038 .map_err(|err| faulted("creating the marks table", err))?;
1039 }
1040 txn.commit()
1041 .map_err(|err| faulted("committing the control store's first transaction", err))?;
1042 crate::secrets::RedbSecretStore::prepare(&db)?;
1043 Ok(RedbControlStore { db: Arc::new(ControlDb::new(path, db)) })
1044 }
1045
1046 pub fn secrets(&self) -> crate::secrets::RedbSecretStore {
1061 crate::secrets::RedbSecretStore::new(Arc::clone(&self.db))
1062 }
1063
1064 fn next_seq(
1067 table: &impl redb::ReadableTable<(&'static str, u64), &'static [u8]>,
1068 name: &str,
1069 ) -> Result<u64> {
1070 let mut range = table
1071 .range((name, 0u64)..=(name, u64::MAX))
1072 .map_err(|err| faulted("reading a document's history", err))?;
1073 match range.next_back() {
1074 None => Ok(1),
1075 Some(entry) => {
1076 let (key, _) = entry.map_err(|err| faulted("reading a history row", err))?;
1077 Ok(key.value().1 + 1)
1078 }
1079 }
1080 }
1081}
1082
1083impl ControlStore for RedbControlStore {
1084 fn put_blob(&self, bytes: &[u8]) -> Result<BlobId> {
1085 let id = BlobId::of(bytes);
1086 self.db.write("a blob write", |txn| {
1087 {
1088 let mut table = txn
1089 .open_table(BLOBS)
1090 .map_err(|err| faulted("opening the blobs table", err))?;
1091 table
1092 .insert(id.0.as_slice(), bytes)
1093 .map_err(|err| faulted("storing a control blob", err))?;
1094 }
1095 Ok(Txn::Commit(id))
1096 })
1097 }
1098
1099 fn put_blob_unsynced(&self, bytes: &[u8]) -> Result<BlobId> {
1100 let id = BlobId::of(bytes);
1101 self.db.write("a blob write", |txn| {
1102 {
1103 let mut table = txn
1104 .open_table(BLOBS)
1105 .map_err(|err| faulted("opening the blobs table", err))?;
1106 table
1107 .insert(id.0.as_slice(), bytes)
1108 .map_err(|err| faulted("storing a control blob", err))?;
1109 }
1110 Ok(Txn::CommitUnsynced(id))
1111 })
1112 }
1113
1114 fn get_blob(&self, id: &BlobId) -> Result<Option<Vec<u8>>> {
1115 self.db.read(
1116 "a blob read",
1117 |txn| {
1118 let table = txn
1119 .open_table(BLOBS)
1120 .map_err(|err| faulted("opening the blobs table", err))?;
1121 let found = table
1122 .get(id.0.as_slice())
1123 .map_err(|err| faulted("reading a control blob", err))?;
1124 Ok(found.map(|value| value.value().to_vec()))
1125 },
1126 |ram| Ok(ram.blobs.get(id).cloned()),
1127 )
1128 }
1129
1130 fn read_doc(&self, name: &str) -> Result<Option<BlobId>> {
1131 self.db.read(
1132 "a document read",
1133 |txn| {
1134 let table = txn
1135 .open_table(DOCUMENTS)
1136 .map_err(|err| faulted("opening the documents table", err))?;
1137 let found = table
1138 .get(name)
1139 .map_err(|err| faulted("reading a control document pointer", err))?;
1140 match found {
1141 None => Ok(None),
1142 Some(value) => BlobId::from_slice(value.value())
1143 .map(Some)
1144 .ok_or_else(|| Error::CorruptDocument {
1145 ref_name: name.to_owned(),
1146 reason: format!(
1147 "its stored pointer is {} bytes, not the {BLOB_ID_LEN} of a blob id",
1148 value.value().len()
1149 ),
1150 }),
1151 }
1152 },
1153 |ram| Ok(ram.docs.get(name).copied()),
1154 )
1155 }
1156
1157 fn commit_doc(&self, name: &str, expected: Option<BlobId>, new: BlobId) -> Result<bool> {
1158 self.db.write("a control document commit", |txn| {
1159 {
1160 let mut documents = txn
1161 .open_table(DOCUMENTS)
1162 .map_err(|err| faulted("opening the documents table", err))?;
1163 let current = documents
1164 .get(name)
1165 .map_err(|err| faulted("reading a control document pointer", err))?
1166 .and_then(|value| BlobId::from_slice(value.value()));
1167 if current != expected {
1168 return Ok(Txn::Discard(false));
1171 }
1172 documents
1173 .insert(name, new.0.as_slice())
1174 .map_err(|err| faulted("moving a control document pointer", err))?;
1175
1176 let mut history = txn
1181 .open_table(DOC_HISTORY)
1182 .map_err(|err| faulted("opening the document history table", err))?;
1183 let seq = Self::next_seq(&history, name)?;
1184 let mut row = Vec::with_capacity(8 + BLOB_ID_LEN);
1185 row.extend_from_slice(&crate::model::now_ms().to_be_bytes());
1186 row.extend_from_slice(&new.0);
1187 history
1188 .insert((name, seq), row.as_slice())
1189 .map_err(|err| faulted("appending to the document history", err))?;
1190 }
1191 Ok(Txn::Commit(true))
1192 })
1193 }
1194
1195 fn history(&self, name: &str) -> Result<Vec<DocVersion>> {
1196 self.db.read(
1197 "a history read",
1198 |txn| {
1199 let table = txn
1200 .open_table(DOC_HISTORY)
1201 .map_err(|err| faulted("opening the document history table", err))?;
1202 let range = table
1203 .range((name, 0u64)..=(name, u64::MAX))
1204 .map_err(|err| faulted("scanning a document's history", err))?;
1205 let mut out = Vec::new();
1206 for entry in range {
1207 let (key, value) = entry.map_err(|err| faulted("reading a history row", err))?;
1208 let bytes = value.value();
1209 if bytes.len() != 8 + BLOB_ID_LEN {
1210 return Err(Error::CorruptDocument {
1211 ref_name: name.to_owned(),
1212 reason: format!(
1213 "a history row is {} bytes, not {}",
1214 bytes.len(),
1215 8 + BLOB_ID_LEN
1216 ),
1217 });
1218 }
1219 let mut millis = [0u8; 8];
1220 millis.copy_from_slice(&bytes[..8]);
1221 out.push(DocVersion {
1222 seq: key.value().1,
1223 at: i64::from_be_bytes(millis),
1224 blob: BlobId::from_slice(&bytes[8..]).expect("checked length"),
1225 });
1226 }
1227 Ok(out)
1228 },
1229 |ram| {
1233 Err(Error::OffStripe {
1234 why: format!("{}; the audit trail is not held in RAM", ram.why),
1235 })
1236 },
1237 )
1238 }
1239}
1240
1241pub type SharedControlStore = Arc<dyn ControlStore>;
1244
1245pub fn open_control_store(path: impl AsRef<Path>) -> Result<SharedControlStore> {
1247 Ok(Arc::new(RedbControlStore::open(path)?))
1248}
1249
1250pub fn open_control(
1257 path: impl AsRef<Path>,
1258) -> Result<(SharedControlStore, crate::secrets::RedbSecretStore)> {
1259 let (store, secrets, _) = open_control_with_db(path)?;
1260 Ok((store, secrets))
1261}
1262
1263pub fn open_control_with_db(
1268 path: impl AsRef<Path>,
1269) -> Result<(SharedControlStore, crate::secrets::RedbSecretStore, Arc<ControlDb>)> {
1270 let store = RedbControlStore::open(path)?;
1271 let secrets = store.secrets();
1272 let db = store.control_db();
1273 Ok((Arc::new(store), secrets, db))
1274}
1275
1276pub fn memory_control_store() -> Result<SharedControlStore> {
1278 Ok(Arc::new(RedbControlStore::in_memory()?))
1279}
1280
1281pub fn memory_control() -> Result<(SharedControlStore, crate::secrets::RedbSecretStore)> {
1284 let store = RedbControlStore::in_memory()?;
1285 let secrets = store.secrets();
1286 Ok((Arc::new(store), secrets))
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291 use super::*;
1292
1293 #[test]
1294 fn a_blob_id_is_the_hash_of_the_bytes_and_round_trips_through_hex() {
1295 let id = BlobId::of(b"the identity table");
1296 assert_eq!(BlobId::from_hex(&id.to_string()), Some(id));
1297 assert_ne!(id, BlobId::of(b"the identity tabl3"));
1298 assert_eq!(BlobId::from_hex("not hex"), None);
1299 assert_eq!(BlobId::from_hex(""), None);
1300 }
1301
1302 #[test]
1303 fn an_unwritten_document_reads_as_absent_and_a_written_one_reads_back() {
1304 let store = RedbControlStore::in_memory().expect("open");
1305 assert_eq!(store.read_doc("refs/gunnar/auth").expect("read"), None);
1306 let id = store.put_blob(b"{}").expect("put");
1307 assert_eq!(
1308 store.get_blob(&id).expect("get").as_deref(),
1309 Some(&b"{}"[..])
1310 );
1311 assert!(store
1312 .commit_doc("refs/gunnar/auth", None, id)
1313 .expect("commit"));
1314 assert_eq!(store.read_doc("refs/gunnar/auth").expect("read"), Some(id));
1315 }
1316
1317 #[test]
1324 fn a_suspended_store_reads_from_ram_refuses_writes_and_resumes_without_drift() {
1325 use gunnar_store::SecretStore as _;
1326 let store = RedbControlStore::in_memory().expect("open");
1327 let secrets = store.secrets();
1328 let db = store.control_db();
1329 let id = store.put_blob(b"{\"principals\":1}").expect("put");
1330 assert!(store.commit_doc("refs/gunnar/auth", None, id).expect("commit"));
1331 secrets.put("host-key", b"sealed bytes").expect("put secret");
1332 assert!(!db.is_suspended());
1333
1334 let copy = db.suspend("draining for a growth").expect("suspend");
1335 assert!(db.is_suspended());
1336 assert_eq!(copy.documents(), 1);
1337 assert!(copy.bytes() > 0);
1338 assert_eq!(copy.why, "draining for a growth");
1339 assert_eq!(store.read_doc("refs/gunnar/auth").expect("read"), Some(id));
1341 assert_eq!(store.get_blob(&id).expect("get").as_deref(), Some(&b"{\"principals\":1}"[..]));
1342 assert_eq!(store.read_doc("never-written").expect("read"), None);
1343 assert_eq!(secrets.get("host-key").expect("get").as_deref(), Some(&b"sealed bytes"[..]));
1344 assert_eq!(secrets.get("absent").expect("get"), None);
1345 assert_eq!(
1346 secrets.get_or_create("host-key", &mut || panic!("must not mint")).expect("held"),
1347 b"sealed bytes"
1348 );
1349 let refused = store.put_blob(b"new").unwrap_err();
1351 assert!(matches!(refused, Error::OffStripe { .. }), "{refused:?}");
1352 assert!(refused.to_string().contains("draining for a growth"), "{refused}");
1353 assert!(refused.to_string().contains("retry"), "{refused}");
1354 let other = BlobId::of(b"other");
1355 assert!(matches!(store.commit_doc("refs/gunnar/auth", Some(id), other).unwrap_err(), Error::OffStripe { .. }));
1356 assert!(matches!(store.history("refs/gunnar/auth").unwrap_err(), Error::OffStripe { .. }));
1357 let sec = secrets.put("host-key", b"x").unwrap_err().to_string();
1358 assert!(sec.contains("off the stripe"), "{sec}");
1359 let mint = secrets.get_or_create("fresh", &mut || Ok(b"minted".to_vec())).unwrap_err().to_string();
1360 assert!(mint.contains("will not be minted"), "{mint}");
1361 assert!(db.suspend("again").is_err(), "a second suspend is a refusal");
1362
1363 let report = db.resume().expect("resume");
1364 assert!(!db.is_suspended());
1365 assert_eq!(report.documents_matched, 1);
1366 assert!(report.drift.is_empty(), "{:?}", report.drift);
1367 assert!(db.resume().is_err(), "resuming an unsuspended store is a refusal");
1368 assert!(store.commit_doc("refs/gunnar/auth", Some(id), other).expect("commit"));
1370 assert_eq!(store.history("refs/gunnar/auth").expect("history").len(), 2);
1371 assert_eq!(secrets.get_or_create("fresh", &mut || Ok(b"minted".to_vec())).expect("mint"), b"minted");
1372 }
1373
1374 #[cfg(target_os = "linux")]
1379 #[test]
1380 fn a_suspended_file_store_holds_no_descriptor_on_its_file() {
1381 let dir = tempfile::tempdir().expect("tempdir");
1382 let path = dir.path().join("control.redb");
1383 let store = RedbControlStore::open(&path).expect("open");
1384 let db = store.control_db();
1385 let id = store.put_blob(b"doc").expect("put");
1386 assert!(store.commit_doc("d", None, id).expect("commit"));
1387 let holds = |p: &Path| -> bool {
1388 std::fs::read_dir("/proc/self/fd")
1389 .expect("procfs")
1390 .flatten()
1391 .filter_map(|e| std::fs::read_link(e.path()).ok())
1392 .any(|t| t == p)
1393 };
1394 assert!(holds(&path), "the open store holds its file");
1395 db.suspend("unmounting").expect("suspend");
1396 assert!(!holds(&path), "a suspended store holds NO descriptor on its file");
1397 assert_eq!(store.read_doc("d").expect("read from ram"), Some(id));
1398 db.resume().expect("resume");
1399 assert!(holds(&path), "a resumed store holds its file again");
1400 assert_eq!(store.read_doc("d").expect("read from disk"), Some(id));
1401 }
1402
1403 #[test]
1407 fn a_commit_against_the_wrong_expectation_is_refused_and_changes_nothing() {
1408 let store = RedbControlStore::in_memory().expect("open");
1409 let first = store.put_blob(b"one").expect("put");
1410 let second = store.put_blob(b"two").expect("put");
1411 assert!(store.commit_doc("doc", None, first).expect("commit"));
1412
1413 assert!(
1415 !store.commit_doc("doc", None, second).expect("commit"),
1416 "a commit that expected an absent document was accepted against a written one"
1417 );
1418 assert_eq!(
1419 store.read_doc("doc").expect("read"),
1420 Some(first),
1421 "a refused commit still moved the pointer"
1422 );
1423 assert_eq!(
1424 store.history("doc").expect("history").len(),
1425 1,
1426 "a refused commit still appended to the audit trail"
1427 );
1428
1429 assert!(store
1431 .commit_doc("doc", Some(first), second)
1432 .expect("commit"));
1433 assert_eq!(store.read_doc("doc").expect("read"), Some(second));
1434 }
1435
1436 #[test]
1438 fn the_history_is_append_only_and_one_document_does_not_see_anothers() {
1439 let store = RedbControlStore::in_memory().expect("open");
1440 let mut previous = None;
1441 let mut expected = Vec::new();
1442 for text in [b"a".as_slice(), b"bb", b"ccc"] {
1443 let id = store.put_blob(text).expect("put");
1444 assert!(store.commit_doc("auth", previous, id).expect("commit"));
1445 previous = Some(id);
1446 expected.push(id);
1447 }
1448 let other = store.put_blob(b"catalog").expect("put");
1449 assert!(store.commit_doc("catalog", None, other).expect("commit"));
1450
1451 let history = store.history("auth").expect("history");
1452 assert_eq!(
1453 history.iter().map(|v| v.blob).collect::<Vec<_>>(),
1454 expected,
1455 "the audit trail must list every version of the document, oldest first"
1456 );
1457 assert_eq!(
1458 history.iter().map(|v| v.seq).collect::<Vec<_>>(),
1459 vec![1, 2, 3]
1460 );
1461 assert_eq!(
1462 store.history("catalog").expect("history").len(),
1463 1,
1464 "one document's history picked up another document's versions"
1465 );
1466 assert_eq!(store.history("never-written").expect("history").len(), 0);
1467
1468 assert_eq!(
1471 store.get_blob(&history[0].blob).expect("get").as_deref(),
1472 Some(&b"a"[..])
1473 );
1474 }
1475}