1use 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#[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#[derive(Debug, Clone, PartialEq, Eq, Default)]
37pub enum StateBackendConfig {
38 #[default]
40 InProcess,
41
42 Local {
45 path: PathBuf,
47 },
48
49 ObjectStore {
52 url: String,
55 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#[derive(Debug, thiserror::Error)]
94pub enum StateBackendBuildError {
95 #[error("state backend object store: {0}")]
98 Store(#[from] crate::checkpoint::object_store_builder::ObjectStoreBuilderError),
99
100 #[error("state backend construction failed: {0}")]
102 Io(String),
103}
104
105impl StateBackendConfig {
106 #[must_use]
108 pub fn in_process() -> Self {
109 Self::InProcess
110 }
111
112 #[must_use]
114 pub fn local(path: impl Into<PathBuf>) -> Self {
115 Self::Local { path: path.into() }
116 }
117
118 #[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 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 #[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 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 #[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
248fn 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 #[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 #[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}