From 85a2ad700c7de1a77c86aa0e107d419cadf99a08 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 9 Apr 2026 13:25:26 -0400 Subject: [PATCH 1/3] refactor(hot-mdbx): replace FsiCache type alias with two-tier struct (ENG-2136) Replaces Arc> type alias with a two-tier FsiCache struct: lock-free linear scan over 9 known tables, RwLock fallback for dynamic tables. Updates callsites in tx.rs and lib.rs to use new API. Co-Authored-By: Claude Sonnet 4.6 --- crates/hot-mdbx/src/db_info.rs | 108 +++++++++++++++++++++++++++++++-- crates/hot-mdbx/src/lib.rs | 5 +- crates/hot-mdbx/src/tx.rs | 10 +-- 3 files changed, 111 insertions(+), 12 deletions(-) diff --git a/crates/hot-mdbx/src/db_info.rs b/crates/hot-mdbx/src/db_info.rs index 6511f3a..b19c442 100644 --- a/crates/hot-mdbx/src/db_info.rs +++ b/crates/hot-mdbx/src/db_info.rs @@ -1,15 +1,66 @@ use bytes::Buf; use parking_lot::RwLock; use signet_hot::ValSer; -use std::collections::HashMap; +use std::{collections::HashMap, sync::Arc}; + +/// Inner storage for the two-tier FSI cache. +/// +/// The `known` array holds pre-populated entries for the 9 standard tables, +/// searched via lock-free linear scan. The `dynamic` map holds entries for +/// tables created at runtime. +#[derive(Debug)] +struct FsiCacheInner { + /// Pre-populated at open time. Lock-free linear scan. + known: [(&'static str, FixedSizeInfo); 9], + /// Locking fallback for dynamically created tables. + dynamic: RwLock>, +} + +/// Two-tier cache for [`FixedSizeInfo`]. +/// +/// The fast path is a lock-free linear scan over the 9 known table entries. +/// The slow path acquires a `RwLock` for dynamically created tables. +#[derive(Debug, Clone)] +pub struct FsiCache(Arc); + +impl Default for FsiCache { + fn default() -> Self { + Self::new([("", FixedSizeInfo::None); 9]) + } +} -/// Type alias for the FixedSizeInfo cache. -pub type FsiCache = std::sync::Arc>>; +impl FsiCache { + /// Create a new `FsiCache` pre-populated with the known table entries. + pub fn new(known: [(&'static str, FixedSizeInfo); 9]) -> Self { + Self(Arc::new(FsiCacheInner { known, dynamic: RwLock::new(HashMap::new()) })) + } + + /// Look up a table's [`FixedSizeInfo`]. + /// + /// Checks the lock-free known array first, then the locked dynamic map. + /// Returns `None` if the table is not cached. + pub fn get(&self, name: &str) -> Option { + // Fast path: linear scan over known tables (no lock). + for &(known_name, fsi) in &self.0.known { + if known_name == name { + return Some(fsi); + } + } + // Slow path: check dynamic map. + self.0.dynamic.read().get(name).copied() + } + + /// Insert a dynamically created table's [`FixedSizeInfo`]. + pub fn insert_dynamic(&self, name: &'static str, fsi: FixedSizeInfo) { + self.0.dynamic.write().insert(name, fsi); + } +} /// Information about fixed size values in a database. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum FixedSizeInfo { /// Not a DUPSORT table. + #[default] None, /// DUPSORT table without DUP_FIXED (variable value size). DupSort { @@ -144,4 +195,53 @@ mod tests { other => panic!("expected InsufficientData, got: {other:?}"), } } + + #[test] + fn fsi_cache_known_path() { + let known = [ + ("TableA", FixedSizeInfo::None), + ("TableB", FixedSizeInfo::DupSort { key2_size: 32 }), + ("TableC", FixedSizeInfo::DupFixed { key2_size: 32, total_size: 64 }), + ("TableD", FixedSizeInfo::None), + ("TableE", FixedSizeInfo::None), + ("TableF", FixedSizeInfo::None), + ("TableG", FixedSizeInfo::None), + ("TableH", FixedSizeInfo::None), + ("TableI", FixedSizeInfo::None), + ]; + let cache = FsiCache::new(known); + + assert_eq!(cache.get("TableA"), Some(FixedSizeInfo::None)); + assert_eq!(cache.get("TableB"), Some(FixedSizeInfo::DupSort { key2_size: 32 })); + assert_eq!( + cache.get("TableC"), + Some(FixedSizeInfo::DupFixed { key2_size: 32, total_size: 64 }) + ); + // Unknown table returns None + assert_eq!(cache.get("Unknown"), None); + } + + #[test] + fn fsi_cache_dynamic_path() { + let known = [ + ("T1", FixedSizeInfo::None), + ("T2", FixedSizeInfo::None), + ("T3", FixedSizeInfo::None), + ("T4", FixedSizeInfo::None), + ("T5", FixedSizeInfo::None), + ("T6", FixedSizeInfo::None), + ("T7", FixedSizeInfo::None), + ("T8", FixedSizeInfo::None), + ("T9", FixedSizeInfo::None), + ]; + let cache = FsiCache::new(known); + + // Not in known set + assert_eq!(cache.get("DynTable"), None); + + // Insert dynamically + let fsi = FixedSizeInfo::DupSort { key2_size: 20 }; + cache.insert_dynamic("DynTable", fsi); + assert_eq!(cache.get("DynTable"), Some(fsi)); + } } diff --git a/crates/hot-mdbx/src/lib.rs b/crates/hot-mdbx/src/lib.rs index e792bfd..84222eb 100644 --- a/crates/hot-mdbx/src/lib.rs +++ b/crates/hot-mdbx/src/lib.rs @@ -51,12 +51,11 @@ #![deny(unused_must_use, rust_2018_idioms)] #![cfg_attr(docsrs, feature(doc_cfg))] -use parking_lot::RwLock; use signet_libmdbx::{ Environment, EnvironmentFlags, Geometry, Mode, Ro, RoSync, Rw, RwSync, SyncMode, ffi, sys::{HandleSlowReadersReturnCode, PageSize}, }; -use std::{collections::HashMap, ops::Range, path::Path, sync::Arc}; +use std::{ops::Range, path::Path}; mod cursor; pub use cursor::{Cursor, CursorRo, CursorRoSync, CursorRw, CursorRwSync}; @@ -366,7 +365,7 @@ impl DatabaseEnv { // https://github.com/paradigmxyz/reth/blob/fa2b9b685ed9787636d962f4366caf34a9186e66/crates/storage/libmdbx-rs/mdbx-sys/libmdbx/mdbx.c#L16017. inner_env.set_rp_augment_limit(256 * 1024); - let fsi_cache = Arc::new(RwLock::new(HashMap::new())); + let fsi_cache = FsiCache::default(); let env = Self { inner: inner_env.open(path)?, fsi_cache, _lock_file }; if kind.is_rw() { diff --git a/crates/hot-mdbx/src/tx.rs b/crates/hot-mdbx/src/tx.rs index 759acf0..d8f550e 100644 --- a/crates/hot-mdbx/src/tx.rs +++ b/crates/hot-mdbx/src/tx.rs @@ -54,13 +54,13 @@ impl Tx { /// Gets cached FixedSizeInfo for a table. pub fn get_fsi(&self, name: &'static str) -> Result { - // Fast path: read lock - if let Some(&fsi) = self.fsi_cache.read().get(name) { + // Fast path: lock-free scan over known tables, then locked dynamic map. + if let Some(fsi) = self.fsi_cache.get(name) { return Ok(fsi); } - // Slow path: read from table, then write lock + // Slow path: read from table, then insert into dynamic map. let fsi = self.read_fsi_from_table(name)?; - self.fsi_cache.write().insert(name, fsi); + self.fsi_cache.insert_dynamic(name, fsi); Ok(fsi) } @@ -135,7 +135,7 @@ impl Tx { fsi.encode_value_to(&mut value_buf.as_mut_slice()); self.inner.put(db, fsi_name_to_key(table).as_slice(), value_buf, WriteFlags::UPSERT)?; - self.fsi_cache.write().insert(table, fsi); + self.fsi_cache.insert_dynamic(table, fsi); Ok(()) } From 768728ffd86daf922821e647322d7363e15f8593 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 9 Apr 2026 13:28:00 -0400 Subject: [PATCH 2/3] feat(hot-mdbx): pre-populate FSI cache at DB open (ENG-2136) Read FSI metadata for all 9 known tables at open time and store them in the lock-free known array of FsiCache, eliminating the dynamic map fallback for standard tables on every transaction. Co-Authored-By: Claude Sonnet 4.6 --- crates/hot-mdbx/src/lib.rs | 81 ++++++++++++++++++++++++++++++-------- crates/hot-mdbx/src/tx.rs | 5 ++- 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/crates/hot-mdbx/src/lib.rs b/crates/hot-mdbx/src/lib.rs index 84222eb..e890527 100644 --- a/crates/hot-mdbx/src/lib.rs +++ b/crates/hot-mdbx/src/lib.rs @@ -77,7 +77,26 @@ pub use tx::Tx; mod utils; -use signet_hot::model::{HotKv, HotKvError, HotKvWrite}; +use signet_hot::{ + model::{HotKv, HotKvError, HotKvWrite}, + tables::{ + AccountChangeSets, AccountsHistory, Bytecodes, HeaderNumbers, Headers, PlainAccountState, + PlainStorageState, StorageChangeSets, StorageHistory, Table, + }, +}; + +/// The 9 known table names, used to pre-populate the FSI cache at open time. +const KNOWN_TABLE_NAMES: [&str; 9] = [ + Headers::NAME, + HeaderNumbers::NAME, + Bytecodes::NAME, + PlainAccountState::NAME, + PlainStorageState::NAME, + AccountsHistory::NAME, + AccountChangeSets::NAME, + StorageHistory::NAME, + StorageChangeSets::NAME, +]; /// 1 KB in bytes pub const KILOBYTE: usize = 1024; @@ -365,24 +384,15 @@ impl DatabaseEnv { // https://github.com/paradigmxyz/reth/blob/fa2b9b685ed9787636d962f4366caf34a9186e66/crates/storage/libmdbx-rs/mdbx-sys/libmdbx/mdbx.c#L16017. inner_env.set_rp_augment_limit(256 * 1024); - let fsi_cache = FsiCache::default(); - let env = Self { inner: inner_env.open(path)?, fsi_cache, _lock_file }; - - if kind.is_rw() { - env.create_tables()?; - } + let inner = inner_env.open(path)?; - Ok(env) - } + let fsi_cache = if kind.is_rw() { + create_tables_and_populate_cache(&inner)? + } else { + populate_cache_ro(&inner)? + }; - /// Create all standard hot storage tables. - /// - /// Called automatically when opening in read-write mode. - fn create_tables(&self) -> Result<(), MdbxError> { - let tx = self.tx_rw()?; - tx.queue_db_init()?; - tx.raw_commit()?; - Ok(()) + Ok(Self { inner, fsi_cache, _lock_file }) } /// Start a new read-only transaction. @@ -430,3 +440,40 @@ impl HotKv for DatabaseEnv { self.tx_rw().map_err(HotKvError::from_err) } } + +/// Create all standard hot storage tables and return a pre-populated +/// [`FsiCache`]. Called during RW open. +fn create_tables_and_populate_cache(env: &Environment) -> Result { + let inner_tx = env.begin_rw_unsync().map_err(MdbxError::Mdbx)?; + // Use a temporary empty FsiCache so the Tx can function during init. + // The cache returned by queue_db_init's store_fsi calls goes into the + // dynamic map, but we read the authoritative values below. + let tmp_cache = FsiCache::new(Default::default()); + let tx = Tx::new(inner_tx, tmp_cache); + tx.queue_db_init()?; + + let known = read_known_fsi(&tx)?; + tx.raw_commit()?; + Ok(FsiCache::new(known)) +} + +/// Read FSI entries for all known tables from the metadata table. +fn read_known_fsi( + tx: &Tx, +) -> Result<[(&'static str, FixedSizeInfo); 9], MdbxError> { + let mut known = [("", FixedSizeInfo::None); 9]; + for (i, &name) in KNOWN_TABLE_NAMES.iter().enumerate() { + known[i] = (name, tx.read_fsi_from_table(name)?); + } + Ok(known) +} + +/// Read FSI entries for all known tables via a temporary RO transaction. +/// Called during RO open. +fn populate_cache_ro(env: &Environment) -> Result { + let inner_tx = env.begin_ro_unsync().map_err(MdbxError::Mdbx)?; + let tmp_cache = FsiCache::new(Default::default()); + let tx = Tx::new(inner_tx, tmp_cache); + let known = read_known_fsi(&tx)?; + Ok(FsiCache::new(known)) +} diff --git a/crates/hot-mdbx/src/tx.rs b/crates/hot-mdbx/src/tx.rs index d8f550e..e62415f 100644 --- a/crates/hot-mdbx/src/tx.rs +++ b/crates/hot-mdbx/src/tx.rs @@ -40,7 +40,10 @@ impl Tx { } /// Reads FixedSizeInfo from the metadata table. - fn read_fsi_from_table(&self, name: &'static str) -> Result { + pub(crate) fn read_fsi_from_table( + &self, + name: &'static str, + ) -> Result { let db = self.inner.open_db(None)?; let data: [u8; 8] = self From 46f199f4b5b248163a34d385263aaa4cee210a36 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 9 Apr 2026 13:38:30 -0400 Subject: [PATCH 3/3] =?UTF-8?q?refactor(hot-mdbx):=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20NUM=5FTABLES=20constant,=20visibility,=20comments?= =?UTF-8?q?=20(ENG-2136)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add NUM_TABLES constant in signet-hot tables module, replace all hardcoded 9s - Make FsiCache pub(crate) — only used within the crate - Update stale field doc on fsi_cache to reflect pre-population at open - Clarify comment in create_tables_and_populate_cache about throwaway cache Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/hot-mdbx/src/db_info.rs | 18 +++++++++--------- crates/hot-mdbx/src/lib.rs | 31 ++++++++++++++++--------------- crates/hot/src/tables/mod.rs | 5 +++++ 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/crates/hot-mdbx/src/db_info.rs b/crates/hot-mdbx/src/db_info.rs index b19c442..1092b7f 100644 --- a/crates/hot-mdbx/src/db_info.rs +++ b/crates/hot-mdbx/src/db_info.rs @@ -1,37 +1,37 @@ use bytes::Buf; use parking_lot::RwLock; -use signet_hot::ValSer; +use signet_hot::{ValSer, tables::NUM_TABLES}; use std::{collections::HashMap, sync::Arc}; /// Inner storage for the two-tier FSI cache. /// -/// The `known` array holds pre-populated entries for the 9 standard tables, +/// The `known` array holds pre-populated entries for the standard tables, /// searched via lock-free linear scan. The `dynamic` map holds entries for /// tables created at runtime. #[derive(Debug)] struct FsiCacheInner { /// Pre-populated at open time. Lock-free linear scan. - known: [(&'static str, FixedSizeInfo); 9], + known: [(&'static str, FixedSizeInfo); NUM_TABLES], /// Locking fallback for dynamically created tables. dynamic: RwLock>, } /// Two-tier cache for [`FixedSizeInfo`]. /// -/// The fast path is a lock-free linear scan over the 9 known table entries. +/// The fast path is a lock-free linear scan over the known table entries. /// The slow path acquires a `RwLock` for dynamically created tables. #[derive(Debug, Clone)] -pub struct FsiCache(Arc); +pub(crate) struct FsiCache(Arc); impl Default for FsiCache { fn default() -> Self { - Self::new([("", FixedSizeInfo::None); 9]) + Self::new([("", FixedSizeInfo::None); NUM_TABLES]) } } impl FsiCache { /// Create a new `FsiCache` pre-populated with the known table entries. - pub fn new(known: [(&'static str, FixedSizeInfo); 9]) -> Self { + pub(crate) fn new(known: [(&'static str, FixedSizeInfo); NUM_TABLES]) -> Self { Self(Arc::new(FsiCacheInner { known, dynamic: RwLock::new(HashMap::new()) })) } @@ -39,7 +39,7 @@ impl FsiCache { /// /// Checks the lock-free known array first, then the locked dynamic map. /// Returns `None` if the table is not cached. - pub fn get(&self, name: &str) -> Option { + pub(crate) fn get(&self, name: &str) -> Option { // Fast path: linear scan over known tables (no lock). for &(known_name, fsi) in &self.0.known { if known_name == name { @@ -51,7 +51,7 @@ impl FsiCache { } /// Insert a dynamically created table's [`FixedSizeInfo`]. - pub fn insert_dynamic(&self, name: &'static str, fsi: FixedSizeInfo) { + pub(crate) fn insert_dynamic(&self, name: &'static str, fsi: FixedSizeInfo) { self.0.dynamic.write().insert(name, fsi); } } diff --git a/crates/hot-mdbx/src/lib.rs b/crates/hot-mdbx/src/lib.rs index e890527..9349604 100644 --- a/crates/hot-mdbx/src/lib.rs +++ b/crates/hot-mdbx/src/lib.rs @@ -61,7 +61,8 @@ mod cursor; pub use cursor::{Cursor, CursorRo, CursorRoSync, CursorRw, CursorRwSync}; mod db_info; -pub use db_info::{FixedSizeInfo, FsiCache}; +pub use db_info::FixedSizeInfo; +use db_info::FsiCache; mod error; pub use error::MdbxError; @@ -80,13 +81,13 @@ mod utils; use signet_hot::{ model::{HotKv, HotKvError, HotKvWrite}, tables::{ - AccountChangeSets, AccountsHistory, Bytecodes, HeaderNumbers, Headers, PlainAccountState, - PlainStorageState, StorageChangeSets, StorageHistory, Table, + AccountChangeSets, AccountsHistory, Bytecodes, HeaderNumbers, Headers, NUM_TABLES, + PlainAccountState, PlainStorageState, StorageChangeSets, StorageHistory, Table, }, }; -/// The 9 known table names, used to pre-populate the FSI cache at open time. -const KNOWN_TABLE_NAMES: [&str; 9] = [ +/// The known table names, used to pre-populate the FSI cache at open time. +const KNOWN_TABLE_NAMES: [&str; NUM_TABLES] = [ Headers::NAME, HeaderNumbers::NAME, Bytecodes::NAME, @@ -265,12 +266,11 @@ impl DatabaseArguments { pub struct DatabaseEnv { /// Libmdbx-sys environment. inner: Environment, - /// Cached FixedSizeInfo for tables. + /// Cached FixedSizeInfo for tables, pre-populated at open time. /// - /// Important: Do not manually close these DBIs, like via `mdbx_dbi_close`. - /// More generally, do not dynamically create, re-open, or drop tables at - /// runtime. It's better to perform table creation and migration only once - /// at startup. + /// The standard tables are created and their FSI entries cached during + /// [`DatabaseEnv::open`]. Do not manually close DBIs (e.g. via + /// `mdbx_dbi_close`) or dynamically drop tables at runtime. fsi_cache: FsiCache, /// Write lock for when dealing with a read-write environment. @@ -445,9 +445,10 @@ impl HotKv for DatabaseEnv { /// [`FsiCache`]. Called during RW open. fn create_tables_and_populate_cache(env: &Environment) -> Result { let inner_tx = env.begin_rw_unsync().map_err(MdbxError::Mdbx)?; - // Use a temporary empty FsiCache so the Tx can function during init. - // The cache returned by queue_db_init's store_fsi calls goes into the - // dynamic map, but we read the authoritative values below. + // Tx requires an FsiCache, so we pass a throwaway empty one. The FSI + // entries written by queue_db_init's store_fsi calls land in this + // temporary cache's dynamic map — they are discarded. We re-read the + // authoritative values from the metadata table via read_known_fsi. let tmp_cache = FsiCache::new(Default::default()); let tx = Tx::new(inner_tx, tmp_cache); tx.queue_db_init()?; @@ -460,8 +461,8 @@ fn create_tables_and_populate_cache(env: &Environment) -> Result( tx: &Tx, -) -> Result<[(&'static str, FixedSizeInfo); 9], MdbxError> { - let mut known = [("", FixedSizeInfo::None); 9]; +) -> Result<[(&'static str, FixedSizeInfo); NUM_TABLES], MdbxError> { + let mut known = [("", FixedSizeInfo::None); NUM_TABLES]; for (i, &name) in KNOWN_TABLE_NAMES.iter().enumerate() { known[i] = (name, tx.read_fsi_from_table(name)?); } diff --git a/crates/hot/src/tables/mod.rs b/crates/hot/src/tables/mod.rs index c139fa5..24edd22 100644 --- a/crates/hot/src/tables/mod.rs +++ b/crates/hot/src/tables/mod.rs @@ -5,6 +5,11 @@ mod macros; mod definitions; pub use definitions::*; +/// The number of standard hot storage tables created by +/// [`queue_db_init`](crate::model::HotKvWrite::queue_db_init). Update this +/// constant whenever a table is added to or removed from `queue_db_init`. +pub const NUM_TABLES: usize = 9; + use crate::{ DeserError, KeySer, MAX_FIXED_VAL_SIZE, MAX_KEY_SIZE, ValSer, model::{DualKeyValue, KeyValue},