Skip to main content

matrix_sdk_sqlite/
media_store.rs

1// Copyright 2024 The Matrix.org Foundation C.I.C.
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//! An SQLite-based backend for the [`MediaStore`].
16
17use std::{
18    collections::{BTreeMap, HashMap},
19    fmt,
20    path::{Path, PathBuf},
21    sync::Arc,
22};
23
24use async_trait::async_trait;
25use deadpool::managed::PoolConfig;
26use matrix_sdk_base::{
27    cross_process_lock::CrossProcessLockGeneration,
28    media::{
29        MediaRequestParameters, UniqueKey,
30        store::{
31            IgnoreMediaRetentionPolicy, MediaRetentionPolicy, MediaService, MediaStore,
32            MediaStoreInner,
33        },
34    },
35    timer,
36};
37use matrix_sdk_store_encryption::StoreCipher;
38use ruma::{MilliSecondsSinceUnixEpoch, MxcUri, OwnedMxcUri, time::SystemTime};
39use rusqlite::{OptionalExtension, params_from_iter};
40use tokio::{
41    fs,
42    sync::{Mutex, OwnedMutexGuard},
43};
44use tracing::{debug, instrument};
45
46use crate::{
47    OpenStoreError, RuntimeConfig, Secret, SqliteStoreConfig,
48    connection::{
49        self, Connection as SqliteAsyncConn, Pool as SqlitePool, SqliteConnections,
50        SqliteConnectionsInner,
51    },
52    error::{Error, Result},
53    utils::{
54        EncryptableStore, SqliteAsyncConnExt, SqliteKeyValueStoreAsyncConnExt,
55        SqliteKeyValueStoreConnExt, SqliteTransactionExt, time_to_timestamp,
56    },
57};
58
59mod keys {
60    // Entries in Key-value store
61    pub const MEDIA_RETENTION_POLICY: &str = "media_retention_policy";
62    pub const LAST_MEDIA_CLEANUP_TIME: &str = "last_media_cleanup_time";
63
64    // Tables
65    pub const MEDIA: &str = "media";
66}
67
68/// The database name.
69pub const DATABASE_NAME: &str = "matrix-sdk-media.sqlite3";
70
71/// An SQLite-based media store.
72#[derive(Clone)]
73pub struct SqliteMediaStore {
74    store_cipher: Option<Arc<StoreCipher>>,
75
76    /// `Some` when active, `None` when closed.
77    connections: Arc<SqliteConnections>,
78
79    /// Retained so we can rebuild the pool on reopen.
80    db_path: PathBuf,
81
82    /// Retained so we can rebuild the pool on reopen.
83    pool_config: PoolConfig,
84
85    /// Retained so we can re-apply runtime config on reopen.
86    runtime_config: RuntimeConfig,
87
88    media_service: MediaService,
89}
90
91#[cfg(not(tarpaulin_include))]
92impl fmt::Debug for SqliteMediaStore {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        f.debug_struct("SqliteMediaStore").finish_non_exhaustive()
95    }
96}
97
98impl EncryptableStore for SqliteMediaStore {
99    fn get_cypher(&self) -> Option<&StoreCipher> {
100        self.store_cipher.as_deref()
101    }
102}
103
104impl SqliteMediaStore {
105    /// Open the SQLite-based media store at the given path using the given
106    /// passphrase to encrypt private data.
107    pub async fn open(
108        path: impl AsRef<Path>,
109        passphrase: Option<&str>,
110    ) -> Result<Self, OpenStoreError> {
111        Self::open_with_config(&SqliteStoreConfig::new(path).passphrase(passphrase)).await
112    }
113
114    /// Open the SQLite-based media store at the given path using the given key
115    /// to encrypt private data.
116    pub async fn open_with_key(
117        path: impl AsRef<Path>,
118        key: Option<&[u8]>,
119    ) -> Result<Self, OpenStoreError> {
120        Self::open_with_config(&SqliteStoreConfig::new(path).key(key)).await
121    }
122
123    /// Open the SQLite-based media store with the config open config.
124    #[instrument(skip(config), fields(path = ?config.path))]
125    pub async fn open_with_config(config: &SqliteStoreConfig) -> Result<Self, OpenStoreError> {
126        debug!(?config);
127
128        let _timer = timer!("open_with_config");
129
130        fs::create_dir_all(&config.path).await.map_err(OpenStoreError::CreateDir)?;
131
132        let db_path = config.path.join(DATABASE_NAME);
133        let pool_config = config.pool_config();
134        let runtime_config = config.runtime_config();
135
136        let pool = config.build_pool_of_connections(DATABASE_NAME)?;
137
138        let this =
139            Self::open_with_pool(pool, db_path, pool_config, runtime_config, config.secret.clone())
140                .await?;
141
142        // Apply runtime config on the write connection.
143        this.write().await?.apply_runtime_config(runtime_config).await?;
144
145        Ok(this)
146    }
147
148    /// Open an SQLite-based media store using the given SQLite database pool.
149    /// The given passphrase will be used to encrypt private data.
150    async fn open_with_pool(
151        pool: SqlitePool,
152        db_path: PathBuf,
153        pool_config: PoolConfig,
154        runtime_config: RuntimeConfig,
155        secret: Option<Secret>,
156    ) -> Result<Self, OpenStoreError> {
157        let conn = pool.get().await?;
158
159        let version = conn.db_version().await?;
160        run_migrations(&conn, version).await?;
161
162        conn.wal_checkpoint().await;
163
164        let store_cipher = match &secret {
165            Some(s) => Some(Arc::new(conn.get_or_create_store_cipher(s.clone()).await?)),
166            None => None,
167        };
168
169        let media_service = MediaService::new();
170        let media_retention_policy = conn.get_serialized_kv(keys::MEDIA_RETENTION_POLICY).await?;
171        let last_media_cleanup_time = conn.get_serialized_kv(keys::LAST_MEDIA_CLEANUP_TIME).await?;
172        media_service.restore(media_retention_policy, last_media_cleanup_time);
173
174        let connections = SqliteConnectionsInner {
175            pool,
176            // Use `conn` as our selected write connection.
177            write_connection: Arc::new(Mutex::new(conn)),
178        };
179
180        Ok(Self {
181            store_cipher,
182            connections: Arc::new(SqliteConnections::new(connections).await),
183            db_path,
184            pool_config,
185            runtime_config,
186            media_service,
187        })
188    }
189
190    // Acquire a connection for executing read operations.
191    #[instrument(skip_all)]
192    async fn read(&self) -> Result<SqliteAsyncConn> {
193        let pool = {
194            let guard = self.connections.lock().await;
195            let conns = guard.as_ref().ok_or(Error::StoreClosed)?;
196            conns.pool.clone()
197        };
198
199        let connection = pool.get().await?;
200
201        // Per https://www.sqlite.org/foreignkeys.html#fk_enable, foreign key
202        // support must be enabled on a per-connection basis. Execute it every
203        // time we try to get a connection, since we can't guarantee a previous
204        // connection did enable it before.
205        connection.execute_batch("PRAGMA foreign_keys = ON;").await?;
206
207        Ok(connection)
208    }
209
210    // Acquire a connection for executing write operations.
211    #[instrument(skip_all)]
212    async fn write(&self) -> Result<OwnedMutexGuard<SqliteAsyncConn>> {
213        let write_connection = {
214            let guard = self.connections.lock().await;
215            let conns = guard.as_ref().ok_or(Error::StoreClosed)?;
216            conns.write_connection.clone()
217        };
218
219        let connection = write_connection.lock_owned().await;
220
221        // Per https://www.sqlite.org/foreignkeys.html#fk_enable, foreign key
222        // support must be enabled on a per-connection basis. Execute it every
223        // time we try to get a connection, since we can't guarantee a previous
224        // connection did enable it before.
225        connection.execute_batch("PRAGMA foreign_keys = ON;").await?;
226
227        Ok(connection)
228    }
229
230    pub async fn vacuum(&self) -> Result<()> {
231        let write_connection = {
232            let guard = self.connections.lock().await;
233            let conns = guard.as_ref().ok_or(Error::StoreClosed)?;
234            conns.write_connection.clone()
235        };
236        write_connection.lock().await.vacuum().await
237    }
238
239    async fn get_db_size(&self) -> Result<Option<usize>> {
240        let pool = {
241            let guard = self.connections.lock().await;
242            let conns = guard.as_ref().ok_or(Error::StoreClosed)?;
243            conns.pool.clone()
244        };
245        Ok(Some(pool.get().await?.get_db_size().await?))
246    }
247
248    pub async fn close(&self) -> Result<()> {
249        connection::close_connections(&self.connections, "Media store").await;
250        Ok(())
251    }
252
253    pub async fn reopen(&self) -> Result<()> {
254        connection::reopen_connections(
255            &self.connections,
256            self.db_path.clone(),
257            self.pool_config,
258            self.runtime_config,
259        )
260        .await?;
261        Ok(())
262    }
263
264    /// Returns the pool size status, for testing purposes.
265    #[cfg(test)]
266    async fn pool_max_size(&self) -> Option<usize> {
267        let guard = self.connections.lock().await;
268        guard.as_ref().map(|conns| conns.pool.status().max_size)
269    }
270}
271
272/// Run migrations for the given version of the database.
273async fn run_migrations(conn: &SqliteAsyncConn, version: u8) -> Result<()> {
274    // Always enable foreign keys for the current connection.
275    conn.execute_batch("PRAGMA foreign_keys = ON;").await?;
276
277    if version < 1 {
278        debug!("Creating database");
279        // First turn on WAL mode, this can't be done in the transaction, it
280        // fails with the error message: "cannot change into wal mode from
281        // within a transaction".
282        conn.execute_batch("PRAGMA journal_mode = wal;").await?;
283        conn.with_transaction(|txn| {
284            txn.execute_batch(include_str!("../migrations/media_store/001_init.sql"))?;
285            txn.set_db_version(1)
286        })
287        .await?;
288    }
289
290    if version < 2 {
291        debug!("Upgrading database to version 2");
292        conn.with_transaction(|txn| {
293            txn.execute_batch(include_str!(
294                "../migrations/media_store/002_lease_locks_with_generation.sql"
295            ))?;
296            txn.set_db_version(2)
297        })
298        .await?;
299    }
300
301    Ok(())
302}
303
304#[async_trait]
305impl MediaStore for SqliteMediaStore {
306    type Error = Error;
307
308    #[instrument(skip(self))]
309    async fn try_take_leased_lock(
310        &self,
311        lease_duration_ms: u32,
312        key: &str,
313        holder: &str,
314    ) -> Result<Option<CrossProcessLockGeneration>> {
315        let key = key.to_owned();
316        let holder = holder.to_owned();
317
318        let now: u64 = MilliSecondsSinceUnixEpoch::now().get().into();
319        let expiration = now + lease_duration_ms as u64;
320
321        // Learn about the `excluded` keyword in https://sqlite.org/lang_upsert.html.
322        let generation = self
323            .write()
324            .await?
325            .with_transaction(move |txn| {
326                txn.query_one(
327                    "INSERT INTO lease_locks (key, holder, expiration)
328                    VALUES (?1, ?2, ?3)
329                    ON CONFLICT (key)
330                    DO
331                        UPDATE SET
332                            holder = excluded.holder,
333                            expiration = excluded.expiration,
334                            generation =
335                                CASE holder
336                                    WHEN excluded.holder THEN generation
337                                    ELSE generation + 1
338                                END
339                        WHERE
340                            holder = excluded.holder
341                            OR expiration < ?4
342                    RETURNING generation
343                    ",
344                    (key, holder, expiration, now),
345                    |row| row.get(0),
346                )
347                .optional()
348            })
349            .await?;
350
351        Ok(generation)
352    }
353
354    async fn add_media_content(
355        &self,
356        request: &MediaRequestParameters,
357        content: Vec<u8>,
358        ignore_policy: IgnoreMediaRetentionPolicy,
359    ) -> Result<()> {
360        let _timer = timer!("method");
361
362        self.media_service.add_media_content(self, request, content, ignore_policy).await
363    }
364
365    #[instrument(skip_all)]
366    async fn replace_media_key(
367        &self,
368        from: &MediaRequestParameters,
369        to: &MediaRequestParameters,
370    ) -> Result<(), Self::Error> {
371        let _timer = timer!("method");
372
373        let prev_uri = self.encode_key(keys::MEDIA, from.source.unique_key());
374        let prev_format = self.encode_key(keys::MEDIA, from.format.unique_key());
375
376        let new_uri = self.encode_key(keys::MEDIA, to.source.unique_key());
377        let new_format = self.encode_key(keys::MEDIA, to.format.unique_key());
378
379        let conn = self.write().await?;
380        conn.execute(
381            r#"UPDATE media SET uri = ?, format = ? WHERE uri = ? AND format = ?"#,
382            (new_uri, new_format, prev_uri, prev_format),
383        )
384        .await?;
385
386        Ok(())
387    }
388
389    #[instrument(skip_all)]
390    async fn get_media_content(&self, request: &MediaRequestParameters) -> Result<Option<Vec<u8>>> {
391        let _timer = timer!("method");
392
393        self.media_service.get_media_content(self, request).await
394    }
395
396    #[instrument(skip_all)]
397    async fn remove_media_content(&self, request: &MediaRequestParameters) -> Result<()> {
398        let _timer = timer!("method");
399
400        let uri = self.encode_key(keys::MEDIA, request.source.unique_key());
401        let format = self.encode_key(keys::MEDIA, request.format.unique_key());
402
403        let conn = self.write().await?;
404        conn.execute("DELETE FROM media WHERE uri = ? AND format = ?", (uri, format)).await?;
405
406        Ok(())
407    }
408
409    #[instrument(skip(self))]
410    async fn remove_media_content_for_uri(&self, uri: &MxcUri) -> Result<()> {
411        let _timer = timer!("method");
412
413        let uri = self.encode_key(keys::MEDIA, uri);
414
415        let conn = self.write().await?;
416        conn.execute("DELETE FROM media WHERE uri = ?", (uri,)).await?;
417
418        Ok(())
419    }
420
421    #[instrument(skip_all)]
422    async fn set_media_retention_policy(
423        &self,
424        policy: MediaRetentionPolicy,
425    ) -> Result<(), Self::Error> {
426        let _timer = timer!("method");
427
428        self.media_service.set_media_retention_policy(self, policy).await
429    }
430
431    #[instrument(skip_all)]
432    fn media_retention_policy(&self) -> MediaRetentionPolicy {
433        let _timer = timer!("method");
434
435        self.media_service.media_retention_policy()
436    }
437
438    #[instrument(skip_all)]
439    async fn set_ignore_media_retention_policy(
440        &self,
441        request: &MediaRequestParameters,
442        ignore_policy: IgnoreMediaRetentionPolicy,
443    ) -> Result<(), Self::Error> {
444        let _timer = timer!("method");
445
446        self.media_service.set_ignore_media_retention_policy(self, request, ignore_policy).await
447    }
448
449    #[instrument(skip_all)]
450    async fn clean(&self) -> Result<(), Self::Error> {
451        let _timer = timer!("method");
452
453        self.media_service.clean(self).await
454    }
455
456    async fn close(&self) -> Result<(), Self::Error> {
457        SqliteMediaStore::close(self).await
458    }
459
460    async fn reopen(&self) -> Result<(), Self::Error> {
461        SqliteMediaStore::reopen(self).await
462    }
463
464    async fn optimize(&self) -> Result<(), Self::Error> {
465        Ok(self.vacuum().await?)
466    }
467
468    async fn get_size(&self) -> Result<Option<usize>, Self::Error> {
469        self.get_db_size().await
470    }
471
472    #[instrument(skip_all)]
473    async fn media_sizes(
474        &self,
475        uris: &[OwnedMxcUri],
476    ) -> Result<BTreeMap<OwnedMxcUri, u64>, Self::Error> {
477        let _timer = timer!("method");
478
479        if uris.is_empty() {
480            return Ok(BTreeMap::new());
481        }
482
483        let mut lookup = HashMap::with_capacity(uris.len());
484        let mut keys = Vec::with_capacity(uris.len());
485        for uri in uris {
486            let key = self.encode_key(keys::MEDIA, uri.as_str());
487            lookup.insert(key.to_vec(), uri.clone());
488            keys.push(key);
489        }
490
491        let sizes = self
492            .read()
493            .await?
494            .chunk_large_query_over(keys, None, move |txn, chunk| {
495                let sql = format!(
496                    "SELECT uri, sum(length(data)) FROM media WHERE uri IN ({}) GROUP BY uri",
497                    chunk.host_parameters()
498                );
499                let mut stmt = txn.prepare(&sql)?;
500                let mut sizes = Vec::new();
501                let rows = stmt.query(params_from_iter(chunk.iter()))?;
502                for row in rows.mapped(|row| Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, u64>(1)?)))
503                {
504                    let (key, size) = row?;
505                    if let Some(uri) = lookup.get(&key) {
506                        sizes.push((uri.clone(), size));
507                    }
508                }
509                Ok(sizes)
510            })
511            .await?;
512
513        Ok(sizes.into_iter().collect())
514    }
515}
516
517#[cfg_attr(target_family = "wasm", async_trait(?Send))]
518#[cfg_attr(not(target_family = "wasm"), async_trait)]
519impl MediaStoreInner for SqliteMediaStore {
520    type Error = Error;
521
522    async fn media_retention_policy_inner(
523        &self,
524    ) -> Result<Option<MediaRetentionPolicy>, Self::Error> {
525        let conn = self.read().await?;
526        conn.get_serialized_kv(keys::MEDIA_RETENTION_POLICY).await
527    }
528
529    async fn set_media_retention_policy_inner(
530        &self,
531        policy: MediaRetentionPolicy,
532    ) -> Result<(), Self::Error> {
533        let conn = self.write().await?;
534        conn.set_serialized_kv(keys::MEDIA_RETENTION_POLICY, policy).await?;
535        Ok(())
536    }
537
538    async fn add_media_content_inner(
539        &self,
540        request: &MediaRequestParameters,
541        data: Vec<u8>,
542        last_access: SystemTime,
543        policy: MediaRetentionPolicy,
544        ignore_policy: IgnoreMediaRetentionPolicy,
545    ) -> Result<(), Self::Error> {
546        let ignore_policy = ignore_policy.is_yes();
547        let data = self.encode_value(data)?;
548
549        if !ignore_policy && policy.exceeds_max_file_size(data.len() as u64) {
550            return Ok(());
551        }
552
553        let uri = self.encode_key(keys::MEDIA, request.source.unique_key());
554        let format = self.encode_key(keys::MEDIA, request.format.unique_key());
555        let timestamp = time_to_timestamp(last_access);
556
557        let conn = self.write().await?;
558        conn.execute(
559            "INSERT OR REPLACE INTO media (uri, format, data, last_access, ignore_policy) VALUES (?, ?, ?, ?, ?)",
560            (uri, format, data, timestamp, ignore_policy),
561        )
562        .await?;
563
564        Ok(())
565    }
566
567    async fn set_ignore_media_retention_policy_inner(
568        &self,
569        request: &MediaRequestParameters,
570        ignore_policy: IgnoreMediaRetentionPolicy,
571    ) -> Result<(), Self::Error> {
572        let uri = self.encode_key(keys::MEDIA, request.source.unique_key());
573        let format = self.encode_key(keys::MEDIA, request.format.unique_key());
574        let ignore_policy = ignore_policy.is_yes();
575
576        let conn = self.write().await?;
577        conn.execute(
578            r#"UPDATE media SET ignore_policy = ? WHERE uri = ? AND format = ?"#,
579            (ignore_policy, uri, format),
580        )
581        .await?;
582
583        Ok(())
584    }
585
586    async fn get_media_content_inner(
587        &self,
588        request: &MediaRequestParameters,
589        current_time: SystemTime,
590    ) -> Result<Option<Vec<u8>>, Self::Error> {
591        let uri = self.encode_key(keys::MEDIA, request.source.unique_key());
592        let format = self.encode_key(keys::MEDIA, request.format.unique_key());
593        let timestamp = time_to_timestamp(current_time);
594
595        let conn = self.write().await?;
596        let data = conn
597            .with_transaction::<_, rusqlite::Error, _>(move |txn| {
598                // Update the last access. We need to do this first so the
599                // transaction is in write mode right away. See:
600                // https://sqlite.org/lang_transaction.html#read_transactions_versus_write_transactions
601                txn.execute(
602                    "UPDATE media SET last_access = ? WHERE uri = ? AND format = ?",
603                    (timestamp, &uri, &format),
604                )?;
605
606                txn.query_row::<Vec<u8>, _, _>(
607                    "SELECT data FROM media WHERE uri = ? AND format = ?",
608                    (&uri, &format),
609                    |row| row.get(0),
610                )
611                .optional()
612            })
613            .await?;
614
615        data.map(|v| self.decode_value(&v).map(Into::into)).transpose()
616    }
617
618    async fn clean_inner(
619        &self,
620        policy: MediaRetentionPolicy,
621        current_time: SystemTime,
622    ) -> Result<(), Self::Error> {
623        if !policy.has_limitations() {
624            // We can safely skip all the checks.
625            return Ok(());
626        }
627
628        let conn = self.write().await?;
629        let removed = conn
630            .with_transaction::<_, Error, _>(move |txn| {
631                let mut removed = false;
632
633                // First, check media content that exceed the max filesize.
634                if let Some(max_file_size) = policy.computed_max_file_size() {
635                    let count = txn.execute(
636                        "DELETE FROM media WHERE ignore_policy IS FALSE AND length(data) > ?",
637                        (max_file_size,),
638                    )?;
639
640                    if count > 0 {
641                        removed = true;
642                    }
643                }
644
645                // Then, clean up expired media content.
646                if let Some(last_access_expiry) = policy.last_access_expiry {
647                    let current_timestamp = time_to_timestamp(current_time);
648                    let expiry_secs = last_access_expiry.as_secs();
649                    let count = txn.execute(
650                        "DELETE FROM media WHERE ignore_policy IS FALSE AND (? - last_access) >= ?",
651                        (current_timestamp, expiry_secs),
652                    )?;
653
654                    if count > 0 {
655                        removed = true;
656                    }
657                }
658
659                // Finally, if the cache size is too big, remove old items until
660                // it fits.
661                if let Some(max_cache_size) = policy.max_cache_size {
662                    // i64 is the integer type used by SQLite, use it here to
663                    // avoid usize overflow during the conversion of the result.
664                    let cache_size = txn
665                        .query_row(
666                            "SELECT sum(length(data)) FROM media WHERE ignore_policy IS FALSE",
667                            (),
668                            |row| {
669                                // `sum()` returns `NULL` if there are no rows.
670                                row.get::<_, Option<u64>>(0)
671                            },
672                        )?
673                        .unwrap_or_default();
674
675                    // If the cache size is overflowing or bigger than max cache
676                    // size, clean up.
677                    if cache_size > max_cache_size {
678                        // Get the sizes of the media contents ordered by last
679                        // access.
680                        let mut cached_stmt = txn.prepare_cached(
681                            "SELECT rowid, length(data) FROM media \
682                             WHERE ignore_policy IS FALSE ORDER BY last_access DESC",
683                        )?;
684                        let content_sizes = cached_stmt
685                            .query(())?
686                            .mapped(|row| Ok((row.get::<_, i64>(0)?, row.get::<_, u64>(1)?)));
687
688                        let mut accumulated_items_size = 0u64;
689                        let mut limit_reached = false;
690                        let mut rows_to_remove = Vec::new();
691
692                        for result in content_sizes {
693                            let (row_id, size) = match result {
694                                Ok(content_size) => content_size,
695                                Err(error) => {
696                                    return Err(error.into());
697                                }
698                            };
699
700                            if limit_reached {
701                                rows_to_remove.push(row_id);
702                                continue;
703                            }
704
705                            match accumulated_items_size.checked_add(size) {
706                                Some(acc) if acc > max_cache_size => {
707                                    // We can stop accumulating.
708                                    limit_reached = true;
709                                    rows_to_remove.push(row_id);
710                                }
711                                Some(acc) => accumulated_items_size = acc,
712                                None => {
713                                    // The accumulated size is overflowing but
714                                    // the setting cannot be bigger than
715                                    // usize::MAX, we can stop accumulating.
716                                    limit_reached = true;
717                                    rows_to_remove.push(row_id);
718                                }
719                            }
720                        }
721
722                        if !rows_to_remove.is_empty() {
723                            removed = true;
724                        }
725
726                        txn.chunk_large_query_over(rows_to_remove, None, |txn, row_ids| {
727                            let sql_params = row_ids.host_parameters();
728                            let query = format!("DELETE FROM media WHERE rowid IN ({sql_params})");
729                            txn.prepare(&query)?.execute(params_from_iter(row_ids))?;
730                            Ok(Vec::<()>::new())
731                        })?;
732                    }
733                }
734
735                txn.set_serialized_kv(keys::LAST_MEDIA_CLEANUP_TIME, current_time)?;
736
737                Ok(removed)
738            })
739            .await?;
740
741        // If we removed media, defragment the database and free space on the
742        // filesystem.
743        if removed {
744            conn.vacuum().await?;
745        }
746
747        Ok(())
748    }
749
750    async fn last_media_cleanup_time_inner(&self) -> Result<Option<SystemTime>, Self::Error> {
751        let conn = self.read().await?;
752        conn.get_serialized_kv(keys::LAST_MEDIA_CLEANUP_TIME).await
753    }
754}
755
756#[cfg(test)]
757mod tests {
758    use std::{
759        path::PathBuf,
760        sync::{
761            LazyLock,
762            atomic::{AtomicU32, Ordering::SeqCst},
763        },
764        time::Duration,
765    };
766
767    use matrix_sdk_base::{
768        media::{
769            MediaFormat, MediaRequestParameters, MediaThumbnailSettings,
770            store::{IgnoreMediaRetentionPolicy, MediaStore, MediaStoreError},
771        },
772        media_store_inner_integration_tests, media_store_integration_tests,
773        media_store_integration_tests_time,
774    };
775    use matrix_sdk_test::async_test;
776    use ruma::{events::room::MediaSource, media::Method, mxc_uri, uint};
777    use tempfile::{TempDir, tempdir};
778
779    use super::SqliteMediaStore;
780    use crate::{SqliteStoreConfig, utils::SqliteAsyncConnExt};
781
782    static TMP_DIR: LazyLock<TempDir> = LazyLock::new(|| tempdir().unwrap());
783    static NUM: AtomicU32 = AtomicU32::new(0);
784
785    fn new_media_store_workspace() -> PathBuf {
786        let name = NUM.fetch_add(1, SeqCst).to_string();
787        TMP_DIR.path().join(name)
788    }
789
790    async fn get_media_store() -> Result<SqliteMediaStore, MediaStoreError> {
791        let tmpdir_path = new_media_store_workspace();
792
793        tracing::info!("using media store @ {}", tmpdir_path.to_str().unwrap());
794
795        Ok(SqliteMediaStore::open(tmpdir_path.to_str().unwrap(), None).await.unwrap())
796    }
797
798    media_store_integration_tests!();
799    media_store_integration_tests_time!();
800    media_store_inner_integration_tests!();
801
802    async fn get_media_store_content_sorted_by_last_access(
803        media_store: &SqliteMediaStore,
804    ) -> Vec<Vec<u8>> {
805        let sqlite_db = media_store.read().await.expect("accessing sqlite db failed");
806        sqlite_db
807            .prepare("SELECT data FROM media ORDER BY last_access DESC", |mut stmt| {
808                stmt.query(())?.mapped(|row| row.get(0)).collect()
809            })
810            .await
811            .expect("querying media cache content by last access failed")
812    }
813
814    #[async_test]
815    async fn test_pool_size() {
816        let tmpdir_path = new_media_store_workspace();
817        let store_open_config = SqliteStoreConfig::new(tmpdir_path).pool_max_size(42);
818
819        let store = SqliteMediaStore::open_with_config(&store_open_config).await.unwrap();
820
821        assert_eq!(store.pool_max_size().await.unwrap(), 42);
822    }
823
824    #[async_test]
825    async fn test_last_access() {
826        let media_store = get_media_store().await.expect("creating media cache failed");
827        let uri = mxc_uri!("mxc://localhost/media");
828        let file_request = MediaRequestParameters {
829            source: MediaSource::Plain(uri.to_owned()),
830            format: MediaFormat::File,
831        };
832        let thumbnail_request = MediaRequestParameters {
833            source: MediaSource::Plain(uri.to_owned()),
834            format: MediaFormat::Thumbnail(MediaThumbnailSettings::with_method(
835                Method::Crop,
836                uint!(100),
837                uint!(100),
838            )),
839        };
840
841        let content: Vec<u8> = "hello world".into();
842        let thumbnail_content: Vec<u8> = "hello…".into();
843
844        // Add the media.
845        media_store
846            .add_media_content(&file_request, content.clone(), IgnoreMediaRetentionPolicy::No)
847            .await
848            .expect("adding file failed");
849
850        // Since the precision of the timestamp is in seconds, wait so the
851        // timestamps differ.
852        tokio::time::sleep(Duration::from_secs(3)).await;
853
854        media_store
855            .add_media_content(
856                &thumbnail_request,
857                thumbnail_content.clone(),
858                IgnoreMediaRetentionPolicy::No,
859            )
860            .await
861            .expect("adding thumbnail failed");
862
863        // File's last access is older than thumbnail.
864        let contents = get_media_store_content_sorted_by_last_access(&media_store).await;
865
866        assert_eq!(contents.len(), 2, "media cache contents length is wrong");
867        assert_eq!(contents[0], thumbnail_content, "thumbnail is not last access");
868        assert_eq!(contents[1], content, "file is not second-to-last access");
869
870        // Since the precision of the timestamp is in seconds, wait so the
871        // timestamps differ.
872        tokio::time::sleep(Duration::from_secs(3)).await;
873
874        // Access the file so its last access is more recent.
875        let _ = media_store
876            .get_media_content(&file_request)
877            .await
878            .expect("getting file failed")
879            .expect("file is missing");
880
881        // File's last access is more recent than thumbnail.
882        let contents = get_media_store_content_sorted_by_last_access(&media_store).await;
883
884        assert_eq!(contents.len(), 2, "media cache contents length is wrong");
885        assert_eq!(contents[0], content, "file is not last access");
886        assert_eq!(contents[1], thumbnail_content, "thumbnail is not second-to-last access");
887    }
888}
889
890#[cfg(test)]
891mod close_reopen_tests {
892    use std::sync::{
893        LazyLock,
894        atomic::{AtomicU32, Ordering::SeqCst},
895    };
896
897    use matrix_sdk_base::media::{
898        MediaFormat, MediaRequestParameters,
899        store::{IgnoreMediaRetentionPolicy, MediaStore},
900    };
901    use matrix_sdk_test::async_test;
902    use ruma::{events::room::MediaSource, mxc_uri};
903    use tempfile::{TempDir, tempdir};
904
905    use super::SqliteMediaStore;
906
907    static TMP_DIR: LazyLock<TempDir> = LazyLock::new(|| tempdir().unwrap());
908    static NUM: AtomicU32 = AtomicU32::new(0);
909
910    async fn new_store() -> SqliteMediaStore {
911        let name = NUM.fetch_add(1, SeqCst).to_string();
912        let tmpdir_path = TMP_DIR.path().join(name);
913        SqliteMediaStore::open(tmpdir_path, None).await.unwrap()
914    }
915
916    fn test_request() -> MediaRequestParameters {
917        MediaRequestParameters {
918            source: MediaSource::Plain(mxc_uri!("mxc://localhost/test_media").to_owned()),
919            format: MediaFormat::File,
920        }
921    }
922
923    #[async_test]
924    async fn test_close_completes_without_timeout() {
925        let store = new_store().await;
926
927        // Close should complete quickly without hitting any timeout.
928        let start = std::time::Instant::now();
929        store.close().await.unwrap();
930        let elapsed = start.elapsed();
931
932        assert!(
933            elapsed < std::time::Duration::from_secs(2),
934            "close() took {elapsed:?}, expected < 2s (no timeout)"
935        );
936
937        // Connections should be None after close.
938        let guard = store.connections.lock().await;
939        assert!(guard.is_none(), "connections should be None after close");
940    }
941
942    #[async_test]
943    async fn test_reopen_restores_connections() {
944        let store = new_store().await;
945
946        store.close().await.unwrap();
947
948        {
949            let guard = store.connections.lock().await;
950            assert!(guard.is_none());
951        }
952
953        store.reopen().await.unwrap();
954
955        {
956            let guard = store.connections.lock().await;
957            assert!(guard.is_some(), "connections should be Some after reopen");
958        }
959    }
960
961    #[async_test]
962    async fn test_close_is_idempotent() {
963        let store = new_store().await;
964
965        store.close().await.unwrap();
966        // Second close should be a no-op.
967        store.close().await.unwrap();
968
969        let guard = store.connections.lock().await;
970        assert!(guard.is_none());
971    }
972
973    #[async_test]
974    async fn test_reopen_is_idempotent() {
975        let store = new_store().await;
976
977        // Reopen on an active store should be a no-op.
978        store.reopen().await.unwrap();
979
980        let guard = store.connections.lock().await;
981        assert!(guard.is_some());
982    }
983
984    #[async_test]
985    async fn test_read_fails_when_closed() {
986        let store = new_store().await;
987        store.close().await.unwrap();
988
989        let err = store.get_media_content(&test_request()).await;
990        assert!(err.is_err(), "read should fail when closed");
991
992        let err_msg = err.unwrap_err().to_string();
993        assert!(err_msg.contains("closed"), "error should mention 'closed', got: {err_msg}");
994    }
995
996    #[async_test]
997    async fn test_write_fails_when_closed() {
998        let store = new_store().await;
999        store.close().await.unwrap();
1000
1001        let err = store
1002            .add_media_content(&test_request(), b"data".to_vec(), IgnoreMediaRetentionPolicy::No)
1003            .await;
1004        assert!(err.is_err(), "write should fail when closed");
1005
1006        let err_msg = err.unwrap_err().to_string();
1007        assert!(err_msg.contains("closed"), "error should mention 'closed', got: {err_msg}");
1008    }
1009
1010    #[async_test]
1011    async fn test_data_persists_across_close_reopen() {
1012        let store = new_store().await;
1013
1014        // Write some media content.
1015        store
1016            .add_media_content(
1017                &test_request(),
1018                b"hello world".to_vec(),
1019                IgnoreMediaRetentionPolicy::Yes,
1020            )
1021            .await
1022            .unwrap();
1023
1024        // Verify it's there.
1025        let content = store.get_media_content(&test_request()).await.unwrap();
1026        assert_eq!(content.as_deref(), Some(b"hello world".as_slice()));
1027
1028        // Close and reopen.
1029        store.close().await.unwrap();
1030        store.reopen().await.unwrap();
1031
1032        // Content should still be there after reopen.
1033        let content = store.get_media_content(&test_request()).await.unwrap();
1034        assert_eq!(
1035            content.as_deref(),
1036            Some(b"hello world".as_slice()),
1037            "media content should persist across close/reopen"
1038        );
1039    }
1040
1041    #[async_test]
1042    async fn test_multiple_close_reopen_cycles() {
1043        let store = new_store().await;
1044
1045        for _ in 0..5 {
1046            store.close().await.unwrap();
1047            store.reopen().await.unwrap();
1048
1049            // After each cycle, the store should be fully operational.
1050            let result = store.get_media_content(&test_request()).await;
1051            assert!(result.is_ok(), "store should work after close/reopen cycle");
1052        }
1053    }
1054
1055    #[async_test]
1056    async fn test_pool_is_fully_drained_after_close() {
1057        let store = new_store().await;
1058
1059        // Do a few reads to exercise the pool.
1060        let _ = store.get_media_content(&test_request()).await;
1061        let _ = store.get_media_content(&test_request()).await;
1062
1063        store.close().await.unwrap();
1064
1065        // After close, the connections field should be None (pool and write
1066        // connection have been fully torn down).
1067        let guard = store.connections.lock().await;
1068        assert!(guard.is_none(), "all connections should be released after close");
1069    }
1070
1071    #[async_test]
1072    async fn test_operations_work_immediately_after_reopen() {
1073        let store = new_store().await;
1074
1075        store.close().await.unwrap();
1076        store.reopen().await.unwrap();
1077
1078        // Read should work immediately after reopen.
1079        let result = store.get_media_content(&test_request()).await;
1080        assert!(result.is_ok(), "read should succeed immediately after reopen");
1081
1082        // Write should work immediately after reopen.
1083        let result = store
1084            .add_media_content(
1085                &test_request(),
1086                b"after_reopen".to_vec(),
1087                IgnoreMediaRetentionPolicy::No,
1088            )
1089            .await;
1090        assert!(result.is_ok(), "write should succeed immediately after reopen");
1091    }
1092
1093    #[async_test]
1094    async fn test_close_waits_for_held_read_connection_to_drain() {
1095        let store = new_store().await;
1096
1097        // Acquire a read connection and hold it, simulating an in-flight read.
1098        let held_conn = store.read().await.unwrap();
1099
1100        // Spawn close in a background task — it will close the pool and then
1101        // poll-wait for pool.status().size == 0 in the drain loop.
1102        let store_clone = store.clone();
1103        let close_handle = tokio::spawn(async move {
1104            store_clone.close().await.unwrap();
1105        });
1106
1107        // Give close() a moment to close the pool and enter the drain loop.
1108        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1109
1110        // The close task should still be running because we hold a connection.
1111        assert!(!close_handle.is_finished(), "close should be waiting for the held connection");
1112
1113        // Release the held connection — this lets pool.status().size drop to 0.
1114        drop(held_conn);
1115
1116        // Now close should complete promptly (well within the 5s timeout).
1117        let timeout = tokio::time::timeout(std::time::Duration::from_secs(3), close_handle).await;
1118        assert!(timeout.is_ok(), "close should complete after the held connection is released");
1119        timeout.unwrap().unwrap();
1120
1121        // Verify the store is fully closed.
1122        let guard = store.connections.lock().await;
1123        assert!(guard.is_none(), "connections should be None after close");
1124    }
1125}
1126
1127#[cfg(test)]
1128mod encrypted_tests {
1129    use std::sync::{
1130        LazyLock,
1131        atomic::{AtomicU32, Ordering::SeqCst},
1132    };
1133
1134    use matrix_sdk_base::{
1135        media::store::MediaStoreError, media_store_inner_integration_tests,
1136        media_store_integration_tests, media_store_integration_tests_time,
1137    };
1138    use tempfile::{TempDir, tempdir};
1139
1140    use super::SqliteMediaStore;
1141
1142    static TMP_DIR: LazyLock<TempDir> = LazyLock::new(|| tempdir().unwrap());
1143    static NUM: AtomicU32 = AtomicU32::new(0);
1144
1145    async fn get_media_store() -> Result<SqliteMediaStore, MediaStoreError> {
1146        let name = NUM.fetch_add(1, SeqCst).to_string();
1147        let tmpdir_path = TMP_DIR.path().join(name);
1148
1149        tracing::info!("using media store @ {}", tmpdir_path.to_str().unwrap());
1150
1151        Ok(SqliteMediaStore::open(tmpdir_path.to_str().unwrap(), Some("default_test_password"))
1152            .await
1153            .unwrap())
1154    }
1155
1156    media_store_integration_tests!();
1157    media_store_integration_tests_time!();
1158    media_store_inner_integration_tests!();
1159}