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