Skip to main content

commonware_storage/journal/segmented/
glob.rs

1//! Simple section-based blob storage for values.
2//!
3//! This module provides a minimal blob storage optimized for storing values where
4//! the size is tracked externally (in an index entry). Unlike the segmented variable
5//! journal, this format does not include a size prefix since the caller already
6//! knows the size.
7//!
8//! # Format
9//!
10//! Each entry is stored as:
11//!
12//! ```text
13//! +---+---+---+---+---+---+---+---+---+---+---+---+
14//! |     Compressed Data (variable)    |   CRC32   |
15//! +---+---+---+---+---+---+---+---+---+---+---+---+
16//! ```
17//!
18//! - **Compressed Data**: zstd compressed (if enabled) or raw codec output
19//! - **CRC32**: 4-byte checksum of the compressed data
20//!
21//! # Read Flow
22//!
23//! 1. Get `(offset, size)` from index entry
24//! 2. Read `size` bytes directly from blob at byte offset
25//! 3. Last 4 bytes are CRC32, verify it
26//! 4. Decompress remaining bytes if compression enabled
27//! 5. Decode value
28
29use super::manager::{Config as ManagerConfig, Manager, WriteFactory};
30use crate::journal::Error;
31use commonware_codec::{Codec, CodecShared, FixedSize};
32use commonware_cryptography::{crc32, Crc32};
33use commonware_runtime::{BufMut, BufferPooler, Error as RError, Handle, Metrics, Storage};
34use std::{io::Cursor, num::NonZeroUsize};
35use zstd::{bulk::compress, decode_all};
36
37/// Configuration for blob storage.
38#[derive(Clone)]
39pub struct Config<C> {
40    /// The partition to use for storing blobs.
41    pub partition: String,
42
43    /// Optional compression level (using `zstd`) to apply to data before storing.
44    pub compression: Option<u8>,
45
46    /// The codec configuration to use for encoding and decoding items.
47    pub codec_config: C,
48
49    /// The size of the write buffer to use for each blob.
50    pub write_buffer: NonZeroUsize,
51}
52
53/// Simple section-based blob storage for values.
54///
55/// Uses [`buffer::Write`](commonware_runtime::buffer::Write) for batching writes.
56/// Reads go directly to blobs without any caching (ideal for large values that
57/// shouldn't pollute a page cache).
58pub struct Glob<E: BufferPooler + Storage + Metrics, V: Codec> {
59    manager: Manager<E, WriteFactory>,
60
61    /// Compression level (if enabled).
62    compression: Option<u8>,
63
64    /// Codec configuration.
65    codec_config: V::Cfg,
66}
67
68impl<E: BufferPooler + Storage + Metrics, V: CodecShared> Glob<E, V> {
69    /// Initialize blob storage, opening existing section blobs.
70    pub async fn init(context: E, cfg: Config<V::Cfg>) -> Result<Self, Error> {
71        let manager_cfg = ManagerConfig {
72            partition: cfg.partition,
73            factory: WriteFactory {
74                capacity: cfg.write_buffer,
75                pool: context.storage_buffer_pool().clone(),
76            },
77        };
78        let manager = Manager::init(context, manager_cfg).await?;
79
80        Ok(Self {
81            manager,
82            compression: cfg.compression,
83            codec_config: cfg.codec_config,
84        })
85    }
86
87    /// Append value to section, returns (offset, size).
88    ///
89    /// The returned offset is the byte offset where the entry was written.
90    /// The returned size is the total bytes written (compressed_data + crc32).
91    /// Both should be stored in the index entry for later retrieval.
92    pub async fn append(&mut self, section: u64, value: &V) -> Result<(u64, u32), Error> {
93        // Encode and optionally compress, then append checksum
94        let buf = if let Some(level) = self.compression {
95            // Compressed: encode first, then compress, then append checksum
96            let encoded = value.encode();
97            let mut compressed =
98                compress(&encoded, level as i32).map_err(|_| Error::CompressionFailed)?;
99            let checksum = Crc32::checksum(&compressed);
100            compressed.put_u32(checksum);
101            compressed
102        } else {
103            // Uncompressed: pre-allocate exact size to avoid copying
104            let entry_size = value.encode_size() + crc32::Digest::SIZE;
105            let mut buf = Vec::with_capacity(entry_size);
106            value.write(&mut buf);
107            let checksum = Crc32::checksum(&buf);
108            buf.put_u32(checksum);
109            buf
110        };
111
112        // Write to blob
113        let entry_size = u32::try_from(buf.len()).map_err(|_| Error::ValueTooLarge)?;
114        let writer = self.manager.get_or_create(section).await?;
115        let offset = writer.size();
116        writer.write_at(offset, buf).await.map_err(Error::Runtime)?;
117
118        Ok((offset, entry_size))
119    }
120
121    /// Read value at offset with known size (from index entry).
122    ///
123    /// The offset should be the byte offset returned by `append()`.
124    /// Reads directly from blob without any caching.
125    pub async fn get(&self, section: u64, offset: u64, size: u32) -> Result<V, Error> {
126        let writer = self
127            .manager
128            .get(section)?
129            .ok_or(Error::SectionOutOfRange(section))?;
130
131        // Read via buffered writer (handles read-through for buffered data)
132        let buf = writer.read_at(offset, size as usize).await?.coalesce();
133
134        // Entry format: [compressed_data] [crc32 (4 bytes)]
135        if buf.len() < crc32::Digest::SIZE {
136            return Err(Error::Runtime(RError::BlobInsufficientLength));
137        }
138
139        let data_len = buf.len() - crc32::Digest::SIZE;
140        let compressed_data = &buf.as_ref()[..data_len];
141        let stored_checksum = u32::from_be_bytes(
142            buf.as_ref()[data_len..]
143                .try_into()
144                .expect("checksum is 4 bytes"),
145        );
146
147        // Verify checksum
148        let checksum = Crc32::checksum(compressed_data);
149        if checksum != stored_checksum {
150            return Err(Error::ChecksumMismatch(stored_checksum, checksum));
151        }
152
153        // Decompress if needed and decode
154        let value = if self.compression.is_some() {
155            let decompressed =
156                decode_all(Cursor::new(compressed_data)).map_err(|_| Error::DecompressionFailed)?;
157            V::decode_cfg(decompressed.as_ref(), &self.codec_config).map_err(Error::Codec)?
158        } else {
159            V::decode_cfg(compressed_data, &self.codec_config).map_err(Error::Codec)?
160        };
161
162        Ok(value)
163    }
164
165    /// Sync the given `sections` to disk (flushes write buffers).
166    pub async fn sync(&mut self, sections: impl crate::Sections) -> Result<(), Error> {
167        self.manager.sync(sections).await
168    }
169
170    /// Start syncing the given `sections` to disk.
171    pub async fn start_sync(
172        &mut self,
173        sections: impl crate::Sections,
174    ) -> Result<Handle<()>, Error> {
175        self.manager.start_sync(sections).await
176    }
177
178    /// Sync all sections to disk.
179    pub async fn sync_all(&mut self) -> Result<(), Error> {
180        self.manager.sync_all().await
181    }
182
183    /// Get the current size of a section (including buffered data).
184    pub fn size(&self, section: u64) -> Result<u64, Error> {
185        self.manager.size(section)
186    }
187
188    /// Rewind to a specific section and size.
189    ///
190    /// Truncates the section to the given size and removes all sections after it.
191    pub async fn rewind(&mut self, section: u64, size: u64) -> Result<(), Error> {
192        self.manager.rewind(section, size).await
193    }
194
195    /// Rewind only the given section to a specific size.
196    ///
197    /// Unlike `rewind`, this does not affect other sections.
198    pub async fn rewind_section(&mut self, section: u64, size: u64) -> Result<(), Error> {
199        self.manager.rewind_section(section, size).await
200    }
201
202    /// Prune sections before min.
203    pub async fn prune(&mut self, min: u64) -> Result<bool, Error> {
204        self.manager.prune(min).await
205    }
206
207    /// Returns the number of the oldest section.
208    pub fn oldest_section(&self) -> Option<u64> {
209        self.manager.oldest_section()
210    }
211
212    /// Returns the number of the newest section.
213    pub fn newest_section(&self) -> Option<u64> {
214        self.manager.newest_section()
215    }
216
217    /// Returns an iterator over all section numbers.
218    pub fn sections(&self) -> impl Iterator<Item = u64> + '_ {
219        self.manager.sections()
220    }
221
222    /// Remove a specific section. Returns true if the section existed and was removed.
223    pub async fn remove_section(&mut self, section: u64) -> Result<bool, Error> {
224        self.manager.remove_section(section).await
225    }
226
227    /// Destroy all blobs.
228    pub async fn destroy(self) -> Result<(), Error> {
229        self.manager.destroy().await
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use commonware_macros::test_traced;
237    use commonware_runtime::{deterministic, Runner, Supervisor as _};
238    use commonware_utils::NZUsize;
239
240    fn test_cfg() -> Config<()> {
241        Config {
242            partition: "test-partition".into(),
243            compression: None,
244            codec_config: (),
245            write_buffer: NZUsize!(1024),
246        }
247    }
248
249    #[test_traced]
250    fn test_glob_append_and_get() {
251        let executor = deterministic::Runner::default();
252        executor.start(|context| async move {
253            let mut glob: Glob<_, i32> = Glob::init(context.child("storage"), test_cfg())
254                .await
255                .expect("Failed to init glob");
256
257            // Append a value
258            let value: i32 = 42;
259            let (offset, size) = glob.append(1, &value).await.expect("Failed to append");
260            assert_eq!(offset, 0);
261
262            // Get the value back
263            let retrieved = glob.get(1, offset, size).await.expect("Failed to get");
264            assert_eq!(retrieved, value);
265
266            // Sync and verify
267            glob.sync(1).await.expect("Failed to sync");
268            let retrieved = glob.get(1, offset, size).await.expect("Failed to get");
269            assert_eq!(retrieved, value);
270
271            glob.destroy().await.expect("Failed to destroy");
272        });
273    }
274
275    #[test_traced]
276    fn test_glob_multiple_values() {
277        let executor = deterministic::Runner::default();
278        executor.start(|context| async move {
279            let mut glob: Glob<_, i32> = Glob::init(context.child("storage"), test_cfg())
280                .await
281                .expect("Failed to init glob");
282
283            // Append multiple values
284            let values: Vec<i32> = vec![1, 2, 3, 4, 5];
285            let mut locations = Vec::new();
286
287            for value in &values {
288                let (offset, size) = glob.append(1, value).await.expect("Failed to append");
289                locations.push((offset, size));
290            }
291
292            // Get all values back
293            for (i, (offset, size)) in locations.iter().enumerate() {
294                let retrieved = glob.get(1, *offset, *size).await.expect("Failed to get");
295                assert_eq!(retrieved, values[i]);
296            }
297
298            glob.destroy().await.expect("Failed to destroy");
299        });
300    }
301
302    #[test_traced]
303    fn test_glob_with_compression() {
304        let executor = deterministic::Runner::default();
305        executor.start(|context| async move {
306            let cfg = Config {
307                partition: "test-partition".into(),
308                compression: Some(3), // zstd level 3
309                codec_config: (),
310                write_buffer: NZUsize!(1024),
311            };
312            let mut glob: Glob<_, [u8; 100]> = Glob::init(context.child("storage"), cfg)
313                .await
314                .expect("Failed to init glob");
315
316            // Append a value
317            let value: [u8; 100] = [0u8; 100]; // Compressible data
318            let (offset, size) = glob.append(1, &value).await.expect("Failed to append");
319
320            // Size should be smaller due to compression
321            assert!(size < 100 + 4);
322
323            // Get the value back
324            let retrieved = glob.get(1, offset, size).await.expect("Failed to get");
325            assert_eq!(retrieved, value);
326
327            glob.destroy().await.expect("Failed to destroy");
328        });
329    }
330
331    #[test_traced]
332    fn test_glob_prune() {
333        let executor = deterministic::Runner::default();
334        executor.start(|context| async move {
335            let mut glob: Glob<_, i32> = Glob::init(context.child("storage"), test_cfg())
336                .await
337                .expect("Failed to init glob");
338
339            // Append to multiple sections
340            for section in 1..=5 {
341                glob.append(section, &(section as i32))
342                    .await
343                    .expect("Failed to append");
344                glob.sync(section).await.expect("Failed to sync");
345            }
346
347            // Prune sections < 3
348            glob.prune(3).await.expect("Failed to prune");
349
350            // Sections 1 and 2 should be gone
351            assert!(glob.get(1, 0, 8).await.is_err());
352            assert!(glob.get(2, 0, 8).await.is_err());
353
354            // Sections 3-5 should still exist
355            assert!(glob.manager.blobs.contains_key(&3));
356            assert!(glob.manager.blobs.contains_key(&4));
357            assert!(glob.manager.blobs.contains_key(&5));
358
359            glob.destroy().await.expect("Failed to destroy");
360        });
361    }
362
363    #[test_traced]
364    fn test_glob_checksum_mismatch() {
365        let executor = deterministic::Runner::default();
366        executor.start(|context| async move {
367            let mut glob: Glob<_, i32> = Glob::init(context.child("storage"), test_cfg())
368                .await
369                .expect("Failed to init glob");
370
371            // Append a value
372            let value: i32 = 42;
373            let (offset, size) = glob.append(1, &value).await.expect("Failed to append");
374            glob.sync(1).await.expect("Failed to sync");
375
376            // Corrupt the data by writing directly to the underlying blob
377            let writer = glob.manager.blobs.get_mut(&1).unwrap();
378            writer
379                .write_at(offset, vec![0xFF, 0xFF, 0xFF, 0xFF])
380                .await
381                .expect("Failed to corrupt");
382            writer.sync().await.expect("Failed to sync");
383
384            // Get should fail with checksum mismatch
385            let result = glob.get(1, offset, size).await;
386            assert!(matches!(result, Err(Error::ChecksumMismatch(_, _))));
387
388            glob.destroy().await.expect("Failed to destroy");
389        });
390    }
391
392    #[test_traced]
393    fn test_glob_rewind() {
394        let executor = deterministic::Runner::default();
395        executor.start(|context| async move {
396            let mut glob: Glob<_, i32> = Glob::init(context.child("storage"), test_cfg())
397                .await
398                .expect("Failed to init glob");
399
400            // Append multiple values and track sizes
401            let values: Vec<i32> = vec![1, 2, 3, 4, 5];
402            let mut locations = Vec::new();
403
404            for value in &values {
405                let (offset, size) = glob.append(1, value).await.expect("Failed to append");
406                locations.push((offset, size));
407            }
408            glob.sync(1).await.expect("Failed to sync");
409
410            // Rewind to after the third value
411            let (third_offset, third_size) = locations[2];
412            let rewind_size = third_offset + u64::from(third_size);
413            glob.rewind_section(1, rewind_size)
414                .await
415                .expect("Failed to rewind");
416
417            // First three values should still be readable
418            for (i, (offset, size)) in locations.iter().take(3).enumerate() {
419                let retrieved = glob.get(1, *offset, *size).await.expect("Failed to get");
420                assert_eq!(retrieved, values[i]);
421            }
422
423            // Fourth and fifth values should fail (reading past end of blob)
424            let (fourth_offset, fourth_size) = locations[3];
425            let result = glob.get(1, fourth_offset, fourth_size).await;
426            assert!(result.is_err());
427
428            glob.destroy().await.expect("Failed to destroy");
429        });
430    }
431
432    #[test_traced]
433    fn test_glob_persistence() {
434        let executor = deterministic::Runner::default();
435        executor.start(|context| async move {
436            let cfg = test_cfg();
437
438            // Create and populate glob
439            let mut glob: Glob<_, i32> = Glob::init(context.child("first"), cfg.clone())
440                .await
441                .expect("Failed to init glob");
442
443            let value: i32 = 42;
444            let (offset, size) = glob.append(1, &value).await.expect("Failed to append");
445            glob.sync(1).await.expect("Failed to sync");
446            drop(glob);
447
448            // Reopen and verify
449            let glob: Glob<_, i32> = Glob::init(context.child("second"), cfg)
450                .await
451                .expect("Failed to reinit glob");
452
453            let retrieved = glob.get(1, offset, size).await.expect("Failed to get");
454            assert_eq!(retrieved, value);
455
456            glob.destroy().await.expect("Failed to destroy");
457        });
458    }
459
460    #[test_traced]
461    fn test_glob_get_invalid_size() {
462        let executor = deterministic::Runner::default();
463        executor.start(|context| async move {
464            let mut glob: Glob<_, i32> = Glob::init(context.child("storage"), test_cfg())
465                .await
466                .expect("Failed to init glob");
467
468            let (offset, _size) = glob.append(1, &42).await.expect("Failed to append");
469            glob.sync(1).await.expect("Failed to sync");
470
471            // Size 0 - should fail
472            assert!(glob.get(1, offset, 0).await.is_err());
473
474            // Size < CRC_SIZE (1, 2, 3 bytes) - should fail with BlobInsufficientLength
475            for size in 1..4u32 {
476                let result = glob.get(1, offset, size).await;
477                assert!(matches!(
478                    result,
479                    Err(Error::Runtime(RError::BlobInsufficientLength))
480                ));
481            }
482
483            glob.destroy().await.expect("Failed to destroy");
484        });
485    }
486
487    #[test_traced]
488    fn test_glob_get_wrong_size() {
489        let executor = deterministic::Runner::default();
490        executor.start(|context| async move {
491            let mut glob: Glob<_, i32> = Glob::init(context.child("storage"), test_cfg())
492                .await
493                .expect("Failed to init glob");
494
495            let (offset, correct_size) = glob.append(1, &42).await.expect("Failed to append");
496            glob.sync(1).await.expect("Failed to sync");
497
498            // Size too small (but >= CRC_SIZE) - checksum mismatch
499            let result = glob.get(1, offset, correct_size - 1).await;
500            assert!(matches!(result, Err(Error::ChecksumMismatch(_, _))));
501
502            glob.destroy().await.expect("Failed to destroy");
503        });
504    }
505}