Skip to main content

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 during a
71    /// 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 expired.
78    ///
79    /// If this is set, media content whose last access is older than this
80    /// duration will be removed from the media cache during a cleanup.
81    ///
82    /// Defaults to 60 days.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub last_access_expiry: Option<Duration>,
85
86    /// The duration between two automatic media cache cleanups.
87    ///
88    /// If this is set, a cleanup will be triggered after the given duration is
89    /// elapsed, at the next call to the media cache API. If this is set to
90    /// zero, each call to the media cache API will trigger a cleanup. If this
91    /// is `None`, cleanups will only occur if they are triggered manually.
92    ///
93    /// Defaults to running cleanups daily.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub cleanup_frequency: Option<Duration>,
96}
97
98impl MediaRetentionPolicy {
99    /// Create a [`MediaRetentionPolicy`] with the default values.
100    pub fn new() -> Self {
101        Self::default()
102    }
103
104    /// Create an empty [`MediaRetentionPolicy`].
105    ///
106    /// This means that all media will be cached and cleanups have no effect.
107    pub fn empty() -> Self {
108        Self {
109            max_cache_size: None,
110            max_file_size: None,
111            last_access_expiry: None,
112            cleanup_frequency: None,
113        }
114    }
115
116    /// Set the maximum authorized size of the overall media cache, in bytes.
117    pub fn with_max_cache_size(mut self, size: Option<u64>) -> Self {
118        self.max_cache_size = size;
119        self
120    }
121
122    /// Set the maximum authorized size of a single media content, in bytes.
123    pub fn with_max_file_size(mut self, size: Option<u64>) -> Self {
124        self.max_file_size = size;
125        self
126    }
127
128    /// Set the duration before which unaccessed media content is considered
129    /// expired.
130    pub fn with_last_access_expiry(mut self, duration: Option<Duration>) -> Self {
131        self.last_access_expiry = duration;
132        self
133    }
134
135    /// Set the duration between two automatic media cache cleanups.
136    pub fn with_cleanup_frequency(mut self, duration: Option<Duration>) -> Self {
137        self.cleanup_frequency = duration;
138        self
139    }
140
141    /// Whether this policy has limitations.
142    ///
143    /// If this policy has no limitations, a cleanup job would have no effect.
144    ///
145    /// Returns `true` if at least one limitation is set.
146    pub fn has_limitations(&self) -> bool {
147        self.max_cache_size.is_some()
148            || self.max_file_size.is_some()
149            || self.last_access_expiry.is_some()
150    }
151
152    /// Whether the given size exceeds the maximum authorized size of the media
153    /// cache.
154    ///
155    /// # Arguments
156    ///
157    /// - `size` - The overall size of the media cache to check, in bytes.
158    pub fn exceeds_max_cache_size(&self, size: u64) -> bool {
159        self.max_cache_size.is_some_and(|max_size| size > max_size)
160    }
161
162    /// The computed maximum authorized size of a single media content, in
163    /// bytes.
164    ///
165    /// This is the lowest value between `max_cache_size` and `max_file_size`.
166    pub fn computed_max_file_size(&self) -> Option<u64> {
167        match (self.max_cache_size, self.max_file_size) {
168            (None, None) => None,
169            (None, Some(size)) => Some(size),
170            (Some(size), None) => Some(size),
171            (Some(max_cache_size), Some(max_file_size)) => Some(max_cache_size.min(max_file_size)),
172        }
173    }
174
175    /// Whether the given size, in bytes, exceeds the computed maximum
176    /// authorized size of a single media content.
177    ///
178    /// # Arguments
179    ///
180    /// - `size` - The size of the media content to check, in bytes.
181    pub fn exceeds_max_file_size(&self, size: u64) -> bool {
182        self.computed_max_file_size().is_some_and(|max_size| size > max_size)
183    }
184
185    /// Whether a content whose last access was at the given time has expired.
186    ///
187    /// # Arguments
188    ///
189    /// - `current_time` - The current time.
190    /// - `last_access_time` - The time when the media content to check was last
191    ///   accessed.
192    pub fn has_content_expired(
193        &self,
194        current_time: SystemTime,
195        last_access_time: SystemTime,
196    ) -> bool {
197        self.last_access_expiry.is_some_and(|max_duration| {
198            current_time
199                .duration_since(last_access_time)
200                // If this returns an error, the last access time is newer than
201                // the current time. This shouldn't happen but in this case the
202                // content cannot be expired.
203                .is_ok_and(|elapsed| elapsed >= max_duration)
204        })
205    }
206
207    /// Whether an automatic media cache cleanup should be triggered given the
208    /// time of the last cleanup.
209    ///
210    /// # Arguments
211    ///
212    /// - `current_time` - The current time.
213    /// - `last_cleanup_time` - The time of the last media cache cleanup.
214    pub fn should_clean_up(&self, current_time: SystemTime, last_cleanup_time: SystemTime) -> bool {
215        self.cleanup_frequency.is_some_and(|max_duration| {
216            current_time
217                .duration_since(last_cleanup_time)
218                // If this returns an error, the last cleanup time is newer than
219                // the current time. This shouldn't happen but in this case no
220                // cleanup job is needed.
221                .is_ok_and(|elapsed| elapsed >= max_duration)
222        })
223    }
224}
225
226impl Default for MediaRetentionPolicy {
227    fn default() -> Self {
228        Self {
229            // 400 MiB.
230            max_cache_size: Some(400 * 1024 * 1024),
231            // 20 MiB.
232            max_file_size: Some(20 * 1024 * 1024),
233            // 60 days.
234            last_access_expiry: Some(Duration::from_secs(60 * 24 * 60 * 60)),
235            // 1 day.
236            cleanup_frequency: Some(Duration::from_secs(24 * 60 * 60)),
237        }
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use ruma::time::{Duration, SystemTime};
244
245    use super::MediaRetentionPolicy;
246
247    #[test]
248    fn test_media_retention_policy_has_limitations() {
249        let mut policy = MediaRetentionPolicy::empty();
250        assert!(!policy.has_limitations());
251
252        policy = policy.with_last_access_expiry(Some(Duration::from_secs(60)));
253        assert!(policy.has_limitations());
254
255        policy = policy.with_last_access_expiry(None);
256        assert!(!policy.has_limitations());
257
258        policy = policy.with_max_cache_size(Some(1_024));
259        assert!(policy.has_limitations());
260
261        policy = policy.with_max_cache_size(None);
262        assert!(!policy.has_limitations());
263
264        policy = policy.with_max_file_size(Some(1_024));
265        assert!(policy.has_limitations());
266
267        policy = policy.with_max_file_size(None);
268        assert!(!policy.has_limitations());
269
270        // With default values.
271        assert!(MediaRetentionPolicy::new().has_limitations());
272    }
273
274    #[test]
275    fn test_media_retention_policy_max_cache_size() {
276        let file_size = 2_048;
277
278        let mut policy = MediaRetentionPolicy::empty();
279        assert!(!policy.exceeds_max_cache_size(file_size));
280        assert_eq!(policy.computed_max_file_size(), None);
281        assert!(!policy.exceeds_max_file_size(file_size));
282
283        policy = policy.with_max_cache_size(Some(4_096));
284        assert!(!policy.exceeds_max_cache_size(file_size));
285        assert_eq!(policy.computed_max_file_size(), Some(4_096));
286        assert!(!policy.exceeds_max_file_size(file_size));
287
288        policy = policy.with_max_cache_size(Some(2_048));
289        assert!(!policy.exceeds_max_cache_size(file_size));
290        assert_eq!(policy.computed_max_file_size(), Some(2_048));
291        assert!(!policy.exceeds_max_file_size(file_size));
292
293        policy = policy.with_max_cache_size(Some(1_024));
294        assert!(policy.exceeds_max_cache_size(file_size));
295        assert_eq!(policy.computed_max_file_size(), Some(1_024));
296        assert!(policy.exceeds_max_file_size(file_size));
297    }
298
299    #[test]
300    fn test_media_retention_policy_max_file_size() {
301        let file_size = 2_048;
302
303        let mut policy = MediaRetentionPolicy::empty();
304        assert_eq!(policy.computed_max_file_size(), None);
305        assert!(!policy.exceeds_max_file_size(file_size));
306
307        // With max_file_size only.
308        policy = policy.with_max_file_size(Some(4_096));
309        assert_eq!(policy.computed_max_file_size(), Some(4_096));
310        assert!(!policy.exceeds_max_file_size(file_size));
311
312        policy = policy.with_max_file_size(Some(2_048));
313        assert_eq!(policy.computed_max_file_size(), Some(2_048));
314        assert!(!policy.exceeds_max_file_size(file_size));
315
316        policy = policy.with_max_file_size(Some(1_024));
317        assert_eq!(policy.computed_max_file_size(), Some(1_024));
318        assert!(policy.exceeds_max_file_size(file_size));
319
320        // With max_cache_size as well.
321        policy = policy.with_max_cache_size(Some(2_048));
322        assert_eq!(policy.computed_max_file_size(), Some(1_024));
323        assert!(policy.exceeds_max_file_size(file_size));
324
325        policy = policy.with_max_file_size(Some(2_048));
326        assert_eq!(policy.computed_max_file_size(), Some(2_048));
327        assert!(!policy.exceeds_max_file_size(file_size));
328
329        policy = policy.with_max_file_size(Some(4_096));
330        assert_eq!(policy.computed_max_file_size(), Some(2_048));
331        assert!(!policy.exceeds_max_file_size(file_size));
332
333        policy = policy.with_max_cache_size(Some(1_024));
334        assert_eq!(policy.computed_max_file_size(), Some(1_024));
335        assert!(policy.exceeds_max_file_size(file_size));
336    }
337
338    #[test]
339    fn test_media_retention_policy_has_content_expired() {
340        let epoch = SystemTime::UNIX_EPOCH;
341        let last_access_time = epoch + Duration::from_secs(30);
342        let epoch_plus_60 = epoch + Duration::from_secs(60);
343        let epoch_plus_120 = epoch + Duration::from_secs(120);
344
345        let mut policy = MediaRetentionPolicy::empty();
346        assert!(!policy.has_content_expired(epoch, last_access_time));
347        assert!(!policy.has_content_expired(last_access_time, last_access_time));
348        assert!(!policy.has_content_expired(epoch_plus_60, last_access_time));
349        assert!(!policy.has_content_expired(epoch_plus_120, last_access_time));
350
351        policy = policy.with_last_access_expiry(Some(Duration::from_secs(120)));
352        assert!(!policy.has_content_expired(epoch, last_access_time));
353        assert!(!policy.has_content_expired(last_access_time, last_access_time));
354        assert!(!policy.has_content_expired(epoch_plus_60, last_access_time));
355        assert!(!policy.has_content_expired(epoch_plus_120, last_access_time));
356
357        policy = policy.with_last_access_expiry(Some(Duration::from_secs(60)));
358        assert!(!policy.has_content_expired(epoch, last_access_time));
359        assert!(!policy.has_content_expired(last_access_time, last_access_time));
360        assert!(!policy.has_content_expired(epoch_plus_60, last_access_time));
361        assert!(policy.has_content_expired(epoch_plus_120, last_access_time));
362
363        policy = policy.with_last_access_expiry(Some(Duration::from_secs(30)));
364        assert!(!policy.has_content_expired(epoch, last_access_time));
365        assert!(!policy.has_content_expired(last_access_time, last_access_time));
366        assert!(policy.has_content_expired(epoch_plus_60, last_access_time));
367        assert!(policy.has_content_expired(epoch_plus_120, last_access_time));
368
369        policy = policy.with_last_access_expiry(Some(Duration::from_secs(0)));
370        assert!(!policy.has_content_expired(epoch, last_access_time));
371        assert!(policy.has_content_expired(last_access_time, last_access_time));
372        assert!(policy.has_content_expired(epoch_plus_60, last_access_time));
373        assert!(policy.has_content_expired(epoch_plus_120, last_access_time));
374    }
375
376    #[test]
377    fn test_media_retention_policy_cleanup_frequency() {
378        let epoch = SystemTime::UNIX_EPOCH;
379        let epoch_plus_60 = epoch + Duration::from_secs(60);
380        let epoch_plus_120 = epoch + Duration::from_secs(120);
381
382        let mut policy = MediaRetentionPolicy::empty();
383        assert!(!policy.should_clean_up(epoch_plus_60, epoch));
384        assert!(!policy.should_clean_up(epoch_plus_60, epoch_plus_60));
385        assert!(!policy.should_clean_up(epoch_plus_60, epoch_plus_120));
386
387        policy = policy.with_cleanup_frequency(Some(Duration::from_secs(0)));
388        assert!(policy.should_clean_up(epoch_plus_60, epoch));
389        assert!(policy.should_clean_up(epoch_plus_60, epoch_plus_60));
390        assert!(!policy.should_clean_up(epoch_plus_60, epoch_plus_120));
391
392        policy = policy.with_cleanup_frequency(Some(Duration::from_secs(30)));
393        assert!(policy.should_clean_up(epoch_plus_60, epoch));
394        assert!(!policy.should_clean_up(epoch_plus_60, epoch_plus_60));
395        assert!(!policy.should_clean_up(epoch_plus_60, epoch_plus_120));
396
397        policy = policy.with_cleanup_frequency(Some(Duration::from_secs(60)));
398        assert!(policy.should_clean_up(epoch_plus_60, epoch));
399        assert!(!policy.should_clean_up(epoch_plus_60, epoch_plus_60));
400        assert!(!policy.should_clean_up(epoch_plus_60, epoch_plus_120));
401
402        policy = policy.with_cleanup_frequency(Some(Duration::from_secs(90)));
403        assert!(!policy.should_clean_up(epoch_plus_60, epoch));
404        assert!(!policy.should_clean_up(epoch_plus_60, epoch_plus_60));
405        assert!(!policy.should_clean_up(epoch_plus_60, epoch_plus_120));
406    }
407}