Skip to main content

erigon_seg/
stack.rs

1//! A newest-wins stack of seg files spanning a step range.
2//!
3//! Erigon keeps a domain's state across several files covering successive *step* ranges
4//! (e.g. `v1.1-accounts.0-1024.kv`, `v1.1-accounts.1024-2048.kv`, …). A newer file may
5//! override a key carried by an older one, so a point lookup must consult the files
6//! newest-first and return the first hit. [`KvStack`] wraps an ordered set of
7//! [`KvReader`]s and implements exactly that semantics, resolving the `.kvei` bloom salt
8//! once and enabling each file's filter against it.
9
10use std::path::{Path, PathBuf};
11
12use crate::error::{Error, Result};
13use crate::hash::murmur3_x64_128_h1;
14use crate::reader::KvReader;
15use crate::salt::Salt;
16
17/// A stack of seg files for one domain, queried newest-first so overrides win.
18///
19/// The salt is resolved once at [`open`](KvStack::open) time (brute-forced from the
20/// oldest file when [`Salt::Find`] is used) and then applied to every file's bloom, so a
21/// wrong or absent salt only disables the negative-lookup speedup — it can never cause a
22/// missed key.
23pub struct KvStack {
24    /// Ordered oldest → newest; queried in reverse so the newest match wins.
25    readers: Vec<KvReader>,
26    /// The resolved salt, if one was supplied or found.
27    salt: Option<u32>,
28}
29
30impl KvStack {
31    /// Open an explicit set of `.kv` files as a newest-wins stack. The files are sorted
32    /// oldest → newest by their `<from>-<to>` step range (parsed from the file name), so
33    /// the caller need not pre-sort them. Errors if `paths` is empty or any file fails to
34    /// open.
35    ///
36    /// `salt` controls the `.kvei` bloom accelerator (see [`Salt`]): the salt is resolved
37    /// once (brute-forced from the oldest file for [`Salt::Find`]) and each file's bloom
38    /// is enabled only if it self-validates against that file's real keys.
39    pub fn open<I, P>(paths: I, salt: Salt) -> Result<KvStack>
40    where
41        I: IntoIterator<Item = P>,
42        P: AsRef<Path>,
43    {
44        let mut paths: Vec<PathBuf> = paths
45            .into_iter()
46            .map(|p| p.as_ref().to_path_buf())
47            .collect();
48        if paths.is_empty() {
49            return Err(Error::format("KvStack::open: no .kv files supplied"));
50        }
51        paths.sort_by_key(|p| step_key(p));
52        let mut readers = Vec::with_capacity(paths.len());
53        for p in &paths {
54            readers.push(KvReader::open(p)?);
55        }
56        let salt = resolve_and_enable(&mut readers, salt);
57        Ok(KvStack { readers, salt })
58    }
59
60    /// Open every `.kv` in `dir` whose file name contains `name_filter`, as a newest-wins
61    /// stack. Use `name_filter` to select a single domain (e.g. `"accounts"`) so files
62    /// from different domains in the same directory are not mixed. Errors if no matching
63    /// `.kv` file is found.
64    pub fn open_dir(dir: impl AsRef<Path>, name_filter: &str, salt: Salt) -> Result<KvStack> {
65        let dir = dir.as_ref();
66        let mut kvs: Vec<PathBuf> = std::fs::read_dir(dir)
67            .map_err(|e| Error::format(format!("read_dir {}: {e}", dir.display())))?
68            .filter_map(|e| e.ok().map(|e| e.path()))
69            .filter(|p| {
70                if p.extension().is_none_or(|x| x != "kv") {
71                    return false;
72                }
73                p.file_name()
74                    .map(|s| s.to_string_lossy().contains(name_filter))
75                    .unwrap_or(false)
76            })
77            .collect();
78        if kvs.is_empty() {
79            return Err(Error::format(format!(
80                "no .kv files matching {name_filter:?} in {}",
81                dir.display()
82            )));
83        }
84        kvs.sort_by_key(|p| step_key(p));
85        KvStack::open(kvs, salt)
86    }
87
88    /// The resolved bloom salt, if one was supplied or found.
89    pub fn salt(&self) -> Option<u32> {
90        self.salt
91    }
92
93    /// Number of files with an active (validated) bloom accelerator.
94    pub fn bloom_count(&self) -> usize {
95        self.readers.iter().filter(|r| r.bloom_active()).count()
96    }
97
98    /// Number of files in the stack.
99    pub fn len(&self) -> usize {
100        self.readers.len()
101    }
102
103    /// Whether the stack has no files. (Never true for a stack from [`open`](KvStack::open),
104    /// which rejects an empty input.)
105    pub fn is_empty(&self) -> bool {
106        self.readers.is_empty()
107    }
108
109    /// The readers, oldest → newest.
110    pub fn readers(&self) -> &[KvReader] {
111        &self.readers
112    }
113
114    /// Iterate `(file name, key count)` for each file, oldest → newest.
115    pub fn files(&self) -> impl Iterator<Item = (&str, u64)> {
116        self.readers.iter().map(|r| (r.name(), r.key_count()))
117    }
118
119    /// Look up `key` across all files, newest-first; returns the value from the newest
120    /// file that contains it (overrides win), or `None` if no file has it.
121    pub fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>> {
122        // One salt covers the whole stack, so the bloom hash is the same for every file:
123        // compute it once instead of re-hashing the key per reader.
124        let key_hash = self.salt.map(|s| murmur3_x64_128_h1(key, s));
125        for r in self.readers.iter().rev() {
126            // Only reuse the hash for readers whose bloom was enabled with that salt.
127            let h = key_hash.filter(|_| r.salt() == self.salt);
128            if let Some(v) = r.get_hashed(key, h) {
129                return Ok(Some(v));
130            }
131        }
132        Ok(None)
133    }
134
135    /// Advise the kernel that every file in the stack is read by point lookup. See
136    /// [`KvReader::advise_random`] for when this is worth setting.
137    pub fn advise_random(&self) -> std::io::Result<()> {
138        for r in &self.readers {
139            r.advise_random()?;
140        }
141        Ok(())
142    }
143
144    /// Total bytes [`preload_index`](KvStack::preload_index) would make resident across
145    /// the stack. A stack lookup consults every file, so this is the figure to budget.
146    pub fn index_bytes(&self) -> u64 {
147        self.readers.iter().map(|r| r.index_bytes()).sum()
148    }
149
150    /// Preload every file's index into the page cache. See
151    /// [`KvReader::preload_index`] — the case for it is stronger here, because a stack
152    /// lookup searches each file in turn until one hits.
153    pub fn preload_index(&self) -> u64 {
154        self.readers.iter().map(|r| r.preload_index()).sum()
155    }
156
157    /// Pin every file's index in RAM. See [`KvReader::lock_index`] for the
158    /// `RLIMIT_MEMLOCK` caveat, which applies to the stack's total.
159    pub fn lock_index(&self) -> std::io::Result<()> {
160        for r in &self.readers {
161            r.lock_index()?;
162        }
163        Ok(())
164    }
165
166    /// Release the pages pinned by [`lock_index`](KvStack::lock_index).
167    pub fn unlock_index(&self) -> std::io::Result<()> {
168        for r in &self.readers {
169            r.unlock_index()?;
170        }
171        Ok(())
172    }
173}
174
175/// Resolve the salt once (brute-forcing from the oldest file for [`Salt::Find`]) and
176/// enable each file's bloom against it. Returns the resolved salt, if any.
177fn resolve_and_enable(readers: &mut [KvReader], salt: Salt) -> Option<u32> {
178    let resolved = match salt {
179        Salt::None => None,
180        Salt::Known(s) => Some(s),
181        Salt::Find(threads) => readers.first().and_then(|r| r.find_salt(threads)),
182    };
183    if let Some(s) = resolved {
184        for r in readers.iter_mut() {
185            r.enable_bloom(Salt::Known(s));
186        }
187    }
188    resolved
189}
190
191/// Sort key from a `…<name>.<from>-<to>.kv` file name: the `<from>` step (then `<to>`),
192/// so files order oldest → newest (override files have a higher `<from>`). Names without
193/// a recognizable range sort first.
194fn step_key(p: &Path) -> (u64, u64) {
195    let name = p
196        .file_name()
197        .map(|s| s.to_string_lossy().into_owned())
198        .unwrap_or_default();
199    for seg in name.split('.') {
200        if let Some((a, b)) = seg.split_once('-')
201            && let (Ok(a), Ok(b)) = (a.parse::<u64>(), b.parse::<u64>())
202        {
203            return (a, b);
204        }
205    }
206    (0, 0)
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use std::path::Path;
213
214    #[test]
215    fn step_key_parses_range() {
216        assert_eq!(step_key(Path::new("v1.1-accounts.0-1024.kv")), (0, 1024));
217        assert_eq!(
218            step_key(Path::new("v1.1-accounts.1024-2048.kv")),
219            (1024, 2048)
220        );
221        // No range segment -> sorts first.
222        assert_eq!(step_key(Path::new("accounts.kv")), (0, 0));
223    }
224
225    #[test]
226    fn empty_open_errors() {
227        let empty: Vec<&Path> = Vec::new();
228        assert!(KvStack::open(empty, Salt::None).is_err());
229    }
230}