sync_engine/
submit_options.rs

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