sync_engine/submit_options.rs
1//! Submit options for caller-controlled storage routing.
2//!
3//! sync-engine is a **dumb storage layer** - it stores bytes and routes
4//! them to L1/L2/L3 based on caller-provided options. The caller decides
5//! where data goes. Compression is the caller's responsibility.
6//!
7//! # Example
8//!
9//! ```rust,ignore
10//! use sync_engine::{SubmitOptions, CacheTtl};
11//!
12//! // Default: store in both Redis and SQL
13//! let default_opts = SubmitOptions::default();
14//!
15//! // Durable storage (SQL only, skip Redis cache)
16//! let durable_opts = SubmitOptions::durable();
17//!
18//! // Ephemeral cache (Redis only with TTL)
19//! let cache_opts = SubmitOptions::cache(CacheTtl::Hour);
20//! ```
21
22use std::time::Duration;
23
24/// Standard cache TTL values that encourage batch grouping.
25///
26/// Using standard TTLs means items naturally batch together for efficient
27/// pipelined writes. Custom durations are supported but should be used sparingly.
28///
29/// # Batching Behavior
30///
31/// Items with the same `CacheTtl` variant are batched together:
32/// - 1000 items with `CacheTtl::Short` → 1 Redis pipeline
33/// - 500 items with `CacheTtl::Short` + 500 with `CacheTtl::Hour` → 2 pipelines
34///
35/// # Example
36///
37/// ```rust
38/// use sync_engine::{SubmitOptions, CacheTtl};
39///
40/// // Prefer standard TTLs for batching efficiency
41/// let opts = SubmitOptions::cache(CacheTtl::Hour);
42///
43/// // Custom TTL when you really need a specific duration
44/// let opts = SubmitOptions::cache(CacheTtl::custom_secs(90));
45/// ```
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum CacheTtl {
48 /// 1 minute - very short-lived cache
49 Minute,
50 /// 5 minutes - short cache
51 Short,
52 /// 15 minutes - medium cache
53 Medium,
54 /// 1 hour - standard cache (most common)
55 Hour,
56 /// 24 hours - long cache
57 Day,
58 /// 7 days - very long cache
59 Week,
60 /// Custom duration in seconds (use sparingly to preserve batching)
61 Custom(u64),
62}
63
64impl CacheTtl {
65 /// Create a custom TTL from seconds.
66 ///
67 /// **Prefer standard TTLs** (`Minute`, `Hour`, etc.) for better batching.
68 /// Custom TTLs create separate batches, reducing pipeline efficiency.
69 #[must_use]
70 pub fn custom_secs(secs: u64) -> Self {
71 Self::Custom(secs)
72 }
73
74 /// Convert to Duration.
75 #[must_use]
76 pub fn to_duration(self) -> Duration {
77 match self {
78 CacheTtl::Minute => Duration::from_secs(60),
79 CacheTtl::Short => Duration::from_secs(5 * 60),
80 CacheTtl::Medium => Duration::from_secs(15 * 60),
81 CacheTtl::Hour => Duration::from_secs(60 * 60),
82 CacheTtl::Day => Duration::from_secs(24 * 60 * 60),
83 CacheTtl::Week => Duration::from_secs(7 * 24 * 60 * 60),
84 CacheTtl::Custom(secs) => Duration::from_secs(secs),
85 }
86 }
87
88 /// Get the TTL in seconds (for OptionsKey grouping).
89 #[must_use]
90 pub fn as_secs(self) -> u64 {
91 self.to_duration().as_secs()
92 }
93}
94
95impl From<Duration> for CacheTtl {
96 /// Convert Duration to CacheTtl, snapping to standard values when close.
97 fn from(d: Duration) -> Self {
98 let secs = d.as_secs();
99 match secs {
100 0..=90 => CacheTtl::Minute, // 0-1.5min → 1min
101 91..=450 => CacheTtl::Short, // 1.5-7.5min → 5min
102 451..=2700 => CacheTtl::Medium, // 7.5-45min → 15min
103 2701..=5400 => CacheTtl::Hour, // 45min-1.5hr → 1hr
104 5401..=129600 => CacheTtl::Day, // 1.5hr-36hr → 24hr
105 129601..=864000 => CacheTtl::Week, // 36hr-10days → 7days
106 _ => CacheTtl::Custom(secs), // Beyond 10 days → custom
107 }
108 }
109}
110
111/// Options for controlling where data is stored.
112///
113/// sync-engine is a **dumb byte router** - it stores `Vec<u8>` and routes
114/// to L1 (always), L2 (Redis), and L3 (SQL) based on these options.
115///
116/// **Compression is the caller's responsibility.** Compress before submit
117/// if desired. This allows callers to choose their trade-offs:
118/// - Compressed data = smaller storage, but no SQL JSON search
119/// - Uncompressed JSON = SQL JSON functions work, larger storage
120#[derive(Debug, Clone)]
121pub struct SubmitOptions {
122 /// Store in L2 Redis.
123 ///
124 /// Default: `true`
125 pub redis: bool,
126
127 /// TTL for Redis key. `None` means no expiry.
128 ///
129 /// Use [`CacheTtl`] enum values for efficient batching.
130 ///
131 /// Default: `None`
132 pub redis_ttl: Option<CacheTtl>,
133
134 /// Store in L3 SQL (MySQL/SQLite).
135 ///
136 /// Default: `true`
137 pub sql: bool,
138}
139
140impl Default for SubmitOptions {
141 fn default() -> Self {
142 Self {
143 redis: true,
144 redis_ttl: None,
145 sql: true,
146 }
147 }
148}
149
150impl SubmitOptions {
151 /// Create options for Redis-only ephemeral cache with TTL.
152 ///
153 /// Uses [`CacheTtl`] enum for efficient batching. Items with the same
154 /// TTL variant are batched together in a single Redis pipeline.
155 ///
156 /// - Redis: yes, not compressed (searchable)
157 /// - SQL: no
158 ///
159 /// # Example
160 ///
161 /// ```rust
162 /// use sync_engine::{SubmitOptions, CacheTtl};
163 ///
164 /// // Standard 1-hour cache (batches efficiently)
165 /// let opts = SubmitOptions::cache(CacheTtl::Hour);
166 ///
167 /// // 5-minute short cache
168 /// let opts = SubmitOptions::cache(CacheTtl::Short);
169 /// ```
170 #[must_use]
171 pub fn cache(ttl: CacheTtl) -> Self {
172 Self {
173 redis: true,
174 redis_ttl: Some(ttl),
175 sql: false,
176 }
177 }
178
179 /// Create options for SQL-only durable storage.
180 ///
181 /// - Redis: no
182 /// - SQL: yes
183 #[must_use]
184 pub fn durable() -> Self {
185 Self {
186 redis: false,
187 redis_ttl: None,
188 sql: true,
189 }
190 }
191
192 /// Returns true if data should be stored anywhere.
193 #[must_use]
194 pub fn stores_anywhere(&self) -> bool {
195 self.redis || self.sql
196 }
197}
198
199/// Key for grouping items with compatible options in batches.
200///
201/// Items with the same `OptionsKey` can be batched together for
202/// efficient pipelined writes. Uses [`CacheTtl`] enum directly for
203/// natural grouping by standard TTL values.
204#[derive(Debug, Clone, PartialEq, Eq, Hash)]
205pub struct OptionsKey {
206 /// Store in Redis
207 pub redis: bool,
208 /// TTL enum value (None = no expiry)
209 pub redis_ttl: Option<CacheTtl>,
210 /// Store in SQL
211 pub sql: bool,
212}
213
214impl From<&SubmitOptions> for OptionsKey {
215 fn from(opts: &SubmitOptions) -> Self {
216 Self {
217 redis: opts.redis,
218 redis_ttl: opts.redis_ttl,
219 sql: opts.sql,
220 }
221 }
222}
223
224impl From<SubmitOptions> for OptionsKey {
225 fn from(opts: SubmitOptions) -> Self {
226 Self::from(&opts)
227 }
228}
229
230impl OptionsKey {
231 /// Convert back to SubmitOptions (for use in flush logic).
232 #[must_use]
233 pub fn to_options(&self) -> SubmitOptions {
234 SubmitOptions {
235 redis: self.redis,
236 redis_ttl: self.redis_ttl,
237 sql: self.sql,
238 }
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn test_default_options() {
248 let opts = SubmitOptions::default();
249 assert!(opts.redis);
250 assert!(opts.redis_ttl.is_none());
251 assert!(opts.sql);
252 }
253
254 #[test]
255 fn test_cache_options() {
256 let opts = SubmitOptions::cache(CacheTtl::Hour);
257 assert!(opts.redis);
258 assert_eq!(opts.redis_ttl, Some(CacheTtl::Hour));
259 assert!(!opts.sql);
260 }
261
262 #[test]
263 fn test_durable_options() {
264 let opts = SubmitOptions::durable();
265 assert!(!opts.redis);
266 assert!(opts.sql);
267 }
268
269 #[test]
270 fn test_stores_anywhere() {
271 assert!(SubmitOptions::default().stores_anywhere());
272 assert!(SubmitOptions::cache(CacheTtl::Minute).stores_anywhere());
273 assert!(SubmitOptions::durable().stores_anywhere());
274
275 let nowhere = SubmitOptions {
276 redis: false,
277 sql: false,
278 ..Default::default()
279 };
280 assert!(!nowhere.stores_anywhere());
281 }
282
283 #[test]
284 fn test_options_key_grouping() {
285 let opts1 = SubmitOptions::default();
286 let opts2 = SubmitOptions::default();
287
288 let key1 = OptionsKey::from(&opts1);
289 let key2 = OptionsKey::from(&opts2);
290
291 // Same options should have same key
292 assert_eq!(key1, key2);
293 }
294
295 #[test]
296 fn test_options_key_same_ttl_enum() {
297 // Items with same CacheTtl variant batch together
298 let opts1 = SubmitOptions::cache(CacheTtl::Hour);
299 let opts2 = SubmitOptions::cache(CacheTtl::Hour);
300
301 let key1 = OptionsKey::from(&opts1);
302 let key2 = OptionsKey::from(&opts2);
303
304 assert_eq!(key1, key2);
305 }
306
307 #[test]
308 fn test_options_key_different_ttl_enum() {
309 // Different CacheTtl variants = different batches
310 let opts1 = SubmitOptions::cache(CacheTtl::Hour);
311 let opts2 = SubmitOptions::cache(CacheTtl::Day);
312
313 let key1 = OptionsKey::from(&opts1);
314 let key2 = OptionsKey::from(&opts2);
315
316 assert_ne!(key1, key2);
317 }
318
319 #[test]
320 fn test_cache_ttl_to_duration() {
321 assert_eq!(CacheTtl::Minute.to_duration(), Duration::from_secs(60));
322 assert_eq!(CacheTtl::Short.to_duration(), Duration::from_secs(300));
323 assert_eq!(CacheTtl::Hour.to_duration(), Duration::from_secs(3600));
324 assert_eq!(CacheTtl::Day.to_duration(), Duration::from_secs(86400));
325 assert_eq!(CacheTtl::Custom(123).to_duration(), Duration::from_secs(123));
326 }
327
328 #[test]
329 fn test_cache_ttl_from_duration_snapping() {
330 // Close to 1 minute → snaps to Minute
331 assert_eq!(CacheTtl::from(Duration::from_secs(45)), CacheTtl::Minute);
332 assert_eq!(CacheTtl::from(Duration::from_secs(90)), CacheTtl::Minute);
333
334 // Close to 5 minutes → snaps to Short
335 assert_eq!(CacheTtl::from(Duration::from_secs(180)), CacheTtl::Short);
336
337 // Close to 1 hour → snaps to Hour
338 assert_eq!(CacheTtl::from(Duration::from_secs(3600)), CacheTtl::Hour);
339 }
340
341 #[test]
342 fn test_options_key_roundtrip() {
343 let original = SubmitOptions::cache(CacheTtl::Hour);
344 let key = OptionsKey::from(&original);
345 let recovered = key.to_options();
346
347 assert_eq!(original.redis, recovered.redis);
348 assert_eq!(original.redis_ttl, recovered.redis_ttl);
349 assert_eq!(original.sql, recovered.sql);
350 }
351
352 #[test]
353 fn test_options_key_hashable() {
354 use std::collections::HashMap;
355
356 let mut map: HashMap<OptionsKey, Vec<String>> = HashMap::new();
357
358 let key = OptionsKey::from(&SubmitOptions::default());
359 map.entry(key).or_default().push("item1".into());
360
361 assert_eq!(map.len(), 1);
362 }
363}