From 96d7713915861da178c101700c37d0ac580dd1dc Mon Sep 17 00:00:00 2001 From: Julien Kritter Date: Fri, 13 Sep 2024 10:40:46 +0200 Subject: [PATCH 1/4] Add support for an LSM-tree-based backend with Fjall --- Cargo.lock | 197 ++++++++++++++++++++- Cargo.toml | 1 + src/db/Cargo.toml | 2 + src/db/fjall_adapter.rs | 366 ++++++++++++++++++++++++++++++++++++++++ src/db/lib.rs | 2 + src/db/open.rs | 23 +++ src/garage/Cargo.toml | 1 + src/model/Cargo.toml | 1 + src/model/garage.rs | 7 + src/util/config.rs | 4 + 10 files changed, 603 insertions(+), 1 deletion(-) create mode 100644 src/db/fjall_adapter.rs diff --git a/Cargo.lock b/Cargo.lock index 6acd85ff..cd44160c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -687,6 +687,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d2c12f985c78475a6b8d629afd0c360260ef34cfef52efccdcfd31972f81c2e" +[[package]] +name = "byteview" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6236364b88b9b6d0bc181ba374cf1ab55ba3ef97a1cb6f8cddad48a273767fb5" + [[package]] name = "cc" version = "1.2.16" @@ -798,6 +804,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "compare" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0095f6103c2a8b44acd6fd15960c801dafebf02e21940360833e0673f48ba7" + [[package]] name = "core-foundation" version = "0.9.4" @@ -874,6 +886,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -883,6 +904,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-skiplist" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -954,6 +985,20 @@ dependencies = [ "num_cpus", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core 0.9.10", +] + [[package]] name = "deranged" version = "0.4.0" @@ -996,6 +1041,12 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "double-ended-peekable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0d05e1c0dbad51b52c38bda7adceef61b9efc2baf04acfe8726a8c4630a6f57" + [[package]] name = "dyn-clone" version = "1.0.19" @@ -1017,6 +1068,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "env_logger" version = "0.10.2" @@ -1084,6 +1147,23 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "fjall" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b25ad44cd4360a0448a9b5a0a6f1c7a621101cca4578706d43c9a821418aebc" +dependencies = [ + "byteorder", + "byteview", + "dashmap 6.1.0", + "log", + "lsm-tree", + "path-absolutize", + "std-semaphore", + "tempfile", + "xxhash-rust", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1412,6 +1492,7 @@ name = "garage_db" version = "1.2.0" dependencies = [ "err-derive", + "fjall", "heed", "mktemp", "r2d2", @@ -1655,6 +1736,12 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "guardian" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" + [[package]] name = "h2" version = "0.3.26" @@ -2229,6 +2316,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "interval-heap" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11274e5e8e89b8607cfedc2910b6626e998779b48a019151c7604d0adcb86ac6" +dependencies = [ + "compare", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2585,6 +2681,36 @@ dependencies = [ "hashbrown 0.15.2", ] +[[package]] +name = "lsm-tree" +version = "2.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab73c02eadb3dc12c0024e5b61d6284e6d59064e67e74fbad77856caa56f62c7" +dependencies = [ + "byteorder", + "crossbeam-skiplist", + "double-ended-peekable", + "enum_dispatch", + "guardian", + "interval-heap", + "log", + "lz4_flex", + "path-absolutize", + "quick_cache", + "rustc-hash", + "self_cell", + "tempfile", + "value-log", + "varint-rs", + "xxhash-rust", +] + +[[package]] +name = "lz4_flex" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75761162ae2b0e580d7e7c390558127e5f01b4194debd6221fd8c207fc80e3f5" + [[package]] name = "matchers" version = "0.1.0" @@ -2839,7 +2965,7 @@ checksum = "6105e89802af13fdf48c49d7646d3b533a70e536d818aae7e78ba0433d01acb8" dependencies = [ "async-trait", "crossbeam-channel", - "dashmap", + "dashmap 4.0.2", "fnv", "futures-channel", "futures-executor", @@ -3000,6 +3126,24 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "path-absolutize" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4af381fe79fa195b4909485d99f73a80792331df0625188e707854f0b3383f5" +dependencies = [ + "path-dedot", +] + +[[package]] +name = "path-dedot" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07ba0ad7e047712414213ff67533e6dd477af0a4e1d14fb52343e53d30ea9397" +dependencies = [ + "once_cell", +] + [[package]] name = "pem" version = "3.0.5" @@ -3295,6 +3439,16 @@ dependencies = [ "serde", ] +[[package]] +name = "quick_cache" +version = "0.6.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ad6644cb07b7f3488b9f3d2fde3b4c0a7fa367cafefb39dff93a659f76eb786" +dependencies = [ + "equivalent", + "hashbrown 0.15.2", +] + [[package]] name = "quote" version = "1.0.40" @@ -3532,6 +3686,12 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3774,6 +3934,12 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" + [[package]] name = "semver" version = "1.0.26" @@ -3987,6 +4153,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "std-semaphore" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ae9eec00137a8eed469fb4148acd9fc6ac8c3f9b110f52cd34698c8b5bfa0e" + [[package]] name = "strsim" version = "0.11.1" @@ -4664,6 +4836,29 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "value-log" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62fc7c4ce161f049607ecea654dca3f2d727da5371ae85e2e4f14ce2b98ed67c" +dependencies = [ + "byteorder", + "byteview", + "interval-heap", + "log", + "path-absolutize", + "rustc-hash", + "tempfile", + "varint-rs", + "xxhash-rust", +] + +[[package]] +name = "varint-rs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f54a172d0620933a27a4360d3db3e2ae0dd6cceae9730751a036bbf182c4b23" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index 789225b8..9876db60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,6 +90,7 @@ heed = { version = "0.11", default-features = false, features = ["lmdb"] } rusqlite = "0.31.0" r2d2 = "0.8" r2d2_sqlite = "0.24" +fjall = "2.4" async-compression = { version = "0.4", features = ["tokio", "zstd"] } zstd = { version = "0.13", default-features = false } diff --git a/src/db/Cargo.toml b/src/db/Cargo.toml index 666296ce..06b2fabc 100644 --- a/src/db/Cargo.toml +++ b/src/db/Cargo.toml @@ -19,6 +19,7 @@ heed = { workspace = true, optional = true } rusqlite = { workspace = true, optional = true, features = ["backup"] } r2d2 = { workspace = true, optional = true } r2d2_sqlite = { workspace = true, optional = true } +fjall = { workspace = true, optional = true } [dev-dependencies] mktemp.workspace = true @@ -27,4 +28,5 @@ mktemp.workspace = true default = [ "lmdb", "sqlite" ] bundled-libs = [ "rusqlite?/bundled" ] lmdb = [ "heed" ] +fjall = [ "dep:fjall" ] sqlite = [ "rusqlite", "r2d2", "r2d2_sqlite" ] diff --git a/src/db/fjall_adapter.rs b/src/db/fjall_adapter.rs new file mode 100644 index 00000000..57b540c1 --- /dev/null +++ b/src/db/fjall_adapter.rs @@ -0,0 +1,366 @@ +use core::ops::Bound; + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; + +use fjall::{ + PartitionCreateOptions, PersistMode, TransactionalKeyspace, TransactionalPartitionHandle, + WriteTransaction, +}; + +use crate::{ + Db, Error, IDb, ITx, ITxFn, OnCommit, Result, TxError, TxFnResult, TxOpError, TxOpResult, + TxResult, TxValueIter, Value, ValueIter, +}; + +pub use fjall; + +// -- err + +impl From for Error { + fn from(e: fjall::Error) -> Error { + Error(format!("fjall: {}", e).into()) + } +} + +impl From for Error { + fn from(e: fjall::LsmError) -> Error { + Error(format!("fjall lsm_tree: {}", e).into()) + } +} + +impl From for TxOpError { + fn from(e: fjall::Error) -> TxOpError { + TxOpError(e.into()) + } +} + +// -- db + +pub struct FjallDb { + path: PathBuf, + keyspace: TransactionalKeyspace, + trees: RwLock<(Vec, HashMap)>, +} + +type ByteRefRangeBound<'r> = (Bound<&'r [u8]>, Bound<&'r [u8]>); + +impl FjallDb { + pub fn init(path: &PathBuf, keyspace: TransactionalKeyspace) -> Db { + let s = Self { + path: path.clone(), + keyspace, + trees: RwLock::new((Vec::new(), HashMap::new())), + }; + Db(Arc::new(s)) + } + + fn get_tree(&self, i: usize) -> Result { + self.trees + .read() + .unwrap() + .0 + .get(i) + .cloned() + .ok_or_else(|| Error("invalid tree id".into())) + } + + fn canonicalize(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect::() + } +} + +impl IDb for FjallDb { + fn engine(&self) -> String { + "LSM trees (using Fjall crate)".into() + } + + fn open_tree(&self, name: &str) -> Result { + let mut trees = self.trees.write().unwrap(); + let canonical_name = FjallDb::canonicalize(name); + if let Some(i) = trees.1.get(&canonical_name) { + Ok(*i) + } else { + let tree = self + .keyspace + .open_partition(&canonical_name, PartitionCreateOptions::default())?; + let i = trees.0.len(); + trees.0.push(tree); + trees.1.insert(canonical_name, i); + Ok(i) + } + } + + fn list_trees(&self) -> Result> { + Ok(self + .keyspace + .list_partitions() + .iter() + .map(|n| n.to_string()) + .collect()) + } + + fn snapshot(&self, to: &PathBuf) -> Result<()> { + std::fs::create_dir_all(to)?; + let mut path = to.clone(); + path.push("data.fjall"); + + let source_keyspace = fjall::Config::new(&self.path).open()?; + let copy_keyspace = fjall::Config::new(path).open()?; + + for partition_name in source_keyspace.list_partitions() { + let source_partition = source_keyspace + .open_partition(&partition_name, PartitionCreateOptions::default())?; + let snapshot = source_partition.snapshot(); + let copy_partition = + copy_keyspace.open_partition(&partition_name, PartitionCreateOptions::default())?; + + for entry in snapshot.iter() { + let (key, value) = entry?; + copy_partition.insert(key, value)?; + } + } + + copy_keyspace.persist(PersistMode::SyncAll)?; + Ok(()) + } + + // ---- + + fn get(&self, tree_idx: usize, key: &[u8]) -> Result> { + let tree = self.get_tree(tree_idx)?; + let tx = self.keyspace.read_tx(); + let val = tx.get(&tree, key)?; + match val { + None => Ok(None), + Some(v) => Ok(Some(v.to_vec())), + } + } + + fn len(&self, tree_idx: usize) -> Result { + let tree = self.get_tree(tree_idx)?; + let tx = self.keyspace.read_tx(); + Ok(tx.len(&tree)?) + } + + fn insert(&self, tree_idx: usize, key: &[u8], value: &[u8]) -> Result<()> { + let tree = self.get_tree(tree_idx)?; + let mut tx = self.keyspace.write_tx(); + tx.insert(&tree, key, value); + tx.commit()?; + Ok(()) + } + + fn remove(&self, tree_idx: usize, key: &[u8]) -> Result<()> { + let tree = self.get_tree(tree_idx)?; + let mut tx = self.keyspace.write_tx(); + tx.remove(&tree, key); + tx.commit()?; + Ok(()) + } + + fn clear(&self, tree_idx: usize) -> Result<()> { + let tree = self.get_tree(tree_idx)?; + let tree_name = tree.inner().name.clone(); + self.keyspace.delete_partition(tree)?; + let tree = self + .keyspace + .open_partition(&tree_name, PartitionCreateOptions::default())?; + let mut trees = self.trees.write().unwrap(); + trees.0[tree_idx] = tree; + Ok(()) + } + + fn iter(&self, tree_idx: usize) -> Result> { + let tree = self.get_tree(tree_idx)?; + let tx = self.keyspace.read_tx(); + Ok(Box::new(tx.iter(&tree).map(iterator_remap))) + } + + fn iter_rev(&self, tree_idx: usize) -> Result> { + let tree = self.get_tree(tree_idx)?; + let tx = self.keyspace.read_tx(); + Ok(Box::new(tx.iter(&tree).rev().map(iterator_remap))) + } + + fn range<'r>( + &self, + tree_idx: usize, + low: Bound<&'r [u8]>, + high: Bound<&'r [u8]>, + ) -> Result> { + let tree = self.get_tree(tree_idx)?; + let tx = self.keyspace.read_tx(); + Ok(Box::new( + tx.range::<&'r [u8], ByteRefRangeBound>(&tree, (low, high)) + .map(iterator_remap), + )) + } + fn range_rev<'r>( + &self, + tree_idx: usize, + low: Bound<&'r [u8]>, + high: Bound<&'r [u8]>, + ) -> Result> { + let tree = self.get_tree(tree_idx)?; + let tx = self.keyspace.read_tx(); + Ok(Box::new( + tx.range::<&'r [u8], ByteRefRangeBound>(&tree, (low, high)) + .rev() + .map(iterator_remap), + )) + } + + // ---- + + fn transaction(&self, f: &dyn ITxFn) -> TxResult { + let trees = self.trees.read().unwrap(); + let mut tx = FjallTx { + trees: &trees.0[..], + tx: self.keyspace.write_tx(), + }; + + let res = f.try_on(&mut tx); + match res { + TxFnResult::Ok(on_commit) => { + tx.tx.commit().map_err(Error::from).map_err(TxError::Db)?; + Ok(on_commit) + } + TxFnResult::Abort => { + tx.tx.rollback(); + Err(TxError::Abort(())) + } + TxFnResult::DbErr => { + tx.tx.rollback(); + Err(TxError::Db(Error( + "(this message will be discarded)".into(), + ))) + } + } + } +} + +// ---- + +struct FjallTx<'a> { + trees: &'a [TransactionalPartitionHandle], + tx: WriteTransaction<'a>, +} + +impl<'a> FjallTx<'a> { + fn get_tree(&self, i: usize) -> TxOpResult<&TransactionalPartitionHandle> { + self.trees.get(i).ok_or_else(|| { + TxOpError(Error( + "invalid tree id (it might have been openned after the transaction started)".into(), + )) + }) + } +} + +impl<'a> ITx for FjallTx<'a> { + fn get(&self, tree_idx: usize, key: &[u8]) -> TxOpResult> { + let tree = self.get_tree(tree_idx)?; + match self.tx.get(tree, key)? { + Some(v) => Ok(Some(v.to_vec())), + None => Ok(None), + } + } + fn len(&self, tree_idx: usize) -> TxOpResult { + let tree = self.get_tree(tree_idx)?; + Ok(self.tx.len(tree)? as usize) + } + + fn insert(&mut self, tree_idx: usize, key: &[u8], value: &[u8]) -> TxOpResult<()> { + let tree = self.get_tree(tree_idx)?.clone(); + self.tx.insert(&tree, key, value); + Ok(()) + } + fn remove(&mut self, tree_idx: usize, key: &[u8]) -> TxOpResult<()> { + let tree = self.get_tree(tree_idx)?.clone(); + self.tx.remove(&tree, key); + Ok(()) + } + fn clear(&mut self, _tree_idx: usize) -> TxOpResult<()> { + unimplemented!("LSM tree clearing in cross-partition transaction is not supported") + } + + fn iter(&self, tree_idx: usize) -> TxOpResult> { + let tree = self.get_tree(tree_idx)?.clone(); + Ok(Box::new(self.tx.iter(&tree).map(iterator_remap_tx))) + } + fn iter_rev(&self, tree_idx: usize) -> TxOpResult> { + let tree = self.get_tree(tree_idx)?.clone(); + Ok(Box::new(self.tx.iter(&tree).rev().map(iterator_remap_tx))) + } + + fn range<'r>( + &self, + tree_idx: usize, + low: Bound<&'r [u8]>, + high: Bound<&'r [u8]>, + ) -> TxOpResult> { + let tree = self.get_tree(tree_idx)?; + let low = clone_bound(low); + let high = clone_bound(high); + Ok(Box::new( + self.tx + .range::, ByteVecRangeBounds>(&tree, (low, high)) + .map(iterator_remap_tx), + )) + } + fn range_rev<'r>( + &self, + tree_idx: usize, + low: Bound<&'r [u8]>, + high: Bound<&'r [u8]>, + ) -> TxOpResult> { + let tree = self.get_tree(tree_idx)?; + let low = clone_bound(low); + let high = clone_bound(high); + Ok(Box::new( + self.tx + .range::, ByteVecRangeBounds>(&tree, (low, high)) + .rev() + .map(iterator_remap_tx), + )) + } +} + +// -- maps fjall's (k, v) to ours + +fn iterator_remap(r: fjall::Result<(fjall::Slice, fjall::Slice)>) -> Result<(Value, Value)> { + r.map(|(k, v)| (k.to_vec(), v.to_vec())) + .map_err(|e| e.into()) +} + +fn iterator_remap_tx(r: fjall::Result<(fjall::Slice, fjall::Slice)>) -> TxOpResult<(Value, Value)> { + r.map(|(k, v)| (k.to_vec(), v.to_vec())) + .map_err(|e| e.into()) +} + +// -- utils to deal with Garage's tightness on Bound lifetimes + +type ByteVecBound = Bound>; +type ByteVecRangeBounds = (ByteVecBound, ByteVecBound); + +fn clone_bound(bound: Bound<&[u8]>) -> ByteVecBound { + let value = match bound { + Bound::Excluded(v) | Bound::Included(v) => v.to_vec(), + Bound::Unbounded => vec![], + }; + + match bound { + Bound::Included(_) => Bound::Included(value), + Bound::Excluded(_) => Bound::Excluded(value), + Bound::Unbounded => Bound::Unbounded, + } +} diff --git a/src/db/lib.rs b/src/db/lib.rs index c55c8643..3454c759 100644 --- a/src/db/lib.rs +++ b/src/db/lib.rs @@ -1,6 +1,8 @@ #[macro_use] extern crate tracing; +#[cfg(feature = "fjall")] +pub mod fjall_adapter; #[cfg(feature = "lmdb")] pub mod lmdb_adapter; #[cfg(feature = "sqlite")] diff --git a/src/db/open.rs b/src/db/open.rs index ff3bc830..83ae1f93 100644 --- a/src/db/open.rs +++ b/src/db/open.rs @@ -1,4 +1,6 @@ +use std::convert::TryInto; use std::path::PathBuf; +use std::sync::Arc; use crate::{Db, Error, Result}; @@ -11,6 +13,7 @@ use crate::{Db, Error, Result}; pub enum Engine { Lmdb, Sqlite, + Fjall, } impl Engine { @@ -19,6 +22,7 @@ impl Engine { match self { Self::Lmdb => "lmdb", Self::Sqlite => "sqlite", + Self::Fjall => "fjall", } } } @@ -36,6 +40,7 @@ impl std::str::FromStr for Engine { match text { "lmdb" | "heed" => Ok(Self::Lmdb), "sqlite" | "sqlite3" | "rusqlite" => Ok(Self::Sqlite), + "fjall" => Ok(Self::Fjall), "sled" => Err(Error("Sled is no longer supported as a database engine. Converting your old metadata db can be done using an older Garage binary (e.g. v0.9.4).".into())), kind => Err(Error( format!( @@ -51,6 +56,7 @@ impl std::str::FromStr for Engine { pub struct OpenOpt { pub fsync: bool, pub lmdb_map_size: Option, + pub fjall_block_cache_size: Option, } impl Default for OpenOpt { @@ -58,6 +64,7 @@ impl Default for OpenOpt { Self { fsync: false, lmdb_map_size: None, + fjall_block_cache_size: None, } } } @@ -114,6 +121,22 @@ pub fn open_db(path: &PathBuf, engine: Engine, opt: &OpenOpt) -> Result { } } + // ---- Fjall DB ---- + #[cfg(feature = "fjall")] + Engine::Fjall => { + info!("Opening Fjall database at: {}", path.display()); + let fsync_ms = opt.fsync.then(|| 1000 as u16); + let mut config = fjall::Config::new(path).fsync_ms(fsync_ms); + if let Some(block_cache_size) = opt.fjall_block_cache_size { + let block_cache = Arc::new(fjall::BlockCache::with_capacity_bytes( + block_cache_size.try_into().unwrap(), + )); + config = config.block_cache(block_cache); + } + let keyspace = config.open_transactional()?; + Ok(crate::fjall_adapter::FjallDb::init(path, keyspace)) + } + // Pattern is unreachable when all supported DB engines are compiled into binary. The allow // attribute is added so that we won't have to change this match in case stop building // support for one or more engines by default. diff --git a/src/garage/Cargo.toml b/src/garage/Cargo.toml index ae3b5609..7d60313e 100644 --- a/src/garage/Cargo.toml +++ b/src/garage/Cargo.toml @@ -91,6 +91,7 @@ k2v = [ "garage_util/k2v", "garage_api_k2v" ] # Database engines lmdb = [ "garage_model/lmdb" ] sqlite = [ "garage_model/sqlite" ] +fjall = [ "garage_model/fjall" ] # Automatic registration and discovery via Consul API consul-discovery = [ "garage_rpc/consul-discovery" ] diff --git a/src/model/Cargo.toml b/src/model/Cargo.toml index 376eaa9a..14f92253 100644 --- a/src/model/Cargo.toml +++ b/src/model/Cargo.toml @@ -44,3 +44,4 @@ default = [ "lmdb", "sqlite" ] k2v = [ "garage_util/k2v" ] lmdb = [ "garage_db/lmdb" ] sqlite = [ "garage_db/sqlite" ] +fjall = [ "garage_db/fjall" ] \ No newline at end of file diff --git a/src/model/garage.rs b/src/model/garage.rs index 11c0d90f..7420e740 100644 --- a/src/model/garage.rs +++ b/src/model/garage.rs @@ -124,6 +124,9 @@ impl Garage { db::Engine::Lmdb => { db_path.push("db.lmdb"); } + db::Engine::Fjall => { + db_path.push("db.fjall"); + } } let db_opt = db::OpenOpt { fsync: config.metadata_fsync, @@ -131,6 +134,10 @@ impl Garage { v if v == usize::default() => None, v => Some(v), }, + fjall_block_cache_size: match config.fjall_block_cache_size { + v if v == usize::default() => None, + v => Some(v), + }, }; let db = db::open_db(&db_path, db_engine, &db_opt) .ok_or_message("Unable to open metadata db")?; diff --git a/src/util/config.rs b/src/util/config.rs index c74029e7..19c3e821 100644 --- a/src/util/config.rs +++ b/src/util/config.rs @@ -122,6 +122,10 @@ pub struct Config { #[serde(deserialize_with = "deserialize_capacity", default)] pub lmdb_map_size: usize, + /// Fjall block cache size + #[serde(deserialize_with = "deserialize_capacity", default)] + pub fjall_block_cache_size: usize, + // -- APIs /// Configuration for S3 api pub s3_api: S3ApiConfig, From a6c6c44310973aba4625abba3819eaf1099362b5 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Sat, 4 Jan 2025 17:56:09 +0100 Subject: [PATCH 2/4] nix: build and test fjall feature --- .woodpecker/debug.yaml | 5 +++++ flake.nix | 3 +++ nix/compile.nix | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.woodpecker/debug.yaml b/.woodpecker/debug.yaml index 65dab9ab..62266aa4 100644 --- a/.woodpecker/debug.yaml +++ b/.woodpecker/debug.yaml @@ -28,6 +28,11 @@ steps: commands: - nix-build -j4 --attr flakePackages.tests-sqlite + - name: unit + func tests (fjall) + image: nixpkgs/nix:nixos-22.05 + commands: + - nix-build -j4 --attr flakePackages.tests-fjall + - name: integration tests image: nixpkgs/nix:nixos-22.05 commands: diff --git a/flake.nix b/flake.nix index fc599e0b..2fb8c48e 100644 --- a/flake.nix +++ b/flake.nix @@ -53,6 +53,9 @@ tests-sqlite = testWith { GARAGE_TEST_INTEGRATION_DB_ENGINE = "sqlite"; }; + tests-fjall = testWith { + GARAGE_TEST_INTEGRATION_DB_ENGINE = "fjall"; + }; }; # ---- developpment shell, for making native builds only ---- diff --git a/nix/compile.nix b/nix/compile.nix index bbadaa37..7e9f79ab 100644 --- a/nix/compile.nix +++ b/nix/compile.nix @@ -68,7 +68,7 @@ let rootFeatures = if features != null then features else - ([ "bundled-libs" "lmdb" "sqlite" "k2v" ] ++ (lib.optionals release [ + ([ "bundled-libs" "lmdb" "sqlite" "fjall" "k2v" ] ++ (lib.optionals release [ "consul-discovery" "kubernetes-discovery" "metrics" From aa69c06f2b1b76630ae5b0f9d14c4223dbee6641 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 27 Aug 2025 19:41:06 +0200 Subject: [PATCH 3/4] fix potential race condition and naming bug in fjall adapter --- Cargo.lock | 1 + Cargo.toml | 1 + src/db/Cargo.toml | 5 +- src/db/fjall_adapter.rs | 170 ++++++++++++++++++++++++++++------------ src/db/open.rs | 8 +- src/db/test.rs | 14 +++- 6 files changed, 139 insertions(+), 60 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cd44160c..997d6a92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1495,6 +1495,7 @@ dependencies = [ "fjall", "heed", "mktemp", + "parking_lot 0.12.3", "r2d2", "r2d2_sqlite", "rusqlite", diff --git a/Cargo.toml b/Cargo.toml index 9876db60..fdec5010 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,7 @@ md-5 = "0.10" mktemp = "0.5" nix = { version = "0.29", default-features = false, features = ["fs"] } nom = "7.1" +parking_lot = "0.12" parse_duration = "2.1" pin-project = "1.0.12" pnet_datalink = "0.34" diff --git a/src/db/Cargo.toml b/src/db/Cargo.toml index 06b2fabc..e9ed15c9 100644 --- a/src/db/Cargo.toml +++ b/src/db/Cargo.toml @@ -16,10 +16,13 @@ err-derive.workspace = true tracing.workspace = true heed = { workspace = true, optional = true } + rusqlite = { workspace = true, optional = true, features = ["backup"] } r2d2 = { workspace = true, optional = true } r2d2_sqlite = { workspace = true, optional = true } + fjall = { workspace = true, optional = true } +parking_lot = { workspace = true, optional = true } [dev-dependencies] mktemp.workspace = true @@ -28,5 +31,5 @@ mktemp.workspace = true default = [ "lmdb", "sqlite" ] bundled-libs = [ "rusqlite?/bundled" ] lmdb = [ "heed" ] -fjall = [ "dep:fjall" ] +fjall = [ "dep:fjall", "dep:parking_lot" ] sqlite = [ "rusqlite", "r2d2", "r2d2_sqlite" ] diff --git a/src/db/fjall_adapter.rs b/src/db/fjall_adapter.rs index 57b540c1..d91ef12f 100644 --- a/src/db/fjall_adapter.rs +++ b/src/db/fjall_adapter.rs @@ -1,8 +1,9 @@ use core::ops::Bound; -use std::collections::HashMap; use std::path::PathBuf; -use std::sync::{Arc, RwLock}; +use std::sync::Arc; + +use parking_lot::{MappedRwLockReadGuard, RwLock, RwLockReadGuard}; use fjall::{ PartitionCreateOptions, PersistMode, TransactionalKeyspace, TransactionalPartitionHandle, @@ -39,63 +40,48 @@ impl From for TxOpError { // -- db pub struct FjallDb { - path: PathBuf, keyspace: TransactionalKeyspace, - trees: RwLock<(Vec, HashMap)>, + trees: RwLock>, } type ByteRefRangeBound<'r> = (Bound<&'r [u8]>, Bound<&'r [u8]>); impl FjallDb { - pub fn init(path: &PathBuf, keyspace: TransactionalKeyspace) -> Db { + pub fn init(keyspace: TransactionalKeyspace) -> Db { let s = Self { - path: path.clone(), keyspace, - trees: RwLock::new((Vec::new(), HashMap::new())), + trees: RwLock::new(Vec::new()), }; Db(Arc::new(s)) } - fn get_tree(&self, i: usize) -> Result { - self.trees - .read() - .unwrap() - .0 - .get(i) - .cloned() - .ok_or_else(|| Error("invalid tree id".into())) - } - - fn canonicalize(name: &str) -> String { - name.chars() - .map(|c| { - if c.is_alphanumeric() || c == '-' || c == '_' { - c - } else { - '_' - } - }) - .collect::() + fn get_tree( + &self, + i: usize, + ) -> Result> { + RwLockReadGuard::try_map(self.trees.read(), |trees: &Vec<_>| { + trees.get(i).map(|tup| &tup.1) + }) + .map_err(|_| Error("invalid tree id".into())) } } impl IDb for FjallDb { fn engine(&self) -> String { - "LSM trees (using Fjall crate)".into() + "Fjall (EXPERIMENTAL!)".into() } fn open_tree(&self, name: &str) -> Result { - let mut trees = self.trees.write().unwrap(); - let canonical_name = FjallDb::canonicalize(name); - if let Some(i) = trees.1.get(&canonical_name) { - Ok(*i) + let mut trees = self.trees.write(); + let safe_name = encode_name(name)?; + if let Some(i) = trees.iter().position(|(name, _)| *name == safe_name) { + Ok(i) } else { let tree = self .keyspace - .open_partition(&canonical_name, PartitionCreateOptions::default())?; - let i = trees.0.len(); - trees.0.push(tree); - trees.1.insert(canonical_name, i); + .open_partition(&safe_name, PartitionCreateOptions::default())?; + let i = trees.len(); + trees.push((safe_name, tree)); Ok(i) } } @@ -105,8 +91,8 @@ impl IDb for FjallDb { .keyspace .list_partitions() .iter() - .map(|n| n.to_string()) - .collect()) + .map(|n| decode_name(&n)) + .collect::>>()?) } fn snapshot(&self, to: &PathBuf) -> Result<()> { @@ -114,17 +100,17 @@ impl IDb for FjallDb { let mut path = to.clone(); path.push("data.fjall"); - let source_keyspace = fjall::Config::new(&self.path).open()?; + let source_state = self.keyspace.read_tx(); let copy_keyspace = fjall::Config::new(path).open()?; - for partition_name in source_keyspace.list_partitions() { - let source_partition = source_keyspace + for partition_name in self.keyspace.list_partitions() { + let source_partition = self + .keyspace .open_partition(&partition_name, PartitionCreateOptions::default())?; - let snapshot = source_partition.snapshot(); let copy_partition = copy_keyspace.open_partition(&partition_name, PartitionCreateOptions::default())?; - for entry in snapshot.iter() { + for entry in source_state.iter(&source_partition) { let (key, value) = entry?; copy_partition.insert(key, value)?; } @@ -169,14 +155,19 @@ impl IDb for FjallDb { } fn clear(&self, tree_idx: usize) -> Result<()> { - let tree = self.get_tree(tree_idx)?; - let tree_name = tree.inner().name.clone(); + let mut trees = self.trees.write(); + + if tree_idx >= trees.len() { + return Err(Error("invalid tree id".into())); + } + let (name, tree) = trees.remove(tree_idx); + self.keyspace.delete_partition(tree)?; let tree = self .keyspace - .open_partition(&tree_name, PartitionCreateOptions::default())?; - let mut trees = self.trees.write().unwrap(); - trees.0[tree_idx] = tree; + .open_partition(&name, PartitionCreateOptions::default())?; + trees.insert(tree_idx, (name, tree)); + Ok(()) } @@ -223,9 +214,9 @@ impl IDb for FjallDb { // ---- fn transaction(&self, f: &dyn ITxFn) -> TxResult { - let trees = self.trees.read().unwrap(); + let trees = self.trees.read(); let mut tx = FjallTx { - trees: &trees.0[..], + trees: &trees[..], tx: self.keyspace.write_tx(), }; @@ -252,13 +243,13 @@ impl IDb for FjallDb { // ---- struct FjallTx<'a> { - trees: &'a [TransactionalPartitionHandle], + trees: &'a [(String, TransactionalPartitionHandle)], tx: WriteTransaction<'a>, } impl<'a> FjallTx<'a> { fn get_tree(&self, i: usize) -> TxOpResult<&TransactionalPartitionHandle> { - self.trees.get(i).ok_or_else(|| { + self.trees.get(i).map(|tup| &tup.1).ok_or_else(|| { TxOpError(Error( "invalid tree id (it might have been openned after the transaction started)".into(), )) @@ -364,3 +355,78 @@ fn clone_bound(bound: Bound<&[u8]>) -> ByteVecBound { Bound::Unbounded => Bound::Unbounded, } } + +// -- utils to encode table names -- + +fn encode_name(s: &str) -> Result { + let base = 'A' as u32; + + let mut ret = String::with_capacity(s.len() + 10); + for c in s.chars() { + if c.is_alphanumeric() || c == '_' || c == '-' || c == '#' { + ret.push(c); + } else if c <= u8::MAX as char { + ret.push('$'); + let c_hi = c as u32 / 16; + let c_lo = c as u32 % 16; + ret.push(char::from_u32(base + c_hi).unwrap()); + ret.push(char::from_u32(base + c_lo).unwrap()); + } else { + return Err(Error( + format!("table name {} could not be safely encoded", s).into(), + )); + } + } + Ok(ret) +} + +fn decode_name(s: &str) -> Result { + use std::convert::TryFrom; + + let errfn = || Error(format!("encoded table name {} is invalid", s).into()); + let c_map = |c: char| { + let c = c as u32; + let base = 'A' as u32; + if (base..base + 16).contains(&c) { + Some(c - base) + } else { + None + } + }; + + let mut ret = String::with_capacity(s.len()); + let mut it = s.chars(); + while let Some(c) = it.next() { + if c == '$' { + let c_hi = it.next().and_then(c_map).ok_or_else(errfn)?; + let c_lo = it.next().and_then(c_map).ok_or_else(errfn)?; + let c_dec = char::try_from(c_hi * 16 + c_lo).map_err(|_| errfn())?; + ret.push(c_dec); + } else { + ret.push(c); + } + } + Ok(ret) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encdec_name() { + for name in [ + "testname", + "test_name", + "test name", + "test$name", + "test:name@help.me$get/this**right", + ] { + let encname = encode_name(name).unwrap(); + assert!(!encname.contains(' ')); + assert!(!encname.contains('.')); + assert!(!encname.contains('*')); + assert_eq!(*name, decode_name(&encname).unwrap()); + } + } +} diff --git a/src/db/open.rs b/src/db/open.rs index 83ae1f93..fbd8d74a 100644 --- a/src/db/open.rs +++ b/src/db/open.rs @@ -1,6 +1,5 @@ use std::convert::TryInto; use std::path::PathBuf; -use std::sync::Arc; use crate::{Db, Error, Result}; @@ -128,13 +127,10 @@ pub fn open_db(path: &PathBuf, engine: Engine, opt: &OpenOpt) -> Result { let fsync_ms = opt.fsync.then(|| 1000 as u16); let mut config = fjall::Config::new(path).fsync_ms(fsync_ms); if let Some(block_cache_size) = opt.fjall_block_cache_size { - let block_cache = Arc::new(fjall::BlockCache::with_capacity_bytes( - block_cache_size.try_into().unwrap(), - )); - config = config.block_cache(block_cache); + config = config.cache_size(block_cache_size.try_into().unwrap()); } let keyspace = config.open_transactional()?; - Ok(crate::fjall_adapter::FjallDb::init(path, keyspace)) + Ok(crate::fjall_adapter::FjallDb::init(keyspace)) } // Pattern is unreachable when all supported DB engines are compiled into binary. The allow diff --git a/src/db/test.rs b/src/db/test.rs index 26b816b8..08ce1dda 100644 --- a/src/db/test.rs +++ b/src/db/test.rs @@ -1,7 +1,7 @@ use crate::*; fn test_suite(db: Db) { - let tree = db.open_tree("tree").unwrap(); + let tree = db.open_tree("tree:this_is_a_tree").unwrap(); let ka: &[u8] = &b"test"[..]; let kb: &[u8] = &b"zwello"[..]; @@ -148,3 +148,15 @@ fn test_sqlite_db() { let db = SqliteDb::new(manager, false).unwrap(); test_suite(db); } + +#[test] +#[cfg(feature = "fjall")] +fn test_fjall_db() { + use crate::fjall_adapter::{fjall, FjallDb}; + + let path = mktemp::Temp::new_dir().unwrap(); + let config = fjall::Config::new(path); + let keyspace = config.open_transactional().unwrap(); + let db = FjallDb::init(keyspace); + test_suite(db); +} From 6ea86db8cd7687a766679526f10cf1cb42ae00b2 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 27 Aug 2025 19:51:38 +0200 Subject: [PATCH 4/4] document fjall db engine, remove flakey metadata_fsync implementation --- doc/book/reference-manual/configuration.md | 10 ++++++++++ src/db/open.rs | 8 ++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/doc/book/reference-manual/configuration.md b/doc/book/reference-manual/configuration.md index 84aaf511..e134a83f 100644 --- a/doc/book/reference-manual/configuration.md +++ b/doc/book/reference-manual/configuration.md @@ -333,6 +333,7 @@ Since `v0.8.0`, Garage can use alternative storage backends as follows: | --------- | ----------------- | ------------- | | [LMDB](https://www.symas.com/lmdb) (since `v0.8.0`, default since `v0.9.0`) | `"lmdb"` | `/db.lmdb/` | | [Sqlite](https://sqlite.org) (since `v0.8.0`) | `"sqlite"` | `/db.sqlite` | +| [Fjall](https://github.com/fjall-rs/fjall) (**experimental support** since `v1.3.0`) | `"fjall"` | `/db.fjall/` | | [Sled](https://sled.rs) (old default, removed since `v1.0`) | `"sled"` | `/db/` | Sled was supported until Garage v0.9.x, and was removed in Garage v1.0. @@ -369,6 +370,14 @@ LMDB works very well, but is known to have the following limitations: so it is not the best choice for high-performance storage clusters, but it should work fine in many cases. +- Fjall: a storage engine based on LSM trees, which theoretically allow for + higher write throughput than other storage engines that are based on B-trees. + Using Fjall could potentially improve Garage's performance significantly in + write-heavy workloads. **Support for Fjall is experimental at this point**, + we have added it to Garage for evaluation purposes only. **Do not use it for + production-critical workloads.** + + It is possible to convert Garage's metadata directory from one format to another using the `garage convert-db` command, which should be used as follows: @@ -406,6 +415,7 @@ Here is how this option impacts the different database engines: |----------|------------------------------------|-------------------------------| | Sqlite | `PRAGMA synchronous = OFF` | `PRAGMA synchronous = NORMAL` | | LMDB | `MDB_NOMETASYNC` + `MDB_NOSYNC` | `MDB_NOMETASYNC` | +| Fjall | default options | not supported | Note that the Sqlite database is always ran in `WAL` mode (`PRAGMA journal_mode = WAL`). diff --git a/src/db/open.rs b/src/db/open.rs index fbd8d74a..d5469b58 100644 --- a/src/db/open.rs +++ b/src/db/open.rs @@ -124,8 +124,12 @@ pub fn open_db(path: &PathBuf, engine: Engine, opt: &OpenOpt) -> Result { #[cfg(feature = "fjall")] Engine::Fjall => { info!("Opening Fjall database at: {}", path.display()); - let fsync_ms = opt.fsync.then(|| 1000 as u16); - let mut config = fjall::Config::new(path).fsync_ms(fsync_ms); + if opt.fsync { + return Err(Error( + "metadata_fsync is not supported with the Fjall database engine".into(), + )); + } + let mut config = fjall::Config::new(path); if let Some(block_cache_size) = opt.fjall_block_cache_size { config = config.cache_size(block_cache_size.try_into().unwrap()); }