diff --git a/crates/matrix-sdk-base/CHANGELOG.md b/crates/matrix-sdk-base/CHANGELOG.md index 6716adf5e44..7182f9acc98 100644 --- a/crates/matrix-sdk-base/CHANGELOG.md +++ b/crates/matrix-sdk-base/CHANGELOG.md @@ -6,15 +6,30 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - ReleaseDate -### Breaking changes +### Features -- Replaced `Room::compute_display_name` with the reintroduced `Room::display_name()`. The new - method computes a display name, or return a cached value from the previous successful computation. - If you need a sync variant, consider using `Room::cached_display_name()`. +- [**breaking**] `EventCacheStore` allows to control which media content is + allowed in the media cache, and how long it should be kept, with a + `MediaRetentionPolicy`: + - `EventCacheStore::add_media_content()` has an extra argument, + `ignore_policy`, which decides whether a media content should ignore the + `MediaRetentionPolicy`. It should be stored alongside the media content. + - `EventCacheStore` has four new methods: `media_retention_policy()`, + `set_media_retention_policy()`, `set_ignore_media_retention_policy()` and + `clean_up_media_cache()`. + - `EventCacheStore` implementations should delegate media cache methods to the + methods of the same name of `MediaService` to use the `MediaRetentionPolicy`. + They need to implement the `EventCacheStoreMedia` trait that can be tested + with the `event_cache_store_media_integration_tests!` macro. + ([#4571](https://github.com/matrix-org/matrix-rust-sdk/pull/4571)) -### Features +### Refactor -### Bug Fixes +- [**breaking**] Replaced `Room::compute_display_name` with the reintroduced + `Room::display_name()`. The new method computes a display name, or return a + cached value from the previous successful computation. If you need a sync + variant, consider using `Room::cached_display_name()`. + ([#4470](https://github.com/matrix-org/matrix-rust-sdk/pull/4470)) ## [0.9.0] - 2024-12-18 diff --git a/crates/matrix-sdk-base/src/event_cache/store/integration_tests.rs b/crates/matrix-sdk-base/src/event_cache/store/integration_tests.rs index 83ab9b3eda1..b710b3b6dc6 100644 --- a/crates/matrix-sdk-base/src/event_cache/store/integration_tests.rs +++ b/crates/matrix-sdk-base/src/event_cache/store/integration_tests.rs @@ -32,7 +32,7 @@ use ruma::{ push::Action, room_id, uint, RoomId, }; -use super::DynEventCacheStore; +use super::{media::IgnoreMediaRetentionPolicy, DynEventCacheStore}; use crate::{ event_cache::{Event, Gap}, media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings}, @@ -168,7 +168,9 @@ impl EventCacheStoreIntegrationTests for DynEventCacheStore { ); // Let's add the media. - self.add_media_content(&request_file, content.clone()).await.expect("adding media failed"); + self.add_media_content(&request_file, content.clone(), IgnoreMediaRetentionPolicy::No) + .await + .expect("adding media failed"); // Media is present in the cache. assert_eq!( @@ -196,7 +198,7 @@ impl EventCacheStoreIntegrationTests for DynEventCacheStore { ); // Let's add the media again. - self.add_media_content(&request_file, content.clone()) + self.add_media_content(&request_file, content.clone(), IgnoreMediaRetentionPolicy::No) .await .expect("adding media again failed"); @@ -207,9 +209,13 @@ impl EventCacheStoreIntegrationTests for DynEventCacheStore { ); // Let's add the thumbnail media. - self.add_media_content(&request_thumbnail, thumbnail_content.clone()) - .await - .expect("adding thumbnail failed"); + self.add_media_content( + &request_thumbnail, + thumbnail_content.clone(), + IgnoreMediaRetentionPolicy::No, + ) + .await + .expect("adding thumbnail failed"); // Media's thumbnail is present. assert_eq!( @@ -225,9 +231,13 @@ impl EventCacheStoreIntegrationTests for DynEventCacheStore { ); // Let's add another media with a different URI. - self.add_media_content(&request_other_file, other_content.clone()) - .await - .expect("adding other media failed"); + self.add_media_content( + &request_other_file, + other_content.clone(), + IgnoreMediaRetentionPolicy::No, + ) + .await + .expect("adding other media failed"); // Other file is present. assert_eq!( @@ -279,7 +289,9 @@ impl EventCacheStoreIntegrationTests for DynEventCacheStore { assert!(self.get_media_content(&req).await.unwrap().is_none(), "unexpected media found"); // Add the media. - self.add_media_content(&req, content.clone()).await.expect("adding media failed"); + self.add_media_content(&req, content.clone(), IgnoreMediaRetentionPolicy::No) + .await + .expect("adding media failed"); // Sanity-check: media is found after adding it. assert_eq!(self.get_media_content(&req).await.unwrap().unwrap(), b"hello"); diff --git a/crates/matrix-sdk-base/src/event_cache/store/media/integration_tests.rs b/crates/matrix-sdk-base/src/event_cache/store/media/integration_tests.rs new file mode 100644 index 00000000000..647c0096d4b --- /dev/null +++ b/crates/matrix-sdk-base/src/event_cache/store/media/integration_tests.rs @@ -0,0 +1,1035 @@ +// 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. + +//! Trait and macro of integration tests for `EventCacheStoreMedia` +//! implementations. + +use async_trait::async_trait; +use ruma::{ + events::room::MediaSource, + mxc_uri, owned_mxc_uri, + time::{Duration, SystemTime}, +}; + +use super::{ + media_service::IgnoreMediaRetentionPolicy, EventCacheStoreMedia, MediaRetentionPolicy, +}; +use crate::media::{MediaFormat, MediaRequestParameters}; + +/// [`EventCacheStoreMedia`] integration tests. +/// +/// This trait is not meant to be used directly, but will be used with the +/// [`event_cache_store_media_integration_tests!`] macro. +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait EventCacheStoreMediaIntegrationTests { + /// Test media retention policy storage. + async fn test_store_media_retention_policy(&self); + + /// Test media content's retention policy max file size. + async fn test_media_max_file_size(&self); + + /// Test media content's retention policy max cache size. + async fn test_media_max_cache_size(&self); + + /// Test media content's retention policy expiry. + async fn test_media_expiry(&self); + + /// Test [`IgnoreMediaRetentionPolicy`] with the media content's retention + /// policy max sizes. + async fn test_media_ignore_max_size(&self); + + /// Test [`IgnoreMediaRetentionPolicy`] with the media content's retention + /// policy expiry. + async fn test_media_ignore_expiry(&self); +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl EventCacheStoreMediaIntegrationTests for Store +where + Store: EventCacheStoreMedia + std::fmt::Debug, +{ + async fn test_store_media_retention_policy(&self) { + let stored = self.media_retention_policy_inner().await.unwrap(); + assert!(stored.is_none()); + + let policy = MediaRetentionPolicy::default(); + self.set_media_retention_policy_inner(policy).await.unwrap(); + + let stored = self.media_retention_policy_inner().await.unwrap(); + assert_eq!(stored, Some(policy)); + } + + async fn test_media_max_file_size(&self) { + let time = SystemTime::now(); + + // 256 bytes content. + let content_big = vec![0; 256]; + let uri_big = owned_mxc_uri!("mxc://localhost/big-media"); + let request_big = MediaRequestParameters { + source: MediaSource::Plain(uri_big), + format: MediaFormat::File, + }; + + // 128 bytes content. + let content_avg = vec![0; 128]; + let uri_avg = owned_mxc_uri!("mxc://localhost/average-media"); + let request_avg = MediaRequestParameters { + source: MediaSource::Plain(uri_avg), + format: MediaFormat::File, + }; + + // 64 bytes content. + let content_small = vec![0; 64]; + let uri_small = owned_mxc_uri!("mxc://localhost/small-media"); + let request_small = MediaRequestParameters { + source: MediaSource::Plain(uri_small), + format: MediaFormat::File, + }; + + // First, with a policy that doesn't accept the big media. + let policy = MediaRetentionPolicy::empty().with_max_file_size(Some(200)); + + self.add_media_content_inner( + &request_big, + content_big.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + self.add_media_content_inner( + &request_avg, + content_avg.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + self.add_media_content_inner( + &request_small, + content_small, + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + + // The big content was NOT cached but the others were. + let stored = self.get_media_content_inner(&request_big, time).await.unwrap(); + assert!(stored.is_none()); + let stored = self.get_media_content_inner(&request_avg, time).await.unwrap(); + assert!(stored.is_some()); + let stored = self.get_media_content_inner(&request_small, time).await.unwrap(); + assert!(stored.is_some()); + + // A cleanup doesn't have any effect. + self.clean_up_media_cache_inner(policy, time).await.unwrap(); + + let stored = self.get_media_content_inner(&request_avg, time).await.unwrap(); + assert!(stored.is_some()); + let stored = self.get_media_content_inner(&request_small, time).await.unwrap(); + assert!(stored.is_some()); + + // Change to a policy that doesn't accept the average media. + let policy = MediaRetentionPolicy::empty().with_max_file_size(Some(100)); + + // The cleanup removes the average media. + self.clean_up_media_cache_inner(policy, time).await.unwrap(); + + let stored = self.get_media_content_inner(&request_avg, time).await.unwrap(); + assert!(stored.is_none()); + let stored = self.get_media_content_inner(&request_small, time).await.unwrap(); + assert!(stored.is_some()); + + // Caching big and average media doesn't work. + self.add_media_content_inner( + &request_big, + content_big.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + self.add_media_content_inner( + &request_avg, + content_avg.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + + let stored = self.get_media_content_inner(&request_big, time).await.unwrap(); + assert!(stored.is_none()); + let stored = self.get_media_content_inner(&request_avg, time).await.unwrap(); + assert!(stored.is_none()); + + // If there are both a cache size and a file size, the minimum value is used. + let policy = MediaRetentionPolicy::empty() + .with_max_cache_size(Some(200)) + .with_max_file_size(Some(1000)); + + // Caching big doesn't work. + self.add_media_content_inner( + &request_big, + content_big.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + self.add_media_content_inner( + &request_avg, + content_avg.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + + let stored = self.get_media_content_inner(&request_big, time).await.unwrap(); + assert!(stored.is_none()); + let stored = self.get_media_content_inner(&request_avg, time).await.unwrap(); + assert!(stored.is_some()); + + // Change to a policy that doesn't accept the average media. + let policy = MediaRetentionPolicy::empty() + .with_max_cache_size(Some(100)) + .with_max_file_size(Some(1000)); + + // The cleanup removes the average media. + self.clean_up_media_cache_inner(policy, time).await.unwrap(); + + let stored = self.get_media_content_inner(&request_avg, time).await.unwrap(); + assert!(stored.is_none()); + let stored = self.get_media_content_inner(&request_small, time).await.unwrap(); + assert!(stored.is_some()); + + // Caching big and average media doesn't work. + self.add_media_content_inner( + &request_big, + content_big, + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + self.add_media_content_inner( + &request_avg, + content_avg, + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + + let stored = self.get_media_content_inner(&request_big, time).await.unwrap(); + assert!(stored.is_none()); + let stored = self.get_media_content_inner(&request_avg, time).await.unwrap(); + assert!(stored.is_none()); + } + + async fn test_media_max_cache_size(&self) { + // 256 bytes content. + let content_big = vec![0; 256]; + let uri_big = owned_mxc_uri!("mxc://localhost/big-media"); + let request_big = MediaRequestParameters { + source: MediaSource::Plain(uri_big), + format: MediaFormat::File, + }; + + // 128 bytes content. + let content_avg = vec![0; 128]; + let uri_avg = mxc_uri!("mxc://localhost/average-media"); + let request_avg = MediaRequestParameters { + source: MediaSource::Plain(uri_avg.to_owned()), + format: MediaFormat::File, + }; + + // 64 bytes content. + let content_small = vec![0; 64]; + let uri_small_1 = owned_mxc_uri!("mxc://localhost/small-media-1"); + let request_small_1 = MediaRequestParameters { + source: MediaSource::Plain(uri_small_1), + format: MediaFormat::File, + }; + let uri_small_2 = owned_mxc_uri!("mxc://localhost/small-media-2"); + let request_small_2 = MediaRequestParameters { + source: MediaSource::Plain(uri_small_2), + format: MediaFormat::File, + }; + let uri_small_3 = owned_mxc_uri!("mxc://localhost/small-media-3"); + let request_small_3 = MediaRequestParameters { + source: MediaSource::Plain(uri_small_3), + format: MediaFormat::File, + }; + let uri_small_4 = owned_mxc_uri!("mxc://localhost/small-media-4"); + let request_small_4 = MediaRequestParameters { + source: MediaSource::Plain(uri_small_4), + format: MediaFormat::File, + }; + let uri_small_5 = owned_mxc_uri!("mxc://localhost/small-media-5"); + let request_small_5 = MediaRequestParameters { + source: MediaSource::Plain(uri_small_5), + format: MediaFormat::File, + }; + + // A policy that doesn't accept the big media. + let policy = MediaRetentionPolicy::empty().with_max_cache_size(Some(200)); + + // Try to add all the content at different times. + let mut time = SystemTime::UNIX_EPOCH; + self.add_media_content_inner( + &request_big, + content_big, + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + time += Duration::from_secs(1); + self.add_media_content_inner( + &request_small_1, + content_small.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + time += Duration::from_secs(1); + self.add_media_content_inner( + &request_small_2, + content_small.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + time += Duration::from_secs(1); + self.add_media_content_inner( + &request_small_3, + content_small.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + time += Duration::from_secs(1); + self.add_media_content_inner( + &request_small_4, + content_small.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + time += Duration::from_secs(1); + self.add_media_content_inner( + &request_small_5, + content_small.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + time += Duration::from_secs(1); + self.add_media_content_inner( + &request_avg, + content_avg, + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + + // The big content was NOT cached but the others were. + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_big, time).await.unwrap(); + assert!(stored.is_none()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small_1, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small_2, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small_3, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small_4, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small_5, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_for_uri_inner(uri_avg, time).await.unwrap(); + assert!(stored.is_some()); + + // Cleanup removes the oldest content first. + time += Duration::from_secs(1); + self.clean_up_media_cache_inner(policy, time).await.unwrap(); + + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small_1, time).await.unwrap(); + assert!(stored.is_none()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small_2, time).await.unwrap(); + assert!(stored.is_none()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small_3, time).await.unwrap(); + assert!(stored.is_none()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small_4, time).await.unwrap(); + assert!(stored.is_none()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small_5, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_avg, time).await.unwrap(); + assert!(stored.is_some()); + + // Reinsert the small medias that were removed. + time += Duration::from_secs(1); + self.add_media_content_inner( + &request_small_1, + content_small.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + time += Duration::from_secs(1); + self.add_media_content_inner( + &request_small_2, + content_small.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + time += Duration::from_secs(1); + self.add_media_content_inner( + &request_small_3, + content_small.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + time += Duration::from_secs(1); + self.add_media_content_inner( + &request_small_4, + content_small, + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + + // Check that they are cached. + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small_1, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small_2, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small_3, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small_4, time).await.unwrap(); + assert!(stored.is_some()); + + // Access small_5 too so its last access is updated too. + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small_5, time).await.unwrap(); + assert!(stored.is_some()); + + // Cleanup still removes the oldest content first, which is not the same as + // before. + time += Duration::from_secs(1); + tracing::info!(?self, "before"); + self.clean_up_media_cache_inner(policy, time).await.unwrap(); + tracing::info!(?self, "after"); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small_1, time).await.unwrap(); + assert!(stored.is_none()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small_2, time).await.unwrap(); + assert!(stored.is_none()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small_3, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small_4, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small_5, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_avg, time).await.unwrap(); + assert!(stored.is_none()); + } + + async fn test_media_expiry(&self) { + // 64 bytes content. + let content = vec![0; 64]; + + let uri_1 = owned_mxc_uri!("mxc://localhost/media-1"); + let request_1 = + MediaRequestParameters { source: MediaSource::Plain(uri_1), format: MediaFormat::File }; + let uri_2 = owned_mxc_uri!("mxc://localhost/media-2"); + let request_2 = + MediaRequestParameters { source: MediaSource::Plain(uri_2), format: MediaFormat::File }; + let uri_3 = owned_mxc_uri!("mxc://localhost/media-3"); + let request_3 = + MediaRequestParameters { source: MediaSource::Plain(uri_3), format: MediaFormat::File }; + let uri_4 = owned_mxc_uri!("mxc://localhost/media-4"); + let request_4 = + MediaRequestParameters { source: MediaSource::Plain(uri_4), format: MediaFormat::File }; + let uri_5 = owned_mxc_uri!("mxc://localhost/media-5"); + let request_5 = + MediaRequestParameters { source: MediaSource::Plain(uri_5), format: MediaFormat::File }; + + // A policy with 30 seconds expiry. + let policy = + MediaRetentionPolicy::empty().with_last_access_expiry(Some(Duration::from_secs(30))); + + // Add all the content at different times. + let mut time = SystemTime::UNIX_EPOCH; + self.add_media_content_inner( + &request_1, + content.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + time += Duration::from_secs(1); + self.add_media_content_inner( + &request_2, + content.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + time += Duration::from_secs(1); + self.add_media_content_inner( + &request_3, + content.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + time += Duration::from_secs(1); + self.add_media_content_inner( + &request_4, + content.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + time += Duration::from_secs(1); + self.add_media_content_inner( + &request_5, + content, + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + + // The content was cached. + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_1, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_2, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_3, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_4, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_5, time).await.unwrap(); + assert!(stored.is_some()); + + // We are now at UNIX_EPOCH + 10 seconds, the oldest content was accessed 5 + // seconds ago. + time += Duration::from_secs(1); + assert_eq!(time, SystemTime::UNIX_EPOCH + Duration::from_secs(10)); + + // Cleanup has no effect, nothing has expired. + self.clean_up_media_cache_inner(policy, time).await.unwrap(); + + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_1, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_2, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_3, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_4, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_5, time).await.unwrap(); + assert!(stored.is_some()); + + // We are now at UNIX_EPOCH + 16 seconds, the oldest content was accessed 5 + // seconds ago. + time += Duration::from_secs(1); + assert_eq!(time, SystemTime::UNIX_EPOCH + Duration::from_secs(16)); + + // Jump 26 seconds in the future, so the 2 first media contents are expired. + time += Duration::from_secs(26); + + // Cleanup removes the two oldest media contents. + self.clean_up_media_cache_inner(policy, time).await.unwrap(); + + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_1, time).await.unwrap(); + assert!(stored.is_none()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_2, time).await.unwrap(); + assert!(stored.is_none()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_3, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_4, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_5, time).await.unwrap(); + assert!(stored.is_some()); + } + + async fn test_media_ignore_max_size(&self) { + // 256 bytes content. + let content_big = vec![0; 256]; + let uri_big = owned_mxc_uri!("mxc://localhost/big-media"); + let request_big = MediaRequestParameters { + source: MediaSource::Plain(uri_big), + format: MediaFormat::File, + }; + + // 128 bytes content. + let content_avg = vec![0; 128]; + let uri_avg = mxc_uri!("mxc://localhost/average-media"); + let request_avg = MediaRequestParameters { + source: MediaSource::Plain(uri_avg.to_owned()), + format: MediaFormat::File, + }; + + // 64 bytes content. + let content_small = vec![0; 64]; + let uri_small = owned_mxc_uri!("mxc://localhost/small-media-1"); + let request_small = MediaRequestParameters { + source: MediaSource::Plain(uri_small), + format: MediaFormat::File, + }; + + // A policy that will result in only one media content in the cache, which is + // the average or small content, depending on the last access time. + let policy = MediaRetentionPolicy::empty().with_max_cache_size(Some(150)); + + // Try to add all the big content without ignoring the policy, it should fail. + let mut time = SystemTime::UNIX_EPOCH; + self.add_media_content_inner( + &request_big, + content_big.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + + let stored = self.get_media_content_inner(&request_big, time).await.unwrap(); + assert!(stored.is_none()); + + // Try to add it again but ignore the policy this time, it should succeed. + time += Duration::from_secs(1); + self.add_media_content_inner( + &request_big, + content_big, + time, + policy, + IgnoreMediaRetentionPolicy::Yes, + ) + .await + .unwrap(); + + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_big, time).await.unwrap(); + assert!(stored.is_some()); + + // Add the other contents. + time += Duration::from_secs(1); + self.add_media_content_inner( + &request_small, + content_small.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + time += Duration::from_secs(1); + self.add_media_content_inner( + &request_avg, + content_avg, + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + + // The other contents were added. + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_for_uri_inner(uri_avg, time).await.unwrap(); + assert!(stored.is_some()); + + // Ignore the average content for now so the max cache size is not reached. + self.set_ignore_media_retention_policy_inner(&request_avg, IgnoreMediaRetentionPolicy::Yes) + .await + .unwrap(); + + // Because the big and average contents are ignored, cleanup has no effect. + time += Duration::from_secs(1); + self.clean_up_media_cache_inner(policy, time).await.unwrap(); + + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_for_uri_inner(uri_avg, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_big, time).await.unwrap(); + assert!(stored.is_some()); + + // Stop ignoring the big media, it should then be cleaned up. + self.set_ignore_media_retention_policy_inner(&request_big, IgnoreMediaRetentionPolicy::No) + .await + .unwrap(); + + time += Duration::from_secs(1); + self.clean_up_media_cache_inner(policy, time).await.unwrap(); + + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_for_uri_inner(uri_avg, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_big, time).await.unwrap(); + assert!(stored.is_none()); + + // Stop ignoring the average media. Since the cache size is bigger than + // the max, the content that was not the last accessed should be cleaned up. + self.set_ignore_media_retention_policy_inner(&request_avg, IgnoreMediaRetentionPolicy::No) + .await + .unwrap(); + + time += Duration::from_secs(1); + self.clean_up_media_cache_inner(policy, time).await.unwrap(); + + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_small, time).await.unwrap(); + assert!(stored.is_none()); + time += Duration::from_secs(1); + let stored = self.get_media_content_for_uri_inner(uri_avg, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_big, time).await.unwrap(); + assert!(stored.is_none()); + } + + async fn test_media_ignore_expiry(&self) { + // 64 bytes content. + let content = vec![0; 64]; + + let uri_1 = owned_mxc_uri!("mxc://localhost/media-1"); + let request_1 = + MediaRequestParameters { source: MediaSource::Plain(uri_1), format: MediaFormat::File }; + let uri_2 = owned_mxc_uri!("mxc://localhost/media-2"); + let request_2 = + MediaRequestParameters { source: MediaSource::Plain(uri_2), format: MediaFormat::File }; + let uri_3 = owned_mxc_uri!("mxc://localhost/media-3"); + let request_3 = + MediaRequestParameters { source: MediaSource::Plain(uri_3), format: MediaFormat::File }; + let uri_4 = owned_mxc_uri!("mxc://localhost/media-4"); + let request_4 = + MediaRequestParameters { source: MediaSource::Plain(uri_4), format: MediaFormat::File }; + let uri_5 = owned_mxc_uri!("mxc://localhost/media-5"); + let request_5 = + MediaRequestParameters { source: MediaSource::Plain(uri_5), format: MediaFormat::File }; + + // A policy with 30 seconds expiry. + let policy = + MediaRetentionPolicy::empty().with_last_access_expiry(Some(Duration::from_secs(30))); + + // Add all the content at different times. + let mut time = SystemTime::UNIX_EPOCH; + self.add_media_content_inner( + &request_1, + content.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::Yes, + ) + .await + .unwrap(); + time += Duration::from_secs(1); + self.add_media_content_inner( + &request_2, + content.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::Yes, + ) + .await + .unwrap(); + time += Duration::from_secs(1); + self.add_media_content_inner( + &request_3, + content.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + time += Duration::from_secs(1); + self.add_media_content_inner( + &request_4, + content.clone(), + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + time += Duration::from_secs(1); + self.add_media_content_inner( + &request_5, + content, + time, + policy, + IgnoreMediaRetentionPolicy::No, + ) + .await + .unwrap(); + + // The content was cached. + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_1, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_2, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_3, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_4, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_5, time).await.unwrap(); + assert!(stored.is_some()); + + // We advance of 120 seconds, all media should be expired. + time += Duration::from_secs(120); + + // Cleanup removes all the media contents that are not ignored. + self.clean_up_media_cache_inner(policy, time).await.unwrap(); + + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_1, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_2, time).await.unwrap(); + assert!(stored.is_some()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_3, time).await.unwrap(); + assert!(stored.is_none()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_4, time).await.unwrap(); + assert!(stored.is_none()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_5, time).await.unwrap(); + assert!(stored.is_none()); + + // Do no ignore the content anymore. + self.set_ignore_media_retention_policy_inner(&request_1, IgnoreMediaRetentionPolicy::No) + .await + .unwrap(); + self.set_ignore_media_retention_policy_inner(&request_2, IgnoreMediaRetentionPolicy::No) + .await + .unwrap(); + + // We advance of 120 seconds, all media should be expired again. + time += Duration::from_secs(120); + + // Cleanup removes the remaining media contents. + self.clean_up_media_cache_inner(policy, time).await.unwrap(); + + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_1, time).await.unwrap(); + assert!(stored.is_none()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_2, time).await.unwrap(); + assert!(stored.is_none()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_3, time).await.unwrap(); + assert!(stored.is_none()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_4, time).await.unwrap(); + assert!(stored.is_none()); + time += Duration::from_secs(1); + let stored = self.get_media_content_inner(&request_5, time).await.unwrap(); + assert!(stored.is_none()); + } +} + +/// Macro building to allow your [`EventCacheStoreMedia`] implementation to run +/// the entire tests suite locally. +/// +/// Can be run with the `with_media_size_tests` argument to include more tests +/// about the media cache retention policy based on content size. It is not +/// recommended to run those in encrypted stores because the size of the +/// encrypted content may vary compared to what the tests expect. +/// +/// You need to provide an `async fn get_event_cache_store() -> +/// event_cache::store::Result` that provides a fresh event cache store +/// that implements `EventCacheStoreMedia` on the same level you invoke the +/// macro. +/// +/// ## Usage Example: +/// ```no_run +/// # use matrix_sdk_base::event_cache::store::{ +/// # EventCacheStore, +/// # MemoryStore as MyStore, +/// # Result as EventCacheStoreResult, +/// # }; +/// +/// #[cfg(test)] +/// mod tests { +/// use super::{EventCacheStoreResult, MyStore}; +/// +/// async fn get_event_cache_store() -> EventCacheStoreResult { +/// Ok(MyStore::new()) +/// } +/// +/// event_cache_store_media_integration_tests!(); +/// } +/// ``` +#[allow(unused_macros, unused_extern_crates)] +#[macro_export] +macro_rules! event_cache_store_media_integration_tests { + (with_media_size_tests) => { + mod event_cache_store_media_integration_tests { + $crate::event_cache_store_media_integration_tests!(@inner); + + #[async_test] + async fn test_media_max_file_size() { + let event_cache_store_media = get_event_cache_store().await.unwrap(); + event_cache_store_media.test_media_max_file_size().await; + } + + #[async_test] + async fn test_media_max_cache_size() { + let event_cache_store_media = get_event_cache_store().await.unwrap(); + event_cache_store_media.test_media_max_cache_size().await; + } + + #[async_test] + async fn test_media_ignore_max_size() { + let event_cache_store_media = get_event_cache_store().await.unwrap(); + event_cache_store_media.test_media_ignore_max_size().await; + } + } + }; + + () => { + mod event_cache_store_media_integration_tests { + $crate::event_cache_store_media_integration_tests!(@inner); + } + }; + + (@inner) => { + use matrix_sdk_test::async_test; + use $crate::event_cache::store::media::EventCacheStoreMediaIntegrationTests; + + use super::get_event_cache_store; + + #[async_test] + async fn test_store_media_retention_policy() { + let event_cache_store_media = get_event_cache_store().await.unwrap(); + event_cache_store_media.test_store_media_retention_policy().await; + } + + #[async_test] + async fn test_media_expiry() { + let event_cache_store_media = get_event_cache_store().await.unwrap(); + event_cache_store_media.test_media_expiry().await; + } + + #[async_test] + async fn test_media_ignore_expiry() { + let event_cache_store_media = get_event_cache_store().await.unwrap(); + event_cache_store_media.test_media_ignore_expiry().await; + } + }; +} diff --git a/crates/matrix-sdk-base/src/event_cache/store/media/media_retention_policy.rs b/crates/matrix-sdk-base/src/event_cache/store/media/media_retention_policy.rs new file mode 100644 index 00000000000..6d6ce0379b9 --- /dev/null +++ b/crates/matrix-sdk-base/src/event_cache/store/media/media_retention_policy.rs @@ -0,0 +1,320 @@ +// 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. + +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, + + /// 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, + + /// 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, +} + +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) -> 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) -> 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) -> 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 { + 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)); + } +} diff --git a/crates/matrix-sdk-base/src/event_cache/store/media/media_service.rs b/crates/matrix-sdk-base/src/event_cache/store/media/media_service.rs new file mode 100644 index 00000000000..722c0c5ec88 --- /dev/null +++ b/crates/matrix-sdk-base/src/event_cache/store/media/media_service.rs @@ -0,0 +1,884 @@ +// 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. + +use std::fmt; + +use async_trait::async_trait; +use matrix_sdk_common::{locks::Mutex, AsyncTraitDeps}; +use ruma::{time::SystemTime, MxcUri}; +use tokio::sync::Mutex as AsyncMutex; + +use super::MediaRetentionPolicy; +use crate::{event_cache::store::EventCacheStoreError, media::MediaRequestParameters}; + +/// API for implementors of [`EventCacheStore`] to manage their media through +/// their implementation of [`EventCacheStoreMedia`]. +/// +/// [`EventCacheStore`]: crate::event_cache::store::EventCacheStore +#[derive(Debug)] +pub struct MediaService { + /// The time provider. + time_provider: Time, + + /// The current [`MediaRetentionPolicy`]. + policy: Mutex, + + /// A mutex to ensure a single cleanup is running at a time. + cleanup_guard: AsyncMutex<()>, +} + +impl MediaService { + /// Construct a new default `MediaService`. + /// + /// [`MediaService::restore()`] should be called after constructing the + /// `MediaService` to restore its previous state. + pub fn new() -> Self { + Self::default() + } +} + +impl Default for MediaService { + fn default() -> Self { + Self::with_time_provider(DefaultTimeProvider) + } +} + +impl