Skip to main content

hashtree_lmdb/
lib.rs

1//! LMDB-backed content-addressed blob storage.
2
3use async_trait::async_trait;
4use hashtree_core::store::{Store, StoreError, StoreStats};
5use hashtree_core::types::Hash;
6use heed::types::*;
7use heed::{Database, EnvOpenOptions, Error as HeedError, MdbError, PutFlags};
8use std::path::Path;
9use std::sync::atomic::{AtomicU64, Ordering};
10
11// Re-export sha256 for convenience
12pub use hashtree_core::hash::sha256 as compute_sha256;
13
14#[cfg(target_pointer_width = "64")]
15const DEFAULT_MAP_SIZE: usize = 10 * 1024 * 1024 * 1024;
16#[cfg(target_pointer_width = "32")]
17const DEFAULT_MAP_SIZE: usize = 1024 * 1024 * 1024;
18const DEFAULT_MAX_READERS: u32 = 1024;
19const DATABASE_COUNT: u32 = 4;
20const REOPEN_HEADROOM_BYTES: u64 = 64 * 1024 * 1024;
21const LMDB_PAGE_SIZE_BYTES: u64 = 4096;
22const EVICTION_BATCH_TARGET_BYTES: u64 = 256 * 1024 * 1024;
23const EVICTION_BATCH_MAX_ITEMS: usize = 4096;
24const BLOB_META_BYTES: usize = 16;
25const ORDER_KEY_BYTES: usize = 40;
26const PIN_COUNT_BYTES: usize = 4;
27
28#[derive(Debug, Clone, Copy)]
29struct BlobMeta {
30    order: u64,
31    size: u64,
32}
33
34/// LMDB-backed blob store implementing hashtree's Store trait.
35pub struct LmdbBlobStore {
36    env: heed::Env,
37    /// Maps SHA256 hash (32 bytes) → blob data
38    blobs: Database<Bytes, Bytes>,
39    /// Maps SHA256 hash (32 bytes) → [order: u64][size: u64]
40    metadata: Database<Bytes, Bytes>,
41    /// Maps [order: u64][hash: 32 bytes] → ()
42    eviction_order: Database<Bytes, Unit>,
43    /// Maps SHA256 hash (32 bytes) → pin count (u32)
44    pins: Database<Bytes, Bytes>,
45    max_bytes: AtomicU64,
46    current_bytes: AtomicU64,
47    next_order: AtomicU64,
48}
49
50impl LmdbBlobStore {
51    /// Open or create an LMDB blob store at the given path.
52    pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, StoreError> {
53        Self::with_map_size(path, DEFAULT_MAP_SIZE)
54    }
55
56    /// Open or create with a maximum logical storage size.
57    pub fn with_max_bytes<P: AsRef<Path>>(path: P, max_bytes: u64) -> Result<Self, StoreError> {
58        let path_ref = path.as_ref();
59        let existing_map_size = std::fs::metadata(path_ref.join("data.mdb"))
60            .map(|metadata| metadata.len())
61            .unwrap_or(0);
62        let existing_headroom = if existing_map_size == 0 {
63            0
64        } else {
65            existing_map_size
66                .saturating_div(10)
67                .max(REOPEN_HEADROOM_BYTES)
68        };
69        let requested_map_size = usize::try_from(Self::align_map_size_bytes(
70            existing_map_size
71                .saturating_add(existing_headroom)
72                .max(max_bytes),
73        ))
74        .unwrap_or(usize::MAX)
75        .max(DEFAULT_MAP_SIZE);
76        let store = Self::with_map_size(path_ref, requested_map_size)?;
77        store.max_bytes.store(max_bytes, Ordering::Relaxed);
78        let current = store.current_bytes.load(Ordering::Relaxed);
79        if max_bytes > 0 && current > max_bytes {
80            let target = max_bytes.saturating_mul(9) / 10;
81            store.evict_to_target(current, target)?;
82        }
83        Ok(store)
84    }
85
86    fn align_map_size_bytes(bytes: u64) -> u64 {
87        if bytes == 0 {
88            return 0;
89        }
90        let remainder = bytes % LMDB_PAGE_SIZE_BYTES;
91        if remainder == 0 {
92            bytes
93        } else {
94            bytes.saturating_add(LMDB_PAGE_SIZE_BYTES - remainder)
95        }
96    }
97
98    /// Open or create with custom map size.
99    pub fn with_map_size<P: AsRef<Path>>(path: P, map_size: usize) -> Result<Self, StoreError> {
100        let path_ref = path.as_ref();
101        std::fs::create_dir_all(path_ref).map_err(StoreError::Io)?;
102        let existing_map_size = std::fs::metadata(path_ref.join("data.mdb"))
103            .map(|metadata| metadata.len())
104            .unwrap_or(0);
105        let existing_headroom = if existing_map_size == 0 {
106            0
107        } else {
108            existing_map_size
109                .saturating_div(10)
110                .max(REOPEN_HEADROOM_BYTES)
111        };
112        let requested_map_size = u64::try_from(map_size).unwrap_or(u64::MAX);
113        let map_size = usize::try_from(Self::align_map_size_bytes(
114            requested_map_size.max(existing_map_size.saturating_add(existing_headroom)),
115        ))
116        .unwrap_or(usize::MAX);
117
118        let env = unsafe {
119            EnvOpenOptions::new()
120                .map_size(map_size)
121                .max_dbs(DATABASE_COUNT)
122                .max_readers(DEFAULT_MAX_READERS)
123                .open(path_ref)
124                .map_err(map_heed_error)?
125        };
126        let _ = env.clear_stale_readers();
127        if env.info().map_size < map_size {
128            unsafe { env.resize(map_size) }.map_err(map_heed_error)?;
129        }
130
131        let mut wtxn = env.write_txn().map_err(map_heed_error)?;
132        let blobs = env
133            .create_database(&mut wtxn, Some("blobs"))
134            .map_err(map_heed_error)?;
135        let metadata = env
136            .create_database(&mut wtxn, Some("metadata"))
137            .map_err(map_heed_error)?;
138        let eviction_order = env
139            .create_database(&mut wtxn, Some("eviction_order"))
140            .map_err(map_heed_error)?;
141        let pins = env
142            .create_database(&mut wtxn, Some("pins"))
143            .map_err(map_heed_error)?;
144        wtxn.commit().map_err(map_heed_error)?;
145
146        let (next_order, current_bytes) = {
147            let rtxn = env.read_txn().map_err(map_heed_error)?;
148            let next = eviction_order
149                .iter(&rtxn)
150                .map_err(map_heed_error)?
151                .last()
152                .transpose()
153                .map_err(map_heed_error)?
154                .map(|(key, _)| {
155                    Self::decode_order_from_order_key(key).map(|order| order.saturating_add(1))
156                })
157                .transpose()?
158                .unwrap_or(0);
159            let current = metadata
160                .iter(&rtxn)
161                .map_err(map_heed_error)?
162                .map(|item| {
163                    item.map_err(map_heed_error)
164                        .and_then(|(_, bytes)| Self::decode_blob_meta(bytes))
165                        .map(|meta| meta.size)
166                })
167                .try_fold(0u64, |total, size| {
168                    size.map(|size| total.saturating_add(size))
169                })?;
170            (next, current)
171        };
172
173        Ok(Self {
174            env,
175            blobs,
176            metadata,
177            eviction_order,
178            pins,
179            max_bytes: AtomicU64::new(0),
180            current_bytes: AtomicU64::new(current_bytes),
181            next_order: AtomicU64::new(next_order),
182        })
183    }
184
185    /// Check if a hash exists (sync version for internal use).
186    pub fn exists(&self, hash: &Hash) -> Result<bool, StoreError> {
187        let rtxn = self
188            .env
189            .read_txn()
190            .map_err(|e| StoreError::Other(e.to_string()))?;
191
192        Ok(self
193            .blobs
194            .get(&rtxn, hash)
195            .map_err(|e| StoreError::Other(e.to_string()))?
196            .is_some())
197    }
198
199    pub fn map_size_bytes(&self) -> usize {
200        self.env.info().map_size
201    }
202
203    fn evict_before_write(&self, incoming_bytes: u64) -> Result<u64, StoreError> {
204        let max = self.max_bytes.load(Ordering::Relaxed);
205        if max == 0 {
206            return Ok(0);
207        }
208
209        let current = self.current_bytes.load(Ordering::Relaxed);
210        if current.saturating_add(incoming_bytes) <= max {
211            return Ok(0);
212        }
213
214        let target = if incoming_bytes >= max {
215            0
216        } else {
217            (max.saturating_mul(9) / 10).min(max.saturating_sub(incoming_bytes))
218        };
219        self.evict_to_target(current, target)
220    }
221
222    fn evict_for_write_pressure(&self, incoming_bytes: u64) -> Result<u64, StoreError> {
223        let current = self.current_bytes.load(Ordering::Relaxed);
224        if current == 0 {
225            return Ok(0);
226        }
227
228        let headroom = incoming_bytes.max(current / 10).max(1);
229        let target = current.saturating_sub(headroom);
230        self.evict_to_target(current, target)
231    }
232
233    fn is_map_full_error(err: &HeedError) -> bool {
234        matches!(err, HeedError::Mdb(MdbError::MapFull))
235    }
236
237    fn put_sync_attempt(&self, hash: Hash, data: &[u8]) -> std::result::Result<bool, HeedError> {
238        let mut wtxn = self.env.write_txn()?;
239        let inserted =
240            match self
241                .blobs
242                .put_with_flags(&mut wtxn, PutFlags::NO_OVERWRITE, &hash, data)
243            {
244                Ok(()) => true,
245                Err(HeedError::Mdb(MdbError::KeyExist)) => false,
246                Err(err) => return Err(err),
247            };
248
249        if inserted {
250            let order = self.next_order.fetch_add(1, Ordering::Relaxed);
251            let meta = Self::encode_blob_meta(BlobMeta {
252                order,
253                size: data.len() as u64,
254            });
255            let order_key = Self::encode_order_key(order, &hash);
256            self.metadata.put(&mut wtxn, &hash, &meta)?;
257            self.eviction_order.put(&mut wtxn, &order_key, &())?;
258        }
259
260        wtxn.commit()?;
261        Ok(inserted)
262    }
263
264    fn put_many_sync_attempt(
265        &self,
266        items: &[(Hash, Vec<u8>)],
267    ) -> std::result::Result<(usize, u64), HeedError> {
268        let mut wtxn = self.env.write_txn()?;
269        let mut inserted = 0usize;
270        let mut inserted_bytes = 0u64;
271
272        for (hash, data) in items {
273            let inserted_blob = match self.blobs.put_with_flags(
274                &mut wtxn,
275                PutFlags::NO_OVERWRITE,
276                hash,
277                data.as_slice(),
278            ) {
279                Ok(()) => true,
280                Err(HeedError::Mdb(MdbError::KeyExist)) => false,
281                Err(err) => return Err(err),
282            };
283
284            if !inserted_blob {
285                continue;
286            }
287
288            let order = self.next_order.fetch_add(1, Ordering::Relaxed);
289            let meta = Self::encode_blob_meta(BlobMeta {
290                order,
291                size: data.len() as u64,
292            });
293            let order_key = Self::encode_order_key(order, hash);
294            self.metadata.put(&mut wtxn, hash, &meta)?;
295            self.eviction_order.put(&mut wtxn, &order_key, &())?;
296            inserted += 1;
297            inserted_bytes = inserted_bytes.saturating_add(data.len() as u64);
298        }
299
300        wtxn.commit()?;
301        Ok((inserted, inserted_bytes))
302    }
303
304    /// Get storage statistics.
305    pub fn stats(&self) -> Result<LmdbStats, StoreError> {
306        let rtxn = self
307            .env
308            .read_txn()
309            .map_err(|e| StoreError::Other(e.to_string()))?;
310
311        let count = self
312            .blobs
313            .len(&rtxn)
314            .map_err(|e| StoreError::Other(e.to_string()))? as usize;
315
316        let mut total_bytes = 0u64;
317        let mut pinned_count = 0usize;
318        let mut pinned_bytes = 0u64;
319        for item in self
320            .blobs
321            .iter(&rtxn)
322            .map_err(|e| StoreError::Other(e.to_string()))?
323        {
324            let (hash, data) = item.map_err(|e| StoreError::Other(e.to_string()))?;
325            let size = data.len() as u64;
326            total_bytes += size;
327            if self.read_pin_count(&rtxn, hash)? > 0 {
328                pinned_count += 1;
329                pinned_bytes += size;
330            }
331        }
332
333        Ok(LmdbStats {
334            count,
335            total_bytes,
336            pinned_count,
337            pinned_bytes,
338        })
339    }
340
341    /// List all hashes in the store.
342    pub fn list(&self) -> Result<Vec<Hash>, StoreError> {
343        let rtxn = self
344            .env
345            .read_txn()
346            .map_err(|e| StoreError::Other(e.to_string()))?;
347
348        let mut hashes = Vec::new();
349        for item in self
350            .blobs
351            .iter(&rtxn)
352            .map_err(|e| StoreError::Other(e.to_string()))?
353        {
354            let (hash, _) = item.map_err(|e| StoreError::Other(e.to_string()))?;
355            let hash_arr: Hash = hash
356                .try_into()
357                .map_err(|_| StoreError::Other("invalid hash length".into()))?;
358            hashes.push(hash_arr);
359        }
360
361        Ok(hashes)
362    }
363
364    /// Sync put operation (for use in sync contexts).
365    pub fn put_sync(&self, hash: Hash, data: &[u8]) -> Result<bool, StoreError> {
366        let incoming_bytes = data.len() as u64;
367        self.evict_before_write(incoming_bytes)?;
368
369        let mut retried_after_eviction = false;
370        loop {
371            match self.put_sync_attempt(hash, data) {
372                Ok(inserted) => {
373                    if inserted {
374                        self.current_bytes
375                            .fetch_add(incoming_bytes, Ordering::Relaxed);
376                    }
377                    return Ok(inserted);
378                }
379                Err(err) if Self::is_map_full_error(&err) && !retried_after_eviction => {
380                    let freed = self.evict_for_write_pressure(incoming_bytes)?;
381                    if freed == 0 {
382                        return Err(StoreError::Other(err.to_string()));
383                    }
384                    retried_after_eviction = true;
385                }
386                Err(err) => return Err(StoreError::Other(err.to_string())),
387            }
388        }
389    }
390
391    /// Sync batch put operation (for use in sync contexts).
392    pub fn put_many_sync(&self, items: &[(Hash, Vec<u8>)]) -> Result<usize, StoreError> {
393        if items.is_empty() {
394            return Ok(0);
395        }
396
397        let incoming_bytes = items
398            .iter()
399            .map(|(_, data)| data.len() as u64)
400            .fold(0u64, |total, size| total.saturating_add(size));
401        self.evict_before_write(incoming_bytes)?;
402
403        let mut retried_after_eviction = false;
404        loop {
405            match self.put_many_sync_attempt(items) {
406                Ok((inserted, inserted_bytes)) => {
407                    if inserted_bytes > 0 {
408                        self.current_bytes
409                            .fetch_add(inserted_bytes, Ordering::Relaxed);
410                    }
411                    return Ok(inserted);
412                }
413                Err(err) if Self::is_map_full_error(&err) && !retried_after_eviction => {
414                    let freed = self.evict_for_write_pressure(incoming_bytes)?;
415                    if freed == 0 {
416                        return Err(StoreError::Other(err.to_string()));
417                    }
418                    retried_after_eviction = true;
419                }
420                Err(err) => return Err(StoreError::Other(err.to_string())),
421            }
422        }
423    }
424
425    /// Sync get operation (for use in sync contexts).
426    pub fn get_sync(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
427        let rtxn = self
428            .env
429            .read_txn()
430            .map_err(|e| StoreError::Other(e.to_string()))?;
431
432        Ok(self
433            .blobs
434            .get(&rtxn, hash)
435            .map_err(|e| StoreError::Other(e.to_string()))?
436            .map(|b| b.to_vec()))
437    }
438
439    /// Sync delete operation (for use in sync contexts).
440    pub fn delete_sync(&self, hash: &Hash) -> Result<bool, StoreError> {
441        let mut wtxn = self
442            .env
443            .write_txn()
444            .map_err(|e| StoreError::Other(e.to_string()))?;
445        let (existed, freed) = self.delete_blob_in_txn(&mut wtxn, hash)?;
446
447        wtxn.commit()
448            .map_err(|e| StoreError::Other(e.to_string()))?;
449        if freed > 0 {
450            self.current_bytes.fetch_sub(freed, Ordering::Relaxed);
451        }
452
453        Ok(existed)
454    }
455
456    fn pin_sync(&self, hash: &Hash) -> Result<(), StoreError> {
457        let mut wtxn = self
458            .env
459            .write_txn()
460            .map_err(|e| StoreError::Other(e.to_string()))?;
461        let count = self.read_pin_count(&wtxn, hash)?.saturating_add(1);
462        let encoded = count.to_be_bytes();
463        self.pins
464            .put(&mut wtxn, hash, &encoded)
465            .map_err(|e| StoreError::Other(e.to_string()))?;
466        wtxn.commit()
467            .map_err(|e| StoreError::Other(e.to_string()))?;
468        Ok(())
469    }
470
471    fn unpin_sync(&self, hash: &Hash) -> Result<(), StoreError> {
472        let mut wtxn = self
473            .env
474            .write_txn()
475            .map_err(|e| StoreError::Other(e.to_string()))?;
476        let count = self.read_pin_count(&wtxn, hash)?;
477        if count <= 1 {
478            let _ = self
479                .pins
480                .delete(&mut wtxn, hash)
481                .map_err(|e| StoreError::Other(e.to_string()))?;
482        } else {
483            let encoded = (count - 1).to_be_bytes();
484            self.pins
485                .put(&mut wtxn, hash, &encoded)
486                .map_err(|e| StoreError::Other(e.to_string()))?;
487        }
488        wtxn.commit()
489            .map_err(|e| StoreError::Other(e.to_string()))?;
490        Ok(())
491    }
492
493    fn evict_to_target(&self, current_bytes: u64, target_bytes: u64) -> Result<u64, StoreError> {
494        if current_bytes <= target_bytes {
495            return Ok(0);
496        }
497
498        let rtxn = self
499            .env
500            .read_txn()
501            .map_err(|e| StoreError::Other(e.to_string()))?;
502        let order_keys: Vec<Vec<u8>> = self
503            .eviction_order
504            .iter(&rtxn)
505            .map_err(|e| StoreError::Other(e.to_string()))?
506            .map(|item| {
507                item.map(|(key, _)| key.to_vec())
508                    .map_err(|e| StoreError::Other(e.to_string()))
509            })
510            .collect::<Result<_, _>>()?;
511        drop(rtxn);
512
513        let mut freed_total = 0u64;
514        let to_free = current_bytes - target_bytes;
515        let mut index = 0usize;
516
517        while freed_total < to_free && index < order_keys.len() {
518            let mut wtxn = self
519                .env
520                .write_txn()
521                .map_err(|e| StoreError::Other(e.to_string()))?;
522            let mut batch_freed = 0u64;
523            let mut batch_items = 0usize;
524
525            while freed_total + batch_freed < to_free && index < order_keys.len() {
526                let order_key = &order_keys[index];
527                index += 1;
528
529                let hash = Self::decode_hash_from_order_key(order_key)?;
530                if self.read_pin_count(&wtxn, &hash)? > 0 {
531                    continue;
532                }
533
534                let (_, bytes_freed) = self.delete_blob_in_txn(&mut wtxn, &hash)?;
535                if bytes_freed == 0 {
536                    let _ = self
537                        .eviction_order
538                        .delete(&mut wtxn, order_key)
539                        .map_err(|e| StoreError::Other(e.to_string()))?;
540                    continue;
541                }
542
543                batch_freed = batch_freed.saturating_add(bytes_freed);
544                batch_items += 1;
545
546                if batch_freed >= EVICTION_BATCH_TARGET_BYTES
547                    || batch_items >= EVICTION_BATCH_MAX_ITEMS
548                {
549                    break;
550                }
551            }
552
553            wtxn.commit()
554                .map_err(|e| StoreError::Other(e.to_string()))?;
555            if batch_freed > 0 {
556                self.current_bytes.fetch_sub(batch_freed, Ordering::Relaxed);
557                freed_total = freed_total.saturating_add(batch_freed);
558            }
559        }
560
561        Ok(freed_total)
562    }
563
564    fn delete_blob_in_txn(
565        &self,
566        wtxn: &mut heed::RwTxn,
567        hash: &Hash,
568    ) -> Result<(bool, u64), StoreError> {
569        let data_len = self
570            .blobs
571            .get(wtxn, hash)
572            .map_err(|e| StoreError::Other(e.to_string()))?
573            .map(|data| data.len() as u64);
574        let meta = self
575            .metadata
576            .get(wtxn, hash)
577            .map_err(|e| StoreError::Other(e.to_string()))?
578            .map(Self::decode_blob_meta)
579            .transpose()?;
580
581        let existed = self
582            .blobs
583            .delete(wtxn, hash)
584            .map_err(|e| StoreError::Other(e.to_string()))?;
585        let _ = self
586            .metadata
587            .delete(wtxn, hash)
588            .map_err(|e| StoreError::Other(e.to_string()))?;
589        let _ = self
590            .pins
591            .delete(wtxn, hash)
592            .map_err(|e| StoreError::Other(e.to_string()))?;
593        if let Some(meta) = meta {
594            let order_key = Self::encode_order_key(meta.order, hash);
595            let _ = self
596                .eviction_order
597                .delete(wtxn, &order_key)
598                .map_err(|e| StoreError::Other(e.to_string()))?;
599        }
600
601        Ok((
602            existed || meta.is_some(),
603            data_len.or(meta.map(|m| m.size)).unwrap_or(0),
604        ))
605    }
606
607    fn read_pin_count(&self, txn: &heed::RoTxn, hash: &[u8]) -> Result<u32, StoreError> {
608        self.pins
609            .get(txn, hash)
610            .map_err(|e| StoreError::Other(e.to_string()))?
611            .map(Self::decode_pin_count)
612            .transpose()?
613            .map_or(Ok(0), Ok)
614    }
615
616    fn encode_blob_meta(meta: BlobMeta) -> [u8; BLOB_META_BYTES] {
617        let mut encoded = [0u8; BLOB_META_BYTES];
618        encoded[..8].copy_from_slice(&meta.order.to_be_bytes());
619        encoded[8..].copy_from_slice(&meta.size.to_be_bytes());
620        encoded
621    }
622
623    fn decode_blob_meta(bytes: &[u8]) -> Result<BlobMeta, StoreError> {
624        if bytes.len() != BLOB_META_BYTES {
625            return Err(StoreError::Other(format!(
626                "invalid blob metadata length: {}",
627                bytes.len()
628            )));
629        }
630        Ok(BlobMeta {
631            order: Self::decode_order(&bytes[..8])?,
632            size: u64::from_be_bytes(
633                bytes[8..16]
634                    .try_into()
635                    .map_err(|_| StoreError::Other("invalid blob size bytes".into()))?,
636            ),
637        })
638    }
639
640    fn encode_order_key(order: u64, hash: &Hash) -> [u8; ORDER_KEY_BYTES] {
641        let mut key = [0u8; ORDER_KEY_BYTES];
642        key[..8].copy_from_slice(&order.to_be_bytes());
643        key[8..].copy_from_slice(hash);
644        key
645    }
646
647    fn decode_order(bytes: &[u8]) -> Result<u64, StoreError> {
648        if bytes.len() != 8 {
649            return Err(StoreError::Other(format!(
650                "invalid order length: {}",
651                bytes.len()
652            )));
653        }
654        Ok(u64::from_be_bytes(bytes.try_into().map_err(|_| {
655            StoreError::Other("invalid order bytes".into())
656        })?))
657    }
658
659    fn decode_hash_from_order_key(bytes: &[u8]) -> Result<Hash, StoreError> {
660        if bytes.len() != ORDER_KEY_BYTES {
661            return Err(StoreError::Other(format!(
662                "invalid order key length: {}",
663                bytes.len()
664            )));
665        }
666        let mut hash = [0u8; 32];
667        hash.copy_from_slice(&bytes[8..]);
668        Ok(hash)
669    }
670
671    fn decode_order_from_order_key(bytes: &[u8]) -> Result<u64, StoreError> {
672        if bytes.len() != ORDER_KEY_BYTES {
673            return Err(StoreError::Other(format!(
674                "invalid order key length: {}",
675                bytes.len()
676            )));
677        }
678        Self::decode_order(&bytes[..8])
679    }
680
681    fn decode_pin_count(bytes: &[u8]) -> Result<u32, StoreError> {
682        if bytes.len() != PIN_COUNT_BYTES {
683            return Err(StoreError::Other(format!(
684                "invalid pin count length: {}",
685                bytes.len()
686            )));
687        }
688        Ok(u32::from_be_bytes(bytes.try_into().map_err(|_| {
689            StoreError::Other("invalid pin count bytes".into())
690        })?))
691    }
692}
693
694fn map_heed_error(error: HeedError) -> StoreError {
695    match error {
696        HeedError::Io(io_error) => StoreError::Io(io_error),
697        other => StoreError::Other(other.to_string()),
698    }
699}
700
701#[derive(Debug, Clone)]
702pub struct LmdbStats {
703    pub count: usize,
704    pub total_bytes: u64,
705    pub pinned_count: usize,
706    pub pinned_bytes: u64,
707}
708
709#[async_trait]
710impl Store for LmdbBlobStore {
711    async fn put(&self, hash: Hash, data: Vec<u8>) -> Result<bool, StoreError> {
712        self.put_sync(hash, &data)
713    }
714
715    async fn put_many(&self, items: Vec<(Hash, Vec<u8>)>) -> Result<usize, StoreError> {
716        self.put_many_sync(&items)
717    }
718
719    async fn get(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
720        self.get_sync(hash)
721    }
722
723    async fn has(&self, hash: &Hash) -> Result<bool, StoreError> {
724        self.exists(hash)
725    }
726
727    async fn delete(&self, hash: &Hash) -> Result<bool, StoreError> {
728        self.delete_sync(hash)
729    }
730
731    fn set_max_bytes(&self, max: u64) {
732        self.max_bytes.store(max, Ordering::Relaxed);
733    }
734
735    fn max_bytes(&self) -> Option<u64> {
736        let max = self.max_bytes.load(Ordering::Relaxed);
737        if max > 0 {
738            Some(max)
739        } else {
740            None
741        }
742    }
743
744    async fn stats(&self) -> StoreStats {
745        match self.stats() {
746            Ok(stats) => StoreStats {
747                count: stats.count as u64,
748                bytes: stats.total_bytes,
749                pinned_count: stats.pinned_count as u64,
750                pinned_bytes: stats.pinned_bytes,
751            },
752            Err(_) => StoreStats::default(),
753        }
754    }
755
756    async fn evict_if_needed(&self) -> Result<u64, StoreError> {
757        let max = self.max_bytes.load(Ordering::Relaxed);
758        if max == 0 {
759            return Ok(0);
760        }
761
762        let current = self.current_bytes.load(Ordering::Relaxed);
763        if current <= max {
764            return Ok(0);
765        }
766
767        let target = max * 9 / 10;
768        self.evict_to_target(current, target)
769    }
770
771    async fn pin(&self, hash: &Hash) -> Result<(), StoreError> {
772        self.pin_sync(hash)
773    }
774
775    async fn unpin(&self, hash: &Hash) -> Result<(), StoreError> {
776        self.unpin_sync(hash)
777    }
778
779    fn pin_count(&self, hash: &Hash) -> u32 {
780        let Ok(rtxn) = self.env.read_txn() else {
781            return 0;
782        };
783        self.read_pin_count(&rtxn, hash).unwrap_or(0)
784    }
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790    use hashtree_core::sha256;
791    use heed::EnvOpenOptions;
792    #[cfg(unix)]
793    use std::path::{Path, PathBuf};
794    #[cfg(unix)]
795    use std::process::Command;
796    use std::sync::{
797        atomic::{AtomicBool, Ordering},
798        Arc, Barrier,
799    };
800    use std::time::Duration;
801    use tempfile::TempDir;
802
803    #[cfg(unix)]
804    const STALE_READER_HELPER_ENV: &str = "HASHTREE_LMDB_STALE_READER_HELPER";
805    #[cfg(unix)]
806    const STALE_READER_HELPER_MODE_ENV: &str = "HASHTREE_LMDB_STALE_READER_HELPER_MODE";
807    #[cfg(unix)]
808    const STALE_READER_DB_PATH_ENV: &str = "HASHTREE_LMDB_STALE_READER_DB_PATH";
809    #[cfg(unix)]
810    const STALE_READER_MARKER_PATH_ENV: &str = "HASHTREE_LMDB_STALE_READER_MARKER_PATH";
811    #[cfg(unix)]
812    const TEST_MAX_READERS: u32 = 4;
813
814    #[cfg(unix)]
815    fn run_helper(mode: &str, path: &Path, marker: &Path) {
816        let output = Command::new(std::env::current_exe().expect("test binary path"))
817            .arg("--ignored")
818            .arg("--exact")
819            .arg("tests::lmdb_stale_reader_helper")
820            .env(STALE_READER_HELPER_ENV, "1")
821            .env(STALE_READER_HELPER_MODE_ENV, mode)
822            .env(STALE_READER_DB_PATH_ENV, path)
823            .env(STALE_READER_MARKER_PATH_ENV, marker)
824            .env("RUST_TEST_THREADS", "1")
825            .output()
826            .expect("spawn stale-reader helper");
827
828        assert!(
829            output.status.success(),
830            "stale-reader helper failed: stdout={} stderr={}",
831            String::from_utf8_lossy(&output.stdout),
832            String::from_utf8_lossy(&output.stderr)
833        );
834        assert!(
835            marker.exists(),
836            "stale-reader helper did not run: stdout={} stderr={}",
837            String::from_utf8_lossy(&output.stdout),
838            String::from_utf8_lossy(&output.stderr)
839        );
840    }
841
842    #[tokio::test]
843    async fn test_put_get() -> Result<(), StoreError> {
844        let temp = TempDir::new().unwrap();
845        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
846
847        let data = b"hello lmdb";
848        let hash = sha256(data);
849        store.put(hash, data.to_vec()).await?;
850
851        assert!(store.has(&hash).await?);
852        assert_eq!(store.get(&hash).await?, Some(data.to_vec()));
853
854        Ok(())
855    }
856
857    #[tokio::test]
858    async fn test_delete() -> Result<(), StoreError> {
859        let temp = TempDir::new().unwrap();
860        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
861
862        let data = b"delete me";
863        let hash = sha256(data);
864        store.put(hash, data.to_vec()).await?;
865        assert!(store.has(&hash).await?);
866
867        assert!(store.delete(&hash).await?);
868        assert!(!store.has(&hash).await?);
869        assert!(!store.delete(&hash).await?);
870
871        Ok(())
872    }
873
874    #[tokio::test]
875    async fn test_list() -> Result<(), StoreError> {
876        let temp = TempDir::new().unwrap();
877        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
878
879        let d1 = b"one";
880        let d2 = b"two";
881        let d3 = b"three";
882        let h1 = sha256(d1);
883        let h2 = sha256(d2);
884        let h3 = sha256(d3);
885
886        store.put(h1, d1.to_vec()).await?;
887        store.put(h2, d2.to_vec()).await?;
888        store.put(h3, d3.to_vec()).await?;
889
890        let hashes = store.list()?;
891        assert_eq!(hashes.len(), 3);
892        assert!(hashes.contains(&h1));
893        assert!(hashes.contains(&h2));
894        assert!(hashes.contains(&h3));
895
896        Ok(())
897    }
898
899    #[tokio::test]
900    async fn test_stats() -> Result<(), StoreError> {
901        let temp = TempDir::new().unwrap();
902        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
903
904        let d1 = b"hello";
905        let d2 = b"world";
906        store.put(sha256(d1), d1.to_vec()).await?;
907        store.put(sha256(d2), d2.to_vec()).await?;
908
909        let stats = store.stats()?;
910        assert_eq!(stats.count, 2);
911        assert_eq!(stats.total_bytes, 10);
912
913        Ok(())
914    }
915
916    #[tokio::test]
917    async fn test_deduplication() -> Result<(), StoreError> {
918        let temp = TempDir::new().unwrap();
919        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
920
921        let data = b"same";
922        let hash = sha256(data);
923        assert!(store.put(hash, data.to_vec()).await?); // Returns true (newly stored)
924        assert!(!store.put(hash, data.to_vec()).await?); // Returns false (already existed)
925
926        assert_eq!(store.list()?.len(), 1);
927
928        Ok(())
929    }
930
931    #[tokio::test]
932    async fn test_max_bytes() -> Result<(), StoreError> {
933        let temp = TempDir::new().unwrap();
934        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
935
936        assert!(store.max_bytes().is_none());
937
938        store.set_max_bytes(1000);
939        assert_eq!(store.max_bytes(), Some(1000));
940
941        store.set_max_bytes(0);
942        assert!(store.max_bytes().is_none());
943
944        Ok(())
945    }
946
947    #[test]
948    fn test_with_max_bytes_expands_lmdb_map_size() -> Result<(), StoreError> {
949        let temp = TempDir::new().unwrap();
950        let requested = (DEFAULT_MAP_SIZE as u64) + 64 * 1024 * 1024;
951        let store = LmdbBlobStore::with_max_bytes(temp.path().join("blobs"), requested)?;
952
953        assert!(
954            store.map_size_bytes() as u64 >= requested,
955            "expected LMDB map to grow to at least {requested} bytes, got {}",
956            store.map_size_bytes()
957        );
958        assert_eq!(store.max_bytes(), Some(requested));
959
960        Ok(())
961    }
962
963    #[tokio::test]
964    async fn test_eviction_over_limit() -> Result<(), StoreError> {
965        let temp = TempDir::new().unwrap();
966        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
967        store.set_max_bytes(25);
968
969        let h1 = sha256(b"aaaaaaaaaa");
970        let h2 = sha256(b"bbbbbbbbbb");
971        let h3 = sha256(b"cccccccccc");
972
973        store.put(h1, b"aaaaaaaaaa".to_vec()).await?;
974        store.put(h2, b"bbbbbbbbbb".to_vec()).await?;
975        store.put(h3, b"cccccccccc".to_vec()).await?;
976
977        let freed = store.evict_if_needed().await?;
978        assert_eq!(
979            freed, 0,
980            "write path should have already evicted stale blobs"
981        );
982
983        assert!(
984            !store.has(&h1).await?,
985            "oldest blob should be evicted before the third write"
986        );
987        assert!(store.has(&h2).await?);
988        assert!(store.has(&h3).await?);
989
990        let stats = store.stats()?;
991        assert!(
992            stats.total_bytes <= 22,
993            "store should be reduced to 90% target"
994        );
995
996        Ok(())
997    }
998
999    #[tokio::test]
1000    async fn test_eviction_respects_pins() -> Result<(), StoreError> {
1001        let temp = TempDir::new().unwrap();
1002        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
1003        store.set_max_bytes(25);
1004
1005        let h1 = sha256(b"aaaaaaaaaa");
1006        let h2 = sha256(b"bbbbbbbbbb");
1007        let h3 = sha256(b"cccccccccc");
1008
1009        store.put(h1, b"aaaaaaaaaa".to_vec()).await?;
1010        store.put(h2, b"bbbbbbbbbb".to_vec()).await?;
1011        store.pin(&h1).await?;
1012        store.put(h3, b"cccccccccc".to_vec()).await?;
1013
1014        let freed = store.evict_if_needed().await?;
1015        assert_eq!(
1016            freed, 0,
1017            "write path should have already evicted stale blobs"
1018        );
1019
1020        assert!(store.has(&h1).await?, "pinned blob must not be evicted");
1021        assert!(
1022            !store.has(&h2).await?,
1023            "oldest unpinned blob should be evicted before the third write"
1024        );
1025        assert!(store.has(&h3).await?);
1026
1027        Ok(())
1028    }
1029
1030    #[tokio::test]
1031    async fn test_reopen_with_existing_eviction_order() -> Result<(), StoreError> {
1032        let temp = TempDir::new().unwrap();
1033        let path = temp.path().join("blobs");
1034
1035        {
1036            let store = LmdbBlobStore::new(&path)?;
1037            let h1 = sha256(b"aaaaaaaaaa");
1038            let h2 = sha256(b"bbbbbbbbbb");
1039            store.put(h1, b"aaaaaaaaaa".to_vec()).await?;
1040            store.put(h2, b"bbbbbbbbbb".to_vec()).await?;
1041        }
1042
1043        let reopened = LmdbBlobStore::new(&path)?;
1044        let h3 = sha256(b"cccccccccc");
1045        assert!(reopened.put(h3, b"cccccccccc".to_vec()).await?);
1046        assert!(reopened.has(&h3).await?);
1047
1048        Ok(())
1049    }
1050
1051    #[tokio::test]
1052    async fn test_reopen_with_lower_max_bytes_evicts_existing_blobs() -> Result<(), StoreError> {
1053        let temp = TempDir::new().unwrap();
1054        let path = temp.path().join("blobs");
1055
1056        {
1057            let store = LmdbBlobStore::new(&path)?;
1058            let h1 = sha256(b"aaaaaaaaaa");
1059            let h2 = sha256(b"bbbbbbbbbb");
1060            let h3 = sha256(b"cccccccccc");
1061            store.put(h1, b"aaaaaaaaaa".to_vec()).await?;
1062            store.put(h2, b"bbbbbbbbbb".to_vec()).await?;
1063            store.put(h3, b"cccccccccc".to_vec()).await?;
1064        }
1065
1066        let reopened = LmdbBlobStore::with_max_bytes(&path, 25)?;
1067        let h1 = sha256(b"aaaaaaaaaa");
1068        let h2 = sha256(b"bbbbbbbbbb");
1069        let h3 = sha256(b"cccccccccc");
1070
1071        assert!(
1072            !reopened.has(&h1).await?,
1073            "oldest blob should be evicted when reopening over the new cap"
1074        );
1075        assert!(reopened.has(&h2).await?);
1076        assert!(reopened.has(&h3).await?);
1077
1078        let stats = reopened.stats()?;
1079        assert!(
1080            stats.total_bytes <= 22,
1081            "reopened store should be reduced to the 90% target"
1082        );
1083
1084        Ok(())
1085    }
1086
1087    #[test]
1088    fn test_supports_many_concurrent_readers() -> Result<(), Box<dyn std::error::Error>> {
1089        const READER_THREADS: usize = 160;
1090
1091        let temp = TempDir::new()?;
1092        let store = Arc::new(LmdbBlobStore::new(temp.path().join("blobs"))?);
1093        let hash = sha256(b"many readers");
1094        store.put_sync(hash, b"many readers")?;
1095
1096        let start = Arc::new(Barrier::new(READER_THREADS + 1));
1097        let release = Arc::new(AtomicBool::new(false));
1098        let mut handles = Vec::with_capacity(READER_THREADS);
1099
1100        for _ in 0..READER_THREADS {
1101            let env = store.env.clone();
1102            let start = Arc::clone(&start);
1103            let release = Arc::clone(&release);
1104            handles.push(std::thread::spawn(move || -> Result<(), String> {
1105                start.wait();
1106                let _rtxn = env.read_txn().map_err(|err| err.to_string())?;
1107                while !release.load(Ordering::Relaxed) {
1108                    std::thread::sleep(Duration::from_millis(1));
1109                }
1110                Ok(())
1111            }));
1112        }
1113
1114        start.wait();
1115        std::thread::sleep(Duration::from_millis(50));
1116        release.store(true, Ordering::Relaxed);
1117
1118        let results: Vec<Result<(), String>> = handles
1119            .into_iter()
1120            .map(|handle| handle.join().expect("reader thread panicked"))
1121            .collect();
1122
1123        let failures: Vec<String> = results.into_iter().filter_map(Result::err).collect();
1124        assert!(
1125            failures.is_empty(),
1126            "concurrent reader failures: {}",
1127            failures.join(" | ")
1128        );
1129        assert!(store.exists(&hash)?);
1130
1131        Ok(())
1132    }
1133
1134    #[cfg(unix)]
1135    #[test]
1136    fn test_reclaims_stale_reader_slots() -> Result<(), Box<dyn std::error::Error>> {
1137        let temp = TempDir::new()?;
1138        let path = temp.path().join("blobs");
1139        let data = b"hello stale readers";
1140        let hash = sha256(data);
1141
1142        run_helper("setup", &path, &temp.path().join("setup.marker"));
1143
1144        for index in 0..TEST_MAX_READERS {
1145            let marker = temp.path().join(format!("helper-{index}.marker"));
1146            run_helper("stale", &path, &marker);
1147        }
1148
1149        let store = LmdbBlobStore::with_map_size(&path, 1024 * 1024)?;
1150        assert!(store.exists(&hash)?);
1151
1152        Ok(())
1153    }
1154
1155    #[cfg(unix)]
1156    #[test]
1157    fn test_reopens_existing_env_with_larger_map_size() -> Result<(), Box<dyn std::error::Error>> {
1158        let temp = TempDir::new()?;
1159        let path = temp.path().join("blobs");
1160        run_helper("small-map", &path, &temp.path().join("small-map.marker"));
1161
1162        let reopened = LmdbBlobStore::with_map_size(&path, 8 * 1024 * 1024)?;
1163        assert!(reopened.map_size_bytes() >= 8 * 1024 * 1024);
1164
1165        Ok(())
1166    }
1167
1168    #[cfg(unix)]
1169    #[test]
1170    fn test_reopens_existing_env_with_smaller_requested_map_size(
1171    ) -> Result<(), Box<dyn std::error::Error>> {
1172        let temp = TempDir::new()?;
1173        let path = temp.path().join("blobs");
1174        let hash = sha256(b"large existing blob");
1175        run_helper("large-map", &path, &temp.path().join("large-map.marker"));
1176
1177        let existing_size = std::fs::metadata(path.join("data.mdb"))?.len();
1178        assert!(
1179            existing_size > 1024 * 1024,
1180            "test setup should create an environment larger than the reopen request"
1181        );
1182
1183        let reopened = LmdbBlobStore::with_map_size(&path, 1024 * 1024)?;
1184        assert!(
1185            reopened.map_size_bytes() as u64 >= existing_size,
1186            "expected map size to cover existing data.mdb size {existing_size}, got {}",
1187            reopened.map_size_bytes()
1188        );
1189        assert!(reopened.exists(&hash)?);
1190
1191        Ok(())
1192    }
1193
1194    #[cfg(unix)]
1195    #[test]
1196    #[ignore = "used as a subprocess helper by test_reclaims_stale_reader_slots"]
1197    fn lmdb_stale_reader_helper() {
1198        let Some(db_path) = std::env::var_os(STALE_READER_DB_PATH_ENV) else {
1199            return;
1200        };
1201        let marker_path =
1202            PathBuf::from(std::env::var_os(STALE_READER_MARKER_PATH_ENV).expect("marker path"));
1203        std::fs::write(&marker_path, b"started").expect("write helper marker");
1204
1205        let _env_flag = std::env::var_os(STALE_READER_HELPER_ENV).expect("helper mode enabled");
1206        let mode = std::env::var(STALE_READER_HELPER_MODE_ENV).expect("helper mode");
1207        let db_path = PathBuf::from(db_path);
1208        std::fs::create_dir_all(&db_path).expect("create helper db dir");
1209        let helper_map_size = if mode == "large-map" {
1210            8 * 1024 * 1024
1211        } else {
1212            1024 * 1024
1213        };
1214        let env = unsafe {
1215            EnvOpenOptions::new()
1216                .map_size(helper_map_size)
1217                .max_dbs(DATABASE_COUNT)
1218                .max_readers(TEST_MAX_READERS)
1219                .open(&db_path)
1220                .expect("open lmdb env")
1221        };
1222        match mode.as_str() {
1223            "setup" => {
1224                let mut wtxn = env.write_txn().expect("open write txn");
1225                let blobs: Database<Bytes, Bytes> = env
1226                    .create_database(&mut wtxn, Some("blobs"))
1227                    .expect("create blobs database");
1228                let data = b"hello stale readers";
1229                let hash = sha256(data);
1230                blobs.put(&mut wtxn, &hash, data).expect("seed blob");
1231                wtxn.commit().expect("commit setup txn");
1232                std::process::exit(0);
1233            }
1234            "stale" => {
1235                let _rtxn = env.read_txn().expect("open read txn");
1236                std::process::exit(0);
1237            }
1238            "small-map" => {
1239                let mut wtxn = env.write_txn().expect("open write txn");
1240                let _blobs: Database<Bytes, Bytes> = env
1241                    .create_database(&mut wtxn, Some("blobs"))
1242                    .expect("create blobs database");
1243                let _metadata: Database<Bytes, Bytes> = env
1244                    .create_database(&mut wtxn, Some("metadata"))
1245                    .expect("create metadata database");
1246                let _eviction_order: Database<Bytes, Unit> = env
1247                    .create_database(&mut wtxn, Some("eviction_order"))
1248                    .expect("create eviction_order database");
1249                let _pins: Database<Bytes, Bytes> = env
1250                    .create_database(&mut wtxn, Some("pins"))
1251                    .expect("create pins database");
1252                wtxn.commit().expect("commit small-map setup txn");
1253                std::process::exit(0);
1254            }
1255            "large-map" => {
1256                let mut wtxn = env.write_txn().expect("open write txn");
1257                let blobs: Database<Bytes, Bytes> = env
1258                    .create_database(&mut wtxn, Some("blobs"))
1259                    .expect("create blobs database");
1260                let _metadata: Database<Bytes, Bytes> = env
1261                    .create_database(&mut wtxn, Some("metadata"))
1262                    .expect("create metadata database");
1263                let _eviction_order: Database<Bytes, Unit> = env
1264                    .create_database(&mut wtxn, Some("eviction_order"))
1265                    .expect("create eviction_order database");
1266                let _pins: Database<Bytes, Bytes> = env
1267                    .create_database(&mut wtxn, Some("pins"))
1268                    .expect("create pins database");
1269                let data = vec![42u8; 3 * 1024 * 1024];
1270                let hash = sha256(b"large existing blob");
1271                blobs.put(&mut wtxn, &hash, &data).expect("seed blob");
1272                wtxn.commit().expect("commit large-map setup txn");
1273                std::process::exit(0);
1274            }
1275            other => panic!("unknown helper mode: {other}"),
1276        }
1277    }
1278}