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 }