Skip to main content

laminar_db/
profile.rs

1//! Deployment profiles for `LaminarDB`.
2//!
3//! A [`Profile`] determines which subsystems are activated at startup.
4//! Profiles form a hierarchy: each tier includes all capabilities of
5//! the tiers below it.
6//!
7//! ```text
8//! BareMetal ⊂ Embedded ⊂ Durable ⊂ Cluster
9//! ```
10//!
11//! ## Usage
12//!
13//! ```rust,ignore
14//! use laminar_db::{LaminarDB, Profile};
15//!
16//! let db = LaminarDB::builder()
17//!     .profile(Profile::Durable)
18//!     .object_store_url("s3://my-bucket/checkpoints")
19//!     .build()
20//!     .await?;
21//! ```
22
23use std::fmt;
24use std::str::FromStr;
25
26use crate::config::LaminarConfig;
27
28/// Deployment profile — determines which subsystems are activated.
29///
30/// Profiles are ordered by capability: each tier includes everything
31/// from the tiers below it.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum Profile {
34    /// In-memory only, no persistence. Fastest startup.
35    #[default]
36    BareMetal,
37    /// Local WAL persistence (embedded single-node).
38    Embedded,
39    /// Object-store checkpoints + rkyv snapshots.
40    Durable,
41    /// Full distributed: Durable + gRPC + gossip + cluster primitives.
42    Cluster,
43}
44
45impl Profile {
46    /// Auto-detect the appropriate profile from configuration.
47    ///
48    /// Uses orthogonal signals (checkpoint URL scheme, presence of
49    /// discovery config) rather than requiring an explicit profile choice.
50    ///
51    /// | Signal | Detected Profile |
52    /// |--------|-----------------|
53    /// | `has_discovery` = true | `Cluster` |
54    /// | `object_store_url` uses a supported cloud scheme | `Durable` |
55    /// | `object_store_url` is `file://` or `storage_dir` set | `Embedded` |
56    /// | None of the above | `BareMetal` |
57    #[must_use]
58    pub fn from_config(config: &LaminarConfig, has_discovery: bool) -> Self {
59        if has_discovery {
60            return Self::Cluster;
61        }
62        if let Some(url) = &config.object_store_url {
63            if url.starts_with("s3://")
64                || url.starts_with("gs://")
65                || url.starts_with("az://")
66                || url.starts_with("abfs://")
67                || url.starts_with("abfss://")
68            {
69                return Self::Durable;
70            }
71            if url.starts_with("file://") {
72                return Self::Embedded;
73            }
74        }
75        if config.storage_dir.is_some()
76            || config
77                .checkpoint
78                .as_ref()
79                .and_then(|checkpoint| checkpoint.data_dir.as_ref())
80                .is_some()
81        {
82            return Self::Embedded;
83        }
84        Self::BareMetal
85    }
86
87    /// Validate that the compiled feature flags satisfy this profile's
88    /// requirements. Returns an error if a required feature was not
89    /// compiled in.
90    ///
91    /// # Errors
92    ///
93    /// Returns [`ProfileError::FeatureNotCompiled`] if a required Cargo
94    /// feature is missing.
95    pub fn validate_features(self) -> Result<(), ProfileError> {
96        // Feature gates for durable/cluster were removed — all profiles are
97        // always available. Heavy distributed deps (tonic, chitchat)
98        // are gated on laminar-core's `cluster` feature, which the
99        // server binary enables unconditionally. Library users of laminar-db
100        // get lightweight builds without distributed infrastructure.
101        match self {
102            Self::BareMetal | Self::Embedded | Self::Durable | Self::Cluster => Ok(()),
103        }
104    }
105
106    /// Validate that the given configuration satisfies this profile's
107    /// runtime requirements (e.g., a storage directory for Embedded,
108    /// an object store URL for Durable).
109    ///
110    /// # Errors
111    ///
112    /// Returns [`ProfileError::RequirementNotMet`] if a required config
113    /// field is missing.
114    pub fn validate_config(
115        self,
116        config: &LaminarConfig,
117        object_store_url: Option<&str>,
118    ) -> Result<(), ProfileError> {
119        match self {
120            Self::BareMetal => Ok(()),
121            Self::Embedded => {
122                if let Some(url) = object_store_url.filter(|url| url.starts_with("file://")) {
123                    laminar_core::storage::object_store_builder::file_url_path(url)
124                        .map_err(|error| ProfileError::RequirementNotMet(error.to_string()))?;
125                    return Ok(());
126                }
127                if config.storage_dir.is_some()
128                    || config
129                        .checkpoint
130                        .as_ref()
131                        .and_then(|checkpoint| checkpoint.data_dir.as_ref())
132                        .is_some()
133                {
134                    return Ok(());
135                }
136                Err(ProfileError::RequirementNotMet(
137                    "Embedded profile requires an absolute file:// checkpoint URL or local storage directory"
138                        .into(),
139                ))
140            }
141            Self::Durable | Self::Cluster => {
142                if object_store_url.is_none() {
143                    return Err(ProfileError::RequirementNotMet(
144                        "Durable/Cluster profile requires an \
145                         object_store_url"
146                            .into(),
147                    ));
148                }
149                Ok(())
150            }
151        }
152    }
153
154    /// Apply sensible defaults to a [`LaminarConfig`] for this profile.
155    ///
156    /// Does not override fields that the user has already set.
157    pub fn apply_defaults(self, config: &mut LaminarConfig) {
158        match self {
159            Self::BareMetal => {
160                // No persistence — nothing to configure.
161            }
162            Self::Embedded => {
163                // Ensure a reasonable buffer size for local workloads.
164                if config.default_buffer_size == LaminarConfig::default().default_buffer_size {
165                    config.default_buffer_size = 32_768;
166                }
167            }
168            Self::Durable => {
169                // Larger buffers for durable workloads.
170                if config.default_buffer_size == LaminarConfig::default().default_buffer_size {
171                    config.default_buffer_size = 131_072;
172                }
173            }
174            Self::Cluster => {
175                // Largest buffers for distributed workloads.
176                if config.default_buffer_size == LaminarConfig::default().default_buffer_size {
177                    config.default_buffer_size = 262_144;
178                }
179            }
180        }
181    }
182}
183
184impl FromStr for Profile {
185    type Err = ProfileError;
186
187    fn from_str(s: &str) -> Result<Self, Self::Err> {
188        match s.to_ascii_lowercase().as_str() {
189            "bare_metal" | "baremetal" | "bare-metal" => Ok(Self::BareMetal),
190            "embedded" => Ok(Self::Embedded),
191            "durable" => Ok(Self::Durable),
192            "cluster" => Ok(Self::Cluster),
193            _ => Err(ProfileError::UnknownProfileName(s.into())),
194        }
195    }
196}
197
198impl fmt::Display for Profile {
199    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200        match self {
201            Self::BareMetal => write!(f, "bare_metal"),
202            Self::Embedded => write!(f, "embedded"),
203            Self::Durable => write!(f, "durable"),
204            Self::Cluster => write!(f, "cluster"),
205        }
206    }
207}
208
209/// Errors from profile validation.
210#[derive(Debug, thiserror::Error)]
211pub enum ProfileError {
212    /// A runtime requirement (e.g., config field) was not satisfied.
213    #[error("profile requirement not met: {0}")]
214    RequirementNotMet(String),
215
216    /// A required Cargo feature was not compiled in.
217    #[error("feature `{0}` not compiled — enable it in Cargo.toml")]
218    FeatureNotCompiled(String),
219
220    /// The profile name could not be parsed.
221    #[error("unknown profile name: {0}")]
222    UnknownProfileName(String),
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_bare_metal_zero_config() {
231        let config = LaminarConfig::default();
232        let profile = Profile::BareMetal;
233
234        // BareMetal needs no features and no config
235        assert!(profile.validate_features().is_ok());
236        assert!(profile.validate_config(&config, None).is_ok());
237    }
238
239    #[test]
240    fn test_embedded_requires_storage_dir() {
241        let config = LaminarConfig::default();
242        let result = Profile::Embedded.validate_config(&config, None);
243        assert!(result.is_err());
244        assert!(matches!(
245            result.unwrap_err(),
246            ProfileError::RequirementNotMet(_)
247        ));
248    }
249
250    #[test]
251    fn embedded_accepts_an_absolute_file_checkpoint_url() {
252        let config = LaminarConfig::default();
253        let directory = tempfile::tempdir().unwrap();
254        let normalized = directory.path().display().to_string().replace('\\', "/");
255        let url = if normalized.starts_with('/') {
256            format!("file://{normalized}")
257        } else {
258            format!("file:///{normalized}")
259        };
260        assert!(Profile::Embedded
261            .validate_config(&config, Some(&url))
262            .is_ok());
263        assert!(Profile::Embedded
264            .validate_config(&config, Some("file://./relative"))
265            .is_err());
266        let config_with_fallback = LaminarConfig {
267            storage_dir: Some(directory.path().to_path_buf()),
268            ..LaminarConfig::default()
269        };
270        assert!(Profile::Embedded
271            .validate_config(&config_with_fallback, Some("file://./relative"))
272            .is_err());
273    }
274
275    #[test]
276    fn test_durable_fails_without_object_store_url() {
277        let config = LaminarConfig::default();
278        let result = Profile::Durable.validate_config(&config, None);
279        assert!(result.is_err());
280        assert!(matches!(
281            result.unwrap_err(),
282            ProfileError::RequirementNotMet(_)
283        ));
284    }
285
286    #[test]
287    fn test_profile_from_str() {
288        assert_eq!(Profile::from_str("bare_metal").unwrap(), Profile::BareMetal);
289        assert_eq!(Profile::from_str("baremetal").unwrap(), Profile::BareMetal);
290        assert_eq!(Profile::from_str("bare-metal").unwrap(), Profile::BareMetal);
291        assert_eq!(Profile::from_str("embedded").unwrap(), Profile::Embedded);
292        assert_eq!(Profile::from_str("durable").unwrap(), Profile::Durable);
293        assert_eq!(Profile::from_str("cluster").unwrap(), Profile::Cluster);
294        // Case insensitive
295        assert_eq!(Profile::from_str("DURABLE").unwrap(), Profile::Durable);
296        // Unknown name
297        assert!(Profile::from_str("quantum").is_err());
298        assert!(matches!(
299            Profile::from_str("quantum").unwrap_err(),
300            ProfileError::UnknownProfileName(_)
301        ));
302    }
303
304    #[test]
305    fn test_all_profiles_validate_features() {
306        // Feature gates removed — all profiles always pass validation.
307        assert!(Profile::BareMetal.validate_features().is_ok());
308        assert!(Profile::Embedded.validate_features().is_ok());
309        assert!(Profile::Durable.validate_features().is_ok());
310        assert!(Profile::Cluster.validate_features().is_ok());
311    }
312
313    #[test]
314    fn test_profile_display() {
315        assert_eq!(Profile::BareMetal.to_string(), "bare_metal");
316        assert_eq!(Profile::Embedded.to_string(), "embedded");
317        assert_eq!(Profile::Durable.to_string(), "durable");
318        assert_eq!(Profile::Cluster.to_string(), "cluster");
319    }
320
321    #[test]
322    fn test_profile_default() {
323        assert_eq!(Profile::default(), Profile::BareMetal);
324    }
325
326    #[test]
327    fn test_apply_defaults_bare_metal_noop() {
328        let mut config = LaminarConfig::default();
329        let original_buffer = config.default_buffer_size;
330        Profile::BareMetal.apply_defaults(&mut config);
331        assert_eq!(config.default_buffer_size, original_buffer);
332    }
333
334    #[test]
335    fn test_apply_defaults_does_not_override_user_values() {
336        let mut config = LaminarConfig {
337            default_buffer_size: 999,
338            ..LaminarConfig::default()
339        };
340        Profile::Durable.apply_defaults(&mut config);
341        // User explicitly set 999 — should not be overridden
342        assert_eq!(config.default_buffer_size, 999);
343    }
344
345    #[test]
346    fn test_from_config_bare_metal() {
347        let config = LaminarConfig::default();
348        assert_eq!(Profile::from_config(&config, false), Profile::BareMetal);
349    }
350
351    #[test]
352    fn test_from_config_embedded_storage_dir() {
353        let config = LaminarConfig {
354            storage_dir: Some(std::path::PathBuf::from("/tmp/data")),
355            ..LaminarConfig::default()
356        };
357        assert_eq!(Profile::from_config(&config, false), Profile::Embedded);
358    }
359
360    #[test]
361    fn test_from_config_embedded_file_url() {
362        let config = LaminarConfig {
363            object_store_url: Some("file:///tmp/checkpoints".to_string()),
364            ..LaminarConfig::default()
365        };
366        assert_eq!(Profile::from_config(&config, false), Profile::Embedded);
367    }
368
369    #[test]
370    fn test_from_config_durable_s3() {
371        let config = LaminarConfig {
372            object_store_url: Some("s3://my-bucket/prefix".to_string()),
373            ..LaminarConfig::default()
374        };
375        assert_eq!(Profile::from_config(&config, false), Profile::Durable);
376    }
377
378    #[test]
379    fn test_from_config_durable_gs() {
380        let config = LaminarConfig {
381            object_store_url: Some("gs://my-bucket/prefix".to_string()),
382            ..LaminarConfig::default()
383        };
384        assert_eq!(Profile::from_config(&config, false), Profile::Durable);
385    }
386
387    #[test]
388    fn test_from_config_durable_az() {
389        let config = LaminarConfig {
390            object_store_url: Some("az://container/prefix".to_string()),
391            ..LaminarConfig::default()
392        };
393        assert_eq!(Profile::from_config(&config, false), Profile::Durable);
394    }
395
396    #[test]
397    fn test_from_config_durable_abfs() {
398        let config = LaminarConfig {
399            object_store_url: Some("abfs://container/prefix".to_string()),
400            ..LaminarConfig::default()
401        };
402        assert_eq!(Profile::from_config(&config, false), Profile::Durable);
403    }
404
405    #[test]
406    fn test_from_config_durable_abfss() {
407        let config = LaminarConfig {
408            object_store_url: Some("abfss://container/prefix".to_string()),
409            ..LaminarConfig::default()
410        };
411        assert_eq!(Profile::from_config(&config, false), Profile::Durable);
412    }
413
414    #[test]
415    fn test_from_config_cluster() {
416        let config = LaminarConfig::default();
417        assert_eq!(Profile::from_config(&config, true), Profile::Cluster);
418    }
419
420    #[test]
421    fn test_from_config_cluster_overrides_url() {
422        let config = LaminarConfig {
423            object_store_url: Some("s3://bucket/prefix".to_string()),
424            ..LaminarConfig::default()
425        };
426        // Discovery takes priority over URL-based detection
427        assert_eq!(Profile::from_config(&config, true), Profile::Cluster);
428    }
429}