Skip to main content

xmtp_db/encrypted_store/database/
native.rs

1mod pool;
2mod sqlcipher_connection;
3
4use crate::StorageError;
5use crate::database::instrumentation::TestInstrumentation;
6/// Native SQLite connection using SqlCipher
7use crate::{ConnectionError, ConnectionExt, DbConnection, NotFound};
8use arc_swap::ArcSwapOption;
9use diesel::sqlite::SqliteConnection;
10use diesel::{
11    Connection,
12    connection::SimpleConnection,
13    r2d2::{self, CustomizeConnection, PooledConnection},
14};
15use parking_lot::Mutex;
16use std::sync::Arc;
17use thiserror::Error;
18use xmtp_common::{BoxDynError, ErrorCode, RetryableError, retryable};
19use xmtp_configuration::{BUSY_TIMEOUT, MAX_DB_POOL_SIZE, MIN_DB_POOL_SIZE};
20
21use pool::*;
22
23pub type RawDbConnection = PooledConnection<ConnectionManager>;
24
25pub use self::sqlcipher_connection::EncryptedConnection;
26use crate::{EncryptionKey, StorageOption, XmtpDb};
27
28use super::PersistentOrMem;
29
30trait XmtpConnection:
31    ValidatedConnection
32    + ConnectionOptions
33    + CustomizeConnection<SqliteConnection, r2d2::Error>
34    + dyn_clone::DynClone
35{
36}
37
38trait ConnectionOptions {
39    fn options(&self) -> &StorageOption;
40    fn is_persistent(&self) -> bool {
41        matches!(self.options(), StorageOption::Persistent(_))
42    }
43}
44
45impl<T> XmtpConnection for T where
46    T: ValidatedConnection
47        + CustomizeConnection<SqliteConnection, r2d2::Error>
48        + ConnectionOptions
49        + dyn_clone::DynClone
50{
51}
52
53dyn_clone::clone_trait_object!(XmtpConnection);
54
55pub(crate) trait ValidatedConnection {
56    fn validate(&self, _conn: &mut SqliteConnection) -> Result<(), PlatformStorageError> {
57        Ok(())
58    }
59}
60
61/// Pragmas to execute on acquiring a new SQLite connection
62/// According to [pragmas](https://docs.rs/diesel/latest/diesel/prelude/struct.SqliteConnection.html#concurrency)
63/// for concurrency
64/// these pragmas only required to be ran once per session.
65fn connection_pragmas(c: &mut impl SimpleConnection) -> diesel::result::QueryResult<()> {
66    // pragmas must be in a separate call to ensure they apply correctly
67    // _NOTE:_ order is important to ensure later pragmas do not timeout
68    c.batch_execute(&format!("PRAGMA busy_timeout = {};", BUSY_TIMEOUT))?; // sleep for 5s if the database is busy
69    c.batch_execute("PRAGMA synchronous = NORMAL;")?; // fsync only in critical moments
70    c.batch_execute("PRAGMA wal_autocheckpoint = 1000;")?; // write WAL changes back every 1000 pages, for an in average 1MB WAL file. May affect readers if number is increased
71    c.batch_execute("PRAGMA wal_checkpoint(TRUNCATE);")?; // free some space by truncating possibly massive WAL files from the last run.
72    c.batch_execute("PRAGMA query_only = OFF;")?; // Enable writing with the connection
73    c.batch_execute("PRAGMA journal_size_limit = 67108864")?; // maximum size of the WAL file, corresponds to 64MB
74    c.batch_execute("PRAGMA mmap_size = 134217728")?; // maximum size of the internal mmap pool. Corresponds to 128MB
75    c.batch_execute("PRAGMA cache_size = 2000")?; // maximum number of database disk pages that will be hold in memory. Corresponds to ~8MB
76    c.batch_execute("PRAGMA foreign_keys = ON;")?; // enforce foreign keys
77
78    Ok(())
79}
80
81/// An Unencrypted Connection
82/// Creates a Sqlite3 Database/Connection in WAL mode.
83/// _*NOTE:*_Unencrypted Connections are not validated and mostly meant for testing.
84/// It is not recommended to use an unencrypted connection in production.
85#[derive(Clone, Debug)]
86pub struct UnencryptedConnection {
87    options: StorageOption,
88}
89
90impl UnencryptedConnection {
91    pub fn new(options: StorageOption) -> Self {
92        Self { options }
93    }
94}
95
96impl ValidatedConnection for UnencryptedConnection {}
97
98impl ConnectionOptions for UnencryptedConnection {
99    fn options(&self) -> &StorageOption {
100        &self.options
101    }
102}
103
104impl CustomizeConnection<SqliteConnection, r2d2::Error> for UnencryptedConnection {
105    fn on_acquire(&self, c: &mut SqliteConnection) -> Result<(), r2d2::Error> {
106        if cfg!(any(test, feature = "test-utils")) {
107            c.set_instrumentation(TestInstrumentation);
108        }
109        connection_pragmas(c)?;
110        Ok(())
111    }
112}
113
114impl ConnectionOptions for NopConnection {
115    fn options(&self) -> &StorageOption {
116        &self.options
117    }
118}
119
120#[derive(Clone, Debug)]
121pub struct NopConnection {
122    options: StorageOption,
123}
124
125impl Default for NopConnection {
126    fn default() -> Self {
127        NopConnection {
128            options: StorageOption::Ephemeral,
129        }
130    }
131}
132
133impl ValidatedConnection for NopConnection {}
134impl CustomizeConnection<SqliteConnection, r2d2::Error> for NopConnection {
135    fn on_acquire(&self, c: &mut SqliteConnection) -> Result<(), r2d2::Error> {
136        if cfg!(any(test, feature = "test-utils")) {
137            c.set_instrumentation(TestInstrumentation);
138        }
139        Ok(())
140    }
141}
142
143impl StorageOption {
144    pub(super) fn path(&self) -> Option<&String> {
145        use StorageOption::*;
146        match self {
147            Persistent(path) => Some(path),
148            _ => None,
149        }
150    }
151}
152
153#[derive(Debug, Error, ErrorCode)]
154pub enum PlatformStorageError {
155    /// Pool error.
156    ///
157    /// Database connection pool error. Retryable.
158    #[error("Pool error: {0}")]
159    Pool(#[from] diesel::r2d2::PoolError),
160    /// DB connection error.
161    ///
162    /// R2D2 connection manager error (e.g. a failed `on_acquire` while
163    /// establishing a connection). Transient — retryable.
164    #[error("Error with connection to Sqlite {0}")]
165    DbConnection(#[from] diesel::r2d2::Error),
166    /// Pool needs connection.
167    ///
168    /// Pool must reconnect before use. Retryable.
169    #[error("Pool needs to  reconnect before use")]
170    PoolNeedsConnection,
171    /// Pool requires path.
172    ///
173    /// DB pool requires a persistent file path. Not retryable.
174    #[error("Using a DB Pool requires a persistent path")]
175    PoolRequiresPath,
176    /// SQLCipher not loaded.
177    ///
178    /// Encryption key given but SQLCipher not available. Retryable.
179    #[error("The SQLCipher Sqlite extension is not present, but an encryption key is given")]
180    SqlCipherNotLoaded,
181    /// SQLCipher key incorrect.
182    ///
183    /// PRAGMA key or salt has wrong value. Not retryable.
184    #[error("PRAGMA key or salt has incorrect value")]
185    SqlCipherKeyIncorrect,
186    /// Database locked.
187    ///
188    /// Database file is locked by another process. Retryable.
189    #[error("Database is locked")]
190    DatabaseLocked,
191    /// Diesel result error.
192    ///
193    /// Database query error. May be retryable.
194    #[error(transparent)]
195    DieselResult(#[from] diesel::result::Error),
196    /// Not found.
197    ///
198    /// Record not found in storage. Not retryable.
199    #[error(transparent)]
200    NotFound(#[from] NotFound),
201    /// I/O error.
202    ///
203    /// File system I/O error. Retryable.
204    #[error(transparent)]
205    Io(#[from] std::io::Error),
206    /// Hex decode error.
207    ///
208    /// Failed to decode hex string. Not retryable.
209    #[error(transparent)]
210    FromHex(#[from] hex::FromHexError),
211    /// Diesel connection error.
212    ///
213    /// Failed to establish connection. Retryable.
214    #[error(transparent)]
215    DieselConnect(#[from] diesel::ConnectionError),
216    /// Boxed error.
217    ///
218    /// Wrapped dynamic error. Not retryable.
219    #[error(transparent)]
220    Boxed(#[from] BoxDynError),
221}
222
223impl RetryableError for PlatformStorageError {
224    fn is_retryable(&self) -> bool {
225        match self {
226            Self::Pool(_) => true,
227            // An r2d2 connection-setup error (e.g. a failed `on_acquire` when
228            // establishing the single connection) is transient — retryable, in
229            // line with how the pooled checkout path classifies the same failure.
230            Self::DbConnection(_) => true,
231            Self::SqlCipherNotLoaded => true,
232            Self::PoolNeedsConnection => true,
233            Self::SqlCipherKeyIncorrect => false,
234            Self::DatabaseLocked => true,
235            Self::DieselResult(result) => retryable!(result),
236            Self::Io(_) => true,
237            Self::DieselConnect(_) => true,
238
239            _ => false,
240        }
241    }
242}
243
244/// Database used in `native` (everywhere but web)
245#[derive(Clone, Debug)]
246pub struct NativeDb {
247    customizer: Box<dyn XmtpConnection>,
248    conn: Arc<PersistentOrMem<NativeDbConnection, SingleDbConnection, EphemeralDbConnection>>,
249    opts: StorageOption,
250}
251
252use native_db_builder::{Empty, IsComplete, IsSet, IsUnset, SetKey, SetOpts, SetSingleConnection};
253
254impl NativeDb {
255    pub fn builder() -> NativeDbBuilder<Empty> {
256        native_db()
257    }
258}
259
260#[bon::builder]
261pub fn native_db(
262    #[builder(setters(vis = "", name = opts_internal))] opts: StorageOption,
263    #[builder(required, setters(vis = "", name = key_internal))] key: Option<EncryptionKey>,
264    #[builder(default = MAX_DB_POOL_SIZE)] max_pool_size: u32,
265    /// minimum amount of connections maintained at any time
266    #[builder(default = MIN_DB_POOL_SIZE)]
267    min_pool_size: u32,
268    /// When true, use a single `Mutex<SqliteConnection>` instead of a pool.
269    /// Costs one file descriptor per database. `max_pool_size`/`min_pool_size`
270    /// are ignored in this mode. Only meaningful for persistent databases.
271    #[builder(default = false, setters(vis = "", name = single_connection_internal))]
272    single_connection: bool,
273) -> Result<NativeDb, StorageError> {
274    NativeDb::new_inner(&opts, key, max_pool_size, min_pool_size, single_connection)
275        .map_err(Into::into)
276}
277
278impl<S: native_db_builder::State> NativeDbBuilder<S> {
279    pub fn ephemeral(self) -> NativeDbBuilder<SetOpts<S>>
280    where
281        S::Opts: IsUnset,
282    {
283        self.opts_internal(StorageOption::Ephemeral)
284    }
285
286    pub fn persistent(self, path: impl Into<String>) -> NativeDbBuilder<SetOpts<S>>
287    where
288        S::Opts: IsUnset,
289    {
290        self.opts_internal(StorageOption::Persistent(path.into()))
291    }
292
293    pub fn key(self, key: impl Into<EncryptionKey>) -> NativeDbBuilder<SetKey<S>>
294    where
295        S::Key: IsUnset,
296    {
297        self.key_internal(Some(key.into()))
298    }
299
300    /// Use a single `Mutex<SqliteConnection>` instead of a connection pool.
301    /// Costs exactly one file descriptor. Only meaningful for persistent
302    /// databases; ignored for ephemeral ones.
303    pub fn single_connection(self) -> NativeDbBuilder<SetSingleConnection<S>>
304    where
305        S::SingleConnection: IsUnset,
306    {
307        self.single_connection_internal(true)
308    }
309
310    /// Explicitly build the db without encryption
311    pub fn build_unencrypted(self) -> Result<NativeDb, StorageError>
312    where
313        S::Key: IsUnset,
314        S::Opts: IsSet,
315    {
316        let this = self.key_internal(Option::<EncryptionKey>::None);
317        this.call()
318    }
319
320    /// Build the native db with encryption
321    pub fn build(self) -> Result<NativeDb, StorageError>
322    where
323        S: IsComplete,
324    {
325        self.call()
326    }
327}
328
329impl NativeDb {
330    /// This function is private so that an unencrypted database cannot be created by accident
331    fn new_inner(
332        opts: &StorageOption,
333        enc_key: Option<EncryptionKey>,
334        max_pool_size: u32,
335        min_pool_size: u32,
336        single_connection: bool,
337    ) -> Result<Self, PlatformStorageError> {
338        let customizer = if let Some(key) = enc_key {
339            let enc_connection = EncryptedConnection::new(key, opts)?;
340            if let Some(path) = enc_connection.options().path() {
341                let mut conn = SqliteConnection::establish(path)?;
342                enc_connection.validate(&mut conn)?;
343            }
344            Box::new(enc_connection) as Box<dyn XmtpConnection>
345        } else if matches!(opts, StorageOption::Persistent(_)) {
346            Box::new(UnencryptedConnection::new(opts.clone())) as Box<dyn XmtpConnection>
347        } else {
348            Box::new(NopConnection::default()) as Box<dyn XmtpConnection>
349        };
350        let conn = if customizer.is_persistent() {
351            if single_connection {
352                PersistentOrMem::Single(SingleDbConnection::new(customizer.clone())?)
353            } else {
354                PersistentOrMem::Persistent(NativeDbConnection::new(
355                    customizer.clone(),
356                    max_pool_size,
357                    min_pool_size,
358                )?)
359            }
360        } else {
361            if single_connection {
362                tracing::info!(
363                    "single_connection requested for an ephemeral database; ignoring (ephemeral is already single-connection)"
364                );
365            }
366            PersistentOrMem::Mem(EphemeralDbConnection::new()?)
367        };
368
369        Ok(Self {
370            opts: opts.clone(),
371            conn: conn.into(),
372            customizer,
373        })
374    }
375}
376
377impl XmtpDb for NativeDb {
378    type Connection =
379        Arc<PersistentOrMem<NativeDbConnection, SingleDbConnection, EphemeralDbConnection>>;
380    type DbQuery = DbConnection<Self::Connection>;
381
382    fn conn(&self) -> Self::Connection {
383        self.conn.clone()
384    }
385
386    fn db(&self) -> Self::DbQuery {
387        DbConnection::new(self.conn.clone())
388    }
389
390    fn opts(&self) -> &StorageOption {
391        &self.opts
392    }
393
394    fn validate(&self, conn: &mut SqliteConnection) -> Result<(), ConnectionError> {
395        self.customizer.validate(conn)?;
396        Ok(())
397    }
398
399    fn disconnect(&self) -> Result<(), ConnectionError> {
400        self.conn.disconnect()
401    }
402
403    fn reconnect(&self) -> Result<(), ConnectionError> {
404        self.conn.reconnect()
405    }
406}
407
408pub struct EphemeralDbConnection {
409    conn: Arc<Mutex<SqliteConnection>>,
410}
411
412impl std::fmt::Debug for EphemeralDbConnection {
413    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
414        write!(
415            f,
416            "EphemeralConnection {{ is_locked={} }}",
417            self.conn.is_locked()
418        )
419    }
420}
421
422impl EphemeralDbConnection {
423    pub fn new() -> Result<Self, PlatformStorageError> {
424        let mut c = SqliteConnection::establish(":memory:")?;
425        UnencryptedConnection::on_acquire(
426            &UnencryptedConnection::new(StorageOption::Ephemeral),
427            &mut c,
428        )?;
429        Ok(Self {
430            conn: Arc::new(Mutex::new(c)),
431        })
432    }
433
434    fn db_disconnect(&self) -> Result<(), PlatformStorageError> {
435        Ok(())
436    }
437
438    fn db_reconnect(&self) -> Result<(), PlatformStorageError> {
439        let mut w = self.conn.lock();
440        let conn = SqliteConnection::establish(":memory:")?;
441        *w = conn;
442        Ok(())
443    }
444}
445
446impl ConnectionExt for EphemeralDbConnection {
447    fn raw_query<T, F>(&self, fun: F) -> Result<T, crate::ConnectionError>
448    where
449        F: FnOnce(&mut SqliteConnection) -> Result<T, diesel::result::Error>,
450        Self: Sized,
451    {
452        let mut conn = self.conn.lock();
453        fun(&mut conn).map_err(ConnectionError::from)
454    }
455
456    fn disconnect(&self) -> Result<(), crate::ConnectionError> {
457        Ok(self.db_disconnect()?)
458    }
459
460    fn reconnect(&self) -> Result<(), crate::ConnectionError> {
461        Ok(self.db_reconnect()?)
462    }
463}
464
465/// A native database backed by a single `Mutex<SqliteConnection>` instead of a
466/// pool. Costs exactly one file descriptor. Chosen via the `single_connection`
467/// builder flag — useful for services that run many clients in one process
468/// (where a per-client pool would exhaust the OS file-descriptor limit) and do
469/// serial work per client. There is no connection reentrancy in the codebase,
470/// so a non-reentrant `Mutex` is safe (see the design spec).
471///
472/// The connection is held in an `Option` so that [`disconnect`] can drop it and
473/// genuinely release the underlying file descriptor (SQLite closes the fd when
474/// the connection is dropped). After a disconnect, `raw_query` returns
475/// [`PlatformStorageError::PoolNeedsConnection`] — the same contract as the
476/// pooled [`NativeDbConnection`] — until [`reconnect`] re-establishes it.
477///
478/// [`disconnect`]: ConnectionExt::disconnect
479/// [`reconnect`]: ConnectionExt::reconnect
480pub struct SingleDbConnection {
481    conn: Arc<Mutex<Option<SqliteConnection>>>,
482    customizer: Box<dyn XmtpConnection>,
483}
484
485impl std::fmt::Debug for SingleDbConnection {
486    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
487        // Use `try_lock`: the mutex is non-reentrant, so formatting `{:?}` from
488        // within a `raw_query` callback (or panic/error logging that runs while
489        // the callback holds the lock) must not block — that would deadlock the
490        // thread. Report `connected=<locked>` when the lock is already held.
491        let connected = match self.conn.try_lock() {
492            Some(guard) => guard.is_some().to_string(),
493            None => "<locked>".to_string(),
494        };
495        write!(
496            f,
497            "SingleDbConnection {{ path: {}, connected={} }}",
498            self.customizer.options(),
499            connected
500        )
501    }
502}
503
504impl SingleDbConnection {
505    fn new(customizer: Box<dyn XmtpConnection>) -> Result<Self, PlatformStorageError> {
506        let StorageOption::Persistent(path) = customizer.options() else {
507            return Err(PlatformStorageError::PoolRequiresPath);
508        };
509        let conn = Self::establish(path, &*customizer)?;
510        Ok(Self {
511            conn: Arc::new(Mutex::new(Some(conn))),
512            customizer,
513        })
514    }
515
516    /// Establish a fresh connection and apply the same setup the pool applies:
517    /// the customizer's `on_acquire` (sqlcipher key + `connection_pragmas`),
518    /// then the one-time WAL/busy_timeout pragmas that `DbPool::new` runs.
519    fn establish(
520        path: &str,
521        customizer: &dyn XmtpConnection,
522    ) -> Result<SqliteConnection, PlatformStorageError> {
523        let mut conn = SqliteConnection::establish(path)?;
524        // Same per-connection setup the r2d2 customizer applies on checkout.
525        // Surface the `on_acquire` failure as `DbConnection` (its natural
526        // `From<diesel::r2d2::Error>` mapping) rather than boxing it, so a
527        // transient setup failure is classified as retryable — matching how the
528        // pooled path's checkout failures are retried. Boxing would mark these
529        // permanent and break reconnect/retry loops.
530        customizer
531            .on_acquire(&mut conn)
532            .map_err(PlatformStorageError::DbConnection)?;
533        // Same one-time pragmas DbPool::new applies on pool creation.
534        conn.batch_execute(&format!("PRAGMA busy_timeout = {};", BUSY_TIMEOUT))?;
535        conn.batch_execute("PRAGMA journal_mode = WAL;")?;
536        Ok(conn)
537    }
538
539    /// Drop the connection, releasing its file descriptor. This is the whole
540    /// point of single-connection mode for many-client processes: a disconnected
541    /// client holds zero fds. Subsequent `raw_query` calls fail with
542    /// `PoolNeedsConnection` until `reconnect` is called.
543    fn db_disconnect(&self) -> Result<(), PlatformStorageError> {
544        tracing::warn!("single-connection: dropping sqlite connection (releasing file descriptor)");
545        // Dropping the `SqliteConnection` here closes the underlying fd.
546        *self.conn.lock() = None;
547        Ok(())
548    }
549
550    fn db_reconnect(&self) -> Result<(), PlatformStorageError> {
551        tracing::info!("single-connection: reconnecting sqlite database connection");
552        let StorageOption::Persistent(path) = self.customizer.options() else {
553            return Err(PlatformStorageError::PoolRequiresPath);
554        };
555        // Drop the existing connection (releasing its fd) BEFORE establishing the
556        // new one, so we never momentarily hold two fds for the same client.
557        // Under a tight `ulimit -n` with many clients reconnecting at once, the
558        // old establish-then-swap order could otherwise transiently double the
559        // fd count and hit EMFILE. We hold the lock across the whole operation so
560        // a concurrent `raw_query` can't observe a half-open state.
561        let mut guard = self.conn.lock();
562        *guard = None;
563        *guard = Some(Self::establish(path, &*self.customizer)?);
564        Ok(())
565    }
566}
567
568impl ConnectionExt for SingleDbConnection {
569    fn raw_query<T, F>(&self, fun: F) -> Result<T, crate::ConnectionError>
570    where
571        F: FnOnce(&mut SqliteConnection) -> Result<T, diesel::result::Error>,
572        Self: Sized,
573    {
574        let mut guard = self.conn.lock();
575        match guard.as_mut() {
576            Some(conn) => fun(conn).map_err(ConnectionError::from),
577            // Connection was released by `disconnect`; mirror the pooled path's
578            // contract so retry/`db_needs_connection()` logic works identically.
579            None => Err(ConnectionError::from(
580                PlatformStorageError::PoolNeedsConnection,
581            )),
582        }
583    }
584
585    fn disconnect(&self) -> Result<(), crate::ConnectionError> {
586        Ok(self.db_disconnect()?)
587    }
588
589    fn reconnect(&self) -> Result<(), crate::ConnectionError> {
590        Ok(self.db_reconnect()?)
591    }
592}
593
594pub struct NativeDbConnection {
595    pub(super) pool: ArcSwapOption<DbPool>,
596    customizer: Box<dyn XmtpConnection>,
597    max_pool_size: u32,
598    min_pool_size: u32,
599}
600
601impl std::fmt::Debug for NativeDbConnection {
602    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
603        write!(
604            f,
605            "NativeDbConnection {{ path: {}, state={:?} }}",
606            self.customizer.options(),
607            self.pool.load().as_ref().map(|s| s.state()),
608        )
609    }
610}
611
612impl NativeDbConnection {
613    fn new(
614        customizer: Box<dyn XmtpConnection>,
615        max_pool_size: u32,
616        min_pool_size: u32,
617    ) -> Result<Self, PlatformStorageError> {
618        let pool = DbPool::builder()
619            .customizer(customizer.clone())
620            .max_size(max_pool_size)
621            .min_size(min_pool_size)
622            .build()?;
623
624        Ok(Self {
625            pool: ArcSwapOption::new(Some(Arc::new(pool))),
626            customizer,
627            max_pool_size,
628            min_pool_size,
629        })
630    }
631
632    fn db_disconnect(&self) -> Result<(), PlatformStorageError> {
633        tracing::warn!("released sqlite database connection");
634        self.pool.store(None);
635        Ok(())
636    }
637
638    fn db_reconnect(&self) -> Result<(), PlatformStorageError> {
639        tracing::info!("reconnecting sqlite database connection");
640        let pool = DbPool::builder()
641            .max_size(self.max_pool_size)
642            .min_size(self.min_pool_size)
643            .customizer(self.customizer.clone())
644            .build()?;
645        self.pool.store(Some(Arc::new(pool)));
646        Ok(())
647    }
648}
649
650impl ConnectionExt for NativeDbConnection {
651    fn raw_query<T, F>(&self, fun: F) -> Result<T, crate::ConnectionError>
652    where
653        F: FnOnce(&mut SqliteConnection) -> Result<T, diesel::result::Error>,
654        Self: Sized,
655    {
656        if let Some(pool) = &*self.pool.load() {
657            let mut conn = pool.get()?;
658            fun(&mut conn).map_err(ConnectionError::from)
659        } else {
660            Err(ConnectionError::from(
661                PlatformStorageError::PoolNeedsConnection,
662            ))
663        }
664    }
665
666    fn disconnect(&self) -> Result<(), ConnectionError> {
667        Ok(self.db_disconnect()?)
668    }
669
670    fn reconnect(&self) -> Result<(), ConnectionError> {
671        Ok(self.db_reconnect()?)
672    }
673}
674
675#[cfg(test)]
676mod tests {
677    use crate::{EncryptedMessageStore, XmtpTestDb};
678
679    use super::*;
680    use crate::{Fetch, Store, identity::StoredIdentity};
681    use xmtp_common::{rand_vec, tmp_path};
682
683    #[tokio::test]
684    async fn releases_db_lock() {
685        let db_path = tmp_path();
686        {
687            let store = crate::TestDb::create_persistent_store(Some(db_path.clone())).await;
688            let conn = &store.conn();
689
690            let inbox_id = "inbox_id";
691            StoredIdentity::new(inbox_id.to_string(), rand_vec::<24>(), rand_vec::<24>())
692                .store(conn)
693                .unwrap();
694
695            let fetched_identity: StoredIdentity = conn.fetch(&()).unwrap().unwrap();
696
697            assert_eq!(fetched_identity.inbox_id, inbox_id);
698
699            store.release_connection().unwrap();
700            if let PersistentOrMem::Persistent(p) = &*store.db.conn() {
701                assert!(p.pool.load().is_none())
702            } else {
703                panic!("conn expected")
704            }
705            store.reconnect().unwrap();
706            let fetched_identity2: StoredIdentity = conn.fetch(&()).unwrap().unwrap();
707
708            assert_eq!(fetched_identity2.inbox_id, inbox_id);
709        }
710
711        EncryptedMessageStore::<()>::remove_db_files(db_path)
712    }
713
714    #[xmtp_common::test(unwrap_try = true)]
715    async fn mismatched_encryption_key() {
716        use crate::database::PlatformStorageError;
717        use xmtp_common::{ErrorCode, RetryableError};
718        let mut enc_key = [1u8; 32];
719
720        let db_path = tmp_path();
721        {
722            let db = NativeDb::builder()
723                .persistent(db_path.clone())
724                .key(enc_key)
725                .build()
726                .unwrap();
727            db.init().unwrap();
728
729            StoredIdentity::new(
730                "dummy_address".to_string(),
731                rand_vec::<24>(),
732                rand_vec::<24>(),
733            )
734            .store(&db.conn())
735            .unwrap();
736        } // Drop it
737        enc_key[3] = 145; // Alter the enc_key
738        let err = NativeDb::builder()
739            .persistent(db_path.clone())
740            .key(enc_key)
741            .build()
742            .unwrap_err();
743        // Ensure it fails
744        assert!(
745            matches!(
746                err,
747                crate::StorageError::Platform(PlatformStorageError::SqlCipherKeyIncorrect)
748            ),
749            "Expected SqlCipherKeyIncorrect error, got {}",
750            err
751        );
752        assert_eq!(err.error_code(), "StorageError::Platform");
753        assert!(!err.is_retryable());
754        EncryptedMessageStore::<()>::remove_db_files(db_path)
755    }
756
757    #[tokio::test]
758    async fn single_connection_roundtrip_and_reconnect() {
759        use crate::{Fetch, Store, identity::StoredIdentity};
760
761        let db_path = tmp_path();
762        {
763            let db = NativeDb::builder()
764                .persistent(db_path.clone())
765                .key([7u8; 32])
766                .single_connection()
767                .build()
768                .unwrap();
769            db.init().unwrap();
770
771            assert!(
772                matches!(&*db.conn(), PersistentOrMem::Single(_)),
773                "expected Single arm for single_connection() persistent db"
774            );
775
776            let conn = db.conn();
777            let inbox_id = "single_conn_inbox";
778            StoredIdentity::new(inbox_id.to_string(), rand_vec::<24>(), rand_vec::<24>())
779                .store(&conn)
780                .unwrap();
781
782            let fetched: StoredIdentity = conn.fetch(&()).unwrap().unwrap();
783            assert_eq!(fetched.inbox_id, inbox_id);
784
785            conn.reconnect().unwrap();
786            let fetched2: StoredIdentity = conn.fetch(&()).unwrap().unwrap();
787            assert_eq!(fetched2.inbox_id, inbox_id);
788        }
789        EncryptedMessageStore::<()>::remove_db_files(db_path)
790    }
791
792    /// `disconnect` must actually drop the connection and release the file
793    /// descriptor (the hard requirement for many-client processes). After
794    /// disconnect: the inner connection is `None`, a query fails with a
795    /// `db_needs_connection()` error (same contract as the pool), and a
796    /// reconnect restores service.
797    #[tokio::test]
798    async fn single_connection_disconnect_releases_then_reconnect() {
799        use crate::{Fetch, Store, identity::StoredIdentity};
800
801        let db_path = tmp_path();
802        {
803            let db = NativeDb::builder()
804                .persistent(db_path.clone())
805                .key([8u8; 32])
806                .single_connection()
807                .build()
808                .unwrap();
809            db.init().unwrap();
810
811            let conn = db.conn();
812            let inbox_id = "fd_release_inbox";
813            StoredIdentity::new(inbox_id.to_string(), rand_vec::<24>(), rand_vec::<24>())
814                .store(&conn)
815                .unwrap();
816
817            // Healthy connection: query succeeds.
818            let ok: Result<Option<StoredIdentity>, _> = conn.fetch(&());
819            assert!(ok.is_ok());
820
821            // Disconnect drops the connection (releases the fd).
822            conn.disconnect().unwrap();
823            if let PersistentOrMem::Single(s) = &*db.conn() {
824                assert!(
825                    s.conn.lock().is_none(),
826                    "single connection should be dropped (fd released) after disconnect"
827                );
828            } else {
829                panic!("expected Single arm");
830            }
831
832            // A query against the released connection reports needs-connection,
833            // matching the pooled path's contract.
834            let res: Result<Option<StoredIdentity>, _> = conn.fetch(&());
835            let err = res.expect_err("query against a disconnected single connection should fail");
836            assert!(
837                err.db_needs_connection(),
838                "expected db_needs_connection() after disconnect, got: {err:?}"
839            );
840
841            // Reconnect restores service; data persisted on disk.
842            conn.reconnect().unwrap();
843            let fetched: StoredIdentity = conn.fetch(&()).unwrap().unwrap();
844            assert_eq!(fetched.inbox_id, inbox_id);
845        }
846        EncryptedMessageStore::<()>::remove_db_files(db_path)
847    }
848
849    #[xmtp_common::test(unwrap_try = true)]
850    async fn single_connection_mismatched_key_fails() {
851        use crate::database::PlatformStorageError;
852        use xmtp_common::{ErrorCode, RetryableError};
853
854        let db_path = tmp_path();
855        {
856            let db = NativeDb::builder()
857                .persistent(db_path.clone())
858                .key([1u8; 32])
859                .single_connection()
860                .build()
861                .unwrap();
862            db.init().unwrap();
863            StoredIdentity::new("addr".to_string(), rand_vec::<24>(), rand_vec::<24>())
864                .store(&db.conn())
865                .unwrap();
866        }
867        let mut bad = [1u8; 32];
868        bad[3] = 200;
869        let err = NativeDb::builder()
870            .persistent(db_path.clone())
871            .key(bad)
872            .single_connection()
873            .build()
874            .unwrap_err();
875        assert!(
876            matches!(
877                err,
878                crate::StorageError::Platform(PlatformStorageError::SqlCipherKeyIncorrect)
879            ),
880            "expected SqlCipherKeyIncorrect, got {err}"
881        );
882        assert_eq!(err.error_code(), "StorageError::Platform");
883        assert!(!err.is_retryable());
884        EncryptedMessageStore::<()>::remove_db_files(db_path)
885    }
886
887    // Exercises a transaction + nested savepoint on a single (non-reentrant
888    // Mutex) connection. The single-connection mode threads one `&mut
889    // SqliteConnection` down through the transaction closure, so re-deriving a
890    // transaction-scoped key store inside the closure (and again inside the
891    // savepoint) must NOT re-acquire the outer Mutex and deadlock. Reaching the
892    // assertion at all proves there is no deadlock; the COUNT proves the writes
893    // persisted.
894    #[tokio::test]
895    async fn single_connection_nested_transaction_no_deadlock() {
896        use crate::{
897            ConnectionExt, StorageError, Store, StoreOrIgnore,
898            TransactionOutcome::Continue,
899            TransactionalKeyStore, XmtpMlsStorageProvider,
900            refresh_state::{EntityKind, RefreshState},
901            sql_key_store::SqlKeyStore,
902        };
903        use diesel::prelude::*;
904
905        let db_path = tmp_path();
906        {
907            let db = NativeDb::builder()
908                .persistent(db_path.clone())
909                .key([9u8; 32])
910                .single_connection()
911                .build()
912                .unwrap();
913            db.init().unwrap();
914
915            // The storage provider wraps the single connection (an `Arc<PersistentOrMem<..>>`
916            // that implements `ConnectionExt`). `SqlKeyStore<C>` implements
917            // `XmtpMlsStorageProvider`, exposing `.transaction()`.
918            let provider = SqlKeyStore::new(db.conn());
919
920            provider
921                .transaction(|conn| {
922                    // `conn` is `&mut SqliteConnection`; `key_store()` (from
923                    // `TransactionalKeyStore`) gives a transaction-scoped provider.
924                    // `identity` is a singleton table, so only the outer write
925                    // targets it; the nested savepoint writes to a multi-row
926                    // table (`refresh_state`) to avoid a constraint collision.
927                    let storage = conn.key_store();
928                    StoredIdentity::new(
929                        "txn_outer".to_string(),
930                        rand_vec::<24>(),
931                        rand_vec::<24>(),
932                    )
933                    .store(&storage.db())?;
934
935                    // Nested write inside a SQLite savepoint, re-deriving the
936                    // key store from the savepoint's `&mut SqliteConnection`.
937                    storage.savepoint(|sp_conn| {
938                        let inner = sp_conn.key_store();
939                        RefreshState {
940                            entity_id: rand_vec::<24>(),
941                            entity_kind: EntityKind::Welcome,
942                            sequence_id: 1,
943                            received_sequence_id: None,
944                        }
945                        .store_or_ignore(&inner.db())?;
946                        Ok::<_, StorageError>(Continue(()))
947                    })?;
948                    Ok::<_, StorageError>(Continue(()))
949                })
950                .unwrap();
951
952            // Reaching here means no deadlock. Confirm BOTH writes persisted:
953            // the outer transaction's `identity` row and the nested savepoint's
954            // `refresh_state` row. Counting only the outer would let a silently
955            // rolled-back / skipped savepoint go undetected.
956            let (identity_count, refresh_count): (i64, i64) = db
957                .conn()
958                .raw_query(|c| {
959                    use diesel::dsl::sql;
960                    use diesel::sql_types::BigInt;
961                    let identity_count =
962                        diesel::select(sql::<BigInt>("(SELECT COUNT(*) FROM identity)"))
963                            .get_result(c)?;
964                    let refresh_count =
965                        diesel::select(sql::<BigInt>("(SELECT COUNT(*) FROM refresh_state)"))
966                            .get_result(c)?;
967                    Ok((identity_count, refresh_count))
968                })
969                .unwrap();
970            assert!(identity_count >= 1, "expected the outer identity row");
971            assert!(
972                refresh_count >= 1,
973                "expected the nested savepoint's refresh_state row to persist"
974            );
975        }
976        EncryptedMessageStore::<()>::remove_db_files(db_path)
977    }
978}