matrix_sdk_base/event_cache/store/media/
media_retention_policy.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
// Copyright 2025 Kévin Commaille
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! Configuration to decide whether or not to keep media in the cache, allowing
//! to do periodic cleanups to avoid to have the size of the media cache grow
//! indefinitely.
//!
//! To proceed to a cleanup, first set the [`MediaRetentionPolicy`] to use with
//! [`EventCacheStore::set_media_retention_policy()`]. Then call
//! [`EventCacheStore::clean_up_media_cache()`].
//!
//! In the future, other settings will allow to run automatic periodic cleanup
//! jobs.
//!
//! [`EventCacheStore::set_media_retention_policy()`]: crate::event_cache::store::EventCacheStore::set_media_retention_policy
//! [`EventCacheStore::clean_up_media_cache()`]: crate::event_cache::store::EventCacheStore::clean_up_media_cache

use ruma::time::{Duration, SystemTime};
use serde::{Deserialize, Serialize};

/// The retention policy for media content used by the [`EventCacheStore`].
///
/// [`EventCacheStore`]: crate::event_cache::store::EventCacheStore
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct MediaRetentionPolicy {
    /// The maximum authorized size of the overall media cache, in bytes.
    ///
    /// The cache size is defined as the sum of the sizes of all the (possibly
    /// encrypted) media contents in the cache, excluding any metadata
    /// associated with them.
    ///
    /// If this is set and the cache size is bigger than this value, the oldest
    /// media contents in the cache will be removed during a cleanup until the
    /// cache size is below this threshold.
    ///
    /// Note that it is possible for the cache size to temporarily exceed this
    /// value between two cleanups.
    ///
    /// Defaults to 400 MiB.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_cache_size: Option<usize>,

    /// The maximum authorized size of a single media content, in bytes.
    ///
    /// The size of a media content is the size taken by the content in the
    /// database, after it was possibly encrypted, so it might differ from the
    /// initial size of the content.
    ///
    /// The maximum authorized size of a single media content is actually the
    /// lowest value between `max_cache_size` and `max_file_size`.
    ///
    /// If it is set, media content bigger than the maximum size will not be
    /// cached. If the maximum size changed after media content that exceeds the
    /// new value was cached, the corresponding content will be removed
    /// during a cleanup.
    ///
    /// Defaults to 20 MiB.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_file_size: Option<usize>,

    /// The duration after which unaccessed media content is considered
    /// expired.
    ///
    /// If this is set, media content whose last access is older than this
    /// duration will be removed from the media cache during a cleanup.
    ///
    /// Defaults to 60 days.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_access_expiry: Option<Duration>,
}

impl MediaRetentionPolicy {
    /// Create a [`MediaRetentionPolicy`] with the default values.
    pub fn new() -> Self {
        Self::default()
    }

    /// Create an empty [`MediaRetentionPolicy`].
    ///
    /// This means that all media will be cached and cleanups have no effect.
    pub fn empty() -> Self {
        Self { max_cache_size: None, max_file_size: None, last_access_expiry: None }
    }

    /// Set the maximum authorized size of the overall media cache, in bytes.
    pub fn with_max_cache_size(mut self, size: Option<usize>) -> Self {
        self.max_cache_size = size;
        self
    }

    /// Set the maximum authorized size of a single media content, in bytes.
    pub fn with_max_file_size(mut self, size: Option<usize>) -> Self {
        self.max_file_size = size;
        self
    }

    /// Set the duration before which unaccessed media content is considered
    /// expired.
    pub fn with_last_access_expiry(mut self, duration: Option<Duration>) -> Self {
        self.last_access_expiry = duration;
        self
    }

    /// Whether this policy has limitations.
    ///
    /// If this policy has no limitations, a cleanup job would have no effect.
    ///
    /// Returns `true` if at least one limitation is set.
    pub fn has_limitations(&self) -> bool {
        self.max_cache_size.is_some()
            || self.max_file_size.is_some()
            || self.last_access_expiry.is_some()
    }

