taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

db.rs (6636B)


      1 /*
      2   This file is part of TALER
      3   Copyright (C) 2025-2026 Taler Systems SA
      4 
      5   TALER is free software; you can redistribute it and/or modify it under the
      6   terms of the GNU Affero General Public License as published by the Free Software
      7   Foundation; either version 3, or (at your option) any later version.
      8 
      9   TALER is distributed in the hope that it will be useful, but WITHOUT ANY
     10   WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11   A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more details.
     12 
     13   You should have received a copy of the GNU Affero General Public License along with
     14   TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15 */
     16 
     17 use std::{path::Path, str::FromStr as _, time::Duration};
     18 
     19 use sqlx::{
     20     Connection, PgConnection, PgPool,
     21     postgres::{PgConnectOptions, PgPoolOptions},
     22 };
     23 use taler_api::config::DbCfg;
     24 use taler_common::{
     25     config::{Config, parser::ConfigSource},
     26     db::{dbinit, pool_with_options},
     27 };
     28 use tracing::info;
     29 
     30 use crate::setup_tracing;
     31 
     32 /// An exclusive reservation of a reusable test database.
     33 ///
     34 /// The administrative connection holds a session advisory lock. Dropping this
     35 /// reservation closes that connection and releases the slot. Keep it alive
     36 /// until every task and connection using the database has been dropped.
     37 pub struct TestDb {
     38     _lock: PgConnection,
     39     options: PgConnectOptions,
     40 }
     41 
     42 impl TestDb {
     43     /// Connection options for the reserved database.
     44     pub fn connect_options(&self) -> PgConnectOptions {
     45         self.options.clone()
     46     }
     47 
     48     /// Reset the component schema and create a pool using its local config.
     49     pub async fn setup(&self, src: ConfigSource) -> PgPool {
     50         let cfg = Config::load(src, None::<&str>).unwrap();
     51         let name = format!("{}db-postgres", src.component_name);
     52         let db_cfg = DbCfg::parse(cfg.section(&name)).unwrap();
     53         self.setup_manual(db_cfg.sql_dir.as_ref(), src.component_name)
     54             .await
     55     }
     56 
     57     /// Reset the component schema using an explicit SQL directory.
     58     pub async fn setup_manual(&self, sql_dir: &Path, component_name: &str) -> PgPool {
     59         let pool = pool_with_options(
     60             self.connect_options(),
     61             &component_name.replace('-', "_"),
     62             PgPoolOptions::new().max_connections(3),
     63         )
     64         .await
     65         .unwrap();
     66         let mut conn = pool.acquire().await.unwrap();
     67         dbinit(&mut conn, sql_dir, component_name, true)
     68             .await
     69             .unwrap();
     70         drop(conn);
     71         pool
     72     }
     73 }
     74 
     75 /// Run a database test using a component's local configuration.
     76 ///
     77 /// The test gets its own current-thread Tokio runtime. On return or panic the
     78 /// runtime drops its remaining tasks before the database reservation is released.
     79 /// Return values must not retain database resources; use `()` or `Result<(), E>`.
     80 /// Keep all work using the database on this runtime so teardown can stop it.
     81 pub fn run_db_test(src: ConfigSource, test_name: &str, test: impl AsyncFnOnce(PgPool)) {
     82     run(test_name, async |db| test(db.setup(src).await).await)
     83 }
     84 
     85 /// Run a database test using an explicit SQL directory and component name.
     86 pub fn run_db_test_manual(
     87     sql_dir: &Path,
     88     component_name: &str,
     89     test_name: &str,
     90     test: impl AsyncFnOnce(PgPool),
     91 ) {
     92     run(test_name, async |db| {
     93         test(db.setup_manual(sql_dir, component_name).await).await
     94     })
     95 }
     96 
     97 /// Run a test with reserved connection options and no schema initialization.
     98 ///
     99 /// Use this for migration tests, custom fixtures, and tests sharing a database
    100 /// across components. As with `run_db_test`, the reservation outlives the test
    101 /// runtime, and all database work must remain on that runtime.
    102 pub fn run_db_test_raw(test_name: &str, test: impl AsyncFnOnce(PgConnectOptions)) {
    103     run(test_name, async |db| test(db.connect_options()).await)
    104 }
    105 
    106 fn run(test_name: &str, test: impl AsyncFnOnce(&TestDb)) {
    107     setup_tracing();
    108     // Locals drop in reverse declaration order, including during unwinding.
    109     // The reservation MUST outlive the runtime: detached tasks may own pools,
    110     // checked-out connections, or listeners until runtime shutdown drops them.
    111     let mut reservation = None;
    112     let runtime = tokio::runtime::Builder::new_current_thread()
    113         .enable_all()
    114         .build()
    115         .expect("create database test runtime");
    116     runtime.block_on(async {
    117         reservation = Some(
    118             reserve_db(test_name)
    119                 .await
    120                 .unwrap_or_else(|e| panic!("{test_name}: reserve test database: {e}")),
    121         );
    122         let db = reservation.as_ref().unwrap();
    123         info!(target: "test", test_name, database = ?db.options.get_database(), "Reserved test database");
    124         test(db).await
    125     })
    126 }
    127 
    128 /// Reserve one of 25 reusable test databases through a session advisory lock.
    129 /// Wait until a database is available if all are currently reserved.
    130 ///
    131 /// Connection defaults are `postgres:///taler_rust_check`. PostgreSQL's usual
    132 /// `PGHOST`, `PGPORT`, and `PGUSER` settings can select another test server.
    133 pub async fn test_db() -> sqlx::Result<TestDb> {
    134     reserve_db("taler-test-utils").await
    135 }
    136 
    137 async fn reserve_db(test_name: &str) -> sqlx::Result<TestDb> {
    138     let admin =
    139         PgConnectOptions::from_str("postgres:///taler_rust_check")?.application_name(test_name);
    140     let mut conn = PgConnection::connect_with(&admin).await?;
    141 
    142     // Advisory locks enforce the limit across test processes as well as threads.
    143     let slot = loop {
    144         let slot: Option<i32> = sqlx::query_scalar(
    145             "SELECT id FROM generate_series(0, 24) AS id
    146              WHERE pg_try_advisory_lock(id) LIMIT 1",
    147         )
    148         .fetch_optional(&mut conn)
    149         .await?;
    150         if let Some(slot) = slot {
    151             break slot;
    152         }
    153         tokio::time::sleep(Duration::from_millis(100)).await;
    154     };
    155     let name = match slot {
    156         0 => "taler_rust_test".to_owned(),
    157         id => format!("taler_rust_test_{id}"),
    158     };
    159     // Check in a fresh statement after acquiring the lock. A previous holder
    160     // may have created this database after the allocation query's snapshot.
    161     let exists: bool = sqlx::query_scalar(
    162         "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1)",
    163     )
    164     .bind(&name)
    165     .fetch_one(&mut conn)
    166     .await?;
    167     if !exists {
    168         sqlx::raw_sql(sqlx::AssertSqlSafe(format!("CREATE DATABASE {name}")))
    169             .execute(&mut conn)
    170             .await?;
    171     }
    172     let options = admin.database(&name);
    173     Ok(TestDb {
    174         _lock: conn,
    175         options,
    176     })
    177 }