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 ⊂ Delta
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 + Raft.
42    Delta,
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 | `Delta` |
54    /// | `object_store_url` is `s3://`/`gs://`/`az://` | `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::Delta;
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            {
68                return Self::Durable;
69            }
70            if url.starts_with("file://") {
71                return Self::Embedded;
72            }
73        }
74        if config.storage_dir.is_some() {
75            return Self::Embedded;
76        }
77        Self::BareMetal
78    }
79
80    /// Validate that the compiled feature flags satisfy this profile's
81    /// requirements. Returns an error if a required feature was not
82    /// compiled in.
83    ///
84    /// # Errors
85    ///
86    /// Returns [`ProfileError::FeatureNotCompiled`] if a required Cargo
87    /// feature is missing.
88    pub fn validate_features(self) -> Result<(), ProfileError> {
89        // Feature gates for durable/delta were removed — all profiles are
90        // always available. Heavy distributed deps (tonic, openraft, chitchat)
91        // are gated on laminar-core's `delta` feature, which the server binary
92        // enables unconditionally. Library users of laminar-db get lightweight
93        // builds without distributed infrastructure.
94        match self {
95            Self::BareMetal | Self::Embedded | Self::Durable | Self::Delta => Ok(()),
96        }
97    }
98
99    /// Validate that the given configuration satisfies this profile's
100    /// runtime requirements (e.g., a storage directory for Embedded,
101    /// an object store URL for Durable).
102    ///
103    /// # Errors
104    ///
105    /// Returns [`ProfileError::RequirementNotMet`] if a required config
106    /// field is missing.
107    pub fn validate_config(
108        self,
109        config: &LaminarConfig,
110        object_store_url: Option<&str>,
111    ) -> Result<(), ProfileError> {
112        match self {
113            Self::BareMetal => Ok(()),
114            Self::Embedded => {
115                if config.storage_dir.is_none() {
116                    return Err(ProfileError::RequirementNotMet(
117                        "Embedded profile requires a storage_dir".into(),
118                    ));
119                }
120                Ok(())
121            }
122            Self::Durable | Self::Delta => {
123                if object_store_url.is_none() {
124                    return Err(ProfileError::RequirementNotMet(
125                        "Durable/Delta profile requires an \
126                         object_store_url"
127                            .into(),
128                    ));
129                }
130                Ok(())
131            }
132        }
133    }
134
135    /// Apply sensible defaults to a [`LaminarConfig`] for this profile.
136    ///
137    /// Does not override fields that the user has already set.
138    pub fn apply_defaults(self, config: &mut LaminarConfig) {
139        match self {
140            Self::BareMetal => {
141                // No persistence — nothing to configure.
142            }
143            Self::Embedded => {
144                // Ensure a reasonable buffer size for local workloads.
145                if config.default_buffer_size == LaminarConfig::default().default_buffer_size {
146                    config.default_buffer_size = 32_768;
147                }
148            }
149            Self::Durable => {
150                // Larger buffers for durable workloads.
151                if config.default_buffer_size == LaminarConfig::default().default_buffer_size {
152                    config.default_buffer_size = 131_072;
153                }
154            }
155            Self::Delta => {
156                // Largest buffers for distributed workloads.
157                if config.default_buffer_size == LaminarConfig::default().default_buffer_size {
158                    config.default_buffer_size = 262_144;
159                }
160            }
161        }
162    }
163}
164
165impl FromStr for Profile {
166    type Err = ProfileError;
167
168    fn from_str(s: &str) -> Result<Self, Self::Err> {
169        match s.to_ascii_lowercase().as_str() {
170            "bare_metal" | "baremetal" | "bare-metal" => Ok(Self::BareMetal),
171            "embedded" => Ok(Self::Embedded),
172            "durable" => Ok(Self::Durable),
173            "delta" => Ok(Self::Delta),
174            _ => Err(ProfileError::UnknownProfileName(s.into())),
175        }
176    }
177}
178
179impl fmt::Display for Profile {
180    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181        match self {
182            Self::BareMetal => write!(f, "bare_metal"),
183            Self::Embedded => write!(f, "embedded"),
184            Self::Durable => write!(f, "durable"),
185            Self::Delta => write!(f, "delta"),
186        }
187    }
188}
189
190/// Errors from profile validation.
191#[derive(Debug, thiserror::Error)]
192pub enum ProfileError {
193    /// A runtime requirement (e.g., config field) was not satisfied.
194    #[error("profile requirement not met: {0}")]
195    RequirementNotMet(String),
196
197    /// A required Cargo feature was not compiled in.
198    #[error("feature `{0}` not compiled — enable it in Cargo.toml")]
199    FeatureNotCompiled(String),
200
201    /// The profile name could not be parsed.
202    #[error("unknown profile name: {0}")]
203    UnknownProfileName(String),
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_bare_metal_zero_config() {
212        let config = LaminarConfig::default();
213        let profile = Profile::BareMetal;
214
215        // BareMetal needs no features and no config
216        assert!(profile.validate_features().is_ok());
217        assert!(profile.validate_config(&config, None).is_ok());
218    }
219
220    #[test]
221    fn test_embedded_requires_storage_dir() {
222        let config = LaminarConfig::default();
223        let result = Profile::Embedded.validate_config(&config, None);
224        assert!(result.is_err());
225        assert!(matches!(
226            result.unwrap_err(),
227            ProfileError::RequirementNotMet(_)
228        ));
229    }
230
231    #[test]
232    fn test_durable_fails_without_object_store_url() {
233        let config = LaminarConfig::default();
234        let result = Profile::Durable.validate_config(&config, None);
235        assert!(result.is_err());
236        assert!(matches!(
237            result.unwrap_err(),
238            ProfileError::RequirementNotMet(_)
239        ));
240    }
241
242    #[test]
243    fn test_profile_from_str() {
244        assert_eq!(Profile::from_str("bare_metal").unwrap(), Profile::BareMetal);
245        assert_eq!(Profile::from_str("baremetal").unwrap(), Profile::BareMetal);
246        assert_eq!(Profile::from_str("bare-metal").unwrap(), Profile::BareMetal);
247        assert_eq!(Profile::from_str("embedded").unwrap(), Profile::Embedded);
248        assert_eq!(Profile::from_str("durable").unwrap(), Profile::Durable);
249        assert_eq!(Profile::from_str("delta").unwrap(), Profile::Delta);
250        // Case insensitive
251        assert_eq!(Profile::from_str("DURABLE").unwrap(), Profile::Durable);
252        // Unknown name
253        assert!(Profile::from_str("quantum").is_err());
254        assert!(matches!(
255            Profile::from_str("quantum").unwrap_err(),
256            ProfileError::UnknownProfileName(_)
257        ));
258    }
259
260    #[test]
261    fn test_all_profiles_validate_features() {
262        // Feature gates removed — all profiles always pass validation.
263        assert!(Profile::BareMetal.validate_features().is_ok());
264        assert!(Profile::Embedded.validate_features().is_ok());
265        assert!(Profile::Durable.validate_features().is_ok());
266        assert!(Profile::Delta.validate_features().is_ok());
267    }
268
269    #[test]
270    fn test_profile_display() {
271        assert_eq!(Profile::BareMetal.to_string(), "bare_metal");
272        assert_eq!(Profile::Embedded.to_string(), "embedded");
273        assert_eq!(Profile::Durable.to_string(), "durable");
274        assert_eq!(Profile::Delta.to_string(), "delta");
275    }
276
277    #[test]
278    fn test_profile_default() {
279        assert_eq!(Profile::default(), Profile::BareMetal);
280    }
281
282    #[test]
283    fn test_apply_defaults_bare_metal_noop() {
284        let mut config = LaminarConfig::default();
285        let original_buffer = config.default_buffer_size;
286        Profile::BareMetal.apply_defaults(&mut config);
287        assert_eq!(config.default_buffer_size, original_buffer);
288    }
289
290    #[test]
291    fn test_apply_defaults_does_not_override_user_values() {
292        let mut config = LaminarConfig {
293            default_buffer_size: 999,
294            ..LaminarConfig::default()
295        };
296        Profile::Durable.apply_defaults(&mut config);
297        // User explicitly set 999 — should not be overridden
298        assert_eq!(config.default_buffer_size, 999);
299    }
300
301    #[test]
302    fn test_from_config_bare_metal() {
303        let config = LaminarConfig::default();
304        assert_eq!(Profile::from_config(&config, false), Profile::BareMetal);
305    }
306
307    #[test]
308    fn test_from_config_embedded_storage_dir() {
309        let config = LaminarConfig {
310            storage_dir: Some(std::path::PathBuf::from("/tmp/data")),
311            ..LaminarConfig::default()
312        };
313        assert_eq!(Profile::from_config(&config, false), Profile::Embedded);
314    }
315
316    #[test]
317    fn test_from_config_embedded_file_url() {
318        let config = LaminarConfig {
319            object_store_url: Some("file:///tmp/checkpoints".to_string()),
320            ..LaminarConfig::default()
321        };
322        assert_eq!(Profile::from_config(&config, false), Profile::Embedded);
323    }
324
325    #[test]
326    fn test_from_config_durable_s3() {
327        let config = LaminarConfig {
328            object_store_url: Some("s3://my-bucket/prefix".to_string()),
329            ..LaminarConfig::default()
330        };
331        assert_eq!(Profile::from_config(&config, false), Profile::Durable);
332    }
333
334    #[test]
335    fn test_from_config_durable_gs() {
336        let config = LaminarConfig {
337            object_store_url: Some("gs://my-bucket/prefix".to_string()),
338            ..LaminarConfig::default()
339        };
340        assert_eq!(Profile::from_config(&config, false), Profile::Durable);
341    }
342
343    #[test]
344    fn test_from_config_durable_az() {
345        let config = LaminarConfig {
346            object_store_url: Some("az://container/prefix".to_string()),
347            ..LaminarConfig::default()
348        };
349        assert_eq!(Profile::from_config(&config, false), Profile::Durable);
350    }
351
352    #[test]
353    fn test_from_config_durable_abfs() {
354        let config = LaminarConfig {
355            object_store_url: Some("abfs://container/prefix".to_string()),
356            ..LaminarConfig::default()
357        };
358        assert_eq!(Profile::from_config(&config, false), Profile::Durable);
359    }
360
361    #[test]
362    fn test_from_config_delta() {
363        let config = LaminarConfig::default();
364        assert_eq!(Profile::from_config(&config, true), Profile::Delta);
365    }
366
367    #[test]
368    fn test_from_config_delta_overrides_url() {
369        let config = LaminarConfig {
370            object_store_url: Some("s3://bucket/prefix".to_string()),
371            ..LaminarConfig::default()
372        };
373        // Discovery takes priority over URL-based detection
374        assert_eq!(Profile::from_config(&config, true), Profile::Delta);
375    }
376}