Skip to main content

sui_castore/
config.rs

1//! Storage backend configuration types.
2
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use crate::storage::WritePolicy;
8
9/// Storage backend selection.
10///
11/// Dispatched by [`build_backend`](crate::build_backend) to the concrete
12/// backend implementation. The `#[serde(tag = "type", rename_all =
13/// "lowercase")]` shape is the stable JSON wire format; a config file's
14/// `"type": "local"` / `"type": "tiered"` etc. is the operator's vocabulary.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(tag = "type", rename_all = "lowercase")]
17pub enum BackendConfig {
18    /// Local filesystem storage.
19    Local {
20        /// Root directory for NAR and narinfo files.
21        path: PathBuf,
22    },
23    /// S3-compatible object storage.
24    S3 {
25        bucket: String,
26        region: String,
27        endpoint: Option<String>,
28    },
29    /// Redis L1 hot cache (requires the `redis-client` feature to construct).
30    Redis {
31        /// Connection URL, e.g. `redis://redis.super-cache-ci.svc:6379`.
32        url: String,
33        /// Optional per-write TTL in seconds; `None` relies on `maxmemory` LRU.
34        #[serde(default)]
35        ttl_secs: Option<u64>,
36    },
37    /// Postgres L2 durable cache tier (requires the `postgres` feature to
38    /// construct).
39    Pg {
40        /// Connection URL, e.g. `postgres://user@pg.svc:5432/sui`.
41        url: String,
42        /// Connection-pool ceiling.
43        max_conns: u32,
44    },
45    /// Tiered `L1 → L2 → L3` resolver composing three nested backends.
46    ///
47    /// The canonical super-cache shape: `l1: Redis`, `l2: Pg`, `l3: S3`. Any
48    /// nesting is legal (the arms recurse), so a deployment can pick `{disk |
49    /// tiered}` — or any composition — purely by config.
50    Tiered {
51        /// L1 hot tier (typically [`Redis`](BackendConfig::Redis)).
52        l1: Box<BackendConfig>,
53        /// L2 durable tier (typically [`Pg`](BackendConfig::Pg)).
54        l2: Box<BackendConfig>,
55        /// L3 object tier (typically [`S3`](BackendConfig::S3)).
56        l3: Box<BackendConfig>,
57        /// How `put`s propagate across the tiers.
58        #[serde(default)]
59        write_policy: WritePolicy,
60    },
61}
62
63impl Default for BackendConfig {
64    fn default() -> Self {
65        Self::Local {
66            path: PathBuf::from("/var/cache/sui"),
67        }
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn default_backend_is_local() {
77        assert!(matches!(BackendConfig::default(), BackendConfig::Local { .. }));
78    }
79
80    #[test]
81    fn local_backend_roundtrips_through_json() {
82        let cfg = BackendConfig::Local {
83            path: PathBuf::from("/var/cache/sui"),
84        };
85        let json = serde_json::to_string(&cfg).unwrap();
86        assert!(json.contains("local"));
87        let parsed: BackendConfig = serde_json::from_str(&json).unwrap();
88        assert!(matches!(parsed, BackendConfig::Local { .. }));
89    }
90
91    #[test]
92    fn s3_backend_roundtrips_through_json() {
93        let cfg = BackendConfig::S3 {
94            bucket: "my-cache".to_string(),
95            region: "us-east-1".to_string(),
96            endpoint: Some("http://localhost:9000".to_string()),
97        };
98        let json = serde_json::to_string_pretty(&cfg).unwrap();
99        let parsed: BackendConfig = serde_json::from_str(&json).unwrap();
100        assert!(matches!(parsed, BackendConfig::S3 { .. }));
101    }
102
103    #[test]
104    fn redis_ttl_defaults_to_none() {
105        let json = r#"{ "type": "redis", "url": "redis://r:6379" }"#;
106        let parsed: BackendConfig = serde_json::from_str(json).unwrap();
107        assert!(matches!(parsed, BackendConfig::Redis { ttl_secs: None, .. }));
108    }
109
110    #[test]
111    fn tiered_backend_roundtrips_through_json() {
112        let backend = BackendConfig::Tiered {
113            l1: Box::new(BackendConfig::Redis {
114                url: "redis://redis:6379".to_string(),
115                ttl_secs: Some(3600),
116            }),
117            l2: Box::new(BackendConfig::Pg {
118                url: "postgres://pg:5432/sui".to_string(),
119                max_conns: 16,
120            }),
121            l3: Box::new(BackendConfig::S3 {
122                bucket: "sui-super-cache".to_string(),
123                region: "us-east-1".to_string(),
124                endpoint: None,
125            }),
126            write_policy: WritePolicy::WriteThrough,
127        };
128        let json = serde_json::to_string_pretty(&backend).unwrap();
129        assert!(json.contains("tiered"));
130        assert!(json.contains("redis"));
131        assert!(json.contains("write-through"));
132        let parsed: BackendConfig = serde_json::from_str(&json).unwrap();
133        match parsed {
134            BackendConfig::Tiered { l1, l2, l3, write_policy } => {
135                assert!(matches!(*l1, BackendConfig::Redis { .. }));
136                assert!(matches!(*l2, BackendConfig::Pg { .. }));
137                assert!(matches!(*l3, BackendConfig::S3 { .. }));
138                assert_eq!(write_policy, WritePolicy::WriteThrough);
139            }
140            other => panic!("expected tiered, got {other:?}"),
141        }
142    }
143
144    #[test]
145    fn tiered_write_policy_defaults_when_absent() {
146        let json = r#"{
147            "type": "tiered",
148            "l1": { "type": "redis", "url": "redis://r:6379" },
149            "l2": { "type": "pg", "url": "postgres://p:5432/s", "max_conns": 8 },
150            "l3": { "type": "local", "path": "/var/cache/sui" }
151        }"#;
152        let parsed: BackendConfig = serde_json::from_str(json).unwrap();
153        match parsed {
154            BackendConfig::Tiered { write_policy, .. } => {
155                assert_eq!(write_policy, WritePolicy::default());
156                assert_eq!(write_policy, WritePolicy::WriteThrough);
157            }
158            other => panic!("expected tiered, got {other:?}"),
159        }
160    }
161}