erigon-seg 1.1.0

Read, query, write, and merge the Erigon 3 seg state file format: .kv (seg-compressed words), .bt (B-tree / Elias-Fano index), and .kvei (existence/bloom filter).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
//! High-level reader over a `.kv` + sibling `.bt` + `.kvei` triple.
//!
//! [`KvReader`] is the main entry point: it opens the data file and, when present, its
//! B-tree index and existence filter, then answers point lookups ([`get`](KvReader::get))
//! and sequential scans ([`iter`](KvReader::iter)).

use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};

use crate::bloom::ExistenceFilter;
use crate::btree::BtreeIndex;
use crate::error::Result;
use crate::hash::murmur3_x64_128_h1;
use crate::salt::Salt;
use crate::seg::{Getter, OpenOptions, Seg};

/// A reader over one seg file set (`.kv` data, optional `.bt` index, optional `.kvei`
/// existence filter).
pub struct KvReader {
    seg: Seg,
    index: Option<BtreeIndex>,
    bloom: Option<ExistenceFilter>,
    /// Active salt: `Some` only once a bloom has been validated via [`enable_bloom`].
    salt: Option<u32>,
    /// The `.kv` file's base name (e.g. `v1.1-accounts.0-1024.kv`), for display.
    name: String,
}

impl KvReader {
    /// Open a `.kv` file and any sibling `.bt` / `.kvei` files found next to it (same
    /// base name). The existence filter is loaded but not used for lookups until a salt
    /// is supplied via [`enable_bloom`](KvReader::enable_bloom).
    pub fn open(kv_path: impl AsRef<Path>) -> Result<KvReader> {
        KvReader::open_with(kv_path, OpenOptions::default())
    }

    /// Like [`open`](KvReader::open) but with explicit seg [`OpenOptions`] (e.g. for
    /// files carrying out-of-band metadata).
    pub fn open_with(kv_path: impl AsRef<Path>, opts: OpenOptions) -> Result<KvReader> {
        let kv_path = kv_path.as_ref();
        let seg = Seg::open_with(kv_path, opts)?;

        let bt_path = kv_path.with_extension("bt");
        let index = if bt_path.exists() {
            Some(BtreeIndex::open(&bt_path)?)
        } else {
            None
        };

        let kvei_path = kv_path.with_extension("kvei");
        let bloom = if kvei_path.exists() {
            Some(ExistenceFilter::open(&kvei_path)?)
        } else {
            None
        };

        let name = kv_path
            .file_name()
            .map(|s| s.to_string_lossy().into_owned())
            .unwrap_or_default();

        Ok(KvReader {
            seg,
            index,
            bloom,
            salt: None,
            name,
        })
    }

    /// The `.kv` file's base name (e.g. `v1.1-accounts.0-1024.kv`).
    pub fn name(&self) -> &str {
        &self.name
    }

    /// Whether the bloom filter is active for lookups — i.e. a `.kvei` is present and a
    /// salt has been validated against real keys via [`enable_bloom`](KvReader::enable_bloom).
    pub fn bloom_active(&self) -> bool {
        self.salt.is_some()
    }

    /// The underlying seg data file.
    pub fn seg(&self) -> &Seg {
        &self.seg
    }

    /// The B-tree index, if a `.bt` was found.
    pub fn index(&self) -> Option<&BtreeIndex> {
        self.index.as_ref()
    }

    /// The existence filter, if a `.kvei` was found.
    pub fn existence_filter(&self) -> Option<&ExistenceFilter> {
        self.bloom.as_ref()
    }

    /// The active bloom salt, if [`enable_bloom`](KvReader::enable_bloom) has succeeded.
    pub fn salt(&self) -> Option<u32> {
        self.salt
    }

    /// Number of keys: from the `.bt` index if present, otherwise inferred as
    /// `words_count / 2` (domain files store alternating key/value words).
    pub fn key_count(&self) -> u64 {
        match &self.index {
            Some(idx) => idx.key_count(),
            None => self.seg.words_count() / 2,
        }
    }

