1use 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
11pub 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
34pub struct LmdbBlobStore {
36 env: heed::Env,
37 blobs: Database<Bytes, Bytes>,
39 metadata: Database<Bytes, Bytes>,
41 eviction_order: Database<Bytes, Unit>,
43 pins: Database<Bytes, Bytes>,
45 max_bytes: AtomicU64,
46 current_bytes: AtomicU64,
47 next_order: AtomicU64,
48}
49
50impl LmdbBlobStore {
51 pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, StoreError> {
53 Self::with_map_size(path, DEFAULT_MAP_SIZE)
54 }
55
56 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 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 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 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 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 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 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 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 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?); assert!(!store.put(hash, data.to_vec()).await?); 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}