Skip to main content

reddb_server/storage/cache/blob/
config.rs

1use std::path::{Path, PathBuf};
2
3pub const DEFAULT_BLOB_L1_BYTES_MAX: usize = 256 * 1024 * 1024;
4pub const DEFAULT_BLOB_L2_BYTES_MAX: u64 = 4 * 1024 * 1024 * 1024;
5pub const DEFAULT_BLOB_MAX_NAMESPACES: usize = 256;
6pub const DEFAULT_BLOB_SHARDS: usize = 64;
7pub const DEFAULT_CONTENT_METADATA_KEYS_MAX: usize = 32;
8pub const DEFAULT_CONTENT_METADATA_BYTES_MAX: usize = 4 * 1024;
9pub const METRIC_CACHE_BLOB_L1_BYTES_IN_USE: &str = "cache_blob_l1_bytes_in_use";
10pub const METRIC_CACHE_VERSION_MISMATCH_TOTAL: &str = "cache_version_mismatch_total";
11pub const METRIC_CACHE_BLOB_L2_BYTES_IN_USE: &str = "reddb_cache_blob_l2_bytes_in_use";
12pub const METRIC_CACHE_BLOB_L2_FULL_REJECTIONS_TOTAL: &str =
13    "reddb_cache_blob_l2_full_rejections_total";
14pub const METRIC_CACHE_BLOB_SYNOPSIS_METADATA_READS_TOTAL: &str =
15    "cache_blob_synopsis_metadata_reads_total";
16pub const METRIC_CACHE_BLOB_SYNOPSIS_BYTES: &str = "cache_blob_synopsis_bytes";
17
18/// Default per-namespace Bloom synopsis sizing target. The filter is sized
19/// for ~10K entries at ~1% false-positive rate.
20pub const DEFAULT_BLOB_SYNOPSIS_CAPACITY: usize = 10_000;
21pub const DEFAULT_BLOB_SYNOPSIS_FPR: f64 = 0.01;
22
23/// Switch for L2 zstd compression (issue #192, lane 2/5).
24///
25/// `On` (default) routes every L2 spill through [`L2BlobCompressor`]; payloads
26/// that fail the shrinkage gate or hit a precompressed-media content type are
27/// still stored raw, but the L2 entry header carries the v2 framing. `Off`
28/// skips the compress call entirely (CPU-saving), still emitting v2 framing
29/// with `tag=0` so the on-disk format stays uniform across modes.
30#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
31pub enum L2Compression {
32    Off,
33    #[default]
34    On,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct BlobCacheConfig {
39    pub(super) l1_bytes_max: usize,
40    pub(super) l2_bytes_max: u64,
41    pub(super) l2_path: Option<PathBuf>,
42    pub(super) max_namespaces: usize,
43    pub(super) shard_count: usize,
44    pub(super) content_metadata_keys_max: usize,
45    pub(super) content_metadata_bytes_max: usize,
46    pub(super) l2_compression: L2Compression,
47}
48
49impl Default for BlobCacheConfig {
50    fn default() -> Self {
51        Self {
52            l1_bytes_max: DEFAULT_BLOB_L1_BYTES_MAX,
53            l2_bytes_max: DEFAULT_BLOB_L2_BYTES_MAX,
54            l2_path: None,
55            max_namespaces: DEFAULT_BLOB_MAX_NAMESPACES,
56            shard_count: DEFAULT_BLOB_SHARDS,
57            content_metadata_keys_max: DEFAULT_CONTENT_METADATA_KEYS_MAX,
58            content_metadata_bytes_max: DEFAULT_CONTENT_METADATA_BYTES_MAX,
59            l2_compression: L2Compression::default(),
60        }
61    }
62}
63
64impl BlobCacheConfig {
65    /// Returns a fresh builder primed with the cache defaults.
66    ///
67    /// Prefer this over field literals — fields are private so future
68    /// additions (PRD stories #8–#10) do not break callers.
69    pub fn builder() -> BlobCacheConfigBuilder {
70        BlobCacheConfigBuilder::new()
71    }
72
73    pub fn with_l1_bytes_max(mut self, l1_bytes_max: usize) -> Self {
74        self.l1_bytes_max = l1_bytes_max;
75        self
76    }
77
78    pub fn with_l2_bytes_max(mut self, l2_bytes_max: u64) -> Self {
79        self.l2_bytes_max = l2_bytes_max;
80        self
81    }
82
83    pub fn with_l2_path(mut self, path: impl Into<PathBuf>) -> Self {
84        self.l2_path = Some(path.into());
85        self
86    }
87
88    pub fn with_max_namespaces(mut self, max_namespaces: usize) -> Self {
89        self.max_namespaces = max_namespaces;
90        self
91    }
92
93    pub fn with_shard_count(mut self, shard_count: usize) -> Self {
94        self.shard_count = shard_count.max(1);
95        self
96    }
97
98    pub fn with_content_metadata_limits(mut self, keys_max: usize, bytes_max: usize) -> Self {
99        self.content_metadata_keys_max = keys_max;
100        self.content_metadata_bytes_max = bytes_max;
101        self
102    }
103
104    pub fn with_l2_compression(mut self, compression: L2Compression) -> Self {
105        self.l2_compression = compression;
106        self
107    }
108
109    pub fn l1_bytes_max(&self) -> usize {
110        self.l1_bytes_max
111    }
112
113    pub fn l2_bytes_max(&self) -> u64 {
114        self.l2_bytes_max
115    }
116
117    pub fn l2_path(&self) -> Option<&Path> {
118        self.l2_path.as_deref()
119    }
120
121    pub fn max_namespaces(&self) -> usize {
122        self.max_namespaces
123    }
124
125    pub fn shard_count(&self) -> usize {
126        self.shard_count
127    }
128
129    pub fn content_metadata_keys_max(&self) -> usize {
130        self.content_metadata_keys_max
131    }
132
133    pub fn content_metadata_bytes_max(&self) -> usize {
134        self.content_metadata_bytes_max
135    }
136
137    pub fn l2_compression(&self) -> L2Compression {
138        self.l2_compression
139    }
140}
141
142/// Builder for [`BlobCacheConfig`].
143///
144/// Created via [`BlobCacheConfig::builder`]. Each setter validates its
145/// argument; invalid configurations are rejected at [`build`](Self::build).
146#[derive(Debug, Clone)]
147pub struct BlobCacheConfigBuilder {
148    inner: BlobCacheConfig,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum BlobCacheConfigError {
153    /// `shard_count` must be at least 1.
154    ZeroShardCount,
155    /// `max_namespaces` must be at least 1.
156    ZeroMaxNamespaces,
157}
158
159impl BlobCacheConfigBuilder {
160    fn new() -> Self {
161        Self {
162            inner: BlobCacheConfig::default(),
163        }
164    }
165
166    pub fn l1_bytes_max(mut self, value: usize) -> Self {
167        self.inner.l1_bytes_max = value;
168        self
169    }
170
171    pub fn l2_bytes_max(mut self, value: u64) -> Self {
172        self.inner.l2_bytes_max = value;
173        self
174    }
175
176    pub fn l2_path(mut self, path: impl Into<PathBuf>) -> Self {
177        self.inner.l2_path = Some(path.into());
178        self
179    }
180
181    pub fn max_namespaces(mut self, value: usize) -> Self {
182        self.inner.max_namespaces = value;
183        self
184    }
185
186    pub fn shard_count(mut self, value: usize) -> Self {
187        self.inner.shard_count = value;
188        self
189    }
190
191    pub fn content_metadata_keys_max(mut self, value: usize) -> Self {
192        self.inner.content_metadata_keys_max = value;
193        self
194    }
195
196    pub fn content_metadata_bytes_max(mut self, value: usize) -> Self {
197        self.inner.content_metadata_bytes_max = value;
198        self
199    }
200
201    pub fn l2_compression(mut self, value: L2Compression) -> Self {
202        self.inner.l2_compression = value;
203        self
204    }
205
206    pub fn try_build(self) -> Result<BlobCacheConfig, BlobCacheConfigError> {
207        if self.inner.shard_count == 0 {
208            return Err(BlobCacheConfigError::ZeroShardCount);
209        }
210        if self.inner.max_namespaces == 0 {
211            return Err(BlobCacheConfigError::ZeroMaxNamespaces);
212        }
213        Ok(self.inner)
214    }
215
216    /// Convenience wrapper around [`try_build`](Self::try_build) that
217    /// panics on invalid input. Tests and bootstrap code should prefer this.
218    pub fn build(self) -> BlobCacheConfig {
219        self.try_build().expect("blob cache config")
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn blob_cache_config_builder_rejects_zero_shard_count() {
229        let err = BlobCacheConfig::builder()
230            .shard_count(0)
231            .try_build()
232            .expect_err("zero shard count must be rejected");
233        assert_eq!(err, BlobCacheConfigError::ZeroShardCount);
234    }
235
236    #[test]
237    fn blob_cache_config_builder_rejects_zero_max_namespaces() {
238        let err = BlobCacheConfig::builder()
239            .max_namespaces(0)
240            .try_build()
241            .expect_err("zero max_namespaces must be rejected");
242        assert_eq!(err, BlobCacheConfigError::ZeroMaxNamespaces);
243    }
244}