Skip to main content

firewood_ffi/
handle.rs

1// Copyright (C) 2025, Ava Labs, Inc. All rights reserved.
2// See the file LICENSE.md for licensing terms.
3
4use std::num::{NonZeroU64, NonZeroUsize};
5
6use firewood::{
7    api::{
8        self, ArcDynDbView, Db as _, DbView, FrozenChangeProof, HashKey, HashKeyExt, IntoBatchIter,
9        KeyType,
10    },
11    db::{Db, DbConfig},
12    manager::RevisionManagerConfig,
13    merkle::Merkle,
14};
15use firewood_storage::{Committed, FileBacked, NodeStore};
16
17use crate::{BatchOp, BorrowedBytes, CView, CreateProposalResult, arc_cache::ArcCache};
18
19use crate::revision::{GetRevisionResult, RevisionHandle};
20use firewood_metrics::{
21    MetricsContext, firewood_increment, firewood_record, fwd_expensive_timed_result,
22};
23
24/// The hashing mode to use for the database.
25///
26/// This determines the cryptographic hash function and trie structure used.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28#[repr(C)]
29pub enum NodeHashAlgorithm {
30    /// MerkleDB Firewood hashing (SHA-256 based)
31    MerkleDB = 0,
32    /// Ethereum-compatible hashing (Keccak-256 based)
33    Ethereum = 1,
34}
35
36impl From<NodeHashAlgorithm> for firewood_storage::NodeHashAlgorithm {
37    fn from(alg: NodeHashAlgorithm) -> Self {
38        match alg {
39            NodeHashAlgorithm::MerkleDB => firewood_storage::NodeHashAlgorithm::MerkleDB,
40            NodeHashAlgorithm::Ethereum => firewood_storage::NodeHashAlgorithm::Ethereum,
41        }
42    }
43}
44
45/// Arguments for creating or opening a database. These are passed to [`fwd_open_db`]
46///
47/// [`fwd_open_db`]: crate::fwd_open_db
48#[repr(C)]
49#[derive(Debug)]
50pub struct DatabaseHandleArgs<'a> {
51    /// The path to the database directory.
52    ///
53    /// This must be a valid UTF-8 string.
54    ///
55    /// If this is empty, an error will be returned.
56    pub dir: BorrowedBytes<'a>,
57
58    /// Whether to enable `RootStore`.
59    ///
60    /// Note: Setting this feature will only track new revisions going forward
61    /// and will not contain revisions from a prior database instance that didn't
62    /// enable `root_store`.
63    pub root_store: bool,
64
65    /// The optional memory limit for the node cache in bytes.
66    ///
67    /// Set to `0` to leave this unset and rely on the default configured in
68    /// `RevisionManagerConfig`.
69    pub node_cache_memory_limit: usize,
70
71    /// The size of the free list cache.
72    ///
73    /// Opening returns an error if this is zero.
74    pub free_list_cache_size: usize,
75
76    /// The maximum number of revisions to keep.
77    ///
78    /// Must be > `deferred_persistence_commit_count`.
79    pub revisions: usize,
80
81    /// The cache read strategy to use.
82    ///
83    /// This must be one of the following:
84    ///
85    /// - `0`: No cache.
86    /// - `1`: Cache only branch reads.
87    /// - `2`: Cache all reads.
88    ///
89    /// Opening returns an error if this is not one of the above values.
90    pub strategy: u8,
91
92    /// Whether to truncate the database file if it exists.
93    pub truncate: bool,
94
95    /// Whether to enable expensive metrics recording for this database handle.
96    ///
97    /// Expensive metrics are disabled by default.
98    pub expensive_metrics: bool,
99
100    /// The hashing mode to use for the database.
101    ///
102    /// This must match the compile-time feature:
103    /// - [`NodeHashAlgorithm::Ethereum`] if the `ethhash` feature is enabled
104    /// - [`NodeHashAlgorithm::MerkleDB`] if the `ethhash` feature is disabled
105    ///
106    /// Opening returns an error if this does not match the compile-time feature.
107    pub node_hash_algorithm: NodeHashAlgorithm,
108
109    /// The maximum number of unpersisted revisions that can exist at a given time.
110    ///
111    /// Note: `revisions` must be > `deferred_persistence_commit_count`.
112    pub deferred_persistence_commit_count: u64,
113}
114
115impl DatabaseHandleArgs<'_> {
116    fn as_rev_manager_config(&self) -> Result<RevisionManagerConfig, api::Error> {
117        let cache_read_strategy = match self.strategy {
118            0 => firewood::manager::CacheReadStrategy::WritesOnly,
119            1 => firewood::manager::CacheReadStrategy::BranchReads,
120            2 => firewood::manager::CacheReadStrategy::All,
121            _ => return Err(invalid_data("invalid cache strategy")),
122        };
123        let free_list_cache_size = NonZeroUsize::new(self.free_list_cache_size)
124            .ok_or_else(|| invalid_data("free list cache size should be non-zero"))?;
125        let commit_count = NonZeroU64::new(self.deferred_persistence_commit_count)
126            .ok_or(api::Error::ZeroCommitCount)?;
127
128        let memory_limit = NonZeroUsize::new(self.node_cache_memory_limit);
129
130        let config = {
131            let builder = RevisionManagerConfig::builder()
132                .max_revisions(self.revisions)
133                .cache_read_strategy(cache_read_strategy)
134                .free_list_cache_size(free_list_cache_size)
135                .deferred_persistence_commit_count(commit_count);
136
137            if let Some(memory_limit) = memory_limit {
138                builder.node_cache_memory_limit(memory_limit).build()
139            } else {
140                builder.build()
141            }
142        };
143
144        Ok(config)
145    }
146}
147
148/// A handle to the database, returned by `fwd_open_db`.
149///
150/// These handles are passed to the other FFI functions.
151///
152#[derive(Debug)]
153#[repr(C)]
154pub struct DatabaseHandle {
155    /// A single cached view to improve performance of reads while committing
156    cached_view: ArcCache<HashKey, dyn api::DynDbView>,
157
158    /// The database
159    db: Db,
160
161    metrics_context: MetricsContext,
162}
163
164impl DatabaseHandle {
165    /// Creates a new database handle from the given arguments.
166    ///
167    /// # Errors
168    ///
169    /// If the path is empty, or if the configuration is invalid, this will return an error.
170    pub fn new(args: DatabaseHandleArgs<'_>) -> Result<Self, api::Error> {
171        let metrics_context = MetricsContext::new(args.expensive_metrics);
172
173        let cfg = DbConfig::builder()
174            .node_hash_algorithm(args.node_hash_algorithm.into())
175            .truncate(args.truncate)
176            .manager(args.as_rev_manager_config()?)
177            .root_store(args.root_store)
178            .build();
179
180        let path = args
181            .dir
182            .as_str()
183            .map_err(|err| invalid_data(format!("database path contains invalid utf-8: {err}")))?;
184
185        if path.is_empty() {
186            return Err(invalid_data("database path cannot be empty"));
187        }
188
189        let db = Db::new(path, cfg)?;
190        Ok(Self {
191            cached_view: ArcCache::new(),
192            db,
193            metrics_context,
194        })
195    }
196
197    /// Returns the current root hash of the database.
198    ///
199    /// # Errors
200    ///
201    /// Never errors.
202    pub fn current_root_hash(&self) -> Option<HashKey> {
203        self.db.root_hash()
204    }
205
206    /// Returns a value from the database for the given key from the latest root hash.
207    ///
208    /// # Errors
209    ///
210    /// An error is returned if there was an i/o error while reading the value.
211    pub fn get_latest(&self, key: impl KeyType) -> Result<Option<Box<[u8]>>, api::Error> {
212        let Some(root) = self.current_root_hash() else {
213            return Err(api::Error::RevisionNotFound {
214                provided: HashKey::default_root_hash(),
215            });
216        };
217
218        self.db.revision(root)?.val(key)
219    }
220
221    /// Creates and commits a proposal with the given values.
222    ///
223    /// # Errors
224    ///
225    /// An error is returned if the proposal could not be created.
226    pub fn create_batch<'a>(
227        &self,
228        values: impl AsRef<[BatchOp<'a>]> + 'a,
229    ) -> Result<Option<HashKey>, api::Error> {
230        let (root_hash_result, elapsed) =
231            fwd_expensive_timed_result!(crate::registry::BATCH_MS_BUCKET, {
232                let CreateProposalResult { handle } =
233                    self.create_proposal_handle(values.as_ref())?;
234                handle.commit_proposal()
235            });
236        let root_hash = root_hash_result?;
237        firewood_increment!(crate::registry::BATCH_MS, elapsed.as_millis() as u64);
238        firewood_increment!(crate::registry::BATCH_COUNT, 1);
239        firewood_record!(
240            crate::registry::BATCH_MS_BUCKET,
241            elapsed.as_secs_f64() * 1000.0,
242            expensive
243        );
244
245        Ok(root_hash)
246    }
247
248    /// Returns an owned handle to the revision corresponding to the provided root hash.
249    ///
250    /// # Errors
251    ///
252    /// Returns an error if could not get the view from underlying database for the specified
253    /// root hash, for example when the revision does not exist or an I/O error occurs while
254    /// accessing the database.
255    pub fn get_revision(&self, root: HashKey) -> Result<GetRevisionResult<'_>, api::Error> {
256        let view = self.db.view(root.clone())?;
257        let historical = match self.db.revision(root.clone()) {
258            Ok(rev) => Some(rev),
259            Err(api::Error::RevisionNotFound { .. }) => None,
260            Err(err) => return Err(err),
261        };
262        Ok(GetRevisionResult {
263            handle: RevisionHandle::new(view, historical, self.metrics_context, self),
264            root_hash: root,
265        })
266    }
267
268    /// Reconstructs a view on top of an existing historical node store.
269    ///
270    /// # Errors
271    ///
272    /// Returns an error if reconstruction fails.
273    pub fn reconstruct_from_view<'db>(
274        &'db self,
275        parent: &NodeStore<Committed, FileBacked>,
276        batch: impl IntoBatchIter,
277    ) -> Result<firewood::db::ReconstructedView<'db>, api::Error> {
278        self.db.reconstruct_from_view(parent, batch)
279    }
280
281    pub(crate) fn get_root(&self, root: HashKey) -> Result<ArcDynDbView, api::Error> {
282        let mut cache_miss = false;
283        let view = self.cached_view.get_or_try_insert_with(root, |key| {
284            cache_miss = true;
285            self.db.view(HashKey::clone(key))
286        })?;
287
288        if cache_miss {
289            firewood_increment!(crate::registry::CACHED_VIEW_MISS, 1);
290        } else {
291            firewood_increment!(crate::registry::CACHED_VIEW_HIT, 1);
292        }
293
294        Ok(view)
295    }
296
297    pub(crate) fn clear_cached_view(&self) {
298        self.cached_view.clear();
299    }
300
301    pub(crate) fn merge_key_value_range(
302        &self,
303        first_key: Option<impl KeyType>,
304        last_key: Option<impl KeyType>,
305        key_values: impl IntoIterator<Item: api::KeyValuePair>,
306    ) -> Result<CreateProposalResult<'_>, api::Error> {
307        CreateProposalResult::new(self, || {
308            self.db
309                .merge_key_value_range(first_key, last_key, key_values)
310        })
311    }
312
313    /// Create a Change Proof between two revisions specified by the start and end hash.
314    ///
315    /// # Errors
316    ///
317    /// * `api::Error::StartRevisionNotFound` - If the revision for `start_hash` cannot
318    ///   be found. Note that only an `EndRevisionNotFound` is returned when both
319    ///   `start_hash` and `end_hash` cannot be found.
320    ///
321    /// * `api::Error::EndRevisionNotFound` - If the revision for `end_hash` cannot be
322    ///   found. Note that only an `EndRevisionNotFound` is returned when both
323    ///   `start_hash` and `end_hash` cannot be found.
324    ///
325    /// * `api::Error::InvalidRange` - If `start_key` > `end_key` when both are provided.
326    ///   This ensures the range bounds are logically consistent.
327    ///
328    /// * `api::Error` - Various other errors can occur during proof generation, such as:
329    ///   - I/O errors when reading nodes from storage
330    ///   - Corrupted trie structure
331    ///   - Invalid node references
332    pub(crate) fn change_proof(
333        &self,
334        start_hash: HashKey,
335        end_hash: HashKey,
336        start_key: Option<&[u8]>,
337        end_key: Option<&[u8]>,
338        limit: Option<NonZeroUsize>,
339    ) -> Result<FrozenChangeProof, api::Error> {
340        // Convert `RevisionNotFound` to `EndRevisionNotFound`. We get the end merkle
341        // before the start merkle since we want to return an `EndRevisionNotFound` in
342        // the case where both the start and end keys are not available.
343        let end_merkle = Merkle::from(self.db.revision(end_hash).map_err(|err| {
344            if let api::Error::RevisionNotFound { provided } = err {
345                api::Error::EndRevisionNotFound { provided }
346            } else {
347                err
348            }
349        })?);
350
351        // Convert `RevisionNotFound` to `StartRevisionNotFound`.
352        let start_merkle = Merkle::from(self.db.revision(start_hash).map_err(|err| {
353            if let api::Error::RevisionNotFound { provided } = err {
354                api::Error::StartRevisionNotFound { provided }
355            } else {
356                err
357            }
358        })?);
359
360        end_merkle.change_proof(start_key, end_key, start_merkle.nodestore(), limit)
361    }
362
363    /// Applies the `BatchOp`s of a change proof to the parent.
364    ///
365    /// # Errors
366    ///
367    /// Returns a `LatestIsEmpty` error if the trie is empty. A range proof should be used in
368    /// this case.
369    pub fn apply_change_proof_to_parent(
370        &self,
371        start_hash: HashKey,
372        change_proof: &FrozenChangeProof,
373    ) -> Result<CreateProposalResult<'_>, api::Error> {
374        CreateProposalResult::new(self, || {
375            let parent = &self.db.revision(start_hash)?;
376            self.db.apply_change_proof_to_parent(change_proof, parent)
377        })
378    }
379
380    /// Dumps the Trie structure of the latest revision to a DOT (Graphviz) format string.
381    ///
382    /// # Errors
383    ///
384    /// An error is returned if there was an i/o error while dumping the trie.
385    pub fn dump_to_string(&self) -> Result<String, api::Error> {
386        self.db.dump_to_string().map_err(api::Error::from)
387    }
388
389    /// Closes the database gracefully.
390    ///
391    /// # Errors
392    ///
393    /// An error is returned if the persistence background thread panicked or
394    /// errored during execution.
395    pub fn close(self) -> Result<(), api::Error> {
396        self.db.close()
397    }
398}
399
400impl<'db> CView<'db> for &'db crate::DatabaseHandle {
401    fn handle(&self) -> &'db crate::DatabaseHandle {
402        self
403    }
404
405    fn create_proposal(
406        self,
407        values: impl IntoBatchIter,
408    ) -> Result<firewood::db::Proposal<'db>, api::Error> {
409        self.db.propose(values)
410    }
411}
412
413impl crate::MetricsContextExt for DatabaseHandle {
414    fn metrics_context(&self) -> Option<MetricsContext> {
415        Some(self.metrics_context)
416    }
417}
418
419fn invalid_data(error: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> api::Error {
420    api::Error::IO(std::io::Error::new(std::io::ErrorKind::InvalidData, error))
421}