erigon_seg/reader.rs
1//! High-level reader over a `.kv` + sibling `.bt` + `.kvei` triple.
2//!
3//! [`KvReader`] is the main entry point: it opens the data file and, when present, its
4//! B-tree index and existence filter, then answers point lookups ([`get`](KvReader::get))
5//! and sequential scans ([`iter`](KvReader::iter)).
6
7use std::path::Path;
8use std::sync::atomic::{AtomicU64, Ordering};
9
10use crate::bloom::ExistenceFilter;
11use crate::btree::BtreeIndex;
12use crate::error::Result;
13use crate::hash::murmur3_x64_128_h1;
14use crate::salt::Salt;
15use crate::seg::{Getter, OpenOptions, Seg};
16
17/// A reader over one seg file set (`.kv` data, optional `.bt` index, optional `.kvei`
18/// existence filter).
19pub struct KvReader {
20 seg: Seg,
21 index: Option<BtreeIndex>,
22 bloom: Option<ExistenceFilter>,
23 /// Active salt: `Some` only once a bloom has been validated via [`enable_bloom`].
24 salt: Option<u32>,
25 /// The `.kv` file's base name (e.g. `v1.1-accounts.0-1024.kv`), for display.
26 name: String,
27}
28
29impl KvReader {
30 /// Open a `.kv` file and any sibling `.bt` / `.kvei` files found next to it (same
31 /// base name). The existence filter is loaded but not used for lookups until a salt
32 /// is supplied via [`enable_bloom`](KvReader::enable_bloom).
33 pub fn open(kv_path: impl AsRef<Path>) -> Result<KvReader> {
34 KvReader::open_with(kv_path, OpenOptions::default())
35 }
36
37 /// Like [`open`](KvReader::open) but with explicit seg [`OpenOptions`] (e.g. for
38 /// files carrying out-of-band metadata).
39 pub fn open_with(kv_path: impl AsRef<Path>, opts: OpenOptions) -> Result<KvReader> {
40 let kv_path = kv_path.as_ref();
41 let seg = Seg::open_with(kv_path, opts)?;
42
43 let bt_path = kv_path.with_extension("bt");
44 let index = if bt_path.exists() {
45 Some(BtreeIndex::open(&bt_path)?)
46 } else {
47 None
48 };
49
50 let kvei_path = kv_path.with_extension("kvei");
51 let bloom = if kvei_path.exists() {
52 Some(ExistenceFilter::open(&kvei_path)?)
53 } else {
54 None
55 };
56
57 let name = kv_path
58 .file_name()
59 .map(|s| s.to_string_lossy().into_owned())
60 .unwrap_or_default();
61
62 Ok(KvReader {
63 seg,
64 index,
65 bloom,
66 salt: None,
67 name,
68 })
69 }
70
71 /// The `.kv` file's base name (e.g. `v1.1-accounts.0-1024.kv`).
72 pub fn name(&self) -> &str {
73 &self.name
74 }
75
76 /// Whether the bloom filter is active for lookups — i.e. a `.kvei` is present and a
77 /// salt has been validated against real keys via [`enable_bloom`](KvReader::enable_bloom).
78 pub fn bloom_active(&self) -> bool {
79 self.salt.is_some()
80 }
81
82 /// The underlying seg data file.
83 pub fn seg(&self) -> &Seg {
84 &self.seg
85 }
86
87 /// The B-tree index, if a `.bt` was found.
88 pub fn index(&self) -> Option<&BtreeIndex> {
89 self.index.as_ref()
90 }
91
92 /// The existence filter, if a `.kvei` was found.
93 pub fn existence_filter(&self) -> Option<&ExistenceFilter> {
94 self.bloom.as_ref()
95 }
96
97 /// The active bloom salt, if [`enable_bloom`](KvReader::enable_bloom) has succeeded.
98 pub fn salt(&self) -> Option<u32> {
99 self.salt
100 }
101
102 /// Number of keys: from the `.bt` index if present, otherwise inferred as
103 /// `words_count / 2` (domain files store alternating key/value words).
104 pub fn key_count(&self) -> u64 {
105 match &self.index {
106 Some(idx) => idx.key_count(),
107 None => self.seg.words_count() / 2,
108 }
109 }
110
111 /// Enable the `.kvei` bloom as a negative-lookup accelerator, resolving the salt per
112 /// [`Salt`]. Returns `true` only if a usable bloom is present and the resolved salt
113 /// self-validates against real keys (so a wrong salt can never cause a missed key —
114 /// it just leaves lookups unaccelerated).
115 pub fn enable_bloom(&mut self, salt: Salt) -> bool {
116 let Some(bloom) = &self.bloom else {
117 return false;
118 };
119 if !bloom.is_accelerating() {
120 return false;
121 }
122 let resolved = match salt {
123 Salt::None => return false,
124 Salt::Known(s) => s,
125 Salt::Find(threads) => match self.find_salt(threads) {
126 Some(s) => s,
127 None => return false,
128 },
129 };
130 let samples = self.sample_keys(64);
131 if samples.is_empty() {
132 return false;
133 }
134 let ok = samples
135 .iter()
136 .all(|k| bloom.contains_hash(murmur3_x64_128_h1(k, resolved)));
137 if ok {
138 self.salt = Some(resolved);
139 }
140 ok
141 }
142
143 /// Brute-force the bloom salt by requiring a batch of real keys to all hit the
144 /// filter, using `threads` workers. Returns `None` if no `.kvei` bloom is usable or
145 /// no salt validates (e.g. a fuse-filter or format mismatch).
146 pub fn find_salt(&self, threads: usize) -> Option<u32> {
147 let bloom = self.bloom.as_ref()?;
148 if !bloom.is_accelerating() {
149 return None;
150 }
151 let samples = self.sample_keys(16);
152 if samples.is_empty() {
153 return None;
154 }
155 let threads = threads.clamp(1, 256) as u32;
156 let found = AtomicU64::new(u64::MAX);
157 std::thread::scope(|sc| {
158 for t in 0..threads {
159 let (found, bloom, samples) = (&found, bloom, &samples);
160 sc.spawn(move || {
161 let mut salt = t;
162 loop {
163 if found.load(Ordering::Relaxed) != u64::MAX {
164 return;
165 }
166 if samples
167 .iter()
168 .all(|k| bloom.contains_hash(murmur3_x64_128_h1(k, salt)))
169 {
170 found.fetch_min(salt as u64, Ordering::Relaxed);
171 return;
172 }
173 match salt.checked_add(threads) {
174 Some(s) => salt = s,
175 None => return,
176 }
177 }
178 });
179 }
180 });
181 let f = found.load(Ordering::Relaxed);
182 (f != u64::MAX).then_some(f as u32)
183 }
184
185 /// Advise the kernel that this file set is read by point lookup, so a page fault
186 /// should read one page instead of a read-ahead window.
187 ///
188 /// A binary search touches a handful of scattered pages, and the kernel's default
189 /// fault-around then reads far more than is used — on a file much larger than RAM
190 /// that read amplification dominates lookup latency. This is *not* the default,
191 /// because suppressing read-ahead is a regression for a file small enough to sit in
192 /// the page cache, where the surplus pages get used by later lookups anyway. Set it
193 /// when the data is large relative to RAM; leave it alone otherwise.
194 ///
195 /// Advice is a hint: errors are reported but ignoring them is safe, and on platforms
196 /// without `madvise` this does nothing.
197 pub fn advise_random(&self) -> std::io::Result<()> {
198 self.seg.advise_random()?;
199 if let Some(idx) = &self.index {
200 idx.advise_random()?;
201 }
202 if let Some(bloom) = &self.bloom {
203 bloom.advise_random()?;
204 }
205 Ok(())
206 }
207
208 /// Advise the kernel that this `.kv` is about to be read front to back — before an
209 /// [`iter`](KvReader::iter) or a merge — so read-ahead works in your favour.
210 pub fn advise_sequential(&self) -> std::io::Result<()> {
211 self.seg.advise_sequential()
212 }
213
214 /// Bytes [`preload_index`](KvReader::preload_index) would make resident: the `.bt`,
215 /// plus the `.kvei` when the bloom is active. Use it to budget before calling.
216 pub fn index_bytes(&self) -> u64 {
217 let bt = self.index.as_ref().map_or(0, |i| i.mapped_bytes());
218 let kvei = match (&self.bloom, self.salt) {
219 (Some(b), Some(_)) => b.mapped_bytes(),
220 _ => 0,
221 };
222 bt + kvei
223 }
224
225 /// Read the index files into the page cache and return once they are resident,
226 /// reporting how many bytes were loaded.
227 ///
228 /// A point lookup touches the `.bt` far more than the `.kv` — every search
229 /// comparison reads the Elias-Fano offset array, while only the final block of keys
230 /// is decompressed — and the `.bt` is one to two orders of magnitude smaller. On a
231 /// machine with RAM to spare, holding the whole index resident removes nearly all
232 /// the remaining faults: on a 37 GiB file set with a 1.4 GiB `.bt`, cold lookups
233 /// went from ~440 µs to ~155 µs, for a one-off ~0.7 s load.
234 ///
235 /// The `.kvei` is included only when the bloom is active, since otherwise it is
236 /// never read. Loading is a hint to the kernel, not a reservation: these pages can
237 /// still be evicted under memory pressure — see [`lock_index`](KvReader::lock_index)
238 /// to prevent that.
239 pub fn preload_index(&self) -> u64 {
240 let mut n = 0;
241 if let Some(idx) = &self.index {
242 n += idx.preload();
243 }
244 if self.salt.is_some()
245 && let Some(bloom) = &self.bloom
246 {
247 n += bloom.preload();
248 }
249 n
250 }
251
252 /// Pin the index files in RAM with `mlock`, so the kernel cannot evict them.
253 ///
254 /// Stronger than [`preload_index`](KvReader::preload_index), and worth it when a
255 /// large `.kv` is streaming through the page cache and would otherwise push the
256 /// index back out. Same file selection: the `.bt`, plus the `.kvei` when the bloom
257 /// is active.
258 ///
259 /// Fails with `ENOMEM` (or `EPERM`) if the total exceeds `RLIMIT_MEMLOCK`, which is
260 /// commonly a few megabytes by default; check [`index_bytes`](KvReader::index_bytes)
261 /// against `ulimit -l` first. A failure is safe to ignore — it just leaves the pages
262 /// evictable — but note the limit applies per process across every locked mapping.
263 ///
264 /// Unix only: elsewhere this reports [`ErrorKind::Unsupported`] rather than quietly
265 /// doing nothing, since the point of the call is a guarantee.
266 /// [`preload_index`](KvReader::preload_index) still works everywhere.
267 ///
268 /// [`ErrorKind::Unsupported`]: std::io::ErrorKind::Unsupported
269 ///
270 /// Preloads first: `mlock` faults the pages in itself, but page-at-a-time, so
271 /// warming them sequentially beforehand is markedly faster.
272 pub fn lock_index(&self) -> std::io::Result<()> {
273 self.preload_index();
274 if let Some(idx) = &self.index {
275 idx.lock()?;
276 }
277 if self.salt.is_some()
278 && let Some(bloom) = &self.bloom
279 {
280 bloom.lock()?;
281 }
282 Ok(())
283 }
284
285 /// Release the pages pinned by [`lock_index`](KvReader::lock_index).
286 pub fn unlock_index(&self) -> std::io::Result<()> {
287 if let Some(idx) = &self.index {
288 idx.unlock()?;
289 }
290 if let Some(bloom) = &self.bloom {
291 bloom.unlock()?;
292 }
293 Ok(())
294 }
295
296 /// Look up `key`, returning its value if present.
297 ///
298 /// Uses, in order: the bloom filter for a fast definite-absent answer (if enabled),
299 /// then the `.bt` index for an `O(log n)` binary search, or — if there is no index —
300 /// an ordered linear scan.
301 pub fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>> {
302 Ok(self.get_hashed(key, None))
303 }
304
305 /// [`get`](KvReader::get) with the bloom hash supplied by the caller.
306 ///
307 /// `key_hash` must be `murmur3_x64_128_h1(key, salt)` for *this* reader's active
308 /// salt; pass `None` to compute it here. [`KvStack`](crate::KvStack) uses this to
309 /// hash a key once and reuse it across every file, since one salt covers the stack.
310 pub(crate) fn get_hashed(&self, key: &[u8], key_hash: Option<u64>) -> Option<Vec<u8>> {
311 // Fast negative.
312 if let Some(salt) = self.salt
313 && let Some(bloom) = &self.bloom
314 && !bloom.contains_hash(key_hash.unwrap_or_else(|| murmur3_x64_128_h1(key, salt)))
315 {
316 return None;
317 }
318 match &self.index {
319 Some(idx) => self.get_indexed(idx, key),
320 None => self.get_scan(key),
321 }
322 }
323
324 fn get_indexed(&self, idx: &BtreeIndex, key: &[u8]) -> Option<Vec<u8>> {
325 if idx.key_count() == 0 {
326 return None;
327 }
328 // The `.bt` di-nodes cut the search to the one M-key block that can hold `key`,
329 // using in-memory comparisons; without them this is the full range.
330 let (mut lo, mut hi) = idx.narrow(key);
331 let mut g = self.seg.getter();
332 while lo < hi {
333 let mid = lo + (hi - lo) / 2;
334 let off = idx.key_offset(mid)?;
335 g.reset(off);
336 if !g.has_next() {
337 return None;
338 }
339 let probe = g.next();
340 match probe.as_slice().cmp(key) {
341 std::cmp::Ordering::Less => lo = mid + 1,
342 std::cmp::Ordering::Greater => hi = mid,
343 std::cmp::Ordering::Equal => {
344 return Some(if g.has_next() { g.next() } else { Vec::new() });
345 }
346 }
347 }
348 None
349 }
350
351 fn get_scan(&self, key: &[u8]) -> Option<Vec<u8>> {
352 let mut g = self.seg.getter();
353 while g.has_next() {
354 let k = g.next();
355 match k.as_slice().cmp(key) {
356 // Skipping the value avoids decompressing and allocating a word we are
357 // about to discard — half the words in the file, on a miss.
358 std::cmp::Ordering::Less => {
359 if g.has_next() {
360 g.skip();
361 }
362 }
363 std::cmp::Ordering::Greater => return None, // keys are sorted
364 std::cmp::Ordering::Equal => {
365 return Some(if g.has_next() { g.next() } else { Vec::new() });
366 }
367 }
368 }
369 None
370 }
371
372 /// Sample up to `n` real keys spread across the file, for salt validation / search.
373 /// Each returned key is genuinely present, so a correct bloom must contain it.
374 fn sample_keys(&self, n: usize) -> Vec<Vec<u8>> {
375 match &self.index {
376 Some(idx) => {
377 let count = idx.key_count();
378 if count == 0 {
379 return Vec::new();
380 }
381 let n = (n as u64).min(count);
382 let mut g = self.seg.getter();
383 (0..n)
384 .filter_map(|s| {
385 let di = s * count / n;
386 idx.key_offset(di).map(|off| {
387 g.reset(off);
388 g.next()
389 })
390 })
391 .collect()
392 }
393 None => {
394 // No index: take the first `n` keys by scanning.
395 let mut g = self.seg.getter();
396 let mut out = Vec::new();
397 while out.len() < n && g.has_next() {
398 out.push(g.next());
399 if g.has_next() {
400 g.next(); // skip value
401 }
402 }
403 out
404 }
405 }
406 }
407
408 /// Iterate every `(key, value)` pair sequentially, in stored (key) order.
409 pub fn iter(&self) -> KvIter<'_> {
410 KvIter {
411 getter: self.seg.getter(),
412 }
413 }
414}
415
416/// Iterator over the `(key, value)` pairs of a [`KvReader`], in stored order.
417pub struct KvIter<'a> {
418 getter: Getter<'a>,
419}
420
421impl Iterator for KvIter<'_> {
422 type Item = Result<(Vec<u8>, Vec<u8>)>;
423
424 fn next(&mut self) -> Option<Self::Item> {
425 if !self.getter.has_next() {
426 return None;
427 }
428 let key = self.getter.next();
429 let value = if self.getter.has_next() {
430 self.getter.next()
431 } else {
432 Vec::new()
433 };
434 Some(Ok((key, value)))
435 }
436}