libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit 1a41dcd2ff3401ae7c6bf321a58b54f748c04b17
parent 472f8f47423a2c014d76975ee3c17c88aa6615d9
Author: Antoine A <>
Date:   Thu,  2 Jan 2025 15:00:21 +0100

bank: optimize db locking and clean SQL

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt | 5+++--
Mbank/src/test/kotlin/DatabaseTest.kt | 89+------------------------------------------------------------------------------
Mcommon/src/main/kotlin/Constants.kt | 4++--
Mcommon/src/main/kotlin/db/transaction.kt | 4++--
Mdatabase-versioning/libeufin-bank-procedures.sql | 46+++++++++++++++++++++++-----------------------
5 files changed, 31 insertions(+), 117 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2024 Taler Systems S.A. + * Copyright (C) 2023-2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -115,7 +115,7 @@ class TransactionDAO(private val db: Database) { } else if (exchangeCreditor) { val bounceCause = runCatching { parseIncomingTxMetadata(subject) }.fold( onSuccess = { metadata -> - val registered = conn.withStatement("CALL register_incoming(?, ?::taler_incoming_type, ?, ?)") { + val registered = conn.withStatement("CALL register_incoming(?, ?::taler_incoming_type, ?, ?, ?)") { setLong(1, creditRowId) setString(2, metadata.type.name) when (metadata.type) { @@ -129,6 +129,7 @@ class TransactionDAO(private val db: Database) { } TalerIncomingType.wad -> throw UnsupportedOperationException() } + setLong(5, creditAccountId) executeProcedureViolation() } if (!registered) { diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2024 Taler Systems S.A. + * Copyright (C) 2023-2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -49,93 +49,6 @@ class DatabaseTest { } @Test - fun serialisation() = bankSetup { db -> - client.cachedToken("customer") - client.cachedToken("merchant") - client.cachedToken("admin") - - assertBalance("customer", "+KUDOS:0") - assertBalance("merchant", "+KUDOS:0") - - // Generate concurrent write transactions and check that they are all successful - coroutineScope { - repeat(10) { - launch { - tx("customer", "KUDOS:0.$it", "merchant", "concurrent $it") - } - } - } - assertBalance("customer", "-KUDOS:4.5") - assertBalance("merchant", "+KUDOS:4.5") - - // Generate concurrent write and read transactions and check that they only write transaction sometimes fails - var failures = 0 - coroutineScope { - repeat(100) { - // Write transaction from merchant to customer - launch { - while (true) { - val result = client.postA("/accounts/customer/transactions") { - json { - "payto_uri" to "$merchantPayto?message=${"concurrent customer 0$it".encodeURLParameter()}&amount=KUDOS:0.0$it" - } - } - if (result.status == HttpStatusCode.InternalServerError) { - val body = result.json<TalerError>() - assertEquals(TalerErrorCode.BANK_SOFT_EXCEPTION.code, body.code) - failures += 1 - delay(10) - continue // retry - } else { - result.assertOk() - break - } - } - } - // Write transactions from customer to merchant - launch { - while (true) { - val result = client.postA("/accounts/merchant/transactions") { - json { - "payto_uri" to "$customerPayto?message=${"concurrent merchant 0$it".encodeURLParameter()}&amount=KUDOS:0.0$it" - } - } - if (result.status == HttpStatusCode.InternalServerError) { - val body = result.json<TalerError>() - assertEquals(TalerErrorCode.BANK_SOFT_EXCEPTION.code, body.code) - failures += 1 - delay(10) - continue // retry - } else { - result.assertOk() - break - } - } - } - // Simple read transaction :SELECT - launch { - client.getA("/accounts/merchant/transactions").assertOk() - } - // Complex read transaction: stored procedure - launch { - client.getAdmin("/monitor").assertOk() - } - // GC logic - launch { - try { - db.gc.collect(Instant.now(), Duration.ofMinutes(1), Duration.ofMinutes(1), Duration.ofMinutes(1)) - } catch (e: Exception) { - // Check only serialization exception - } - } - } - } - assert(failures > 0) { "Write operation must sometimes fail under contention" } - assertBalance("customer", "-KUDOS:4.5") - assertBalance("merchant", "+KUDOS:4.5") - } - - @Test fun tanChallenge() = bankSetup { db -> db.conn { conn -> val createStmt = conn.prepareStatement("SELECT tan_challenge_create('','account_reconfig'::op_enum,?,?,?,?,'customer',NULL,NULL)") val markSentStmt = conn.prepareStatement("SELECT tan_challenge_mark_sent(?,?,?)") diff --git a/common/src/main/kotlin/Constants.kt b/common/src/main/kotlin/Constants.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. + * Copyright (C) 2024-2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -20,7 +20,7 @@ package tech.libeufin.common // DB const val MIN_VERSION: Int = 14 -const val SERIALIZATION_RETRY: Int = 10 +const val SERIALIZATION_RETRY: Int = 30 // Security const val MAX_BODY_LENGTH: Int = 4 * 1024 // 4kB diff --git a/common/src/main/kotlin/db/transaction.kt b/common/src/main/kotlin/db/transaction.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. + * Copyright (C) 2025 Taler Systems S.A. * * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -31,7 +31,7 @@ import java.sql.SQLException internal val logger: Logger = LoggerFactory.getLogger("libeufin-db") val SERIALIZATION_ERROR = setOf( - "40001", //serialization_failure + "40001", // serialization_failure "40P01", // deadlock_detected "55P03", // lock_not_available ) diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -288,12 +288,12 @@ IF has_fee THEN admin_payto, admin_name FROM bank_accounts JOIN customers ON customer_id=owning_customer_id - WHERE username = 'admin' - FOR UPDATE; + WHERE username = 'admin'; IF NOT FOUND THEN RAISE EXCEPTION 'No admin'; END IF; END IF; + -- Retrieve debtor info SELECT has_debt, @@ -307,8 +307,7 @@ SELECT debtor_payto, debtor_name FROM bank_accounts JOIN customers ON customer_id=owning_customer_id - WHERE bank_account_id=in_debtor_account_id - FOR UPDATE; + WHERE bank_account_id=in_debtor_account_id; IF NOT FOUND THEN RAISE EXCEPTION 'Unknown debtor %', in_debtor_account_id; END IF; @@ -323,8 +322,7 @@ SELECT creditor_payto, creditor_name FROM bank_accounts JOIN customers ON customer_id=owning_customer_id - WHERE bank_account_id=in_creditor_account_id - FOR UPDATE NOWAIT; + WHERE bank_account_id=in_creditor_account_id; IF NOT FOUND THEN RAISE EXCEPTION 'Unknown creditor %', in_creditor_account_id; END IF; @@ -440,6 +438,12 @@ IF has_fee THEN END IF; END IF; +-- Lock account in order to prevent deadlocks +PERFORM FROM bank_accounts + WHERE bank_account_id IN (in_debtor_account_id, in_creditor_account_id, admin_account_id) + ORDER BY bank_account_id + FOR UPDATE; + -- now actually create the bank transaction. -- debtor side: INSERT INTO bank_account_transactions ( @@ -571,8 +575,7 @@ SELECT ,out_balance_not_zero FROM customers JOIN bank_accounts ON owning_customer_id = customer_id - WHERE username = in_username AND deleted_at IS NULL - FOR UPDATE; + WHERE username = in_username AND deleted_at IS NULL; IF NOT FOUND OR out_balance_not_zero OR out_tan_required THEN out_not_found=NOT FOUND; RETURN; @@ -587,12 +590,12 @@ CREATE PROCEDURE register_incoming( IN in_tx_row_id INT8, IN in_type taler_incoming_type, IN in_reserve_pub BYTEA, - IN in_account_pub BYTEA + IN in_account_pub BYTEA, + IN in_account_id INT8 ) LANGUAGE plpgsql AS $$ DECLARE local_amount taler_amount; -local_bank_account_id INT8; BEGIN -- Register incoming transaction INSERT @@ -607,16 +610,15 @@ INSERT in_tx_row_id, in_type ); --- Get bank transaction info -SELECT (amount).val, (amount).frac, bank_account_id -INTO local_amount.val, local_amount.frac, local_bank_account_id -FROM bank_account_transactions WHERE bank_transaction_id=in_tx_row_id; -- Update stats IF in_type = 'reserve' THEN + SELECT (amount).val, (amount).frac + INTO local_amount.val, local_amount.frac + FROM bank_account_transactions WHERE bank_transaction_id=in_tx_row_id; CALL stats_register_payment('taler_in', NULL, local_amount, null); END IF; -- Notify new incoming transaction -PERFORM pg_notify('bank_incoming_tx', local_bank_account_id || ' ' || in_tx_row_id); +PERFORM pg_notify('bank_incoming_tx', in_account_id || ' ' || in_tx_row_id); END $$; COMMENT ON PROCEDURE register_incoming IS 'Register a bank transaction as a taler incoming transaction and announce it'; @@ -844,8 +846,8 @@ IF out_debitor_balance_insufficient THEN END IF; -- Register incoming transaction CASE in_type - WHEN 'reserve' THEN CALL register_incoming(out_tx_row_id, 'reserve', in_key, NULL); - WHEN 'kyc' THEN CALL register_incoming(out_tx_row_id, 'kyc', NULL, in_key); + WHEN 'reserve' THEN CALL register_incoming(out_tx_row_id, 'reserve', in_key, NULL, exchange_bank_account_id); + WHEN 'kyc' THEN CALL register_incoming(out_tx_row_id, 'kyc', NULL, in_key, exchange_bank_account_id); ELSE RAISE EXCEPTION 'Unsupported incoming type %', in_type; END CASE; END $$; @@ -1045,8 +1047,7 @@ SELECT wallet_bank_account INTO not_selected, out_status, out_already_selected, out_amount_differs, account_id FROM taler_withdrawal_operations - WHERE withdrawal_uuid=in_withdrawal_uuid - FOR UPDATE; + WHERE withdrawal_uuid=in_withdrawal_uuid; IF NOT FOUND OR out_already_selected OR out_amount_differs THEN out_no_op=NOT FOUND; RETURN; @@ -1181,8 +1182,7 @@ SELECT FROM taler_withdrawal_operations JOIN bank_accounts ON wallet_bank_account=bank_account_id JOIN customers ON owning_customer_id=customer_id - WHERE withdrawal_uuid=in_withdrawal_uuid AND username=in_username AND deleted_at IS NULL - FOR UPDATE; + WHERE withdrawal_uuid=in_withdrawal_uuid AND username=in_username AND deleted_at IS NULL; out_no_op=NOT FOUND; IF out_no_op OR already_confirmed OR out_aborted OR out_not_selected OR out_missing_amount OR out_amount_differs THEN RETURN; @@ -1226,7 +1226,7 @@ UPDATE taler_withdrawal_operations WHERE withdrawal_uuid=in_withdrawal_uuid; -- Register incoming transaction -CALL register_incoming(tx_row_id, 'reserve'::taler_incoming_type, reserve_pub_local, NULL); +CALL register_incoming(tx_row_id, 'reserve'::taler_incoming_type, reserve_pub_local, NULL, exchange_bank_account_id); -- Notify status change PERFORM pg_notify('bank_withdrawal_status', in_withdrawal_uuid::text || ' confirmed'); @@ -1304,7 +1304,7 @@ IF out_balance_insufficient THEN END IF; -- Register incoming transaction -CALL register_incoming(tx_row_id, 'reserve'::taler_incoming_type, in_reserve_pub, NULL); +CALL register_incoming(tx_row_id, 'reserve'::taler_incoming_type, in_reserve_pub, NULL, exchange_account_id); -- update stats CALL stats_register_payment('cashin', NULL, converted_amount, in_amount);