matrix_sdk_base/media/store/
media_retention_policy.rs

1// Copyright 2025 Kévin Commaille
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Configuration to decide whether or not to keep media in the cache, allowing
16//! to do periodic cleanups to avoid to have the size of the media cache grow
17//! indefinitely.
18//!
19//! To proceed to a cleanup, first set the [`MediaRetentionPolicy`] to use with
20//! [`MediaStore::set_media_retention_policy()`]. Then call
21//! [`MediaStore::clean()`].
22//!
23//! In the future, other settings will allow to run automatic periodic cleanup
24//! jobs.
25//!
26//! [`MediaStore::set_media_retention_policy()`]: crate::media::store::MediaStore::set_media_retention_policy
27//! [`MediaStore::clean()`]: crate::media::store::MediaStore::clean
28
29use ruma::time::{Duration, SystemTime};
30use serde::{Deserialize, Serialize};
31
32#[cfg(doc)]
33use crate::media::store::MediaStore;
34
35/// The retention policy for media content used by the [`MediaStore`].
36///
37/// [`EventCacheStore`]: crate::event_cache::store::EventCacheStore
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
40#[non_exhaustive]
41pub struct MediaRetentionPolicy {
42    /// The maximum authorized size of the overall media cache, in bytes.
43    ///
44    /// The cache size is defined as the sum of the sizes of all the (possibly
45    /// encrypted) media contents in the cache, excluding any metadata
46    /// associated with them.
47    ///
48    /// If this is set and the cache size is bigger than this value, the oldest
49    /// media contents in the cache will be removed during a cleanup until the
50    /// cache size is below this threshold.
51    ///
52    /// Note that it is possible for the cache size to temporarily exceed this
53    /// value between two cleanups.
54    ///
55    /// Defaults to 400 MiB.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub max_cache_size: Option<u64>,
58
59    /// The maximum authorized size of a single media content, in bytes.
60    ///
61    /// The size of a media content is the size taken by the content in the
62    /// database, after it was possibly encrypted, so it might differ from the
63    /// initial size of the content.
64    ///
65    /// The maximum authorized size of a single media content is actually the
66    /// lowest value between `max_cache_size` and `max_file_size`.
67    ///
68    /// If it is set, media content bigger than the maximum size will not be
69    /// cached. If the maximum size changed after media content that exceeds the
70    /// new value was cached, the corresponding content will be removed
71    /// during a cleanup.
72    ///
73    /// Defaults to 20 MiB.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub max_file_size: Option<u64>,
76
77    /// The duration after which unaccessed media content is considered
78    /// expired.
79    ///
80    /// If this is set, media content whose last access is older than this
81    /// duration will be removed from the media cache during a cleanup.
82    ///
83    /// Defaults to 60 days.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub last_access_expiry: Option<Duration>,
86
87    /// The duration between two automatic media cache cleanups.
88    ///
89    /// If this is set, a cleanup will be triggered after the given duration
90    /// is elapsed, at the next call to the media cache API. If this is set to
91    /// zero, each call to the media cache API will trigger a cleanup. If this
92    /// is `None`, cleanups will only occur if they are triggered manually.
93    ///
94    /// Defaults to running cleanups daily.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub cleanup_frequency: Option<Duration>,
97}
98
99impl MediaRetentionPolicy {
100    /// Create a [`MediaRetentionPolicy`] with the default values.
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// Create an empty [`MediaRetentionPolicy`].
106    ///
107    /// This means that all media will be cached and cleanups have no effect.
108    pub fn empty() -> Self {
109        Self {
110            max_cache_size: None,
111            max_file_size: None,
112            last_access_expiry: None,
113            cleanup_frequency: None,
114        }
115    }
116
117    /// Set the maximum authorized size of the overall media cache, in bytes.
118    pub fn with_max_cache_size(mut self, size: Option<u64>) -> Self {
119        self.max_cache_size = size;
120        self
121    }
122
123    /// Set the maximum authorized size of a single media content, in bytes.
124    pub fn with_max_file_size(mut self, size: Option<u64>) -> Self {
125        self.max_file_size = size;
126        self
127    }
128
129    /// Set the duration before which unaccessed media content is considered
130    /// expired.
131    pub fn with_last_access_expiry(mut self, duration: Option<Duration>) -> Self {
132        self.last_access_expiry = duration;
133        self
134    }
135
136    /// Set the duration between two automatic media cache cleanups.
137    pub fn with_cleanup_frequency(mut self, duration: Option<Duration>) -> Self {
138        self.cleanup_frequency = duration;
139        self
140    }
141
142    /// Whether this policy has limitations.
143    ///
144    /// If this policy has no limitations, a cleanup job would have no effect.
145    ///
146    /// Returns `true` if at least one limitation is set.
147    pub fn has_limitations(&self) -> bool {
148        self.max_cache_size.is_some()
149            || self.max_file_size.is_some()
150            || self.last_access_expiry.is_some()
151    }
152
153    /// Whether the given size exceeds the maximum authorized size of the media
154    /// cache.
155    ///
156    /// # Arguments
157    ///
158    /// * `size` - The overall size of the media cache to check, in bytes.
159    pub fn exceeds_max_cache_size(&self, size: u64) -> bool {
160        self.max_cache_size.is_some_and(|max_size| size > max_size)
161    }
162
163    /// The computed maximum authorized size of a single media content, in
164    /// bytes.
165    ///
166    /// This is the lowest value between `max_cache_size` and `max_file_size`.
167    pub fn computed_max_file_size(&self) -> Option<u64> {
168        match (self.max_cache_size, self.max_file_size) {
169            (None, None) => None,
170            (None, Some(size)) => Some(size),
171            (Some(size), None) => Some(size),
172            (Some(max_cache_size), Some(max_file_size)) => Some(max_cache_size.min(max_file_size)),
173        }
174    }
175
176    /// Whether the given size, in bytes, exceeds the computed maximum
177    /// authorized size of a single media content.
178    ///
179    /// # Arguments
180    ///
181    /// * `size` - The size of the media content to check, in bytes.
182    pub fn exceeds_max_file_size(&self, size: u64) -> bool {
183        self.computed_max_file_size().is_some_and(|max_size| size > max_size)
184    }
185
186    /// Whether a content whose last access was at the given time has expired.
187    ///
188    /// # Arguments
189    ///
190    /// * `current_time` - The current time.
191    ///
192    /// * `last_access_time` - The time when the media content to check was last
193    ///   accessed.
194    pub fn has_content_expired(
195        &self,
196        current_time: SystemTime,
197        last_access_time: SystemTime,
198    ) -> bool {
199        self.last_access_expiry.is_some_and(|max_duration| {
200            current_time
201                .duration_since(last_access_time)
202                // If this returns an error, the last access time is newer than the current time.
203                // This shouldn't happen but in this case the content cannot be expired.
204                .is_ok_and(|elapsed| elapsed >= max_duration)
205        })
206    }
207
208    /// Whether an automatic media cache cleanup should be triggered given the
209    /// time of the last cleanup.
210    ///
211    /// # Arguments
212    ///
213    /// * `current_time` - The current time.
214    ///
215    /// * `last_cleanup_time` - The time of the last media cache cleanup.
216    pub fn should_clean_up(&self, current_time: SystemTime, last_cleanup_time: SystemTime) -> bool {
217        self.cleanup_frequency.is_some_and(|max_duration| {
218            current_time
219                .duration_since(last_cleanup_time)
220                // If this returns an error, the last cleanup time is newer than the current time.
221                // This shouldn't happen but in this case no cleanup job is needed.
222                .is_ok_and(|elapsed| elapsed >= max_duration)
223        })
224    }
225}
226
227impl Default for MediaRetentionPolicy {
228    fn default() -> Self {
229        Self {
230            // 400 MiB.
231            max_cache_size: Some(400 * 1024 * 1024),
232            // 20 MiB.
233            max_file_size: Some(20 * 1024 * 1024),
234            // 60 days.
235            last_access_expiry: Some(Duration::from_secs(60 * 24 * 60 * 60)),
236            // 1 day.
237            cleanup_frequency: Some(Duration::from_secs(24 * 60 * 60)),
238        }
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use ruma::time::{Duration, SystemTime};
245
246    use super::MediaRetentionPolicy;
247
248    #[test]
249    fn test_media_retention_policy_has_limitations() {
250        let mut policy = MediaRetentionPolicy::empty();
251        assert!(!policy.has_limitations());
252
253        policy = policy.with_last_access_expiry(Some(Duration::from_secs(60)));
254        assert!(policy.has_limitations());
255
256        policy = policy.with_last_access_expiry(None);
257        assert!(!policy.has_limitations());
258
259        policy = policy.with_max_cache_size(Some(1_024));
260        assert!(policy.has_limitations());
261
262        policy = policy.with_max_cache_size(None);
263        assert!(!policy.has_limitations());
264
265        policy = policy.with_max_file_size(Some(1_024));
266        assert!(policy.has_limitations());
267
268        policy = policy.with_max_file_size(None);
269        assert!(!policy.has_limitations());
270
271        // With default values.
272        assert!(MediaRetentionPolicy::new().has_limitations());
273    }
274
275    #[test]
276    fn test_media_retention_policy_max_cache_size() {
277        let file_size = 2_048;
278
279        let mut policy = MediaRetentionPolicy::empty();
280        assert!(!policy.exceeds_max_cache_size(file_size));
281        assert_eq!(policy.computed_max_file_size(), None);
282        assert!(!policy.exceeds_max_file_size(file_size));
283
284        policy = policy.with_max_cache_size(Some(4_096));
285        assert!(!policy.exceeds_max_cache_size(file_size));
286        assert_eq!(policy.computed_max_file_size(), Some(4_096));
287        assert!(!policy.exceeds_max_file_size(file_size));
288
289        policy = policy.with_max_cache_size(Some(2_048));
290        assert!(!policy.exceeds_max_cache_size(file_size));
291        assert_eq!(policy.computed_max_file_size(), Some(2_048));
292        assert!(!policy.exceeds_max_file_size(file_size));
293
294        policy = policy.with_max_cache_size(Some(1_024));
295        assert!(policy.exceeds_max_cache_size(file_size));
296        assert_eq!(policy.computed_max_file_size(), Some(1_024));
297        assert!(policy.exceeds_max_file_size(file_size));
298    }
299
300    #[test]
301    fn test_media_retention_policy_max_file_size() {
302        let file_size = 2_048;
303
304        let mut policy = MediaRetentionPolicy::empty();
305        assert_eq!(policy.computed_max_file_size(), None);
306        assert!(!policy.exceeds_max_file_size(file_size));
307
308        // With max_file_size only.
309        policy = policy.with_max_file_size(Some(4_096));
310        assert_eq!(policy.computed_max_file_size(), Some(4_096));
311        assert!(!policy.exceeds_max_file_size(file_size));
312
313        policy = policy.with_max_file_size(Some(2_048));
314        assert_eq!(policy.computed_max_file_size(), Some(2_048));
315        assert!(!policy.exceeds_max_file_size(file_size));
316
317        policy = policy.with_max_file_size(Some(1_024));
318        assert_eq!(policy.computed_max_file_size(), Some(1_024));
319        assert!(policy.exceeds_max_file_size(file_size));
320
321        // With max_cache_size as well.
322        policy = policy.with_max_cache_size(Some(2_048));
323        assert_eq!(policy.computed_max_file_size(), Some(1_024));
324        assert!(policy.exceeds_max_file_size(file_size));
325
326        policy = policy.with_max_file_size(Some(2_048));
327        assert_eq!(policy.computed_max_file_size(), Some(2_048));
328        assert!(!policy.exceeds_max_file_size(file_size));
329
330        policy = policy.with_max_file_size(Some(4_096));
331        assert_eq!(policy.computed_max_file_size(), Some(2_048));
332        assert!(!policy.exceeds_max_file_size(file_size));
333
334        policy = policy.with_max_cache_size(Some(1_024));
335        assert_eq!(policy.computed_max_file_size(), Some(1_024));
336        assert!(policy.exceeds_max_file_size(file_size));
337    }
338
339    #[test]
340    fn test_media_retention_policy_has_content_expired() {
341        let epoch = SystemTime::UNIX_EPOCH;
342        let last_access_time = epoch + Duration::from_secs(30);
343        let epoch_plus_60 = epoch + Duration::from_secs(60);
344        let epoch_plus_120 = epoch + Duration::from_secs(120);
345
346        let mut policy = MediaRetentionPolicy::empty();
347        assert!(!policy.has_content_expired(epoch, last_access_time));
348        assert!(!policy.has_content_expired(last_access_time, last_access_time));
349        assert!(!policy.has_content_expired(epoch_plus_60, last_access_time));
350        assert!(!policy.has_content_expired(epoch_plus_120, last_access_time));
351
352        policy = policy.with_last_access_expiry(Some(Duration::from_secs(120)));
353        assert!(!policy.has_content_expired(epoch, last_access_time));
354        assert!(!policy.has_content_expired(last_access_time, last_access_time));
355        assert!(!policy.has_content_expired(epoch_plus_60, last_access_time));
356        assert!(!policy.has_content_expired(epoch_plus_120, last_access_time));
357
358        policy = policy.with_last_access_expiry(Some(Duration::from_secs(60)));
359        assert!(!policy.has_content_expired(epoch, last_access_time));
360        assert!(!policy.has_content_expired(last_access_time, last_access_time));
361        assert!(!policy.has_content_expired(epoch_plus_60, last_access_time));
362        assert!(policy.has_content_expired(epoch_plus_120, last_access_time));
363
364        policy = policy.with_last_access_expiry(Some(Duration::from_secs(30)));
365        assert!(!policy.has_content_expired(epoch, last_access_time));
366        assert!(!policy.has_content_expired(last_access_time, last_access_time));
367        assert!(policy.has_content_expired(epoch_plus_60, last_access_time));
368        assert!(policy.has_content_expired(epoch_plus_120, last_access_time));
369
370        policy = policy.with_last_access_expiry(Some(Duration::from_secs(0)));
371        assert!(!policy.has_content_expired(epoch, last_access_time));
372        assert!(policy.has_content_expired(last_access_time, last_access_time));
373        assert!(policy.has_content_expired(epoch_plus_60, last_access_time));
374        assert!(policy.has_content_expired(epoch_plus_120, last_access_time));
375    }
376
377    #[test]
378    fn test_media_retention_policy_cleanup_frequency() {
379        let epoch = SystemTime::UNIX_EPOCH;
380        let epoch_plus_60 = epoch + Duration::from_secs(60);
381        let epoch_plus_120 = epoch + Duration::from_secs(120);
382
383        let mut policy = MediaRetentionPolicy::empty();
384        assert!(!policy.should_clean_up(epoch_plus_60, epoch));
385        assert!(!policy.should_clean_up(epoch_plus_60, epoch_plus_60));
386        assert!(!policy.should_clean_up(epoch_plus_60, epoch_plus_120));
387
388        policy = policy.with_cleanup_frequency(Some(Duration::from_secs(0)));
389        assert!(policy.should_clean_up(epoch_plus_60, epoch));
390        assert!(policy.should_clean_up(epoch_plus_60, epoch_plus_60));
391        assert!(!policy.should_clean_up(epoch_plus_60, epoch_plus_120));
392
393        policy = policy.with_cleanup_frequency(Some(Duration::from_secs(30)));
394        assert!(policy.should_clean_up(epoch_plus_60, epoch));
395        assert!(!policy.should_clean_up(epoch_plus_60, epoch_plus_60));
396        assert!(!policy.should_clean_up(epoch_plus_60, epoch_plus_120));
397
398        policy = policy.with_cleanup_frequency(Some(Duration::from_secs(60)));
399        assert!(policy.should_clean_up(epoch_plus_60, epoch));
400        assert!(!policy.should_clean_up(epoch_plus_60, epoch_plus_60));
401        assert!(!policy.should_clean_up(epoch_plus_60, epoch_plus_120));
402
403        policy = policy.with_cleanup_frequency(Some(Duration::from_secs(90)));
404        assert!(!policy.should_clean_up(epoch_plus_60, epoch));
405        assert!(!policy.should_clean_up(epoch_plus_60, epoch_plus_60));
406        assert!(!policy.should_clean_up(epoch_plus_60, epoch_plus_120));
407    }
408}