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}