1use std::path::PathBuf;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Arc;
9
10#[derive(Debug, Clone)]
16pub struct BlockStoreConfig {
17 pub path: PathBuf,
19 pub cache_size: usize,
21}
22
23impl Default for BlockStoreConfig {
24 fn default() -> Self {
25 Self {
26 path: PathBuf::from(".ipfrs/blocks"),
27 cache_size: 100 * 1024 * 1024, }
29 }
30}
31
32impl BlockStoreConfig {
33 pub fn development() -> Self {
37 Self {
38 path: std::env::temp_dir().join("ipfrs-dev"),
39 cache_size: 50 * 1024 * 1024,
40 }
41 }
42
43 pub fn production(path: PathBuf) -> Self {
47 Self {
48 path,
49 cache_size: 500 * 1024 * 1024,
50 }
51 }
52
53 pub fn embedded(path: PathBuf) -> Self {
57 Self {
58 path,
59 cache_size: 10 * 1024 * 1024,
60 }
61 }
62
63 pub fn testing() -> Self {
67 let temp_dir = std::env::temp_dir().join(format!("ipfrs-test-{}", std::process::id()));
68 Self {
69 path: temp_dir,
70 cache_size: 5 * 1024 * 1024,
71 }
72 }
73
74 pub fn with_path(mut self, path: PathBuf) -> Self {
76 self.path = path;
77 self
78 }
79
80 pub fn with_cache_mb(mut self, cache_mb: usize) -> Self {
82 self.cache_size = cache_mb * 1024 * 1024;
83 self
84 }
85
86 pub fn with_cache_bytes(mut self, cache_bytes: usize) -> Self {
88 self.cache_size = cache_bytes;
89 self
90 }
91}
92
93#[derive(Debug, Clone, PartialEq)]
99pub struct DeduplicationStatsSnapshot {
100 pub total_puts: u64,
102 pub deduplicated: u64,
104 pub bytes_saved: u64,
106 pub dedup_ratio: f64,
108}
109
110#[derive(Debug, Default)]
114pub struct DeduplicationStats {
115 pub total_puts: AtomicU64,
117 pub deduplicated: AtomicU64,
119 pub bytes_saved: AtomicU64,
121}
122
123impl DeduplicationStats {
124 pub fn new() -> Arc<Self> {
126 Arc::new(Self::default())
127 }
128
129 pub fn record_put(&self, deduplicated: bool, bytes: usize) {
134 self.total_puts.fetch_add(1, Ordering::Relaxed);
135 if deduplicated {
136 self.deduplicated.fetch_add(1, Ordering::Relaxed);
137 self.bytes_saved.fetch_add(bytes as u64, Ordering::Relaxed);
138 }
139 }
140
141 pub fn snapshot(&self) -> DeduplicationStatsSnapshot {
143 let total_puts = self.total_puts.load(Ordering::Relaxed);
144 let deduplicated = self.deduplicated.load(Ordering::Relaxed);
145 let bytes_saved = self.bytes_saved.load(Ordering::Relaxed);
146 let dedup_ratio = if total_puts == 0 {
147 0.0
148 } else {
149 deduplicated as f64 / total_puts as f64
150 };
151 DeduplicationStatsSnapshot {
152 total_puts,
153 deduplicated,
154 bytes_saved,
155 dedup_ratio,
156 }
157 }
158}
159
160#[cfg(feature = "sled-backend")]
165mod sled_store {
166 use super::{BlockStoreConfig, DeduplicationStats};
167 use crate::compaction::{CompactionConfig, CompactionScheduler};
168 use crate::traits::BlockStore;
169 use async_trait::async_trait;
170 use ipfrs_core::{Block, Cid, Error, Result};
171 use sled::Db;
172 use std::sync::Arc;
173
174 pub struct SledBlockStore {
179 db: Db,
180 dedup_stats: Arc<DeduplicationStats>,
182 compaction_scheduler: Arc<CompactionScheduler>,
184 }
185
186 impl SledBlockStore {
187 pub fn new(config: BlockStoreConfig) -> Result<Self> {
189 Self::new_with_compaction(config, CompactionConfig::default())
190 }
191
192 pub fn new_with_compaction(
194 config: BlockStoreConfig,
195 compaction_config: CompactionConfig,
196 ) -> Result<Self> {
197 if let Some(parent) = config.path.parent() {
199 std::fs::create_dir_all(parent)
200 .map_err(|e| Error::Storage(format!("Failed to create directory: {e}")))?;
201 }
202
203 let db = sled::Config::new()
204 .path(&config.path)
205 .cache_capacity(config.cache_size as u64)
206 .open()
207 .map_err(|e| Error::Storage(format!("Failed to open database: {e}")))?;
208
209 Ok(Self {
210 db,
211 dedup_stats: DeduplicationStats::new(),
212 compaction_scheduler: CompactionScheduler::new(compaction_config),
213 })
214 }
215
216 pub async fn put_if_absent(&self, block: &Block) -> Result<bool> {
223 let key = block.cid().to_bytes();
224 let exists = self
225 .db
226 .contains_key(&key)
227 .map_err(|e| Error::Storage(format!("Failed to check block: {e}")))?;
228
229 let bytes = block.data().len();
230 self.dedup_stats.record_put(exists, bytes);
231
232 if exists {
233 return Ok(false);
234 }
235
236 self.db
237 .insert(key, block.data().to_vec())
238 .map_err(|e| Error::Storage(format!("Failed to insert block: {e}")))?;
239
240 self.db
241 .flush_async()
242 .await
243 .map_err(|e| Error::Storage(format!("Failed to flush: {e}")))?;
244
245 self.compaction_scheduler.record_write(bytes);
246 Ok(true)
247 }
248
249 pub async fn put_batch_dedup(&self, blocks: &[Block]) -> Result<(usize, usize)> {
253 let mut written = 0usize;
254 let mut deduped = 0usize;
255
256 for block in blocks {
257 if self.put_if_absent(block).await? {
258 written += 1;
259 } else {
260 deduped += 1;
261 }
262 }
263 Ok((written, deduped))
264 }
265
266 pub fn dedup_stats(&self) -> Arc<DeduplicationStats> {
268 Arc::clone(&self.dedup_stats)
269 }
270
271 pub async fn maybe_compact(&self) -> Result<bool> {
277 if !self.compaction_scheduler.should_compact() {
278 return Ok(false);
279 }
280 if !self.compaction_scheduler.mark_compaction_started() {
281 return Ok(false);
283 }
284 let flush_result = self
285 .db
286 .flush_async()
287 .await
288 .map_err(|e| Error::Storage(format!("Failed to flush during compaction: {e}")));
289 self.compaction_scheduler.mark_compaction_done();
290 flush_result?;
291 Ok(true)
292 }
293
294 pub fn compaction_scheduler(&self) -> Arc<CompactionScheduler> {
296 Arc::clone(&self.compaction_scheduler)
297 }
298
299 pub fn snapshot_pin_registry(
302 &self,
303 ) -> ipfrs_core::Result<crate::gc::SledSnapshotPinRegistry> {
304 crate::gc::SledSnapshotPinRegistry::open(&self.db)
305 }
306 }
307
308 #[async_trait]
309 impl BlockStore for SledBlockStore {
310 async fn put(&self, block: &Block) -> Result<()> {
315 let key = block.cid().to_bytes();
316 let bytes = block.data().len();
317
318 let exists = self
320 .db
321 .contains_key(&key)
322 .map_err(|e| Error::Storage(format!("Failed to check block: {e}")))?;
323
324 self.dedup_stats.record_put(exists, bytes);
325
326 if exists {
327 return Ok(());
328 }
329
330 self.db
331 .insert(key, block.data().to_vec())
332 .map_err(|e| Error::Storage(format!("Failed to insert block: {e}")))?;
333
334 self.db
335 .flush_async()
336 .await
337 .map_err(|e| Error::Storage(format!("Failed to flush: {e}")))?;
338
339 self.compaction_scheduler.record_write(bytes);
340 Ok(())
341 }
342
343 async fn get(&self, cid: &Cid) -> Result<Option<Block>> {
345 let key = cid.to_bytes();
346
347 match self.db.get(&key) {
348 Ok(Some(value)) => {
349 let data = bytes::Bytes::from(value.to_vec());
350 Ok(Some(Block::from_parts(*cid, data)))
351 }
352 Ok(None) => Ok(None),
353 Err(e) => Err(Error::Storage(format!("Failed to get block: {e}"))),
354 }
355 }
356
357 async fn has(&self, cid: &Cid) -> Result<bool> {
359 let key = cid.to_bytes();
360 self.db
361 .contains_key(&key)
362 .map_err(|e| Error::Storage(format!("Failed to check block: {e}")))
363 }
364
365 async fn delete(&self, cid: &Cid) -> Result<()> {
367 let key = cid.to_bytes();
368 self.db
369 .remove(&key)
370 .map_err(|e| Error::Storage(format!("Failed to delete block: {e}")))?;
371
372 self.db
373 .flush_async()
374 .await
375 .map_err(|e| Error::Storage(format!("Failed to flush: {e}")))?;
376
377 Ok(())
378 }
379
380 fn len(&self) -> usize {
382 self.db.len()
383 }
384
385 fn is_empty(&self) -> bool {
387 self.db.is_empty()
388 }
389
390 fn list_cids(&self) -> Result<Vec<Cid>> {
392 let mut cids = Vec::new();
393
394 for item in self.db.iter() {
395 let (key, _) = item.map_err(|e| Error::Storage(format!("Iteration error: {e}")))?;
396
397 let cid = Cid::try_from(key.to_vec())
398 .map_err(|e| Error::Cid(format!("Failed to parse CID: {e}")))?;
399
400 cids.push(cid);
401 }
402
403 Ok(cids)
404 }
405
406 async fn put_many(&self, blocks: &[Block]) -> Result<()> {
408 let mut batch = sled::Batch::default();
409
410 for block in blocks {
411 let key = block.cid().to_bytes();
412 let value = block.data().to_vec();
413 batch.insert(key, value);
414 }
415
416 self.db
417 .apply_batch(batch)
418 .map_err(|e| Error::Storage(format!("Failed to apply batch: {e}")))?;
419
420 self.db
421 .flush_async()
422 .await
423 .map_err(|e| Error::Storage(format!("Failed to flush: {e}")))?;
424
425 Ok(())
426 }
427
428 async fn get_many(&self, cids: &[Cid]) -> Result<Vec<Option<Block>>> {
430 let mut results = Vec::with_capacity(cids.len());
431
432 for cid in cids {
433 let key = cid.to_bytes();
434 match self.db.get(&key) {
435 Ok(Some(value)) => {
436 let data = bytes::Bytes::from(value.to_vec());
437 results.push(Some(Block::from_parts(*cid, data)));
438 }
439 Ok(None) => results.push(None),
440 Err(e) => return Err(Error::Storage(format!("Failed to get block: {e}"))),
441 }
442 }
443
444 Ok(results)
445 }
446
447 async fn has_many(&self, cids: &[Cid]) -> Result<Vec<bool>> {
449 let mut results = Vec::with_capacity(cids.len());
450
451 for cid in cids {
452 let key = cid.to_bytes();
453 let exists = self
454 .db
455 .contains_key(&key)
456 .map_err(|e| Error::Storage(format!("Failed to check block: {e}")))?;
457 results.push(exists);
458 }
459
460 Ok(results)
461 }
462
463 async fn delete_many(&self, cids: &[Cid]) -> Result<()> {
465 let mut batch = sled::Batch::default();
466
467 for cid in cids {
468 let key = cid.to_bytes();
469 batch.remove(key);
470 }
471
472 self.db
473 .apply_batch(batch)
474 .map_err(|e| Error::Storage(format!("Failed to apply batch: {e}")))?;
475
476 self.db
477 .flush_async()
478 .await
479 .map_err(|e| Error::Storage(format!("Failed to flush: {e}")))?;
480
481 Ok(())
482 }
483
484 async fn flush(&self) -> Result<()> {
486 self.db
487 .flush_async()
488 .await
489 .map_err(|e| Error::Storage(format!("Failed to flush: {e}")))?;
490 Ok(())
491 }
492 }
493
494 #[cfg(test)]
497 mod tests {
498 use super::*;
499 use bytes::Bytes;
500 use std::path::PathBuf;
501
502 fn unique_test_dir(suffix: &str) -> PathBuf {
503 std::env::temp_dir().join(format!(
504 "ipfrs-test-blockstore-{}-{}",
505 suffix,
506 std::process::id()
507 ))
508 }
509
510 #[tokio::test]
511 async fn test_put_get_block() {
512 let path = unique_test_dir("basic");
513 let _ = std::fs::remove_dir_all(&path);
514 let config = BlockStoreConfig {
515 path: path.clone(),
516 cache_size: 1024 * 1024,
517 };
518
519 let store = SledBlockStore::new(config).expect("open store");
520 let data = Bytes::from("hello world");
521 let block = Block::new(data.clone()).expect("create block");
522
523 store.put(&block).await.expect("put");
524
525 let retrieved = store.get(block.cid()).await.expect("get");
526 assert!(retrieved.is_some());
527 assert_eq!(retrieved.expect("block").data(), &data);
528
529 assert!(store.has(block.cid()).await.expect("has"));
530
531 store.delete(block.cid()).await.expect("delete");
532 assert!(!store.has(block.cid()).await.expect("has after delete"));
533
534 let _ = std::fs::remove_dir_all(&path);
535 }
536
537 #[tokio::test]
538 async fn test_dedup_skip_on_existing_cid() {
539 let path = unique_test_dir("dedup-skip");
540 let _ = std::fs::remove_dir_all(&path);
541 let store = SledBlockStore::new(BlockStoreConfig {
542 path: path.clone(),
543 cache_size: 1024 * 1024,
544 })
545 .expect("open store");
546
547 let block = Block::new(Bytes::from("dedup-skip-data")).expect("create block");
548
549 store.put(&block).await.expect("first put");
550 assert!(store.has(block.cid()).await.expect("has after first put"));
551
552 let snap_before = store.dedup_stats().snapshot();
553
554 store.put(&block).await.expect("second put");
555
556 let snap_after = store.dedup_stats().snapshot();
557 assert_eq!(
558 snap_after.deduplicated,
559 snap_before.deduplicated + 1,
560 "second put must be counted as deduplicated"
561 );
562 assert!(
563 snap_after.bytes_saved > snap_before.bytes_saved,
564 "bytes_saved must increase"
565 );
566
567 let _ = std::fs::remove_dir_all(&path);
568 }
569
570 #[tokio::test]
571 async fn test_dedup_stats_ratio() {
572 let path = unique_test_dir("dedup-ratio");
573 let _ = std::fs::remove_dir_all(&path);
574 let store = SledBlockStore::new(BlockStoreConfig {
575 path: path.clone(),
576 cache_size: 1024 * 1024,
577 })
578 .expect("open store");
579
580 let block = Block::new(Bytes::from("ratio-block")).expect("create block");
581
582 store.put(&block).await.expect("put 1");
583 store.put(&block).await.expect("put 2");
584
585 let snap = store.dedup_stats().snapshot();
586 assert_eq!(snap.total_puts, 2);
587 assert_eq!(snap.deduplicated, 1);
588 let expected_ratio = 0.5_f64;
589 assert!(
590 (snap.dedup_ratio - expected_ratio).abs() < f64::EPSILON,
591 "dedup_ratio must be 0.5, got {}",
592 snap.dedup_ratio
593 );
594
595 let _ = std::fs::remove_dir_all(&path);
596 }
597
598 #[tokio::test]
599 async fn test_dedup_bytes_saved() {
600 let path = unique_test_dir("dedup-bytes");
601 let _ = std::fs::remove_dir_all(&path);
602 let store = SledBlockStore::new(BlockStoreConfig {
603 path: path.clone(),
604 cache_size: 1024 * 1024,
605 })
606 .expect("open store");
607
608 let payload = Bytes::from(vec![42u8; 1024]);
609 let block = Block::new(payload.clone()).expect("create block");
610
611 store.put(&block).await.expect("put 1");
612 assert_eq!(store.dedup_stats().snapshot().bytes_saved, 0);
613
614 for _ in 0..3 {
615 store.put(&block).await.expect("dup put");
616 }
617
618 let snap = store.dedup_stats().snapshot();
619 assert_eq!(snap.deduplicated, 3);
620 assert_eq!(
621 snap.bytes_saved,
622 3 * payload.len() as u64,
623 "bytes_saved must equal 3 × block size"
624 );
625
626 let _ = std::fs::remove_dir_all(&path);
627 }
628
629 #[tokio::test]
630 async fn test_dedup_put_if_absent() {
631 let path = unique_test_dir("dedup-absent");
632 let _ = std::fs::remove_dir_all(&path);
633 let store = SledBlockStore::new(BlockStoreConfig {
634 path: path.clone(),
635 cache_size: 1024 * 1024,
636 })
637 .expect("open store");
638
639 let block = Block::new(Bytes::from("deduplicated data")).expect("create block");
640
641 let written = store.put_if_absent(&block).await.expect("put_if_absent 1");
642 assert!(written, "first write must return true");
643 assert!(store.has(block.cid()).await.expect("has"));
644
645 let written_again = store.put_if_absent(&block).await.expect("put_if_absent 2");
646 assert!(!written_again, "duplicate write must return false");
647
648 let snap = store.dedup_stats().snapshot();
649 assert_eq!(snap.deduplicated, 1);
650 assert!(snap.bytes_saved > 0);
651
652 let _ = std::fs::remove_dir_all(&path);
653 }
654
655 #[tokio::test]
656 async fn test_batch_dedup_stats() {
657 let path = unique_test_dir("batch-dedup");
658 let _ = std::fs::remove_dir_all(&path);
659 let store = SledBlockStore::new(BlockStoreConfig {
660 path: path.clone(),
661 cache_size: 1024 * 1024,
662 })
663 .expect("open store");
664
665 let block_a = Block::new(Bytes::from("alpha block")).expect("block a");
666 let block_b = Block::new(Bytes::from("beta block")).expect("block b");
667
668 store.put(&block_a).await.expect("pre-store block_a");
669
670 let (written, deduped) = store
671 .put_batch_dedup(&[block_a.clone(), block_b.clone()])
672 .await
673 .expect("put_batch_dedup");
674
675 assert_eq!(written, 1, "only block_b should be written");
676 assert_eq!(deduped, 1, "block_a should be dedup'd");
677 assert!(store.has(block_b.cid()).await.expect("has block_b"));
678
679 let _ = std::fs::remove_dir_all(&path);
680 }
681
682 #[tokio::test]
683 async fn test_maybe_compact_fires_when_due() {
684 let path = unique_test_dir("compact");
685 let _ = std::fs::remove_dir_all(&path);
686
687 let compaction_config = CompactionConfig {
688 idle_threshold: std::time::Duration::from_secs(0),
689 min_interval: std::time::Duration::from_secs(0),
690 max_bytes_since_compact: 1,
691 };
692
693 let store = SledBlockStore::new_with_compaction(
694 BlockStoreConfig {
695 path: path.clone(),
696 cache_size: 1024 * 1024,
697 },
698 compaction_config,
699 )
700 .expect("open store");
701
702 let block = Block::new(Bytes::from("compact-me")).expect("block");
703 store.put(&block).await.expect("put");
704
705 let compacted = store.maybe_compact().await.expect("maybe_compact");
706 assert!(compacted, "should have compacted with near-zero thresholds");
707 assert_eq!(store.compaction_scheduler().compaction_count(), 1);
708
709 let _ = std::fs::remove_dir_all(&path);
710 }
711 }
712}
713
714#[cfg(feature = "sled-backend")]
715pub use sled_store::SledBlockStore;