Skip to main content

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}