Skip to main content

fedimint_rocksdb/
lib.rs

1#![deny(clippy::pedantic)]
2#![allow(clippy::missing_errors_doc)]
3#![allow(clippy::must_use_candidate)]
4#![allow(clippy::needless_lifetimes)]
5
6pub mod envs;
7
8use std::fmt;
9use std::ops::Range;
10use std::path::Path;
11use std::str::FromStr;
12
13use async_trait::async_trait;
14use fedimint_core::db::{
15    DatabaseError, DatabaseResult, IDatabaseTransactionOps, IDatabaseTransactionOpsCore,
16    IRawDatabase, IRawDatabaseTransaction, PrefixStream,
17};
18use fedimint_core::task::block_in_place;
19use fedimint_db_locked::{DbLockError, Locked, LockedBuilder};
20use futures::stream;
21pub use rocksdb;
22use rocksdb::{
23    DBRecoveryMode, OptimisticTransactionDB, OptimisticTransactionOptions, WriteOptions,
24};
25use tracing::debug;
26
27use crate::envs::{FM_ROCKSDB_BLOCK_CACHE_SIZE_ENV, FM_ROCKSDB_WRITE_BUFFER_SIZE_ENV};
28
29// turn an `iter` into a `Stream` where every `next` is ran inside
30// `block_in_place` to offload the blocking calls
31fn convert_to_async_stream<'i, I>(iter: I) -> impl futures::Stream<Item = I::Item> + use<I>
32where
33    I: Iterator + Send + 'i,
34    I::Item: Send,
35{
36    stream::unfold(iter, |mut iter| async {
37        fedimint_core::runtime::block_in_place(|| {
38            let item = iter.next();
39            item.map(|item| (item, iter))
40        })
41    })
42}
43
44#[derive(Debug)]
45pub struct RocksDb(rocksdb::OptimisticTransactionDB);
46
47pub struct RocksDbTransaction<'a>(rocksdb::Transaction<'a, rocksdb::OptimisticTransactionDB>);
48
49#[bon::bon]
50impl RocksDb {
51    /// Open the database using blocking IO
52    #[builder(start_fn = build)]
53    #[builder(finish_fn = open_blocking)]
54    pub fn open_blocking(
55        #[builder(start_fn)] db_path: impl AsRef<Path>,
56    ) -> Result<Locked<RocksDb>, RocksDbOpenError> {
57        let db_path = db_path.as_ref();
58
59        block_in_place(|| {
60            std::fs::create_dir_all(db_path.parent().ok_or(RocksDbOpenError::NoBaseDir)?)?;
61            LockedBuilder::new(db_path)?.with_db(|| Self::open_blocking_unlocked(db_path))
62        })
63    }
64}
65
66impl<I1, S> RocksDbOpenBlockingBuilder<I1, S>
67where
68    S: rocks_db_open_blocking_builder::State,
69    I1: std::convert::AsRef<std::path::Path>,
70{
71    /// Open the database
72    #[allow(clippy::unused_async)]
73    pub async fn open(self) -> Result<Locked<RocksDb>, RocksDbOpenError> {
74        block_in_place(|| self.open_blocking())
75    }
76}
77
78impl RocksDb {
79    fn open_blocking_unlocked(db_path: &Path) -> Result<RocksDb, RocksDbOpenError> {
80        let mut opts = get_default_options()?;
81        // Synchronous writes (set_sync(true)) ensure completed writes are
82        // durable, but a SIGKILL mid-write can still leave a truncated WAL tail
83        // record. TolerateCorruptedTailRecords (RocksDB's own default) discards
84        // only incomplete tail records — no committed data is lost.
85        // AbsoluteConsistency was used previously but made the database
86        // permanently unrecoverable after any unclean shutdown.
87        // See: https://github.com/fedimint/fedimint/issues/8072
88        opts.set_wal_recovery_mode(DBRecoveryMode::TolerateCorruptedTailRecords);
89        let db: rocksdb::OptimisticTransactionDB =
90            rocksdb::OptimisticTransactionDB::<rocksdb::SingleThreaded>::open(&opts, db_path)?;
91        Ok(RocksDb(db))
92    }
93
94    pub fn inner(&self) -> &rocksdb::OptimisticTransactionDB {
95        &self.0
96    }
97}
98
99// TODO: Remove this and inline it in the places where it's used.
100fn is_power_of_two(num: usize) -> bool {
101    num.is_power_of_two()
102}
103
104impl fmt::Debug for RocksDbReadOnlyTransaction<'_> {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        f.write_str("RocksDbTransaction")
107    }
108}
109
110impl fmt::Debug for RocksDbTransaction<'_> {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        f.write_str("RocksDbTransaction")
113    }
114}
115
116#[test]
117fn is_power_of_two_sanity() {
118    assert!(!is_power_of_two(0));
119    assert!(is_power_of_two(1));
120    assert!(is_power_of_two(2));
121    assert!(!is_power_of_two(3));
122    assert!(is_power_of_two(4));
123    assert!(!is_power_of_two(5));
124    assert!(is_power_of_two(2 << 10));
125    assert!(!is_power_of_two((2 << 10) + 1));
126}
127
128/// Default write buffer size: 2 MiB (`RocksDB` default is 64 MiB)
129const DEFAULT_WRITE_BUFFER_SIZE: usize = 2 * 1024 * 1024;
130
131/// Default block cache size: 2 MiB (`RocksDB` default is 8 MiB).
132/// Index/filter blocks are placed in this cache too
133/// (`set_cache_index_and_filter_blocks`), so everything is bounded.
134/// We only need correctness, not throughput, so we keep this minimal.
135const DEFAULT_BLOCK_CACHE_SIZE: usize = 2 * 1024 * 1024;
136
137/// Default max open files: 256 (`RocksDB` default is unlimited which
138/// consumes memory for each open file handle and associated metadata)
139const DEFAULT_MAX_OPEN_FILES: i32 = 256;
140
141fn parse_env_size(env_name: &'static str) -> Result<Option<usize>, RocksDbOpenError> {
142    let Ok(var) = std::env::var(env_name) else {
143        return Ok(None);
144    };
145    let size: usize = FromStr::from_str(&var).map_err(|source| RocksDbOpenError::EnvParse {
146        var: env_name,
147        source,
148    })?;
149    if !is_power_of_two(size) {
150        return Err(RocksDbOpenError::EnvNotPowerOfTwo { var: env_name });
151    }
152    Ok(Some(size))
153}
154
155fn get_default_options() -> Result<rocksdb::Options, RocksDbOpenError> {
156    let mut opts = rocksdb::Options::default();
157
158    let write_buffer_size =
159        parse_env_size(FM_ROCKSDB_WRITE_BUFFER_SIZE_ENV)?.unwrap_or(DEFAULT_WRITE_BUFFER_SIZE);
160    opts.set_write_buffer_size(write_buffer_size);
161
162    // Keep at most 2 write buffers (1 active + 1 flushing)
163    opts.set_max_write_buffer_number(2);
164
165    let block_cache_size =
166        parse_env_size(FM_ROCKSDB_BLOCK_CACHE_SIZE_ENV)?.unwrap_or(DEFAULT_BLOCK_CACHE_SIZE);
167    let cache = rocksdb::Cache::new_lru_cache(block_cache_size);
168    let mut block_opts = rocksdb::BlockBasedOptions::default();
169    block_opts.set_block_cache(&cache);
170    // Put index and filter blocks into the block cache so they are
171    // bounded by the same memory budget instead of growing unbounded.
172    block_opts.set_cache_index_and_filter_blocks(true);
173    opts.set_block_based_table_factory(&block_opts);
174
175    opts.set_max_open_files(DEFAULT_MAX_OPEN_FILES);
176
177    debug!(
178        write_buffer_size,
179        block_cache_size,
180        max_open_files = DEFAULT_MAX_OPEN_FILES,
181        "RocksDB memory options"
182    );
183
184    opts.create_if_missing(true);
185    Ok(opts)
186}
187
188#[derive(Debug)]
189pub struct RocksDbReadOnly(rocksdb::DB);
190
191pub struct RocksDbReadOnlyTransaction<'a>(&'a rocksdb::DB);
192
193impl RocksDbReadOnly {
194    #[allow(clippy::unused_async)]
195    pub async fn open_read_only(
196        db_path: impl AsRef<Path>,
197    ) -> Result<RocksDbReadOnly, RocksDbOpenError> {
198        let db_path = db_path.as_ref();
199        block_in_place(|| Self::open_read_only_blocking(db_path))
200    }
201
202    pub fn open_read_only_blocking(db_path: &Path) -> Result<RocksDbReadOnly, RocksDbOpenError> {
203        let opts = get_default_options()?;
204        // Note: rocksdb is OK if one process has write access, and other read-access
205        let db = rocksdb::DB::open_for_read_only(&opts, db_path, false)?;
206        Ok(RocksDbReadOnly(db))
207    }
208}
209
210/// Why a [`RocksDb`] or [`RocksDbReadOnly`] could not be opened.
211#[derive(Debug, thiserror::Error)]
212#[non_exhaustive]
213pub enum RocksDbOpenError {
214    /// The database path has no parent directory to create; only when
215    /// opening for writing.
216    #[error("db path must have a base dir")]
217    NoBaseDir,
218
219    /// The parent directory of the database could not be created; only when
220    /// opening for writing.
221    #[error(transparent)]
222    CreateDir(#[from] std::io::Error),
223
224    /// The lock file next to the database could not be opened or locked;
225    /// only when opening for writing.
226    #[error(transparent)]
227    Lock(#[from] DbLockError),
228
229    /// A size override in the environment is not a number, or is too large
230    /// for a `usize`.
231    #[error("Could not parse {var}")]
232    EnvParse {
233        /// The environment variable.
234        var: &'static str,
235        /// Why its value could not be parsed.
236        #[source]
237        source: std::num::ParseIntError,
238    },
239
240    /// A size override in the environment is not a power of two.
241    #[error("{var} is not a power of 2")]
242    EnvNotPowerOfTwo {
243        /// The environment variable.
244        var: &'static str,
245    },
246
247    /// `RocksDB` could not open the database.
248    #[error(transparent)]
249    RocksDb(#[from] rocksdb::Error),
250}
251
252impl From<rocksdb::OptimisticTransactionDB> for RocksDb {
253    fn from(db: OptimisticTransactionDB) -> Self {
254        RocksDb(db)
255    }
256}
257
258impl From<RocksDb> for rocksdb::OptimisticTransactionDB {
259    fn from(db: RocksDb) -> Self {
260        db.0
261    }
262}
263
264// When finding by prefix iterating in Reverse order, we need to start from
265// "prefix+1" instead of "prefix", using lexicographic ordering. See the tests
266// below.
267// Will return None if there is no next prefix (i.e prefix is already the last
268// possible/max one)
269fn next_prefix(prefix: &[u8]) -> Option<Vec<u8>> {
270    let mut next_prefix = prefix.to_vec();
271    let mut is_last_prefix = true;
272    for i in (0..next_prefix.len()).rev() {
273        next_prefix[i] = next_prefix[i].wrapping_add(1);
274        if next_prefix[i] > 0 {
275            is_last_prefix = false;
276            break;
277        }
278    }
279    if is_last_prefix {
280        // The given prefix is already the last/max prefix, so there is no next prefix,
281        // return None to represent that
282        None
283    } else {
284        Some(next_prefix)
285    }
286}
287
288#[async_trait]
289impl IRawDatabase for RocksDb {
290    type Transaction<'a> = RocksDbTransaction<'a>;
291    async fn begin_transaction<'a>(&'a self) -> RocksDbTransaction {
292        let mut optimistic_options = OptimisticTransactionOptions::default();
293        optimistic_options.set_snapshot(true);
294
295        let mut write_options = WriteOptions::default();
296        // Make sure we never lose data on unclean shutdown
297        write_options.set_sync(true);
298
299        RocksDbTransaction(self.0.transaction_opt(&write_options, &optimistic_options))
300    }
301
302    fn checkpoint(&self, backup_path: &Path) -> DatabaseResult<()> {
303        let checkpoint =
304            rocksdb::checkpoint::Checkpoint::new(&self.0).map_err(DatabaseError::backend)?;
305        checkpoint
306            .create_checkpoint(backup_path)
307            .map_err(DatabaseError::backend)?;
308        Ok(())
309    }
310}
311
312#[async_trait]
313impl IRawDatabase for RocksDbReadOnly {
314    type Transaction<'a> = RocksDbReadOnlyTransaction<'a>;
315    async fn begin_transaction<'a>(&'a self) -> RocksDbReadOnlyTransaction<'a> {
316        RocksDbReadOnlyTransaction(&self.0)
317    }
318
319    fn checkpoint(&self, backup_path: &Path) -> DatabaseResult<()> {
320        let checkpoint =
321            rocksdb::checkpoint::Checkpoint::new(&self.0).map_err(DatabaseError::backend)?;
322        checkpoint
323            .create_checkpoint(backup_path)
324            .map_err(DatabaseError::backend)?;
325        Ok(())
326    }
327}
328
329#[async_trait]
330impl IDatabaseTransactionOpsCore for RocksDbTransaction<'_> {
331    async fn raw_insert_bytes(
332        &mut self,
333        key: &[u8],
334        value: &[u8],
335    ) -> DatabaseResult<Option<Vec<u8>>> {
336        fedimint_core::runtime::block_in_place(|| {
337            let val = self.0.snapshot().get(key).unwrap();
338            self.0.put(key, value).map_err(DatabaseError::backend)?;
339            Ok(val)
340        })
341    }
342
343    async fn raw_get_bytes(&mut self, key: &[u8]) -> DatabaseResult<Option<Vec<u8>>> {
344        fedimint_core::runtime::block_in_place(|| {
345            self.0.snapshot().get(key).map_err(DatabaseError::backend)
346        })
347    }
348
349    async fn raw_remove_entry(&mut self, key: &[u8]) -> DatabaseResult<Option<Vec<u8>>> {
350        fedimint_core::runtime::block_in_place(|| {
351            let val = self.0.snapshot().get(key).unwrap();
352            self.0.delete(key).map_err(DatabaseError::backend)?;
353            Ok(val)
354        })
355    }
356
357    async fn raw_find_by_prefix(&mut self, key_prefix: &[u8]) -> DatabaseResult<PrefixStream<'_>> {
358        Ok(fedimint_core::runtime::block_in_place(|| {
359            let prefix = key_prefix.to_vec();
360            let mut options = rocksdb::ReadOptions::default();
361            options.set_iterate_range(rocksdb::PrefixRange(prefix.clone()));
362            let iter = self.0.snapshot().iterator_opt(
363                rocksdb::IteratorMode::From(&prefix, rocksdb::Direction::Forward),
364                options,
365            );
366            let rocksdb_iter = iter.map_while(move |res| {
367                let (key_bytes, value_bytes) = res.expect("Error reading from RocksDb");
368                key_bytes
369                    .starts_with(&prefix)
370                    .then_some((key_bytes.to_vec(), value_bytes.to_vec()))
371            });
372            Box::pin(convert_to_async_stream(rocksdb_iter))
373        }))
374    }
375
376    async fn raw_find_by_range(&mut self, range: Range<&[u8]>) -> DatabaseResult<PrefixStream<'_>> {
377        Ok(fedimint_core::runtime::block_in_place(|| {
378            let range = Range {
379                start: range.start.to_vec(),
380                end: range.end.to_vec(),
381            };
382            let mut options = rocksdb::ReadOptions::default();
383            options.set_iterate_range(range.clone());
384            let iter = self.0.snapshot().iterator_opt(
385                rocksdb::IteratorMode::From(&range.start, rocksdb::Direction::Forward),
386                options,
387            );
388            let rocksdb_iter = iter.map_while(move |res| {
389                let (key_bytes, value_bytes) = res.expect("Error reading from RocksDb");
390                (key_bytes.as_ref() < range.end.as_slice())
391                    .then_some((key_bytes.to_vec(), value_bytes.to_vec()))
392            });
393            Box::pin(convert_to_async_stream(rocksdb_iter))
394        }))
395    }
396
397    async fn raw_remove_by_prefix(&mut self, key_prefix: &[u8]) -> DatabaseResult<()> {
398        fedimint_core::runtime::block_in_place(|| {
399            // Note: delete_range is not supported in Transactions :/
400            let mut options = rocksdb::ReadOptions::default();
401            options.set_iterate_range(rocksdb::PrefixRange(key_prefix.to_owned()));
402            let iter = self
403                .0
404                .snapshot()
405                .iterator_opt(
406                    rocksdb::IteratorMode::From(key_prefix, rocksdb::Direction::Forward),
407                    options,
408                )
409                .map_while(|res| {
410                    res.map(|(key_bytes, _)| {
411                        key_bytes
412                            .starts_with(key_prefix)
413                            .then_some(key_bytes.to_vec())
414                    })
415                    .transpose()
416                });
417
418            for item in iter {
419                let key = item.map_err(DatabaseError::backend)?;
420                self.0.delete(key).map_err(DatabaseError::backend)?;
421            }
422
423            Ok(())
424        })
425    }
426
427    async fn raw_find_by_prefix_sorted_descending(
428        &mut self,
429        key_prefix: &[u8],
430    ) -> DatabaseResult<PrefixStream<'_>> {
431        let prefix = key_prefix.to_vec();
432        let next_prefix = next_prefix(&prefix);
433        let iterator_mode = if let Some(next_prefix) = &next_prefix {
434            rocksdb::IteratorMode::From(next_prefix, rocksdb::Direction::Reverse)
435        } else {
436            rocksdb::IteratorMode::End
437        };
438        Ok(fedimint_core::runtime::block_in_place(|| {
439            let mut options = rocksdb::ReadOptions::default();
440            options.set_iterate_range(rocksdb::PrefixRange(prefix.clone()));
441            let iter = self.0.snapshot().iterator_opt(iterator_mode, options);
442            let rocksdb_iter = iter.map_while(move |res| {
443                let (key_bytes, value_bytes) = res.expect("Error reading from RocksDb");
444                key_bytes
445                    .starts_with(&prefix)
446                    .then_some((key_bytes.to_vec(), value_bytes.to_vec()))
447            });
448            Box::pin(convert_to_async_stream(rocksdb_iter))
449        }))
450    }
451}
452
453impl IDatabaseTransactionOps for RocksDbTransaction<'_> {}
454
455#[async_trait]
456impl IRawDatabaseTransaction for RocksDbTransaction<'_> {
457    async fn commit_tx(self) -> DatabaseResult<()> {
458        fedimint_core::runtime::block_in_place(|| {
459            match self.0.commit() {
460                Ok(()) => Ok(()),
461                Err(err) => {
462                    // `Busy` means another transaction wrote a key this one also wrote,
463                    // after our snapshot was taken. `TryAgain` means RocksDB could not
464                    // check for conflicts at all, because our snapshot is older than the
465                    // write history it retains — a different failure with a different
466                    // fix, so it gets its own variant.
467                    //
468                    // Anything else keeps its original kind and message: collapsing
469                    // unrelated failures into a conflict hides what actually went wrong.
470                    // Note that `Database::autocommit` retries on any commit error, so
471                    // nothing here changes which errors it recovers from.
472                    //
473                    // See: https://github.com/fedimint/fedimint/issues/8077
474                    // See: https://github.com/fedimint/fedimint/issues/8872
475                    match err.kind() {
476                        rocksdb::ErrorKind::Busy => Err(DatabaseError::WriteConflict),
477                        rocksdb::ErrorKind::TryAgain => Err(DatabaseError::snapshot_too_old(err)),
478                        _ => Err(DatabaseError::backend(err)),
479                    }
480                }
481            }
482        })
483    }
484}
485
486#[async_trait]
487impl IDatabaseTransactionOpsCore for RocksDbReadOnlyTransaction<'_> {
488    async fn raw_insert_bytes(
489        &mut self,
490        _key: &[u8],
491        _value: &[u8],
492    ) -> DatabaseResult<Option<Vec<u8>>> {
493        panic!("Cannot insert into a read only transaction");
494    }
495
496    async fn raw_get_bytes(&mut self, key: &[u8]) -> DatabaseResult<Option<Vec<u8>>> {
497        fedimint_core::runtime::block_in_place(|| {
498            self.0.snapshot().get(key).map_err(DatabaseError::backend)
499        })
500    }
501
502    async fn raw_remove_entry(&mut self, _key: &[u8]) -> DatabaseResult<Option<Vec<u8>>> {
503        panic!("Cannot remove from a read only transaction");
504    }
505
506    async fn raw_find_by_range(&mut self, range: Range<&[u8]>) -> DatabaseResult<PrefixStream<'_>> {
507        Ok(fedimint_core::runtime::block_in_place(|| {
508            let range = Range {
509                start: range.start.to_vec(),
510                end: range.end.to_vec(),
511            };
512            let mut options = rocksdb::ReadOptions::default();
513            options.set_iterate_range(range.clone());
514            let iter = self.0.snapshot().iterator_opt(
515                rocksdb::IteratorMode::From(&range.start, rocksdb::Direction::Forward),
516                options,
517            );
518            let rocksdb_iter = iter.map_while(move |res| {
519                let (key_bytes, value_bytes) = res.expect("Error reading from RocksDb");
520                (key_bytes.as_ref() < range.end.as_slice())
521                    .then_some((key_bytes.to_vec(), value_bytes.to_vec()))
522            });
523            Box::pin(convert_to_async_stream(rocksdb_iter))
524        }))
525    }
526
527    async fn raw_find_by_prefix(&mut self, key_prefix: &[u8]) -> DatabaseResult<PrefixStream<'_>> {
528        Ok(fedimint_core::runtime::block_in_place(|| {
529            let prefix = key_prefix.to_vec();
530            let mut options = rocksdb::ReadOptions::default();
531            options.set_iterate_range(rocksdb::PrefixRange(prefix.clone()));
532            let iter = self.0.snapshot().iterator_opt(
533                rocksdb::IteratorMode::From(&prefix, rocksdb::Direction::Forward),
534                options,
535            );
536            let rocksdb_iter = iter.map_while(move |res| {
537                let (key_bytes, value_bytes) = res.expect("Error reading from RocksDb");
538                key_bytes
539                    .starts_with(&prefix)
540                    .then_some((key_bytes.to_vec(), value_bytes.to_vec()))
541            });
542            Box::pin(convert_to_async_stream(rocksdb_iter))
543        }))
544    }
545
546    async fn raw_remove_by_prefix(&mut self, _key_prefix: &[u8]) -> DatabaseResult<()> {
547        panic!("Cannot remove from a read only transaction");
548    }
549
550    async fn raw_find_by_prefix_sorted_descending(
551        &mut self,
552        key_prefix: &[u8],
553    ) -> DatabaseResult<PrefixStream<'_>> {
554        let prefix = key_prefix.to_vec();
555        let next_prefix = next_prefix(&prefix);
556        let iterator_mode = if let Some(next_prefix) = &next_prefix {
557            rocksdb::IteratorMode::From(next_prefix, rocksdb::Direction::Reverse)
558        } else {
559            rocksdb::IteratorMode::End
560        };
561        Ok(fedimint_core::runtime::block_in_place(|| {
562            let mut options = rocksdb::ReadOptions::default();
563            options.set_iterate_range(rocksdb::PrefixRange(prefix.clone()));
564            let iter = self.0.snapshot().iterator_opt(iterator_mode, options);
565            let rocksdb_iter = iter.map_while(move |res| {
566                let (key_bytes, value_bytes) = res.expect("Error reading from RocksDb");
567                key_bytes
568                    .starts_with(&prefix)
569                    .then_some((key_bytes.to_vec(), value_bytes.to_vec()))
570            });
571            Box::pin(stream::iter(rocksdb_iter))
572        }))
573    }
574}
575
576impl IDatabaseTransactionOps for RocksDbReadOnlyTransaction<'_> {}
577
578#[async_trait]
579impl IRawDatabaseTransaction for RocksDbReadOnlyTransaction<'_> {
580    async fn commit_tx(self) -> DatabaseResult<()> {
581        panic!("Cannot commit a read only transaction");
582    }
583}
584
585#[cfg(test)]
586mod fedimint_rocksdb_tests;