Skip to content

Commit 0375ddc

Browse files
committed
feat(reconstruction): Mutable->reconstruted conversions
Also adds tests
1 parent c356643 commit 0375ddc

File tree

4 files changed

+220
-14
lines changed

4 files changed

+220
-14
lines changed

firewood/src/db.rs

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -499,18 +499,60 @@ impl api::Reconstructible for Reconstructed {
499499
where
500500
Self: Sized,
501501
{
502-
reconstruct_with_parent(&*self.nodestore, batch)
502+
reconstruct_with_reconstructed(self.nodestore, batch)
503503
}
504504
}
505505

506+
/// Reconstruction path for a `Reconstructed` view.
507+
///
508+
/// Converts `Reconstructed` → `MutableProposal` using the optimized `TryFrom` impl
509+
/// that moves the root Node when possible and clones otherwise, then applies the batch.
510+
fn reconstruct_with_reconstructed(
511+
nodestore: Arc<NodeStore<StorageReconstructed, FileBacked>>,
512+
batch: impl IntoBatchIter,
513+
) -> Result<Reconstructed, api::Error> {
514+
// The TryFrom<Arc<NodeStore<Reconstructed>>> impl extracts and moves the root
515+
// Node when uniquely owned, or clones when shared.
516+
let proposal =
517+
NodeStore::<MutableProposal, _>::try_from(nodestore).map_err(api::Error::from)?;
518+
519+
let mut merkle = Merkle::from(proposal);
520+
521+
for res in batch.into_iter().into_batch_iter::<api::Error>() {
522+
match res? {
523+
BatchOp::Put { key, value } => {
524+
merkle.insert(key.as_ref(), value.as_ref().into())?;
525+
}
526+
BatchOp::Delete { key } => {
527+
merkle.remove(key.as_ref())?;
528+
}
529+
BatchOp::DeleteRange { prefix } => {
530+
merkle.remove_prefix(prefix.as_ref())?;
531+
}
532+
}
533+
}
534+
535+
let nodestore = merkle.into_inner();
536+
Ok(Reconstructed {
537+
nodestore: Arc::new(nodestore.try_into()?),
538+
})
539+
}
540+
541+
/// Reconstruction path for a `DbView` (or other parent-backed view).
542+
///
543+
/// Creates a new `NodeStore` from the parent, which requires cloning the root node.
544+
/// This is significantly slower than the reconstructed path due to the clone.
506545
fn reconstruct_with_parent<P>(
507546
parent: &NodeStore<P, FileBacked>,
508547
batch: impl IntoBatchIter,
509548
) -> Result<Reconstructed, api::Error>
510549
where
550+
P: Parentable,
511551
NodeStore<P, FileBacked>: TrieReader + HashedNodeReader,
512552
{
513-
let proposal = NodeStore::<MutableProposal, _>::new_for_reconstruction(parent)?;
553+
// This clones the root node when creating a mutable nodestore from a parent view.
554+
let proposal = NodeStore::<MutableProposal, _>::new(parent)?;
555+
514556
let mut merkle = Merkle::from(proposal);
515557

516558
for res in batch.into_iter().into_batch_iter::<api::Error>() {
@@ -526,11 +568,9 @@ where
526568
}
527569
}
528570
}
529-
530571
let nodestore = merkle.into_inner();
531-
let reconstructed = Arc::new(nodestore.try_into()?);
532572
Ok(Reconstructed {
533-
nodestore: reconstructed,
573+
nodestore: Arc::new(nodestore.try_into()?),
534574
})
535575
}
536576