    /// Enable the `.kvei` bloom as a negative-lookup accelerator, resolving the salt per
    /// [`Salt`]. Returns `true` only if a usable bloom is present and the resolved salt
    /// self-validates against real keys (so a wrong salt can never cause a missed key —
    /// it just leaves lookups unaccelerated).
    pub fn enable_bloom(&mut self, salt: Salt) -> bool {
        let Some(bloom) = &self.bloom else {
            return false;
        };
        if !bloom.is_accelerating() {
            return false;
        }
        let resolved = match salt {
            Salt::None => return false,
            Salt::Known(s) => s,
            Salt::Find(threads) => match self.find_salt(threads) {
                Some(s) => s,
                None => return false,
            },
        };
        let samples = self.sample_keys(64);
        if samples.is_empty() {
            return false;
        }
        let ok = samples
            .iter()
            .all(|k| bloom.contains_hash(murmur3_x64_128_h1(k, resolved)));
        if ok {
            self.salt = Some(resolved);
        }
        ok
    }

    /// Brute-force the bloom salt by requiring a batch of real keys to all hit the
    /// filter, using `threads` workers. Returns `None` if no `.kvei` bloom is usable or
    /// no salt validates (e.g. a fuse-filter or format mismatch).
    pub fn find_salt(&self, threads: usize) -> Option<u32> {
        let bloom = self.bloom.as_ref()?;
        if !bloom.is_accelerating() {
            return None;
        }
        let samples = self.sample_keys(16);
        if samples.is_empty() {
            return None;
        }
        let threads = threads.clamp(1, 256) as u32;
        let found = AtomicU64::new(u64::MAX);
        std::thread::scope(|sc| {
            for t in 0..threads {
                let (found, bloom, samples) = (&found, bloom, &samples);
                sc.spawn(move || {
                    let mut salt = t;
                    loop {
                        if found.load(Ordering::Relaxed) != u64::MAX {
                            return;
                        }
                        if samples
                            .iter()
                            .all(|k| bloom.contains_hash(murmur3_x64_128_h1(k, salt)))
                        {
                            found.fetch_min(salt as u64, Ordering::Relaxed);
                            return;
                        }
                        match salt.checked_add(threads) {
                            Some(s) => salt = s,
                            None => return,
                        }
                    }
                });
            }
        });
        let f = found.load(Ordering::Relaxed);
        (f != u64::MAX).then_some(f as u32)
    }

    /// Advise the kernel that this file set is read by point lookup, so a page fault
    /// should read one page instead of a read-ahead window.
    ///
    /// A binary search touches a handful of scattered pages, and the kernel's default
    /// fault-around then reads far more than is used — on a file much larger than RAM
    /// that read amplification dominates lookup latency. This is *not* the default,
    /// because suppressing read-ahead is a regression for a file small enough to sit in
    /// the page cache, where the surplus pages get used by later lookups anyway. Set it
    /// when the data is large relative to RAM; leave it alone otherwise.
    ///
    /// Advice is a hint: errors are reported but ignoring them is safe, and on platforms
    /// without `madvise` this does nothing.
    pub fn advise_random(&self) -> std::io::Result<()> {
        self.seg.advise_random()?;
        if let Some(idx) = &self.index {
            idx.advise_random()?;
        }
        if let Some(bloom) = &self.bloom {
            bloom.advise_random()?;
        }
        Ok(())
    }

    /// Advise the kernel that this `.kv` is about to be read front to back — before an
    /// [`iter`](KvReader::iter) or a merge — so read-ahead works in your favour.
    pub fn advise_sequential(&self) -> std::io::Result<()> {
        self.seg.advise_sequential()
    }

