Skip to main content

qml_rs/storage/
config.rs

1use serde::{Deserialize, Serialize};
2use std::time::Duration;
3
4/// Configuration for storage backends
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(tag = "type", rename_all = "lowercase")]
7pub enum StorageConfig {
8    /// In-memory storage configuration
9    Memory(MemoryConfig),
10    /// Redis storage configuration
11    #[cfg(feature = "redis")]
12    Redis(RedisConfig),
13    /// PostgreSQL storage configuration
14    #[cfg(feature = "postgres")]
15    Postgres(PostgresConfig),
16}
17
18impl Default for StorageConfig {
19    fn default() -> Self {
20        Self::Memory(MemoryConfig::default())
21    }
22}
23
24/// Configuration for in-memory storage.
25///
26/// Earlier revisions carried `auto_cleanup` / `cleanup_interval` knobs
27/// here, but those were dead — `MemoryStorage` never read them, and
28/// final-state expiration is owned out-of-band by
29/// [`crate::processing::CleanupWorker`] which sweeps `expires_at` set
30/// by `JobProcessor`. The fields and their builders are kept as
31/// deprecated no-ops for one release so existing callers compile, but
32/// they don't influence runtime behavior.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct MemoryConfig {
35    /// Maximum number of jobs to keep in memory
36    pub max_jobs: Option<usize>,
37    /// **Deprecated.** Was a knob for in-process auto-cleanup, but
38    /// `MemoryStorage` never observed it and never will — cleanup is
39    /// owned by the cross-backend `CleanupWorker`. Kept to avoid
40    /// breaking downstream serializations that mention it. Will be
41    /// removed in the next major release.
42    #[deprecated(
43        note = "auto_cleanup is a no-op; CleanupWorker handles expiration. Will be removed."
44    )]
45    pub auto_cleanup: bool,
46    /// **Deprecated.** Same story as `auto_cleanup` — kept as a
47    /// compatibility shim. Will be removed.
48    #[deprecated(
49        note = "cleanup_interval is a no-op; configure CleanupWorker via ServerConfig instead."
50    )]
51    pub cleanup_interval: Option<Duration>,
52}
53
54impl Default for MemoryConfig {
55    fn default() -> Self {
56        // The deprecated fields are still initialized so the struct
57        // round-trips through serde for callers that have serialized
58        // old configs to disk.
59        #[allow(deprecated)]
60        Self {
61            max_jobs: Some(10_000),
62            auto_cleanup: true,
63            cleanup_interval: Some(Duration::from_secs(300)),
64        }
65    }
66}
67
68impl MemoryConfig {
69    /// Create a new memory config with default settings
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// Set the maximum number of jobs to keep in memory
75    pub fn with_max_jobs(mut self, max_jobs: usize) -> Self {
76        self.max_jobs = Some(max_jobs);
77        self
78    }
79
80    /// Disable job limit (unlimited jobs in memory)
81    pub fn unlimited(mut self) -> Self {
82        self.max_jobs = None;
83        self
84    }
85
86    /// **Deprecated.** No-op — see the field-level note on
87    /// [`MemoryConfig::auto_cleanup`].
88    #[deprecated(
89        note = "auto_cleanup is a no-op; CleanupWorker handles expiration. Will be removed."
90    )]
91    pub fn with_auto_cleanup(mut self, enabled: bool) -> Self {
92        #[allow(deprecated)]
93        {
94            self.auto_cleanup = enabled;
95        }
96        self
97    }
98
99    /// **Deprecated.** No-op — see the field-level note on
100    /// [`MemoryConfig::cleanup_interval`].
101    #[deprecated(
102        note = "cleanup_interval is a no-op; configure CleanupWorker via ServerConfig instead."
103    )]
104    pub fn with_cleanup_interval(mut self, interval: Duration) -> Self {
105        #[allow(deprecated)]
106        {
107            self.cleanup_interval = Some(interval);
108        }
109        self
110    }
111}
112
113/// Configuration for Redis storage
114#[cfg(feature = "redis")]
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct RedisConfig {
117    /// Redis connection URL (redis://localhost:6379)
118    pub url: String,
119    /// Connection pool size
120    pub pool_size: u32,
121    /// Connection timeout
122    pub connection_timeout: Duration,
123    /// Command timeout
124    pub command_timeout: Duration,
125    /// Key prefix for all QML keys
126    pub key_prefix: String,
127    /// Database number (0-15 for standard Redis)
128    pub database: Option<u8>,
129    /// Username for authentication (Redis 6.0+)
130    pub username: Option<String>,
131    /// Password for authentication
132    pub password: Option<String>,
133    /// Enable TLS/SSL
134    pub tls: bool,
135    /// TTL for completed jobs (None = no expiration)
136    pub completed_job_ttl: Option<Duration>,
137    /// TTL for failed jobs (None = no expiration)
138    pub failed_job_ttl: Option<Duration>,
139}
140
141#[cfg(feature = "redis")]
142impl Default for RedisConfig {
143    fn default() -> Self {
144        Self {
145            url: std::env::var("REDIS_URL")
146                .unwrap_or_else(|_| "redis://localhost:6379".to_string()),
147            pool_size: 10,
148            connection_timeout: Duration::from_secs(5),
149            command_timeout: Duration::from_secs(5),
150            key_prefix: "qml".to_string(),
151            database: None,
152            username: std::env::var("REDIS_USERNAME")
153                .ok()
154                .filter(|s| !s.is_empty()),
155            password: std::env::var("REDIS_PASSWORD")
156                .ok()
157                .filter(|s| !s.is_empty()),
158            tls: false,
159            completed_job_ttl: None,
160            failed_job_ttl: None,
161        }
162    }
163}
164
165#[cfg(feature = "redis")]
166impl RedisConfig {
167    /// Create a new Redis config with default settings
168    pub fn new() -> Self {
169        Self::default()
170    }
171    /// Set the Redis connection URL
172    pub fn with_url<S: Into<String>>(mut self, url: S) -> Self {
173        self.url = url.into();
174        self
175    }
176
177    /// Set the connection pool size
178    pub fn with_pool_size(mut self, size: u32) -> Self {
179        self.pool_size = size;
180        self
181    }
182
183    /// Set connection timeout
184    pub fn with_connection_timeout(mut self, timeout: Duration) -> Self {
185        self.connection_timeout = timeout;
186        self
187    }
188
189    /// Set command timeout
190    pub fn with_command_timeout(mut self, timeout: Duration) -> Self {
191        self.command_timeout = timeout;
192        self
193    }
194
195    /// Set the key prefix for QML keys
196    pub fn with_key_prefix<S: Into<String>>(mut self, prefix: S) -> Self {
197        self.key_prefix = prefix.into();
198        self
199    }
200
201    /// Set the Redis database number
202    pub fn with_database(mut self, database: u8) -> Self {
203        self.database = Some(database);
204        self
205    }
206
207    /// Set authentication credentials
208    pub fn with_credentials<U: Into<String>, P: Into<String>>(
209        mut self,
210        username: U,
211        password: P,
212    ) -> Self {
213        self.username = Some(username.into());
214        self.password = Some(password.into());
215        self
216    }
217
218    /// Set password for authentication (username will be empty)
219    pub fn with_password<P: Into<String>>(mut self, password: P) -> Self {
220        self.password = Some(password.into());
221        self
222    }
223
224    /// Enable TLS/SSL
225    pub fn with_tls(mut self, enabled: bool) -> Self {
226        self.tls = enabled;
227        self
228    }
229
230    /// Set TTL for completed jobs
231    pub fn with_completed_job_ttl(mut self, ttl: Duration) -> Self {
232        self.completed_job_ttl = Some(ttl);
233        self
234    }
235
236    /// Set TTL for failed jobs
237    pub fn with_failed_job_ttl(mut self, ttl: Duration) -> Self {
238        self.failed_job_ttl = Some(ttl);
239        self
240    }
241
242    /// Disable TTL for completed jobs (keep forever)
243    pub fn no_completed_job_ttl(mut self) -> Self {
244        self.completed_job_ttl = None;
245        self
246    }
247
248    /// Disable TTL for failed jobs (keep forever)
249    pub fn no_failed_job_ttl(mut self) -> Self {
250        self.failed_job_ttl = None;
251        self
252    }
253
254    /// Generate the full Redis URL including credentials and database
255    pub fn full_url(&self) -> String {
256        let mut url = self.url.clone();
257
258        // Add credentials if provided
259        if let (Some(username), Some(password)) = (&self.username, &self.password) {
260            url = url.replace("redis://", &format!("redis://{}:{}@", username, password));
261        } else if let Some(password) = &self.password {
262            url = url.replace("redis://", &format!("redis://:{}@", password));
263        }
264
265        // Add database if specified
266        if let Some(db) = self.database {
267            if !url.ends_with('/') {
268                url.push('/');
269            }
270            url.push_str(&db.to_string());
271        }
272
273        url
274    }
275}
276
277/// Configuration for PostgreSQL storage
278#[cfg(feature = "postgres")]
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280pub struct PostgresConfig {
281    /// PostgreSQL connection URL (postgresql://user:password@localhost/qml)
282    pub database_url: String,
283    /// Maximum number of connections in the pool
284    pub max_connections: u32,
285    /// Minimum number of connections in the pool
286    pub min_connections: u32,
287    /// Connection timeout
288    pub connect_timeout: Duration,
289    /// Command timeout
290    pub command_timeout: Duration,
291    /// Table name for jobs (default: qml_jobs)
292    pub table_name: String,
293    /// Schema name (default: public)
294    pub schema_name: String,
295    /// Enable automatic migration on startup
296    pub auto_migrate: bool,
297    /// Connection idle timeout
298    pub idle_timeout: Duration,
299    /// Maximum connection lifetime
300    pub max_lifetime: Option<Duration>,
301    /// Enable SSL/TLS
302    pub require_ssl: bool,
303}
304
305#[cfg(feature = "postgres")]
306impl Default for PostgresConfig {
307    fn default() -> Self {
308        Self {
309            database_url: std::env::var("DATABASE_URL").unwrap_or_else(|_| {
310                "postgresql://postgres:password@localhost:5432/qml".to_string()
311            }),
312            max_connections: 20,
313            min_connections: 1,
314            connect_timeout: Duration::from_secs(30),
315            command_timeout: Duration::from_secs(30),
316            table_name: "qml_jobs".to_string(),
317            schema_name: "qml".to_string(),
318            auto_migrate: true,
319            idle_timeout: Duration::from_secs(600),
320            max_lifetime: Some(Duration::from_secs(1800)),
321            require_ssl: false,
322        }
323    }
324}
325
326#[cfg(feature = "postgres")]
327impl PostgresConfig {
328    /// Create a new PostgreSQL config with default settings
329    pub fn new() -> Self {
330        Self::default()
331    }
332
333    /// Create a new PostgreSQL config without reading environment variables
334    pub fn with_defaults() -> Self {
335        Self {
336            database_url: String::new(), // Empty, must be set with with_database_url()
337            max_connections: 20,
338            min_connections: 1,
339            connect_timeout: Duration::from_secs(30),
340            command_timeout: Duration::from_secs(30),
341            table_name: "qml_jobs".to_string(),
342            schema_name: "qml".to_string(),
343            auto_migrate: true,
344            idle_timeout: Duration::from_secs(600),
345            max_lifetime: Some(Duration::from_secs(1800)),
346            require_ssl: false,
347        }
348    }
349
350    /// Set the database URL
351    pub fn with_database_url<S: Into<String>>(mut self, url: S) -> Self {
352        self.database_url = url.into();
353        self
354    }
355
356    /// Set the maximum number of connections in the pool
357    pub fn with_max_connections(mut self, max: u32) -> Self {
358        self.max_connections = max;
359        self
360    }
361
362    /// Set the minimum number of connections in the pool
363    pub fn with_min_connections(mut self, min: u32) -> Self {
364        self.min_connections = min;
365        self
366    }
367
368    /// Set connection timeout
369    pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
370        self.connect_timeout = timeout;
371        self
372    }
373
374    /// Set command timeout
375    pub fn with_command_timeout(mut self, timeout: Duration) -> Self {
376        self.command_timeout = timeout;
377        self
378    }
379
380    /// Set the table name for jobs
381    pub fn with_table_name<S: Into<String>>(mut self, name: S) -> Self {
382        self.table_name = name.into();
383        self
384    }
385
386    /// Set the schema name
387    pub fn with_schema_name<S: Into<String>>(mut self, name: S) -> Self {
388        self.schema_name = name.into();
389        self
390    }
391
392    /// Enable or disable automatic migration
393    pub fn with_auto_migrate(mut self, enabled: bool) -> Self {
394        self.auto_migrate = enabled;
395        self
396    }
397
398    /// Set idle timeout for connections
399    pub fn with_idle_timeout(mut self, timeout: Duration) -> Self {
400        self.idle_timeout = timeout;
401        self
402    }
403
404    /// Set maximum connection lifetime
405    pub fn with_max_lifetime(mut self, lifetime: Duration) -> Self {
406        self.max_lifetime = Some(lifetime);
407        self
408    }
409
410    /// Disable maximum connection lifetime
411    pub fn without_max_lifetime(mut self) -> Self {
412        self.max_lifetime = None;
413        self
414    }
415
416    /// Enable or disable SSL requirement
417    pub fn with_ssl(mut self, require_ssl: bool) -> Self {
418        self.require_ssl = require_ssl;
419        self
420    }
421
422    /// Get the full table name including schema
423    pub fn full_table_name(&self) -> String {
424        format!("{}.{}", self.schema_name, self.table_name)
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    #[allow(deprecated)]
434    fn test_memory_config_default() {
435        // The deprecated `auto_cleanup` / `cleanup_interval` fields
436        // still need to default to a value because the struct has to
437        // round-trip through serde without dropping them. The runtime
438        // ignores them either way.
439        let config = MemoryConfig::default();
440        assert_eq!(config.max_jobs, Some(10_000));
441        assert!(config.auto_cleanup);
442        assert_eq!(config.cleanup_interval, Some(Duration::from_secs(300)));
443    }
444
445    #[test]
446    #[allow(deprecated)]
447    fn test_memory_config_builder() {
448        // Same back-compat shim as `test_memory_config_default`: the
449        // builders are deprecated no-ops but must still set the fields
450        // they take so old serialized configs round-trip cleanly.
451        let config = MemoryConfig::new()
452            .with_max_jobs(5_000)
453            .with_auto_cleanup(false)
454            .with_cleanup_interval(Duration::from_secs(600));
455
456        assert_eq!(config.max_jobs, Some(5_000));
457        assert!(!config.auto_cleanup);
458        assert_eq!(config.cleanup_interval, Some(Duration::from_secs(600)));
459    }
460
461    #[test]
462    #[cfg(feature = "redis")]
463    fn test_redis_config_default() {
464        let config = RedisConfig::default();
465        // Should use default fallback when REDIS_URL is not set
466        assert_eq!(config.url, "redis://localhost:6379");
467        assert_eq!(config.pool_size, 10);
468        assert_eq!(config.key_prefix, "qml");
469        assert!(!config.tls);
470    }
471
472    #[test]
473    #[cfg(feature = "redis")]
474    fn test_redis_config_builder() {
475        let config = RedisConfig::new()
476            .with_url("redis://localhost:6380")
477            .with_pool_size(20)
478            .with_key_prefix("test")
479            .with_database(1)
480            .with_credentials("user", "pass")
481            .with_tls(true);
482
483        assert_eq!(config.url, "redis://localhost:6380");
484        assert_eq!(config.pool_size, 20);
485        assert_eq!(config.key_prefix, "test");
486        assert_eq!(config.database, Some(1));
487        assert_eq!(config.username, Some("user".to_string()));
488        assert_eq!(config.password, Some("pass".to_string()));
489        assert!(config.tls);
490    }
491
492    #[test]
493    #[cfg(feature = "redis")]
494    fn test_redis_full_url() {
495        let config = RedisConfig::new()
496            .with_url("redis://localhost:6379")
497            .with_credentials("user", "pass")
498            .with_database(5);
499
500        assert_eq!(config.full_url(), "redis://user:pass@localhost:6379/5");
501    }
502
503    #[test]
504    #[cfg(feature = "redis")]
505    fn test_redis_full_url_password_only() {
506        let config = RedisConfig::new()
507            .with_url("redis://localhost:6379")
508            .with_password("pass")
509            .with_database(2);
510
511        assert_eq!(config.full_url(), "redis://:pass@localhost:6379/2");
512    }
513
514    #[test]
515    #[cfg(feature = "redis")]
516    fn test_storage_config_serialization() {
517        let memory_config = StorageConfig::Memory(MemoryConfig::default());
518        let redis_config = StorageConfig::Redis(RedisConfig::default());
519
520        // Test that configs can be serialized/deserialized
521        let memory_json = serde_json::to_string(&memory_config).unwrap();
522        let redis_json = serde_json::to_string(&redis_config).unwrap();
523
524        let _: StorageConfig = serde_json::from_str(&memory_json).unwrap();
525        let _: StorageConfig = serde_json::from_str(&redis_json).unwrap();
526    }
527
528    #[test]
529    #[cfg(feature = "postgres")]
530    fn test_postgres_config_default() {
531        // `PostgresConfig::default()` reads DATABASE_URL when present, falling
532        // back to the hardcoded dev string. Compute the expected value the
533        // same way so this test is stable whether or not the surrounding
534        // environment exports DATABASE_URL — earlier we asserted the
535        // fallback unconditionally and broke under `DATABASE_URL=...
536        // cargo test` setups.
537        let expected_url = std::env::var("DATABASE_URL")
538            .unwrap_or_else(|_| "postgresql://postgres:password@localhost:5432/qml".to_string());
539        let config = PostgresConfig::default();
540        assert_eq!(config.database_url, expected_url);
541        assert_eq!(config.max_connections, 20);
542        assert_eq!(config.min_connections, 1);
543        assert_eq!(config.table_name, "qml_jobs");
544        assert_eq!(config.schema_name, "qml");
545        assert!(config.auto_migrate);
546        assert!(!config.require_ssl);
547    }
548
549    #[test]
550    #[cfg(feature = "postgres")]
551    fn test_postgres_config_builder() {
552        let config = PostgresConfig::new()
553            .with_database_url("postgresql://user:pass@localhost:5433/testdb")
554            .with_max_connections(50)
555            .with_min_connections(5)
556            .with_table_name("custom_jobs")
557            .with_schema_name("qml")
558            .with_auto_migrate(false)
559            .with_ssl(true);
560
561        assert_eq!(
562            config.database_url,
563            "postgresql://user:pass@localhost:5433/testdb"
564        );
565        assert_eq!(config.max_connections, 50);
566        assert_eq!(config.min_connections, 5);
567        assert_eq!(config.table_name, "custom_jobs");
568        assert_eq!(config.schema_name, "qml");
569        assert!(!config.auto_migrate);
570        assert!(config.require_ssl);
571    }
572
573    #[test]
574    #[cfg(feature = "postgres")]
575    fn test_postgres_full_table_name() {
576        let config = PostgresConfig::new()
577            .with_schema_name("qml")
578            .with_table_name("jobs");
579
580        assert_eq!(config.full_table_name(), "qml.jobs");
581    }
582
583    #[test]
584    #[cfg(feature = "postgres")]
585    fn test_postgres_config_serialization() {
586        let postgres_config = StorageConfig::Postgres(PostgresConfig::default());
587
588        // Test that config can be serialized/deserialized
589        let postgres_json = serde_json::to_string(&postgres_config).unwrap();
590        let _: StorageConfig = serde_json::from_str(&postgres_json).unwrap();
591    }
592}