Skip to content

Commit f81e3ea

Browse files
committed
feat: defer persist every 'N' commits
1 parent 99d5a36 commit f81e3ea

File tree

7 files changed

+257
-41
lines changed

7 files changed

+257
-41
lines changed

ffi/firewood.go

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -113,14 +113,18 @@ type config struct {
113113
rootStore bool
114114
// expensiveMetricsEnabled controls whether expensive metrics recording is enabled.
115115
expensiveMetricsEnabled bool
116+
// deferredPersistenceCommitCount determines the maximum number of uncommitted
117+
// revisions that can exist at a given time.
118+
deferredPersistenceCommitCount uint
116119
}
117120

118121
func defaultConfig() *config {
119122
return &config{
120-
nodeCacheEntries: 1_000_000,
121-
freeListCacheEntries: 40_000,
122-
revisions: 100,
123-
readCacheStrategy: OnlyCacheWrites,
123+
nodeCacheEntries: 1_000_000,
124+
freeListCacheEntries: 40_000,
125+
revisions: 100,
126+
readCacheStrategy: OnlyCacheWrites,
127+
deferredPersistenceCommitCount: 1,
124128
}
125129
}
126130

@@ -193,6 +197,15 @@ func WithExpensiveMetrics() Option {
193197
}
194198
}
195199

200+
// WithDeferredPersistenceCommitCount sets the maximum number of unpersisted revisions
201+
// that can exist at a time. Note: `commitCount` must be greater than 0.
202+
// Default: 1
203+
func WithDeferredPersistenceCommitCount(commitCount uint) Option {
204+
return func(c *config) {
205+
c.deferredPersistenceCommitCount = commitCount
206+
}
207+
}
208+
196209
// A CacheStrategy represents the caching strategy used by a [Database].
197210
type CacheStrategy uint8
198211