    /// Bytes [`preload_index`](KvReader::preload_index) would make resident: the `.bt`,
    /// plus the `.kvei` when the bloom is active. Use it to budget before calling.
    pub fn index_bytes(&self) -> u64 {
        let bt = self.index.as_ref().map_or(0, |i| i.mapped_bytes());
        let kvei = match (&self.bloom, self.salt) {
            (Some(b), Some(_)) => b.mapped_bytes(),
            _ => 0,
        };
        bt + kvei
    }

    /// Read the index files into the page cache and return once they are resident,
    /// reporting how many bytes were loaded.
    ///
    /// A point lookup touches the `.bt` far more than the `.kv` — every search
    /// comparison reads the Elias-Fano offset array, while only the final block of keys
    /// is decompressed — and the `.bt` is one to two orders of magnitude smaller. On a
    /// machine with RAM to spare, holding the whole index resident removes nearly all
    /// the remaining faults: on a 37 GiB file set with a 1.4 GiB `.bt`, cold lookups
    /// went from ~440 µs to ~155 µs, for a one-off ~0.7 s load.
    ///
    /// The `.kvei` is included only when the bloom is active, since otherwise it is
    /// never read. Loading is a hint to the kernel, not a reservation: these pages can
    /// still be evicted under memory pressure — see [`lock_index`](KvReader::lock_index)
    /// to prevent that.
    pub fn preload_index(&self) -> u64 {
        let mut n = 0;
        if let Some(idx) = &self.index {
            n += idx.preload();
        }
        if self.salt.is_some()
            && let Some(bloom) = &self.bloom
        {
            n += bloom.preload();
        }
        n
    }

    /// Pin the index files in RAM with `mlock`, so the kernel cannot evict them.
    ///
    /// Stronger than [`preload_index`](KvReader::preload_index), and worth it when a
    /// large `.kv` is streaming through the page cache and would otherwise push the
    /// index back out. Same file selection: the `.bt`, plus the `.kvei` when the bloom
    /// is active.
    ///
    /// Fails with `ENOMEM` (or `EPERM`) if the total exceeds `RLIMIT_MEMLOCK`, which is
    /// commonly a few megabytes by default; check [`index_bytes`](KvReader::index_bytes)
    /// against `ulimit -l` first. A failure is safe to ignore — it just leaves the pages
    /// evictable — but note the limit applies per process across every locked mapping.
    ///
    /// Unix only: elsewhere this reports [`ErrorKind::Unsupported`] rather than quietly
    /// doing nothing, since the point of the call is a guarantee.
    /// [`preload_index`](KvReader::preload_index) still works everywhere.
    ///
    /// [`ErrorKind::Unsupported`]: std::io::ErrorKind::Unsupported
    ///
    /// Preloads first: `mlock` faults the pages in itself, but page-at-a-time, so
    /// warming them sequentially beforehand is markedly faster.
    pub fn lock_index(&self) -> std::io::Result<()> {
        self.preload_index();
        if let Some(idx) = &self.index {
            idx.lock()?;
        }
        if self.salt.is_some()
            && let Some(bloom) = &self.bloom
        {
            bloom.lock()?;
        }
        Ok(())
    }

    /// Release the pages pinned by [`lock_index`](KvReader::lock_index).
    pub fn unlock_index(&self) -> std::io::Result<()> {
        if let Some(idx) = &self.index {
            idx.unlock()?;
        }
        if let Some(bloom) = &self.bloom {
            bloom.unlock()?;
        }
        Ok(())
    }

    /// Look up `key`, returning its value if present.
    ///
    /// Uses, in order: the bloom filter for a fast definite-absent answer (if enabled),
    /// then the `.bt` index for an `O(log n)` binary search, or — if there is no index —
    /// an ordered linear scan.
    pub fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>> {
        Ok(self.get_hashed(key, None))
    }

