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::{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}
50
51/// SQL Transaction Writer
52#[allow(missing_debug_implementations)]
53pub struct SQLTransaction<RM>
54where
55    RM: DatabasePool + 'static,
56{
57    pub(crate) inner: ConnectionWithTransaction<RM::Connection, PooledResource<RM>>,
58}
59
60/// Sorted, deduplicated advisory lock keys for a batch of quotes.
61fn quote_lock_keys(quote_ids: &[QuoteId]) -> Vec<String> {
62    let mut keys = quote_ids
63        .iter()
64        .map(|quote_id| format!("cdk:quote:{quote_id}"))
65        .collect::<Vec<_>>();
66    keys.sort();
67    keys.dedup();
68    keys
69}
70
71impl<RM> SQLMintDatabase<RM>
72where
73    RM: DatabasePool + 'static,
74{
75    /// Creates a new instance
76    pub async fn new<X>(db: X) -> Result<Self, Error>
77    where
78        X: Into<RM::Config>,
79    {
80        let pool = Pool::new(db.into());
81
82        Self::migrate(pool.get().await.map_err(|e| Error::Database(Box::new(e)))?).await?;
83
84        Ok(Self { pool })
85    }
86
87    async fn begin_transaction_from_pool(
88        pool: &Arc<Pool<RM>>,
89    ) -> Result<Box<dyn database::MintTransaction<Error> + Send + Sync>, Error> {
90        let tx = SQLTransaction {
91            inner: ConnectionWithTransaction::new(
92                pool.get().await.map_err(|e| Error::Database(Box::new(e)))?,
93            )
94            .await?,
95        };
96
97        Ok(Box::new(tx))
98    }
99
100    /// Migrate
101    async fn migrate(conn: PooledResource<RM>) -> Result<(), Error> {
102        let tx = ConnectionWithTransaction::new(conn).await?;
103        migrate(&tx, RM::Connection::name(), MIGRATIONS).await?;
104        tx.commit().await?;
105        Ok(())
106    }
107}
108
109impl<RM> SQLTransaction<RM>
110where
111    RM: DatabasePool + 'static,
112{
113    /// Take quote advisory locks in one statement and stable key order.
114    async fn take_quote_locks(&mut self, quote_ids: &[QuoteId]) -> Result<bool, Error> {
115        if quote_ids.is_empty() || RM::Connection::name() != "postgres" {
116            return Ok(false);
117        }
118
119        query(
120            r#"
121            SELECT pg_advisory_xact_lock(hashtextextended(key, 0))
122            FROM (
123                SELECT key FROM unnest(ARRAY[:keys]::TEXT[]) AS t(key) ORDER BY key
124            ) sorted
125            "#,
126        )?
127        .bind_vec("keys", quote_lock_keys(quote_ids))?
128        .execute(&self.inner)
129        .await?;
130
131        Ok(true)
132    }
133}
134
135#[async_trait]
136impl<RM> database::MintTransaction<Error> for SQLTransaction<RM>
137where
138    RM: DatabasePool + 'static,
139{
140    async fn lock_quotes(&mut self, quote_ids: &[QuoteId]) -> Result<bool, Error> {
141        self.take_quote_locks(quote_ids).await
142    }
143}
144
145#[async_trait]
146impl<RM> DbTransactionFinalizer for SQLTransaction<RM>
147where
148    RM: DatabasePool + 'static,
149{
150    type Err = Error;
151
152    async fn commit(self: Box<Self>) -> Result<(), Error> {
153        #[cfg(feature = "prometheus")]
154        let metrics = MintMetricGuard::new("transaction_commit");
155
156        let result = self.inner.commit().await;
157
158        #[cfg(feature = "prometheus")]
159        {
160            metrics.record(result.is_ok());
161        }
162
163        Ok(result?)
164    }
165
166    async fn rollback(self: Box<Self>) -> Result<(), Error> {
167        #[cfg(feature = "prometheus")]
168        let metrics = MintMetricGuard::new("transaction_rollback");
169
170        let result = self.inner.rollback().await;
171
172        #[cfg(feature = "prometheus")]
173        {
174            metrics.record(result.is_ok());
175        }
176        Ok(result?)
177    }
178}
179
180#[async_trait]
181impl<RM> MintDatabase<Error> for SQLMintDatabase<RM>
182where
183    RM: DatabasePool + 'static,
184{
185    async fn begin_transaction(
186        &self,
187    ) -> Result<Box<dyn database::MintTransaction<Error> + Send + Sync>, Error> {
188        Self::begin_transaction_from_pool(&self.pool).await
189    }
190}
191
192#[cfg(all(test, feature = "prometheus"))]
193mod tests {
194    use std::fmt;
195    use std::sync::atomic::AtomicBool;
196    use std::sync::Arc;
197    use std::time::Duration;
198
199    use cdk_common::database::{DbTransactionFinalizer, Error as DatabaseError};
200    use cdk_prometheus::METRICS;
201
202    use super::SQLTransaction;
203    use crate::database::{
204        ConnectionWithTransaction, DatabaseConnector, DatabaseExecutor, DatabaseTransaction,
205    };
206    use crate::pool::{DatabaseConfig, DatabasePool, Error as PoolError, Pool};
207    use crate::stmt::{Column, Statement};
208
209    #[derive(Debug, Clone)]
210    struct TestConfig {
211        fail_commit: bool,
212        fail_rollback: bool,
213    }
214
215    impl DatabaseConfig for TestConfig {
216        fn max_size(&self) -> usize {
217            1
218        }
219
220        fn default_timeout(&self) -> Duration {
221            Duration::from_millis(10)
222        }
223    }
224
225    #[derive(Debug)]
226    struct TestResourceError;
227
228    impl fmt::Display for TestResourceError {
229        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230            f.write_str("test resource error")
231        }
232    }
233
234    impl std::error::Error for TestResourceError {}
235
236    #[derive(Debug)]
237    struct TestConnection {
238        fail_commit: bool,
239        fail_rollback: bool,
240    }
241
242    #[async_trait::async_trait]
243    impl DatabaseExecutor for TestConnection {
244        fn name() -> &'static str {
245            "test"
246        }
247
248        async fn execute(&self, _statement: Statement) -> Result<usize, DatabaseError> {
249            Ok(0)
250        }
251
252        async fn fetch_one(
253            &self,
254            _statement: Statement,
255        ) -> Result<Option<Vec<Column>>, DatabaseError> {
256            Ok(None)
257        }
258
259        async fn fetch_all(
260            &self,
261            _statement: Statement,
262        ) -> Result<Vec<Vec<Column>>, DatabaseError> {
263            Ok(Vec::new())
264        }
265
266        async fn pluck(&self, _statement: Statement) -> Result<Option<Column>, DatabaseError> {
267            Ok(None)
268        }
269
270        async fn batch(&self, _statement: Statement) -> Result<(), DatabaseError> {
271            Ok(())
272        }
273    }
274
275    #[derive(Debug)]
276    struct TestTransaction;
277
278    #[async_trait::async_trait]
279    impl DatabaseTransaction<TestConnection> for TestTransaction {
280        async fn commit(conn: &mut TestConnection) -> Result<(), DatabaseError> {
281            if conn.fail_commit {
282                Err(DatabaseError::Internal("commit failed".to_owned()))
283            } else {
284                Ok(())
285            }
286        }
287
288        async fn begin(_conn: &mut TestConnection) -> Result<(), DatabaseError> {
289            Ok(())
290        }
291
292        async fn rollback(conn: &mut TestConnection) -> Result<(), DatabaseError> {
293            if conn.fail_rollback {
294                Err(DatabaseError::Internal("rollback failed".to_owned()))
295            } else {
296                Ok(())
297            }
298        }
299    }
300
301    impl DatabaseConnector for TestConnection {
302        type Transaction = TestTransaction;
303    }
304
305    #[derive(Debug)]
306    struct TestPool;
307
308    impl DatabasePool for TestPool {
309        type Connection = TestConnection;
310        type Config = TestConfig;
311        type Error = TestResourceError;
312
313        fn new_resource(
314            config: &Self::Config,
315            _stale: Arc<AtomicBool>,
316            _timeout: Duration,
317        ) -> Result<Self::Connection, PoolError<Self::Error>> {
318            Ok(TestConnection {
319                fail_commit: config.fail_commit,
320                fail_rollback: config.fail_rollback,
321            })
322        }
323    }
324
325    async fn new_transaction(fail_commit: bool, fail_rollback: bool) -> SQLTransaction<TestPool> {
326        let pool = Pool::<TestPool>::new(TestConfig {
327            fail_commit,
328            fail_rollback,
329        });
330        let conn = pool
331            .get()
332            .await
333            .expect("test resource should be checked out");
334        let inner = ConnectionWithTransaction::new(conn)
335            .await
336            .expect("test transaction should begin");
337
338        SQLTransaction { inner }
339    }
340
341    fn labels_match(
342        metric: &cdk_prometheus::prometheus::proto::Metric,
343        labels: &[(&str, &str)],
344    ) -> bool {
345        labels.iter().all(|(name, value)| {
346            metric
347                .get_label()
348                .iter()
349                .any(|label| label.name() == *name && label.value() == *value)
350        })
351    }
352
353    fn counter_value(name: &str, labels: &[(&str, &str)]) -> f64 {
354        for family in METRICS.registry().gather() {
355            if family.name() != name {
356                continue;
357            }
358
359            for metric in family.get_metric() {
360                if labels_match(metric, labels) {
361                    return metric.get_counter().value();
362                }
363            }
364        }
365
366        0.0
367    }
368
369    fn gauge_value(name: &str, labels: &[(&str, &str)]) -> f64 {
370        for family in METRICS.registry().gather() {
371            if family.name() != name {
372                continue;
373            }
374
375            for metric in family.get_metric() {
376                if labels_match(metric, labels) {
377                    return metric.get_gauge().value();
378                }
379            }
380        }
381
382        0.0
383    }
384
385    fn histogram_count(name: &str, labels: &[(&str, &str)]) -> f64 {
386        for family in METRICS.registry().gather() {
387            if family.name() != name {
388                continue;
389            }
390
391            for metric in family.get_metric() {
392                if labels_match(metric, labels) {
393                    return metric.get_histogram().sample_count() as f64;
394                }
395            }
396        }
397
398        0.0
399    }
400
401    #[tokio::test(flavor = "current_thread")]
402    async fn transaction_commit_records_success_duration_and_balances_in_flight() {
403        let _lock = crate::metrics_test_lock::lock().await;
404        let operation = "transaction_commit";
405        let labels = [("operation", operation), ("status", "success")];
406        let in_flight_labels = [("operation", operation)];
407
408        let success_before = counter_value("cdk_mint_operations_total", &labels);
409        let duration_count_before = histogram_count("cdk_mint_operation_duration_seconds", &labels);
410        let in_flight_before = gauge_value("cdk_mint_in_flight_requests", &in_flight_labels);
411
412        let tx = new_transaction(false, false).await;
413        Box::new(tx)
414            .commit()
415            .await
416            .expect("transaction commit should succeed");
417
418        assert_eq!(
419            counter_value("cdk_mint_operations_total", &labels),
420            success_before + 1.0
421        );
422        assert_eq!(
423            histogram_count("cdk_mint_operation_duration_seconds", &labels),
424            duration_count_before + 1.0
425        );
426        assert_eq!(
427            gauge_value("cdk_mint_in_flight_requests", &in_flight_labels),
428            in_flight_before
429        );
430    }
431
432    #[tokio::test(flavor = "current_thread")]
433    async fn transaction_commit_records_error_duration_and_balances_in_flight() {
434        let _lock = crate::metrics_test_lock::lock().await;
435        let operation = "transaction_commit";
436        let labels = [("operation", operation), ("status", "error")];
437        let in_flight_labels = [("operation", operation)];
438
439        let error_before = counter_value("cdk_mint_operations_total", &labels);
440        let duration_count_before = histogram_count("cdk_mint_operation_duration_seconds", &labels);
441        let in_flight_before = gauge_value("cdk_mint_in_flight_requests", &in_flight_labels);
442
443        let tx = new_transaction(true, false).await;
444        Box::new(tx)
445            .commit()
446            .await
447            .expect_err("transaction commit should fail");
448
449        assert_eq!(
450            counter_value("cdk_mint_operations_total", &labels),
451            error_before + 1.0
452        );
453        assert_eq!(
454            histogram_count("cdk_mint_operation_duration_seconds", &labels),
455            duration_count_before + 1.0
456        );
457        assert_eq!(
458            gauge_value("cdk_mint_in_flight_requests", &in_flight_labels),
459            in_flight_before
460        );
461    }
462
463    #[tokio::test(flavor = "current_thread")]
464    async fn transaction_rollback_records_success_duration_and_balances_in_flight() {
465        let _lock = crate::metrics_test_lock::lock().await;
466        let operation = "transaction_rollback";
467        let labels = [("operation", operation), ("status", "success")];
468        let in_flight_labels = [("operation", operation)];
469
470        let success_before = counter_value("cdk_mint_operations_total", &labels);
471        let duration_count_before = histogram_count("cdk_mint_operation_duration_seconds", &labels);
472        let in_flight_before = gauge_value("cdk_mint_in_flight_requests", &in_flight_labels);
473
474        let tx = new_transaction(false, false).await;
475        Box::new(tx)
476            .rollback()
477            .await
478            .expect("transaction rollback should succeed");
479
480        assert_eq!(
481            counter_value("cdk_mint_operations_total", &labels),
482            success_before + 1.0
483        );
484        assert_eq!(
485            histogram_count("cdk_mint_operation_duration_seconds", &labels),
486            duration_count_before + 1.0
487        );
488        assert_eq!(
489            gauge_value("cdk_mint_in_flight_requests", &in_flight_labels),
490            in_flight_before
491        );
492    }
493}
494
495#[cfg(test)]
496mod quote_lock_tests {
497    use super::*;
498
499    #[test]
500    fn quote_lock_keys_are_sorted_and_deduplicated() {
501        let first = QuoteId::BASE64("YQ==".to_owned());
502        let second = QuoteId::BASE64("Yg==".to_owned());
503        let mut expected = vec![format!("cdk:quote:{first}"), format!("cdk:quote:{second}")];
504        expected.sort();
505
506        assert_eq!(quote_lock_keys(&[second.clone(), first, second]), expected);
507    }
508}