reinhardt_auth/sessions/cleanup.rs
1//! Automatic session cleanup and expiration
2//!
3//! This module provides functionality to automatically clean up expired sessions
4//! from different backends. It can be run as a background task or scheduled job.
5//!
6//! ## Example
7//!
8//! ```rust
9//! use reinhardt_auth::sessions::cleanup::SessionCleanupTask;
10//! use reinhardt_auth::sessions::backends::InMemorySessionBackend;
11//! use std::time::Duration;
12//!
13//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
14//! let backend = InMemorySessionBackend::new();
15//!
16//! // Create a cleanup task
17//! let cleanup = SessionCleanupTask::new(backend, Duration::from_secs(3600));
18//!
19//! // Run cleanup manually
20//! let removed_count = cleanup.run_cleanup().await?;
21//! println!("Removed {} expired sessions", removed_count);
22//! # Ok(())
23//! # }
24//! ```
25
26use super::backends::{SessionBackend, SessionError};
27use async_trait::async_trait;
28use chrono::{DateTime, Duration as ChronoDuration, Utc};
29use std::marker::PhantomData;
30use std::time::Duration;
31
32/// Session cleanup configuration
33///
34/// # Example
35///
36/// ```rust
37/// use reinhardt_auth::sessions::cleanup::CleanupConfig;
38/// use std::time::Duration;
39///
40/// let config = CleanupConfig {
41/// max_age: Duration::from_secs(7200),
42/// batch_size: 100,
43/// };
44/// ```
45#[derive(Debug, Clone)]
46pub struct CleanupConfig {
47 /// Maximum session age before cleanup
48 pub max_age: Duration,
49 /// Number of sessions to clean up in one batch
50 pub batch_size: usize,
51}
52
53impl Default for CleanupConfig {
54 /// Create default cleanup configuration
55 ///
56 /// # Example
57 ///
58 /// ```rust
59 /// use reinhardt_auth::sessions::cleanup::CleanupConfig;
60 ///
61 /// let config = CleanupConfig::default();
62 /// assert_eq!(config.max_age.as_secs(), 1209600); // 2 weeks
63 /// assert_eq!(config.batch_size, 1000);
64 /// ```
65 fn default() -> Self {
66 Self {
67 max_age: Duration::from_secs(1209600), // 2 weeks
68 batch_size: 1000,
69 }
70 }
71}
72
73/// Trait for session backends that support cleanup
74#[async_trait]
75pub trait CleanupableBackend: SessionBackend {
76 /// Get all session keys
77 async fn get_all_keys(&self) -> Result<Vec<String>, SessionError>;
78
79 /// Get session metadata (creation time, last access time)
80 async fn get_metadata(
81 &self,
82 session_key: &str,
83 ) -> Result<Option<SessionMetadata>, SessionError>;
84
85 /// Get list of keys filtered by prefix
86 ///
87 /// Default implementation uses get_all_keys() for filtering.
88 /// Backends may provide more efficient implementations (e.g., database LIKE queries).
89 async fn list_keys_with_prefix(&self, prefix: &str) -> Result<Vec<String>, SessionError> {
90 // Default implementation: filter using get_all_keys()
91 let all_keys = self.get_all_keys().await?;
92 Ok(all_keys
93 .into_iter()
94 .filter(|key| key.starts_with(prefix))
95 .collect())
96 }
97
98 /// Count keys filtered by prefix
99 ///
100 /// Default implementation uses list_keys_with_prefix().
101 /// Backends may provide more efficient implementations (e.g., COUNT queries).
102 async fn count_keys_with_prefix(&self, prefix: &str) -> Result<usize, SessionError> {
103 let keys = self.list_keys_with_prefix(prefix).await?;
104 Ok(keys.len())
105 }
106
107 /// Delete all keys matching prefix
108 ///
109 /// Default implementation uses list_keys_with_prefix() and delete().
110 /// Backends may provide more efficient implementations (e.g., bulk DELETE).
111 ///
112 /// # Returns
113 ///
114 /// Returns the number of deleted sessions.
115 async fn delete_keys_with_prefix(&self, prefix: &str) -> Result<usize, SessionError> {
116 let keys = self.list_keys_with_prefix(prefix).await?;
117 let mut deleted = 0;
118 for key in keys {
119 if self.delete(&key).await.is_ok() {
120 deleted += 1;
121 }
122 }
123 Ok(deleted)
124 }
125}
126
127/// Session metadata for cleanup
128#[derive(Debug, Clone)]
129pub struct SessionMetadata {
130 /// When the session was created
131 pub created_at: DateTime<Utc>,
132 /// When the session was last accessed
133 pub last_accessed: Option<DateTime<Utc>>,
134}
135
136/// Session cleanup task
137///
138/// Automatically removes expired sessions based on configuration.
139///
140/// # Example
141///
142/// ```rust
143/// use reinhardt_auth::sessions::cleanup::SessionCleanupTask;
144/// use reinhardt_auth::sessions::backends::InMemorySessionBackend;
145/// use std::time::Duration;
146///
147/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
148/// let backend = InMemorySessionBackend::new();
149/// let cleanup = SessionCleanupTask::new(backend, Duration::from_secs(3600));
150///
151/// // Run cleanup
152/// let count = cleanup.run_cleanup().await?;
153/// println!("Cleaned up {} sessions", count);
154/// # Ok(())
155/// # }
156/// ```
157pub struct SessionCleanupTask<B: SessionBackend> {
158 backend: B,
159 config: CleanupConfig,
160 _phantom: PhantomData<B>,
161}
162
163impl<B: SessionBackend> SessionCleanupTask<B> {
164 /// Create a new cleanup task with default configuration
165 ///
166 /// # Example
167 ///
168 /// ```rust
169 /// use reinhardt_auth::sessions::cleanup::SessionCleanupTask;
170 /// use reinhardt_auth::sessions::backends::InMemorySessionBackend;
171 /// use std::time::Duration;
172 ///
173 /// let backend = InMemorySessionBackend::new();
174 /// let cleanup = SessionCleanupTask::new(backend, Duration::from_secs(3600));
175 /// ```
176 pub fn new(backend: B, max_age: Duration) -> Self {
177 Self {
178 backend,
179 config: CleanupConfig {
180 max_age,
181 ..Default::default()
182 },
183 _phantom: PhantomData,
184 }
185 }
186
187 /// Create a new cleanup task with custom configuration
188 ///
189 /// # Example
190 ///
191 /// ```rust
192 /// use reinhardt_auth::sessions::cleanup::{SessionCleanupTask, CleanupConfig};
193 /// use reinhardt_auth::sessions::backends::InMemorySessionBackend;
194 /// use std::time::Duration;
195 ///
196 /// let backend = InMemorySessionBackend::new();
197 /// let config = CleanupConfig {
198 /// max_age: Duration::from_secs(7200),
199 /// batch_size: 500,
200 /// };
201 /// let cleanup = SessionCleanupTask::with_config(backend, config);
202 /// ```
203 pub fn with_config(backend: B, config: CleanupConfig) -> Self {
204 Self {
205 backend,
206 config,
207 _phantom: PhantomData,
208 }
209 }
210
211 /// Run cleanup operation
212 ///
213 /// Returns the number of sessions that were removed.
214 ///
215 /// # Example
216 ///
217 /// ```rust
218 /// use reinhardt_auth::sessions::cleanup::SessionCleanupTask;
219 /// use reinhardt_auth::sessions::backends::InMemorySessionBackend;
220 /// use std::time::Duration;
221 ///
222 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
223 /// let backend = InMemorySessionBackend::new();
224 /// let cleanup = SessionCleanupTask::new(backend, Duration::from_secs(3600));
225 ///
226 /// let removed = cleanup.run_cleanup().await?;
227 /// println!("Removed {} expired sessions", removed);
228 /// # Ok(())
229 /// # }
230 /// ```
231 pub async fn run_cleanup(&self) -> Result<usize, SessionError> {
232 // For basic backends without metadata support, we can't determine age
233 // This is a simplified implementation that always returns 0
234 // Specific backends (database, file) should implement CleanupableBackend
235 Ok(0)
236 }
237}
238
239impl<B: SessionBackend + CleanupableBackend> SessionCleanupTask<B> {
240 /// Run cleanup operation for backends with metadata support
241 ///
242 /// # Example
243 ///
244 /// ```rust,no_run,ignore
245 /// use reinhardt_auth::sessions::cleanup::SessionCleanupTask;
246 /// # use reinhardt_auth::sessions::backends::InMemorySessionBackend;
247 /// use std::time::Duration;
248 ///
249 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
250 /// # let backend = InMemorySessionBackend::new();
251 /// let cleanup = SessionCleanupTask::new(backend, Duration::from_secs(3600));
252 ///
253 /// let removed = cleanup.run_cleanup_with_metadata().await?;
254 /// println!("Removed {} expired sessions", removed);
255 /// # Ok(())
256 /// # }
257 /// ```
258 pub async fn run_cleanup_with_metadata(&self) -> Result<usize, SessionError> {
259 let all_keys = self.backend.get_all_keys().await?;
260 let cutoff_time = Utc::now() - ChronoDuration::from_std(self.config.max_age).unwrap();
261
262 let mut removed_count = 0;
263
264 for chunk in all_keys.chunks(self.config.batch_size) {
265 for key in chunk {
266 if let Some(metadata) = self.backend.get_metadata(key).await? {
267 // Check if session is expired based on last_accessed time
268 if metadata.last_accessed < Some(cutoff_time)
269 && self.backend.delete(key).await.is_ok()
270 {
271 removed_count += 1;
272 }
273 }
274 }
275 }
276
277 Ok(removed_count)
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use crate::sessions::InMemorySessionBackend;
285 use rstest::rstest;
286
287 #[rstest]
288 #[tokio::test]
289 async fn test_cleanup_config_default() {
290 let config = CleanupConfig::default();
291 assert_eq!(config.max_age.as_secs(), 1209600); // 2 weeks
292 assert_eq!(config.batch_size, 1000);
293 }
294
295 #[rstest]
296 #[tokio::test]
297 async fn test_cleanup_task_creation() {
298 let backend = InMemorySessionBackend::new();
299 let _cleanup = SessionCleanupTask::new(backend, Duration::from_secs(3600));
300 }
301
302 #[rstest]
303 #[tokio::test]
304 async fn test_cleanup_task_with_config() {
305 let backend = InMemorySessionBackend::new();
306 let config = CleanupConfig {
307 max_age: Duration::from_secs(7200),
308 batch_size: 500,
309 };
310 let _cleanup = SessionCleanupTask::with_config(backend, config);
311 }
312
313 #[rstest]
314 #[tokio::test]
315 async fn test_run_cleanup_basic_backend() {
316 let backend = InMemorySessionBackend::new();
317 let cleanup = SessionCleanupTask::new(backend, Duration::from_secs(3600));
318
319 // Basic backend without metadata support returns 0
320 let removed = cleanup.run_cleanup().await.unwrap();
321 assert_eq!(removed, 0);
322 }
323
324 #[rstest]
325 #[tokio::test]
326 async fn test_cleanup_removes_expired_sessions() {
327 // Arrange
328 let backend = InMemorySessionBackend::new();
329 let data = serde_json::json!({"user": "expired_user"});
330 backend
331 .save("sess_expired", &data, Some(3600))
332 .await
333 .unwrap();
334
335 // Wait briefly so the session ages past the very short max_age
336 tokio::time::sleep(Duration::from_millis(50)).await;
337
338 // Create cleanup task with very short max_age (1ms) so session is considered expired
339 let cleanup = SessionCleanupTask::new(backend.clone(), Duration::from_millis(1));
340
341 // Act
342 let removed = cleanup.run_cleanup_with_metadata().await.unwrap();
343
344 // Assert
345 assert_eq!(removed, 1);
346 let loaded: Option<serde_json::Value> = backend.load("sess_expired").await.unwrap();
347 assert!(loaded.is_none());
348 }
349
350 #[rstest]
351 #[tokio::test]
352 async fn test_cleanup_preserves_recent_sessions() {
353 // Arrange
354 let backend = InMemorySessionBackend::new();
355 let data = serde_json::json!({"user": "active_user"});
356 backend
357 .save("sess_recent", &data, Some(3600))
358 .await
359 .unwrap();
360
361 // Access the session to populate last_accessed timestamp
362 let _: Option<serde_json::Value> = backend.load("sess_recent").await.unwrap();
363
364 // Create cleanup task with long max_age so session is still valid
365 let cleanup = SessionCleanupTask::new(backend.clone(), Duration::from_secs(3600));
366
367 // Act
368 let removed = cleanup.run_cleanup_with_metadata().await.unwrap();
369
370 // Assert
371 assert_eq!(removed, 0);
372 let loaded: Option<serde_json::Value> = backend.load("sess_recent").await.unwrap();
373 assert!(loaded.is_some());
374 }
375
376 #[rstest]
377 #[tokio::test]
378 async fn test_cleanup_batch_processing() {
379 // Arrange
380 let backend = InMemorySessionBackend::new();
381 let data = serde_json::json!({"batch": true});
382 backend
383 .save("batch_sess_1", &data, Some(3600))
384 .await
385 .unwrap();
386 backend
387 .save("batch_sess_2", &data, Some(3600))
388 .await
389 .unwrap();
390 backend
391 .save("batch_sess_3", &data, Some(3600))
392 .await
393 .unwrap();
394
395 // Wait briefly so sessions age past the very short max_age
396 tokio::time::sleep(Duration::from_millis(50)).await;
397
398 // Create cleanup task with batch_size=1 and very short max_age
399 let config = CleanupConfig {
400 max_age: Duration::from_millis(1),
401 batch_size: 1,
402 };
403 let cleanup = SessionCleanupTask::with_config(backend.clone(), config);
404
405 // Act
406 let removed = cleanup.run_cleanup_with_metadata().await.unwrap();
407
408 // Assert
409 assert_eq!(removed, 3);
410 assert!(!backend.exists("batch_sess_1").await.unwrap());
411 assert!(!backend.exists("batch_sess_2").await.unwrap());
412 assert!(!backend.exists("batch_sess_3").await.unwrap());
413 }
414}