    /// [`get`](KvReader::get) with the bloom hash supplied by the caller.
    ///
    /// `key_hash` must be `murmur3_x64_128_h1(key, salt)` for *this* reader's active
    /// salt; pass `None` to compute it here. [`KvStack`](crate::KvStack) uses this to
    /// hash a key once and reuse it across every file, since one salt covers the stack.
    pub(crate) fn get_hashed(&self, key: &[u8], key_hash: Option<u64>) -> Option<Vec<u8>> {
        // Fast negative.
        if let Some(salt) = self.salt
            && let Some(bloom) = &self.bloom
            && !bloom.contains_hash(key_hash.unwrap_or_else(|| murmur3_x64_128_h1(key, salt)))
        {
            return None;
        }
        match &self.index {
            Some(idx) => self.get_indexed(idx, key),
            None => self.get_scan(key),
        }
    }

    fn get_indexed(&self, idx: &BtreeIndex, key: &[u8]) -> Option<Vec<u8>> {
        if idx.key_count() == 0 {
            return None;
        }
        // The `.bt` di-nodes cut the search to the one M-key block that can hold `key`,
        // using in-memory comparisons; without them this is the full range.
        let (mut lo, mut hi) = idx.narrow(key);
        let mut g = self.seg.getter();
        while lo < hi {
            let mid = lo + (hi - lo) / 2;
            let off = idx.key_offset(mid)?;
            g.reset(off);
            if !g.has_next() {
                return None;
            }
            let probe = g.next();
            match probe.as_slice().cmp(key) {
                std::cmp::Ordering::Less => lo = mid + 1,
                std::cmp::Ordering::Greater => hi = mid,
                std::cmp::Ordering::Equal => {
                    return Some(if g.has_next() { g.next() } else { Vec::new() });
                }
            }
        }
        None
    }

    fn get_scan(&self, key: &[u8]) -> Option<Vec<u8>> {
        let mut g = self.seg.getter();
        while g.has_next() {
            let k = g.next();
            match k.as_slice().cmp(key) {
                // Skipping the value avoids decompressing and allocating a word we are
                // about to discard — half the words in the file, on a miss.
                std::cmp::Ordering::Less => {
                    if g.has_next() {
                        g.skip();
                    }
                }
                std::cmp::Ordering::Greater => return None, // keys are sorted
                std::cmp::Ordering::Equal => {
                    return Some(if g.has_next() { g.next() } else { Vec::new() });
                }
            }
        }
        None
    }

    /// Sample up to `n` real keys spread across the file, for salt validation / search.
    /// Each returned key is genuinely present, so a correct bloom must contain it.
    fn sample_keys(&self, n: usize) -> Vec<Vec<u8>> {
        match &self.index {
            Some(idx) => {
                let count = idx.key_count();
                if count == 0 {
                    return Vec::new();
                }
                let n = (n as u64).min(count);
                let mut g = self.seg.getter();
                (0..n)
                    .filter_map(|s| {
                        let di = s * count / n;
                        idx.key_offset(di).map(|off| {
                            g.reset(off);
                            g.next()
                        })
                    })
                    .collect()
            }
            None => {
                // No index: take the first `n` keys by scanning.
                let mut g = self.seg.getter();
                let mut out = Vec::new();
                while out.len() < n && g.has_next() {
                    out.push(g.next());
                    if g.has_next() {
                        g.next(); // skip value
                    }
                }
                out
            }
        }
    }

    /// Iterate every `(key, value)` pair sequentially, in stored (key) order.
    pub fn iter(&self) -> KvIter<'_> {
        KvIter {
            getter: self.seg.getter(),
        }
    }
}

/// Iterator over the `(key, value)` pairs of a [`KvReader`], in stored order.
pub struct KvIter<'a> {
    getter: Getter<'a>,
}

impl Iterator for KvIter<'_> {
    type Item = Result<(Vec<u8>, Vec<u8>)>;

    fn next(&mut self) -> Option<Self::Item> {
        if !self.getter.has_next() {
            return None;
        }
        let key = self.getter.next();
        let value = if self.getter.has_next() {
            self.getter.next()
        } else {
            Vec::new()
        };
        Some(Ok((key, value)))
    }
}