    /// Whether the given size exceeds the maximum authorized size of the media
    /// cache.
    ///
    /// # Arguments
    ///
    /// * `size` - The overall size of the media cache to check, in bytes.
    pub fn exceeds_max_cache_size(&self, size: usize) -> bool {
        self.max_cache_size.is_some_and(|max_size| size > max_size)
    }

    /// The computed maximum authorized size of a single media content, in
    /// bytes.
    ///
    /// This is the lowest value between `max_cache_size` and `max_file_size`.
    pub fn computed_max_file_size(&self) -> Option<usize> {
        match (self.max_cache_size, self.max_file_size) {
            (None, None) => None,
            (None, Some(size)) => Some(size),
            (Some(size), None) => Some(size),
            (Some(max_cache_size), Some(max_file_size)) => Some(max_cache_size.min(max_file_size)),
        }
    }

    /// Whether the given size, in bytes, exceeds the computed maximum
    /// authorized size of a single media content.
    ///
    /// # Arguments
    ///
    /// * `size` - The size of the media content to check, in bytes.
    pub fn exceeds_max_file_size(&self, size: usize) -> bool {
        self.computed_max_file_size().is_some_and(|max_size| size > max_size)
    }

    /// Whether a content whose last access was at the given time has expired.
    ///
    /// # Arguments
    ///
    /// * `current_time` - The current time.
    ///
    /// * `last_access_time` - The time when the media content to check was last
    ///   accessed.
    pub fn has_content_expired(
        &self,
        current_time: SystemTime,
        last_access_time: SystemTime,
    ) -> bool {
        self.last_access_expiry.is_some_and(|max_duration| {
            current_time
                .duration_since(last_access_time)
                // If this returns an error, the last access time is newer than the current time.
                // This shouldn't happen but in this case the content cannot be expired.
                .is_ok_and(|elapsed| elapsed >= max_duration)
        })
    }
}

impl Default for MediaRetentionPolicy {
    fn default() -> Self {
        Self {
            // 400 MiB.
            max_cache_size: Some(400 * 1024 * 1024),
            // 20 MiB.
            max_file_size: Some(20 * 1024 * 1024),
            // 60 days.
            last_access_expiry: Some(Duration::from_secs(60 * 24 * 60 * 60)),
        }
    }
}

#[cfg(test)]
mod tests {
    use ruma::time::{Duration, SystemTime};

    use super::MediaRetentionPolicy;

    #[test]
    fn test_media_retention_policy_has_limitations() {
        let mut policy = MediaRetentionPolicy::empty();
        assert!(!policy.has_limitations());

        policy = policy.with_last_access_expiry(Some(Duration::from_secs(60)));
        assert!(policy.has_limitations());

        policy = policy.with_last_access_expiry(None);
        assert!(!policy.has_limitations());

        policy = policy.with_max_cache_size(Some(1_024));
        assert!(policy.has_limitations());

        policy = policy.with_max_cache_size(None);
        assert!(!policy.has_limitations());

        policy = policy.with_max_file_size(Some(1_024));
        assert!(policy.has_limitations());

        policy = policy.with_max_file_size(None);
        assert!(!policy.has_limitations());

        // With default values.
        assert!(MediaRetentionPolicy::new().has_limitations());
    }

    #[test]
    fn test_media_retention_policy_max_cache_size() {
        let file_size = 2_048;

        let mut policy = MediaRetentionPolicy::empty();
        assert!(!policy.exceeds_max_cache_size(file_size));
        assert_eq!(policy.computed_max_file_size(), None);
        assert!(!policy.exceeds_max_file_size(file_size));

        policy = policy.with_max_cache_size(Some(4_096));
        assert!(!policy.exceeds_max_cache_size(file_size));
        assert_eq!(policy.computed_max_file_size(), Some(4_096));
        assert!(!policy.exceeds_max_file_size(file_size));

        policy = policy.with_max_cache_size(Some(2_048));
        assert!(!policy.exceeds_max_cache_size(file_size));
        assert_eq!(policy.computed_max_file_size(), Some(2_048));
        assert!(!policy.exceeds_max_file_size(file_size));

        policy = policy.with_max_cache_size(Some(1_024));
        assert!(policy.exceeds_max_cache_size(file_size));
        assert_eq!(policy.computed_max_file_size(), Some(1_024));
        assert!(policy.exceeds_max_file_size(file_size));
    }

