use std::path::Path;
use std::sync::Arc;
use redb::ReadableTable;
use crate::error::{Error, Result};
const DOCUMENTS: redb::TableDefinition<'static, &str, &[u8]> =
redb::TableDefinition::new("documents");
const BLOBS: redb::TableDefinition<'static, &[u8], &[u8]> = redb::TableDefinition::new("blobs");
const DOC_HISTORY: redb::TableDefinition<'static, (&str, u64), &[u8]> =
redb::TableDefinition::new("document_history");
const MARKS: redb::TableDefinition<'static, &str, u64> = redb::TableDefinition::new("box_marks");
const PRINCIPAL_ACTIVITY: redb::TableDefinition<'static, &str, &[u8]> =
redb::TableDefinition::new("principal_activity");
pub const TWIN_APPLIED: &str = "twin.applied";
pub(crate) enum Txn<T> {
Commit(T),
CommitUnsynced(T),
Discard(T),
}
struct Group {
owner: std::thread::ThreadId,
txn: redb::WriteTransaction,
dirty: bool,
poisoned: bool,
}
pub const BLOB_ID_LEN: usize = 32;
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct BlobId([u8; BLOB_ID_LEN]);
impl BlobId {
pub fn of(bytes: &[u8]) -> Self {
use sha2::Digest;
let mut hasher = sha2::Sha256::new();
hasher.update(bytes);
BlobId(hasher.finalize().into())
}
pub fn as_bytes(&self) -> &[u8; BLOB_ID_LEN] {
&self.0
}
pub fn from_hex(text: &str) -> Option<Self> {
let mut out = [0u8; BLOB_ID_LEN];
hex::decode_to_slice(text, &mut out).ok()?;
Some(BlobId(out))
}
fn from_slice(bytes: &[u8]) -> Option<Self> {
Some(BlobId(bytes.try_into().ok()?))
}
}
impl std::fmt::Display for BlobId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&hex::encode(self.0))
}
}
impl std::fmt::Debug for BlobId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "BlobId({self})")
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct DocVersion {
pub seq: u64,
pub at: crate::model::UnixMillis,
pub blob: BlobId,
}
pub trait ControlStore: Send + Sync + 'static {
fn put_blob(&self, bytes: &[u8]) -> Result<BlobId>;
fn put_blob_unsynced(&self, bytes: &[u8]) -> Result<BlobId> {
self.put_blob(bytes)
}
fn get_blob(&self, id: &BlobId) -> Result<Option<Vec<u8>>>;
fn read_doc(&self, name: &str) -> Result<Option<BlobId>>;
fn commit_doc(&self, name: &str, expected: Option<BlobId>, new: BlobId) -> Result<bool>;
fn history(&self, name: &str) -> Result<Vec<DocVersion>>;
}
#[derive(Debug, Clone)]
pub struct RamCopy {
pub why: String,
docs: std::collections::BTreeMap<String, BlobId>,
blobs: std::collections::BTreeMap<BlobId, Vec<u8>>,
secrets: std::collections::BTreeMap<String, Vec<u8>>,
pub taken_unix_ms: crate::model::UnixMillis,
}
impl RamCopy {
pub fn bytes(&self) -> u64 {
self.blobs.values().map(|b| b.len() as u64).sum::<u64>()
+ self.secrets.values().map(|b| b.len() as u64).sum::<u64>()
}
pub fn documents(&self) -> usize {
self.docs.len()
}
pub fn pointers(&self) -> Vec<(String, String)> {
self.docs
.iter()
.map(|(k, v)| (k.clone(), v.to_string()))
.collect()
}
pub(crate) fn secret_row(&self, key: &str) -> Option<&[u8]> {
self.secrets.get(key).map(|v| v.as_slice())
}
}
#[derive(Debug, Clone, Default)]
pub struct ControlExport {
pub docs: std::collections::BTreeMap<String, BlobId>,
pub blobs: std::collections::BTreeMap<BlobId, Vec<u8>>,
pub secrets: std::collections::BTreeMap<String, Option<Vec<u8>>>,
}
impl ControlDb {
pub fn export(&self) -> Result<ControlExport> {
let raw = self.read(
"exporting the control database",
|txn| {
let mut docs = std::collections::BTreeMap::new();
let mut blobs = std::collections::BTreeMap::new();
let table = txn
.open_table(DOCUMENTS)
.map_err(|err| faulted("opening the documents table", err))?;
for entry in table
.iter()
.map_err(|err| faulted("scanning the documents table", err))?
{
let (k, v) = entry.map_err(|err| faulted("reading a document pointer", err))?;
if let Some(id) = BlobId::from_slice(v.value()) {
docs.insert(k.value().to_owned(), id);
}
}
let table = txn
.open_table(BLOBS)
.map_err(|err| faulted("opening the blobs table", err))?;
for entry in table
.iter()
.map_err(|err| faulted("scanning the blobs table", err))?
{
let (k, v) = entry.map_err(|err| faulted("reading a blob", err))?;
if let Some(id) = BlobId::from_slice(k.value()) {
blobs.insert(id, v.value().to_vec());
}
}
Ok((docs, blobs, crate::secrets::RedbSecretStore::rows(txn)?))
},
|ram| Ok((ram.docs.clone(), ram.blobs.clone(), ram.secrets.clone())),
)?;
let (docs, blobs, rows) = raw;
let mut secrets = std::collections::BTreeMap::new();
for (key, row) in rows {
let value = crate::secrets::RedbSecretStore::decode_row_of(&key, &row)
.map_err(|err| Error::ControlStore(err.to_string()))?;
secrets.insert(key, value);
}
Ok(ControlExport {
docs,
blobs,
secrets,
})
}
}
#[derive(Debug, Clone, Copy)]
pub enum ExportRow<'a> {
Blob(BlobId, &'a [u8]),
Doc(&'a str, BlobId),
Secret(&'a str, Option<&'a [u8]>),
}
impl ControlDb {
pub fn export_each<E>(
&self,
row: &mut dyn FnMut(ExportRow<'_>) -> std::result::Result<(), E>,
) -> Result<std::result::Result<(), E>> {
let row = std::cell::RefCell::new(row);
self.read(
"exporting the control database row by row",
|txn| export_txn(txn, &mut **row.borrow_mut()),
|ram| export_ram(ram, &mut **row.borrow_mut()),
)
}
pub fn view(&self) -> Result<ControlView> {
if let Some(copy) = read_lock(&self.ram).as_ref() {
return Ok(ControlView(View::Ram(Box::new(copy.clone()))));
}
let guard = read_lock(&self.live);
let Some(db) = guard.as_ref() else {
return Err(self.off_stripe());
};
let txn = db
.begin_read()
.map_err(|err| faulted("beginning a view of the control database", err))?;
Ok(ControlView(View::Live(Box::new(txn))))
}
}
pub struct ControlView(View);
enum View {
Live(Box<redb::ReadTransaction>),
Ram(Box<RamCopy>),
}
impl ControlView {
pub fn principal_activity(&self) -> Result<Vec<(String, Vec<u8>)>> {
match &self.0 {
View::Live(txn) => activity_rows(txn),
View::Ram(_) => Ok(Vec::new()),
}
}
pub fn export_each<E>(
&self,
row: &mut dyn FnMut(ExportRow<'_>) -> std::result::Result<(), E>,
) -> Result<std::result::Result<(), E>> {
match &self.0 {
View::Live(txn) => export_txn(txn, row),
View::Ram(ram) => export_ram(ram, row),
}
}
}
fn activity_rows(txn: &redb::ReadTransaction) -> Result<Vec<(String, Vec<u8>)>> {
let table = match txn.open_table(PRINCIPAL_ACTIVITY) {
Ok(t) => t,
Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
Err(err) => return Err(faulted("opening the last-seen table", err)),
};
let mut rows = Vec::new();
for entry in table
.iter()
.map_err(|err| faulted("scanning the last-seen table", err))?
{
let (k, v) = entry.map_err(|err| faulted("reading a last-seen row", err))?;
rows.push((k.value().to_owned(), v.value().to_vec()));
}
Ok(rows)
}
fn export_txn<E>(
txn: &redb::ReadTransaction,
row: &mut dyn FnMut(ExportRow<'_>) -> std::result::Result<(), E>,
) -> Result<std::result::Result<(), E>> {
let table = txn
.open_table(BLOBS)
.map_err(|err| faulted("opening the blobs table", err))?;
for entry in table
.iter()
.map_err(|err| faulted("scanning the blobs table", err))?
{
let (k, v) = entry.map_err(|err| faulted("reading a blob", err))?;
if let Some(id) = BlobId::from_slice(k.value()) {
if let Err(e) = row(ExportRow::Blob(id, v.value())) {
return Ok(Err(e));
}
}
}
let table = txn
.open_table(DOCUMENTS)
.map_err(|err| faulted("opening the documents table", err))?;
for entry in table
.iter()
.map_err(|err| faulted("scanning the documents table", err))?
{
let (k, v) = entry.map_err(|err| faulted("reading a document pointer", err))?;
if let Some(id) = BlobId::from_slice(v.value()) {
if let Err(e) = row(ExportRow::Doc(k.value(), id)) {
return Ok(Err(e));
}
}
}
for (key, raw) in crate::secrets::RedbSecretStore::rows(txn)? {
let value = crate::secrets::RedbSecretStore::decode_row_of(&key, &raw)
.map_err(|err| Error::ControlStore(err.to_string()))?;
if let Err(e) = row(ExportRow::Secret(&key, value.as_deref())) {
return Ok(Err(e));
}
}
Ok(Ok(()))
}
fn export_ram<E>(
ram: &RamCopy,
row: &mut dyn FnMut(ExportRow<'_>) -> std::result::Result<(), E>,
) -> Result<std::result::Result<(), E>> {
for (id, bytes) in &ram.blobs {
if let Err(e) = row(ExportRow::Blob(*id, bytes)) {
return Ok(Err(e));
}
}
for (name, id) in &ram.docs {
if let Err(e) = row(ExportRow::Doc(name, *id)) {
return Ok(Err(e));
}
}
for (key, raw) in &ram.secrets {
let value = crate::secrets::RedbSecretStore::decode_row_of(key, raw)
.map_err(|err| Error::ControlStore(err.to_string()))?;
if let Err(e) = row(ExportRow::Secret(key, value.as_deref())) {
return Ok(Err(e));
}
}
Ok(Ok(()))
}
pub const RAM_COPY_CEILING_BYTES: u64 = 512 << 20;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResumeReport {
pub documents_matched: usize,
pub drift: Vec<String>,
pub off_stripe_ms: i64,
}
pub struct ControlDb {
path: Option<std::path::PathBuf>,
live: std::sync::RwLock<Option<redb::Database>>,
ram: std::sync::RwLock<Option<RamCopy>>,
group: std::sync::Mutex<Option<Group>>,
durable_commits: std::sync::atomic::AtomicU64,
}
impl std::fmt::Debug for ControlDb {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ControlDb")
.field("path", &self.path)
.field("suspended", &self.is_suspended())
.finish()
}
}
fn read_lock<T>(lock: &std::sync::RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
lock.read().unwrap_or_else(|poison| poison.into_inner())
}
fn write_lock<T>(lock: &std::sync::RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
lock.write().unwrap_or_else(|poison| poison.into_inner())
}
impl ControlDb {
fn new(path: Option<std::path::PathBuf>, db: redb::Database) -> Self {
ControlDb {
path,
live: std::sync::RwLock::new(Some(db)),
ram: std::sync::RwLock::new(None),
group: std::sync::Mutex::new(None),
durable_commits: std::sync::atomic::AtomicU64::new(0),
}
}
#[cfg(test)]
pub(crate) fn in_memory_for_tests(db: redb::Database) -> Self {
Self::new(None, db)
}
pub fn is_suspended(&self) -> bool {
read_lock(&self.ram).is_some()
}
pub fn ram_copy(&self) -> Option<RamCopy> {
read_lock(&self.ram).clone()
}
fn off_stripe(&self) -> Error {
Error::OffStripe {
why: read_lock(&self.ram)
.as_ref()
.map(|r| r.why.clone())
.unwrap_or_else(|| "suspended".to_owned()),
}
}
pub(crate) fn read<T>(
&self,
what: &str,
live: impl FnOnce(&redb::ReadTransaction) -> Result<T>,
ram: impl FnOnce(&RamCopy) -> Result<T>,
) -> Result<T> {
if let Some(copy) = read_lock(&self.ram).as_ref() {
return ram(copy);
}
let guard = read_lock(&self.live);
let Some(db) = guard.as_ref() else {
return Err(self.off_stripe());
};
let txn = db
.begin_read()
.map_err(|err| faulted(&format!("beginning {what}"), err))?;
live(&txn)
}
pub(crate) fn write<T>(
&self,
what: &str,
f: impl FnOnce(&redb::WriteTransaction) -> Result<Txn<T>>,
) -> Result<T> {
if self.is_suspended() {
return Err(self.off_stripe());
}
{
let mut group = self.group.lock().unwrap_or_else(|p| p.into_inner());
if let Some(g) = group
.as_mut()
.filter(|g| g.owner == std::thread::current().id())
{
return match f(&g.txn) {
Ok(Txn::Commit(t) | Txn::CommitUnsynced(t)) => {
g.dirty = true;
Ok(t)
}
Ok(Txn::Discard(t)) => Ok(t),
Err(err) => {
g.poisoned = true;
Err(err)
}
};
}
}
let guard = read_lock(&self.live);
let Some(db) = guard.as_ref() else {
return Err(self.off_stripe());
};
let mut txn = db
.begin_write()
.map_err(|err| faulted(&format!("beginning {what}"), err))?;
match f(&txn)? {
Txn::CommitUnsynced(t) => {
txn.set_durability(redb::Durability::None);
txn.commit()
.map_err(|err| faulted(&format!("committing {what}"), err))?;
Ok(t)
}
Txn::Commit(t) => {
txn.commit()
.map_err(|err| faulted(&format!("committing {what}"), err))?;
self.durable_commits
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(t)
}
Txn::Discard(t) => Ok(t),
}
}
pub fn begin_group(&self) -> Result<()> {
if self.is_suspended() {
return Err(self.off_stripe());
}
let mut group = self.group.lock().unwrap_or_else(|p| p.into_inner());
if group.is_some() {
return Err(Error::ControlStore(
"a group transaction is already open on this control store".to_owned(),
));
}
let guard = read_lock(&self.live);
let Some(db) = guard.as_ref() else {
return Err(self.off_stripe());
};
let txn = db
.begin_write()
.map_err(|err| faulted("beginning a group of writes", err))?;
*group = Some(Group {
owner: std::thread::current().id(),
txn,
dirty: false,
poisoned: false,
});
Ok(())
}
pub fn commit_group(&self, key: &str, seq: u64, mark: bool) -> Result<bool> {
let g = self.take_group()?;
if g.poisoned {
drop(g.txn);
return Err(Error::ControlStore(
"a write inside the group failed; the whole group was rolled back".to_owned(),
));
}
if !g.dirty && !mark {
return Ok(false);
}
{
let mut marks = g
.txn
.open_table(MARKS)
.map_err(|err| faulted("opening the marks table", err))?;
marks
.insert(key, seq)
.map_err(|err| faulted("moving a marker", err))?;
}
g.txn
.commit()
.map_err(|err| faulted("committing a group of writes", err))?;
self.durable_commits
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(true)
}
pub fn durable_commits(&self) -> u64 {
self.durable_commits
.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn abort_group(&self) {
let _ = self.take_group();
}
fn take_group(&self) -> Result<Group> {
let mut group = self.group.lock().unwrap_or_else(|p| p.into_inner());
match group.take() {
Some(g) if g.owner == std::thread::current().id() => Ok(g),
Some(g) => {
*group = Some(g);
Err(Error::ControlStore(
"the open group transaction belongs to another thread".to_owned(),
))
}
None => Err(Error::ControlStore(
"no group transaction is open".to_owned(),
)),
}
}
pub fn set_mark(&self, key: &str, seq: u64) -> Result<()> {
self.write("a marker", |txn| {
let mut marks = txn
.open_table(MARKS)
.map_err(|err| faulted("opening the marks table", err))?;
marks
.insert(key, seq)
.map_err(|err| faulted("moving a marker", err))?;
Ok(Txn::Commit(()))
})
}
pub fn mark(&self, key: &str) -> Result<Option<u64>> {
self.read(
"a marker read",
|txn| {
let table = match txn.open_table(MARKS) {
Ok(t) => t,
Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None),
Err(err) => return Err(faulted("opening the marks table", err)),
};
let found = table
.get(key)
.map_err(|err| faulted("reading a marker", err))?;
Ok(found.map(|v| v.value()))
},
|_ram| Ok(None),
)
}
pub fn principal_activity(&self) -> Result<Vec<(String, Vec<u8>)>> {
self.read("a last-seen read", activity_rows, |_ram| Ok(Vec::new()))
}
pub fn put_principal_activity(
&self,
rows: &[(String, Vec<u8>)],
mark: Option<&str>,
) -> Result<()> {
self.write("last-seen rows", |txn| {
{
let mut table = txn
.open_table(PRINCIPAL_ACTIVITY)
.map_err(|err| faulted("opening the last-seen table", err))?;
for (id, value) in rows {
table
.insert(id.as_str(), value.as_slice())
.map_err(|err| faulted("writing a last-seen row", err))?;
}
}
if let Some(key) = mark {
let mut marks = txn
.open_table(MARKS)
.map_err(|err| faulted("opening the marks table", err))?;
marks
.insert(key, 1u64)
.map_err(|err| faulted("moving a marker", err))?;
}
Ok(Txn::Commit(()))
})
}
pub fn suspend(&self, why: &str) -> Result<RamCopy> {
if self
.group
.lock()
.unwrap_or_else(|p| p.into_inner())
.is_some()
{
return Err(Error::ControlStore(
"a group of writes is open on the control store; it is suspended after the group ends".to_owned(),
));
}
let mut live = write_lock(&self.live);
if self.is_suspended() {
return Err(Error::ControlStore(
"the control store is already suspended".to_owned(),
));
}
let Some(db) = live.as_ref() else {
return Err(Error::ControlStore(
"the control store has no database to suspend".to_owned(),
));
};
let txn = db
.begin_read()
.map_err(|err| faulted("beginning the suspend snapshot", err))?;
let mut copy = RamCopy {
why: why.to_owned(),
docs: Default::default(),
blobs: Default::default(),
secrets: Default::default(),
taken_unix_ms: crate::model::now_ms(),
};
{
let table = txn
.open_table(DOCUMENTS)
.map_err(|err| faulted("opening the documents table", err))?;
for entry in table
.iter()
.map_err(|err| faulted("scanning the documents table", err))?
{
let (k, v) = entry.map_err(|err| faulted("reading a document pointer", err))?;
if let Some(id) = BlobId::from_slice(v.value()) {
copy.docs.insert(k.value().to_owned(), id);
}
}
let table = txn
.open_table(BLOBS)
.map_err(|err| faulted("opening the blobs table", err))?;
for entry in table
.iter()
.map_err(|err| faulted("scanning the blobs table", err))?
{
let (k, v) = entry.map_err(|err| faulted("reading a blob", err))?;
if let Some(id) = BlobId::from_slice(k.value()) {
copy.blobs.insert(id, v.value().to_vec());
}
}
copy.secrets = crate::secrets::RedbSecretStore::rows(&txn)?;
}
drop(txn);
let bytes = copy.bytes();
if bytes > RAM_COPY_CEILING_BYTES {
return Err(Error::ControlStore(format!(
"the control database would take {bytes} bytes of RAM to hold off the stripe, \
over the {RAM_COPY_CEILING_BYTES}-byte ceiling; not suspending"
)));
}
*write_lock(&self.ram) = Some(copy.clone());
if self.path.is_some() {
*live = None;
}
Ok(copy)
}
pub fn resume(&self) -> Result<ResumeReport> {
let mut live = write_lock(&self.live);
let Some(copy) = read_lock(&self.ram).clone() else {
return Err(Error::ControlStore(
"the control store is not suspended".to_owned(),
));
};
if live.is_none() {
let path = self.path.as_ref().ok_or_else(|| {
Error::ControlStore("no path to reopen the control store from".to_owned())
})?;
let db = redb::Database::open(path).map_err(|err| {
faulted(
&format!("reopening the control store {}", path.display()),
err,
)
})?;
*live = Some(db);
}
let db = live.as_ref().expect("installed above");
let txn = db
.begin_read()
.map_err(|err| faulted("beginning the resume check", err))?;
let table = txn
.open_table(DOCUMENTS)
.map_err(|err| faulted("opening the documents table", err))?;
let mut matched = 0;
let mut drift = Vec::new();
for (name, ram_id) in ©.docs {
let disk = table
.get(name.as_str())
.map_err(|err| faulted("reading a document pointer", err))?
.and_then(|v| BlobId::from_slice(v.value()));
match disk {
Some(id) if id == *ram_id => matched += 1,
other => drift.push(format!(
"{name}: ram {ram_id} vs disk {}",
other
.map(|i| i.to_string())
.unwrap_or_else(|| "absent".to_owned())
)),
}
}
for entry in table
.iter()
.map_err(|err| faulted("scanning the documents table", err))?
{
let (k, v) = entry.map_err(|err| faulted("reading a document pointer", err))?;
if !copy.docs.contains_key(k.value()) {
drift.push(format!(
"{}: ram absent vs disk {}",
k.value(),
BlobId::from_slice(v.value())
.map(|i| i.to_string())
.unwrap_or_else(|| "?".to_owned())
));
}
}
drop(table);
drop(txn);
*write_lock(&self.ram) = None;
Ok(ResumeReport {
documents_matched: matched,
drift,
off_stripe_ms: crate::model::now_ms() - copy.taken_unix_ms,
})
}
}
#[derive(Clone)]
pub struct RedbControlStore {
db: Arc<ControlDb>,
}
impl std::fmt::Debug for RedbControlStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("RedbControlStore")
}
}
fn faulted(what: &str, err: impl std::fmt::Display) -> Error {
Error::ControlStore(format!("{what}: {err}"))
}
impl RedbControlStore {
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let db = redb::Database::create(path).map_err(|err| {
faulted(
&format!("opening the control store {}", path.display()),
err,
)
})?;
Self::prepared(Some(path.to_path_buf()), db)
}
pub fn control_db(&self) -> Arc<ControlDb> {
Arc::clone(&self.db)
}
pub fn in_memory() -> Result<Self> {
let db = redb::Database::builder()
.create_with_backend(redb::backends::InMemoryBackend::new())
.map_err(|err| faulted("opening an in-memory control store", err))?;
Self::prepared(None, db)
}
fn prepared(path: Option<std::path::PathBuf>, db: redb::Database) -> Result<Self> {
let txn = db
.begin_write()
.map_err(|err| faulted("beginning the control store's first transaction", err))?;
{
txn.open_table(DOCUMENTS)
.map_err(|err| faulted("creating the documents table", err))?;
txn.open_table(BLOBS)
.map_err(|err| faulted("creating the blobs table", err))?;
txn.open_table(DOC_HISTORY)
.map_err(|err| faulted("creating the document history table", err))?;
txn.open_table(MARKS)
.map_err(|err| faulted("creating the marks table", err))?;
}
txn.commit()
.map_err(|err| faulted("committing the control store's first transaction", err))?;
crate::secrets::RedbSecretStore::prepare(&db)?;
Ok(RedbControlStore {
db: Arc::new(ControlDb::new(path, db)),
})
}
pub fn secrets(&self) -> crate::secrets::RedbSecretStore {
crate::secrets::RedbSecretStore::new(Arc::clone(&self.db))
}
fn next_seq(
table: &impl redb::ReadableTable<(&'static str, u64), &'static [u8]>,
name: &str,
) -> Result<u64> {
let mut range = table
.range((name, 0u64)..=(name, u64::MAX))
.map_err(|err| faulted("reading a document's history", err))?;
match range.next_back() {
None => Ok(1),
Some(entry) => {
let (key, _) = entry.map_err(|err| faulted("reading a history row", err))?;
Ok(key.value().1 + 1)
}
}
}
}
impl ControlStore for RedbControlStore {
fn put_blob(&self, bytes: &[u8]) -> Result<BlobId> {
let id = BlobId::of(bytes);
self.db.write("a blob write", |txn| {
{
let mut table = txn
.open_table(BLOBS)
.map_err(|err| faulted("opening the blobs table", err))?;
table
.insert(id.0.as_slice(), bytes)
.map_err(|err| faulted("storing a control blob", err))?;
}
Ok(Txn::Commit(id))
})
}
fn put_blob_unsynced(&self, bytes: &[u8]) -> Result<BlobId> {
let id = BlobId::of(bytes);
self.db.write("a blob write", |txn| {
{
let mut table = txn
.open_table(BLOBS)
.map_err(|err| faulted("opening the blobs table", err))?;
table
.insert(id.0.as_slice(), bytes)
.map_err(|err| faulted("storing a control blob", err))?;
}
Ok(Txn::CommitUnsynced(id))
})
}
fn get_blob(&self, id: &BlobId) -> Result<Option<Vec<u8>>> {
self.db.read(
"a blob read",
|txn| {
let table = txn
.open_table(BLOBS)
.map_err(|err| faulted("opening the blobs table", err))?;
let found = table
.get(id.0.as_slice())
.map_err(|err| faulted("reading a control blob", err))?;
Ok(found.map(|value| value.value().to_vec()))
},
|ram| Ok(ram.blobs.get(id).cloned()),
)
}
fn read_doc(&self, name: &str) -> Result<Option<BlobId>> {
self.db.read(
"a document read",
|txn| {
let table = txn
.open_table(DOCUMENTS)
.map_err(|err| faulted("opening the documents table", err))?;
let found = table
.get(name)
.map_err(|err| faulted("reading a control document pointer", err))?;
match found {
None => Ok(None),
Some(value) => BlobId::from_slice(value.value())
.map(Some)
.ok_or_else(|| Error::CorruptDocument {
ref_name: name.to_owned(),
reason: format!(
"its stored pointer is {} bytes, not the {BLOB_ID_LEN} of a blob id",
value.value().len()
),
}),
}
},
|ram| Ok(ram.docs.get(name).copied()),
)
}
fn commit_doc(&self, name: &str, expected: Option<BlobId>, new: BlobId) -> Result<bool> {
self.db.write("a control document commit", |txn| {
{
let mut documents = txn
.open_table(DOCUMENTS)
.map_err(|err| faulted("opening the documents table", err))?;
let current = documents
.get(name)
.map_err(|err| faulted("reading a control document pointer", err))?
.and_then(|value| BlobId::from_slice(value.value()));
if current != expected {
return Ok(Txn::Discard(false));
}
documents
.insert(name, new.0.as_slice())
.map_err(|err| faulted("moving a control document pointer", err))?;
let mut history = txn
.open_table(DOC_HISTORY)
.map_err(|err| faulted("opening the document history table", err))?;
let seq = Self::next_seq(&history, name)?;
let mut row = Vec::with_capacity(8 + BLOB_ID_LEN);
row.extend_from_slice(&crate::model::now_ms().to_be_bytes());
row.extend_from_slice(&new.0);
history
.insert((name, seq), row.as_slice())
.map_err(|err| faulted("appending to the document history", err))?;
}
Ok(Txn::Commit(true))
})
}
fn history(&self, name: &str) -> Result<Vec<DocVersion>> {
self.db.read(
"a history read",
|txn| {
let table = txn
.open_table(DOC_HISTORY)
.map_err(|err| faulted("opening the document history table", err))?;
let range = table
.range((name, 0u64)..=(name, u64::MAX))
.map_err(|err| faulted("scanning a document's history", err))?;
let mut out = Vec::new();
for entry in range {
let (key, value) =
entry.map_err(|err| faulted("reading a history row", err))?;
let bytes = value.value();
if bytes.len() != 8 + BLOB_ID_LEN {
return Err(Error::CorruptDocument {
ref_name: name.to_owned(),
reason: format!(
"a history row is {} bytes, not {}",
bytes.len(),
8 + BLOB_ID_LEN
),
});
}
let mut millis = [0u8; 8];
millis.copy_from_slice(&bytes[..8]);
out.push(DocVersion {
seq: key.value().1,
at: i64::from_be_bytes(millis),
blob: BlobId::from_slice(&bytes[8..]).expect("checked length"),
});
}
Ok(out)
},
|ram| {
Err(Error::OffStripe {
why: format!("{}; the audit trail is not held in RAM", ram.why),
})
},
)
}
}
pub type SharedControlStore = Arc<dyn ControlStore>;
pub fn open_control_store(path: impl AsRef<Path>) -> Result<SharedControlStore> {
Ok(Arc::new(RedbControlStore::open(path)?))
}
pub fn open_control(
path: impl AsRef<Path>,
) -> Result<(SharedControlStore, crate::secrets::RedbSecretStore)> {
let (store, secrets, _) = open_control_with_db(path)?;
Ok((store, secrets))
}
pub fn open_control_with_db(
path: impl AsRef<Path>,
) -> Result<(
SharedControlStore,
crate::secrets::RedbSecretStore,
Arc<ControlDb>,
)> {
let store = RedbControlStore::open(path)?;
let secrets = store.secrets();
let db = store.control_db();
Ok((Arc::new(store), secrets, db))
}
pub fn memory_control_store() -> Result<SharedControlStore> {
Ok(Arc::new(RedbControlStore::in_memory()?))
}
pub fn memory_control() -> Result<(SharedControlStore, crate::secrets::RedbSecretStore)> {
let store = RedbControlStore::in_memory()?;
let secrets = store.secrets();
Ok((Arc::new(store), secrets))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_blob_id_is_the_hash_of_the_bytes_and_round_trips_through_hex() {
let id = BlobId::of(b"the identity table");
assert_eq!(BlobId::from_hex(&id.to_string()), Some(id));
assert_ne!(id, BlobId::of(b"the identity tabl3"));
assert_eq!(BlobId::from_hex("not hex"), None);
assert_eq!(BlobId::from_hex(""), None);
}
#[test]
fn an_unwritten_document_reads_as_absent_and_a_written_one_reads_back() {
let store = RedbControlStore::in_memory().expect("open");
assert_eq!(store.read_doc("refs/gunnar/auth").expect("read"), None);
let id = store.put_blob(b"{}").expect("put");
assert_eq!(
store.get_blob(&id).expect("get").as_deref(),
Some(&b"{}"[..])
);
assert!(store
.commit_doc("refs/gunnar/auth", None, id)
.expect("commit"));
assert_eq!(store.read_doc("refs/gunnar/auth").expect("read"), Some(id));
}
#[test]
fn a_suspended_store_reads_from_ram_refuses_writes_and_resumes_without_drift() {
use gunnar_store::SecretStore as _;
let store = RedbControlStore::in_memory().expect("open");
let secrets = store.secrets();
let db = store.control_db();
let id = store.put_blob(b"{\"principals\":1}").expect("put");
assert!(store
.commit_doc("refs/gunnar/auth", None, id)
.expect("commit"));
secrets
.put("host-key", b"sealed bytes")
.expect("put secret");
assert!(!db.is_suspended());
let copy = db.suspend("draining for a growth").expect("suspend");
assert!(db.is_suspended());
assert_eq!(copy.documents(), 1);
assert!(copy.bytes() > 0);
assert_eq!(copy.why, "draining for a growth");
assert_eq!(store.read_doc("refs/gunnar/auth").expect("read"), Some(id));
assert_eq!(
store.get_blob(&id).expect("get").as_deref(),
Some(&b"{\"principals\":1}"[..])
);
assert_eq!(store.read_doc("never-written").expect("read"), None);
assert_eq!(
secrets.get("host-key").expect("get").as_deref(),
Some(&b"sealed bytes"[..])
);
assert_eq!(secrets.get("absent").expect("get"), None);
assert_eq!(
secrets
.get_or_create("host-key", &mut || panic!("must not mint"))
.expect("held"),
b"sealed bytes"
);
let refused = store.put_blob(b"new").unwrap_err();
assert!(matches!(refused, Error::OffStripe { .. }), "{refused:?}");
assert!(
refused.to_string().contains("draining for a growth"),
"{refused}"
);
assert!(refused.to_string().contains("retry"), "{refused}");
let other = BlobId::of(b"other");
assert!(matches!(
store
.commit_doc("refs/gunnar/auth", Some(id), other)
.unwrap_err(),
Error::OffStripe { .. }
));
assert!(matches!(
store.history("refs/gunnar/auth").unwrap_err(),
Error::OffStripe { .. }
));
let sec = secrets.put("host-key", b"x").unwrap_err().to_string();
assert!(sec.contains("off the stripe"), "{sec}");
let mint = secrets
.get_or_create("fresh", &mut || Ok(b"minted".to_vec()))
.unwrap_err()
.to_string();
assert!(mint.contains("will not be minted"), "{mint}");
assert!(
db.suspend("again").is_err(),
"a second suspend is a refusal"
);
let report = db.resume().expect("resume");
assert!(!db.is_suspended());
assert_eq!(report.documents_matched, 1);
assert!(report.drift.is_empty(), "{:?}", report.drift);
assert!(
db.resume().is_err(),
"resuming an unsuspended store is a refusal"
);
assert!(store
.commit_doc("refs/gunnar/auth", Some(id), other)
.expect("commit"));
assert_eq!(store.history("refs/gunnar/auth").expect("history").len(), 2);
assert_eq!(
secrets
.get_or_create("fresh", &mut || Ok(b"minted".to_vec()))
.expect("mint"),
b"minted"
);
}
#[cfg(target_os = "linux")]
#[test]
fn a_suspended_file_store_holds_no_descriptor_on_its_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("control.redb");
let store = RedbControlStore::open(&path).expect("open");
let db = store.control_db();
let id = store.put_blob(b"doc").expect("put");
assert!(store.commit_doc("d", None, id).expect("commit"));
let holds = |p: &Path| -> bool {
std::fs::read_dir("/proc/self/fd")
.expect("procfs")
.flatten()
.filter_map(|e| std::fs::read_link(e.path()).ok())
.any(|t| t == p)
};
assert!(holds(&path), "the open store holds its file");
db.suspend("unmounting").expect("suspend");
assert!(
!holds(&path),
"a suspended store holds NO descriptor on its file"
);
assert_eq!(store.read_doc("d").expect("read from ram"), Some(id));
db.resume().expect("resume");
assert!(holds(&path), "a resumed store holds its file again");
assert_eq!(store.read_doc("d").expect("read from disk"), Some(id));
}
#[test]
fn a_commit_against_the_wrong_expectation_is_refused_and_changes_nothing() {
let store = RedbControlStore::in_memory().expect("open");
let first = store.put_blob(b"one").expect("put");
let second = store.put_blob(b"two").expect("put");
assert!(store.commit_doc("doc", None, first).expect("commit"));
assert!(
!store.commit_doc("doc", None, second).expect("commit"),
"a commit that expected an absent document was accepted against a written one"
);
assert_eq!(
store.read_doc("doc").expect("read"),
Some(first),
"a refused commit still moved the pointer"
);
assert_eq!(
store.history("doc").expect("history").len(),
1,
"a refused commit still appended to the audit trail"
);
assert!(store
.commit_doc("doc", Some(first), second)
.expect("commit"));
assert_eq!(store.read_doc("doc").expect("read"), Some(second));
}
#[test]
fn the_history_is_append_only_and_one_document_does_not_see_anothers() {
let store = RedbControlStore::in_memory().expect("open");
let mut previous = None;
let mut expected = Vec::new();
for text in [b"a".as_slice(), b"bb", b"ccc"] {
let id = store.put_blob(text).expect("put");
assert!(store.commit_doc("auth", previous, id).expect("commit"));
previous = Some(id);
expected.push(id);
}
let other = store.put_blob(b"catalog").expect("put");
assert!(store.commit_doc("catalog", None, other).expect("commit"));
let history = store.history("auth").expect("history");
assert_eq!(
history.iter().map(|v| v.blob).collect::<Vec<_>>(),
expected,
"the audit trail must list every version of the document, oldest first"
);
assert_eq!(
history.iter().map(|v| v.seq).collect::<Vec<_>>(),
vec![1, 2, 3]
);
assert_eq!(
store.history("catalog").expect("history").len(),
1,
"one document's history picked up another document's versions"
);
assert_eq!(store.history("never-written").expect("history").len(), 0);
assert_eq!(
store.get_blob(&history[0].blob).expect("get").as_deref(),
Some(&b"a"[..])
);
}
}