global: mono repo

This commit is contained in:
nym21
2026-08-02 14:26:25 +02:00
parent be3136d579
commit 9927ac782e
732 changed files with 97745 additions and 1689 deletions
Generated
+733 -53
View File
File diff suppressed because it is too large Load Diff
+13 -6
View File
@@ -59,33 +59,40 @@ brk_traversable = { version = "0.3.6", path = "crates/brk_traversable", features
brk_traversable_derive = { version = "0.3.6", path = "crates/brk_traversable_derive" }
brk_types = { version = "0.3.6", path = "crates/brk_types" }
brk_website = { version = "0.3.6", path = "crates/brk_website" }
byteview = "0.10.1"
byteview = "0.10.2"
color-eyre = "0.6.5"
corepc-jsonrpc = { package = "jsonrpc", version = "0.19.0", features = ["simple_http"], default-features = false }
corepc-types = { version = "0.15.0", features = ["std"], default-features = false }
derive_more = { version = "2.1.1", features = ["deref", "deref_mut"] }
fjall = { path = "../fjall", version = "3.1.8" }
fjall = { path = "crates/fjall", version = "0.3.6" }
indexmap = { version = "2.14.0", features = ["serde"] }
jiff = { version = "0.2.34", features = ["perf-inline", "tz-system"], default-features = false }
jiff = { version = "0.2.35", features = ["perf-inline", "tz-system"], default-features = false }
libc = "0.2"
log = "0.4.33"
lsm-tree = { path = "crates/lsm-tree", version = "0.3.6", default-features = false }
lz4_flex = { version = "0.13.1", default-features = false }
owo-colors = "4.3.0"
parking_lot = "0.12.5"
pco = "1.0.2"
quickmatch = { path = "crates/quickmatch", version = "0.3.6" }
rawdb = { path = "crates/rawdb", version = "0.3.6" }
rayon = "1.12.0"
rapidhash = "4.5.1"
rustc-hash = "2.1.3"
schemars = { version = "1.2.1", features = ["indexmap2"] }
schemars = { version = "1.2.2", features = ["indexmap2"] }
serde = "1.0.229"
serde_bytes = "0.11.19"
serde_derive = "1.0.229"
serde_json = { version = "1.0.151", features = ["float_roundtrip", "preserve_order"] }
smallvec = "1.15.2"
tempfile = "3.27.0"
tokio = { version = "1.53.1", features = ["rt-multi-thread"] }
tower-http = { version = "0.7.0", features = ["catch-panic", "compression-br", "compression-gzip", "compression-zstd", "cors", "normalize-path", "timeout", "trace"] }
tower-layer = "0.3"
tracing = { version = "0.1", default-features = false, features = ["std"] }
ureq = { version = "3.3.0", features = ["json"] }
# vecdb = { version = "0.10.4", features = ["derive", "serde_json", "pco", "schemars"] }
vecdb = { path = "../anydb/crates/vecdb", features = ["derive", "serde_json", "pco", "schemars"] }
vecdb = { path = "crates/vecdb", version = "0.3.6", features = ["derive", "serde_json", "pco", "schemars"] }
vecdb_derive = { path = "crates/vecdb_derive", version = "0.3.6" }
[workspace.metadata.release]
+1 -1
View File
@@ -25,7 +25,7 @@ owo-colors = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
tokio = { workspace = true }
toml = "1.1.3"
toml = "1.1.4"
vecdb = { workspace = true }
[[bin]]
+5064 -1202
View File
File diff suppressed because it is too large Load Diff
@@ -11,8 +11,8 @@ use brk_types::{
use rayon::prelude::*;
use rustc_hash::FxHashMap;
use vecdb::{
AnyStoredVec, AnyVec, BytesVec, Database, ImportOptions, ImportableVec, ReadableVec, Reader,
Rw, Stamp, StorageMode, WritableVec,
AnyStoredVec, AnyVec, BytesVec, Database, ImportOptions, ImportableVec, ReadableVec, Rw, Stamp,
StorageMode, WritableVec,
};
use super::super::AddrTypeToTypeIndexMap;
@@ -57,15 +57,6 @@ macro_rules! define_any_addr_indexes_vecs {
Ok(())
}
/// Get address index for a given type and type_index.
/// Uses get_any_or_read_at to check updated layer (needed after rollback).
pub(crate) fn get(&self, addr_type: OutputType, type_index: TypeIndex, reader: &Reader) -> Result<AnyAddrIndex> {
match addr_type {
$(OutputType::$variant => Ok(self.$field.get_any_or_read_at(type_index.into(), reader)?.unwrap()),)*
_ => unreachable!("Invalid addr type: {:?}", addr_type),
}
}
/// Returns a parallel iterator over all vecs for parallel writing.
pub(crate) fn par_iter_mut(&mut self) -> impl ParallelIterator<Item = &mut dyn AnyStoredVec> {
vec![$(&mut self.$field as &mut dyn AnyStoredVec),*].into_par_iter()
+8 -18
View File
@@ -1,5 +1,4 @@
use brk_cohort::ByAddrType;
use brk_error::Result;
use brk_types::{
AnyAddrDataIndexEnum, EmptyAddrData, FundedAddrData, OutputType, TxIndex, TypeIndex,
};
@@ -99,38 +98,29 @@ pub(crate) fn load_uncached_addr_data(
vr: &VecsReaders,
any_addr_indexes: &AnyAddrIndexesVecs,
addrs_data: &AddrsDataVecs,
) -> Result<Option<WithAddrDataSource<FundedAddrData>>> {
) -> Option<WithAddrDataSource<FundedAddrData>> {
// Check if this is a new address (type_index >= first for this height)
let first = *first_addr_indexes.get(addr_type).unwrap();
if first <= type_index {
return Ok(Some(WithAddrDataSource::New(FundedAddrData::default())));
return Some(WithAddrDataSource::New(FundedAddrData::default()));
}
// Skip if already in cache
if cache.contains(addr_type, type_index) {
return Ok(None);
return None;
}
// Read from storage
let reader = vr.addr_reader(addr_type);
let any_addr_index = any_addr_indexes.get(addr_type, type_index, reader)?;
let any_addr_index = vr.any_addr_index(any_addr_indexes, addr_type, type_index);
Ok(Some(match any_addr_index.to_enum() {
Some(match any_addr_index.to_enum() {
AnyAddrDataIndexEnum::Funded(funded_index) => {
let reader = &vr.any_addr_index_to_any_addr_data.funded;
let funded_data = addrs_data
.funded
.get_any_or_read_at(funded_index.into(), reader)?
.unwrap();
let funded_data = vr.funded_data(addrs_data, funded_index);
WithAddrDataSource::FromFunded(funded_index, funded_data)
}
AnyAddrDataIndexEnum::Empty(empty_index) => {
let reader = &vr.any_addr_index_to_any_addr_data.empty;
let empty_data = addrs_data
.empty
.get_any_or_read_at(empty_index.into(), reader)?
.unwrap();
let empty_data = vr.empty_data(addrs_data, empty_index);
WithAddrDataSource::FromEmpty(empty_index, empty_data.into())
}
}))
})
}
@@ -1,5 +1,4 @@
use brk_cohort::ByAddrType;
use brk_error::Result;
use brk_types::{FundedAddrData, Height, OutputType, Sats, TxIndex, TypeIndex};
use rayon::prelude::*;
use rustc_hash::FxHashMap;
@@ -56,8 +55,8 @@ pub(crate) fn process_inputs(
vr: &VecsReaders,
any_addr_indexes: &AnyAddrIndexesVecs,
addrs_data: &AddrsDataVecs,
) -> Result<InputsResult> {
let map_fn = |local_idx: usize| -> Result<_> {
) -> InputsResult {
let map_fn = |local_idx: usize| {
let tx_index = txin_index_to_tx_index[local_idx];
let prev_height = txin_index_to_prev_height[local_idx];
@@ -65,7 +64,7 @@ pub(crate) fn process_inputs(
let input_type = txin_index_to_output_type[local_idx];
if input_type.is_not_addr() {
return Ok((prev_height, value, input_type, None));
return (prev_height, value, input_type, None);
}
let type_index = txin_index_to_type_index[local_idx];
@@ -79,23 +78,20 @@ pub(crate) fn process_inputs(
vr,
any_addr_indexes,
addrs_data,
)?;
);
Ok((
(
prev_height,
value,
input_type,
Some((type_index, tx_index, value, addr_data_opt)),
))
)
};
let items: Vec<_> = if input_count < 128 {
(0..input_count).map(map_fn).collect::<Result<Vec<_>>>()?
(0..input_count).map(map_fn).collect()
} else {
(0..input_count)
.into_par_iter()
.map(map_fn)
.collect::<Result<Vec<_>>>()?
(0..input_count).into_par_iter().map(map_fn).collect()
};
// Phase 2: Sequential accumulation - no merge overhead
@@ -140,10 +136,10 @@ pub(crate) fn process_inputs(
}
}
Ok(InputsResult {
InputsResult {
height_to_sent,
sent_data,
addr_data,
tx_index_vecs,
})
}
}
@@ -1,5 +1,4 @@
use brk_cohort::ByAddrType;
use brk_error::Result;
use brk_types::{FundedAddrData, Sats, TxIndex, TypeIndex};
use rayon::prelude::*;
use smallvec::SmallVec;
@@ -43,17 +42,17 @@ pub(crate) fn process_outputs(
vr: &VecsReaders,
any_addr_indexes: &AnyAddrIndexesVecs,
addrs_data: &AddrsDataVecs,
) -> Result<OutputsResult> {
) -> OutputsResult {
let output_count = txout_data_vec.len();
// Phase 1: Addr lookups (mmap reads) — parallel for large blocks, sequential for small
let map_fn = |local_idx: usize| -> Result<_> {
let map_fn = |local_idx: usize| {
let txout_data = &txout_data_vec[local_idx];
let value = txout_data.value;
let output_type = txout_data.output_type;
if output_type.is_not_addr() {
return Ok((value, output_type, None));
return (value, output_type, None);
}
let type_index = txout_data.type_index;
@@ -67,22 +66,19 @@ pub(crate) fn process_outputs(
vr,
any_addr_indexes,
addrs_data,
)?;
);
Ok((
(
value,
output_type,
Some((type_index, tx_index, value, addr_data_opt)),
))
)
};
let items: Vec<_> = if output_count < 128 {
(0..output_count).map(map_fn).collect::<Result<Vec<_>>>()?
(0..output_count).map(map_fn).collect()
} else {
(0..output_count)
.into_par_iter()
.map(map_fn)
.collect::<Result<Vec<_>>>()?
(0..output_count).into_par_iter().map(map_fn).collect()
};
// Phase 2: Sequential accumulation
@@ -117,10 +113,10 @@ pub(crate) fn process_outputs(
}
}
Ok(OutputsResult {
OutputsResult {
transacted,
received_data,
addr_data,
tx_index_vecs,
})
}
}
@@ -266,13 +266,13 @@ pub(crate) fn process_blocks(
// Collection (build tx_index mappings + bulk mmap reads) is merged into the
// processing closures so outputs and inputs collection overlap each other
// and tick-tock, instead of running sequentially before the join.
let (matured, oi_result) = rayon::join(
let (matured, (outputs_result, inputs_result)) = rayon::join(
|| {
vecs.utxo_cohorts
.tick_tock_next_block(chain_state, timestamp)
},
|| -> Result<_> {
let (outputs_result, inputs_result) = rayon::join(
|| {
rayon::join(
|| {
let txout_index_to_tx_index = txout_to_tx_index_buf.build(
first_tx_index,
@@ -291,7 +291,7 @@ pub(crate) fn process_blocks(
&vecs.addrs_data,
)
},
|| -> Result<_> {
|| {
if input_count > 1 {
let txin_index_to_tx_index = txin_to_tx_index_buf.build(
first_tx_index,
@@ -322,19 +322,17 @@ pub(crate) fn process_blocks(
&vecs.addrs_data,
)
} else {
Ok(InputsResult {
InputsResult {
height_to_sent: Default::default(),
sent_data: Default::default(),
addr_data: Default::default(),
tx_index_vecs: Default::default(),
})
}
}
},
);
Ok((outputs_result?, inputs_result?))
)
},
);
let (outputs_result, inputs_result) = oi_result?;
// Merge new address data into current cache
cache.merge_funded(outputs_result.addr_data);
@@ -1,7 +1,10 @@
use brk_cohort::{ByAddrType, ByAnyAddr};
use brk_indexer::Indexer;
use brk_types::{Height, OutPoint, OutputType, Sats, StoredU64, TxIndex, TypeIndex};
use vecdb::{ReadableVec, Reader, VecIndex};
use brk_types::{
AnyAddrIndex, EmptyAddrData, EmptyAddrIndex, FundedAddrData, FundedAddrIndex, Height, OutPoint,
OutputType, P2AAddrIndex, P2PK33AddrIndex, P2PK65AddrIndex, P2PKHAddrIndex, P2SHAddrIndex,
P2TRAddrIndex, P2WPKHAddrIndex, P2WSHAddrIndex, Sats, StoredU64, TxIndex, TypeIndex,
};
use vecdb::{BytesVecReader, ReadableVec, VecIndex};
use crate::{
distribution::{
@@ -159,35 +162,67 @@ impl<'a> TxInReaders<'a> {
/// Cached readers for stateful vectors.
pub struct VecsReaders {
pub addr_type_index_to_any_addr_index: ByAddrType<Reader>,
pub any_addr_index_to_any_addr_data: ByAnyAddr<Reader>,
p2a: BytesVecReader<P2AAddrIndex, AnyAddrIndex>,
p2pk33: BytesVecReader<P2PK33AddrIndex, AnyAddrIndex>,
p2pk65: BytesVecReader<P2PK65AddrIndex, AnyAddrIndex>,
p2pkh: BytesVecReader<P2PKHAddrIndex, AnyAddrIndex>,
p2sh: BytesVecReader<P2SHAddrIndex, AnyAddrIndex>,
p2tr: BytesVecReader<P2TRAddrIndex, AnyAddrIndex>,
p2wpkh: BytesVecReader<P2WPKHAddrIndex, AnyAddrIndex>,
p2wsh: BytesVecReader<P2WSHAddrIndex, AnyAddrIndex>,
funded: BytesVecReader<FundedAddrIndex, FundedAddrData>,
empty: BytesVecReader<EmptyAddrIndex, EmptyAddrData>,
}
impl VecsReaders {
pub(crate) fn new(any_addr_indexes: &AnyAddrIndexesVecs, addrs_data: &AddrsDataVecs) -> Self {
Self {
addr_type_index_to_any_addr_index: ByAddrType {
p2a: any_addr_indexes.p2a.create_reader(),
p2pk33: any_addr_indexes.p2pk33.create_reader(),
p2pk65: any_addr_indexes.p2pk65.create_reader(),
p2pkh: any_addr_indexes.p2pkh.create_reader(),
p2sh: any_addr_indexes.p2sh.create_reader(),
p2tr: any_addr_indexes.p2tr.create_reader(),
p2wpkh: any_addr_indexes.p2wpkh.create_reader(),
p2wsh: any_addr_indexes.p2wsh.create_reader(),
},
any_addr_index_to_any_addr_data: ByAnyAddr {
funded: addrs_data.funded.create_reader(),
empty: addrs_data.empty.create_reader(),
},
p2a: any_addr_indexes.p2a.reader(),
p2pk33: any_addr_indexes.p2pk33.reader(),
p2pk65: any_addr_indexes.p2pk65.reader(),
p2pkh: any_addr_indexes.p2pkh.reader(),
p2sh: any_addr_indexes.p2sh.reader(),
p2tr: any_addr_indexes.p2tr.reader(),
p2wpkh: any_addr_indexes.p2wpkh.reader(),
p2wsh: any_addr_indexes.p2wsh.reader(),
funded: addrs_data.funded.reader(),
empty: addrs_data.empty.reader(),
}
}
/// Get reader for specific address type.
pub(crate) fn addr_reader(&self, addr_type: OutputType) -> &Reader {
self.addr_type_index_to_any_addr_index
.get(addr_type)
.unwrap()
/// Read the unified address index, including uncommitted updates after rollback.
pub(crate) fn any_addr_index(
&self,
vecs: &AnyAddrIndexesVecs,
addr_type: OutputType,
type_index: TypeIndex,
) -> AnyAddrIndex {
let index = match addr_type {
OutputType::P2A => vecs.p2a.get_with_reader(type_index.into(), &self.p2a),
OutputType::P2PK33 => vecs.p2pk33.get_with_reader(type_index.into(), &self.p2pk33),
OutputType::P2PK65 => vecs.p2pk65.get_with_reader(type_index.into(), &self.p2pk65),
OutputType::P2PKH => vecs.p2pkh.get_with_reader(type_index.into(), &self.p2pkh),
OutputType::P2SH => vecs.p2sh.get_with_reader(type_index.into(), &self.p2sh),
OutputType::P2TR => vecs.p2tr.get_with_reader(type_index.into(), &self.p2tr),
OutputType::P2WPKH => vecs.p2wpkh.get_with_reader(type_index.into(), &self.p2wpkh),
OutputType::P2WSH => vecs.p2wsh.get_with_reader(type_index.into(), &self.p2wsh),
_ => unreachable!("invalid address type: {addr_type:?}"),
};
index.unwrap()
}
#[inline]
pub(crate) fn funded_data(
&self,
vecs: &AddrsDataVecs,
index: FundedAddrIndex,
) -> FundedAddrData {
vecs.funded.get_with_reader(index, &self.funded).unwrap()
}
#[inline]
pub(crate) fn empty_data(&self, vecs: &AddrsDataVecs, index: EmptyAddrIndex) -> EmptyAddrData {
vecs.empty.get_with_reader(index, &self.empty).unwrap()
}
}
+38 -7
View File
@@ -350,6 +350,44 @@ impl Vecs {
) -> Result<()> {
self.db.sync_bg_tasks()?;
let base_version = VERSION
+ [
prices.spot.cents.height.version(),
indexes.timestamp.monotonic.version(),
indexer.vecs.transactions.first_tx_index.version(),
indexer.vecs.outputs.first_txout_index.version(),
indexer.vecs.inputs.first_txin_index.version(),
transactions.count.total.block.version(),
outputs.count.total.sum.version(),
inputs.count.sum.version(),
indexes.tx_index.output_count.version(),
indexes.tx_index.input_count.version(),
indexer.vecs.outputs.value.version(),
indexer.vecs.outputs.output_type.version(),
indexer.vecs.outputs.type_index.version(),
inputs.spent.value.version(),
indexer.vecs.inputs.outpoint.version(),
indexer.vecs.inputs.output_type.version(),
indexer.vecs.inputs.type_index.version(),
indexer.vecs.addrs.p2pk65.first_index.version(),
indexer.vecs.addrs.p2pk33.first_index.version(),
indexer.vecs.addrs.p2pkh.first_index.version(),
indexer.vecs.addrs.p2sh.first_index.version(),
indexer.vecs.addrs.p2wpkh.first_index.version(),
indexer.vecs.addrs.p2wsh.first_index.version(),
indexer.vecs.addrs.p2tr.first_index.version(),
indexer.vecs.addrs.p2a.first_index.version(),
]
.into_iter()
.sum::<Version>();
debug!("validating computed versions");
self.supply_state
.validate_computed_version_or_reset(base_version)?;
self.utxo_cohorts.validate_computed_versions(base_version)?;
self.addr_cohorts.validate_computed_versions(base_version)?;
debug!("computed versions validated");
let starting_lengths = indexer.safe_lengths();
// 1. Find minimum height we have data for across stateful vecs
@@ -503,13 +541,6 @@ impl Vecs {
recovered_height
};
// 2c. Validate computed versions
debug!("validating computed versions");
let base_version = VERSION;
self.utxo_cohorts.validate_computed_versions(base_version)?;
self.addr_cohorts.validate_computed_versions(base_version)?;
debug!("computed versions validated");
// 3. Get last height from indexer
let last_height = Height::from(indexer.vecs.blocks.blockhash.len().saturating_sub(1));
debug!(
@@ -70,8 +70,7 @@ impl Vecs {
if entry.tx_index.is_coinbase() {
break;
}
entry.txout_index =
first_txout_index_reader.get(entry.tx_index.to_usize()) + entry.vout;
entry.txout_index = first_txout_index_reader.get(entry.tx_index) + entry.vout;
}
// Sort 2: by txout_index (sequential value reads)
@@ -80,7 +79,7 @@ impl Vecs {
if entry.txout_index.is_coinbase() {
break;
}
entry.value = value_reader.get(entry.txout_index.to_usize());
entry.value = value_reader.get(entry.txout_index);
}
// Scatter-write to output buffers using original_idx (avoids Sort 3)
+3 -2
View File
@@ -5,7 +5,7 @@ use brk_types::{
Bitcoin, Cents, Date, Day1, Dollars, Height, PartsPerMillionSigned64, Sats, Version,
};
use vecdb::{
BinaryTransform, CachedBoxedVec, CheckedSub, EagerVec, ImportableVec, PcoVec,
AnyVec, BinaryTransform, CachedBoxedVec, CheckedSub, EagerVec, ImportableVec, PcoVec,
ReadableCloneableVec, ReadableVec, TypedVec, VecIndex,
};
@@ -33,8 +33,9 @@ impl Vecs {
let db = open_db(parent_path, super::DB_NAME, 50_000)?;
let version = parent_version;
let sats_cumulative_version = version + prices.split.close.usd.day1.version();
let sats_cumulative: EagerVec<PcoVec<Height, Sats>> =
ImportableVec::forced_import(&db, "dca_sats_cumulative", version)?;
ImportableVec::forced_import(&db, "dca_sats_cumulative", sats_cumulative_version)?;
let sats_per_day = LazyPreviousDeltaVec::new(
"dca_sats_per_day",
version,
+1
View File
@@ -121,6 +121,7 @@ impl Computer {
let mining = Box::new(mining::Vecs::forced_import(
&computed_path,
VERSION,
indexer,
&indexes,
&cached_starts,
)?);
+3 -1
View File
@@ -1,6 +1,7 @@
use std::path::Path;
use brk_error::Result;
use brk_indexer::Indexer;
use brk_types::Version;
use crate::{
@@ -17,13 +18,14 @@ impl Vecs {
pub(crate) fn forced_import(
parent_path: &Path,
parent_version: Version,
indexer: &Indexer,
indexes: &indexes::Vecs,
cached_starts: &Windows<&WindowStartVec>,
) -> Result<Self> {
let db = open_db(parent_path, super::DB_NAME, 1_000_000)?;
let version = parent_version;
let rewards = RewardsVecs::forced_import(&db, version, indexes, cached_starts)?;
let rewards = RewardsVecs::forced_import(&db, version, indexer, indexes, cached_starts)?;
let hashrate = HashrateVecs::forced_import(&db, version, indexes)?;
let this = Self {
@@ -28,7 +28,12 @@ impl Vecs {
prices,
&indexer.vecs.transactions.first_tx_index,
|_, tx_index| {
let mut txout_cursor = indexer.vecs.transactions.first_txout_index.cursor();
let mut txout_cursor = indexer
.vecs
.transactions
.first_txout_index
.reader()
.cursor();
let mut count_cursor = indexes.tx_index.output_count.cursor();
let ti = tx_index.to_usize();
@@ -1,6 +1,7 @@
use brk_error::Result;
use brk_indexer::Indexer;
use brk_types::Version;
use vecdb::{Database, EagerVec, ImportableVec};
use vecdb::{AnyVec, Database, EagerVec, ImportableVec};
use super::Vecs;
use crate::{
@@ -16,9 +17,15 @@ impl Vecs {
pub(crate) fn forced_import(
db: &Database,
version: Version,
indexer: &Indexer,
indexes: &indexes::Vecs,
cached_starts: &Windows<&WindowStartVec>,
) -> Result<Self> {
let coinbase_version = version
+ indexer.vecs.transactions.first_txout_index.version()
+ indexes.tx_index.output_count.version()
+ indexer.vecs.outputs.value.version();
let fee_dominance =
PercentCumulativeRolling::forced_import(db, "fee_dominance", version, indexes)?;
@@ -32,7 +39,7 @@ impl Vecs {
coinbase: ValuePerBlockCumulativeRolling::forced_import(
db,
"coinbase",
version,
coinbase_version,
indexes,
cached_starts,
)?,
@@ -41,8 +41,13 @@ impl Vecs {
let txid_len = indexer.vecs.transactions.txid.len();
let total_txout_len = indexer.vecs.outputs.output_type.len();
let mut otype_cursor = indexer.vecs.outputs.output_type.cursor();
let mut fo_cursor = indexer.vecs.transactions.first_txout_index.cursor();
let mut otype_cursor = indexer.vecs.outputs.output_type.reader().cursor();
let fo_cursor = indexer
.vecs
.transactions
.first_txout_index
.reader()
.cursor();
let mut height = skip;
walk_blocks(
@@ -18,6 +18,13 @@ impl Vecs {
) -> Result<ExitGuard> {
let starting_lengths = indexer.safe_lengths();
let dep_version = inputs.spent.txout_index.version()
+ indexer.vecs.outputs.first_txout_index.version()
+ indexer.vecs.inputs.first_txin_index.version()
+ indexer.vecs.outputs.value.version();
self.txin_index
.validate_computed_version_or_reset(dep_version)?;
let target_height = indexer.vecs.blocks.blockhash.len();
if target_height == 0 {
return Ok(exit.lock());
+37 -14
View File
@@ -3,7 +3,10 @@ use std::{collections::BTreeMap, path::Path};
use brk_error::Result;
use brk_indexer::Indexer;
use brk_traversable::Traversable;
use brk_types::{Addr, AddrBytes, Height, OutputType, PoolSlug, Pools, TxOutIndex, pools};
use brk_types::{
Addr, AddrBytes, Height, OutputType, POOL_ATTRIBUTION_VERSION, PoolSlug, Pools, TxOutIndex,
pools,
};
use rayon::prelude::*;
use vecdb::{
AnyStoredVec, AnyVec, BytesVec, Database, Exit, ImportableVec, ReadableVec, Rw, StorageMode,
@@ -49,7 +52,10 @@ impl Vecs {
let db = open_db(parent_path, DB_NAME, 100_000)?;
let pools = pools();
let version = parent_version + Version::new(4) + Version::new(pools.len() as u32);
let version = parent_version
+ Version::new(4)
+ POOL_ATTRIBUTION_VERSION
+ Version::new(pools.len() as u32);
let mut major_map = BTreeMap::new();
let mut minor_map = BTreeMap::new();
@@ -121,7 +127,24 @@ impl Vecs {
) -> Result<()> {
let starting_height = indexer.safe_lengths().height;
let dep_version = indexer.vecs.blocks.coinbase_tag.version();
let dep_version: Version = [
indexer.vecs.blocks.coinbase_tag.version(),
indexer.vecs.transactions.first_tx_index.version(),
indexer.vecs.transactions.first_txout_index.version(),
indexes.tx_index.output_count.version(),
indexer.vecs.outputs.output_type.version(),
indexer.vecs.outputs.type_index.version(),
indexer.vecs.addrs.p2pk65.bytes.version(),
indexer.vecs.addrs.p2pk33.bytes.version(),
indexer.vecs.addrs.p2pkh.bytes.version(),
indexer.vecs.addrs.p2sh.bytes.version(),
indexer.vecs.addrs.p2wpkh.bytes.version(),
indexer.vecs.addrs.p2wsh.bytes.version(),
indexer.vecs.addrs.p2tr.bytes.version(),
indexer.vecs.addrs.p2a.bytes.version(),
]
.into_iter()
.sum();
let pool_vec_version = self.pool.header().vec_version();
let pool_computed = self.pool.header().computed_version();
let expected = pool_vec_version + dep_version;
@@ -167,7 +190,7 @@ impl Vecs {
len,
|coinbase_tag| -> Result<()> {
let tx_index = first_tx_index_cursor.next().unwrap();
let out_start = first_txout_index.get(tx_index.to_usize());
let out_start = first_txout_index.get(tx_index);
let ti = tx_index.to_usize();
output_count_cursor.advance(ti - output_count_cursor.position());
@@ -176,17 +199,17 @@ impl Vecs {
let pool = (*out_start..(*out_start + *output_count_val))
.map(TxOutIndex::from)
.find_map(|txout_index| {
let ot = output_type.get(txout_index.to_usize());
let ti = usize::from(type_index.get(txout_index.to_usize()));
let ot = output_type.get(txout_index);
let ti = usize::from(type_index.get(txout_index));
match ot {
OutputType::P2PK65 => Some(AddrBytes::from(p2pk65.get(ti))),
OutputType::P2PK33 => Some(AddrBytes::from(p2pk33.get(ti))),
OutputType::P2PKH => Some(AddrBytes::from(p2pkh.get(ti))),
OutputType::P2SH => Some(AddrBytes::from(p2sh.get(ti))),
OutputType::P2WPKH => Some(AddrBytes::from(p2wpkh.get(ti))),
OutputType::P2WSH => Some(AddrBytes::from(p2wsh.get(ti))),
OutputType::P2TR => Some(AddrBytes::from(p2tr.get(ti))),
OutputType::P2A => Some(AddrBytes::from(p2a.get(ti))),
OutputType::P2PK65 => Some(AddrBytes::from(p2pk65.get_at(ti))),
OutputType::P2PK33 => Some(AddrBytes::from(p2pk33.get_at(ti))),
OutputType::P2PKH => Some(AddrBytes::from(p2pkh.get_at(ti))),
OutputType::P2SH => Some(AddrBytes::from(p2sh.get_at(ti))),
OutputType::P2WPKH => Some(AddrBytes::from(p2wpkh.get_at(ti))),
OutputType::P2WSH => Some(AddrBytes::from(p2wsh.get_at(ti))),
OutputType::P2TR => Some(AddrBytes::from(p2tr.get_at(ti))),
OutputType::P2A => Some(AddrBytes::from(p2a.get_at(ti))),
_ => None,
}
.map(|bytes| Addr::try_from(&bytes).unwrap())
@@ -14,7 +14,9 @@ impl PoolHeights {
let mut map: FxHashMap<PoolSlug, Vec<Height>> = FxHashMap::default();
let reader = pool.reader();
for h in 0..len {
map.entry(reader.get(h)).or_default().push(Height::from(h));
map.entry(reader.get_at(h))
.or_default()
.push(Height::from(h));
}
Self(Arc::new(RwLock::new(map)))
}
+10 -2
View File
@@ -63,8 +63,16 @@ impl Vecs {
fn compute_prices(&mut self, indexer: &Indexer, exit: &Exit) -> Result<()> {
let starting_height = indexer.safe_lengths().height;
let source_version =
indexer.vecs.outputs.value.version() + indexer.vecs.outputs.output_type.version();
let source_version = [
indexer.vecs.transactions.txid.version(),
indexer.vecs.transactions.first_tx_index.version(),
indexer.vecs.outputs.first_txout_index.version(),
indexer.vecs.transactions.first_txout_index.version(),
indexer.vecs.outputs.value.version(),
indexer.vecs.outputs.output_type.version(),
]
.into_iter()
.sum();
self.spot
.cents
.height
@@ -101,9 +101,9 @@ impl Vecs {
let mut input_value = spent.value.cursor();
let mut input_type = indexer.vecs.inputs.output_type.cursor();
let mut input_type_index = indexer.vecs.inputs.type_index.cursor();
let mut output_value = indexer.vecs.outputs.value.cursor();
let mut output_type = indexer.vecs.outputs.output_type.cursor();
let mut output_type_index = indexer.vecs.outputs.type_index.cursor();
let mut output_value = indexer.vecs.outputs.value.reader().cursor();
let mut output_type = indexer.vecs.outputs.output_type.reader().cursor();
let mut output_type_index = indexer.vecs.outputs.type_index.reader().cursor();
let mut has_op_return = features.has_op_return.cursor();
let mut has_inscription = features.has_inscription.cursor();
let mut tx_count = indexes.height.tx_index_count.cursor();
+4 -3
View File
@@ -12,6 +12,7 @@ use brk_reader::{Reader, XORBytes};
use brk_rpc::Client;
use brk_types::{BlockHash, Height};
use fjall::PersistMode;
use rayon::prelude::*;
use tracing::{debug, error, info};
use vecdb::{
Exit, RawDBError, ReadOnlyClone, ReadableVec, Ro, Rw, StorageMode, WritableVec, unlikely,
@@ -332,9 +333,9 @@ impl Indexer {
if !tasks.is_empty() {
let i = Instant::now();
for task in tasks {
task().map_err(vecdb::RawDBError::other)?;
}
tasks
.into_par_iter()
.try_for_each(|task| task().map_err(vecdb::RawDBError::other))?;
debug!("Stores committed in {:?}", i.elapsed());
let i = Instant::now();
@@ -78,7 +78,7 @@ impl<'a> BlockProcessor<'a> {
.vecs
.transactions
.txid
.get_pushed_or_read(prev_tx_index, &self.readers.txid)
.get_append_only(prev_tx_index, &self.readers.txid)
.ok_or(Error::Internal("Missing txid for tx_index"))
.inspect_err(|_| {
error!(?tx_index, len, "Missing txid for tx_index");
@@ -104,10 +104,10 @@ impl<'a> BlockProcessor<'a> {
&mut self,
txs: Vec<ComputedTx>,
mut txouts: Vec<ProcessedOutput>,
txins: Vec<InputSource>,
txins: &[InputSource],
addresses: &mut BlockAddresses,
) -> Result<()> {
let transaction_analyses = self.analyze_transactions(&txs, &txins, &txouts);
let transaction_analyses = self.analyze_transactions(&txs, txins, &txouts);
let lengths = &mut *self.lengths;
let base_tx_index = lengths.tx_index;
let base_txin_index = lengths.txin_index;
+4 -4
View File
@@ -18,11 +18,11 @@ use super::{BlockProcessor, transaction::ComputedTx, txout::ProcessedOutput};
use crate::InputsVecs;
impl<'a> BlockProcessor<'a> {
pub(crate) fn process_inputs(
pub(crate) fn process_inputs<'b>(
&self,
txs: &[ComputedTx],
resolver: &mut InputResolver,
) -> Result<Vec<InputSource>> {
resolver: &'b mut InputResolver,
) -> Result<&'b [InputSource]> {
resolver.resolve(self, txs)
}
}
@@ -36,7 +36,7 @@ pub(super) fn finalize_inputs(
inputs: &mut InputsVecs,
addr_tx_index_stores: &mut ByAddrType<Store<AddrIndexTxIndex, Unit>>,
addr_outpoint_stores: &mut ByAddrType<Store<AddrIndexOutPoint, Unit>>,
txins: Vec<InputSource>,
txins: &[InputSource],
txouts: &[ProcessedOutput],
) -> Result<()> {
let mut input_offset = 0;
+165 -132
View File
@@ -12,13 +12,15 @@ use vecdb::unlikely;
use super::InputSource;
use crate::processor::{BlockProcessor, transaction::ComputedTx};
const PARALLEL_PARENT_READ_THRESHOLD: usize = 1_000;
#[derive(Default)]
pub(crate) struct InputResolver {
same_block_transactions: FxHashMap<TxidPrefix, TxIndex>,
previous_parent_indexes: FxHashMap<TxidPrefix, usize>,
previous_parents: Vec<PreviousParent>,
parent_locations: FxHashMap<TxidPrefix, ParentLocation>,
previous_parent_prefixes: Vec<TxidPrefix>,
inputs: Vec<UnresolvedInput>,
reads: ReadBatch,
resolved: Vec<InputSource>,
}
impl InputResolver {
@@ -26,65 +28,78 @@ impl InputResolver {
&mut self,
processor: &BlockProcessor<'_>,
txs: &[ComputedTx<'_>],
) -> Result<Vec<InputSource>> {
) -> Result<&[InputSource]> {
self.prepare(txs, processor.lengths.tx_index);
self.reads.resolve(
processor,
&self.previous_parents,
&self.previous_parent_prefixes,
&self.inputs,
processor.lengths.tx_index,
)?;
let tracks_executed_legacy_sigops = processor.tracks_executed_legacy_sigops();
let reads = &self.reads;
let inputs = &self.inputs;
self.inputs
.par_iter()
.enumerate()
.map(|(input_index, input)| match *input {
UnresolvedInput::Coinbase => Ok(InputSource::Coinbase),
UnresolvedInput::SameBlock {
outpoint,
txout_offset,
} => Ok(InputSource::SameBlock {
outpoint,
txout_offset,
}),
UnresolvedInput::PreviousBlock { parent_index, vout } => {
let parent = reads.parent(parent_index);
let outpoint = OutPoint::new(parent.tx_index, vout);
let (output_type, type_index) = reads.output(input_index);
let legacy_sigops = if tracks_executed_legacy_sigops {
processor
.vecs
.scripts
.legacy_sigops(output_type, type_index, &processor.readers.scripts)
.ok_or(Error::Internal("Missing legacy_sigops"))?
} else {
SigOps::ZERO
};
Ok(InputSource::PreviousBlock {
self.resolved.clear();
self.resolved.resize(inputs.len(), InputSource::Coinbase);
self.resolved.par_iter_mut().enumerate().try_for_each(
|(input_index, resolved)| -> Result<()> {
match inputs[input_index] {
UnresolvedInput::Coinbase => {
*resolved = InputSource::Coinbase;
Ok(())
}
UnresolvedInput::SameBlock {
outpoint,
output_type,
legacy_sigops,
type_index,
})
txout_offset,
} => {
*resolved = InputSource::SameBlock {
outpoint,
txout_offset,
};
Ok(())
}
UnresolvedInput::PreviousBlock { parent_index, vout } => {
let parent = reads.parent(parent_index);
let outpoint = OutPoint::new(parent.tx_index, vout);
let (output_type, type_index) = reads.output(input_index);
let legacy_sigops = if tracks_executed_legacy_sigops {
processor
.vecs
.scripts
.legacy_sigops(output_type, type_index, &processor.readers.scripts)
.ok_or(Error::Internal("Missing legacy_sigops"))?
} else {
SigOps::ZERO
};
*resolved = InputSource::PreviousBlock {
outpoint,
output_type,
legacy_sigops,
type_index,
};
Ok(())
}
}
})
.collect()
},
)?;
Ok(&self.resolved)
}
fn prepare(&mut self, txs: &[ComputedTx<'_>], block_first_tx_index: TxIndex) {
self.same_block_transactions.clear();
self.previous_parent_indexes.clear();
self.previous_parents.clear();
self.parent_locations.clear();
self.previous_parent_prefixes.clear();
self.inputs.clear();
self.same_block_transactions.reserve(txs.len());
self.same_block_transactions
.extend(txs.iter().map(|tx| (tx.txid_prefix(), tx.tx_index)));
self.parent_locations.reserve(txs.len());
self.parent_locations.extend(
txs.iter()
.map(|tx| (tx.txid_prefix(), ParentLocation::SameBlock(tx.tx_index))),
);
let total_inputs = txs.iter().map(|tx| tx.tx.input.len()).sum();
self.inputs.reserve(total_inputs);
@@ -101,22 +116,25 @@ impl InputResolver {
let txid_prefix = TxidPrefix::from(&txid);
let vout = Vout::from(previous_output.vout);
if let Some(&tx_index) = self.same_block_transactions.get(&txid_prefix) {
let block_tx_index = usize::from(tx_index) - usize::from(block_first_tx_index);
self.inputs.push(UnresolvedInput::SameBlock {
outpoint: OutPoint::new(tx_index, vout),
txout_offset: txs[block_tx_index].txout_offset(vout),
});
continue;
}
let parent_index = match self.previous_parent_indexes.entry(txid_prefix) {
Entry::Occupied(entry) => *entry.get(),
let parent_index = match self.parent_locations.entry(txid_prefix) {
Entry::Occupied(entry) => match *entry.get() {
ParentLocation::SameBlock(tx_index) => {
let block_tx_index =
usize::from(tx_index) - usize::from(block_first_tx_index);
self.inputs.push(UnresolvedInput::SameBlock {
outpoint: OutPoint::new(tx_index, vout),
txout_offset: txs[block_tx_index].txout_offset(vout),
});
continue;
}
ParentLocation::Previous(parent_index) => parent_index.to_usize(),
},
Entry::Vacant(entry) => {
let parent_index = self.previous_parents.len();
entry.insert(parent_index);
self.previous_parents
.push(PreviousParent { txid, txid_prefix });
let parent_index = self.previous_parent_prefixes.len();
entry.insert(ParentLocation::Previous(PreviousParentIndex::new(
parent_index,
)));
self.previous_parent_prefixes.push(txid_prefix);
parent_index
}
};
@@ -129,14 +147,32 @@ impl InputResolver {
}
#[derive(Clone, Copy)]
struct PreviousParent {
txid: Txid,
txid_prefix: TxidPrefix,
enum ParentLocation {
SameBlock(TxIndex),
Previous(PreviousParentIndex),
}
#[derive(Clone, Copy)]
struct PreviousParentIndex(u32);
impl PreviousParentIndex {
fn new(index: usize) -> Self {
Self(
u32::try_from(index)
.expect("number of unique previous parents in a block must fit in u32"),
)
}
#[inline]
fn to_usize(self) -> usize {
self.0 as usize
}
}
const _: () = assert!(size_of::<ParentLocation>() == 8);
#[derive(Clone, Copy)]
struct ParentRead {
original_index: usize,
tx_index: TxIndex,
first_txout_index: TxOutIndex,
}
@@ -150,9 +186,7 @@ struct OutputRead {
#[derive(Default)]
struct ReadBatch {
parents: Vec<ParentRead>,
parent_positions: Vec<usize>,
outputs: Vec<OutputRead>,
output_positions: Vec<usize>,
output_types: Vec<OutputType>,
type_indices: Vec<TypeIndex>,
}
@@ -161,11 +195,11 @@ impl ReadBatch {
fn resolve(
&mut self,
processor: &BlockProcessor<'_>,
previous_parents: &[PreviousParent],
previous_parent_prefixes: &[TxidPrefix],
inputs: &[UnresolvedInput],
current_tx_index: TxIndex,
) -> Result<()> {
self.resolve_parents(processor, previous_parents, current_tx_index)?;
self.resolve_parents(processor, previous_parent_prefixes, current_tx_index)?;
self.prepare_outputs(inputs);
self.read_outputs(processor)
}
@@ -173,61 +207,66 @@ impl ReadBatch {
fn resolve_parents(
&mut self,
processor: &BlockProcessor<'_>,
previous_parents: &[PreviousParent],
previous_parent_prefixes: &[TxidPrefix],
current_tx_index: TxIndex,
) -> Result<()> {
let parallel_raw_reads = previous_parent_prefixes.len() >= PARALLEL_PARENT_READ_THRESHOLD;
self.parents.clear();
self.parents.extend(
(0..previous_parents.len()).map(|original_index| ParentRead {
original_index,
self.parents.resize(
previous_parent_prefixes.len(),
ParentRead {
tx_index: TxIndex::default(),
first_txout_index: TxOutIndex::default(),
}),
},
);
self.parents
.par_iter_mut()
.zip(previous_parent_prefixes.par_iter())
.try_for_each(|read| {
let parent = &previous_parents[read.original_index];
let (read, txid_prefix) = read;
let store_result = processor
.stores
.txid_prefix_to_tx_index
.get(&parent.txid_prefix)?
.get(txid_prefix)?
.map(|value| *value);
let tx_index = match store_result {
Some(tx_index) if tx_index < current_tx_index => tx_index,
_ => {
error!(
"UnknownTxid: txid={}, prefix={:?}, store_result={:?}, current_tx_index={:?}",
parent.txid,
parent.txid_prefix,
store_result,
current_tx_index
"UnknownTxid: prefix={:?}, store_result={:?}, current_tx_index={:?}",
txid_prefix, store_result, current_tx_index
);
return Err(Error::UnknownTxid);
}
};
read.tx_index = tx_index;
if parallel_raw_reads {
read.first_txout_index = processor
.vecs
.transactions
.first_txout_index
.get_append_only(tx_index, &processor.readers.tx_index_to_first_txout_index)
.ok_or(Error::Internal("Missing txout_index"))?;
}
Ok(())
})?;
self.parents.sort_unstable_by_key(|read| read.tx_index);
self.parent_positions.clear();
self.parent_positions.resize(self.parents.len(), 0);
for (position, read) in self.parents.iter_mut().enumerate() {
self.parent_positions[read.original_index] = position;
read.first_txout_index = processor
.vecs
.transactions
.first_txout_index
.get_pushed_or_read(
read.tx_index,
&processor.readers.tx_index_to_first_txout_index,
)
.ok_or(Error::Internal("Missing txout_index"))?;
if !parallel_raw_reads {
for read in &mut self.parents {
read.first_txout_index = processor
.vecs
.transactions
.first_txout_index
.get_append_only(
read.tx_index,
&processor.readers.tx_index_to_first_txout_index,
)
.ok_or(Error::Internal("Missing txout_index"))?;
}
}
Ok(())
@@ -247,55 +286,47 @@ impl ReadBatch {
}
}
self.outputs.sort_unstable_by_key(|read| read.txout_index);
self.output_positions.clear();
self.output_positions.resize(inputs.len(), 0);
for (position, read) in self.outputs.iter().enumerate() {
self.output_positions[read.input_index] = position;
}
self.output_types.clear();
self.output_types.resize(inputs.len(), OutputType::Unknown);
self.type_indices.clear();
self.type_indices.resize(inputs.len(), TypeIndex::default());
}
fn read_outputs(&mut self, processor: &BlockProcessor<'_>) -> Result<()> {
self.output_types.clear();
self.output_types.reserve(self.outputs.len());
self.type_indices.clear();
self.type_indices.reserve(self.outputs.len());
let outputs = &self.outputs;
if outputs.is_empty() {
return Ok(());
}
let output_types = &mut self.output_types;
let type_indices = &mut self.type_indices;
let (output_types_result, type_indices_result) = rayon::join(
|| -> Result<()> {
for read in outputs {
output_types.push(
processor
.vecs
.outputs
.output_type
.get_pushed_or_read(
read.txout_index,
&processor.readers.txout_index_to_output_type,
)
.ok_or(Error::Internal("Missing output_type"))?,
);
output_types[read.input_index] = processor
.vecs
.outputs
.output_type
.get_append_only(
read.txout_index,
&processor.readers.txout_index_to_output_type,
)
.ok_or(Error::Internal("Missing output_type"))?;
}
Ok(())
},
|| -> Result<()> {
for read in outputs {
type_indices.push(
processor
.vecs
.outputs
.type_index
.get_pushed_or_read(
read.txout_index,
&processor.readers.txout_index_to_type_index,
)
.ok_or(Error::Internal("Missing type_index"))?,
);
type_indices[read.input_index] = processor
.vecs
.outputs
.type_index
.get_append_only(
read.txout_index,
&processor.readers.txout_index_to_type_index,
)
.ok_or(Error::Internal("Missing type_index"))?;
}
Ok(())
},
@@ -306,12 +337,14 @@ impl ReadBatch {
}
fn parent(&self, original_index: usize) -> ParentRead {
self.parents[self.parent_positions[original_index]]
self.parents[original_index]
}
fn output(&self, input_index: usize) -> (OutputType, TypeIndex) {
let position = self.output_positions[input_index];
(self.output_types[position], self.type_indices[position])
(
self.output_types[input_index],
self.type_indices[input_index],
)
}
}
@@ -1,6 +1,6 @@
use brk_types::{OutPoint, OutputType, SigOps, TypeIndex};
#[derive(Debug)]
#[derive(Debug, Clone, Copy)]
pub(crate) enum InputSource {
Coinbase,
PreviousBlock {
+8 -8
View File
@@ -24,14 +24,14 @@ impl AddrReaders {
pub fn script_pubkey(&self, output_type: OutputType, type_index: TypeIndex) -> ScriptBuf {
let idx = usize::from(type_index);
let bytes: Option<AddrBytes> = match output_type {
OutputType::P2PK65 => self.p2pk65.try_get(idx).map(Into::into),
OutputType::P2PK33 => self.p2pk33.try_get(idx).map(Into::into),
OutputType::P2PKH => self.p2pkh.try_get(idx).map(Into::into),
OutputType::P2SH => self.p2sh.try_get(idx).map(Into::into),
OutputType::P2WPKH => self.p2wpkh.try_get(idx).map(Into::into),
OutputType::P2WSH => self.p2wsh.try_get(idx).map(Into::into),
OutputType::P2TR => self.p2tr.try_get(idx).map(Into::into),
OutputType::P2A => self.p2a.try_get(idx).map(Into::into),
OutputType::P2PK65 => self.p2pk65.try_get_at(idx).map(Into::into),
OutputType::P2PK33 => self.p2pk33.try_get_at(idx).map(Into::into),
OutputType::P2PKH => self.p2pkh.try_get_at(idx).map(Into::into),
OutputType::P2SH => self.p2sh.try_get_at(idx).map(Into::into),
OutputType::P2WPKH => self.p2wpkh.try_get_at(idx).map(Into::into),
OutputType::P2WSH => self.p2wsh.try_get_at(idx).map(Into::into),
OutputType::P2TR => self.p2tr.try_get_at(idx).map(Into::into),
OutputType::P2A => self.p2a.try_get_at(idx).map(Into::into),
_ => None,
};
bytes.map(|b| b.to_script_pubkey()).unwrap_or_default()
+6 -7
View File
@@ -332,13 +332,13 @@ impl Stores {
.collect_range_at(rollback_start, rollback_end);
for (i, txout_index) in (rollback_start..rollback_end).enumerate() {
let output_type = txout_index_to_output_type_reader.get(txout_index);
let output_type = txout_index_to_output_type_reader.get_at(txout_index);
if !output_type.is_addr() {
continue;
}
let addr_type = output_type;
let addr_index = txout_index_to_type_index_reader.get(txout_index);
let addr_index = txout_index_to_type_index_reader.get_at(txout_index);
let tx_index = tx_indexes[i];
addr_index_tx_index_to_remove.insert((addr_type, addr_index, tx_index));
@@ -346,7 +346,7 @@ impl Stores {
let vout = Vout::from(
txout_index
- tx_index_to_first_txout_index_reader
.get(tx_index.to_usize())
.get(tx_index)
.to_usize(),
);
let outpoint = OutPoint::new(tx_index, vout);
@@ -371,12 +371,11 @@ impl Stores {
let output_tx_index = outpoint.tx_index();
let vout = outpoint.vout();
let txout_index =
tx_index_to_first_txout_index_reader.get(output_tx_index.to_usize()) + vout;
let txout_index = tx_index_to_first_txout_index_reader.get(output_tx_index) + vout;
if txout_index < starting_lengths.txout_index {
let output_type = txout_index_to_output_type_reader.get(txout_index.to_usize());
let type_index = txout_index_to_type_index_reader.get(txout_index.to_usize());
let output_type = txout_index_to_output_type_reader.get(txout_index);
let type_index = txout_index_to_type_index_reader.get(txout_index);
Some((outpoint, output_type, type_index, spending_tx_index))
} else {
None
+9 -9
View File
@@ -233,42 +233,42 @@ impl AddrsVecs {
OutputType::P2PK65 => self
.p2pk65
.bytes
.get_pushed_or_read(type_index.into(), &readers.p2pk65)
.get_append_only(type_index.into(), &readers.p2pk65)
.map(AddrBytes::from),
OutputType::P2PK33 => self
.p2pk33
.bytes
.get_pushed_or_read(type_index.into(), &readers.p2pk33)
.get_append_only(type_index.into(), &readers.p2pk33)
.map(AddrBytes::from),
OutputType::P2PKH => self
.p2pkh
.bytes
.get_pushed_or_read(type_index.into(), &readers.p2pkh)
.get_append_only(type_index.into(), &readers.p2pkh)
.map(AddrBytes::from),
OutputType::P2SH => self
.p2sh
.bytes
.get_pushed_or_read(type_index.into(), &readers.p2sh)
.get_append_only(type_index.into(), &readers.p2sh)
.map(AddrBytes::from),
OutputType::P2WPKH => self
.p2wpkh
.bytes
.get_pushed_or_read(type_index.into(), &readers.p2wpkh)
.get_append_only(type_index.into(), &readers.p2wpkh)
.map(AddrBytes::from),
OutputType::P2WSH => self
.p2wsh
.bytes
.get_pushed_or_read(type_index.into(), &readers.p2wsh)
.get_append_only(type_index.into(), &readers.p2wsh)
.map(AddrBytes::from),
OutputType::P2TR => self
.p2tr
.bytes
.get_pushed_or_read(type_index.into(), &readers.p2tr)
.get_append_only(type_index.into(), &readers.p2tr)
.map(AddrBytes::from),
OutputType::P2A => self
.p2a
.bytes
.get_pushed_or_read(type_index.into(), &readers.p2a)
.get_append_only(type_index.into(), &readers.p2a)
.map(AddrBytes::from),
_ => unreachable!("get_bytes_by_type called with non-address type"),
}
@@ -302,7 +302,7 @@ impl AddrsVecs {
Some(mut index) => {
let reader = $addr.bytes.reader();
Ok(Box::new(std::iter::from_fn(move || {
reader.try_get(index.to_usize()).map(|typedbytes| {
reader.try_get(index).map(|typedbytes| {
let bytes = AddrBytes::from(typedbytes);
index.increment();
AddrHash::from(&bytes)
+2 -2
View File
@@ -153,11 +153,11 @@ impl ScriptsVecs {
OutputType::P2MS => self
.p2ms
.legacy_sigops
.get_pushed_or_read(type_index.into(), &readers.p2ms_legacy_sigops),
.get_append_only(type_index.into(), &readers.p2ms_legacy_sigops),
OutputType::Unknown => self
.unknown
.legacy_sigops
.get_pushed_or_read(type_index.into(), &readers.unknown_legacy_sigops),
.get_append_only(type_index.into(), &readers.unknown_legacy_sigops),
_ => Some(SigOps::ZERO),
}
}
+6 -1
View File
@@ -79,7 +79,12 @@ fn main() {
let total_outputs = indexer.vecs.outputs.value.len();
let first_tx_index: Vec<TxIndex> = indexer.vecs.transactions.first_tx_index.collect();
let out_first: Vec<TxOutIndex> = indexer.vecs.outputs.first_txout_index.collect();
let mut txout_cursor = indexer.vecs.transactions.first_txout_index.cursor();
let mut txout_cursor = indexer
.vecs
.transactions
.first_txout_index
.reader()
.cursor();
let mut blocks: Vec<Block> = Vec::with_capacity(end_height - load_start);
for h in load_start..end_height {
+6 -1
View File
@@ -55,7 +55,12 @@ fn main() {
let total_outputs = indexer.vecs.outputs.value.len();
let first_tx_index: Vec<TxIndex> = indexer.vecs.transactions.first_tx_index.collect();
let out_first: Vec<TxOutIndex> = indexer.vecs.outputs.first_txout_index.collect();
let mut txout_cursor = indexer.vecs.transactions.first_txout_index.cursor();
let mut txout_cursor = indexer
.vecs
.transactions
.first_txout_index
.reader()
.cursor();
let mut tx_starts: Vec<usize> = Vec::new();
let out_path = format!("{out_dir}/oracle_outputs_{start}_{end}.csv");
+6 -1
View File
@@ -534,7 +534,12 @@ fn main() {
let total_outputs = indexer.vecs.outputs.value.len();
let first_tx_index: Vec<TxIndex> = indexer.vecs.transactions.first_tx_index.collect();
let out_first: Vec<TxOutIndex> = indexer.vecs.outputs.first_txout_index.collect();
let mut txout_cursor = indexer.vecs.transactions.first_txout_index.cursor();
let mut txout_cursor = indexer
.vecs
.transactions
.first_txout_index
.reader()
.cursor();
let mut tx_starts: Vec<usize> = Vec::new();
let mut values: Vec<Sats> = Vec::new();
let mut output_types: Vec<OutputType> = Vec::new();
+6 -1
View File
@@ -180,7 +180,12 @@ fn main() {
// large, so the tx-indexed first_txout_index is read through a forward cursor.
let first_tx_index: Vec<TxIndex> = indexer.vecs.transactions.first_tx_index.collect();
let out_first: Vec<TxOutIndex> = indexer.vecs.outputs.first_txout_index.collect();
let mut txout_cursor = indexer.vecs.transactions.first_txout_index.cursor();
let mut txout_cursor = indexer
.vecs
.transactions
.first_txout_index
.reader()
.cursor();
let mut tx_starts: Vec<usize> = Vec::new();
let mut year_stats: Vec<YearStats> = Vec::new();
+6 -1
View File
@@ -842,7 +842,12 @@ fn main() {
// large, so the tx-indexed first_txout_index is read through a forward cursor.
let first_tx_index: Vec<TxIndex> = indexer.vecs.transactions.first_tx_index.collect();
let out_first: Vec<TxOutIndex> = indexer.vecs.outputs.first_txout_index.collect();
let mut txout_cursor = indexer.vecs.transactions.first_txout_index.cursor();
let mut txout_cursor = indexer
.vecs
.transactions
.first_txout_index
.reader()
.cursor();
let mut tx_starts: Vec<usize> = Vec::new();
let mut year_stats: Vec<YearStats> = Vec::new();
+1 -2
View File
@@ -25,8 +25,7 @@ brk_types = { workspace = true }
derive_more = { workspace = true }
jiff = { workspace = true }
parking_lot = { workspace = true }
# quickmatch = { path = "../../../quickmatch" }
quickmatch = "0.5.0"
quickmatch = { workspace = true }
rustc-hash = { workspace = true }
smallvec = { workspace = true }
tokio = { workspace = true, optional = true }
+5 -4
View File
@@ -5,6 +5,7 @@ use brk_types::{
Addr, AddrBytes, AddrChainStats, AddrHash, AddrStats, AnyAddrDataIndexEnum, Dollars,
OutputType, TypeIndex,
};
use vecdb::ReadableVec;
use crate::Query;
@@ -40,8 +41,8 @@ impl Query {
.distribution
.addrs_data
.funded
.reader()
.get(usize::from(index));
.collect_one(index)
.expect("funded address data index should be in bounds");
let price = data.realized_price().to_dollars();
(data, price)
}
@@ -50,8 +51,8 @@ impl Query {
.distribution
.addrs_data
.empty
.reader()
.get(usize::from(index))
.collect_one(index)
.expect("empty address data index should be in bounds")
.into();
(data, Dollars::default())
}
+1 -2
View File
@@ -1,6 +1,5 @@
use brk_error::{OptionData, Result};
use brk_types::{Addr, AddrIndexTxIndex, Transaction, TxIndex, Txid, Unit};
use vecdb::VecIndex;
use crate::Query;
@@ -25,7 +24,7 @@ impl Query {
let txid_reader = self.indexer().vecs.transactions.txid.reader();
Ok(txindices
.into_iter()
.map(|tx_index| txid_reader.get(tx_index.to_usize()))
.map(|tx_index| txid_reader.get(tx_index))
.collect())
}
+3 -4
View File
@@ -1,6 +1,5 @@
use brk_error::{Error, OptionData, Result};
use brk_types::{Addr, AddrIndexOutPoint, Height, TxIndex, TxStatus, Unit, Utxo, Vout};
use vecdb::VecIndex;
use crate::Query;
@@ -36,9 +35,9 @@ impl Query {
let mut utxos = Vec::with_capacity(outpoints.len());
for (tx_index, vout) in outpoints {
let txid = txid_reader.get(tx_index.to_usize());
let first_txout_index = first_txout_index_reader.get(tx_index.to_usize());
let value = value_reader.get(usize::from(first_txout_index + vout));
let txid = txid_reader.get(tx_index);
let first_txout_index = first_txout_index_reader.get(tx_index);
let value = value_reader.get(first_txout_index + vout);
let height = self.confirmed_status_height(tx_index)?;
let status = if let Some((h, ref s)) = cached_status
+2 -3
View File
@@ -88,8 +88,7 @@ impl Query {
.vecs
.transactions
.txid
.reader()
.try_get(first + index)
.collect_one_at(first + index)
.ok_or(Error::Internal(
"block_txid_at_index_by_height: txid index past data",
))
@@ -125,7 +124,7 @@ impl Query {
// ── Phase 1: Decode all transactions, collect outpoints ─────────
let mut txid_cursor = indexer.vecs.transactions.txid.cursor();
let txid_cursor = indexer.vecs.transactions.txid.reader().cursor();
let mut total_size_cursor = indexer.vecs.transactions.total_size.cursor();
let mut sigops_cursor = indexer.vecs.transactions.total_sigop_cost.cursor();
let mut first_txin_cursor = indexer.vecs.transactions.first_txin_index.cursor();
+9 -4
View File
@@ -66,7 +66,7 @@ impl Query {
let position = index.to_usize();
let weight = weight.get(position).data()?;
Ok(Member {
txid: txid.get(position),
txid: txid.get(*index),
fee: fee.get(position).data()?,
weight,
vsize: VSize::from(weight),
@@ -88,7 +88,7 @@ impl Query {
.map(|index| {
let position = index.to_usize();
Ok(CpfpEntry {
txid: txid.get(position),
txid: txid.get(*index),
fee: fee.get(position).data()?,
weight: weight.get(position).data()?,
})
@@ -112,9 +112,14 @@ impl Query {
let mut first_txin = indexer.vecs.transactions.first_txin_index.cursor();
let mut input_count = computer.indexes.tx_index.input_count.cursor();
let mut outpoint = indexer.vecs.inputs.outpoint.cursor();
let mut first_txout = indexer.vecs.transactions.first_txout_index.cursor();
let first_txout = indexer
.vecs
.transactions
.first_txout_index
.reader()
.cursor();
let mut output_count = computer.indexes.tx_index.output_count.cursor();
let mut spent = computer.outputs.spent.txin_index.cursor();
let spent = computer.outputs.spent.txin_index.reader().cursor();
let mut spending_tx = indexer.vecs.inputs.tx_index.cursor();
let mut parents_of = |tx: TxIndex| -> Result<SmallVec<[TxIndex; 2]>> {
+4 -6
View File
@@ -67,16 +67,14 @@ impl Query {
if prev_tx_index >= safe.tx_index {
return None;
}
let first_txout: TxOutIndex =
first_txout_reader.try_get(usize::from(prev_tx_index))?;
let first_txout: TxOutIndex = first_txout_reader.try_get(prev_tx_index)?;
let txout = first_txout + *vout;
if txout >= safe.txout_index {
return None;
}
let txout_idx = usize::from(txout);
let output_type: OutputType = output_type_reader.try_get(txout_idx)?;
let type_index: TypeIndex = type_index_reader.try_get(txout_idx)?;
let value: Sats = value_reader.try_get(txout_idx)?;
let output_type: OutputType = output_type_reader.try_get(txout)?;
let type_index: TypeIndex = type_index_reader.try_get(txout)?;
let value: Sats = value_reader.try_get(txout)?;
let script_pubkey = addr_readers.script_pubkey(output_type, type_index);
Some(((*prev_txid, *vout), TxOut::from((script_pubkey, value))))
})
+11 -6
View File
@@ -205,8 +205,8 @@ impl Query {
.outputs
.spent
.txin_index
.reader()
.get(usize::from(txout_index));
.collect_one(txout_index)
.data()?;
if txin_index == TxInIndex::UNSPENT {
return Ok(TxOutspend::UNSPENT);
@@ -235,7 +235,7 @@ impl Query {
let mut cached_status: Option<(Height, BlockHash, Timestamp)> = None;
let mut outspends = Vec::with_capacity(output_count);
for i in 0..output_count {
let txin_index = txin_index_reader.get(usize::from(first_txout + Vout::from(i)));
let txin_index = txin_index_reader.get(first_txout + Vout::from(i));
if txin_index == TxInIndex::UNSPENT {
outspends.push(TxOutspend::UNSPENT);
@@ -249,7 +249,7 @@ impl Query {
}
let spending_first_txin = first_txin_cursor.get(spending_tx_index.to_usize()).data()?;
let vin = Vin::from(usize::from(txin_index) - usize::from(spending_first_txin));
let spending_txid = txid_reader.get(spending_tx_index.to_usize());
let spending_txid = txid_reader.get(spending_tx_index);
let spending_height: Height = tx_heights.get_shared(spending_tx_index).data()?;
let (block_hash, block_time) = if let Some((h, ref bh, bt)) = cached_status
@@ -318,10 +318,15 @@ impl Query {
return Err(Error::UnknownTxid);
}
let first_txout_vec = &self.indexer().vecs.transactions.first_txout_index;
let first = first_txout_vec.read_once(tx_index)?;
let first_txout_reader = first_txout_vec.reader();
let first = first_txout_reader.try_get(tx_index).ok_or(Error::Internal(
"resolve_tx_outputs: first txout index past data",
))?;
let next_tx = tx_index.incremented();
let next = if next_tx < safe.tx_index {
first_txout_vec.read_once(next_tx)?
first_txout_reader.try_get(next_tx).ok_or(Error::Internal(
"resolve_tx_outputs: next first txout index past data",
))?
} else {
safe.txout_index
};
+3
View File
@@ -16,3 +16,6 @@ brk_types = { workspace = true }
byteview = { workspace = true }
fjall = { workspace = true }
rustc-hash = { workspace = true }
[dev-dependencies]
tempfile = "3"
+1 -8
View File
@@ -1,15 +1,8 @@
use brk_error::Result;
use brk_types::{Height, Version};
use fjall::Keyspace;
use brk_types::Height;
pub trait AnyStore: Send + Sync {
fn name(&self) -> &'static str;
fn height(&self) -> Option<Height>;
fn has(&self, height: Height) -> bool;
fn needs(&self, height: Height) -> bool;
fn version(&self) -> Version;
fn export_meta(&mut self, height: Height) -> Result<()>;
fn export_meta_if_needed(&mut self, height: Height) -> Result<()>;
fn keyspace(&self) -> &Keyspace;
fn commit(&mut self, height: Height) -> Result<()>;
}
+69 -66
View File
@@ -1,18 +1,6 @@
#![doc = include_str!("../README.md")]
use std::{
borrow::Cow,
fmt::Debug,
fs,
hash::Hash,
mem,
ops::Range,
path::Path,
sync::{
Arc,
atomic::{AtomicU64, Ordering::Relaxed},
},
};
use std::{borrow::Cow, cmp::Ordering, fmt::Debug, fs, hash::Hash, mem, ops::Range, path::Path};
use brk_error::Result;
use brk_types::{Height, Version};
@@ -32,7 +20,7 @@ pub use kind::*;
pub use meta::*;
pub use mode::*;
const MAJOR_FJALL_VERSION: Version = Version::new(3);
const MAJOR_FJALL_VERSION: Version = Version::new(4);
pub fn open_database(path: &Path) -> fjall::Result<Database> {
Database::builder(path.join("fjall"))
@@ -44,12 +32,10 @@ pub fn open_database(path: &Path) -> fjall::Result<Database> {
#[derive(Clone)]
pub struct Store<K, V> {
meta: StoreMeta,
name: &'static str,
keyspace: Keyspace,
puts: FxHashMap<K, V>,
dels: FxHashSet<K>,
caches: Vec<FxHashMap<K, V>>,
db_reads: Arc<AtomicU64>,
}
impl<K, V> Store<K, V>
@@ -111,12 +97,10 @@ where
Ok(Self {
meta,
name: Box::leak(Box::new(name.to_string())),
keyspace,
puts: FxHashMap::default(),
dels: FxHashSet::default(),
caches,
db_reads: Arc::new(AtomicU64::new(0)),
})
}
@@ -179,9 +163,7 @@ where
}
}
self.db_reads.fetch_add(1, Relaxed);
if let Some(slice) = self.keyspace.get(ByteView::from(key))? {
if let Some(slice) = self.keyspace.get_standard(ByteView::from(key))? {
Ok(Some(Cow::Owned(V::from(ByteView::from(slice)))))
} else {
Ok(None)
@@ -240,16 +222,16 @@ where
let keyspace = self.keyspace.clone();
Ok(Some(Box::new(move || {
Self::ingest(&keyspace, puts.iter(), dels.iter())
Self::ingest_owned(&keyspace, puts, dels)
})))
}
#[inline]
pub fn iter(&self) -> impl Iterator<Item = (K, V)> {
self.keyspace
.iter()
.map(|res| res.into_inner().unwrap())
.map(|(k, v)| (K::from(ByteView::from(&*k)), V::from(ByteView::from(&*v))))
.iter_standard()
.map(Result::unwrap)
.map(|(k, v)| (K::from(ByteView::from(k)), V::from(ByteView::from(v))))
}
#[inline]
@@ -259,9 +241,9 @@ where
) -> impl DoubleEndedIterator<Item = (K, V)> + '_ {
let prefix: ByteView = prefix.into();
self.keyspace
.prefix(&*prefix)
.map(|res| res.into_inner().unwrap())
.map(|(k, v)| (K::from(ByteView::from(&*k)), V::from(ByteView::from(&*v))))
.prefix_standard(prefix)
.map(Result::unwrap)
.map(|(k, v)| (K::from(ByteView::from(k)), V::from(ByteView::from(v))))
}
#[inline]
@@ -272,9 +254,9 @@ where
let start: ByteView = range.start.into();
let end: ByteView = range.end.into();
self.keyspace
.range(start..end)
.map(|res| res.into_inner().unwrap())
.map(|(k, v)| (K::from(ByteView::from(&*k)), V::from(ByteView::from(&*v))))
.range_standard(start..end)
.map(Result::unwrap)
.map(|(k, v)| (K::from(ByteView::from(k)), V::from(ByteView::from(v))))
}
pub fn approximate_len(&self) -> usize {
@@ -286,11 +268,6 @@ where
self.meta.has(height)
}
#[inline]
pub fn needs(&self, height: Height) -> bool {
self.meta.needs(height)
}
fn export_meta(&mut self, height: Height) -> Result<()> {
self.meta.export(height)?;
Ok(())
@@ -321,20 +298,69 @@ where
items.sort_unstable();
let mut ingestion = keyspace.start_ingestion()?;
// FxHashMap/FxHashSet keep keys unique and disjoint; sorting therefore
// proves the strict ordering required by the prevalidated writer.
for item in items {
match item {
Item::Value { key, value } => {
ingestion.write(ByteView::from(key), ByteView::from(value))?;
ingestion.write_prevalidated(ByteView::from(key), ByteView::from(value))?;
}
Item::Tomb(key) => {
ingestion.write_weak_tombstone(ByteView::from(key))?;
ingestion.write_prevalidated_weak_tombstone(ByteView::from(key))?;
}
}
}
ingestion.finish()?;
// Store keyspaces are mutated only through these ingestion phases, so
// no journaled Fjall write can race their completion.
ingestion.finish_exclusive()?;
Ok(())
}
fn ingest_owned(keyspace: &Keyspace, puts: FxHashMap<K, V>, dels: FxHashSet<K>) -> Result<()> {
let mut puts: Vec<_> = puts.into_iter().collect();
let mut dels: Vec<_> = dels.into_iter().collect();
puts.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
dels.sort_unstable();
let mut puts = puts.into_iter().peekable();
let mut dels = dels.into_iter().peekable();
let mut ingestion = keyspace.start_ingestion()?;
// The buffers are unique and disjoint, and this merge emits them in
// strict key order, so release builds can skip re-cloning each key.
while puts.peek().is_some() || dels.peek().is_some() {
match (puts.peek(), dels.peek()) {
(Some((put_key, _)), Some(del_key)) => match put_key.cmp(del_key) {
Ordering::Less => {
let (key, value) = puts.next().unwrap();
ingestion.write_prevalidated(ByteView::from(key), ByteView::from(value))?;
}
Ordering::Greater => {
ingestion.write_prevalidated_weak_tombstone(ByteView::from(
dels.next().unwrap(),
))?;
}
Ordering::Equal => unreachable!("key is both inserted and deleted"),
},
(Some(_), None) => {
let (key, value) = puts.next().unwrap();
ingestion.write_prevalidated(ByteView::from(key), ByteView::from(value))?;
}
(None, Some(_)) => {
ingestion
.write_prevalidated_weak_tombstone(ByteView::from(dels.next().unwrap()))?;
}
(None, None) => break,
}
}
// Store keyspaces are mutated only through these ingestion phases, so
// no journaled Fjall write can race their completion.
ingestion.finish_exclusive()?;
Ok(())
}
}
impl<K, V> AnyStore for Store<K, V>
@@ -344,38 +370,14 @@ where
for<'a> ByteView: From<K> + From<V> + From<&'a K> + From<&'a V>,
Self: Send + Sync,
{
fn keyspace(&self) -> &Keyspace {
&self.keyspace
}
fn export_meta(&mut self, height: Height) -> Result<()> {
self.export_meta(height)
}
fn export_meta_if_needed(&mut self, height: Height) -> Result<()> {
self.export_meta_if_needed(height)
}
fn name(&self) -> &'static str {
self.name
}
fn height(&self) -> Option<Height> {
self.meta.height()
}
fn has(&self, height: Height) -> bool {
self.has(height)
}
fn needs(&self, height: Height) -> bool {
self.needs(height)
}
fn version(&self) -> Version {
self.meta.version()
}
fn commit(&mut self, height: Height) -> Result<()> {
self.export_meta_if_needed(height)?;
@@ -386,9 +388,10 @@ where
return Ok(());
}
Self::ingest(&self.keyspace, puts.iter(), dels.iter())?;
if !self.caches.is_empty() {
if self.caches.is_empty() {
Self::ingest_owned(&self.keyspace, puts, dels)?;
} else {
Self::ingest(&self.keyspace, puts.iter(), dels.iter())?;
self.caches.pop();
self.caches.insert(0, puts);
}
+1 -7
View File
@@ -12,7 +12,6 @@ use super::Height;
#[derive(Debug, Clone)]
pub struct StoreMeta {
pathbuf: PathBuf,
version: Version,
height: Option<Height>,
}
@@ -41,19 +40,14 @@ impl StoreMeta {
let slf = Self {
pathbuf: path.to_owned(),
version,
height: Height::try_from(Self::path_height_(path).as_path()).ok(),
};
slf.version.write(&slf.path_version())?;
version.write(&slf.path_version())?;
Ok((slf, partition))
}
pub fn version(&self) -> Version {
self.version
}
pub fn export(&mut self, height: Height) -> io::Result<()> {
self.height = Some(height);
height.write(&self.path_height())
+59
View File
@@ -0,0 +1,59 @@
use brk_store::{Kind, Mode, Store, open_database};
use brk_types::{AddrIndexTxIndex, Height, TxIndex, TypeIndex, Unit, Version};
use fjall::PersistMode;
fn key(address: u32, transaction: u32) -> AddrIndexTxIndex {
AddrIndexTxIndex::from((TypeIndex::new(address), TxIndex::new(transaction)))
}
#[test]
fn owned_ingest_merges_puts_and_tombstones() -> brk_error::Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path();
{
let db = open_database(path)?;
let mut store = Store::import(
&db,
path,
"owned_ingest",
Version::ZERO,
Mode::Any,
Kind::Vec,
)?;
store.insert(key(1, 1), Unit);
store.insert(key(2, 2), Unit);
store.take_pending_ingest(Height::from(0_u32))?.unwrap()()?;
store.remove(key(1, 1));
store.remove(key(3, 3));
store.insert(key(4, 4), Unit);
store.take_pending_ingest(Height::from(1_u32))?.unwrap()()?;
db.persist(PersistMode::SyncData)?;
assert!(store.get(&key(1, 1))?.is_none());
assert!(store.get(&key(2, 2))?.is_some());
assert!(store.get(&key(3, 3))?.is_none());
assert!(store.get(&key(4, 4))?.is_some());
}
{
let db = open_database(path)?;
let store: Store<AddrIndexTxIndex, Unit> = Store::import(
&db,
path,
"owned_ingest",
Version::ZERO,
Mode::Any,
Kind::Vec,
)?;
assert!(store.get(&key(1, 1))?.is_none());
assert!(store.get(&key(2, 2))?.is_some());
assert!(store.get(&key(3, 3))?.is_none());
assert!(store.get(&key(4, 4))?.is_some());
}
Ok(())
}
+33 -12
View File
@@ -2,7 +2,6 @@ use std::hash::{Hash, Hasher};
use byteview::ByteView;
use serde::Serialize;
use vecdb::Bytes;
use crate::{AddrIndexTxIndex, Vout};
@@ -16,6 +15,14 @@ pub struct AddrIndexOutPoint {
}
impl AddrIndexOutPoint {
#[inline]
pub(crate) fn to_be_bytes(self) -> [u8; 10] {
let mut bytes = [0; 10];
bytes[..8].copy_from_slice(&self.addr_index_tx_index.to_be_bytes());
bytes[8..].copy_from_slice(&self.vout.to_be_bytes());
bytes
}
#[inline]
pub fn tx_index(&self) -> TxIndex {
self.addr_index_tx_index.tx_index()
@@ -28,11 +35,10 @@ impl AddrIndexOutPoint {
}
impl Hash for AddrIndexOutPoint {
#[inline]
fn hash<H: Hasher>(&self, state: &mut H) {
let mut buf = [0u8; 10];
buf[0..8].copy_from_slice(&self.addr_index_tx_index.to_bytes());
buf[8..].copy_from_slice(&self.vout.to_bytes());
state.write(&buf);
self.addr_index_tx_index.hash(state);
self.vout.hash(state);
}
}
@@ -65,12 +71,27 @@ impl From<AddrIndexOutPoint> for ByteView {
impl From<&AddrIndexOutPoint> for ByteView {
#[inline]
fn from(value: &AddrIndexOutPoint) -> Self {
ByteView::from(
[
&ByteView::from(value.addr_index_tx_index),
value.vout.to_be_bytes().as_slice(),
]
.concat(),
)
ByteView::from(value.to_be_bytes())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn byte_encoding_is_stable_and_roundtrips() {
let value = AddrIndexOutPoint::from((
TypeIndex::new(0x0102_0304),
OutPoint::new(TxIndex::new(0x0506_0708), Vout::from(0x090a_u16)),
));
let bytes = ByteView::from(value);
assert_eq!(
&*bytes,
&[1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
"the LSM key encoding is part of the persisted format",
);
assert_eq!(AddrIndexOutPoint::from(bytes), value);
}
}
@@ -10,6 +10,11 @@ use super::{TxIndex, TypeIndex};
pub struct AddrIndexTxIndex(u64);
impl AddrIndexTxIndex {
#[inline]
pub(crate) fn to_be_bytes(self) -> [u8; 8] {
self.0.to_be_bytes()
}
pub fn addr_index(&self) -> u32 {
(self.0 >> 32) as u32
}
+4 -1
View File
@@ -2,10 +2,13 @@ use std::sync::OnceLock;
use serde::Deserialize;
use crate::PoolSlug;
use crate::{PoolSlug, Version};
use super::Pool;
/// Increment when pool IDs, payout addresses, or coinbase tags change.
pub const POOL_ATTRIBUTION_VERSION: Version = Version::ONE;
const JSON_DATA: &str = include_str!("../pools-v2.json");
const TESTNET_IDS: &[u16] = &[145, 146, 149, 150, 156, 163];
+2
View File
@@ -0,0 +1,2 @@
[profile.default]
slow-timeout = { period = "5s", terminate-after = 3 }
+24
View File
@@ -0,0 +1,24 @@
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
mutants
mutants.out
.fjall_data
.data
.test
/old_*
.directory
+3
View File
@@ -0,0 +1,3 @@
reorder_imports = true
# group_imports = "StdExternalCrate"
# imports_granularity = "crate"
+3
View File
@@ -0,0 +1,3 @@
{
"rust-analyzer.showUnlinkedFileNotification": false
}
+50
View File
@@ -0,0 +1,50 @@
# 3.1.0
- [feat] Implemented support for compaction filters (custom logic during compactions)
- [msrv] Reduced MSRV to 1.90
# 3.0.0
- [feat] Implemented new block format in `lsm-tree`
- [feat] Bookkeep LSM-tree changes (flushes, compactions) in `Version` history
- [feat] Prefix truncation inside data & index blocks
- [feat] Allow unpinning filter blocks
- [feat] Implemented partitioned filters
- [feat] Allow calling bulk ingestion on non-empty keyspaces
- [feat] Introduced level-based configuration policies for most configuration parameters
- [feat] Journal compression for large values
- [feat] Database locking using the new Rust file locking API
- [feat] Rewritten key-value separation to run during compactions, instead of dedicated GC runs
- [feat] Full file checksums to allow fast database corruption checks (in the future)
- [feat] Checksum check on block & blob reads
- [api] Make Ingestion API more flexible
- [feat] Shortening eligible sequence numbers when compacting into the last level to save disk space
- [api] Change constructor to `Database::builder` instead of `Config::new`
- [api] Changed naming of keyspace->database, and partition->keyspace
- [api] Change transaction feature flags to be separate structs, `OptimisticTxDatabase` and `SingleWriterTxDatabase`
- [api] Changed snapshot error type, fixes #156
- [api] Unified transactions read operations and snapshots with `Readable` trait
- [api] Guard API for iterator values
- [api] Removed old garbage collection APIs
- [api] `metrics` feature flag for cache hit rates etc. (will be exposed in the future)
- [api] Change `bytes` feature flag to `bytes_1` to pin its version
- [api] Make read operations in optimistic write transactions non-mut
- [fix] Consider blob files in FIFO compaction size limit, fixes #133
- [perf] Use a single hash per key for filters, instead of two
- [perf] Improve leveled compaction scoring
- [perf] Improve leveled compaction picking to use less hashing and heap allocations
- [perf] Use `quick-cache` for file descriptor caching
- [perf] Promote levels immediately to L6 to get rid of tombstones easily
- [perf] Rewritten maintenance task bookkeeping, and write stalling mechanisms to be less aggressive
- [perf] Allow `lsm-tree` flushes to merge multiple sealed memtables into L0, if necessary
- [perf] Skip heap allocation in blob memtable inserts
- [perf] Skip compression when rewriting compressed blob files
- [msrv] Increased MSRV to **1.91**
- [misc] Blob file descriptor caching
- [misc] Use Rust native `path::absolute`, removing `path-absolutize` dependency
- [misc] Remove `std-semaphore` dependency
- [misc] Remove `miniz` (will be replaced in the future)
- [misc] Use `byteorder-lite` as drop-in replacement for `byteorder`
- [refactor] Changed background workers to be a single thread pool
- [internal] Store keyspace configurations in a meta keyspace, instead of individual binary config files
- [internal] Use `sfa` for most file scaffolding in `lsm-tree`
+11
View File
@@ -0,0 +1,11 @@
# Contributing
## License
By contributing to this project, you agree that your contributions will be licensed under the project's license (MIT OR Apache-2.0).
Thank you for your contribution!
## Looking for issues?
https://github.com/fjall-rs/fjall/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22
+42
View File
@@ -0,0 +1,42 @@
[package]
name = "fjall"
description = "Log-structured, embeddable key-value storage engine"
license.workspace = true
version.workspace = true
edition.workspace = true
readme.workspace = true
include = ["src/**/*", "LICENSE-APACHE", "LICENSE-MIT", "README.md"]
repository.workspace = true
homepage.workspace = true
keywords = ["database", "key-value", "lsm", "rocksdb", "leveldb"]
categories = ["data-structures", "database-implementations", "algorithms"]
[lib]
name = "fjall"
path = "src/lib.rs"
[features]
default = ["lz4"]
lz4 = ["lsm-tree/lz4", "dep:lz4_flex"]
bytes_1 = ["lsm-tree/bytes_1"]
metrics = ["lsm-tree/metrics"]
__internal_whitebox = []
[dependencies]
byteorder = { package = "byteorder-lite", version = "0.1.0" }
byteview = { workspace = true }
lsm-tree = { workspace = true, default-features = false, features = [] }
log = { workspace = true }
tempfile = { workspace = true }
dashmap = "6.1.0"
xxhash-rust = { version = "0.8.15", features = ["xxh3"] }
lz4_flex = { workspace = true, features = ["checked-decode"], optional = true }
flume = { version = "0.12.0", default-features = false }
[dev-dependencies]
nanoid = "0.4.0"
test-log = "0.2.18"
rand = "0.10.0"
[package.metadata.cargo-all-features]
denylist = ["__internal_whitebox"]
+176
View File
@@ -0,0 +1,176 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 fjall-rs
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+207
View File
@@ -0,0 +1,207 @@
<p align="center">
<img src="/kawaii.png" height="200">
</p>
<p align="center">
<a href="https://github.com/fjall-rs/fjall/actions/workflows/test.yml">
<img src="https://github.com/fjall-rs/fjall/actions/workflows/test.yml/badge.svg" alt="CI" />
</a>
<a href="https://docs.rs/fjall">
<img src="https://img.shields.io/docsrs/fjall?color=green" alt="docs.rs" />
</a>
<a href="https://crates.io/crates/fjall">
<img src="https://img.shields.io/crates/v/fjall?color=blue" alt="Crates.io" />
</a>
<img src="https://img.shields.io/badge/MSRV-1.90.0-blue" alt="MSRV" />
<a href="https://deps.rs/repo/github/fjall-rs/fjall">
<img src="https://deps.rs/repo/github/fjall-rs/fjall/status.svg" alt="dependency status" />
</a>
</p>
<p align="center">
<a href="https://discord.com/invite/HvYGp4NFFk">
<img src="https://img.shields.io/discord/1240426554111164486" alt="Discord" />
</a>
<a href="https://bsky.app/profile/fjallrs.bsky.social">
<img src="https://img.shields.io/badge/bluesky-blue" alt="Bluesky" />
</a>
</p>
*Fjall* _(Nordic: "Mountain")_ is a log-structured, embeddable key-value storage engine written in Rust.
It features:
- A thread-safe BTreeMap-like API
- 100% safe & stable Rust
- LSM-tree-based storage similar to `RocksDB`
- Range & prefix searching with forward and reverse iteration
- Multiple keyspaces (a.k.a. column families) with cross-keyspace atomic semantics
- Built-in compression (default = `LZ4`)
- Serializable transactions (optional)
- Key-value separation for large blob use cases (optional)
- Custom compaction filters to run custom logic during compactions (optional)
- Automatic background maintenance
It is not:
- A standalone database server
- A relational or wide-column database: it has no built-in notion of columns or query language
## Sponsors
<a href="https://sqlsync.dev">
<picture>
<source width="240" alt="Orbitinghail" media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/fjall-rs/fjall-rs.github.io/d22fcb1e6966ce08327ea3bf6cf2ea86a840b071/public/logos/orbitinghail.svg" />
<source width="240" alt="Orbitinghail" media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/fjall-rs/fjall-rs.github.io/d22fcb1e6966ce08327ea3bf6cf2ea86a840b071/public/logos/orbitinghail_dark.svg" />
<img width="240" alt="Orbitinghail" src="https://raw.githubusercontent.com/fjall-rs/fjall-rs.github.io/d22fcb1e6966ce08327ea3bf6cf2ea86a840b071/public/logos/orbitinghail_dark.svg" />
</picture>
</a>
## Basic usage
```bash
cargo add fjall
```
```rust
use fjall::{Database, KeyspaceCreateOptions, PersistMode};
fn main() -> fjall::Result<()> {
// A database may contain multiple keyspaces
// You should probably only use a single database for your application
let db = Database::builder(path).open()?;
// TxDatabase::builder for transactional semantics
// Each keyspace is its own physical LSM-tree, and thus isolated from other keyspaces
let items = db.keyspace("my_items", KeyspaceCreateOptions::default)?;
// Write some data
items.insert("a", "hello")?;
// And retrieve it
let bytes = items.get("a")?;
// Or remove it again
items.remove("a")?;
// Search by prefix
for kv in items.prefix("prefix") {
// ...
}
// Search by range
for kv in items.range("a"..="z") {
// ...
}
// Iterators implement DoubleEndedIterator, so you can search backwards, too!
for kv in items.prefix("prefix").rev() {
// ...
}
// Sync the journal to disk to make sure data is definitely durable
// When the database is dropped, it will try to persist with `PersistMode::SyncAll` automatically
db.persist(PersistMode::SyncAll)
}
```
> [!TIP]
> Like any typical key-value store, keys are stored in lexicographic order.
> If you are storing integer keys (e.g. timeseries data), you should use the big endian form to have predictable ordering.
## Durability
To support different kinds of workloads, Fjall is agnostic about the type of durability
your application needs.
After writing data (`insert`, `remove` or committing a write batch/transaction), you can choose to call [`Database::persist`](https://docs.rs/fjall/latest/fjall/struct.Database.html#method.persist) which takes a [`PersistMode`](https://docs.rs/fjall/latest/fjall/enum.PersistMode.html) parameter.
By default, any operation will flush to OS buffers, but **not** to disk.
This matches RocksDB's default durability.
Also, when dropped, the database will try to persist the journal *to disk* synchronously.
## Multithreading, Async and Multiprocess
> [!WARNING]
> A single database may **not** be loaded in parallel from separate *processes*.
Fjall is internally synchronized for multi-*threaded* access, so you can clone around the `Database` and `Keyspace`s as needed, without needing to lock yourself.
For an async example, see the [`tokio`](https://github.com/fjall-rs/fjall/tree/main/examples/tokio) example.
## Memory usage
Generally, memory for loaded data, indexes etc. is managed on a per-block basis, and capped by the block cache capacity.
Note that this also applies to returned values: When you hold a `Slice`, it keeps the backing buffer alive (which may be a block).
If you know that you are going to keep a value around for a long time, you may want to copy it out into a new `Vec<u8>`, `Box<[u8]>`, `Arc<[u8]>` or new `Slice` (using `Slice::new`).
> [!NOTE]
> It is recommended to configure the block cache capacity to be ~20-25% of the available memory - or more **if** the data set fits _fully_ into memory.
Additionally, orthogonally to the block cache, each `Keyspace` has its own write buffer (["Memtable"](https://docs.rs/fjall/latest/fjall/struct.KeyspaceCreateOptions.html#method.max_memtable_size)) which is the unit of data flushed back into the "proper" index structure.
## Error handling
Fjall returns an [error enum](https://docs.rs/fjall/latest/fjall/enum.Error.html), however these variants are mostly used for debugging and tracing purposes, so your application is not expected to handle specific errors.
It's best to let the application crash and restart, which is the [safest way to recover from transient I/O errors](https://ramalagappan.github.io/pdfs/papers/cuttlefs.pdf).
## Transactional modes
The backing store (`lsm-tree`) is a MVCC key-value store, allowing repeatable snapshot reads.
However this isolation level can not do read-modify-write operations without the chance of lost updates.
Also, `WriteBatch` does not allow reading the intermediary state back as you would expect from a proper transaction.
For that reason, if you need transactional semantics, you need to use one of the transactional database implementation (`OptimisticTxDatabase` or `SingleWriterTxDatabase`).
TL;DR: Fjall supports both transactional and non-transactional workloads.
Chances are you want to use a transactional database, unless you know your workload does not need serializable transaction semantics.
### Single writer
Opens a transactional database for single-writer (serialized) transactions.
Single writer means only a single **write** transaction can run at a time.
This is trivially serializable because it _literally_ serializes write transactions.
### Optimistic
Opens a transactional database for multi-writer, serializable transactions.
Conflict checking is done using optimistic concurrency control, meaning transactions can conflict and may have to be rerun.
## Feature flags
### lz4
Allows using `LZ4` compression, powered by [`lz4_flex`](https://github.com/PSeitz/lz4_flex).
*Enabled by default.*
### bytes_1
Uses [`bytes`](https://github.com/tokio-rs/bytes) 1.x as the underlying `Slice` type.
Otherwise, [`byteview`](https://github.com/fjall-rs/byteview) is used instead.
*Disabled by default.*
## Stable disk format
Future breaking changes will result in a major version bump and a migration path.
For the underlying LSM-tree implementation, see: <https://crates.io/crates/lsm-tree>.
## Examples
[See here](https://github.com/fjall-rs/fjall/tree/main/examples) for practical examples.
## Contributing
How can you help?
- [Ask a question](https://github.com/fjall-rs/fjall/discussions/new?category=q-a)
- or join the Discord server: [https://discord.com/invite/HvYGp4NFFk](https://discord.com/invite/HvYGp4NFFk)
- [Post benchmarks and things you created](https://github.com/fjall-rs/fjall/discussions/new?category=show-and-tell)
- [Open a PR](https://github.com/fjall-rs/fjall/compare),
- [See open issues to pick up here](https://github.com/search?q=org%3Afjall-rs+label%3A%22help+wanted%22+state%3Aopen+&type=issues)
- [Open an issue](https://github.com/fjall-rs/fjall/issues/new) (bug report, weirdness)
## License
All source code is licensed under MIT OR Apache-2.0.
All contributions are to be licensed as MIT OR Apache-2.0.
+64
View File
@@ -0,0 +1,64 @@
let machines = [
# Fly.io performance
# "fly.performance.1x",
# "fly.performance.2x",
"fly.performance.4x",
# "fly.performance.8x",
# "fly.performance.16x",
# EC2 T2
# "aws.ec2.t2.nano",
# "aws.ec2.t2.micro",
# "aws.ec2.t2.small",
# "aws.ec2.t2.medium",
# "aws.ec2.t2.large",
# "aws.ec2.t2.xlarge",
# "aws.ec2.t2.2xlarge",
# EC2 T3
# "aws.ec2.t3.nano",
# "aws.ec2.t3.micro",
# "aws.ec2.t3.small",
"aws.ec2.t3.medium",
# "aws.ec2.t3.large",
# "aws.ec2.t3.xlarge",
# "aws.ec2.t3.2xlarge",
# EC2 T3a
# "aws.ec2.t3a.nano",
# "aws.ec2.t3a.micro",
# "aws.ec2.t3a.small",
# "aws.ec2.t3a.medium",
# "aws.ec2.t3a.large",
# "aws.ec2.t3a.xlarge",
# "aws.ec2.t3a.2xlarge",
# EC2 T4g
# "aws.ec2.t4g.nano",
# "aws.ec2.t4g.micro",
# "aws.ec2.t4g.small",
# "aws.ec2.t4g.medium",
# "aws.ec2.t4g.large",
# "aws.ec2.t4g.xlarge",
# "aws.ec2.t4g.2xlarge",
# EC2 M4
# "aws.ec2.m4.large",
]
let table = $env.TABLE_NAME
let commit = $env.COMMIT
print $"Queuing ($commit)"
for machine in $machines {
let q_pk = $"q#($machine)"
print $"Adding queue item for ($machine)"
let item = {
pk: { S: $q_pk },
sk: { S: $commit },
version: { S: "2" },
}
aws dynamodb put-item --table-name $table --item ($item | to json)
}
+60
View File
@@ -0,0 +1,60 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { readdir } from "node:fs/promises";
import { resolve } from "node:path";
const examplesFolder = "examples";
for (const exampleName of await readdir(examplesFolder)) {
const folder = resolve(examplesFolder, exampleName);
{
console.error(`Testing ${exampleName}`);
const proc = spawn("cargo test", {
cwd: folder,
shell: true,
});
proc.stdout.on("data", buf => console.log(String(buf)));
proc.stderr.on("data", buf => console.error(String(buf)));
await new Promise((resolve, _) => {
proc.on("exit", () => {
if (proc.exitCode > 0) {
console.error(`${exampleName} FAILED`);
process.exit(1);
}
else {
resolve();
}
})
});
}
if (existsSync(resolve(folder, ".run"))) {
console.error(`Running ${exampleName}`);
const proc = spawn("cargo run", {
cwd: folder,
shell: true,
});
proc.stdout.on("data", buf => console.log(String(buf)));
proc.stderr.on("data", buf => console.error(String(buf)));
await new Promise((resolve, _) => {
proc.on("exit", () => {
if (proc.exitCode > 0) {
console.error(`${exampleName} FAILED`);
process.exit(1);
}
else {
resolve();
}
})
});
}
console.error(`${exampleName} OK`);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:base"
]
}
+73
View File
@@ -0,0 +1,73 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use crate::Keyspace;
use lsm_tree::{UserKey, UserValue, ValueType};
#[derive(Clone, PartialEq, Eq)]
pub struct Item {
/// Keyspace
pub keyspace: Keyspace,
/// User-defined key - an arbitrary byte array
///
/// Supports up to 2^16 bytes
pub key: UserKey,
/// User-defined value - an arbitrary byte array
///
/// Supports up to 65535 bytes
pub value: UserValue,
/// Tombstone marker - if this is true, the value has been deleted
pub value_type: ValueType,
}
impl std::fmt::Debug for Item {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}:{:?}:{} => {:?}",
self.keyspace.id,
self.key,
match self.value_type {
ValueType::Value => "V",
ValueType::Tombstone => "T",
ValueType::WeakTombstone => "W",
ValueType::Indirection => "Vb",
},
self.value
)
}
}
impl Item {
pub fn new<K: Into<UserKey>, V: Into<UserValue>>(
keyspace: Keyspace,
key: K,
value: V,
value_type: ValueType,
) -> Self {
let k = key.into();
let v = value.into();
assert!(!k.is_empty());
assert!(
u16::try_from(k.len()).is_ok(),
"Keys can be up to 65535 bytes long"
);
assert!(
u32::try_from(v.len()).is_ok(),
"Values can be up to 2^32 bytes long"
);
Self {
keyspace,
key: k,
value: v,
value_type,
}
}
}
+181
View File
@@ -0,0 +1,181 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
pub mod item;
use crate::{Database, Keyspace, PersistMode};
use item::Item;
use lsm_tree::{AbstractTree, UserKey, UserValue, ValueType};
use std::collections::HashSet;
/// An atomic write batch
///
/// Allows atomically writing across keyspaces inside the [`Database`].
pub struct WriteBatch {
pub(crate) data: Vec<Item>,
db: Database,
durability: Option<PersistMode>,
}
impl WriteBatch {
/// Initializes a new write batch.
///
/// This function is called by [`Database::batch`].
pub(crate) fn new(db: Database) -> Self {
Self {
data: Vec::new(),
db,
durability: None,
}
}
/// Initializes a new write batch with preallocated capacity.
///
/// ### Note
///
/// "Capacity" refers to the number of batch item slots, not their size in memory.
#[must_use]
pub fn with_capacity(db: Database, capacity: usize) -> Self {
Self {
data: Vec::with_capacity(capacity),
db,
durability: None,
}
}
/// Gets the number of batched items.
#[must_use]
pub fn len(&self) -> usize {
self.data.len()
}
/// Returns `true` if there are no batches items (yet).
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Sets the durability level.
#[must_use]
pub fn durability(mut self, mode: Option<PersistMode>) -> Self {
self.durability = mode;
self
}
/// Inserts a key-value pair into the batch.
pub fn insert<K: Into<UserKey>, V: Into<UserValue>>(&mut self, p: &Keyspace, key: K, value: V) {
self.data
.push(Item::new(p.clone(), key, value, ValueType::Value));
}
/// Removes a key-value pair.
pub fn remove<K: Into<UserKey>>(&mut self, p: &Keyspace, key: K) {
self.data
.push(Item::new(p.clone(), key, vec![], ValueType::Tombstone));
}
/// Adds a weak tombstone marker for a key.
///
/// The tombstone marker of this delete operation will vanish when it
/// collides with its corresponding insertion.
/// This may cause older versions of the value to be resurrected, so it should
/// only be used and preferred in scenarios where a key is only ever written once.
///
/// # Experimental
///
/// This function is currently experimental.
#[doc(hidden)]
pub fn remove_weak<K: Into<UserKey>>(&mut self, p: &Keyspace, key: K) {
self.data
.push(Item::new(p.clone(), key, vec![], ValueType::WeakTombstone));
}
/// Commits the batch to the [`Database`] atomically.
///
/// # Errors
///
/// Will return `Err` if an IO error occurs.
#[allow(clippy::missing_panics_doc)]
pub fn commit(mut self) -> crate::Result<()> {
if self.is_empty() {
return Ok(());
}
log::trace!("batch: Acquiring journal writer");
let mut journal_writer = self.db.supervisor.journal.get_writer()?;
// IMPORTANT: Check the poisoned flag after getting journal mutex, otherwise TOCTOU
if self.db.is_poisoned.is_poisoned() {
return Err(crate::Error::Poisoned);
}
let batch_seqno = self.db.supervisor.seqno.next();
journal_writer.write_batch(self.data.iter(), self.data.len(), batch_seqno)?;
if let Some(mode) = self.durability {
if let Err(e) = journal_writer.persist(mode) {
self.db.is_poisoned.poison();
log::error!(
"persist failed, which is a FATAL, and possibly hardware-related, failure: {e:?}"
);
return Err(crate::Error::Poisoned);
}
}
// TODO: maybe we can use a stack alloc hashset/vec here, such as smallset
#[expect(clippy::mutable_key_type)]
let mut keyspaces_with_possible_stall = HashSet::new();
#[expect(clippy::expect_used)]
let keyspaces = self
.db
.supervisor
.keyspaces
.read()
.expect("lock is poisoned");
let mut batch_size = 0u64;
log::trace!("Applying batch (size={}) to memtable(s)", self.data.len());
for item in std::mem::take(&mut self.data) {
// TODO: need a better, generic write op
let (item_size, _) = match item.value_type {
ValueType::Value => item.keyspace.tree.insert(item.key, item.value, batch_seqno),
ValueType::Tombstone => item.keyspace.tree.remove(item.key, batch_seqno),
ValueType::WeakTombstone => item.keyspace.tree.remove_weak(item.key, batch_seqno),
ValueType::Indirection => unreachable!(),
};
batch_size += item_size;
// IMPORTANT: Clone the handle, because we don't want to keep the keyspaces lock open
keyspaces_with_possible_stall.insert(item.keyspace.clone());
}
self.db.supervisor.snapshot_tracker.publish(batch_seqno);
drop(journal_writer);
log::trace!("batch: Freed journal writer");
drop(keyspaces);
// IMPORTANT: Add batch size to current write buffer size
// Otherwise write buffer growth is unbounded when using batches
self.db.supervisor.write_buffer_size.allocate(batch_size);
// Check each affected keyspace for write stall/halt
for keyspace in &keyspaces_with_possible_stall {
let memtable_size = keyspace.tree.active_memtable().size();
keyspace.check_memtable_rotate(memtable_size);
keyspace.local_backpressure();
}
Ok(())
}
}
+190
View File
@@ -0,0 +1,190 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use crate::{db_config::CompactionFilterAssigner, tx::single_writer::Openable, Config};
use lsm_tree::{Cache, CompressionType, DescriptorTable};
use std::{marker::PhantomData, path::Path, sync::Arc};
/// Database builder
pub struct Builder<O: Openable> {
inner: Config,
_phantom: PhantomData<O>,
}
impl<O: Openable> Builder<O> {
pub(crate) fn new(path: &Path) -> Self {
Self {
inner: Config::new(path),
_phantom: PhantomData,
}
}
#[doc(hidden)]
#[must_use]
pub fn into_config(self) -> Config {
self.inner
}
/// Opens the database, creating it if it does not exist.
///
/// # Errors
///
/// Errors if an I/O error occurred, or if the database can not be opened.
pub fn open(self) -> crate::Result<O> {
O::open(self.inner)
}
/// Sets the cache capacity in bytes.
///
/// It is recommended to configure the block cache capacity to be ~20-25% of the available memory - or more **if** the data set _fully_ fits into memory.
#[must_use]
pub fn cache_size(mut self, size_bytes: u64) -> Self {
self.inner.cache = Arc::new(Cache::with_capacity_bytes(size_bytes));
self
}
/// Sets the compression type to use for large values that are written into the journal file.
#[must_use]
pub fn journal_compression(mut self, comp: CompressionType) -> Self {
self.inner.journal_compression_type = comp;
self
}
/// If `false`, write batches or transactions automatically flush data to the operating system.
///
/// Default = false
///
/// Set to `true` to handle persistence manually, e.g. manually using `PersistMode::SyncData` for ACID transactions.
#[must_use]
pub fn manual_journal_persist(mut self, flag: bool) -> Self {
self.inner.manual_journal_persist = flag;
self
}
/// Sets the number of worker threads.
///
/// Default = min(# CPU cores, 4)
///
/// # Panics
///
/// Panics, if below 1.
#[must_use]
pub fn worker_threads(self, n: usize) -> Self {
#[cfg(not(test))]
assert!(n > 0, "worker count must be at least 1");
self.worker_threads_unchecked(n)
}
#[doc(hidden)]
#[must_use]
pub fn worker_threads_unchecked(mut self, n: usize) -> Self {
self.inner.worker_threads = n;
self
}
/// Sets the upper limit for cached file descriptors.
///
/// # Note
///
/// Setting to None is currently not supported.
///
/// # Panics
///
/// Panics if n < 10 or `None`.
#[must_use]
pub fn max_cached_files(mut self, n: Option<usize>) -> Self {
self.inner.descriptor_table = n.map(|n| Arc::new(DescriptorTable::new(n)));
self
}
/// Maximum size of all journals in bytes.
///
/// Default = 512 MiB
///
/// # Panics
///
/// Panics if < 64 MiB.
///
/// Same as `max_total_wal_size` in `RocksDB`.
#[must_use]
pub fn max_journaling_size(mut self, bytes: u64) -> Self {
assert!(bytes >= 64 * 1_024 * 1_024);
self.inner.max_journaling_size_in_bytes = bytes;
self
}
/// Maximum size of all memtables in bytes.
///
/// Similar to `db_write_buffer_size` in `RocksDB`, however it is disabled by default in `RocksDB`.
///
/// Set to `u64::MAX` or `0` to disable it.
///
/// Default = off
///
/// # Panics
///
/// Panics if bytes < 1 MiB.
#[doc(hidden)]
#[must_use]
#[deprecated = "todo"]
pub fn max_write_buffer_size(mut self, bytes: Option<u64>) -> Self {
if let Some(bytes) = bytes {
assert!(bytes >= 1_024 * 1_024);
}
self.inner.max_write_buffer_size_in_bytes = bytes;
self
}
/// Sets the `Database` to clean upon drop.
///
/// # Examples
///
/// ```
/// # use fjall::{PersistMode, Database, KeyspaceCreateOptions};
/// # let folder = tempfile::tempdir()?.into_path();
/// let db = Database::builder(&folder).temporary(true).open()?;
///
/// assert!(folder.try_exists()?);
/// drop(db);
/// assert!(!folder.try_exists()?);
/// #
/// # Ok::<_, fjall::Error>(())
/// ```
#[must_use]
pub fn temporary(mut self, flag: bool) -> Self {
self.inner.clean_path_on_drop = flag;
self
}
/// Installs a factory that assigns compaction filters to new or recovered keyspaces.
///
/// # Examples
///
/// ```
/// # use fjall::{PersistMode, Database, KeyspaceCreateOptions};
/// # use std::sync::Arc;
/// # let folder = tempfile::tempdir()?.keep();
/// use lsm_tree::compaction::filter::Factory;
///
/// let db = Database::builder(&folder)
/// .temporary(true)
/// .with_compaction_filter_factories(
/// Arc::new(|keyspace| {
/// // Match on the keyspace name to assign specific compaction filters
/// todo!()
/// })
/// )
/// .open()?;
///
/// #
/// # Ok::<_, fjall::Error>(())
/// ```
pub fn with_compaction_filter_factories(mut self, f: CompactionFilterAssigner) -> Self {
self.inner.compaction_filter_factory_assigner = Some(f);
self
}
}
+17
View File
@@ -0,0 +1,17 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
pub(crate) mod worker;
pub use lsm_tree::compaction::{Fifo, Leveled, Levelled};
/// Compaction filter utilities
pub mod filter {
pub use lsm_tree::compaction::filter::{
CompactionFilter, Context, Factory, ItemAccessor, Verdict,
};
/// Alias for compaction filter return type
pub type CompactionFilterResult = lsm_tree::Result<Verdict>;
}
+60
View File
@@ -0,0 +1,60 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use crate::{snapshot_tracker::SnapshotTracker, stats::Stats, Keyspace};
use lsm_tree::AbstractTree;
use std::time::Instant;
/// Runs a single run of compaction.
pub fn run(
keyspace: &Keyspace,
snapshot_tracker: &SnapshotTracker,
stats: &Stats,
) -> crate::Result<()> {
use std::sync::atomic::Ordering::Relaxed;
if keyspace.is_deleted.load(Relaxed) {
return Ok(());
}
log::trace!(
"Checking compaction strategy for keyspace {:?}",
keyspace.name,
);
let strategy = keyspace.config.compaction_strategy.clone();
stats.active_compaction_count.fetch_add(1, Relaxed);
log::debug!("Compacting keyspace {:?}", keyspace.name);
let start = Instant::now();
if let Err(e) = keyspace
.tree
.compact(strategy.clone(), snapshot_tracker.get_seqno_safe_to_gc())
{
log::error!("Compaction failed: {e:?}");
stats.active_compaction_count.fetch_sub(1, Relaxed);
return Err(e.into());
}
// TODO: we need feedback from the compaction strategy...
// TODO: if there is nothing more to do, we should clear the compaction_manager semaphore
// NOTE: Throttle a bit to avoid a storm of compaction choice attempts
// (in case of write throttling)
std::thread::sleep(std::time::Duration::from_millis(1));
#[expect(clippy::cast_possible_truncation)]
stats
.time_compacting
.fetch_add(start.elapsed().as_micros() as u64, Relaxed);
stats.active_compaction_count.fetch_sub(1, Relaxed);
stats.compactions_completed.fetch_add(1, Relaxed);
Ok(())
}
+921
View File
@@ -0,0 +1,921 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use crate::{
batch::WriteBatch,
db_config::Config,
file::{fsync_directory, KEYSPACES_FOLDER, LOCK_FILE, VERSION_MARKER},
flush::manager::FlushManager,
journal::{manager::JournalManager, writer::PersistMode, Journal},
keyspace::{name::is_valid_keyspace_name, KeyspaceKey},
locked_file::LockedFileGuard,
meta_keyspace::MetaKeyspace,
poison::{PoisonDart, PoisonSignal},
recovery::{recover_keyspaces, recover_sealed_memtables},
snapshot::Snapshot,
snapshot_tracker::SnapshotTracker,
stats::Stats,
supervisor::{Supervisor, SupervisorInner},
tx::single_writer::Openable,
version::FormatVersion,
worker_pool::{WorkerMessage, WorkerPool},
write_buffer_manager::WriteBufferManager,
HashMap, Keyspace, KeyspaceCreateOptions,
};
use lsm_tree::{AbstractTree, SequenceNumberCounter};
use std::{
fs::remove_dir_all,
path::Path,
sync::{atomic::AtomicUsize, Arc, RwLock},
};
pub type Keyspaces = HashMap<KeyspaceKey, Keyspace>;
pub struct DatabaseInner {
pub(crate) meta_keyspace: MetaKeyspace,
/// Database configuration
#[doc(hidden)]
pub config: Config,
#[doc(hidden)]
pub supervisor: Supervisor,
/// Stop signal when database is dropped to stop background threads
pub(crate) stop_signal: lsm_tree::stop_signal::StopSignal,
/// Counter of background threads
pub(crate) active_thread_counter: Arc<AtomicUsize>,
/// True if fsync failed
pub(crate) is_poisoned: PoisonSignal,
pub(crate) stats: Arc<Stats>,
pub(crate) keyspace_id_counter: SequenceNumberCounter,
pub worker_pool: WorkerPool,
pub(crate) lock_file: LockedFileGuard,
}
impl Drop for DatabaseInner {
fn drop(&mut self) {
log::debug!("Dropping database");
self.stop_signal.send();
let _ = self.worker_pool.rx.drain().count();
while self
.active_thread_counter
.load(std::sync::atomic::Ordering::Relaxed)
> 0
{
let _ = self.worker_pool.sender.send(WorkerMessage::Close);
std::thread::sleep(std::time::Duration::from_micros(10));
}
// Drain again after threads are closed
let _ = self.worker_pool.rx.drain().count();
// IMPORTANT: Break cyclic Arcs
self.supervisor.flush_manager.clear();
self.supervisor
.keyspaces
.write()
.expect("lock is poisoned")
.clear();
self.supervisor
.journal_manager
.write()
.expect("lock is poisoned")
.clear();
if self.config.clean_path_on_drop {
log::info!(
"Deleting database because temporary=true: {}",
self.config.path.display(),
);
if let Err(err) = remove_dir_all(&self.config.path) {
log::warn!(
"Failed to clean up path: {} - {err}",
self.config.path.display()
);
}
}
#[cfg(feature = "__internal_whitebox")]
crate::drop::decrement_drop_counter();
}
}
/// A database is a single logical database
/// which can house multiple keyspaces
///
/// In your application, you should create a single database
/// and keep it around for as long as needed
/// (as long as you are using its keyspaces).
#[derive(Clone)]
#[doc(alias = "database")]
#[doc(alias = "collection")]
pub struct Database(pub(crate) Arc<DatabaseInner>);
impl std::ops::Deref for Database {
type Target = DatabaseInner;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Openable for Database {
fn open(config: Config) -> crate::Result<Self>
where
Self: Sized,
{
Self::open(config)
}
}
impl Database {
/// Opens a cross-keyspace snapshot.
///
/// # Caution
///
/// Note that for serializable semantics you need to use a transactional database instead.
#[must_use]
pub fn snapshot(&self) -> Snapshot {
Snapshot::new(self.supervisor.snapshot_tracker.open())
}
/// Creates a new database builder to create or open a database at `path`.
pub fn builder(path: impl AsRef<Path>) -> crate::DatabaseBuilder<Self> {
crate::DatabaseBuilder::new(path.as_ref())
}
/// Initializes a new atomic write batch.
///
/// Items may be written to multiple keyspaces, which
/// will be be updated atomically when the batch is committed.
///
/// # Examples
///
/// ```
/// # use fjall::{Database, KeyspaceCreateOptions};
/// #
/// # let folder = tempfile::tempdir()?;
/// # let db = Database::builder(folder).open()?;
/// # let tree = db.keyspace("default", KeyspaceCreateOptions::default)?;
/// let mut batch = db.batch();
///
/// assert_eq!(tree.len()?, 0);
/// batch.insert(&tree, "1", "abc");
/// batch.insert(&tree, "3", "abc");
/// batch.insert(&tree, "5", "abc");
///
/// assert_eq!(tree.len()?, 0);
///
/// batch.commit()?;
/// assert_eq!(tree.len()?, 3);
/// #
/// # Ok::<(), fjall::Error>(())
/// ```
#[must_use]
pub fn batch(&self) -> WriteBatch {
let mut batch = WriteBatch::new(self.clone());
if !self.config.manual_journal_persist {
batch = batch.durability(Some(PersistMode::Buffer));
}
batch
}
// TODO: refactor: accessor to stats(), so we don't have that many methods in DB
/// Returns the current write buffer size (active + sealed memtables).
///
/// # Experimental
///
/// This is a non-stable API currently.
#[must_use]
#[doc(hidden)]
pub fn write_buffer_size(&self) -> u64 {
self.supervisor.write_buffer_size.get()
}
/// Returns the number of queued memtable flush tasks.
///
/// # Experimental
///
/// This is a non-stable API currently.
#[doc(hidden)]
#[must_use]
pub fn outstanding_flushes(&self) -> usize {
self.supervisor.flush_manager.len()
}
/// Returns the time all compactions took until now.
///
/// # Experimental
///
/// This is a non-stable API currently.
#[doc(hidden)]
#[must_use]
pub fn time_compacting(&self) -> std::time::Duration {
let us = self
.stats
.time_compacting
.load(std::sync::atomic::Ordering::Relaxed);
std::time::Duration::from_micros(us)
}
/// Returns the number of active compactions currently running.
///
/// # Experimental
///
/// This is a non-stable API currently.
#[doc(hidden)]
#[must_use]
pub fn active_compactions(&self) -> usize {
self.stats
.active_compaction_count
.load(std::sync::atomic::Ordering::Relaxed)
}
/// Returns the number of completed compactions.
///
/// # Experimental
///
/// This is a non-stable API currently.
#[doc(hidden)]
#[must_use]
pub fn compactions_completed(&self) -> usize {
self.stats
.compactions_completed
.load(std::sync::atomic::Ordering::Relaxed)
}
/// Returns the number of journals on disk.
///
/// # Examples
///
/// ```
/// # use fjall::{Database, KeyspaceCreateOptions};
/// #
/// # let folder = tempfile::tempdir()?;
/// # let db = Database::builder(folder).open()?;
/// assert_eq!(1, db.journal_count());
/// #
/// # Ok::<(), fjall::Error>(())
/// ```
#[must_use]
pub fn journal_count(&self) -> usize {
self.supervisor
.journal_manager
.read()
.expect("lock is poisoned")
.journal_count()
}
/// Returns the disk space usage of the journal.
#[doc(hidden)]
pub fn journal_disk_space(&self) -> crate::Result<u64> {
Ok(self.supervisor.journal.get_writer()?.len()?
+ self
.supervisor
.journal_manager
.read()
.expect("lock is poisoned")
.disk_space_used())
}
/// Returns the disk space usage of the entire database.
///
/// # Examples
///
/// ```
/// # use fjall::{Database, KeyspaceCreateOptions};
/// #
/// # let folder = tempfile::tempdir()?;
/// # let db = Database::builder(folder).open()?;
/// # let _tree = db.keyspace("default", KeyspaceCreateOptions::default)?;
/// assert!(db.disk_space()? > 0);
/// #
/// # Ok::<(), fjall::Error>(())
/// ```
pub fn disk_space(&self) -> crate::Result<u64> {
let journal_size = self.journal_disk_space()?;
let keyspaces_size = self
.supervisor
.keyspaces
.read()
.expect("lock is poisoned")
.values()
.map(Keyspace::disk_space)
.sum::<u64>();
Ok(journal_size + keyspaces_size)
}
/// Flushes the active journal. The durability depends on the [`PersistMode`]
/// used.
///
/// Persisting only affects durability, NOT consistency! Even without flushing
/// data is crash-safe.
///
/// # Examples
///
/// ```
/// # use fjall::{PersistMode, Database, KeyspaceCreateOptions};
/// # let folder = tempfile::tempdir()?;
/// let db = Database::builder(folder).open()?;
/// let items = db.keyspace("my_items", KeyspaceCreateOptions::default)?;
///
/// items.insert("a", "hello")?;
///
/// db.persist(PersistMode::SyncAll)?;
/// #
/// # Ok::<_, fjall::Error>(())
/// ```
///
/// # Errors
///
/// Returns error, if an IO error occurred.
pub fn persist(&self, mode: PersistMode) -> crate::Result<()> {
if self.is_poisoned.is_poisoned() {
return Err(crate::Error::Poisoned);
}
if let Err(e) = self.supervisor.journal.persist(mode) {
self.is_poisoned.poison();
log::error!(
"flush failed, which is a FATAL, and possibly hardware-related, failure: {e:?}"
);
return Err(crate::Error::Poisoned);
}
Ok(())
}
#[doc(hidden)]
#[must_use]
pub fn cache_capacity(&self) -> u64 {
self.config.cache.capacity()
}
#[doc(hidden)]
#[must_use]
pub fn cache_size(&self) -> u64 {
self.config.cache.size()
}
/// Opens a database in the given directory.
///
/// # Errors
///
/// Returns error, if an IO error occurred.
pub fn open(config: Config) -> crate::Result<Self> {
log::debug!(
"cache capacity={}MiB",
config.cache.capacity() / 1_024 / 1_024,
);
let db = Self::create_or_recover(config)?;
// db.start_background_threads()?;
#[cfg(feature = "__internal_whitebox")]
crate::drop::increment_drop_counter();
Ok(db)
}
/// Same as [`Database::open`], but does not start background threads.
///
/// Needed to open a database without threads for testing.
///
/// Should not be user-facing.
#[doc(hidden)]
pub fn create_or_recover(config: Config) -> crate::Result<Self> {
if config.path.join(VERSION_MARKER).try_exists()? {
Self::recover(config)
} else {
Self::create_new(config)
}
}
/// Destroys the keyspace, removing all data associated with it.
///
/// The keyspace folder will not be deleted until all references to it are dropped,
/// so calling this is safe, even if the keyspace is still accessed in another thread.
///
/// # Errors
///
/// Will return `Err` if an IO error occurs.
#[expect(clippy::needless_pass_by_value)]
pub fn delete_keyspace(&self, handle: Keyspace) -> crate::Result<()> {
self.meta_keyspace.remove_keyspace(&handle.name)?;
handle
.is_deleted
.store(true, std::sync::atomic::Ordering::Release);
Ok(())
}
/// Creates or opens a keyspace.
///
/// If the keyspace does not yet exist, it will be created configured with `create_options`.
/// Otherwise simply a handle to the existing keyspace will be returned.
///
/// Keyspace names can be up to 255 characters long and can not be empty.
///
/// # Errors
///
/// Returns error, if an IO error occurred.
///
/// # Panics
///
/// Panics if the keyspace name is invalid.
pub fn keyspace(
&self,
name: &str,
create_options: impl FnOnce() -> KeyspaceCreateOptions,
) -> crate::Result<Keyspace> {
assert!(is_valid_keyspace_name(name));
let keyspaces = self.supervisor.keyspaces.write().expect("lock is poisoned");
Ok(if let Some(keyspace) = keyspaces.get(name) {
keyspace.clone()
} else {
let name: KeyspaceKey = name.into();
let keyspace_id = self.keyspace_id_counter.next();
let mut opts = create_options();
// Install compaction filter factory if needed
if let Some(f) = self
.config
.compaction_filter_factory_assigner
.as_ref()
.and_then(|f| f(&name))
{
opts = opts.with_compaction_filter_factory(f);
}
let handle = Keyspace::create_new(keyspace_id, self, name.clone(), opts)?;
self.meta_keyspace
.create_keyspace(keyspace_id, &name, handle.clone(), keyspaces)?;
#[cfg(feature = "__internal_whitebox")]
crate::drop::increment_drop_counter();
handle
})
}
/// Returns the number of keyspaces.
#[must_use]
pub fn keyspace_count(&self) -> usize {
self.supervisor
.keyspaces
.read()
.expect("lock is poisoned")
.len()
}
/// Gets a list of all keyspace names in the database.
#[must_use]
pub fn list_keyspace_names(&self) -> Vec<KeyspaceKey> {
self.supervisor
.keyspaces
.read()
.expect("lock is poisoned")
.keys()
.cloned()
.collect()
}
/// Returns `true` if the keyspace with the given name exists.
///
/// # Examples
///
/// ```
/// # use fjall::{Database, KeyspaceCreateOptions};
/// #
/// # let folder = tempfile::tempdir()?;
/// # let db = Database::builder(folder).open()?;
/// assert!(!db.keyspace_exists("default"));
/// db.keyspace("default", KeyspaceCreateOptions::default)?;
/// assert!(db.keyspace_exists("default"));
/// #
/// # Ok::<(), fjall::Error>(())
/// ```
#[must_use]
pub fn keyspace_exists(&self, name: &str) -> bool {
self.meta_keyspace.keyspace_exists(name)
}
/// Gets the would-be-next sequence number.
#[must_use]
#[doc(hidden)]
pub fn seqno(&self) -> crate::SeqNo {
self.supervisor.seqno.get()
}
/// Gets the currently visible sequence number.
#[must_use]
#[doc(hidden)]
pub fn visible_seqno(&self) -> crate::SeqNo {
self.supervisor.snapshot_tracker.get()
}
fn check_version<P: AsRef<Path>>(path: P) -> crate::Result<()> {
let bytes = std::fs::read(path.as_ref().join(VERSION_MARKER))?;
if let Some(version) = FormatVersion::parse_file_header(&bytes) {
if version == FormatVersion::V2 {
log::error!(
"It looks like you are trying to open a V2 database - the database needs a manual migration, a tool is available at https://github.com/fjall-rs/migrate-v2-v3."
);
}
if version as u8 > 4 {
log::error!(
"It looks like you are trying to open a database from the future. Are you a time traveller?"
);
}
if version != FormatVersion::V4 {
return Err(crate::Error::InvalidVersion(Some(version)));
}
} else {
return Err(crate::Error::InvalidVersion(None));
}
Ok(())
}
/// Recovers existing database from directory.
#[expect(clippy::too_many_lines)]
#[doc(hidden)]
pub fn recover(config: Config) -> crate::Result<Self> {
log::info!("Recovering database at {}", config.path.display());
// Check version
Self::check_version(&config.path)?;
let lock_file = LockedFileGuard::try_acquire(&config.path.join(LOCK_FILE))?;
// TODO:
// let recovery_mode = config.journal_recovery_mode;
// Reload active journal
let journal_recovery = Journal::recover(
&config.path,
config.journal_compression_type,
config.journal_compression_threshold,
)?;
log::debug!("journal recovery result: {journal_recovery:#?}");
let active_journal = Arc::new(journal_recovery.active);
active_journal.get_writer()?.persist(PersistMode::SyncAll)?;
let sealed_journals = journal_recovery.sealed;
let journal_manager = JournalManager::new();
let seqno = SequenceNumberCounter::default();
let visible_seqno = SequenceNumberCounter::default();
let meta_tree = lsm_tree::Config::new(
config.path.join(KEYSPACES_FOLDER).join("0"),
seqno.clone(),
visible_seqno.clone(),
)
.use_cache(config.cache.clone())
.use_descriptor_table(config.descriptor_table.clone())
.expect_point_read_hits(true)
.data_block_size_policy(crate::config::BlockSizePolicy::all(4_096))
.data_block_hash_ratio_policy(crate::config::HashRatioPolicy::all(8.0))
.data_block_compression_policy(crate::config::CompressionPolicy::disabled())
.data_block_restart_interval_policy(crate::config::RestartIntervalPolicy::all(1))
.index_block_compression_policy(crate::config::CompressionPolicy::disabled())
.filter_policy(crate::config::FilterPolicy::new([
lsm_tree::config::FilterPolicyEntry::Bloom(
lsm_tree::config::BloomConstructionPolicy::FalsePositiveRate(0.0001),
),
lsm_tree::config::FilterPolicyEntry::Bloom(
lsm_tree::config::BloomConstructionPolicy::FalsePositiveRate(0.01),
),
]))
.open()?;
let keyspaces = Arc::new(RwLock::default());
let meta_keyspace = MetaKeyspace::new(
meta_tree,
keyspaces.clone(),
seqno.clone(),
visible_seqno.clone(),
);
let supervisor = Supervisor::new(SupervisorInner {
db_config: config.clone(),
keyspaces,
flush_manager: FlushManager::new(),
write_buffer_size: WriteBufferManager::default(),
snapshot_tracker: SnapshotTracker::new(visible_seqno),
journal: active_journal,
journal_manager: Arc::new(RwLock::new(journal_manager)),
seqno,
});
let active_thread_counter = Arc::<AtomicUsize>::default();
let stats = Arc::<Stats>::default();
// Construct (empty) database, then fill back with keyspace data
let inner = DatabaseInner {
supervisor,
worker_pool: WorkerPool::prepare(),
keyspace_id_counter: SequenceNumberCounter::new(1),
meta_keyspace: meta_keyspace.clone(),
config,
stop_signal: lsm_tree::stop_signal::StopSignal::default(),
active_thread_counter,
is_poisoned: PoisonSignal::default(),
stats,
lock_file,
};
let db = Self(Arc::new(inner));
// Recover keyspaces
recover_keyspaces(&db, &meta_keyspace)?;
// Recover sealed memtables by walking through old journals
recover_sealed_memtables(
&db,
&sealed_journals
.into_iter()
.map(|(_, x)| x)
.collect::<Vec<_>>(),
)?;
{
#[expect(clippy::expect_used)]
let keyspaces = db.supervisor.keyspaces.read().expect("lock is poisoned");
// NOTE: If this triggers, the last sealed memtable
// was not correctly rotated
for keyspace in keyspaces.values() {
if keyspace.tree.active_memtable().size() > 0 {
log::error!(
"Active memtable is not empty after recovery for keyspace {:?} - recovery failed",
keyspace.name
);
return Err(crate::Error::Unrecoverable);
}
}
// NOTE: We only need to recover the active journal, if it actually existed before
// nothing to recover, if we just created it
if !journal_recovery.was_active_created {
log::trace!("Recovering active memtables from active journal");
let reader = db.supervisor.journal.get_reader()?;
for batch in reader {
let batch = batch?;
for item in batch.items {
let Some(keyspace_name) = db.meta_keyspace.resolve_id(item.keyspace_id)?
else {
continue;
};
let Some(keyspace) = keyspaces.get(&keyspace_name) else {
continue;
};
let tree = &keyspace.tree;
match item.value_type {
lsm_tree::ValueType::Value => {
tree.insert(item.key, item.value, batch.seqno);
}
lsm_tree::ValueType::Tombstone => {
tree.remove(item.key, batch.seqno);
}
lsm_tree::ValueType::WeakTombstone => {
tree.remove_weak(item.key, batch.seqno);
}
lsm_tree::ValueType::Indirection => {
unreachable!()
}
}
}
for keyspace_id in &batch.cleared_keyspaces {
let Some(keyspace_name) = db.meta_keyspace.resolve_id(*keyspace_id)? else {
continue;
};
let Some(keyspace) = keyspaces.get(&keyspace_name) else {
continue;
};
keyspace.tree.clear().ok();
}
}
for keyspace in keyspaces.values() {
let size = keyspace.tree.active_memtable().size();
log::trace!(
"Recovered active memtable of size {size}B for keyspace {:?} ({} items)",
keyspace.name,
keyspace.tree.active_memtable().len(),
);
// IMPORTANT: Add active memtable size to current write buffer size
db.supervisor.write_buffer_size.allocate(size);
// Recover seqno
let maybe_next_seqno = keyspace
.tree
.get_highest_seqno()
.map(|x| x + 1)
.unwrap_or_default();
db.supervisor.seqno.fetch_max(maybe_next_seqno);
log::debug!("Database seqno is now {}", db.supervisor.seqno.get());
}
}
}
db.supervisor
.snapshot_tracker
.set(db.supervisor.seqno.get());
db.supervisor.snapshot_tracker.gc();
for keyspace in db
.supervisor
.keyspaces
.read()
.expect("lock is poisoned")
.values()
{
if keyspace.tree.sealed_memtable_count() > 0 {
log::debug!(
"Queuing keyspace {:?} to get flushed because sealed memtables > 0",
keyspace.name(),
);
// IMPORTANT: Add task to flush manager, so it can be flushed
db.supervisor
.flush_manager
.enqueue(Arc::new(crate::flush::Task {
keyspace: keyspace.clone(),
}));
keyspace.worker_messager.send(WorkerMessage::Flush).ok();
} else if keyspace.tree.l0_run_count() > 0 {
log::debug!(
"Queuing keyspace {:?} to maybe get compacted because L0 runs > 0",
keyspace.name(),
);
keyspace
.worker_messager
.send(WorkerMessage::Compact(keyspace.clone()))
.ok();
}
}
db.worker_pool.start(
db.config.worker_threads,
&db.supervisor,
&db.stats,
&PoisonDart::new(db.is_poisoned.clone()),
&db.active_thread_counter,
)?;
log::trace!("Database recovery successful");
Ok(db)
}
#[doc(hidden)]
pub fn create_new(config: Config) -> crate::Result<Self> {
log::info!("Creating database at {}", config.path.display());
std::fs::create_dir_all(&config.path)?;
let lock_file = LockedFileGuard::create_new(&config.path.join(LOCK_FILE))?;
let journal_folder_path = &config.path;
let keyspaces_folder_path = config.path.join(KEYSPACES_FOLDER);
std::fs::create_dir_all(&keyspaces_folder_path)?;
let active_journal_path = journal_folder_path.join("0.jnl");
let journal = Journal::create_new(&active_journal_path)?.with_compression(
config.journal_compression_type,
config.journal_compression_threshold,
);
let journal = Arc::new(journal);
// NOTE: Lastly, fsync version marker, which contains the version
let mut marker = std::fs::File::create_new(config.path.join(VERSION_MARKER))?;
FormatVersion::V4.write_file_header(&mut marker)?;
marker.sync_all()?;
// IMPORTANT: fsync folders on Unix
fsync_directory(&keyspaces_folder_path)?;
fsync_directory(&config.path)?;
let seqno = SequenceNumberCounter::default();
let visible_seqno = SequenceNumberCounter::default();
let meta_tree = lsm_tree::Config::new(
config.path.join(KEYSPACES_FOLDER).join("0"),
seqno.clone(),
visible_seqno.clone(),
)
.use_cache(config.cache.clone())
.use_descriptor_table(config.descriptor_table.clone())
.expect_point_read_hits(true)
.data_block_size_policy(crate::config::BlockSizePolicy::all(4_096))
.data_block_hash_ratio_policy(crate::config::HashRatioPolicy::all(8.0))
.data_block_compression_policy(crate::config::CompressionPolicy::disabled())
.data_block_restart_interval_policy(crate::config::RestartIntervalPolicy::all(1))
.index_block_compression_policy(crate::config::CompressionPolicy::disabled())
.filter_policy(crate::config::FilterPolicy::new([
lsm_tree::config::FilterPolicyEntry::Bloom(
lsm_tree::config::BloomConstructionPolicy::FalsePositiveRate(0.0001),
),
lsm_tree::config::FilterPolicyEntry::Bloom(
lsm_tree::config::BloomConstructionPolicy::FalsePositiveRate(0.01),
),
]))
.open()?;
let keyspaces = Arc::new(RwLock::default());
let meta_keyspace = MetaKeyspace::new(
meta_tree,
keyspaces.clone(),
seqno.clone(),
visible_seqno.clone(),
);
let supervisor = Supervisor::new(SupervisorInner {
db_config: config.clone(),
keyspaces,
flush_manager: FlushManager::new(),
write_buffer_size: WriteBufferManager::default(),
snapshot_tracker: SnapshotTracker::new(visible_seqno),
journal,
journal_manager: Arc::new(RwLock::new(JournalManager::new())),
seqno,
});
let active_thread_counter = Arc::<AtomicUsize>::default();
let stats = Arc::<Stats>::default();
let inner = DatabaseInner {
supervisor,
worker_pool: WorkerPool::prepare(),
keyspace_id_counter: SequenceNumberCounter::new(1),
meta_keyspace,
config,
stop_signal: lsm_tree::stop_signal::StopSignal::default(),
active_thread_counter,
is_poisoned: PoisonSignal::default(),
stats,
lock_file,
};
let db = Self(Arc::new(inner));
db.worker_pool.start(
db.config.worker_threads,
&db.supervisor,
&db.stats,
&PoisonDart::new(db.is_poisoned.clone()),
&db.active_thread_counter,
)?;
Ok(db)
}
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use crate::path::absolute_path;
use lsm_tree::{Cache, CompressionType, DescriptorTable};
use std::{
path::{Path, PathBuf},
sync::Arc,
};
pub type CompactionFilterAssigner =
Arc<dyn Fn(&str) -> Option<Arc<dyn lsm_tree::compaction::filter::Factory>> + Send + Sync>;
/// Global database configuration
#[derive(Clone)]
pub struct Config {
/// Base path of database
pub(crate) path: PathBuf,
/// When true, the path will be deleted upon drop
pub(crate) clean_path_on_drop: bool,
#[doc(hidden)]
pub cache: Arc<Cache>,
/// Descriptor table that will be shared between keyspaces
pub(crate) descriptor_table: Option<Arc<DescriptorTable>>,
/// Max size of all journals in bytes
pub(crate) max_journaling_size_in_bytes: u64, // TODO: should be configurable during runtime: AtomicU64
/// Max size of all active memtables
///
/// This can be used to cap the memory usage if there are
/// many (possibly inactive) keyspaces.
pub(crate) max_write_buffer_size_in_bytes: Option<u64>, // TODO: should be configurable during runtime: AtomicU64
pub(crate) manual_journal_persist: bool,
/// Number of concurrent worker threads
pub(crate) worker_threads: usize,
pub(crate) journal_compression_type: CompressionType,
pub(crate) journal_compression_threshold: usize,
// pub(crate) journal_recovery_mode: RecoveryMode,
//
pub(crate) compaction_filter_factory_assigner: Option<CompactionFilterAssigner>,
}
const DEFAULT_CPU_CORES: usize = 4;
fn get_open_file_limit() -> usize {
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
return 900;
#[cfg(target_os = "windows")]
return 400;
#[cfg(target_os = "macos")]
return 150;
}
impl Config {
/// Creates a new configuration
pub fn new(path: &Path) -> Self {
let queried_cores = std::thread::available_parallelism().map(usize::from);
let worker_threads = queried_cores.unwrap_or(1).min(DEFAULT_CPU_CORES);
Self {
path: absolute_path(path),
clean_path_on_drop: false,
descriptor_table: Some(Arc::new(DescriptorTable::new(get_open_file_limit()))),
max_write_buffer_size_in_bytes: None,
max_journaling_size_in_bytes: /* 512 MiB */ 512 * 1_024 * 1_024,
worker_threads,
// journal_recovery_mode: RecoveryMode::default(),
manual_journal_persist: false,
#[cfg(not(feature = "lz4"))]
journal_compression_type: CompressionType::None,
#[cfg(feature = "lz4")]
journal_compression_type: CompressionType::Lz4,
journal_compression_threshold: 4_096,
cache: Arc::new(Cache::with_capacity_bytes(/* 32 MiB */ 32 * 1_024 * 1_024)),
compaction_filter_factory_assigner: None,
}
}
}
+244
View File
@@ -0,0 +1,244 @@
use crate::{Database, KeyspaceCreateOptions, KvSeparationOptions};
use test_log::test;
#[test_log::test]
fn clear_recover_sealed() -> crate::Result<()> {
use crate::{Database, KeyspaceCreateOptions};
let folder = tempfile::tempdir()?;
{
let db = Database::builder(&folder).open()?;
let tree = db.keyspace("default", KeyspaceCreateOptions::default)?;
assert!(tree.is_empty()?);
tree.insert("a", "a")?;
assert!(tree.contains_key("a")?);
tree.clear()?;
assert!(tree.is_empty()?);
tree.rotate_memtable_and_wait()?;
assert!(tree.is_empty()?);
db.supervisor.journal.get_writer()?.rotate()?;
tree.insert("b", "a")?;
assert!(tree.contains_key("b")?);
}
{
let db = Database::builder(&folder).open()?;
let tree = db.keyspace("default", KeyspaceCreateOptions::default)?;
assert!(!tree.contains_key("a")?);
assert!(tree.contains_key("b")?);
}
Ok(())
}
// TODO: investigate: flaky on macOS???
#[cfg(feature = "__internal_whitebox")]
#[test]
#[ignore = "restore"]
fn whitebox_db_drop() -> crate::Result<()> {
use crate::Database;
{
let folder = tempfile::tempdir()?;
assert_eq!(0, crate::drop::load_drop_counter());
let db = Database::builder(&folder).open()?;
assert_eq!(5, crate::drop::load_drop_counter());
drop(db);
assert_eq!(0, crate::drop::load_drop_counter());
}
{
let folder = tempfile::tempdir()?;
assert_eq!(0, crate::drop::load_drop_counter());
let db = Database::builder(&folder).open()?;
assert_eq!(5, crate::drop::load_drop_counter());
let tree = db.keyspace("default", Default::default)?;
assert_eq!(6, crate::drop::load_drop_counter());
drop(tree);
drop(db);
assert_eq!(0, crate::drop::load_drop_counter());
}
{
let folder = tempfile::tempdir()?;
assert_eq!(0, crate::drop::load_drop_counter());
let db = Database::builder(&folder).open()?;
assert_eq!(5, crate::drop::load_drop_counter());
let _tree = db.keyspace("default", Default::default)?;
assert_eq!(6, crate::drop::load_drop_counter());
let _tree2 = db.keyspace("different", Default::default)?;
assert_eq!(7, crate::drop::load_drop_counter());
}
assert_eq!(0, crate::drop::load_drop_counter());
Ok(())
}
#[cfg(feature = "__internal_whitebox")]
#[test]
#[ignore = "restore"]
fn whitebox_db_drop_2() -> crate::Result<()> {
use crate::{Database, KeyspaceCreateOptions};
let folder = tempfile::tempdir()?;
{
let db = Database::builder(&folder).open()?;
let tree = db.keyspace("tree", KeyspaceCreateOptions::default)?;
let tree2 = db.keyspace("tree1", KeyspaceCreateOptions::default)?;
tree.insert("a", "a")?;
tree2.insert("b", "b")?;
tree.rotate_memtable_and_wait()?;
}
assert_eq!(0, crate::drop::load_drop_counter());
Ok(())
}
#[test]
pub fn test_exotic_keyspace_names() -> crate::Result<()> {
let folder = tempfile::tempdir()?;
let db = Database::builder(&folder).open()?;
for name in ["hello$world", "hello#world", "hello.world", "hello_world"] {
let tree = db.keyspace(name, KeyspaceCreateOptions::default)?;
tree.insert("a", "a")?;
assert_eq!(1, tree.len()?);
}
Ok(())
}
#[test]
#[expect(clippy::unwrap_used)]
fn recover_sealed_smoke_test() -> crate::Result<()> {
let folder = tempfile::tempdir()?;
for i in 0_u128..3 {
let db = Database::create_or_recover(Database::builder(folder.path()).into_config())?;
let tree = db.keyspace("default", KeyspaceCreateOptions::default)?;
assert_eq!(i, tree.len()?.try_into().unwrap());
tree.insert(i.to_be_bytes(), i.to_be_bytes())?;
assert_eq!(i + 1, tree.len()?.try_into().unwrap());
tree.rotate_memtable_and_wait()?;
}
Ok(())
}
#[test]
#[expect(clippy::unwrap_used)]
fn recover_sealed_order() -> crate::Result<()> {
let folder = tempfile::tempdir()?;
{
let db = Database::builder(folder.path())
.worker_threads_unchecked(0)
.open()?;
let tree = db.keyspace("default", KeyspaceCreateOptions::default)?;
tree.insert("a", "a")?;
tree.rotate_memtable()?;
tree.insert("a", "b")?;
tree.rotate_memtable()?;
tree.insert("a", "c")?;
tree.rotate_memtable()?;
}
{
let db = Database::create_or_recover(Database::builder(folder.path()).into_config())?;
let tree = db.keyspace("default", KeyspaceCreateOptions::default)?;
assert_eq!(b"c", &*tree.get("a")?.unwrap());
}
Ok(())
}
#[test]
#[expect(clippy::unwrap_used)]
fn recover_sealed_blob() -> crate::Result<()> {
let folder = tempfile::tempdir()?;
for i in 0_u128..3 {
let db = Database::create_or_recover(Database::builder(folder.path()).into_config())?;
let tree = db.keyspace("default", || {
KeyspaceCreateOptions::default()
.max_memtable_size(1_000)
.with_kv_separation(Some(KvSeparationOptions::default()))
})?;
assert_eq!(i, tree.len()?.try_into().unwrap());
tree.insert(i.to_be_bytes(), i.to_be_bytes().repeat(1_024))?;
assert_eq!(i + 1, tree.len()?.try_into().unwrap());
tree.rotate_memtable_and_wait()?;
}
Ok(())
}
#[test]
#[expect(clippy::unwrap_used)]
fn recover_sealed_pair_1() -> crate::Result<()> {
let folder = tempfile::tempdir()?;
for i in 0_u128..3 {
let db = Database::create_or_recover(Database::builder(folder.path()).into_config())?;
let tree = db.keyspace("default", || {
KeyspaceCreateOptions::default().max_memtable_size(1_000)
})?;
let tree2 = db.keyspace("default2", || {
KeyspaceCreateOptions::default()
.max_memtable_size(1_000)
.with_kv_separation(Some(KvSeparationOptions::default()))
})?;
assert_eq!(i, tree.len()?.try_into().unwrap());
assert_eq!(i, tree2.len()?.try_into().unwrap());
let mut batch = db.batch();
batch.insert(&tree, i.to_be_bytes(), i.to_be_bytes());
batch.insert(&tree2, i.to_be_bytes(), i.to_be_bytes().repeat(1_024));
batch.commit()?;
assert_eq!(i + 1, tree.len()?.try_into().unwrap());
assert_eq!(i + 1, tree2.len()?.try_into().unwrap());
tree.rotate_memtable_and_wait()?;
}
Ok(())
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use std::sync::atomic::Ordering::Relaxed;
use std::sync::{atomic::AtomicUsize, OnceLock};
static DROP_COUNTER: OnceLock<AtomicUsize> = OnceLock::new();
pub fn increment_drop_counter() {
get_drop_counter().fetch_add(1, Relaxed);
}
pub fn decrement_drop_counter() {
get_drop_counter().fetch_sub(1, Relaxed);
}
pub fn get_drop_counter<'a>() -> &'a AtomicUsize {
DROP_COUNTER.get_or_init(AtomicUsize::default)
}
#[must_use]
pub fn load_drop_counter() -> usize {
get_drop_counter().load(Relaxed)
}
+83
View File
@@ -0,0 +1,83 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use crate::{
journal::error::RecoveryError as JournalRecoveryError, version::FormatVersion, CompressionType,
};
/// Errors that may occur in the storage engine
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
/// Error inside LSM-tree
Storage(lsm_tree::Error),
/// I/O error
Io(std::io::Error),
/// Error during journal recovery
JournalRecovery(JournalRecoveryError),
/// Invalid or unparsable data format version
InvalidVersion(Option<FormatVersion>),
/// Decompression failed
Decompress(CompressionType),
/// Invalid journal trailer detected
InvalidTrailer,
/// Invalid tag detected during decoding
InvalidTag((&'static str, u8)),
/// A previous flush / commit operation failed, indicating a hardware-related failure
///
/// Future writes will not be accepted as consistency cannot be guaranteed.
///
/// **At this point, it's best to let the application crash and try to recover.**
///
/// More info: <https://www.usenix.org/system/files/atc20-rebello.pdf>
Poisoned,
/// Keyspace is deleted
KeyspaceDeleted,
/// Database is locked.
Locked,
/// Database is unrecoverable, see logs for details
Unrecoverable,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "FjallError: {self:?}")
}
}
impl From<std::io::Error> for Error {
fn from(inner: std::io::Error) -> Self {
Self::Io(inner)
}
}
impl From<lsm_tree::Error> for Error {
fn from(inner: lsm_tree::Error) -> Self {
Self::Storage(inner)
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Storage(inner) => Some(inner),
Self::Io(inner) => Some(inner),
Self::JournalRecovery(inner) => Some(inner),
_ => None,
}
}
}
/// Result helper type
pub type Result<T> = std::result::Result<T, Error>;
+35
View File
@@ -0,0 +1,35 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use std::path::Path;
pub const MAGIC_BYTES: &[u8] = &[b'F', b'J', b'L', 3];
pub const KEYSPACES_FOLDER: &str = "keyspaces";
pub const LOCK_FILE: &str = "lock";
pub const VERSION_MARKER: &str = "version";
pub const LSM_CURRENT_VERSION_MARKER: &str = "current";
#[cfg(not(target_os = "windows"))]
pub fn fsync_directory<P: AsRef<Path>>(path: P) -> std::io::Result<()> {
let path = path.as_ref();
let file = std::fs::File::open(path).inspect_err(|e| {
log::error!("Failed to open directory at {}: {e:?}", path.display());
})?;
debug_assert!(file.metadata()?.is_dir());
file.sync_all().inspect_err(|e| {
log::error!("Failed to fsync directory at {}: {e:?}", path.display());
})
}
#[cfg(target_os = "windows")]
pub fn fsync_directory<P: AsRef<Path>>(_path: P) -> std::io::Result<()> {
// Cannot fsync directory on Windows
Ok(())
}
+44
View File
@@ -0,0 +1,44 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use crate::flush::Task;
use std::sync::Arc;
pub struct FlushManager {
sender: flume::Sender<Arc<Task>>,
receiver: flume::Receiver<Arc<Task>>,
}
impl FlushManager {
pub fn new() -> Self {
let (tx, rx) = flume::bounded(1_000);
Self {
sender: tx,
receiver: rx,
}
}
pub fn len(&self) -> usize {
self.receiver.len()
}
pub fn clear(&self) {
let _ = self.receiver.drain().count();
}
pub fn wait_for_empty(&self) {
while !self.receiver.is_empty() {
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
pub fn enqueue(&self, task: Arc<Task>) {
self.sender.send(task).ok();
}
pub fn dequeue(&self) -> Option<Arc<Task>> {
self.receiver.try_recv().ok()
}
}
+9
View File
@@ -0,0 +1,9 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
pub mod manager;
pub mod task;
pub mod worker;
pub use task::Task;
+15
View File
@@ -0,0 +1,15 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use crate::Keyspace;
pub struct Task {
pub(crate) keyspace: Keyspace,
}
impl std::fmt::Debug for Task {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "FlushTask({})", self.keyspace.name)
}
}
+42
View File
@@ -0,0 +1,42 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use crate::{
flush::Task, snapshot_tracker::SnapshotTracker, stats::Stats,
write_buffer_manager::WriteBufferManager,
};
use lsm_tree::AbstractTree;
/// Runs flush logic.
pub fn run(
task: &Task,
write_buffer_manager: &WriteBufferManager,
snapshot_tracker: &SnapshotTracker,
_stats: &Stats,
) -> crate::Result<()> {
log::debug!("Flushing keyspace {:?}", task.keyspace.name);
let gc_watermark = snapshot_tracker.get_seqno_safe_to_gc();
let flush_lock = task.keyspace.tree.get_flush_lock();
match task
.keyspace
.tree
.flush(&flush_lock, gc_watermark)
.inspect_err(|e| {
log::error!("Flush error: {e:?}");
})? {
Some(flushed_bytes) => {
write_buffer_manager.free(flushed_bytes);
log::debug!("Flush completed");
}
None => {
log::trace!("Flush did not return a table");
}
}
Ok(())
}
+153
View File
@@ -0,0 +1,153 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use lsm_tree::{Guard as _Guard, UserKey, UserValue};
/// Guard to access key-value pairs
pub struct Guard(pub(crate) lsm_tree::IterGuardImpl);
impl Guard {
// TODO: is_ok?
/// Accesses the key-value pair if the predicate returns `true`.
///
/// The predicate receives the key - if returning `false`, the value
/// may not be loaded if the tree is key-value separated.
///
/// # Examples
///
/// ```
/// # use fjall::{Database, KeyspaceCreateOptions};
/// #
/// # let folder = tempfile::tempdir()?;
/// # let db = Database::builder(folder).open()?;
/// # let tree = db.keyspace("default", KeyspaceCreateOptions::default)?;
/// tree.insert("abc", "my_value")?;
///
/// let (k,v) = tree.prefix("a")
/// .next()
/// .unwrap()
/// .into_inner_if(|key| key.starts_with(b"a"))?;
///
/// assert_eq!(b"abc", &*k);
/// assert_eq!(Some(b"my_value".as_slice()), v.as_deref());
/// #
/// # Ok::<(), fjall::Error>(())
/// ```
///
/// # Errors
///
/// Will return `Err` if an IO error occurs.
pub fn into_inner_if(
self,
pred: impl Fn(&crate::UserKey) -> bool,
) -> crate::Result<(UserKey, Option<UserValue>)> {
self.0.into_inner_if(pred).map_err(Into::into)
}
/// Returns the key-value tuple.
///
/// # Examples
///
/// ```
/// # use fjall::{Database, KeyspaceCreateOptions};
/// #
/// # let folder = tempfile::tempdir()?;
/// # let db = Database::builder(folder).open()?;
/// # let tree = db.keyspace("default", KeyspaceCreateOptions::default)?;
/// tree.insert("a", "my_value")?;
///
/// let (k,v) = tree.prefix("a")
/// .next()
/// .unwrap()
/// .into_inner()?;
///
/// assert_eq!(b"a", &*k);
/// assert_eq!(b"my_value", &*v);
/// #
/// # Ok::<(), fjall::Error>(())
/// ```
///
/// # Errors
///
/// Will return `Err` if an IO error occurs.
pub fn into_inner(self) -> crate::Result<crate::KvPair> {
self.0.into_inner().map_err(Into::into)
}
/// Returns the key.
///
/// # Examples
///
/// ```
/// # use fjall::{Database, KeyspaceCreateOptions};
/// #
/// # let folder = tempfile::tempdir()?;
/// # let db = Database::builder(folder).open()?;
/// # let tree = db.keyspace("default", KeyspaceCreateOptions::default)?;
/// tree.insert("a", "my_value")?;
///
/// let item = tree.prefix("a").next().unwrap().key()?;
/// assert_eq!(b"a", &*item);
/// #
/// # Ok::<(), fjall::Error>(())
/// ```
///
/// # Errors
///
/// Will return `Err` if an IO error occurs.
pub fn key(self) -> crate::Result<crate::UserKey> {
self.0.key().map_err(Into::into)
}
/// Returns the value size.
///
/// # Examples
///
/// ```
/// # use fjall::{Database, KeyspaceCreateOptions};
/// #
/// # let folder = tempfile::tempdir()?;
/// # let db = Database::builder(folder).open()?;
/// # let tree = db.keyspace("default", KeyspaceCreateOptions::default)?;
/// tree.insert("a", "my_value")?;
///
/// let item = tree.prefix("a").next().unwrap().size()?;
/// assert_eq!(8, item);
/// #
/// # Ok::<(), fjall::Error>(())
/// ```
///
/// # Errors
///
/// Will return `Err` if an IO error occurs.
pub fn size(self) -> crate::Result<u32> {
self.0.size().map_err(Into::into)
}
/// Returns the value.
///
/// # Examples
///
/// ```
/// # use fjall::{Database, KeyspaceCreateOptions};
/// #
/// # let folder = tempfile::tempdir()?;
/// # let db = Database::builder(folder).open()?;
/// # let tree = db.keyspace("default", KeyspaceCreateOptions::default)?;
/// tree.insert("a", "my_value")?;
///
/// let item = tree.prefix("a").next().unwrap().value()?;
/// assert_eq!(b"my_value", &*item);
/// #
/// # Ok::<(), fjall::Error>(())
/// ```
///
/// # Errors
///
/// Will return `Err` if an IO error occurs.
pub fn value(self) -> crate::Result<crate::UserValue> {
self.0.value().map_err(Into::into)
}
}
+100
View File
@@ -0,0 +1,100 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use crate::{worker_pool::WorkerMessage, Keyspace};
use lsm_tree::{AnyIngestion, UserKey, UserValue};
pub struct Ingestion<'a> {
keyspace: &'a Keyspace,
inner: AnyIngestion<'a>,
}
impl<'a> Ingestion<'a> {
pub fn new(keyspace: &'a Keyspace) -> crate::Result<Self> {
let inner = keyspace.tree.ingestion()?;
Ok(Self { keyspace, inner })
}
pub fn write<K: Into<UserKey>, V: Into<UserValue>>(
&mut self,
key: K,
value: V,
) -> crate::Result<()> {
self.inner.write(key, value).map_err(Into::into)
}
#[doc(hidden)]
pub fn write_prevalidated<K: Into<UserKey>, V: Into<UserValue>>(
&mut self,
key: K,
value: V,
) -> crate::Result<()> {
self.inner
.write_prevalidated(key, value)
.map_err(Into::into)
}
pub fn write_tombstone<K: Into<UserKey>>(&mut self, key: K) -> crate::Result<()> {
self.inner.write_tombstone(key).map_err(Into::into)
}
#[doc(hidden)]
pub fn write_weak_tombstone<K: Into<UserKey>>(&mut self, key: K) -> crate::Result<()> {
self.inner.write_weak_tombstone(key).map_err(Into::into)
}
#[doc(hidden)]
pub fn write_prevalidated_weak_tombstone<K: Into<UserKey>>(
&mut self,
key: K,
) -> crate::Result<()> {
self.inner
.write_prevalidated_weak_tombstone(key)
.map_err(Into::into)
}
pub fn finish(self) -> crate::Result<()> {
// NOTE: We hold to avoid a race condition with concurrent writes:
//
// write ingest
// lock journal
// |
// next seqno=1
// |
// --------------finish
// flush
// seqno=2
// register
// |
// -----------------
// |
// insert seqno=1
let _journal_lock = self.keyspace.supervisor.journal.get_writer();
self.finish_inner()
}
/// Finishes the ingestion without taking the global journal writer lock.
///
/// The caller must ensure that no journaled writes can run concurrently in
/// the same database. Exclusive ingestions into independent keyspaces may
/// still finish concurrently.
pub fn finish_exclusive(self) -> crate::Result<()> {
self.finish_inner()
}
fn finish_inner(self) -> crate::Result<()> {
self.inner
.finish()
.inspect(|()| {
self.keyspace
.worker_messager
.try_send(WorkerMessage::Compact(self.keyspace.clone()))
.ok();
self.keyspace.supervisor.snapshot_tracker.gc();
})
.map_err(Into::into)
}
}
+40
View File
@@ -0,0 +1,40 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use crate::{snapshot_nonce::SnapshotNonce, Guard};
type InnerIter = Box<dyn DoubleEndedIterator<Item = lsm_tree::IterGuardImpl> + Send + 'static>;
/// A wrapper around iterators that keep a snapshot alive
//
// We need to hold the snapshot nonce so the GC watermark does not
// move past this snapshot nonce, removing data that may still be read.
//
// Additionally, this struct also maps lsm-tree's Guards to "our" Guards.
pub struct Iter {
iter: InnerIter,
#[expect(unused)]
nonce: SnapshotNonce,
}
impl Iter {
pub(crate) fn new(nonce: SnapshotNonce, iter: InnerIter) -> Self {
Self { iter, nonce }
}
}
impl Iterator for Iter {
type Item = crate::Guard;
fn next(&mut self) -> Option<Self::Item> {
self.iter.next().map(Guard)
}
}
impl DoubleEndedIterator for Iter {
fn next_back(&mut self) -> Option<Self::Item> {
self.iter.next_back().map(Guard)
}
}
+216
View File
@@ -0,0 +1,216 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use super::reader::JournalReader;
use crate::{journal::entry::Entry, keyspace::InternalKeyspaceId, JournalRecoveryError};
use lsm_tree::{SeqNo, UserKey, UserValue, ValueType};
use std::{fs::OpenOptions, hash::Hasher};
#[derive(Debug)]
pub struct ReadBatchItem {
pub keyspace_id: InternalKeyspaceId,
pub key: UserKey,
pub value: UserValue,
pub value_type: ValueType,
}
#[derive(Debug)]
pub struct Batch {
pub(crate) seqno: SeqNo,
pub(crate) items: Vec<ReadBatchItem>,
pub(crate) cleared_keyspaces: Vec<InternalKeyspaceId>,
}
#[expect(clippy::module_name_repetitions)]
pub struct JournalBatchReader {
reader: JournalReader,
items: Vec<ReadBatchItem>,
cleared_keyspaces: Vec<InternalKeyspaceId>,
is_in_batch: bool,
batch_counter: u32,
batch_seqno: SeqNo,
last_valid_pos: u64,
checksum_builder: xxhash_rust::xxh3::Xxh3,
}
impl JournalBatchReader {
pub fn new(reader: JournalReader) -> Self {
Self {
reader,
items: Vec::with_capacity(10),
cleared_keyspaces: Vec::new(),
checksum_builder: xxhash_rust::xxh3::Xxh3::new(),
is_in_batch: false,
batch_seqno: 0,
last_valid_pos: 0,
batch_counter: 0,
}
}
// TODO: reallocate space
fn truncate_to(&self, last_valid_pos: u64) -> crate::Result<()> {
log::trace!("Truncating journal to {last_valid_pos}");
// TODO: on windows, reading file probably needs to be closed first...?
let file = OpenOptions::new().write(true).open(&self.reader.path)?;
file.set_len(last_valid_pos)?;
file.sync_all()?;
Ok(())
}
fn on_close(&self) -> crate::Result<()> {
if self.is_in_batch {
log::debug!("Invalid batch: missing terminator, but last batch, so probably incomplete, discarding to keep atomicity");
// Discard batch
self.truncate_to(self.last_valid_pos)?;
}
Ok(())
}
}
impl Iterator for JournalBatchReader {
type Item = crate::Result<Batch>;
#[expect(clippy::too_many_lines)]
fn next(&mut self) -> Option<Self::Item> {
use crate::Error::JournalRecovery;
loop {
let Some(item) = self.reader.next() else {
fail_iter!(self.on_close());
return None;
};
let item = fail_iter!(item);
let journal_file_pos = self.reader.last_valid_pos;
match item {
Entry::Start { item_count, seqno } => {
if self.is_in_batch {
log::debug!("Invalid batch: found batch start inside batch");
// Discard batch
fail_iter!(self.truncate_to(self.last_valid_pos));
return None;
}
self.is_in_batch = true;
self.batch_counter = item_count;
self.batch_seqno = seqno;
}
Entry::End(expected_checksum) => {
if self.batch_counter > 0 {
log::error!("Invalid batch: insufficient length");
return Some(Err(JournalRecovery(
JournalRecoveryError::InsufficientLength,
)));
}
if !self.is_in_batch {
log::error!("Invalid batch: found end marker without start marker");
// Discard batch
fail_iter!(self.truncate_to(self.last_valid_pos));
return None;
}
let got_checksum = self.checksum_builder.finish();
self.checksum_builder = xxhash_rust::xxh3::Xxh3::new();
if got_checksum != expected_checksum {
log::error!("Invalid batch: checksum check failed, expected: {expected_checksum}, got: {got_checksum}");
return Some(Err(JournalRecovery(JournalRecoveryError::ChecksumMismatch)));
}
// Reset all variables
self.is_in_batch = false;
self.batch_counter = 0;
self.last_valid_pos = journal_file_pos;
let items = std::mem::take(&mut self.items);
let cleared_keyspaces = std::mem::take(&mut self.cleared_keyspaces);
return Some(Ok(Batch {
seqno: self.batch_seqno,
items,
cleared_keyspaces,
}));
}
Entry::Item {
keyspace_id,
key,
value,
value_type,
compression,
} => {
let item = Entry::Item {
keyspace_id,
key: key.clone(),
value: value.clone(),
value_type,
compression,
};
let mut bytes = Vec::with_capacity(100);
fail_iter!(item.encode_into(&mut bytes));
self.checksum_builder.update(&bytes);
if !self.is_in_batch {
log::debug!("Invalid batch: found end marker without start marker");
// Discard batch
fail_iter!(self.truncate_to(self.last_valid_pos));
return None;
}
if self.batch_counter == 0 {
log::error!("Invalid batch: Expected end marker (too many items in batch)");
return Some(Err(JournalRecovery(JournalRecoveryError::TooManyItems)));
}
self.batch_counter -= 1;
self.items.push(ReadBatchItem {
keyspace_id,
key,
value,
value_type,
});
}
Entry::Clear { keyspace_id } => {
let entry = Entry::Clear { keyspace_id };
let mut bytes = Vec::with_capacity(16);
fail_iter!(entry.encode_into(&mut bytes));
self.checksum_builder.update(&bytes);
if !self.is_in_batch {
log::debug!("Invalid batch: found clear marker without start marker");
// Discard batch
fail_iter!(self.truncate_to(self.last_valid_pos));
return None;
}
if self.batch_counter == 0 {
log::error!("Invalid batch: Expected end marker (too many items in batch)");
return Some(Err(JournalRecovery(JournalRecoveryError::TooManyItems)));
}
self.batch_counter -= 1;
self.cleared_keyspaces.push(keyspace_id);
}
}
}
}
}
+306
View File
@@ -0,0 +1,306 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use crate::{file::MAGIC_BYTES, keyspace::InternalKeyspaceId, Slice};
use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
use lsm_tree::{
coding::{Decode, Encode},
CompressionType, SeqNo, UserKey, UserValue, ValueType,
};
use std::io::{Read, Write};
/// Journal entry. Every batch is composed as a Start, followed by N items, followed by an End.
///
/// - The start entry contains the numbers of items. If the numbers of items following doesn't match, the batch is broken.
///
/// - The end entry contains a checksum value. If the checksum of the items doesn't match that, the batch is broken.
///
/// - The end entry terminates each batch with the magic string: [`TRAILER_MAGIC`].
///
/// - If a start entry is detected, while inside a batch, the batch is broken.
#[derive(Debug, Eq, PartialEq)]
pub enum Entry {
Start {
item_count: u32,
seqno: SeqNo,
},
Item {
keyspace_id: InternalKeyspaceId,
key: UserKey,
value: UserValue,
value_type: ValueType,
compression: CompressionType,
},
End(u64),
Clear {
keyspace_id: InternalKeyspaceId,
},
}
pub fn serialize_marker_item<W: Write>(
writer: &mut W,
keyspace_id: InternalKeyspaceId,
key: &[u8],
value: &[u8],
value_type: ValueType,
compression: CompressionType,
) -> Result<(), lsm_tree::Error> {
writer.write_u8(Tag::Item.into())?;
writer.write_u8(u8::from(value_type))?;
compression.encode_into(writer)?;
let compressed_value = match compression {
CompressionType::None => std::borrow::Cow::Borrowed(value),
#[cfg(feature = "lz4")]
CompressionType::Lz4 => {
let compressed = lz4_flex::compress(value);
std::borrow::Cow::Owned(compressed)
}
};
// NOTE: Truncation is okay and actually needed
writer.write_u64::<LittleEndian>(keyspace_id)?;
// NOTE: Truncation is okay and actually needed
#[expect(clippy::cast_possible_truncation)]
writer.write_u16::<LittleEndian>(key.len() as u16)?;
// NOTE: Truncation is okay and actually needed
#[expect(clippy::cast_possible_truncation)]
writer.write_u32::<LittleEndian>(value.len() as u32)?;
// NOTE: Truncation is okay and actually needed
#[expect(clippy::cast_possible_truncation)]
writer.write_u32::<LittleEndian>(compressed_value.len() as u32)?;
writer.write_all(key)?;
writer.write_all(&compressed_value)?;
Ok(())
}
pub enum Tag {
Start = 1,
Item = 2,
End = 3,
Clear = 4,
}
impl TryFrom<u8> for Tag {
type Error = crate::Error;
fn try_from(value: u8) -> Result<Self, Self::Error> {
use Tag::{Clear, End, Item, Start};
match value {
1 => Ok(Start),
2 => Ok(Item),
3 => Ok(End),
4 => Ok(Clear),
_ => Err(crate::Error::InvalidTag(("JournalMarkerTag", value))),
}
}
}
impl From<Tag> for u8 {
fn from(val: Tag) -> Self {
val as Self
}
}
impl Entry {
#[cfg(test)]
pub fn encode_into_vec(&self) -> Vec<u8> {
let mut buf = Vec::new();
self.encode_into(&mut buf).expect("should encode");
buf
}
pub(crate) fn encode_into<W: Write>(&self, writer: &mut W) -> Result<(), crate::Error> {
use Entry::{Clear, End, Item, Start};
match self {
Start { item_count, seqno } => {
writer.write_u8(Tag::Start.into())?;
writer.write_u32::<LittleEndian>(*item_count)?;
writer.write_u64::<LittleEndian>(*seqno)?;
}
Item {
keyspace_id,
key,
value,
value_type,
compression,
} => {
serialize_marker_item(writer, *keyspace_id, key, value, *value_type, *compression)?;
}
End(val) => {
writer.write_u8(Tag::End.into())?;
writer.write_u64::<LittleEndian>(*val)?;
// NOTE: Write some fixed trailer bytes so we know the end marker is fully written
// Otherwise we couldn't know if the checksum value is maybe mangled
// (only partially written, with the rest being padding zeroes)
writer.write_all(MAGIC_BYTES)?;
}
Clear { keyspace_id } => {
writer.write_u8(Tag::Clear.into())?;
writer.write_u64::<LittleEndian>(*keyspace_id)?;
}
}
Ok(())
}
pub(crate) fn decode_from<R: Read>(reader: &mut R) -> Result<Self, crate::Error> {
match reader.read_u8()?.try_into()? {
Tag::Start => {
let item_count = reader.read_u32::<LittleEndian>()?;
let seqno = reader.read_u64::<LittleEndian>()?;
Ok(Self::Start { item_count, seqno })
}
Tag::Item => {
let value_type = reader.read_u8()?;
let value_type = value_type
.try_into()
.map_err(|()| lsm_tree::Error::InvalidTag(("ValueType", value_type)))?;
let compression = CompressionType::decode_from(reader)?;
// Read keyspace ID
let keyspace_id = reader.read_u64::<LittleEndian>()?;
// Read key len
let key_len = reader.read_u16::<LittleEndian>()?;
// Read real value size
let value_len = reader.read_u32::<LittleEndian>()?;
// Read on-disk value size
let on_disk_value_len = reader.read_u32::<LittleEndian>()?;
let key = Slice::from_reader(reader, usize::from(key_len))?;
let value = match compression {
CompressionType::None => {
debug_assert_eq!(value_len, on_disk_value_len);
Slice::from_reader(reader, on_disk_value_len as usize)?
}
#[cfg(feature = "lz4")]
CompressionType::Lz4 => {
let compressed_value =
Slice::from_reader(reader, on_disk_value_len as usize)?;
#[warn(unsafe_code)]
let mut value = unsafe { Slice::builder_unzeroed(value_len as usize) };
let size = lz4_flex::decompress_into(&compressed_value, &mut value)
.map_err(|e| {
log::error!("LZ4 decompression failed: {e}");
crate::Error::Decompress(CompressionType::Lz4)
})?;
if size != value.len() {
log::error!("Decompressed size does not match expected value size");
return Err(crate::Error::Decompress(CompressionType::Lz4));
}
Slice::from(value.freeze())
}
};
Ok(Self::Item {
keyspace_id,
key,
value,
value_type,
compression,
})
}
Tag::End => {
let checksum = reader.read_u64::<LittleEndian>()?;
// Check trailer
let mut magic = [0u8; MAGIC_BYTES.len()];
reader.read_exact(&mut magic)?;
if magic != MAGIC_BYTES {
return Err(crate::Error::InvalidTrailer);
}
Ok(Self::End(checksum))
}
Tag::Clear => {
let keyspace_id = reader.read_u64::<LittleEndian>()?;
Ok(Self::Clear { keyspace_id })
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use test_log::test;
#[test]
fn test_serialize_and_deserialize_success() -> crate::Result<()> {
let item = Entry::Item {
keyspace_id: 0,
key: vec![1, 2, 3].into(),
value: vec![].into(),
value_type: ValueType::Value,
compression: CompressionType::None,
};
let serialized_data = item.encode_into_vec();
let mut reader = &serialized_data[..];
let deserialized_item = Entry::decode_from(&mut reader)?;
assert_eq!(item, deserialized_item);
Ok(())
}
#[test]
fn test_invalid_deserialize() {
let invalid_data = [Tag::Start as u8; 1]; // Should be followed by a u32
// Try to deserialize with invalid data
let mut reader = &invalid_data[..];
let result = Entry::decode_from(&mut reader);
match result {
Ok(_) => panic!("should error"),
Err(error) => match error {
crate::Error::Io(e) => match e.kind() {
std::io::ErrorKind::UnexpectedEof => {}
_ => panic!("should throw UnexpectedEof"),
},
_ => panic!("should throw UnexpectedEof"),
},
}
}
#[test]
fn test_invalid_tag() {
let invalid_data = [5u8; 1]; // Invalid tag
// Try to deserialize with invalid data
let mut reader = &invalid_data[..];
let result = Entry::decode_from(&mut reader);
match result {
Ok(_) => panic!("should error"),
Err(error) => match error {
crate::Error::InvalidTag(("JournalMarkerTag", 5)) => {}
_ => panic!("should throw InvalidTag"),
},
}
}
}
+30
View File
@@ -0,0 +1,30 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
/// Errors that can occur during journal recovery
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[expect(clippy::module_name_repetitions)]
pub enum RecoveryError {
/// Batch had less items than expected, so it's incomplete
InsufficientLength,
/* /// Batch was not terminated, so it's possibly incomplete
MissingTerminator, */
/// Too many items in batch
TooManyItems,
/// The checksum value does not match the expected value
ChecksumMismatch,
/// An unparseable journal file name was encountered
InvalidFileName,
}
impl std::fmt::Display for RecoveryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "RecoveryError({self:?})")
}
}
impl std::error::Error for RecoveryError {}
+186
View File
@@ -0,0 +1,186 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use super::writer::Writer;
use crate::Keyspace;
use lsm_tree::{AbstractTree, SeqNo};
use std::{path::PathBuf, sync::MutexGuard};
/// Stores the highest seqno of a keyspace found in a journal.
#[derive(Clone)]
pub struct EvictionWatermark {
pub(crate) keyspace: Keyspace,
pub(crate) lsn: SeqNo,
}
impl std::fmt::Debug for EvictionWatermark {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}", self.keyspace.name, self.lsn)
}
}
pub struct Item {
pub(crate) path: PathBuf,
pub(crate) size_in_bytes: u64,
pub(crate) watermarks: Vec<EvictionWatermark>,
}
impl std::fmt::Debug for Item {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"JournalManagerItem {:?} => {:#?}",
self.path, self.watermarks
)
}
}
/// The [`JournalManager`] keeps track of sealed journals that are being flushed.
///
/// Each journal may contain items of different keyspaces.
#[expect(clippy::module_name_repetitions)]
#[derive(Debug)]
pub struct JournalManager {
items: Vec<Item>,
disk_space_in_bytes: u64,
}
impl Drop for JournalManager {
fn drop(&mut self) {
log::trace!("Dropping journal manager");
#[cfg(feature = "__internal_whitebox")]
crate::drop::decrement_drop_counter();
}
}
impl JournalManager {
pub(crate) fn new() -> Self {
#[cfg(feature = "__internal_whitebox")]
crate::drop::increment_drop_counter();
Self {
items: Vec::with_capacity(10),
disk_space_in_bytes: 0,
}
}
pub(crate) fn clear(&mut self) {
self.items.clear();
}
pub(crate) fn enqueue(&mut self, item: Item) {
self.disk_space_in_bytes = self.disk_space_in_bytes.saturating_add(item.size_in_bytes);
self.items.push(item);
}
/// Returns the number of journals
pub(crate) fn journal_count(&self) -> usize {
// NOTE: + 1 = active journal
self.sealed_journal_count() + 1
}
/// Returns the number of sealed journals
pub(crate) fn sealed_journal_count(&self) -> usize {
self.items.len()
}
/// Returns the number of bytes used on disk by journals
pub(crate) fn disk_space_used(&self) -> u64 {
self.disk_space_in_bytes
}
/// Gets keyspaces to be flushed so that the oldest journal can be safely evicted
pub(crate) fn get_keyspaces_to_flush_for_oldest_journal_eviction(&self) -> Vec<Keyspace> {
let mut items = vec![];
if let Some(item) = self.items.first() {
for item in &item.watermarks {
let Some(partition_seqno) = item.keyspace.tree.get_highest_persisted_seqno() else {
items.push(item.keyspace.clone());
continue;
};
if partition_seqno < item.lsn {
items.push(item.keyspace.clone());
}
}
}
items
}
/// Performs maintenance, maybe deleting some old journals
pub(crate) fn maintenance(&mut self) -> crate::Result<()> {
log::debug!("Running journal maintenance");
loop {
let Some(item) = self.items.first() else {
return Ok(());
};
// TODO: unit test: check deleted keyspace does not prevent journal eviction
for item in &item.watermarks {
// Only check keyspace seqno if not deleted
if !item
.keyspace
.is_deleted
.load(std::sync::atomic::Ordering::Acquire)
{
let Some(keyspace_seqno) = item.keyspace.tree.get_highest_persisted_seqno()
else {
return Ok(());
};
if keyspace_seqno < item.lsn {
log::trace!(
"Keyspace {:?} not flushed enough to evict journal",
item.keyspace.name,
);
return Ok(());
}
}
}
// NOTE: Once the LSN of *every* keyspace's tables [1] is higher than the journal's stored keyspace seqno,
// it can be deleted from disk, as we know the entire journal has been flushed to tables [2].
//
// [1] We cannot use the keyspace's max seqno, because the memtable will get writes, which increase the seqno.
// We *need* to check the tables specifically, they are the source of truth for flushed data.
//
// [2] Checking the seqno is safe because the queues inside the flush manager are FIFO.
//
// IMPORTANT: On recovery, the journals need to be flushed from oldest to newest.
log::trace!("Removing fully flushed journal at {}", item.path.display());
std::fs::remove_file(&item.path).inspect_err(|e| {
log::error!(
"Failed to clean up stale journal file at {}: {e:?}",
item.path.display(),
);
})?;
self.disk_space_in_bytes = self.disk_space_in_bytes.saturating_sub(item.size_in_bytes);
self.items.remove(0);
}
}
pub(crate) fn rotate_journal(
&mut self,
journal_writer: &mut MutexGuard<Writer>,
watermarks: Vec<EvictionWatermark>,
) -> crate::Result<()> {
let journal_size = journal_writer.len()?;
let (sealed_path, _) = journal_writer.rotate()?;
self.enqueue(Item {
path: sealed_path,
watermarks,
size_in_bytes: journal_size,
});
Ok(())
}
}
+134
View File
@@ -0,0 +1,134 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
pub mod batch_reader;
pub mod entry;
pub mod error;
pub mod manager;
pub mod reader;
mod recovery;
pub mod writer;
#[cfg(test)]
mod test;
use self::writer::PersistMode;
use crate::file::fsync_directory;
use batch_reader::JournalBatchReader;
use lsm_tree::CompressionType;
use reader::JournalReader;
use recovery::{recover_journals, RecoveryResult};
use std::{
path::{Path, PathBuf},
sync::{Mutex, MutexGuard},
};
use writer::Writer;
pub struct Journal {
writer: Mutex<Writer>,
}
impl std::fmt::Debug for Journal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
self.path()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| String::from("<failed to read path>"))
)
}
}
impl Drop for Journal {
fn drop(&mut self) {
log::trace!("Dropping journal, trying to flush");
match self.persist(PersistMode::SyncAll) {
Ok(()) => {
log::trace!("Flushed journal successfully");
}
Err(e) => {
log::error!("Flush error on drop: {e:?}");
}
}
#[cfg(feature = "__internal_whitebox")]
crate::drop::decrement_drop_counter();
}
}
impl Journal {
pub fn with_compression(self, comp: CompressionType, threshold: usize) -> Self {
{
let mut writer = self.writer.lock().expect("lock is poisoned");
writer.set_compression(comp, threshold);
}
self
}
fn from_file<P: AsRef<Path>>(path: P) -> crate::Result<Self> {
Ok(Self {
writer: Mutex::new(Writer::from_file(path)?),
})
}
pub fn create_new<P: AsRef<Path>>(path: P) -> crate::Result<Self> {
let path = path.as_ref();
log::trace!("Creating new journal at {}", path.display());
let folder = path.parent().expect("parent should exist");
std::fs::create_dir_all(folder).inspect_err(|e| {
log::error!(
"Failed to create journal folder at {}: {e:?}",
path.display(),
);
})?;
let writer = Writer::create_new(path)?;
// IMPORTANT: fsync folder on Unix
fsync_directory(folder)?;
#[cfg(feature = "__internal_whitebox")]
crate::drop::increment_drop_counter();
Ok(Self {
writer: Mutex::new(writer),
})
}
/// Hands out write access to the journal.
///
/// # Errors
///
/// Returns an error if the journal writer is poisoned.
pub(crate) fn get_writer(&self) -> crate::Result<MutexGuard<'_, Writer>> {
self.writer.lock().map_err(|_| crate::Error::Poisoned)
}
pub fn path(&self) -> crate::Result<PathBuf> {
Ok(self.get_writer()?.path.clone())
}
pub fn get_reader(&self) -> crate::Result<JournalBatchReader> {
let raw_reader = JournalReader::new(self.path()?)?;
Ok(JournalBatchReader::new(raw_reader))
}
/// Persists the journal.
pub fn persist(&self, mode: PersistMode) -> crate::Result<()> {
let mut journal_writer = self.get_writer()?;
journal_writer.persist(mode).map_err(Into::into)
}
pub fn recover<P: AsRef<Path>>(
path: P,
compression: CompressionType,
compression_threshold: usize,
) -> crate::Result<RecoveryResult> {
recover_journals(path, compression, compression_threshold)
}
}
+78
View File
@@ -0,0 +1,78 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use super::entry::Entry;
use std::{
fs::{File, OpenOptions},
io::{BufReader, Seek},
path::{Path, PathBuf},
};
/// Reads and emits through the entries in a journal file, but doesn't
/// check the validity of batches
///
/// Will truncate the file to the last valid position to prevent corrupt
/// bytes at the end of the file, which would jeopardize future writes into the file.
#[expect(clippy::module_name_repetitions)]
pub struct JournalReader {
pub(crate) path: PathBuf,
pub(crate) reader: BufReader<File>,
pub(crate) last_valid_pos: u64,
}
impl JournalReader {
pub fn new<P: AsRef<Path>>(path: P) -> crate::Result<Self> {
let file = OpenOptions::new().read(true).write(true).open(&path)?;
Ok(Self {
path: path.as_ref().into(),
reader: BufReader::new(file),
last_valid_pos: 0,
})
}
fn truncate_file(&mut self, pos: u64) -> crate::Result<()> {
log::debug!("truncating journal to {pos}");
self.reader.get_mut().set_len(pos)?;
self.reader.get_mut().sync_all()?;
Ok(())
}
fn maybe_truncate_file_to_last_valid_pos(&mut self) -> crate::Result<()> {
let stream_pos = self.reader.stream_position()?;
if stream_pos > self.last_valid_pos {
self.truncate_file(self.last_valid_pos)?;
}
Ok(())
}
}
impl Iterator for JournalReader {
type Item = crate::Result<Entry>;
fn next(&mut self) -> Option<Self::Item> {
match Entry::decode_from(&mut self.reader) {
Ok(item) => {
self.last_valid_pos = fail_iter!(self.reader.stream_position());
Some(Ok(item))
}
Err(e) => {
if let crate::Error::Io(e) = e {
match e.kind() {
std::io::ErrorKind::UnexpectedEof | std::io::ErrorKind::Other => {
fail_iter!(self.maybe_truncate_file_to_last_valid_pos());
None
}
_ => Some(Err(crate::Error::Io(e))),
}
} else {
fail_iter!(self.maybe_truncate_file_to_last_valid_pos());
None
}
}
}
}
}
+91
View File
@@ -0,0 +1,91 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use super::Journal;
use lsm_tree::CompressionType;
use std::path::{Path, PathBuf};
pub type JournalId = u64;
#[derive(Debug)]
pub struct RecoveryResult {
pub(crate) active: Journal,
pub(crate) sealed: Vec<(JournalId, PathBuf)>,
pub(crate) was_active_created: bool,
}
pub fn recover_journals<P: AsRef<Path>>(
path: P,
compression: CompressionType,
compression_threshold: usize,
) -> crate::Result<RecoveryResult> {
let path = path.as_ref();
let mut max_journal_id: JournalId = 0;
let mut journal_fragments = Vec::<(JournalId, PathBuf)>::new();
log::trace!("Got journal fragments: {journal_fragments:#?}");
for dirent in std::fs::read_dir(path)? {
let dirent = dirent?;
let path = dirent.path();
let filename = dirent.file_name();
let Some(filename) = filename.to_str() else {
log::error!("Invalid journal file name: {}", filename.display());
return Err(crate::Error::JournalRecovery(
crate::JournalRecoveryError::InvalidFileName,
));
};
if !std::path::Path::new(filename)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("jnl"))
{
continue;
}
assert!(dirent.file_type()?.is_file());
let Some(basename) = filename.strip_suffix(".jnl") else {
log::error!("Invalid journal file name: {filename}");
return Err(crate::Error::JournalRecovery(
crate::JournalRecoveryError::InvalidFileName,
));
};
let journal_id = basename.parse::<JournalId>().map_err(|_| {
log::error!("Invalid journal file name: {filename}");
crate::Error::JournalRecovery(crate::JournalRecoveryError::InvalidFileName)
})?;
max_journal_id = max_journal_id.max(journal_id);
journal_fragments.push((journal_id, path));
}
// NOTE: Sort ascending, so the last item is the active journal
journal_fragments.sort_by_key(|(a, _)| *a);
log::trace!("Recovered {journal_fragments:#?}");
Ok(match journal_fragments.pop() {
Some((_, active)) => RecoveryResult {
active: Journal::from_file(active)?
.with_compression(compression, compression_threshold),
sealed: journal_fragments,
was_active_created: false,
},
None => RecoveryResult {
active: {
let id: JournalId = max_journal_id + 1;
Journal::create_new(path.join(id.to_string()))?
.with_compression(compression, compression_threshold)
},
sealed: vec![],
was_active_created: true,
},
})
}
+488
View File
@@ -0,0 +1,488 @@
use super::*;
use crate::batch::item::Item as BatchItem;
use entry::Entry;
use lsm_tree::ValueType;
use std::io::Write;
use tempfile::tempdir;
use test_log::test;
impl PartialEq<BatchItem> for crate::journal::batch_reader::ReadBatchItem {
fn eq(&self, other: &BatchItem) -> bool {
self.keyspace_id == other.keyspace.id
&& self.key == other.key
&& self.value == other.value
&& self.value_type == other.value_type
}
}
impl PartialEq<crate::journal::batch_reader::ReadBatchItem> for BatchItem {
fn eq(&self, other: &crate::journal::batch_reader::ReadBatchItem) -> bool {
other.eq(self)
}
}
#[test]
#[expect(clippy::redundant_clone)]
fn journal_rotation() -> crate::Result<()> {
let dir1 = tempdir()?;
let db = crate::Database::builder(&dir1).open()?;
let keyspace = db.keyspace("default", Default::default)?;
let dir2 = tempdir()?;
let path = dir2.path().join("0.jnl");
let next_path = dir2.path().join("1.jnl");
{
let journal = Journal::create_new(&path)?;
let mut writer = journal.get_writer()?;
writer.write_batch(
[
BatchItem::new(keyspace.clone(), *b"a", *b"a", ValueType::Value),
BatchItem::new(keyspace.clone(), *b"b", *b"b", ValueType::Value),
]
.iter(),
2,
0,
)?;
writer.rotate()?;
}
assert!(path.try_exists()?);
assert!(next_path.try_exists()?);
Ok(())
}
#[test]
#[expect(clippy::redundant_clone)]
fn journal_recovery_active() -> crate::Result<()> {
let dir1 = tempdir()?;
let db = crate::Database::builder(&dir1).open()?;
let keyspace0 = db.keyspace("default", Default::default)?;
let keyspace1 = db.keyspace("default1", Default::default)?;
let keyspace2 = db.keyspace("default2", Default::default)?;
let dir2 = tempdir()?;
let path = dir2.path().join("0.jnl");
let next_path = dir2.path().join("1.jnl");
let next_next_path = dir2.path().join("2.jnl");
{
let journal = Journal::create_new(&path)?;
let mut writer = journal.get_writer()?;
writer.write_batch(
[
BatchItem::new(keyspace0.clone(), *b"a", *b"a", ValueType::Value),
BatchItem::new(keyspace0.clone(), *b"b", *b"b", ValueType::Value),
]
.iter(),
2,
0,
)?;
writer.rotate()?;
writer.write_batch(
[
BatchItem::new(keyspace1.clone(), *b"c", *b"c", ValueType::Value),
BatchItem::new(keyspace1.clone(), *b"d", *b"d", ValueType::Value),
]
.iter(),
2,
1,
)?;
writer.rotate()?;
writer.write_batch(
[
BatchItem::new(keyspace2.clone(), *b"c", *b"c", ValueType::Value),
BatchItem::new(keyspace2.clone(), *b"d", *b"d", ValueType::Value),
]
.iter(),
2,
1,
)?;
}
assert!(path.try_exists()?);
assert!(next_path.try_exists()?);
assert!(next_next_path.try_exists()?);
let journal_recovered = Journal::recover(dir2, CompressionType::None, 0)?;
assert_eq!(journal_recovered.active.path()?, next_next_path);
assert_eq!(journal_recovered.sealed, &[(0, path), (1, next_path)]);
Ok(())
}
#[test]
#[cfg(feature = "lz4")]
#[expect(clippy::redundant_clone)]
fn journal_recovery_active_lz4() -> crate::Result<()> {
let dir1 = tempdir()?;
let db = crate::Database::builder(&dir1).open()?;
let keyspace0 = db.keyspace("default", Default::default)?;
let keyspace1 = db.keyspace("default1", Default::default)?;
let keyspace2 = db.keyspace("default2", Default::default)?;
let dir2 = tempdir()?;
let path = dir2.path().join("0.jnl");
let next_path = dir2.path().join("1.jnl");
let next_next_path = dir2.path().join("2.jnl");
{
let journal = Journal::create_new(&path)?.with_compression(CompressionType::Lz4, 1);
let mut writer = journal.get_writer()?;
writer.write_batch(
[
BatchItem::new(keyspace0.clone(), *b"a", *b"a", ValueType::Value),
BatchItem::new(keyspace0.clone(), *b"b", *b"b", ValueType::Value),
]
.iter(),
2,
0,
)?;
writer.rotate()?;
writer.write_batch(
[
BatchItem::new(keyspace1.clone(), *b"c", *b"c", ValueType::Value),
BatchItem::new(keyspace1.clone(), *b"d", *b"d", ValueType::Value),
]
.iter(),
2,
1,
)?;
writer.rotate()?;
writer.write_batch(
[
BatchItem::new(keyspace2.clone(), *b"c", *b"c", ValueType::Value),
BatchItem::new(keyspace2.clone(), *b"d", *b"d", ValueType::Value),
]
.iter(),
2,
1,
)?;
}
assert!(path.try_exists()?);
assert!(next_path.try_exists()?);
assert!(next_next_path.try_exists()?);
let journal_recovered = Journal::recover(dir2, CompressionType::None, 0)?;
assert_eq!(journal_recovered.active.path()?, next_next_path);
assert_eq!(journal_recovered.sealed, &[(0, path), (1, next_path)]);
Ok(())
}
#[test]
#[expect(clippy::redundant_clone)]
fn journal_recovery_no_active() -> crate::Result<()> {
let dir1 = tempdir()?;
let db = crate::Database::builder(&dir1).open()?;
let keyspace = db.keyspace("default", Default::default)?;
let dir2 = tempdir()?;
let path = dir2.path().join("0.jnl");
let next_path = dir2.path().join("1.jnl");
{
let journal = Journal::create_new(&path)?;
{
let mut writer = journal.get_writer()?;
writer.write_batch(
[
BatchItem::new(keyspace.clone(), *b"a", *b"a", ValueType::Value),
BatchItem::new(keyspace.clone(), *b"b", *b"b", ValueType::Value),
]
.iter(),
2,
0,
)?;
writer.rotate()?;
}
// NOTE: Delete the new, active journal -> old journal will be
// reused as active on next recovery
std::fs::remove_file(&next_path)?;
}
assert!(path.try_exists()?);
assert!(!next_path.try_exists()?);
let journal_recovered = Journal::recover(dir2, CompressionType::None, 0)?;
assert_eq!(journal_recovered.active.path()?, path);
assert_eq!(journal_recovered.sealed, &[]);
Ok(())
}
#[test]
#[expect(clippy::unwrap_used, clippy::redundant_clone)]
fn journal_truncation_corrupt_bytes() -> crate::Result<()> {
let dir1 = tempdir()?;
let db = crate::Database::builder(&dir1).open()?;
let keyspace = db.keyspace("default", Default::default)?;
let dir2 = tempdir()?;
let path = dir2.path().join("0.jnl");
let values = [
BatchItem::new(keyspace.clone(), *b"abc", *b"def", ValueType::Value),
BatchItem::new(keyspace.clone(), *b"yxc", *b"ghj", ValueType::Value),
];
{
let journal = Journal::create_new(&path)?;
journal
.get_writer()?
.write_batch(values.iter(), values.len(), 0)?;
}
{
let journal = Journal::from_file(&path)?;
let reader = journal.get_reader()?;
let collected = reader.flatten().collect::<Vec<_>>();
assert_eq!(values.to_vec(), collected.first().unwrap().items);
}
// Mangle journal
{
let mut file = std::fs::OpenOptions::new().append(true).open(&path)?;
file.write_all(b"09pmu35w3a9mp53bao9upw3ab5up")?;
file.sync_all()?;
}
for _ in 0..10 {
let journal = Journal::from_file(&path)?;
let reader = journal.get_reader()?;
let collected = reader.flatten().collect::<Vec<_>>();
assert_eq!(values.to_vec(), collected.first().unwrap().items);
}
// Mangle journal
for _ in 0..5 {
let mut file = std::fs::OpenOptions::new().append(true).open(&path)?;
file.write_all(b"09pmu35w3a9mp53bao9upw3ab5up")?;
file.sync_all()?;
}
for _ in 0..10 {
let journal = Journal::from_file(&path)?;
let reader = journal.get_reader()?;
let collected = reader.flatten().collect::<Vec<_>>();
assert_eq!(values.to_vec(), collected.first().unwrap().items);
}
Ok(())
}
#[test]
#[expect(clippy::unwrap_used, clippy::redundant_clone)]
fn journal_truncation_repeating_start_marker() -> crate::Result<()> {
let dir1 = tempdir()?;
let db = crate::Database::builder(&dir1).open()?;
let keyspace = db.keyspace("default", Default::default)?;
let dir2 = tempdir()?;
let path = dir2.path().join("0.jnl");
let values = [
BatchItem::new(keyspace.clone(), *b"abc", *b"def", ValueType::Value),
BatchItem::new(keyspace.clone(), *b"yxc", *b"ghj", ValueType::Value),
];
{
let journal = Journal::create_new(&path)?;
journal
.get_writer()?
.write_batch(values.iter(), values.len(), 0)?;
}
{
let journal = Journal::from_file(&path)?;
let reader = journal.get_reader()?;
let collected = reader.flatten().collect::<Vec<_>>();
assert_eq!(values.to_vec(), collected.first().unwrap().items);
}
// Mangle journal
{
let mut file = std::fs::OpenOptions::new().append(true).open(&path)?;
Entry::Start {
item_count: 2,
seqno: 64,
}
.encode_into(&mut file)?;
file.sync_all()?;
}
for _ in 0..10 {
let journal = Journal::from_file(&path)?;
let reader = journal.get_reader()?;
let collected = reader.flatten().collect::<Vec<_>>();
assert_eq!(values.to_vec(), collected.first().unwrap().items);
}
// Mangle journal
for _ in 0..5 {
let mut file = std::fs::OpenOptions::new().append(true).open(&path)?;
Entry::Start {
item_count: 2,
seqno: 64,
}
.encode_into(&mut file)?;
file.sync_all()?;
}
for _ in 0..10 {
let journal = Journal::from_file(&path)?;
let reader = journal.get_reader()?;
let collected = reader.flatten().collect::<Vec<_>>();
assert_eq!(values.to_vec(), collected.first().unwrap().items);
}
Ok(())
}
#[test]
#[expect(clippy::unwrap_used, clippy::redundant_clone)]
fn journal_truncation_repeating_end_marker() -> crate::Result<()> {
let dir1 = tempdir()?;
let db = crate::Database::builder(&dir1).open()?;
let keyspace = db.keyspace("default", Default::default)?;
let dir2 = tempdir()?;
let path = dir2.path().join("0.jnl");
let values = [
BatchItem::new(keyspace.clone(), *b"abc", *b"def", ValueType::Value),
BatchItem::new(keyspace.clone(), *b"yxc", *b"ghj", ValueType::Value),
];
{
let journal = Journal::create_new(&path)?;
journal
.get_writer()?
.write_batch(values.iter(), values.len(), 0)?;
}
{
let journal = Journal::from_file(&path)?;
let reader = journal.get_reader()?;
let collected = reader.flatten().collect::<Vec<_>>();
assert_eq!(values.to_vec(), collected.first().unwrap().items);
}
// Mangle journal
{
let mut file = std::fs::OpenOptions::new().append(true).open(&path)?;
Entry::End(5432).encode_into(&mut file)?;
file.sync_all()?;
}
for _ in 0..10 {
let journal = Journal::from_file(&path)?;
let reader = journal.get_reader()?;
let collected = reader.flatten().collect::<Vec<_>>();
assert_eq!(values.to_vec(), collected.first().unwrap().items);
}
// Mangle journal
for _ in 0..5 {
let mut file = std::fs::OpenOptions::new().append(true).open(&path)?;
Entry::End(5432).encode_into(&mut file)?;
file.sync_all()?;
}
for _ in 0..10 {
let journal = Journal::from_file(&path)?;
let reader = journal.get_reader()?;
let collected = reader.flatten().collect::<Vec<_>>();
assert_eq!(values.to_vec(), collected.first().unwrap().items);
}
Ok(())
}
#[test]
#[expect(clippy::unwrap_used, clippy::redundant_clone)]
fn journal_truncation_repeating_item_marker() -> crate::Result<()> {
let dir1 = tempdir()?;
let db = crate::Database::builder(&dir1).open()?;
let keyspace = db.keyspace("default", Default::default)?;
let dir2 = tempdir()?;
let path = dir2.path().join("0.jnl");
let values = [
BatchItem::new(keyspace.clone(), *b"abc", *b"def", ValueType::Value),
BatchItem::new(keyspace.clone(), *b"yxc", *b"ghj", ValueType::Value),
];
{
let journal = Journal::create_new(&path)?;
journal
.get_writer()?
.write_batch(values.iter(), values.len(), 0)?;
}
{
let journal = Journal::from_file(&path)?;
let reader = journal.get_reader()?;
let collected = reader.flatten().collect::<Vec<_>>();
assert_eq!(values.to_vec(), collected.first().unwrap().items);
}
// Mangle journal
{
let mut file = std::fs::OpenOptions::new().append(true).open(&path)?;
Entry::Item {
keyspace_id: 0,
key: (*b"zzz").into(),
value: (*b"").into(),
value_type: ValueType::Tombstone,
compression: lsm_tree::CompressionType::None,
}
.encode_into(&mut file)?;
file.sync_all()?;
}
for _ in 0..10 {
let journal = Journal::from_file(&path)?;
let reader = journal.get_reader()?;
let collected = reader.flatten().collect::<Vec<_>>();
assert_eq!(values.to_vec(), collected.first().unwrap().items);
}
// Mangle journal
for _ in 0..5 {
let mut file = std::fs::OpenOptions::new().append(true).open(&path)?;
Entry::Item {
keyspace_id: 0,
key: (*b"zzz").into(),
value: (*b"").into(),
value_type: ValueType::Tombstone,
compression: lsm_tree::CompressionType::None,
}
.encode_into(&mut file)?;
file.sync_all()?;
}
for _ in 0..10 {
let journal = Journal::from_file(&path)?;
let reader = journal.get_reader()?;
let collected = reader.flatten().collect::<Vec<_>>();
assert_eq!(values.to_vec(), collected.first().unwrap().items);
}
Ok(())
}
+380
View File
@@ -0,0 +1,380 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use super::entry::{serialize_marker_item, Entry};
use crate::{
batch::item::Item as BatchItem, file::fsync_directory, journal::recovery::JournalId,
keyspace::InternalKeyspaceId,
};
use lsm_tree::{CompressionType, SeqNo, ValueType};
use std::{
fs::{File, OpenOptions},
hash::Hasher,
io::{BufWriter, Seek, Write},
path::{Path, PathBuf},
};
// TODO: this should be a database configuration
pub const PRE_ALLOCATED_BYTES: u64 = 64 * 1_024 * 1_024;
pub const JOURNAL_BUFFER_BYTES: usize = 8 * 1_024;
pub struct Writer {
pub(crate) path: PathBuf,
file: BufWriter<File>,
buf: Vec<u8>,
is_buffer_dirty: bool,
compression: CompressionType,
compression_threshold: usize,
}
/// The persist mode allows setting the durability guarantee of previous writes
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum PersistMode {
/// Flushes data to OS buffers. This allows the OS to write out data in case of an
/// application crash.
///
/// When this function returns, data is **not** guaranteed to be persisted in case
/// of a power loss event or OS crash.
Buffer,
/// Flushes data using `fdatasync`.
///
/// Use if you know that `fdatasync` is sufficient for your file system and/or operating system.
SyncData,
/// Flushes data + metadata using `fsync`.
SyncAll,
}
impl Writer {
pub fn set_compression(&mut self, comp: CompressionType, threshold: usize) {
self.compression = comp;
self.compression_threshold = threshold;
}
pub fn pos(&mut self) -> crate::Result<u64> {
self.file.stream_position().map_err(Into::into)
}
pub fn len(&self) -> crate::Result<u64> {
Ok(self.file.get_ref().metadata()?.len())
}
pub fn rotate(&mut self) -> crate::Result<(PathBuf, PathBuf)> {
self.persist(PersistMode::SyncAll)?;
log::debug!(
"Sealing active journal at {}, len={}B",
self.path.display(),
self.path
.metadata()
.inspect_err(|e| {
log::error!(
"Failed to get file metadata of journal file at {}: {e:?}",
self.path.display()
);
})?
.len(),
);
let prev_path = self.path.clone();
let folder = self
.path
.parent()
.expect("should have parent")
.to_path_buf();
let Some(basename) = self
.path
.file_name()
.expect("should be valid file name")
.to_str()
.expect("should be valid utf-8")
.strip_suffix(".jnl")
else {
log::error!("Invalid journal file name: {}", self.path.display());
return Err(crate::Error::JournalRecovery(
crate::JournalRecoveryError::InvalidFileName,
));
};
let journal_id = basename.parse::<JournalId>().map_err(|_| {
log::error!("Invalid journal file name: {}", self.path.display());
crate::Error::JournalRecovery(crate::JournalRecoveryError::InvalidFileName)
})?;
let new_path = folder.join(format!("{}.jnl", journal_id + 1));
log::debug!("Rotating active journal to {}", new_path.display());
let comp = self.compression;
let compt = self.compression_threshold;
*self = Self::create_new(new_path.clone())?;
self.set_compression(comp, compt);
// IMPORTANT: fsync folder on Unix
fsync_directory(&folder)?;
Ok((prev_path, new_path))
}
pub fn create_new<P: Into<PathBuf>>(path: P) -> crate::Result<Self> {
let path = path.into();
let file = File::create_new(&path).inspect_err(|e| {
log::error!("Failed to create journal file at {}: {e:?}", path.display());
})?;
file.set_len(PRE_ALLOCATED_BYTES).inspect_err(|e| {
log::error!(
"Failed to set journal file size to {PRE_ALLOCATED_BYTES}B at {}: {e:?}",
path.display(),
);
})?;
file.sync_all().inspect_err(|e| {
log::error!("Failed to fsync journal file at {}: {e:?}", path.display());
})?;
Ok(Self {
path,
file: BufWriter::new(file),
buf: Vec::new(),
is_buffer_dirty: false,
compression: CompressionType::None,
compression_threshold: 0,
})
}
pub fn from_file<P: AsRef<Path>>(path: P) -> crate::Result<Self> {
let path = path.as_ref();
if !path.try_exists()? {
let file = OpenOptions::new()
.create_new(true)
.write(true)
.open(path)
.inspect_err(|e| {
log::error!("Failed to create journal file at {}: {e:?}", path.display());
})?;
file.set_len(PRE_ALLOCATED_BYTES).inspect_err(|e| {
log::error!(
"Failed to set journal file size to {PRE_ALLOCATED_BYTES}B at {}: {e:?}",
path.display(),
);
})?;
file.sync_all().inspect_err(|e| {
log::error!("Failed to fsync journal file at {}: {e:?}", path.display());
})?;
return Ok(Self {
path: path.into(),
file: BufWriter::with_capacity(JOURNAL_BUFFER_BYTES, file),
buf: Vec::new(),
is_buffer_dirty: false,
compression: CompressionType::None,
compression_threshold: 0,
});
}
let file = OpenOptions::new()
.append(true)
.open(path)
.inspect_err(|e| {
log::error!("Failed to open journal file at {}: {e:?}", path.display());
})?;
Ok(Self {
path: path.into(),
file: BufWriter::with_capacity(JOURNAL_BUFFER_BYTES, file),
buf: Vec::new(),
is_buffer_dirty: false,
compression: CompressionType::None,
compression_threshold: 0,
})
}
/// Persists the journal file.
pub(crate) fn persist(&mut self, mode: PersistMode) -> std::io::Result<()> {
log::trace!(
"Persisting journal at {} with mode={mode:?}",
self.path.display(),
);
if self.is_buffer_dirty {
self.file.flush().inspect_err(|e| {
log::error!(
"Failed to flush journal IO buffers at {}: {e:?}",
self.path.display(),
);
})?;
self.is_buffer_dirty = false;
}
match mode {
PersistMode::SyncAll => self.file.get_mut().sync_all().inspect_err(|e| {
log::error!(
"Failed to fsync journal file at {}: {e:?}",
self.path.display(),
);
}),
PersistMode::SyncData => self.file.get_mut().sync_data().inspect_err(|e| {
log::error!(
"Failed to fsyncdata journal file at {}: {e:?}",
self.path.display(),
);
}),
PersistMode::Buffer => Ok(()),
}
}
/// Writes a batch start marker to the journal
fn write_start(&mut self, item_count: u32, seqno: SeqNo) -> Result<usize, crate::Error> {
debug_assert!(self.buf.is_empty());
Entry::Start { item_count, seqno }.encode_into(&mut self.buf)?;
self.file.write_all(&self.buf)?;
Ok(self.buf.len())
}
/// Writes a batch end marker to the journal
fn write_end(&mut self, checksum: u64) -> Result<usize, crate::Error> {
debug_assert!(self.buf.is_empty());
Entry::End(checksum).encode_into(&mut self.buf)?;
self.file.write_all(&self.buf)?;
Ok(self.buf.len())
}
pub(crate) fn write_raw(
&mut self,
keyspace_id: InternalKeyspaceId,
key: &[u8],
value: &[u8],
value_type: ValueType,
seqno: u64,
) -> crate::Result<usize> {
self.is_buffer_dirty = true;
let mut hasher = xxhash_rust::xxh3::Xxh3::default();
let mut byte_count = 0;
self.buf.clear();
byte_count += self.write_start(1, seqno)?;
self.buf.clear();
serialize_marker_item(
&mut self.buf,
keyspace_id,
key,
value,
value_type,
if self.compression_threshold > 0 && value.len() >= self.compression_threshold {
self.compression
} else {
CompressionType::None
},
)?;
self.file.write_all(&self.buf)?;
hasher.update(&self.buf);
byte_count += self.buf.len();
self.buf.clear();
let checksum = hasher.finish();
byte_count += self.write_end(checksum)?;
Ok(byte_count)
}
pub(crate) fn write_clear(
&mut self,
keyspace_id: InternalKeyspaceId,
seqno: SeqNo,
) -> crate::Result<usize> {
self.is_buffer_dirty = true;
let mut hasher = xxhash_rust::xxh3::Xxh3::default();
let mut byte_count = 0;
self.buf.clear();
byte_count += self.write_start(1, seqno)?;
self.buf.clear();
Entry::Clear { keyspace_id }.encode_into(&mut self.buf)?;
self.file.write_all(&self.buf)?;
hasher.update(&self.buf);
byte_count += self.buf.len();
self.buf.clear();
let checksum = hasher.finish();
byte_count += self.write_end(checksum)?;
Ok(byte_count)
}
pub fn write_batch<'a>(
&mut self,
items: impl Iterator<Item = &'a BatchItem>,
batch_size: usize,
seqno: SeqNo,
) -> crate::Result<usize> {
if batch_size == 0 {
return Ok(0);
}
self.is_buffer_dirty = true;
self.buf.clear();
// NOTE: entries.len() is surely never > u32::MAX
#[expect(clippy::cast_possible_truncation)]
let item_count = batch_size as u32;
let mut hasher = xxhash_rust::xxh3::Xxh3::default();
let mut byte_count = 0;
byte_count += self.write_start(item_count, seqno)?;
self.buf.clear();
for item in items {
debug_assert!(self.buf.is_empty());
serialize_marker_item(
&mut self.buf,
item.keyspace.id,
&item.key,
&item.value,
item.value_type,
if self.compression_threshold > 0 && item.value.len() >= self.compression_threshold
{
self.compression
} else {
CompressionType::None
},
)?;
self.file.write_all(&self.buf)?;
hasher.update(&self.buf);
byte_count += self.buf.len();
self.buf.clear();
}
let checksum = hasher.finish();
byte_count += self.write_end(checksum)?;
Ok(byte_count)
}
}
@@ -0,0 +1,55 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use crate::keyspace::config::{DecodeConfig, EncodeConfig};
use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
impl EncodeConfig for crate::config::BlockSizePolicy {
fn encode(&self) -> crate::Slice {
let mut v = vec![];
// NOTE: Policies are limited to 255 entries
#[expect(clippy::cast_possible_truncation)]
#[expect(clippy::expect_used)]
v.write_u8(self.len() as u8)
.expect("cannot fail writing into a vec");
for item in self.iter() {
#[expect(clippy::expect_used)]
v.write_u32::<LittleEndian>(*item)
.expect("cannot fail writing into a vec");
}
v.into()
}
}
impl DecodeConfig for crate::config::BlockSizePolicy {
fn decode(mut bytes: &[u8]) -> crate::Result<Self> {
let len = bytes.read_u8()?;
let mut v = vec![];
for _ in 0..len {
v.push(bytes.read_u32::<LittleEndian>()?);
}
Ok(Self::new(v))
}
}
#[cfg(test)]
mod tests {
use super::*;
use test_log::test;
#[test]
fn roundtrip_block_size_policy() -> crate::Result<()> {
let policy = crate::config::BlockSizePolicy::new([1024, 2048, 4096]);
let encoded = policy.encode();
let decoded = crate::config::BlockSizePolicy::decode(&encoded)?;
assert_eq!(policy, decoded);
Ok(())
}
}
@@ -0,0 +1,61 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use crate::keyspace::config::{DecodeConfig, EncodeConfig};
use byteorder::{ReadBytesExt, WriteBytesExt};
use lsm_tree::{
coding::{Decode, Encode},
CompressionType,
};
impl EncodeConfig for crate::config::CompressionPolicy {
fn encode(&self) -> crate::Slice {
let mut v = vec![];
// NOTE: Policies are limited to 255 entries
#[expect(clippy::cast_possible_truncation)]
#[expect(clippy::expect_used)]
v.write_u8(self.len() as u8)
.expect("cannot fail writing into a vec");
for item in self.iter() {
#[expect(clippy::expect_used)]
item.encode_into(&mut v)
.expect("cannot fail writing into a vec");
}
v.into()
}
}
impl DecodeConfig for crate::config::CompressionPolicy {
fn decode(mut bytes: &[u8]) -> crate::Result<Self> {
let len = bytes.read_u8()?;
let mut v = vec![];
for _ in 0..len {
v.push(CompressionType::decode_from(&mut bytes)?);
}
Ok(Self::new(v))
}
}
#[cfg(test)]
mod tests {
use super::*;
use test_log::test;
#[test]
#[cfg(feature = "lz4")]
fn roundtrip_compression_policy() -> crate::Result<()> {
let policy =
crate::config::CompressionPolicy::new([CompressionType::None, CompressionType::Lz4]);
let encoded = policy.encode();
let decoded = crate::config::CompressionPolicy::decode(&encoded)?;
assert_eq!(policy, decoded);
Ok(())
}
}
+117
View File
@@ -0,0 +1,117 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use crate::keyspace::config::{DecodeConfig, EncodeConfig};
use byteorder::{ReadBytesExt, WriteBytesExt};
impl EncodeConfig for crate::config::FilterPolicy {
fn encode(&self) -> crate::Slice {
let mut v = vec![];
// NOTE: Policies are limited to 255 entries
#[expect(clippy::cast_possible_truncation)]
#[expect(clippy::expect_used)]
v.write_u8(self.len() as u8)
.expect("cannot fail writing into a vec");
for item in self.iter() {
match item {
crate::config::FilterPolicyEntry::None => {
v.write_u8(0).expect("cannot fail writing into a vec");
}
crate::config::FilterPolicyEntry::Bloom(bloom) => {
v.write_u8(1).expect("cannot fail writing into a vec");
match bloom {
crate::config::BloomConstructionPolicy::BitsPerKey(bits) => {
v.write_u8(0).expect("cannot fail writing into a vec");
v.write_f32::<byteorder::LittleEndian>(*bits)
.expect("cannot fail writing into a vec");
}
crate::config::BloomConstructionPolicy::FalsePositiveRate(fpr) => {
v.write_u8(1).expect("cannot fail writing into a vec");
v.write_f32::<byteorder::LittleEndian>(*fpr)
.expect("cannot fail writing into a vec");
}
}
}
}
}
v.into()
}
}
impl DecodeConfig for crate::config::FilterPolicy {
fn decode(mut bytes: &[u8]) -> crate::Result<Self> {
let len = bytes.read_u8()?;
let mut v = vec![];
for _ in 0..len {
let tag = bytes.read_u8()?;
match tag {
0 => {
v.push(crate::config::FilterPolicyEntry::None);
}
1 => {
let policy_type = bytes.read_u8()?;
let policy = match policy_type {
0 => {
let bits = bytes.read_f32::<byteorder::LittleEndian>()?;
crate::config::FilterPolicyEntry::Bloom(
crate::config::BloomConstructionPolicy::BitsPerKey(bits),
)
}
1 => {
let value = bytes.read_f32::<byteorder::LittleEndian>()?;
crate::config::FilterPolicyEntry::Bloom(
crate::config::BloomConstructionPolicy::FalsePositiveRate(value),
)
}
_ => {
panic!("unknown bloom filter policy type: {policy_type}");
}
};
v.push(policy);
}
_ => {
panic!("unknown filter policy tag: {tag}");
}
}
}
Ok(Self::new(v))
}
}
#[cfg(test)]
mod tests {
use super::*;
use test_log::test;
#[test]
fn roundtrip_filter_policy() -> crate::Result<()> {
let policy = crate::config::FilterPolicy::new([
crate::config::FilterPolicyEntry::Bloom(
crate::config::BloomConstructionPolicy::BitsPerKey(10.0),
),
crate::config::FilterPolicyEntry::Bloom(
crate::config::BloomConstructionPolicy::FalsePositiveRate(0.01),
),
crate::config::FilterPolicyEntry::None,
]);
let encoded = policy.encode();
let decoded = crate::config::FilterPolicy::decode(&encoded)?;
assert_eq!(policy, decoded);
Ok(())
}
}
@@ -0,0 +1,55 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
use crate::keyspace::config::{DecodeConfig, EncodeConfig};
use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
impl EncodeConfig for crate::config::HashRatioPolicy {
fn encode(&self) -> crate::Slice {
let mut v = vec![];
// NOTE: Policies are limited to 255 entries
#[expect(clippy::cast_possible_truncation)]
#[expect(clippy::expect_used)]
v.write_u8(self.len() as u8)
.expect("cannot fail writing into a vec");
for item in self.iter() {
#[expect(clippy::expect_used)]
v.write_f32::<LittleEndian>(*item)
.expect("cannot fail writing into a vec");
}
v.into()
}
}
impl DecodeConfig for crate::config::HashRatioPolicy {
fn decode(mut bytes: &[u8]) -> crate::Result<Self> {
let len = bytes.read_u8()?;
let mut v = vec![];
for _ in 0..len {
v.push(bytes.read_f32::<LittleEndian>()?);
}
Ok(Self::new(v))
}
}
#[cfg(test)]
mod tests {
use super::*;
use test_log::test;
#[test]
fn roundtrip_hash_ratio_policy() -> crate::Result<()> {
let policy = crate::config::HashRatioPolicy::new([0.1, 0.2, 0.3]);
let encoded = policy.encode();
let decoded = crate::config::HashRatioPolicy::decode(&encoded)?;
assert_eq!(policy, decoded);
Ok(())
}
}
+20
View File
@@ -0,0 +1,20 @@
// Copyright (c) 2024-present, fjall-rs
// This source code is licensed under both the Apache 2.0 and MIT License
// (found in the LICENSE-* files in the repository)
mod block_size;
mod compression;
mod filter;
mod hash_ratio;
mod pinning;
mod restart_interval;
pub trait EncodeConfig {
fn encode(&self) -> crate::Slice;
}
pub trait DecodeConfig {
fn decode(bytes: &[u8]) -> crate::Result<Self>
where
Self: Sized;
}

Some files were not shown because too many files have changed in this diff Show More