Skip to main content

laminar_core/state/
config.rs

1//! [`StateBackendConfig`]: tagged enum selecting storage for checkpointed
2//! vnode artifacts. Three shapes: `in_process`, `local` (filesystem path),
3//! `object_store` (S3/GCS/Azure/file URL).
4
5use std::path::PathBuf;
6use std::sync::Arc;
7
8use serde::Deserialize;
9
10use super::{
11    backend::{StateBackend, StateBackendDurability},
12    in_process::InProcessBackend,
13    object_store::ObjectStoreBackend,
14    KeyGroupCount,
15};
16
17const LOCAL_STATE_WRITER_ID: &str = "local";
18const LOCAL_STATE_LOCK: &str = ".laminardb-state.lock";
19
20/// Cloud credential/config overrides for the state object store.
21/// `Debug` redacts values — they can hold secrets
22/// (`aws_secret_access_key`, ...).
23#[derive(Clone, PartialEq, Eq, Default, Deserialize)]
24#[serde(transparent)]
25pub struct StorageOptions(pub rustc_hash::FxHashMap<String, String>);
26
27impl std::fmt::Debug for StorageOptions {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        f.debug_map()
30            .entries(self.0.keys().map(|k| (k, "[REDACTED]")))
31            .finish()
32    }
33}
34
35/// Tagged-union config that selects checkpoint-artifact storage.
36#[derive(Debug, Clone, PartialEq, Eq, Default)]
37pub enum StateBackendConfig {
38    /// Non-durable in-process backend. The default.
39    #[default]
40    InProcess,
41
42    /// Durable single-node backend on a local filesystem path. Shorthand
43    /// for an `object_store` backend with a `file://` URL.
44    Local {
45        /// Filesystem root for state.
46        path: PathBuf,
47    },
48
49    /// Object-store backend. Cloud URLs (`s3://`, `gs://`, `az://`) are
50    /// cluster-shared; `file://` is node-durable only.
51    ObjectStore {
52        /// Object store URL: `s3://bucket/prefix`, `gs://bucket/prefix`,
53        /// etc.
54        url: String,
55        /// Cloud credentials/config overrides (e.g. `endpoint`,
56        /// `aws_access_key_id`), same keys as `[checkpoint.storage]`.
57        /// Anything absent falls back to the provider's standard env
58        /// vars (`AWS_ACCESS_KEY_ID`, ...).
59        storage: StorageOptions,
60    },
61}
62
63#[derive(Deserialize)]
64#[serde(tag = "backend", rename_all = "snake_case", deny_unknown_fields)]
65enum StrictStateBackendConfig {
66    InProcess {},
67    Local {
68        path: PathBuf,
69    },
70    ObjectStore {
71        url: String,
72        #[serde(default)]
73        storage: StorageOptions,
74    },
75}
76
77impl<'de> Deserialize<'de> for StateBackendConfig {
78    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
79    where
80        D: serde::Deserializer<'de>,
81    {
82        Ok(match StrictStateBackendConfig::deserialize(deserializer)? {
83            StrictStateBackendConfig::InProcess {} => Self::InProcess,
84            StrictStateBackendConfig::Local { path } => Self::Local { path },
85            StrictStateBackendConfig::ObjectStore { url, storage } => {
86                Self::ObjectStore { url, storage }
87            }
88        })
89    }
90}
91
92/// Failure modes for [`StateBackendConfig::build`].
93#[derive(Debug, thiserror::Error)]
94pub enum StateBackendBuildError {
95    /// Object store construction failed (bad URL, missing feature
96    /// flag for the scheme, missing credentials, ...).
97    #[error("state backend object store: {0}")]
98    Store(#[from] crate::checkpoint::object_store_builder::ObjectStoreBuilderError),
99
100    /// Backend construction failed at the I/O layer.
101    #[error("state backend construction failed: {0}")]
102    Io(String),
103}
104
105impl StateBackendConfig {
106    /// Builder: embedded library, single process.
107    #[must_use]
108    pub fn in_process() -> Self {
109        Self::InProcess
110    }
111
112    /// Builder: single-node durable state on the local filesystem.
113    #[must_use]
114    pub fn local(path: impl Into<PathBuf>) -> Self {
115        Self::Local { path: path.into() }
116    }
117
118    /// Builder: object-store-backed state for an embedded or single-node runtime.
119    /// Credentials resolve from the provider's standard env vars; use
120    /// the `storage` config field for explicit overrides.
121    #[must_use]
122    pub fn object_store(url: impl Into<String>) -> Self {
123        Self::ObjectStore {
124            url: url.into(),
125            storage: StorageOptions::default(),
126        }
127    }
128
129    /// Instantiate the runtime backend.
130    ///
131    /// # Errors
132    /// - [`StateBackendBuildError::Store`] for a bad URL, a scheme
133    ///   whose feature flag (`aws`/`gcs`/`azure`) is not compiled in,
134    ///   or cloud-client construction failure.
135    /// - [`StateBackendBuildError::Io`] on filesystem setup.
136    pub fn build(
137        &self,
138        key_groups: KeyGroupCount,
139    ) -> Result<Arc<dyn StateBackend>, StateBackendBuildError> {
140        let key_group_count = u32::from(key_groups);
141        match self {
142            Self::InProcess => Ok(Arc::new(InProcessBackend::new(key_group_count))),
143            Self::Local { path } => {
144                let fs = durable_local_store(path)?;
145                Ok(Arc::new(
146                    ObjectStoreBackend::node_durable_with_empty_prefix_cleanup(
147                        fs,
148                        LOCAL_STATE_WRITER_ID,
149                        key_group_count,
150                    ),
151                ))
152            }
153            Self::ObjectStore { url, .. } if url.starts_with("file://") => {
154                let path = crate::checkpoint::object_store_builder::file_url_path(url)?;
155                let fs = durable_local_store(&path)?;
156                Ok(Arc::new(
157                    ObjectStoreBackend::node_durable_with_empty_prefix_cleanup(
158                        fs,
159                        LOCAL_STATE_WRITER_ID,
160                        key_group_count,
161                    ),
162                ))
163            }
164            Self::ObjectStore { url, storage } => {
165                let store = cloud_store(url, storage)?;
166                let backend = match self.durability_scope() {
167                    StateBackendDurability::Volatile => {
168                        ObjectStoreBackend::new(store, LOCAL_STATE_WRITER_ID, key_group_count)
169                    }
170                    StateBackendDurability::NodeDurable => ObjectStoreBackend::node_durable(
171                        store,
172                        LOCAL_STATE_WRITER_ID,
173                        key_group_count,
174                    ),
175                    StateBackendDurability::ClusterShared => ObjectStoreBackend::cluster_shared(
176                        store,
177                        LOCAL_STATE_WRITER_ID,
178                        key_group_count,
179                    ),
180                };
181                Ok(Arc::new(backend))
182            }
183        }
184    }
185
186    /// Filesystem path for durable state, if any. Returns `None` for
187    /// non-filesystem backends.
188    #[must_use]
189    pub fn local_storage_dir(&self) -> Option<&std::path::Path> {
190        match self {
191            Self::Local { path, .. } => Some(path.as_path()),
192            _ => None,
193        }
194    }
195
196    /// Build the underlying `object_store` handle (if any) so callers
197    /// that need to share the same store — e.g. an
198    /// `AssignmentSnapshotStore` alongside the state backend — can
199    /// avoid re-parsing the URL. `None` for `InProcess`.
200    ///
201    /// # Errors
202    /// Same failure modes as [`Self::build`].
203    pub fn build_object_store(
204        &self,
205    ) -> Result<Option<Arc<dyn ::object_store::ObjectStore>>, StateBackendBuildError> {
206        match self {
207            Self::InProcess => Ok(None),
208            Self::Local { path } => Ok(Some(durable_local_store(path)?)),
209            Self::ObjectStore { url, storage, .. } => Ok(Some(state_store(url, storage)?)),
210        }
211    }
212
213    /// Failure scope survived by the configured backend.
214    ///
215    /// A local path and `file://` survive a same-node process restart but do
216    /// not claim peer visibility. Supported cloud schemes name a shared
217    /// service and therefore satisfy cluster recovery admission.
218    #[must_use]
219    pub fn durability_scope(&self) -> StateBackendDurability {
220        match self {
221            Self::InProcess => StateBackendDurability::Volatile,
222            Self::Local { .. } => StateBackendDurability::NodeDurable,
223            Self::ObjectStore { url, .. } => StateBackendDurability::for_storage_url(url),
224        }
225    }
226}
227
228fn durable_local_store(
229    path: &std::path::Path,
230) -> Result<Arc<crate::durable_local_store::DurableLocalObjectStore>, StateBackendBuildError> {
231    crate::durable_local_store::DurableLocalObjectStore::new_exclusive(path, LOCAL_STATE_LOCK)
232        .map(Arc::new)
233        .map_err(|error| StateBackendBuildError::Io(error.to_string()))
234}
235
236fn state_store(
237    url: &str,
238    storage: &StorageOptions,
239) -> Result<Arc<dyn ::object_store::ObjectStore>, StateBackendBuildError> {
240    if url.starts_with("file://") {
241        let path = crate::checkpoint::object_store_builder::file_url_path(url)?;
242        durable_local_store(&path).map(|store| store as Arc<dyn ::object_store::ObjectStore>)
243    } else {
244        cloud_store(url, storage)
245    }
246}
247
248/// Cloud-store construction shared by [`StateBackendConfig::build`] and
249/// [`StateBackendConfig::build_object_store`]: translates the
250/// `StorageOptions` map into the builder's std-HashMap parameter
251/// (cold path, runs once at startup).
252fn cloud_store(
253    url: &str,
254    storage: &StorageOptions,
255) -> Result<Arc<dyn ::object_store::ObjectStore>, StateBackendBuildError> {
256    Ok(crate::checkpoint::object_store_builder::build_object_store(
257        url,
258        &storage
259            .0
260            .iter()
261            .map(|(k, v)| (k.clone(), v.clone()))
262            .collect(),
263    )?)
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    fn persisted_seal_writer(
271        root: &std::path::Path,
272        attempt: crate::state::CheckpointAttempt,
273    ) -> String {
274        let path = root
275            .join("state-v2")
276            .join(format!("epoch={}", attempt.epoch))
277            .join(format!("checkpoint={}", attempt.checkpoint_id))
278            .join("_SEAL");
279        let bytes = std::fs::read(path).unwrap();
280        let seal: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
281        seal["instance_id"].as_str().unwrap().to_owned()
282    }
283
284    #[test]
285    fn parse_in_process_minimal() {
286        let toml = r#"backend = "in_process""#;
287        let c: StateBackendConfig = toml::from_str(toml).unwrap();
288        assert!(matches!(c, StateBackendConfig::InProcess));
289        assert_eq!(c.durability_scope(), StateBackendDurability::Volatile);
290        assert!(c.local_storage_dir().is_none());
291    }
292
293    #[test]
294    fn parse_local_with_path() {
295        let toml = r#"
296backend = "local"
297path = "/var/laminar"
298"#;
299        let c: StateBackendConfig = toml::from_str(toml).unwrap();
300        assert_eq!(
301            c.local_storage_dir(),
302            Some(std::path::Path::new("/var/laminar"))
303        );
304        assert_eq!(c.durability_scope(), StateBackendDurability::NodeDurable);
305        assert!(matches!(c, StateBackendConfig::Local { .. }));
306    }
307
308    #[test]
309    fn storage_url_durability_is_fail_closed() {
310        let local_url = url::Url::from_file_path(std::env::temp_dir().join("laminar-state"))
311            .unwrap()
312            .to_string();
313        assert_eq!(
314            StateBackendDurability::for_storage_url(&local_url),
315            StateBackendDurability::NodeDurable
316        );
317        assert_eq!(
318            StateBackendDurability::for_storage_url("file:///tmp/laminar-state"),
319            StateBackendDurability::NodeDurable
320        );
321        assert_eq!(
322            StateBackendDurability::for_storage_url("file://remote-host/state"),
323            StateBackendDurability::Volatile
324        );
325        assert_eq!(
326            StateBackendDurability::for_storage_url("file://./relative"),
327            StateBackendDurability::Volatile
328        );
329        assert_eq!(
330            StateBackendDurability::for_storage_url("s3://bucket/state"),
331            StateBackendDurability::ClusterShared
332        );
333        assert_eq!(
334            StateBackendDurability::for_storage_url("memory://state"),
335            StateBackendDurability::Volatile
336        );
337        assert_eq!(
338            StateBackendDurability::for_storage_url("custom://state"),
339            StateBackendDurability::Volatile
340        );
341    }
342
343    #[test]
344    fn parse_object_store() {
345        let toml = r#"
346backend = "object_store"
347url = "s3://bucket/laminar"
348"#;
349        let c: StateBackendConfig = toml::from_str(toml).unwrap();
350        assert_eq!(c.durability_scope(), StateBackendDurability::ClusterShared);
351        match c {
352            StateBackendConfig::ObjectStore { url, .. } => {
353                assert_eq!(url, "s3://bucket/laminar");
354            }
355            _ => panic!("expected ObjectStore"),
356        }
357    }
358
359    #[test]
360    fn reject_public_state_writer_identity() {
361        for toml in [
362            "backend = \"local\"\npath = \"/var/laminar\"\ninstance_id = \"node-0\"\n",
363            "backend = \"local\"\npath = \"/var/laminar\"\ninstanceId = \"node-0\"\n",
364            "backend = \"object_store\"\nurl = \"s3://bucket/laminar\"\ninstance_id = \"node-0\"\n",
365            "backend = \"object_store\"\nurl = \"s3://bucket/laminar\"\ninstanceId = \"node-0\"\n",
366        ] {
367            assert!(
368                toml::from_str::<StateBackendConfig>(toml).is_err(),
369                "public writer identity was silently accepted: {toml}"
370            );
371        }
372    }
373
374    #[test]
375    fn reject_unwired_object_store_fields() {
376        for retired in [
377            "vnodes = [0, 1]",
378            "merger_instance = \"node-0\"",
379            "discovery = \"dynamic\"",
380            "seed_peers = [\"10.0.0.1:7946\"]",
381        ] {
382            let toml =
383                format!("backend = \"object_store\"\nurl = \"s3://bucket/laminar\"\n{retired}\n");
384            assert!(
385                toml::from_str::<StateBackendConfig>(&toml).is_err(),
386                "retired field was silently accepted: {retired}"
387            );
388        }
389    }
390
391    #[test]
392    fn reject_backend_specific_vnode_capacity() {
393        for toml in [
394            "backend = \"in_process\"\nvnode_capacity = 256\n",
395            "backend = \"local\"\npath = \"/var/laminar\"\nvnode_capacity = 256\n",
396            "backend = \"object_store\"\nurl = \"s3://bucket/state\"\nvnode_capacity = 256\n",
397        ] {
398            assert!(toml::from_str::<StateBackendConfig>(toml).is_err());
399        }
400    }
401
402    #[tokio::test]
403    async fn build_in_process_returns_backend() {
404        use crate::state::CheckpointAttempt;
405        use bytes::Bytes;
406        let c = StateBackendConfig::in_process();
407        let backend = c.build(super::super::LOCAL_KEY_GROUP_COUNT).unwrap();
408        assert_eq!(backend.durability_scope(), StateBackendDurability::Volatile);
409        let attempt = CheckpointAttempt::new(1, 1);
410        backend
411            .write_partial(attempt, 0, 0, Bytes::from_static(b"ok"))
412            .await
413            .unwrap();
414        assert_eq!(
415            &backend.read_partial(attempt, 0).await.unwrap().unwrap()[..],
416            b"ok",
417        );
418    }
419
420    #[tokio::test]
421    async fn build_local_instantiates_backend() {
422        use crate::state::CheckpointAttempt;
423        let dir = tempfile::tempdir().unwrap();
424        let c = StateBackendConfig::local(dir.path());
425        let backend = c.build(super::super::LOCAL_KEY_GROUP_COUNT).unwrap();
426        assert_eq!(
427            backend.durability_scope(),
428            StateBackendDurability::NodeDurable
429        );
430        let attempt = CheckpointAttempt::new(1, 1);
431        backend
432            .write_partial(attempt, 0, 0, bytes::Bytes::from_static(b"z"))
433            .await
434            .unwrap();
435        assert!(backend
436            .seal_checkpoint(attempt, None, &[0], &[])
437            .await
438            .unwrap());
439        assert_eq!(persisted_seal_writer(dir.path(), attempt), "local");
440        assert_eq!(
441            &backend.read_partial(attempt, 0).await.unwrap().unwrap()[..],
442            b"z",
443        );
444        drop(backend);
445        let restarted = c.build(super::super::LOCAL_KEY_GROUP_COUNT).unwrap();
446        assert_eq!(
447            &restarted.read_partial(attempt, 0).await.unwrap().unwrap()[..],
448            b"z",
449        );
450    }
451
452    #[test]
453    fn local_state_root_has_one_live_owner() {
454        let dir = tempfile::tempdir().unwrap();
455        let config = StateBackendConfig::local(dir.path());
456        let first = config.build(super::super::LOCAL_KEY_GROUP_COUNT).unwrap();
457        let Err(error) = config.build(super::super::LOCAL_KEY_GROUP_COUNT) else {
458            panic!("a second local state owner was admitted");
459        };
460        assert!(error.to_string().contains("already owned"));
461        drop(first);
462        config.build(super::super::LOCAL_KEY_GROUP_COUNT).unwrap();
463    }
464
465    #[tokio::test]
466    async fn build_object_store_file_url_instantiates_backend() {
467        use crate::state::CheckpointAttempt;
468        let dir = tempfile::tempdir().unwrap();
469        let normalized = dir.path().display().to_string().replace('\\', "/");
470        let url = if normalized.starts_with('/') {
471            format!("file://{normalized}")
472        } else {
473            format!("file:///{normalized}")
474        };
475        let c = StateBackendConfig::object_store(url);
476        let backend = c.build(super::super::LOCAL_KEY_GROUP_COUNT).unwrap();
477        assert_eq!(
478            backend.durability_scope(),
479            StateBackendDurability::NodeDurable
480        );
481        let attempt = CheckpointAttempt::new(1, 1);
482        backend
483            .write_partial(attempt, 0, 0, bytes::Bytes::from_static(b"z"))
484            .await
485            .unwrap();
486        assert!(backend
487            .seal_checkpoint(attempt, None, &[0], &[])
488            .await
489            .unwrap());
490        assert_eq!(persisted_seal_writer(dir.path(), attempt), "local");
491        let got = backend.read_partial(attempt, 0).await.unwrap().unwrap();
492        assert_eq!(&got[..], b"z");
493    }
494
495    /// Without the `aws` feature an `s3://` URL must fail with the
496    /// missing-feature error, not silently fall back to local.
497    #[cfg(not(feature = "aws"))]
498    #[test]
499    fn build_object_store_s3_requires_aws_feature() {
500        use crate::checkpoint::object_store_builder::ObjectStoreBuilderError;
501
502        let c = StateBackendConfig::object_store("s3://bucket/path");
503        let Err(err) = c.build(super::super::LOCAL_KEY_GROUP_COUNT) else {
504            panic!("s3 must not build without the aws feature");
505        };
506        assert!(
507            matches!(
508                err,
509                StateBackendBuildError::Store(ObjectStoreBuilderError::MissingFeature { .. })
510            ),
511            "got: {err}",
512        );
513    }
514
515    /// With the `aws` feature, an `s3://` URL + explicit `storage`
516    /// credentials builds a client (construction is offline — no
517    /// network until first use).
518    #[cfg(feature = "aws")]
519    #[test]
520    fn build_object_store_s3_builds_with_storage_options() {
521        let toml = r#"
522backend = "object_store"
523url = "s3://bucket/laminar"
524
525[storage]
526endpoint = "http://127.0.0.1:9000"
527aws_access_key_id = "k"
528aws_secret_access_key = "s"
529region = "us-east-1"
530allow_http = "true"
531"#;
532        let c: StateBackendConfig = toml::from_str(toml).unwrap();
533        c.build(super::super::LOCAL_KEY_GROUP_COUNT)
534            .expect("s3 client must build offline");
535    }
536
537    #[test]
538    fn default_is_in_process() {
539        let c = StateBackendConfig::default();
540        assert!(matches!(c, StateBackendConfig::InProcess));
541    }
542
543    #[test]
544    fn partial_eq_works() {
545        assert_eq!(
546            StateBackendConfig::in_process(),
547            StateBackendConfig::in_process()
548        );
549        assert_ne!(
550            StateBackendConfig::in_process(),
551            StateBackendConfig::local("/tmp/x")
552        );
553    }
554}