Skip to main content

cdk_sql_common/mint/
mod.rs

1//! SQL database implementation of the Mint
2//!
3//! This is a generic SQL implementation for the mint storage layer. Any database can be plugged in
4//! as long as standard ANSI SQL is used, as Postgres and SQLite would understand it.
5//!
6//! This implementation also has a rudimentary but standard migration and versioning system.
7//!
8//! The trait expects an asynchronous interaction, but it also provides tools to spawn blocking
9//! clients in a pool and expose them to an asynchronous environment, making them compatible with
10//! Mint.
11use std::fmt::Debug;
12use std::sync::Arc;
13
14use async_trait::async_trait;
15use cdk_common::database::{self, DbTransactionFinalizer, Error, MintDatabase};
16use cdk_common::QuoteId;
17
18use crate::common::migrate;
19use crate::database::{ConnectionWithTransaction, DatabaseExecutor};
20use crate::pool::{DatabaseConfig, DatabasePool, Pool, PooledResource};
21use crate::stmt::query;
22
23mod auth;
24mod completed_operations;
25mod keys;
26mod keyvalue;
27mod proofs;
28mod quotes;
29mod saga;
30mod signatures;
31
32#[rustfmt::skip]
33mod migrations {
34    include!(concat!(env!("OUT_DIR"), "/migrations_mint.rs"));
35}
36
37pub use auth::SQLMintAuthDatabase;
38#[cfg(feature = "prometheus")]
39use cdk_prometheus::MintMetricGuard;
40use migrations::MIGRATIONS;
41
42/// Mint SQL Database
43#[derive(Debug, Clone)]
44pub struct SQLMintDatabase<RM>
45where
46    RM: DatabasePool + 'static,
47{
48    pub(crate) pool: Arc<Pool<RM>>,
49    dispatch_pool: Option<Arc<Pool<RM>>>,
50}
51
52/// SQL Transaction Writer
53#[allow(missing_debug_implementations)]
54pub struct SQLTransaction<RM>
55where
56    RM: DatabasePool + 'static,
57{
58    pub(crate) inner: ConnectionWithTransaction<RM::Connection, PooledResource<RM>>,
59}
60
61/// Sorted, deduplicated advisory lock keys for a batch of quotes.
62fn quote_lock_keys(quote_ids: &[QuoteId]) -> Vec<String> {
63    let mut keys = quote_ids
64        .iter()
65        .map(|quote_id| format!("cdk:quote:{quote_id}"))
66        .collect::<Vec<_>>();
67    keys.sort();
68    keys.dedup();
69    keys
70}
71
72impl<RM> SQLMintDatabase<RM>
73where
74    RM: DatabasePool + 'static,
75{
76    /// Creates a new instance
77    pub async fn new<X>(db: X) -> Result<Self, Error>
78    where
79        X: Into<RM::Config>,
80    {
81        let config = db.into();
82        let configured_max_size = config.max_size();
83        let (pool, dispatch_pool) =
84            if RM::Connection::name() == "postgres" && configured_max_size >= 2 {
85                // Keep long-lived payment dispatches from consuming the pool used
86                // for quote checks, payment events, and recovery. Both pools share
87                // the configured connection budget.
88                let dispatch_pool_size = (configured_max_size / 4).clamp(1, 8);
89                let regular_pool_size = configured_max_size - dispatch_pool_size;
90                (
91                    Pool::new_with_max_size(config.clone(), regular_pool_size),
92                    Some(Pool::new_with_max_size(config, dispatch_pool_size)),
93                )
94            } else {
95                (Pool::new(config), None)
96            };
97
98        Self::migrate(pool.get().await.map_err(|e| Error::Database(Box::new(e)))?).await?;
99
100        Ok(Self {
101            pool,
102            dispatch_pool,
103        })
104    }
105
106    async fn begin_transaction_from_pool(
107        pool: &Arc<Pool<RM>>,
108    ) -> Result<Box<dyn database::MintTransaction<Error> + Send + Sync>, Error> {
109        let tx = SQLTransaction {
110            inner: ConnectionWithTransaction::new(
111                pool.get().await.map_err(|e| Error::Database(Box::new(e)))?,
112            )
113            .await?,
114        };
115
116        Ok(Box::new(tx))
117    }
118
119    /// Migrate
120    async fn migrate(conn: PooledResource<RM>) -> Result<(), Error> {
121        let tx = ConnectionWithTransaction::new(conn).await?;
122        migrate(&tx, RM::Connection::name(), MIGRATIONS).await?;
123        tx.commit().await?;
124        Ok(())
125    }
126}
127
128impl<RM> SQLTransaction<RM>
129where
130    RM: DatabasePool + 'static,
131{
132    /// Take quote advisory locks in one statement and stable key order.
133    async fn take_quote_locks(&mut self, quote_ids: &[QuoteId]) -> Result<bool, Error> {
134        if quote_ids.is_empty() || RM::Connection::name() != "postgres" {
135            return Ok(false);
136        }
137
138        query(
139            r#"
140            SELECT pg_advisory_xact_lock(hashtextextended(key, 0))
141            FROM (
142                SELECT key FROM unnest(ARRAY[:keys]::TEXT[]) AS t(key) ORDER BY key
143            ) sorted
144            "#,
145        )?
146        .bind_vec("keys", quote_lock_keys(quote_ids))?
147        .execute(&self.inner)
148        .await?;
149
150        Ok(true)
151    }
152
153    /// Attempt quote advisory locks without waiting, in stable key order.
154    async fn try_take_quote_locks(
155        &mut self,
156        quote_ids: &[QuoteId],
157    ) -> Result<database::mint::QuoteLockAttempt, Error> {
158        if quote_ids.is_empty() || RM::Connection::name() != "postgres" {
159            return Ok(database::mint::QuoteLockAttempt::Unsupported);
160        }
161
162        // Rows that fail to lock are returned; rows that succeed are locked
163        // until the transaction ends. The caller rolls back on `Contended`,
164        // releasing any partial acquisitions.
165        let missed = query(
166            r#"
167            SELECT key FROM (
168                SELECT key FROM unnest(ARRAY[:keys]::TEXT[]) AS t(key) ORDER BY key
169            ) sorted
170            WHERE NOT pg_try_advisory_xact_lock(hashtextextended(key, 0))
171            "#,
172        )?
173        .bind_vec("keys", quote_lock_keys(quote_ids))?
174        .fetch_all(&self.inner)
175        .await?;
176
177        Ok(if missed.is_empty() {
178            database::mint::QuoteLockAttempt::Acquired
179        } else {
180            database::mint::QuoteLockAttempt::Contended
181        })
182    }
183}
184
185#[async_trait]
186impl<RM> database::MintTransaction<Error> for SQLTransaction<RM>
187where
188    RM: DatabasePool + 'static,
189{
190    async fn lock_quotes(&mut self, quote_ids: &[QuoteId]) -> Result<bool, Error> {
191        self.take_quote_locks(quote_ids).await
192    }
193
194    async fn try_lock_quotes(
195        &mut self,
196        quote_ids: &[QuoteId],
197    ) -> Result<database::mint::QuoteLockAttempt, Error> {
198        self.try_take_quote_locks(quote_ids).await
199    }
200}
201
202#[async_trait]
203impl<RM> DbTransactionFinalizer for SQLTransaction<RM>
204where
205    RM: DatabasePool + 'static,
206{
207    type Err = Error;
208
209    async fn commit(self: Box<Self>) -> Result<(), Error> {
210        #[cfg(feature = "prometheus")]
211        let metrics = MintMetricGuard::new("transaction_commit");
212
213        let result = self.inner.commit().await;
214
215        #[cfg(feature = "prometheus")]
216        {
217            metrics.record(result.is_ok());
218        }
219
220        Ok(result?)
221    }
222
223    async fn rollback(self: Box<Self>) -> Result<(), Error> {
224        #[cfg(feature = "prometheus")]
225        let metrics = MintMetricGuard::new("transaction_rollback");
226
227        let result = self.inner.rollback().await;
228
229        #[cfg(feature = "prometheus")]
230        {
231            metrics.record(result.is_ok());
232        }
233        Ok(result?)
234    }
235}
236
237#[async_trait]
238impl<RM> MintDatabase<Error> for SQLMintDatabase<RM>
239where
240    RM: DatabasePool + 'static,
241{
242    async fn begin_transaction(
243        &self,
244    ) -> Result<Box<dyn database::MintTransaction<Error> + Send + Sync>, Error> {
245        Self::begin_transaction_from_pool(&self.pool).await
246    }
247
248    async fn begin_dispatch_transaction(
249        &self,
250    ) -> Result<Box<dyn database::MintTransaction<Error> + Send + Sync>, Error> {
251        let pool = self.dispatch_pool.as_ref().unwrap_or(&self.pool);
252        Self::begin_transaction_from_pool(pool).await
253    }
254}
255
256#[cfg(all(test, feature = "prometheus"))]
257mod tests {
258    use std::fmt;
259    use std::sync::atomic::AtomicBool;
260    use std::sync::Arc;
261    use std::time::Duration;
262
263    use cdk_common::database::{DbTransactionFinalizer, Error as DatabaseError};
264    use cdk_prometheus::METRICS;
265
266    use super::SQLTransaction;
267    use crate::database::{
268        ConnectionWithTransaction, DatabaseConnector, DatabaseExecutor, DatabaseTransaction,
269    };
270    use crate::pool::{DatabaseConfig, DatabasePool, Error as PoolError, Pool};
271    use crate::stmt::{Column, Statement};
272
273    #[derive(Debug, Clone)]
274    struct TestConfig {
275        fail_commit: bool,
276        fail_rollback: bool,
277    }
278
279    impl DatabaseConfig for TestConfig {
280        fn max_size(&self) -> usize {
281            1
282        }
283
284        fn default_timeout(&self) -> Duration {
285            Duration::from_millis(10)
286        }
287    }
288
289    #[derive(Debug)]
290    struct TestResourceError;
291
292    impl fmt::Display for TestResourceError {
293        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294            f.write_str("test resource error")
295        }
296    }
297
298    impl std::error::Error for TestResourceError {}
299
300    #[derive(Debug)]
301    struct TestConnection {
302        fail_commit: bool,
303        fail_rollback: bool,
304    }
305
306    #[async_trait::async_trait]
307    impl DatabaseExecutor for TestConnection {
308        fn name() -> &'static str {
309            "test"
310        }
311
312        async fn execute(&self, _statement: Statement) -> Result<usize, DatabaseError> {
313            Ok(0)
314        }
315
316        async fn fetch_one(
317            &self,
318            _statement: Statement,
319        ) -> Result<Option<Vec<Column>>, DatabaseError> {
320            Ok(None)
321        }
322
323        async fn fetch_all(
324            &self,
325            _statement: Statement,
326        ) -> Result<Vec<Vec<Column>>, DatabaseError> {
327            Ok(Vec::new())
328        }
329
330        async fn pluck(&self, _statement: Statement) -> Result<Option<Column>, DatabaseError> {
331            Ok(None)
332        }
333
334        async fn batch(&self, _statement: Statement) -> Result<(), DatabaseError> {
335            Ok(())
336        }
337    }
338
339    #[derive(Debug)]
340    struct TestTransaction;
341
342    #[async_trait::async_trait]
343    impl DatabaseTransaction<TestConnection> for TestTransaction {
344        async fn commit(conn: &mut TestConnection) -> Result<(), DatabaseError> {
345            if conn.fail_commit {
346                Err(DatabaseError::Internal("commit failed".to_owned()))
347            } else {
348                Ok(())
349            }
350        }
351
352        async fn begin(_conn: &mut TestConnection) -> Result<(), DatabaseError> {
353            Ok(())
354        }
355
356        async fn rollback(conn: &mut TestConnection) -> Result<(), DatabaseError> {
357            if conn.fail_rollback {
358                Err(DatabaseError::Internal("rollback failed".to_owned()))
359            } else {
360                Ok(())
361            }
362        }
363    }
364
365    impl DatabaseConnector for TestConnection {
366        type Transaction = TestTransaction;
367    }
368
369    #[derive(Debug)]
370    struct TestPool;
371
372    impl DatabasePool for TestPool {
373        type Connection = TestConnection;
374        type Config = TestConfig;
375        type Error = TestResourceError;
376
377        fn new_resource(
378            config: &Self::Config,
379            _stale: Arc<AtomicBool>,
380            _timeout: Duration,
381        ) -> Result<Self::Connection, PoolError<Self::Error>> {
382            Ok(TestConnection {
383                fail_commit: config.fail_commit,
384                fail_rollback: config.fail_rollback,
385            })
386        }
387    }
388
389    async fn new_transaction(fail_commit: bool, fail_rollback: bool) -> SQLTransaction<TestPool> {
390        let pool = Pool::<TestPool>::new(TestConfig {
391            fail_commit,
392            fail_rollback,
393        });
394        let conn = pool
395            .get()
396            .await
397            .expect("test resource should be checked out");
398        let inner = ConnectionWithTransaction::new(conn)
399            .await
400            .expect("test transaction should begin");
401
402        SQLTransaction { inner }
403    }
404
405    fn labels_match(
406        metric: &cdk_prometheus::prometheus::proto::Metric,
407        labels: &[(&str, &str)],
408    ) -> bool {
409        labels.iter().all(|(name, value)| {
410            metric
411                .get_label()
412                .iter()
413                .any(|label| label.name() == *name && label.value() == *value)
414        })
415    }
416
417    fn counter_value(name: &str, labels: &[(&str, &str)]) -> f64 {
418        for family in METRICS.registry().gather() {
419            if family.name() != name {
420                continue;
421            }
422
423            for metric in family.get_metric() {
424                if labels_match(metric, labels) {
425                    return metric.get_counter().value();
426                }
427            }
428        }
429
430        0.0
431    }
432
433    fn gauge_value(name: &str, labels: &[(&str, &str)]) -> f64 {
434        for family in METRICS.registry().gather() {
435            if family.name() != name {
436                continue;
437            }
438
439            for metric in family.get_metric() {
440                if labels_match(metric, labels) {
441                    return metric.get_gauge().value();
442                }
443            }
444        }
445
446        0.0
447    }
448
449    fn histogram_count(name: &str, labels: &[(&str, &str)]) -> f64 {
450        for family in METRICS.registry().gather() {
451            if family.name() != name {
452                continue;
453            }
454
455            for metric in family.get_metric() {
456                if labels_match(metric, labels) {
457                    return metric.get_histogram().sample_count() as f64;
458                }
459            }
460        }
461
462        0.0
463    }
464
465    #[tokio::test(flavor = "current_thread")]
466    async fn transaction_commit_records_success_duration_and_balances_in_flight() {
467        let _lock = crate::metrics_test_lock::lock().await;
468        let operation = "transaction_commit";
469        let labels = [("operation", operation), ("status", "success")];
470        let in_flight_labels = [("operation", operation)];
471
472        let success_before = counter_value("cdk_mint_operations_total", &labels);
473        let duration_count_before = histogram_count("cdk_mint_operation_duration_seconds", &labels);
474        let in_flight_before = gauge_value("cdk_mint_in_flight_requests", &in_flight_labels);
475
476        let tx = new_transaction(false, false).await;
477        Box::new(tx)
478            .commit()
479            .await
480            .expect("transaction commit should succeed");
481
482        assert_eq!(
483            counter_value("cdk_mint_operations_total", &labels),
484            success_before + 1.0
485        );
486        assert_eq!(
487            histogram_count("cdk_mint_operation_duration_seconds", &labels),
488            duration_count_before + 1.0
489        );
490        assert_eq!(
491            gauge_value("cdk_mint_in_flight_requests", &in_flight_labels),
492            in_flight_before
493        );
494    }
495
496    #[tokio::test(flavor = "current_thread")]
497    async fn transaction_commit_records_error_duration_and_balances_in_flight() {
498        let _lock = crate::metrics_test_lock::lock().await;
499        let operation = "transaction_commit";
500        let labels = [("operation", operation), ("status", "error")];
501        let in_flight_labels = [("operation", operation)];
502
503        let error_before = counter_value("cdk_mint_operations_total", &labels);
504        let duration_count_before = histogram_count("cdk_mint_operation_duration_seconds", &labels);
505        let in_flight_before = gauge_value("cdk_mint_in_flight_requests", &in_flight_labels);
506
507        let tx = new_transaction(true, false).await;
508        Box::new(tx)
509            .commit()
510            .await
511            .expect_err("transaction commit should fail");
512
513        assert_eq!(
514            counter_value("cdk_mint_operations_total", &labels),
515            error_before + 1.0
516        );
517        assert_eq!(
518            histogram_count("cdk_mint_operation_duration_seconds", &labels),
519            duration_count_before + 1.0
520        );
521        assert_eq!(
522            gauge_value("cdk_mint_in_flight_requests", &in_flight_labels),
523            in_flight_before
524        );
525    }
526
527    #[tokio::test(flavor = "current_thread")]
528    async fn transaction_rollback_records_success_duration_and_balances_in_flight() {
529        let _lock = crate::metrics_test_lock::lock().await;
530        let operation = "transaction_rollback";
531        let labels = [("operation", operation), ("status", "success")];
532        let in_flight_labels = [("operation", operation)];
533
534        let success_before = counter_value("cdk_mint_operations_total", &labels);
535        let duration_count_before = histogram_count("cdk_mint_operation_duration_seconds", &labels);
536        let in_flight_before = gauge_value("cdk_mint_in_flight_requests", &in_flight_labels);
537
538        let tx = new_transaction(false, false).await;
539        Box::new(tx)
540            .rollback()
541            .await
542            .expect("transaction rollback should succeed");
543
544        assert_eq!(
545            counter_value("cdk_mint_operations_total", &labels),
546            success_before + 1.0
547        );
548        assert_eq!(
549            histogram_count("cdk_mint_operation_duration_seconds", &labels),
550            duration_count_before + 1.0
551        );
552        assert_eq!(
553            gauge_value("cdk_mint_in_flight_requests", &in_flight_labels),
554            in_flight_before
555        );
556    }
557}
558
559#[cfg(test)]
560mod quote_lock_tests {
561    use super::*;
562
563    #[test]
564    fn quote_lock_keys_are_sorted_and_deduplicated() {
565        let first = QuoteId::BASE64("YQ==".to_owned());
566        let second = QuoteId::BASE64("Yg==".to_owned());
567        let mut expected = vec![format!("cdk:quote:{first}"), format!("cdk:quote:{second}")];
568        expected.sort();
569
570        assert_eq!(quote_lock_keys(&[second.clone(), first, second]), expected);
571    }
572}