Skip to main content

storage_kit/state_stores/config/
redb.rs

1use std::{
2    ffi::OsStr,
3    fmt,
4    path::{Path, PathBuf},
5    time::Duration,
6};
7
8use serde::{
9    Deserialize, Deserializer, Serialize, Serializer, de,
10    ser::{SerializeMap, SerializeStruct},
11};
12
13use crate::StateStoreError;
14
15const DEFAULT_OPERATION_TIMEOUT_MS: u64 = 5_000;
16const DEFAULT_CACHE_SIZE_BYTES: usize = 64 * 1024 * 1024;
17
18/// Local Redb StateStore location.
19///
20/// Redb is an opt-in embedded StateStore backend. Memory locations
21/// are process-local. File locations persist embedded control-plane state to a
22/// caller-selected local path.
23///
24/// # Examples
25///
26/// ```rust
27/// let memory = storage_kit::state::config::RedbStateStoreLocation::Memory;
28/// let file = storage_kit::state::config::RedbStateStoreLocation::File(
29///     std::env::temp_dir().join("storage-kit-state.redb"),
30/// );
31/// let _ = (memory, file);
32/// ```
33pub enum RedbStateStoreLocation {
34    /// Uses a process-local Redb in-memory backend.
35    Memory,
36    /// Uses a caller-selected local Redb database file.
37    File(PathBuf),
38}
39
40impl RedbStateStoreLocation {
41    /// Returns true for an in-memory Redb location.
42    ///
43    /// # Examples
44    ///
45    /// ```rust
46    /// let location = storage_kit::state::config::RedbStateStoreLocation::Memory;
47    /// if !location.is_memory() {
48    ///     return Err(storage_kit::StateStoreError::InvalidRequest(
49    ///         "expected memory location".to_owned(),
50    ///     ));
51    /// }
52    /// # Ok::<(), storage_kit::StateStoreError>(())
53    /// ```
54    #[must_use]
55    pub fn is_memory(&self) -> bool {
56        match self {
57            Self::Memory => true,
58            Self::File(_) => false,
59        }
60    }
61
62    pub(crate) fn validate(&self) -> Result<(), StateStoreError> {
63        match self {
64            Self::Memory => Ok(()),
65            Self::File(path) => RedbStateStorePath::new(path).validate(),
66        }
67    }
68
69    fn from_json_value(value: &serde_json::Value) -> Result<Self, String> {
70        if value.as_str() == Some("Memory") {
71            return Ok(Self::Memory);
72        }
73        let object = value.as_object().ok_or_else(|| {
74            "RedbStateStoreLocation must be `Memory` or `{ File: ... }`".to_owned()
75        })?;
76        if object.len() != 1 {
77            return Err(
78                "RedbStateStoreLocation object must contain exactly one variant".to_owned(),
79            );
80        }
81        let file = object
82            .get("File")
83            .ok_or_else(|| "RedbStateStoreLocation object must contain `File`".to_owned())?;
84        let path = file
85            .as_str()
86            .ok_or_else(|| "RedbStateStoreLocation.File must be a string".to_owned())?;
87        Ok(Self::File(PathBuf::from(path)))
88    }
89}
90
91impl Clone for RedbStateStoreLocation {
92    fn clone(&self) -> Self {
93        match self {
94            Self::Memory => Self::Memory,
95            Self::File(path) => Self::File(path.clone()),
96        }
97    }
98}
99
100impl fmt::Debug for RedbStateStoreLocation {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        match self {
103            Self::Memory => f.write_str("Memory"),
104            Self::File(path) => f.debug_tuple("File").field(path).finish(),
105        }
106    }
107}
108
109impl PartialEq for RedbStateStoreLocation {
110    fn eq(&self, other: &Self) -> bool {
111        match (self, other) {
112            (Self::Memory, Self::Memory) => true,
113            (Self::File(left), Self::File(right)) => left == right,
114            _ => false,
115        }
116    }
117}
118
119impl Eq for RedbStateStoreLocation {}
120
121impl Serialize for RedbStateStoreLocation {
122    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
123    where
124        S: Serializer,
125    {
126        match self {
127            Self::Memory => serializer.serialize_str("Memory"),
128            Self::File(path) => {
129                let value = path.to_str().ok_or_else(|| {
130                    serde::ser::Error::custom(
131                        "RedbStateStoreLocation.File must be valid UTF-8 to serialize as JSON",
132                    )
133                })?;
134                let mut map = serializer.serialize_map(Some(1))?;
135                map.serialize_entry("File", value)?;
136                map.end()
137            }
138        }
139    }
140}
141
142impl<'de> Deserialize<'de> for RedbStateStoreLocation {
143    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
144    where
145        D: Deserializer<'de>,
146    {
147        let value = serde_json::Value::deserialize(deserializer)?;
148        Self::from_json_value(&value).map_err(de::Error::custom)
149    }
150}
151
152/// Redb embedded StateStore configuration.
153///
154/// This always-available config selects the Redb StateStore backend, which
155/// requires the `redb` feature. Use
156/// [`Self::memory`] for process-local embedded state and
157/// [`Self::file`] for persisted embedded state.
158///
159/// # Examples
160///
161/// ```rust
162/// let config = storage_kit::state::config::RedbStateStoreConfig::memory();
163/// let state_config = storage_kit::state::config::StateStoreConfig::redb(config);
164/// let _ = state_config;
165/// ```
166pub struct RedbStateStoreConfig {
167    location: RedbStateStoreLocation,
168    operation_timeout_ms: u64,
169    cache_size_bytes: usize,
170}
171
172impl RedbStateStoreConfig {
173    /// Builds an in-memory Redb StateStore config.
174    ///
175    /// # Examples
176    ///
177    /// ```rust
178    /// let config = storage_kit::state::config::RedbStateStoreConfig::memory();
179    /// if !config.location().is_memory() {
180    ///     return Err(storage_kit::StateStoreError::InvalidRequest(
181    ///         "expected memory location".to_owned(),
182    ///     ));
183    /// }
184    /// # Ok::<(), storage_kit::StateStoreError>(())
185    /// ```
186    #[must_use]
187    pub fn memory() -> Self {
188        Self {
189            location: RedbStateStoreLocation::Memory,
190            operation_timeout_ms: DEFAULT_OPERATION_TIMEOUT_MS,
191            cache_size_bytes: DEFAULT_CACHE_SIZE_BYTES,
192        }
193    }
194
195    /// Builds a file-backed Redb StateStore config.
196    ///
197    /// # Examples
198    ///
199    /// ```rust
200    /// let path = std::env::temp_dir().join("storage-kit-state.redb");
201    /// let config = storage_kit::state::config::RedbStateStoreConfig::file(path);
202    /// let _ = config;
203    /// ```
204    #[must_use]
205    pub fn file(path: impl Into<PathBuf>) -> Self {
206        Self {
207            location: RedbStateStoreLocation::File(path.into()),
208            operation_timeout_ms: DEFAULT_OPERATION_TIMEOUT_MS,
209            cache_size_bytes: DEFAULT_CACHE_SIZE_BYTES,
210        }
211    }
212
213    /// Replaces the operation timeout.
214    ///
215    /// # Errors
216    ///
217    /// Returns an invalid-config error when the timeout is zero or cannot be
218    /// represented as milliseconds.
219    ///
220    /// # Examples
221    ///
222    /// ```rust
223    /// let config = storage_kit::state::config::RedbStateStoreConfig::memory()
224    ///     .with_operation_timeout(std::time::Duration::from_secs(2))?;
225    /// let _ = config;
226    /// # Ok::<(), storage_kit::StateStoreError>(())
227    /// ```
228    pub fn with_operation_timeout(mut self, timeout: Duration) -> Result<Self, StateStoreError> {
229        let millis = u64::try_from(timeout.as_millis()).map_err(|err| {
230            StateStoreError::invalid_config({
231                let mut message = String::from("Redb state store operation_timeout overflow: ");
232                message.push_str(&err.to_string());
233                message
234            })
235        })?;
236        if millis == 0 {
237            return Err(StateStoreError::invalid_config(
238                "Redb state store operation_timeout must be greater than zero",
239            ));
240        }
241        self.operation_timeout_ms = millis;
242        Ok(self)
243    }
244
245    /// Replaces the Redb cache size in bytes.
246    ///
247    /// # Examples
248    ///
249    /// ```rust
250    /// let config = storage_kit::state::config::RedbStateStoreConfig::memory()
251    ///     .with_cache_size_bytes(64 * 1024 * 1024);
252    /// let _ = config;
253    /// ```
254    #[must_use]
255    pub fn with_cache_size_bytes(mut self, bytes: usize) -> Self {
256        self.cache_size_bytes = bytes;
257        self
258    }
259
260    /// Returns the configured operation timeout.
261    ///
262    /// # Examples
263    ///
264    /// ```rust
265    /// let timeout = storage_kit::state::config::RedbStateStoreConfig::memory().operation_timeout();
266    /// let _ = timeout;
267    /// ```
268    #[must_use]
269    pub fn operation_timeout(&self) -> Duration {
270        Duration::from_millis(self.operation_timeout_ms)
271    }
272
273    /// Returns the configured Redb cache size in bytes.
274    ///
275    /// # Examples
276    ///
277    /// ```rust
278    /// let bytes = storage_kit::state::config::RedbStateStoreConfig::memory().cache_size_bytes();
279    /// let _ = bytes;
280    /// ```
281    #[must_use]
282    pub fn cache_size_bytes(&self) -> usize {
283        self.cache_size_bytes
284    }
285
286    /// Returns the configured Redb location.
287    ///
288    /// # Examples
289    ///
290    /// ```rust
291    /// let config = storage_kit::state::config::RedbStateStoreConfig::memory();
292    /// let location = config.location();
293    /// let _ = location;
294    /// ```
295    #[must_use]
296    pub fn location(&self) -> &RedbStateStoreLocation {
297        &self.location
298    }
299
300    /// Validates Redb location, timeout, and cache settings.
301    ///
302    /// # Errors
303    ///
304    /// Returns an invalid-config error when a numeric setting is zero or a
305    /// file path is not valid for Redb construction.
306    ///
307    /// # Examples
308    ///
309    /// ```rust
310    /// storage_kit::state::config::RedbStateStoreConfig::memory().validate()?;
311    /// # Ok::<(), storage_kit::StateStoreError>(())
312    /// ```
313    pub fn validate(&self) -> Result<(), StateStoreError> {
314        if self.operation_timeout_ms == 0 {
315            return Err(StateStoreError::invalid_config(
316                "Redb state store operation_timeout must be greater than zero",
317            ));
318        }
319        if self.cache_size_bytes == 0 {
320            return Err(StateStoreError::invalid_config(
321                "Redb state store cache_size_bytes must be greater than zero",
322            ));
323        }
324        self.location.validate()
325    }
326
327    #[cfg(feature = "redb")]
328    pub(crate) fn operation_timeout_ms(&self) -> u64 {
329        self.operation_timeout_ms
330    }
331
332    pub(crate) fn from_json_value(value: &serde_json::Value) -> Result<Self, String> {
333        let object = value
334            .as_object()
335            .ok_or_else(|| "RedbStateStoreConfig must be an object".to_owned())?;
336        let location = object
337            .get("location")
338            .ok_or_else(|| "RedbStateStoreConfig.location is required".to_owned())
339            .and_then(RedbStateStoreLocation::from_json_value)?;
340        let operation_timeout_ms = object
341            .get("operation_timeout_ms")
342            .ok_or_else(|| "RedbStateStoreConfig.operation_timeout_ms is required".to_owned())?
343            .as_u64()
344            .ok_or_else(|| "RedbStateStoreConfig.operation_timeout_ms must be a u64".to_owned())?;
345        let cache_size_u64 = object
346            .get("cache_size_bytes")
347            .ok_or_else(|| "RedbStateStoreConfig.cache_size_bytes is required".to_owned())?
348            .as_u64()
349            .ok_or_else(|| "RedbStateStoreConfig.cache_size_bytes must be a u64".to_owned())?;
350        let cache_size_bytes = usize::try_from(cache_size_u64).map_err(|err| {
351            let mut message = String::from("RedbStateStoreConfig.cache_size_bytes overflow: ");
352            message.push_str(&err.to_string());
353            message
354        })?;
355        Ok(Self {
356            location,
357            operation_timeout_ms,
358            cache_size_bytes,
359        })
360    }
361}
362
363impl Clone for RedbStateStoreConfig {
364    fn clone(&self) -> Self {
365        Self {
366            location: self.location.clone(),
367            operation_timeout_ms: self.operation_timeout_ms,
368            cache_size_bytes: self.cache_size_bytes,
369        }
370    }
371}
372
373impl fmt::Debug for RedbStateStoreConfig {
374    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
375        f.debug_struct("RedbStateStoreConfig")
376            .field("location", &self.location)
377            .field("operation_timeout_ms", &self.operation_timeout_ms)
378            .field("cache_size_bytes", &self.cache_size_bytes)
379            .finish()
380    }
381}
382
383impl PartialEq for RedbStateStoreConfig {
384    fn eq(&self, other: &Self) -> bool {
385        self.location == other.location
386            && self.operation_timeout_ms == other.operation_timeout_ms
387            && self.cache_size_bytes == other.cache_size_bytes
388    }
389}
390
391impl Eq for RedbStateStoreConfig {}
392
393impl Serialize for RedbStateStoreConfig {
394    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
395    where
396        S: Serializer,
397    {
398        let mut state = serializer.serialize_struct("RedbStateStoreConfig", 3)?;
399        state.serialize_field("location", &self.location)?;
400        state.serialize_field("operation_timeout_ms", &self.operation_timeout_ms)?;
401        state.serialize_field("cache_size_bytes", &self.cache_size_bytes)?;
402        state.end()
403    }
404}
405
406impl<'de> Deserialize<'de> for RedbStateStoreConfig {
407    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
408    where
409        D: Deserializer<'de>,
410    {
411        let value = serde_json::Value::deserialize(deserializer)?;
412        Self::from_json_value(&value).map_err(de::Error::custom)
413    }
414}
415
416struct RedbStateStorePath<'a> {
417    path: &'a Path,
418}
419
420impl<'a> RedbStateStorePath<'a> {
421    fn new(path: &'a Path) -> Self {
422        Self { path }
423    }
424
425    fn validate(&self) -> Result<(), StateStoreError> {
426        if self.path.as_os_str().is_empty() {
427            return Err(StateStoreError::invalid_config(
428                "Redb state store file path must not be empty",
429            ));
430        }
431        if self.contains_nul(self.path.as_os_str()) {
432            return Err(StateStoreError::invalid_config(
433                "Redb state store file path must not contain NUL",
434            ));
435        }
436        Ok(())
437    }
438
439    fn contains_nul(&self, value: &OsStr) -> bool {
440        value.as_encoded_bytes().contains(&0)
441    }
442}