Skip to main content

laminar_db/profile/
mod.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 laminar_core::storage_location::{StorageLocation, StorageProvider};
27
28use crate::config::LaminarConfig;
29
30/// Deployment profile — determines which subsystems are activated.
31///
32/// Profiles are ordered by capability: each tier includes everything
33/// from the tiers below it.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum Profile {
36    /// In-memory only, no persistence. Fastest startup.
37    #[default]
38    BareMetal,
39    /// Local WAL persistence (embedded single-node).
40    Embedded,
41    /// Object-store checkpoints + rkyv snapshots.
42    Durable,
43    /// Full distributed: Durable + gRPC + gossip + cluster primitives.
44    Cluster,
45}
46
47impl Profile {
48    /// Auto-detect the appropriate profile from configuration.
49    ///
50    /// Uses orthogonal signals (checkpoint URL scheme, presence of
51    /// discovery config) rather than requiring an explicit profile choice.
52    ///
53    /// | Signal | Detected Profile |
54    /// |--------|-----------------|
55    /// | `has_discovery` = true | `Cluster` |
56    /// | `object_store_url` uses a supported cloud scheme | `Durable` |
57    /// | `object_store_url` is `file://` or `storage_dir` set | `Embedded` |
58    /// | None of the above | `BareMetal` |
59    #[must_use]
60    pub fn from_config(config: &LaminarConfig, has_discovery: bool) -> Self {
61        if has_discovery {
62            return Self::Cluster;
63        }
64        if let Some(url) = &config.object_store_url {
65            if let Ok(location) = StorageLocation::parse(url) {
66                return if location.provider == StorageProvider::Local {
67                    Self::Embedded
68                } else {
69                    Self::Durable
70                };
71            }
72        }
73        if config.storage_dir.is_some()
74            || config
75                .checkpoint
76                .as_ref()
77                .and_then(|checkpoint| checkpoint.data_dir.as_ref())
78                .is_some()
79        {
80            return Self::Embedded;
81        }
82        Self::BareMetal
83    }
84
85    /// Validate that the compiled feature flags satisfy this profile's
86    /// requirements. Returns an error if a required feature was not
87    /// compiled in.
88    ///
89    /// # Errors
90    ///
91    /// Returns [`ProfileError::FeatureNotCompiled`] if a required Cargo
92    /// feature is missing.
93    pub fn validate_features(self) -> Result<(), ProfileError> {
94        // Feature gates for durable/cluster were removed — all profiles are
95        // always available. Heavy distributed deps (tonic, chitchat)
96        // are gated on laminar-core's `cluster` feature, which the
97        // server binary enables unconditionally. Library users of laminar-db
98        // get lightweight builds without distributed infrastructure.
99        match self {
100            Self::BareMetal | Self::Embedded | Self::Durable | Self::Cluster => Ok(()),
101        }
102    }
103
104    /// Validate that the given configuration satisfies this profile's
105    /// runtime requirements (e.g., a storage directory for Embedded,
106    /// an object store URL for Durable).
107    ///
108    /// # Errors
109    ///
110    /// Returns [`ProfileError::RequirementNotMet`] if a required config
111    /// field is missing.
112    pub fn validate_config(
113        self,
114        config: &LaminarConfig,
115        object_store_url: Option<&str>,
116    ) -> Result<(), ProfileError> {
117        match self {
118            Self::BareMetal => Ok(()),
119            Self::Embedded => {
120                if let Some(url) = object_store_url.filter(|url| {
121                    laminar_core::storage_location::StorageProvider::detect_uri(url)
122                        == Some(laminar_core::storage_location::StorageProvider::Local)
123                }) {
124                    laminar_core::checkpoint::object_store_builder::file_url_path(url)
125                        .map_err(|error| ProfileError::RequirementNotMet(error.to_string()))?;
126                    return Ok(());
127                }
128                if config.storage_dir.is_some()
129                    || config
130                        .checkpoint
131                        .as_ref()
132                        .and_then(|checkpoint| checkpoint.data_dir.as_ref())
133                        .is_some()
134                {
135                    return Ok(());
136                }
137                Err(ProfileError::RequirementNotMet(
138                    "Embedded profile requires an absolute file:// checkpoint URL or local storage directory"
139                        .into(),
140                ))
141            }
142            Self::Durable | Self::Cluster => {
143                if object_store_url.is_none() {
144                    return Err(ProfileError::RequirementNotMet(
145                        "Durable/Cluster profile requires an \
146                         object_store_url"
147                            .into(),
148                    ));
149                }
150                Ok(())
151            }
152        }
153    }
154
155    /// Apply sensible defaults to a [`LaminarConfig`] for this profile.
156    ///
157    /// Does not override fields that the user has already set.
158    pub fn apply_defaults(self, config: &mut LaminarConfig) {
159        match self {
160            Self::BareMetal => {
161                // No persistence — nothing to configure.
162            }
163            Self::Embedded => {
164                // Ensure a reasonable buffer size for local workloads.
165                if config.default_buffer_size == LaminarConfig::default().default_buffer_size {
166                    config.default_buffer_size = 32_768;
167                }
168            }
169            Self::Durable => {
170                // Larger buffers for durable workloads.
171                if config.default_buffer_size == LaminarConfig::default().default_buffer_size {
172                    config.default_buffer_size = 131_072;
173                }
174            }
175            Self::Cluster => {
176                // Largest buffers for distributed workloads.
177                if config.default_buffer_size == LaminarConfig::default().default_buffer_size {
178                    config.default_buffer_size = 262_144;
179                }
180            }
181        }
182    }
183}
184
185impl FromStr for Profile {
186    type Err = ProfileError;
187
188    fn from_str(s: &str) -> Result<Self, Self::Err> {
189        match s.to_ascii_lowercase().as_str() {
190            "bare_metal" | "baremetal" | "bare-metal" => Ok(Self::BareMetal),
191            "embedded" => Ok(Self::Embedded),
192            "durable" => Ok(Self::Durable),
193            "cluster" => Ok(Self::Cluster),
194            _ => Err(ProfileError::UnknownProfileName(s.into())),
195        }
196    }
197}
198
199impl fmt::Display for Profile {
200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201        match self {
202            Self::BareMetal => write!(f, "bare_metal"),
203            Self::Embedded => write!(f, "embedded"),
204            Self::Durable => write!(f, "durable"),
205            Self::Cluster => write!(f, "cluster"),
206        }
207    }
208}
209
210/// Errors from profile validation.
211#[derive(Debug, thiserror::Error)]
212pub enum ProfileError {
213    /// A runtime requirement (e.g., config field) was not satisfied.
214    #[error("profile requirement not met: {0}")]
215    RequirementNotMet(String),
216
217    /// A required Cargo feature was not compiled in.
218    #[error("feature `{0}` not compiled — enable it in Cargo.toml")]
219    FeatureNotCompiled(String),
220
221    /// The profile name could not be parsed.
222    #[error("unknown profile name: {0}")]
223    UnknownProfileName(String),
224}
225
226#[cfg(test)]
227mod tests;