Skip to main content

ipfrs_storage/
blockstore.rs

1//! Block storage implementation
2//!
3//! - [`BlockStoreConfig`] and [`DeduplicationStats`] are always available.
4//! - [`SledBlockStore`] is only compiled when the `sled-backend` feature is enabled.
5
6use std::path::PathBuf;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Arc;
9
10// ---------------------------------------------------------------------------
11// BlockStoreConfig
12// ---------------------------------------------------------------------------
13
14/// Block store configuration
15#[derive(Debug, Clone)]
16pub struct BlockStoreConfig {
17    /// Path to the database directory
18    pub path: PathBuf,
19    /// Cache size in bytes
20    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, // 100MB
28        }
29    }
30}
31
32impl BlockStoreConfig {
33    /// Create a configuration optimized for development
34    /// - Small cache (50MB)
35    /// - Stored in the system temp directory for easy cleanup
36    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    /// Create a configuration optimized for production
44    /// - Large cache (500MB)
45    /// - Stored in standard location
46    pub fn production(path: PathBuf) -> Self {
47        Self {
48            path,
49            cache_size: 500 * 1024 * 1024,
50        }
51    }
52
53    /// Create a configuration optimized for embedded devices
54    /// - Minimal cache (10MB)
55    /// - Configurable path
56    pub fn embedded(path: PathBuf) -> Self {
57        Self {
58            path,
59            cache_size: 10 * 1024 * 1024,
60        }
61    }
62
63    /// Create a configuration optimized for testing
64    /// - Minimal cache (5MB)
65    /// - Temporary directory with unique name
66    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    /// Builder: Set the storage path
75    pub fn with_path(mut self, path: PathBuf) -> Self {
76        self.path = path;
77        self
78    }
79
80    /// Builder: Set the cache size in MB
81    pub fn with_cache_mb(mut self, cache_mb: usize) -> Self {
82        self.cache_size = cache_mb * 1024 * 1024;
83        self
84    }
85
86    /// Builder: Set the cache size in bytes
87    pub fn with_cache_bytes(mut self, cache_bytes: usize) -> Self {
88        self.cache_size = cache_bytes;
89        self
90    }
91}
92
93// ---------------------------------------------------------------------------
94// DeduplicationStats
95// ---------------------------------------------------------------------------
96
97/// Point-in-time snapshot of deduplication statistics.
98#[derive(Debug, Clone, PartialEq)]
99pub struct DeduplicationStatsSnapshot {
100    /// Total number of `put` calls (including deduplicated ones).
101    pub total_puts: u64,
102    /// Puts that were skipped because the CID already existed.
103    pub deduplicated: u64,
104    /// Sum of skipped block sizes in bytes.
105    pub bytes_saved: u64,
106    /// Fraction of puts that were deduplicated (`deduplicated / total_puts`).
107    pub dedup_ratio: f64,
108}
109
110/// Lock-free, atomics-based deduplication statistics.
111///
112/// Wrap in `Arc<DeduplicationStats>` to share across threads without a mutex.
113#[derive(Debug, Default)]
114pub struct DeduplicationStats {
115    /// Total put attempts.
116    pub total_puts: AtomicU64,
117    /// Puts skipped because the CID was already present.
118    pub deduplicated: AtomicU64,
119    /// Cumulative byte count of deduplicated (skipped) blocks.
120    pub bytes_saved: AtomicU64,
121}
122
123impl DeduplicationStats {
124    /// Create a new counter set wrapped in an `Arc`.
125    pub fn new() -> Arc<Self> {
126        Arc::new(Self::default())
127    }
128
129    /// Record a single put operation.
130    ///
131    /// * `deduplicated` — `true` when the block was skipped (CID already existed).
132    /// * `bytes` — byte length of the block.
133    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    /// Take an instantaneous snapshot of all counters.
142    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// ---------------------------------------------------------------------------
161// SledBlockStore — only compiled when the `sled-backend` feature is enabled
162// ---------------------------------------------------------------------------
163
164#[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    /// Block storage using Sled embedded database.
175    ///
176    /// Available only when the `sled-backend` feature is enabled (the default).
177    /// Not available on wasm32 targets — use [`crate::MemoryBlockStore`] there.
178    pub struct SledBlockStore {
179        db: Db,
180        /// Lock-free deduplication counters
181        dedup_stats: Arc<DeduplicationStats>,
182        /// Lock-free compaction scheduler
183        compaction_scheduler: Arc<CompactionScheduler>,
184    }
185
186    impl SledBlockStore {
187        /// Create a new block store with default compaction settings.
188        pub fn new(config: BlockStoreConfig) -> Result<Self> {
189            Self::new_with_compaction(config, CompactionConfig::default())
190        }
191
192        /// Create a new block store with a custom compaction configuration.
193        pub fn new_with_compaction(
194            config: BlockStoreConfig,
195            compaction_config: CompactionConfig,
196        ) -> Result<Self> {
197            // Create parent directory if it doesn't exist
198            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        // ── Deduplication ──────────────────────────────────────────────────────
217
218        /// Write the block only when its CID is not already present.
219        ///
220        /// Returns `true` if the block was written, `false` if it was deduplicated
221        /// (CID already existed).
222        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        /// Batch put with write-time deduplication.
250        ///
251        /// Returns `(written, deduped)`.
252        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        /// Return a shared handle to the deduplication statistics.
267        pub fn dedup_stats(&self) -> Arc<DeduplicationStats> {
268            Arc::clone(&self.dedup_stats)
269        }
270
271        // ── Compaction ─────────────────────────────────────────────────────────
272
273        /// Flush the Sled WAL if the compaction scheduler determines it is time.
274        ///
275        /// Returns `true` when a flush was actually performed.
276        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                // Another task won the race.
282                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        /// Return a shared handle to the compaction scheduler.
295        pub fn compaction_scheduler(&self) -> Arc<CompactionScheduler> {
296            Arc::clone(&self.compaction_scheduler)
297        }
298
299        /// Open (or re-open) the `SledSnapshotPinRegistry` backed by this
300        /// store's Sled database.
301        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        /// Store a block.
311        ///
312        /// If the CID already exists the write is skipped (content-addressed data
313        /// is immutable, so the stored bytes are guaranteed to be identical).
314        async fn put(&self, block: &Block) -> Result<()> {
315            let key = block.cid().to_bytes();
316            let bytes = block.data().len();
317
318            // Check for existence — skip write if already present (deduplication).
319            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        /// Retrieve a block by CID
344        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        /// Check if a block exists
358        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        /// Delete a block
366        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        /// Get the number of blocks stored
381        fn len(&self) -> usize {
382            self.db.len()
383        }
384
385        /// Check if the store is empty
386        fn is_empty(&self) -> bool {
387            self.db.is_empty()
388        }
389
390        /// Get all CIDs in the store
391        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        /// Store multiple blocks atomically using Sled's batch API
407        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        /// Retrieve multiple blocks efficiently
429        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        /// Check if multiple blocks exist efficiently
448        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        /// Delete multiple blocks atomically
464        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        /// Flush pending writes to disk
485        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    // ── Tests ──────────────────────────────────────────────────────────────────
495
496    #[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;