@@ -553,7 +593,7 @@ mod test {
553593

554594
use crate::db::{Db, Proposal, UseParallel};
555595
use crate::manager::RevisionManagerConfig;
556-
use crate::v2::api::{Db as _, DbView, HashKeyExt, Proposal as _};
596+
use crate::v2::api::{Db as _, DbView, HashKeyExt, Proposal as _, Reconstructible as _};
557597

558598
use super::{BatchOp, DbConfig};
559599

@@ -625,6 +665,41 @@ mod test {
625665
assert_eq!(&*historical.val(b"k").unwrap().unwrap(), b"v");
626666
}
627667

668+
#[test]
669+
fn test_reconstruct_reads_and_chains() {
670+
let db = TestDb::new();
671+
672+
let initial = db
673+
.propose(vec![BatchOp::Put {
674+
key: b"base",
675+
value: b"v0",
676+
}])
677+
.unwrap();
678+
initial.commit().unwrap();
679+
680+
let historical_hash = db.root_hash().unwrap();
681+
let historical = db.revision(historical_hash).unwrap();
682+
683+
let reconstructed = historical
684+
.as_ref()
685+
.reconstruct(vec![BatchOp::Put {
686+
key: b"base",
687+
value: b"v1",
688+
}])
689+
.unwrap();
690+
assert_eq!(&*reconstructed.val(b"base").unwrap().unwrap(), b"v1");
691+
692+
let reconstructed = reconstructed
693+
.reconstruct(vec![BatchOp::Put {
694+
key: b"next",
695+
value: b"v2",
696+
}])
697+
.unwrap();
698+
699+
assert_eq!(&*reconstructed.val(b"base").unwrap().unwrap(), b"v1");
700+
assert_eq!(&*reconstructed.val(b"next").unwrap().unwrap(), b"v2");
701+
}
702+
628703
#[test]
629704
fn reopen_test() {
630705
let db = TestDb::new();

firewood/src/v2/api.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,16 @@ pub trait Reconstructible: DbView {
418418
type Reconstructed: DbView + Reconstructible<Reconstructed = Self::Reconstructed>;
419419

420420
/// Reconstruct a new view from this one by applying `data`.
421-
#[expect(clippy::missing_errors_doc)]
421+
///
422+
/// If the current view is shared (for example, by holding a `view()` clone),
423+
/// reconstruction may need to clone the root node or read it from storage.
424+
/// For best performance, drop other references to the view before calling
425+
/// `reconstruct` in a chain.
426+
///
427+
/// # Errors
428+
///
429+
/// Returns an error if applying the batch fails or if the underlying storage
430+
/// cannot be read while resolving nodes.
422431
fn reconstruct(self, data: impl IntoBatchIter) -> Result<Self::Reconstructed, Error>
423432
where
424433
Self: Sized;

storage/src/node/persist.rs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
// See the file LICENSE.md for licensing terms.
33

44
use parking_lot::Mutex;
5-
use std::{fmt::Display, sync::Arc};
5+
use std::{fmt::Display, ops::Deref, sync::Arc};
66

7-
use crate::{FileIoError, LinearAddress, NodeReader, SharedNode};
7+
use crate::{FileIoError, LinearAddress, Node, NodeReader, SharedNode};
88

99
/// A node that is either in memory or on disk.
1010
///
@@ -95,6 +95,40 @@ impl MaybePersistedNode {
9595
}
9696
}
9797

98+
/// Resolve this node into a concrete `Node`, performing I/O when needed.
99+
///
100+
/// Attempts to move the in-memory node without cloning when this is the sole
101+
/// reference. If multiple references exist, it falls back to cloning the
102+
/// node (similar to `reconstruct_with_parent`). If the node is persisted,
103+
/// it reads from storage.
104+
pub fn try_into_node<S: NodeReader>(self, storage: &S) -> Result<Node, FileIoError> {
105+
fn resolve_state<S: NodeReader>(
106+
state: MaybePersisted,
107+
storage: &S,
108+
) -> Result<Node, FileIoError> {
109+
match state {
110+
MaybePersisted::Unpersisted(shared_node)
111+
| MaybePersisted::Allocated(_, shared_node) => {
112+
match triomphe::Arc::try_unwrap(shared_node) {
113+
Ok(node) => Ok(node),
114+
Err(shared) => Ok(shared.deref().clone()),
115+
}
116+
}
117+
MaybePersisted::Persisted(address) => {
118+
let shared = storage.read_node(address)?;
119+
Ok(shared.deref().clone())
120+
}
121+
}
122+
}
123+
124+
match Arc::try_unwrap(self.0) {
125+
Ok(mutex) => resolve_state(mutex.into_inner(), storage),
126+
Err(arc) => {
127+
let shared = Self(arc).as_shared_node(storage)?;
128+
Ok(shared.deref().clone())
129+
}
130+
}
131+
}
98132
/// Returns the linear address of the node if it is persisted on disk.
99133
///
100134
/// # Returns

storage/src/nodestore/mod.rs

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,79 @@ impl<T: Into<NodeStoreParent>, S: ReadableStorage> From<NodeStore<T, S>>
562562
}
563563
}
564564

