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    /// Override state tag for this item.
140    ///
141    /// If `Some`, overrides the item's existing state.
142    /// If `None`, uses the item's current state (default: "default").
143    ///
144    /// State is indexed for fast queries: SQL index + Redis SETs.
145    pub state: Option<String>,
146}
147
148impl Default for SubmitOptions {
149    fn default() -> Self {
150        Self {
151            redis: true,
152            redis_ttl: None,
153            sql: true,
154            state: None,
155        }
156    }
157}
158
159impl SubmitOptions {
160    /// Create options for Redis-only ephemeral cache with TTL.
161    ///
162    /// Uses [`CacheTtl`] enum for efficient batching. Items with the same
163    /// TTL variant are batched together in a single Redis pipeline.
164    ///
165    /// - Redis: yes, not compressed (searchable)
166    /// - SQL: no
167    ///
168    /// # Example
169    ///
170    /// ```rust
171    /// use sync_engine::{SubmitOptions, CacheTtl};
172    ///
173    /// // Standard 1-hour cache (batches efficiently)
174    /// let opts = SubmitOptions::cache(CacheTtl::Hour);
175    ///
176    /// // 5-minute short cache
177    /// let opts = SubmitOptions::cache(CacheTtl::Short);
178    /// ```
179    #[must_use]
180    pub fn cache(ttl: CacheTtl) -> Self {
181        Self {
182            redis: true,
183            redis_ttl: Some(ttl),
184            sql: false,
185            state: None,
186        }
187    }
188
189    /// Create options for SQL-only durable storage.
190    ///
191    /// - Redis: no
192    /// - SQL: yes
193    #[must_use]
194    pub fn durable() -> Self {
195        Self {
196            redis: false,
197            redis_ttl: None,
198            sql: true,
199            state: None,
200        }
201    }
202    
203    /// Set the state for items submitted with these options (builder pattern).
204    ///
205    /// # Example
206    ///
207    /// ```rust
208    /// use sync_engine::SubmitOptions;
209    ///
210    /// let opts = SubmitOptions::default().with_state("delta");
211    /// ```
212    #[must_use]
213    pub fn with_state(mut self, state: impl Into<String>) -> Self {
214        self.state = Some(state.into());
215        self
216    }
217
218    /// Returns true if data should be stored anywhere.
219    #[must_use]
220    pub fn stores_anywhere(&self) -> bool {
221        self.redis || self.sql
222    }
223}
224
225/// Key for grouping items with compatible options in batches.
226///
227/// Items with the same `OptionsKey` can be batched together for
228/// efficient pipelined writes. Uses [`CacheTtl`] enum directly for
229/// natural grouping by standard TTL values.
230#[derive(Debug, Clone, PartialEq, Eq, Hash)]
231pub struct OptionsKey {
232    /// Store in Redis
233    pub redis: bool,
234    /// TTL enum value (None = no expiry)
235    pub redis_ttl: Option<CacheTtl>,
236    /// Store in SQL
237    pub sql: bool,
238}
239
240impl From<&SubmitOptions> for OptionsKey {
241    fn from(opts: &SubmitOptions) -> Self {
242        Self {
243            redis: opts.redis,
244            redis_ttl: opts.redis_ttl,
245            sql: opts.sql,
246        }
247    }
248}
249
250impl From<SubmitOptions> for OptionsKey {
251    fn from(opts: SubmitOptions) -> Self {
252        Self::from(&opts)
253    }
254}
255
256impl OptionsKey {
257    /// Convert back to SubmitOptions (for use in flush logic).
258    #[must_use]
259    pub fn to_options(&self) -> SubmitOptions {
260        SubmitOptions {
261            redis: self.redis,
262            redis_ttl: self.redis_ttl,
263            sql: self.sql,
264            state: None,
265        }
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn test_default_options() {
275        let opts = SubmitOptions::default();
276        assert!(opts.redis);
277        assert!(opts.redis_ttl.is_none());
278        assert!(opts.sql);
279    }
280
281    #[test]
282    fn test_cache_options() {
283        let opts = SubmitOptions::cache(CacheTtl::Hour);
284        assert!(opts.redis);
285        assert_eq!(opts.redis_ttl, Some(CacheTtl::Hour));
286        assert!(!opts.sql);
287    }
288
289    #[test]
290    fn test_durable_options() {
291        let opts = SubmitOptions::durable();
292        assert!(!opts.redis);
293        assert!(opts.sql);
294    }
295
296    #[test]
297    fn test_stores_anywhere() {
298        assert!(SubmitOptions::default().stores_anywhere());
299        assert!(SubmitOptions::cache(CacheTtl::Minute).stores_anywhere());
300        assert!(SubmitOptions::durable().stores_anywhere());
301        
302        let nowhere = SubmitOptions {
303            redis: false,
304            sql: false,
305            ..Default::default()
306        };
307        assert!(!nowhere.stores_anywhere());
308    }
309
310    #[test]
311    fn test_options_key_grouping() {
312        let opts1 = SubmitOptions::default();
313        let opts2 = SubmitOptions::default();
314        
315        let key1 = OptionsKey::from(&opts1);
316        let key2 = OptionsKey::from(&opts2);
317        
318        // Same options should have same key
319        assert_eq!(key1, key2);
320    }
321
322    #[test]
323    fn test_options_key_same_ttl_enum() {
324        // Items with same CacheTtl variant batch together
325        let opts1 = SubmitOptions::cache(CacheTtl::Hour);
326        let opts2 = SubmitOptions::cache(CacheTtl::Hour);
327        
328        let key1 = OptionsKey::from(&opts1);
329        let key2 = OptionsKey::from(&opts2);
330        
331        assert_eq!(key1, key2);
332    }
333
334    #[test]
335    fn test_options_key_different_ttl_enum() {
336        // Different CacheTtl variants = different batches
337        let opts1 = SubmitOptions::cache(CacheTtl::Hour);
338        let opts2 = SubmitOptions::cache(CacheTtl::Day);
339        
340        let key1 = OptionsKey::from(&opts1);
341        let key2 = OptionsKey::from(&opts2);
342        
343        assert_ne!(key1, key2);
344    }
345
346    #[test]
347    fn test_cache_ttl_to_duration() {
348        assert_eq!(CacheTtl::Minute.to_duration(), Duration::from_secs(60));
349        assert_eq!(CacheTtl::Short.to_duration(), Duration::from_secs(300));
350        assert_eq!(CacheTtl::Hour.to_duration(), Duration::from_secs(3600));
351        assert_eq!(CacheTtl::Day.to_duration(), Duration::from_secs(86400));
352        assert_eq!(CacheTtl::Custom(123).to_duration(), Duration::from_secs(123));
353    }
354
355    #[test]
356    fn test_cache_ttl_from_duration_snapping() {
357        // Close to 1 minute → snaps to Minute
358        assert_eq!(CacheTtl::from(Duration::from_secs(45)), CacheTtl::Minute);
359        assert_eq!(CacheTtl::from(Duration::from_secs(90)), CacheTtl::Minute);
360        
361        // Close to 5 minutes → snaps to Short
362        assert_eq!(CacheTtl::from(Duration::from_secs(180)), CacheTtl::Short);
363        
364        // Close to 1 hour → snaps to Hour
365        assert_eq!(CacheTtl::from(Duration::from_secs(3600)), CacheTtl::Hour);
366    }
367
368    #[test]
369    fn test_options_key_roundtrip() {
370        let original = SubmitOptions::cache(CacheTtl::Hour);
371        let key = OptionsKey::from(&original);
372        let recovered = key.to_options();
373        
374        assert_eq!(original.redis, recovered.redis);
375        assert_eq!(original.redis_ttl, recovered.redis_ttl);
376        assert_eq!(original.sql, recovered.sql);
377    }
378
379    #[test]
380    fn test_options_key_hashable() {
381        use std::collections::HashMap;
382        
383        let mut map: HashMap<OptionsKey, Vec<String>> = HashMap::new();
384        
385        let key = OptionsKey::from(&SubmitOptions::default());
386        map.entry(key).or_default().push("item1".into());
387        
388        assert_eq!(map.len(), 1);
389    }
390
391    #[test]
392    fn test_state_default_none() {
393        let opts = SubmitOptions::default();
394        assert!(opts.state.is_none());
395    }
396
397    #[test]
398    fn test_state_with_state_builder() {
399        let opts = SubmitOptions::default().with_state("delta");
400        assert_eq!(opts.state, Some("delta".to_string()));
401    }
402
403    #[test]
404    fn test_state_cache_with_state() {
405        let opts = SubmitOptions::cache(CacheTtl::Hour).with_state("pending");
406        assert!(opts.redis);
407        assert!(!opts.sql);
408        assert_eq!(opts.state, Some("pending".to_string()));
409    }
410
411    #[test]
412    fn test_state_durable_with_state() {
413        let opts = SubmitOptions::durable().with_state("archived");
414        assert!(!opts.redis);
415        assert!(opts.sql);
416        assert_eq!(opts.state, Some("archived".to_string()));
417    }
418
419    #[test]
420    fn test_state_to_options_preserves_none() {
421        let opts = SubmitOptions::cache(CacheTtl::Hour);
422        let key = OptionsKey::from(&opts);
423        let recovered = key.to_options();
424        assert!(recovered.state.is_none());
425    }
426}