    #[test]
    fn test_media_retention_policy_max_file_size() {
        let file_size = 2_048;

        let mut policy = MediaRetentionPolicy::empty();
        assert_eq!(policy.computed_max_file_size(), None);
        assert!(!policy.exceeds_max_file_size(file_size));

        // With max_file_size only.
        policy = policy.with_max_file_size(Some(4_096));
        assert_eq!(policy.computed_max_file_size(), Some(4_096));
        assert!(!policy.exceeds_max_file_size(file_size));

        policy = policy.with_max_file_size(Some(2_048));
        assert_eq!(policy.computed_max_file_size(), Some(2_048));
        assert!(!policy.exceeds_max_file_size(file_size));

        policy = policy.with_max_file_size(Some(1_024));
        assert_eq!(policy.computed_max_file_size(), Some(1_024));
        assert!(policy.exceeds_max_file_size(file_size));

        // With max_cache_size as well.
        policy = policy.with_max_cache_size(Some(2_048));
        assert_eq!(policy.computed_max_file_size(), Some(1_024));
        assert!(policy.exceeds_max_file_size(file_size));

        policy = policy.with_max_file_size(Some(2_048));
        assert_eq!(policy.computed_max_file_size(), Some(2_048));
        assert!(!policy.exceeds_max_file_size(file_size));

        policy = policy.with_max_file_size(Some(4_096));
        assert_eq!(policy.computed_max_file_size(), Some(2_048));
        assert!(!policy.exceeds_max_file_size(file_size));

        policy = policy.with_max_cache_size(Some(1_024));
        assert_eq!(policy.computed_max_file_size(), Some(1_024));
        assert!(policy.exceeds_max_file_size(file_size));
    }

    #[test]
    fn test_media_retention_policy_has_content_expired() {
        let epoch = SystemTime::UNIX_EPOCH;
        let last_access_time = epoch + Duration::from_secs(30);
        let epoch_plus_60 = epoch + Duration::from_secs(60);
        let epoch_plus_120 = epoch + Duration::from_secs(120);

        let mut policy = MediaRetentionPolicy::empty();
        assert!(!policy.has_content_expired(epoch, last_access_time));
        assert!(!policy.has_content_expired(last_access_time, last_access_time));
        assert!(!policy.has_content_expired(epoch_plus_60, last_access_time));
        assert!(!policy.has_content_expired(epoch_plus_120, last_access_time));

        policy = policy.with_last_access_expiry(Some(Duration::from_secs(120)));
        assert!(!policy.has_content_expired(epoch, last_access_time));
        assert!(!policy.has_content_expired(last_access_time, last_access_time));
        assert!(!policy.has_content_expired(epoch_plus_60, last_access_time));
        assert!(!policy.has_content_expired(epoch_plus_120, last_access_time));

        policy = policy.with_last_access_expiry(Some(Duration::from_secs(60)));
        assert!(!policy.has_content_expired(epoch, last_access_time));
        assert!(!policy.has_content_expired(last_access_time, last_access_time));
        assert!(!policy.has_content_expired(epoch_plus_60, last_access_time));
        assert!(policy.has_content_expired(epoch_plus_120, last_access_time));

        policy = policy.with_last_access_expiry(Some(Duration::from_secs(30)));
        assert!(!policy.has_content_expired(epoch, last_access_time));
        assert!(!policy.has_content_expired(last_access_time, last_access_time));
        assert!(policy.has_content_expired(epoch_plus_60, last_access_time));
        assert!(policy.has_content_expired(epoch_plus_120, last_access_time));

        policy = policy.with_last_access_expiry(Some(Duration::from_secs(0)));
        assert!(!policy.has_content_expired(epoch, last_access_time));
        assert!(policy.has_content_expired(last_access_time, last_access_time));
        assert!(policy.has_content_expired(epoch_plus_60, last_access_time));
        assert!(policy.has_content_expired(epoch_plus_120, last_access_time));
    }
}