565+
fn resolve_reconstructed_root<R: NodeReader>(
566+
root_child: Option<Child>,
567+
reader: &R,
568+
) -> Result<Option<Node>, FileIoError> {
569+
match root_child {
570+
Some(Child::Node(node)) => Ok(Some(node)),
571+
Some(Child::MaybePersisted(maybe_persisted, _hash)) => {
572+
Ok(Some(maybe_persisted.try_into_node(reader)?))
573+
}
574+
Some(Child::AddressWithHash(address, _hash)) => {
575+
let shared = reader.read_node(address)?;
576+
Ok(Some(shared.deref().clone()))
577+
}
578+
None => Ok(None),
579+
}
580+
}
581+
582+
impl<S: ReadableStorage> TryFrom<NodeStore<Reconstructed, S>> for NodeStore<MutableProposal, S> {
583+
type Error = FileIoError;
584+
585+
fn try_from(val: NodeStore<Reconstructed, S>) -> Result<Self, Self::Error> {
586+
// Extract the root without cloning - just move it!
587+
let root_child = val.kind.root;
588+
let reader = NodeStore {
589+
kind: Reconstructed { root: None },
590+
storage: val.storage.clone(),
591+
};
592+
let root = resolve_reconstructed_root(root_child, &reader)?;
593+
594+
Ok(NodeStore {
595+
kind: MutableProposal {
596+
root,
597+
// Reconstructed chains never persist, so we don't need to track deletions
598+
deleted: Vec::default(),
599+
parent: NodeStoreParent::Committed(None),
600+
},
601+
storage: val.storage,
602+
})
603+
}
604+
}
605+
606+
impl<S: ReadableStorage> TryFrom<&NodeStore<Reconstructed, S>> for NodeStore<MutableProposal, S> {
607+
type Error = FileIoError;
608+
609+
fn try_from(val: &NodeStore<Reconstructed, S>) -> Result<Self, Self::Error> {
610+
let root_child = val.kind.root.clone();
611+
let root = resolve_reconstructed_root(root_child, val)?;
612+
613+
Ok(NodeStore {
614+
kind: MutableProposal {
615+
root,
616+
// Reconstructed chains never persist, so we don't need to track deletions
617+
deleted: Vec::default(),
618+
parent: NodeStoreParent::Committed(None),
619+
},
620+
storage: val.storage.clone(),
621+
})
622+
}
623+
}
624+
625+
impl<S: ReadableStorage> TryFrom<Arc<NodeStore<Reconstructed, S>>>
626+
for NodeStore<MutableProposal, S>
627+
{
628+
type Error = FileIoError;
629+
630+
fn try_from(val: Arc<NodeStore<Reconstructed, S>>) -> Result<Self, Self::Error> {
631+
match Arc::try_unwrap(val) {
632+
Ok(owned) => Self::try_from(owned),
633+
Err(shared) => Self::try_from(&*shared),
634+
}
635+
}
636+
}
637+
565638
/// Commit a proposal to a new revision of the trie
566639
impl<S: WritableStorage> From<NodeStore<ImmutableProposal, S>> for NodeStore<Committed, S> {
567640
fn from(val: NodeStore<ImmutableProposal, S>) -> Self {
@@ -642,11 +715,26 @@ impl<S: ReadableStorage> TryFrom<NodeStore<MutableProposal, S>> for NodeStore<Re
642715
type Error = FileIoError;
643716

644717
fn try_from(val: NodeStore<MutableProposal, S>) -> Result<Self, Self::Error> {
645-
let NodeStore {
646-
kind: _kind,
647-
storage: _storage,
648-
} = val;
649-
todo!("convert mutable proposal into reconstructed nodestore")
718+
let NodeStore { kind, storage } = val;
719+
720+
let mut nodestore = NodeStore {
721+
kind: Reconstructed { root: None },
722+
storage,
723+
};
724+
725+
let Some(root) = kind.root else {
726+
return Ok(nodestore);
727+
};
728+
729+
// Hash the trie with an empty path and keep the reconstructed root in-memory.
730+
#[cfg(feature = "ethhash")]
731+
let (root, root_hash) = nodestore.hash_helper(root, Path::new())?;
732+
#[cfg(not(feature = "ethhash"))]
733+
let (root, root_hash) = NodeStore::<MutableProposal, S>::hash_helper(root, Path::new())?;
734+
735+
nodestore.kind.root = Some(Child::MaybePersisted(root, root_hash));
736+
737+
Ok(nodestore)
650738
}
651739
}
652740

0 commit comments

Comments
 (0)