@@ -245,20 +258,24 @@ func New(dbDir string, nodeHashAlgorithm NodeHashAlgorithm, opts ...Option) (*Da
245258
if conf.freeListCacheEntries < 1 {
246259
return nil, fmt.Errorf("free list cache entries must be >= 1, got %d", conf.freeListCacheEntries)
247260
}
261+
if conf.deferredPersistenceCommitCount == 0 {
262+
return nil, fmt.Errorf("deferred persistence commit count must be >= 1, got %d", conf.deferredPersistenceCommitCount)
263+
}
248264

249265
var pinner runtime.Pinner
250266
defer pinner.Unpin()
251267

252268
args := C.struct_DatabaseHandleArgs{
253-
dir: newBorrowedBytes([]byte(dbDir), &pinner),
254-
cache_size: C.size_t(conf.nodeCacheEntries),
255-
free_list_cache_size: C.size_t(conf.freeListCacheEntries),
256-
revisions: C.size_t(conf.revisions),
257-
strategy: C.uint8_t(conf.readCacheStrategy),
258-
truncate: C.bool(conf.truncate),
259-
root_store: C.bool(conf.rootStore),
260-
expensive_metrics: C.bool(conf.expensiveMetricsEnabled),
261-
node_hash_algorithm: C.enum_NodeHashAlgorithm(nodeHashAlgorithm),
269+
dir: newBorrowedBytes([]byte(dbDir), &pinner),
270+
cache_size: C.size_t(conf.nodeCacheEntries),
271+
free_list_cache_size: C.size_t(conf.freeListCacheEntries),
272+
revisions: C.size_t(conf.revisions),
273+
strategy: C.uint8_t(conf.readCacheStrategy),
274+
truncate: C.bool(conf.truncate),
275+
root_store: C.bool(conf.rootStore),
276+
expensive_metrics: C.bool(conf.expensiveMetricsEnabled),
277+
node_hash_algorithm: C.enum_NodeHashAlgorithm(nodeHashAlgorithm),
278+
deferred_persistence_commit_count: C.uint64_t(conf.deferredPersistenceCommitCount),
262279
}
263280

264281
return getDatabaseFromHandleResult(C.fwd_open_db(args))

ffi/firewood.h

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ffi/src/handle.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (C) 2025, Ava Labs, Inc. All rights reserved.
22
// See the file LICENSE.md for licensing terms.
33

4-
use std::num::NonZeroUsize;
4+
use std::num::{NonZeroU64, NonZeroUsize};
55

66
use firewood::{
77
db::{Db, DbConfig},
@@ -99,6 +99,9 @@ pub struct DatabaseHandleArgs<'a> {
9999
///
100100
/// Opening returns an error if this does not match the compile-time feature.
101101
pub node_hash_algorithm: NodeHashAlgorithm,
102+
103+
/// The maximum number of uncommitted revisions that can exist at a given time.
104+
pub deferred_persistence_commit_count: u64,
102105
}
103106

104107
impl DatabaseHandleArgs<'_> {
@@ -151,12 +154,15 @@ impl DatabaseHandle {
151154
/// If the path is empty, or if the configuration is invalid, this will return an error.
152155
pub fn new(args: DatabaseHandleArgs<'_>) -> Result<Self, api::Error> {
153156
let metrics_context = MetricsContext::new(args.expensive_metrics);
157+
let commit_count = NonZeroU64::new(args.deferred_persistence_commit_count)
158+
.ok_or(api::Error::InvalidConversionToCommitCount)?;
154159

155160
let cfg = DbConfig::builder()
156161
.node_hash_algorithm(args.node_hash_algorithm.into())
157162
.truncate(args.truncate)
158163
.manager(args.as_rev_manager_config()?)
159164
.root_store(args.root_store)
165+
.deferred_persistence_commit_count(commit_count)
160166
.build();
161167

162168
let path = args

firewood/src/db.rs

Lines changed: 152 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ use firewood_storage::{
2323
CheckOpt, CheckerReport, Committed, FileBacked, FileIoError, HashedNodeReader,
2424
ImmutableProposal, NodeHashAlgorithm, NodeStore, Parentable, ReadableStorage, TrieReader,
2525
};
26+
use nonzero_ext::nonzero;
2627
use std::io::Write;
27-
use std::num::NonZeroUsize;
28+
use std::num::{NonZeroU64, NonZeroUsize};
2829
use std::path::Path;
2930
use std::sync::Arc;
3031
use thiserror::Error;
@@ -135,6 +136,9 @@ pub struct DbConfig {
135136
/// Whether to enable `RootStore`.
136137
#[builder(default = false)]
137138
pub root_store: bool,
139+
/// The maximum number of uncommitted revisions that can exist at a given time.
140+
#[builder(default = nonzero!(1u64))]
141+
pub deferred_persistence_commit_count: NonZeroU64,
138142
}
139143

140144
#[derive(Debug)]
@@ -182,6 +186,7 @@ impl Db {
182186
.create(cfg.create_if_missing)
183187
.truncate(cfg.truncate)
184188
.root_store(cfg.root_store)
189+
.deferred_persistence_commit_count(cfg.deferred_persistence_commit_count)
185190
.manager(cfg.manager)
186191
.build();
187192
let manager = RevisionManager::new(config_manager)?;
@@ -337,9 +342,9 @@ impl Db {
337342

338343
/// Closes the database gracefully.
339344
///
340-
/// This method shuts down the background persistence worker. If not called
341-
/// explicitly, `Drop` will attempt a best-effort shutdown but cannot report
342-
/// errors.
345+
/// This method shuts down the background persistence worker and persists
346+
/// the last committed revision. If not called explicitly, `Drop` will
347+
/// attempt a best-effort shutdown but cannot report errors.
343348
pub fn close(mut self) -> Result<(), api::Error> {
344349
self.manager.close().map_err(Into::into)
345350
}
@@ -423,18 +428,19 @@ mod test {
423428
use core::iter::Take;
424429
use std::collections::HashMap;
425430
use std::iter::Peekable;
426-
use std::num::NonZeroUsize;
431+
use std::num::{NonZeroU64, NonZeroUsize};
427432
use std::ops::{Deref, DerefMut};
428433
use std::path::Path;
429434

430435
use firewood_storage::{
431436
CheckOpt, CheckerError, HashedNodeReader, IntoHashType, LinearAddress, MaybePersistedNode,
432437
NodeStore, RootReader, TrieHash,
433438
};
439+
use nonzero_ext::nonzero;
434440

435441
use crate::db::{Db, Proposal, UseParallel};
436442
use crate::manager::RevisionManagerConfig;
437-
use crate::v2::api::{Db as _, DbView, Proposal as _};
443+
use crate::v2::api::{Db as _, DbView, HashKeyExt, Proposal as _};
438444

439445
use super::{BatchOp, DbConfig};
440446

@@ -1392,6 +1398,146 @@ mod test {
13921398
assert_eq!(value, new_value.as_ref());
13931399
}
13941400

1401+
#[test]
1402+
fn test_deferred_persist_close_with_high_commit_count() {
1403+
const HIGH_COMMIT_COUNT: NonZeroU64 = nonzero!(1_000_000u64);
1404+
1405+
// Set commit count to an arbitrarily high number so persist happens
1406+
// only on shutdown
1407+
let dbcfg = DbConfig::builder()
1408+
.deferred_persistence_commit_count(HIGH_COMMIT_COUNT)
1409+
.build();
1410+
1411+
let db = TestDb::new_with_config(dbcfg);
1412+
1413+
// Then, commit once and see what the latest revision is
1414+
let key = b"foo";
1415+
let value = b"bar";
1416+
let batch = vec![BatchOp::Put { key, value }];
1417+
let proposal = db.propose(batch).unwrap();
1418+
let root_hash = proposal.root_hash().unwrap().unwrap();
1419+
1420+
proposal.commit().unwrap();
1421+
let db = db.reopen();
1422+
1423+
let revision = db.view(root_hash).unwrap();
1424+
let new_value = revision.val(b"foo").unwrap().unwrap();
1425+
1426+
assert_eq!(value, new_value.as_ref());
1427+
}
1428+
1429+
#[test]
1430+
fn test_deferred_persist_after_reaching_sub_interval() {
1431+
const COMMIT_COUNT: NonZeroU64 = nonzero!(10u64);
1432+
const SUB_INTERVAL: u64 = COMMIT_COUNT.get() / 2;
1433+
1434+
let dbcfg = DbConfig::builder()
1435+
.deferred_persistence_commit_count(COMMIT_COUNT)
1436+
.build();
1437+
1438+
let db = TestDb::new_with_config(dbcfg);
1439+
1440+
// Commit SUB_INTERVAL proposals to trigger the first persist
1441+
for i in 0..SUB_INTERVAL {
1442+
let batch = vec![BatchOp::Put {
1443+
key: format!("key{i}").as_bytes().to_vec(),
1444+
value: format!("value{i}").as_bytes().to_vec(),
1445+
}];
1446+
let proposal = db.propose(batch).unwrap();
1447+
proposal.commit().unwrap();
1448+
}
1449+
1450+
// Wait for the background thread to finish persisting
1451+
db.wait_persisted();
1452+
1453+
// Verify the root is now persisted
1454+
let revision = db.manager.current_revision();
1455+
let is_persisted = revision
1456+
.root_as_maybe_persisted_node()
1457+
.is_some_and(|node| node.unpersisted().is_none());
1458+
1459+
assert!(
1460+
is_persisted,
1461+
"Root should be persisted after hitting commit count"
1462+
);
1463+
}
1464+
1465+
/// Verifies that an unpersisted revision which wipes the database is
1466+
/// persisted when the database closes.
1467+
#[test]
1468+
fn test_deferred_persistence_closing_on_empty_trie() {
1469+
const COMMIT_COUNT: NonZeroU64 = nonzero!(10u64);
1470+
const SUB_INTERVAL: u64 = COMMIT_COUNT.get() / 2;
1471+
1472+
let dbcfg = DbConfig::builder()
1473+
.deferred_persistence_commit_count(COMMIT_COUNT)
1474+
.build();
1475+
1476+
let db = TestDb::new_with_config(dbcfg);
1477+
1478+
// Commit SUB_INTERVAL proposals to trigger the first persist
1479+
for i in 0..SUB_INTERVAL {
1480+
let batch = vec![BatchOp::Put {
1481+
key: format!("key{i}").as_bytes().to_vec(),
1482+
value: format!("value{i}").as_bytes().to_vec(),
1483+
}];
1484+
let proposal = db.propose(batch).unwrap();
1485+
proposal.commit().unwrap();
1486+
}
1487+
1488+
// Empty the trie
1489+
let batch: Vec<BatchOp<Vec<u8>, Vec<u8>>> = vec![BatchOp::DeleteRange { prefix: vec![] }];
1490+
let proposal = db.propose(batch).unwrap();
1491+
proposal.commit().unwrap();
1492+
1493+
let db = db.reopen();
1494+
1495+
// Verify that the latest committed revision is empty.
1496+
let last_committed_hash = db.root_hash().unwrap();
1497+
assert_eq!(last_committed_hash, TrieHash::default_root_hash());
1498+
}
1499+
1500+
#[test]
1501+
fn test_deferred_persistence_root_store() {
1502+
const NUM_COMMITS: usize = 20;
1503+
const COMMIT_COUNT: NonZeroU64 = nonzero!(10u64);
1504+
1505+
// Revisions to verify after reopening (1-indexed commit numbers)
1506+
const CHECKPOINTS: [usize; 4] = [5, 10, 15, 20];
1507+
1508+
let dbcfg = DbConfig::builder()
1509+
.deferred_persistence_commit_count(COMMIT_COUNT)
1510+
.root_store(true)
1511+
.build();
1512+
1513+
let db = TestDb::new_with_config(dbcfg);
1514+
1515+
// Track root hashes at checkpoint commits
1516+
let mut checkpoint_roots: Vec<TrieHash> = Vec::new();
1517+
1518+
// Commit NUM_COMMITS proposals
1519+
for i in 1..=NUM_COMMITS {
1520+
let batch = vec![BatchOp::Put {
1521+
key: format!("key{i}").as_bytes().to_vec(),
1522+
value: format!("value{i}").as_bytes().to_vec(),
1523+
}];
1524+
let proposal = db.propose(batch).unwrap();
1525+
1526+
if CHECKPOINTS.contains(&i) {
1527+
checkpoint_roots.push(proposal.root_hash().unwrap().unwrap());
1528+
}
1529+
1530+
proposal.commit().unwrap();
1531+
}
1532+
1533+
let db = db.reopen();
1534+
1535+
// Verify that checkpoint revisions are accessible after reopening
1536+
for root in checkpoint_roots {
1537+
db.view(root).unwrap();
1538+
}
1539+
}
1540+
13951541
// Testdb is a helper struct for testing the Db. Once it's dropped, the directory and file disappear
13961542
pub(super) struct TestDb {
13971543
db: Db,

firewood/src/manager.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@
1010
reason = "Found 3 occurrences after enabling the lint."
1111
)]
1212

13+
use nonzero_ext::nonzero;
1314
use parking_lot::{Mutex, MutexGuard, RwLock};
1415
use std::collections::{HashMap, VecDeque};
1516
use std::io;
16-
use std::num::NonZero;
17+
use std::num::{NonZero, NonZeroU64};
1718
use std::path::PathBuf;
1819
use std::sync::{Arc, OnceLock};
1920

@@ -72,6 +73,9 @@ pub struct ConfigManager {
7273
/// Whether to enable `RootStore`.
7374
#[builder(default = false)]
7475
pub root_store: bool,
76+
/// The maximum number of uncommitted revisions that can exist at a given time.
77+
#[builder(default = nonzero!(1u64))]
78+
pub deferred_persistence_commit_count: NonZeroU64,
7579
/// Revision manager configuration.
7680
#[builder(default = RevisionManagerConfig::builder().build())]
7781
pub manager: RevisionManagerConfig,
@@ -192,7 +196,11 @@ impl RevisionManager {
192196
by_hash.insert(hash, nodestore.clone());
193197
}
194198

195-
let persist_worker = PersistWorker::new(header, root_store.clone());
199+
let persist_worker = PersistWorker::new(
200+
config.deferred_persistence_commit_count,
201+
header,
202+
root_store.clone(),
203+
);
196204

197205
let manager = Self {
198206
max_revisions: config.manager.max_revisions,

0 commit comments

Comments
 (0)