libeufin

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

commit 7153ffba0a060cb6131421933fd8f978d4c9487a
parent 77ea0eb003a2157172350d737573ed587d556a6d
Author: Antoine A <>
Date:   Mon, 24 Jun 2024 18:05:40 +0200

bank: add database benchmark and add new performance indexes

Diffstat:
MMakefile | 4++++
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 2+-
Mbank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt | 24+++++++++++++++++-------
Mbank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt | 12++++++------
Mbank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt | 9+++++----
Mbank/src/main/kotlin/tech/libeufin/bank/params.kt | 4++--
Abank/src/test/kotlin/bench.kt | 407+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/src/main/kotlin/AnsiColor.kt | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/src/main/kotlin/Table.kt | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adatabase-versioning/libeufin-bank-0006.sql | 38++++++++++++++++++++++++++++++++++++++
Mnexus/src/main/kotlin/tech/libeufin/nexus/Main.kt | 40+---------------------------------------
Mtestbench/src/main/kotlin/Main.kt | 11++++++-----
12 files changed, 654 insertions(+), 64 deletions(-)

diff --git a/Makefile b/Makefile @@ -120,3 +120,7 @@ doc: ./gradlew dokkaHtmlMultiModule echo "Open build/dokka/htmlMultiModule/index.html" +.PHONY: bench-db +bench-db: install-nobuild-files + ./gradlew :bank:test --tests Bench.benchDb -i + diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -28,7 +28,7 @@ import com.github.ajalt.clikt.parameters.groups.OptionGroup import com.github.ajalt.clikt.parameters.groups.cooccurring import com.github.ajalt.clikt.parameters.groups.provideDelegate import com.github.ajalt.clikt.parameters.options.* -import com.github.ajalt.clikt.parameters.types.boolean +import com.github.ajalt.clikt.parameters.types.* import io.ktor.server.application.* import io.ktor.server.http.content.* import io.ktor.server.response.* diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt @@ -530,7 +530,7 @@ class AccountDAO(private val db: Database) { } /** Get a page of all public accounts */ - suspend fun pagePublic(params: AccountParams, ctx: BankPaytoCtx): List<PublicAccount> + suspend fun pagePublic(params: AccountParams, ctx: BankPaytoCtx): List<PublicAccount> = db.page( params.page, "bank_account_id", @@ -546,11 +546,17 @@ class AccountDAO(private val db: Database) { bank_account_id FROM bank_accounts JOIN customers ON owning_customer_id = customer_id - WHERE is_public=true AND name LIKE ? AND deleted_at IS NULL AND + WHERE is_public=true AND + ${if (params.loginFilter != null) "name LIKE ? AND" else ""} + deleted_at IS NULL AND """, { - setString(1, params.loginFilter) - 1 + if (params.loginFilter != null) { + setString(1, params.loginFilter) + 1 + } else { + 0 + } } ) { PublicAccount( @@ -595,11 +601,15 @@ class AccountDAO(private val db: Database) { END as status FROM bank_accounts JOIN customers ON owning_customer_id = customer_id - WHERE name LIKE ? AND + WHERE ${if (params.loginFilter != null) "name LIKE ? AND" else ""} """, { - setString(1, params.loginFilter) - 1 + if (params.loginFilter != null) { + setString(1, params.loginFilter) + 1 + } else { + 0 + } } ) { AccountMinimalData( diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt @@ -132,9 +132,7 @@ class CashoutDAO(private val db: Database) { /** Get a page of all cashout operations */ suspend fun pageAll(params: PageParams): List<GlobalCashoutInfo> = db.page(params, "cashout_id", """ - SELECT - cashout_id - ,login + SELECT cashout_id, login FROM cashout_operations JOIN bank_accounts ON bank_account=bank_account_id JOIN customers ON owning_customer_id=customer_id @@ -152,9 +150,11 @@ class CashoutDAO(private val db: Database) { db.page(params, "cashout_id", """ SELECT cashout_id FROM cashout_operations - JOIN bank_accounts ON bank_account=bank_account_id - JOIN customers ON owning_customer_id=customer_id - WHERE login = ? AND + WHERE bank_account=( + SELECT bank_account_id + FROM bank_accounts JOIN customers ON owning_customer_id=customer_id + WHERE deleted_at IS NULL AND login = ? + ) AND """, bind = { setString(1, login) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt @@ -107,7 +107,7 @@ class TokenDAO(private val db: Database) { execute() } - /** Get a page of all public accounts */ + /** Get a page of all tokens of [login] accounts */ suspend fun page(params: PageParams, login: String): List<TokenInfo> = db.page( params, @@ -121,9 +121,10 @@ class TokenDAO(private val db: Database) { description, last_access, bearer_token_id - FROM bearer_tokens JOIN customers - ON bank_customer=customer_id - WHERE deleted_at IS NULL AND login = ? AND + FROM bearer_tokens + WHERE + bank_customer=(SELECT customer_id FROM customers WHERE deleted_at IS NULL AND login = ?) + AND """, { setString(1, login) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/params.kt b/bank/src/main/kotlin/tech/libeufin/bank/params.kt @@ -83,11 +83,11 @@ data class MonitorParams( } data class AccountParams( - val page: PageParams, val loginFilter: String + val page: PageParams, val loginFilter: String? ) { companion object { fun extract(params: Parameters): AccountParams { - val loginFilter = params["filter_name"]?.run { "%$this%" } ?: "%" + val loginFilter = params["filter_name"]?.run { "%$this%" } return AccountParams(PageParams.extract(params), loginFilter) } } diff --git a/bank/src/test/kotlin/bench.kt b/bank/src/test/kotlin/bench.kt @@ -0,0 +1,406 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 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 + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +import org.junit.* +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.testing.* +import tech.libeufin.common.* +import tech.libeufin.common.db.* +import tech.libeufin.common.crypto.PwCrypto +import tech.libeufin.bank.* +import tech.libeufin.bank.db.* +import org.postgresql.jdbc.PgConnection +import kotlin.math.* +import kotlin.time.* +import kotlin.time.Duration.* +import java.util.UUID +import java.time.* +import kotlin.random.Random + +class Bench { + + /** Generate [amount] rows to fill the database */ + fun genData(conn: PgConnection, amount: Int) { + // Skip 4 accounts created by bankSetup + val skipAccount = 4 + // Customer account will be used in tests so we want to generate more data for him + val customerAccount = 3 + val exchangeAccount = 2 + // In general half of the data is for generated account and half is for customer + val mid = amount / 2 + + + val copyManager = conn.getCopyAPI() + val password = PwCrypto.hashpw("password") + fun gen(table: String, lambda: (Int) -> String) { + println("Gen rows for $table") + val full = buildString(150*amount) { + repeat(amount) { + append(lambda(it+1)) + } + } + copyManager.copyIn("COPY $table FROM STDIN", full.reader()) + } + + val token32 = ByteArray(32) + val token64 = ByteArray(64) + + gen("customers(login, name, password_hash, cashout_payto)") { + "account_$it\t$password\tMr n°$it\t$unknownPayto\n" + } + gen("bank_accounts(internal_payto_uri, owning_customer_id, is_public)") { + "payto://x-taler-bank/localhost/account_$it\t${it+skipAccount}\t${it%3==0}\n" + } + gen("bearer_tokens(content, creation_time, expiration_time, scope, is_refreshable, bank_customer, description, last_access)") { + val account = if (it > mid) customerAccount else it+4 + Random.nextBytes(token32) + val hex = token32.encodeHex() + "\\\\x$hex\t0\t0\treadonly\tfalse\t$account\t\\N\t0\n" + } + gen("bank_account_transactions(creditor_payto_uri, creditor_name, debtor_payto_uri, debtor_name, subject, amount, transaction_date, direction, bank_account_id)") { + val account = if (it > mid) customerAccount else it+4 + "creditor_payto\tcreditor_name\tdebtor_payto\tdebtor_name\tsubject\t(42,0)\t0\tcredit\t$exchangeAccount\n" + + "creditor_payto\tcreditor_name\tdebtor_payto\tdebtor_name\tsubject\t(42,0)\t0\tdebit\t$account\n" + } + gen("bank_transaction_operations") { + Random.nextBytes(token32) + val hex = token32.encodeHex() + "\\\\x$hex\t$it\n" + } + gen("tan_challenges(body, op, code, creation_date, expiration_date, retry_counter, customer)") { + val account = if (it > mid) customerAccount else it+4 + "body\taccount_reconfig\tcode\t0\t0\t0\t$account\n" + } + gen("taler_withdrawal_operations(withdrawal_uuid, wallet_bank_account, reserve_pub, creation_date)") { + val account = if (it > mid) customerAccount else it+4 + Random.nextBytes(token32) + val hex = token32.encodeHex() + val uuid = UUID.randomUUID() + "$uuid\t$account\t\\\\x$hex\t0\n" + } + gen("taler_exchange_outgoing(wtid, request_uid, exchange_base_url, bank_transaction, creditor_account_id)") { + Random.nextBytes(token32) + val hex32 = token32.encodeHex() + Random.nextBytes(token64) + val hex64 = token64.encodeHex() + "\\\\x$hex32\t\\\\x$hex64\turl\t${it*2-1}\t$it\n" + } + gen("taler_exchange_incoming(reserve_pub, bank_transaction)") { + Random.nextBytes(token32) + val hex = token32.encodeHex() + "\\\\x$hex\t${it*2}\n" + } + gen("bank_stats(timeframe, start_time)") { + val instant = Instant.ofEpochSecond(it.toLong()) + val date = LocalDateTime.ofInstant(instant, ZoneId.of("UTC")) + "day\t$date\n" + } + gen("cashout_operations(request_uid,amount_debit,amount_credit,subject,creation_time,bank_account,local_transaction)") { + val account = if (it > mid) customerAccount else it+4 + Random.nextBytes(token32) + val hex = token32.encodeHex() + "\\\\x$hex\t(0,0)\t(0,0)\tsubject\t0\t$account\t$it\n" + } + + // Update database statistics for better perf + conn.execSQLUpdate("VACUUM ANALYZE"); + } + + @Test + fun benchDb() { + val ITER = System.getenv("BENCH_ITER")?.toIntOrNull() ?: 0 + val AMOUNT = System.getenv("BENCH_AMOUNT")?.toIntOrNull() ?: 0 + + if (ITER == 0) { + println("Skip benchmark, missing BENCH_ITER") + return + } + println("Bench $ITER times with $AMOUNT rows") + + val WARN = 4.toDuration(DurationUnit.MILLISECONDS) + val ERR = 50.toDuration(DurationUnit.MILLISECONDS) + + + suspend fun fmtMeasures(times: LongArray): List<String> { + val min: Long = times.min() + val max: Long = times.max() + val mean: Long = times.average().toLong() + val variance = times.map { (it.toDouble() - mean).pow(2) }.average() + val stdVar: Long = sqrt(variance.toDouble()).toLong() + return sequenceOf(min, mean, max, stdVar).map { + val duration = it.toDuration(DurationUnit.MICROSECONDS) + val str = duration.toString() + if (duration > ERR) { + ANSI.red(str) + } else if (duration > WARN) { + ANSI.yellow(str) + } else { + ANSI.green(str) + } + + }.toList() + } + + val measures: MutableList<List<String>> = mutableListOf() + + suspend fun <R> measureAction(name: String, lambda: suspend (Int) -> R): List<R> { + val results = mutableListOf<R>() + val times = LongArray(ITER) { idx -> + measureTime { + val result = lambda(idx) + results.add(result) + }.inWholeMicroseconds + } + measures.add(listOf(ANSI.magenta(name)) + fmtMeasures(times)) + return results + } + + bankSetup { db -> + // Prepare custoemr accounts + fillCashoutInfo("customer") + setMaxDebt("customer", "KUDOS:1000000") + + // Generate data + db.conn { genData(it, AMOUNT) } + + // Accounts + val paytos = measureAction("account_create") { + client.post("/accounts") { + json { + "username" to "account_bench_$it" + "password" to "password" + "name" to "Bench Account $it" + } + }.assertOkJson<RegisterAccountResponse>().internal_payto_uri + } + measureAction("account_reconfig") { + client.patch("/accounts/account_bench_$it") { + basicAuth("account_bench_$it", "password") + json { + "name" to "New Bench Account $it" + } + }.assertNoContent() + } + measureAction("account_reconfig_auth") { + client.patch("/accounts/account_bench_$it/auth") { + basicAuth("account_bench_$it", "password") + json { + "old_password" to "password" + "new_password" to "password" + } + }.assertNoContent() + } + measureAction("account_list") { + client.get("/accounts") { + basicAuth("admin", "admin-password") + }.assertOk() + } + measureAction("account_list_public") { + client.get("/public-accounts").assertOk() + } + measureAction("account_get") { + client.get("/accounts/account_bench_$it") { + basicAuth("account_bench_$it", "password") + }.assertOk() + } + measureAction("account_delete") { + client.delete("/accounts/account_bench_$it") { + basicAuth("account_bench_$it", "password") + }.assertNoContent() + } + + // Tokens + val tokens = measureAction("token_create") { + client.postA("/accounts/customer/token") { + json { + "scope" to "readonly" + "refreshable" to true + } + }.assertOkJson<TokenSuccessResponse>().access_token + } + measureAction("token_refresh") { + client.post("/accounts/customer/token") { + headers["Authorization"] = "Bearer ${tokens[it]}" + json { "scope" to "readonly" } + }.assertOk() + } + measureAction("token_list") { + client.getA("/accounts/customer/tokens").assertOk() + } + measureAction("token_delete") { + client.delete("/accounts/customer/token") { + headers["Authorization"] = "Bearer ${tokens[it]}" + }.assertNoContent() + } + + // Transaction + val transactions = measureAction("transaction_create") { + client.postA("/accounts/customer/transactions") { + json { + "payto_uri" to "$merchantPayto?receiver-name=Test&message=payout" + "amount" to "KUDOS:0.0001" + } + }.assertOkJson<TransactionCreateResponse>().row_id + } + measureAction("transaction_get") { + client.getA("/accounts/customer/transactions/${transactions[it]}").assertOk() + } + measureAction("transaction_history") { + client.getA("/accounts/customer/transactions").assertOk() + } + measureAction("transaction_revenue") { + client.getA("/accounts/merchant/taler-revenue/history").assertOk() + } + + // Withdrawal + val withdrawals = measureAction("withdrawal_create") { + client.postA("/accounts/customer/withdrawals") { + json { + "amount" to "KUDOS:0.0001" + } + }.assertOkJson<BankAccountCreateWithdrawalResponse>().withdrawal_id + } + measureAction("withdrawal_get") { + client.get("/withdrawals/${withdrawals[it]}").assertOk() + } + measureAction("withdrawal_status") { + client.get("/taler-integration/withdrawal-operation/${withdrawals[it]}").assertOk() + } + measureAction("withdrawal_select") { + client.post("/taler-integration/withdrawal-operation/${withdrawals[it]}") { + json { + "reserve_pub" to EddsaPublicKey.rand() + "selected_exchange" to exchangePayto + } + }.assertOk() + } + measureAction("withdrawal_confirm") { + client.postA("/accounts/customer/withdrawals/${withdrawals[it]}/confirm") + .assertNoContent() + } + measureAction("withdrawal_abort") { + val uuid = client.postA("/accounts/customer/withdrawals") { + json { + "amount" to "KUDOS:0.0001" + } + }.assertOkJson<BankAccountCreateWithdrawalResponse>().withdrawal_id + client.post("/taler-integration/withdrawal-operation/$uuid/abort") + .assertNoContent() + } + + // Cashout + val converted = convert("KUDOS:0.1") + val cashouts = measureAction("cashout_create") { + client.postA("/accounts/customer/cashouts") { + json { + "request_uid" to ShortHashCode.rand() + "amount_debit" to "KUDOS:0.1" + "amount_credit" to convert("KUDOS:0.1") + } + }.assertOkJson<CashoutResponse>().cashout_id + } + measureAction("cashout_get") { + client.getA("/accounts/customer/cashouts/${cashouts[it]}").assertOk() + } + measureAction("cashout_history") { + client.getA("/accounts/customer/cashouts").assertOk() + } + measureAction("cashout_history_admin") { + client.get("/cashouts") { + pwAuth("admin") + }.assertOk() + } + + // Wire gateway + measureAction("wg_transfer") { + client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + json { + "request_uid" to HashCode.rand() + "amount" to "KUDOS:0.0001" + "exchange_base_url" to "http://exchange.example.com/" + "wtid" to ShortHashCode.rand() + "credit_account" to customerPayto.canonical + } + }.assertOk() + } + measureAction("wg_add") { + client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") { + json { + "amount" to "KUDOS:0.0001" + "reserve_pub" to EddsaPublicKey.rand() + "debit_account" to customerPayto.canonical + } + }.assertOk() + } + measureAction("wg_incoming") { + client.getA("/accounts/exchange/taler-wire-gateway/history/incoming") + .assertOk() + } + measureAction("wg_outgoing") { + client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing") + .assertOk() + } + + // TAN challenges + val challenges = measureAction("tan_send") { + val id = client.patchA("/accounts/customer") { + json { + "contact_data" to obj { + "phone" to "+99" + "email" to "email@example.com" + } + "tan_channel" to "sms" + } + }.assertAcceptedJson<TanChallenge>().challenge_id + val res = client.postA("/accounts/customer/challenge/$id").assertOkJson<TanTransmission>() + val code = tanCode(res.tan_info) + Pair(id, code) + } + measureAction("tan_send") { + val (id, code) = challenges[it] + client.postA("/accounts/customer/challenge/$id/confirm") { + json { "tan" to code } + }.assertNoContent() + } + + // Other + measureAction("monitor") { + val uuid = client.get("/monitor") { + pwAuth("admin") + }.assertOk() + } + db.gc.collect(Instant.now(), java.time.Duration.ZERO, java.time.Duration.ZERO, java.time.Duration.ZERO) + measureAction("gc") { + db.gc.collect(Instant.now(), java.time.Duration.ZERO, java.time.Duration.ZERO, java.time.Duration.ZERO) + } + } + + val cols = IntArray(5) { 0 } + + printTable( + listOf("benchmark", "min", "mean", "max", "std").map { ANSI.bold(it) }, + measures, + ' ', + listOf(ColumnStyle.DEFAULT) + List(5) { ColumnStyle(false) } + ) + } +} +\ No newline at end of file diff --git a/common/src/main/kotlin/AnsiColor.kt b/common/src/main/kotlin/AnsiColor.kt @@ -0,0 +1,80 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 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 + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + * + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + * + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.common + +object ANSI { + private val ANSI_PATTERN = Regex("\\u001B\\[[;\\d]*m") + + enum class Color(val code: Int) { + BLACK(0), + RED(1), + GREEN(2), + YELLOW(3), + BLUE(4), + MAGENTA(5), + CYAN(6), + WHITE(7) + } + + fun espacedSize(msg: String): Int { + // TODO remove allocation ? + val clean = msg.replace(ANSI_PATTERN, "") + return clean.length + } + + fun fmt(msg: String, fg: Color? = null, bg: Color? = null, bold: Boolean = false): String { + if (fg == null && bg == null && !bold) { + return msg + } + return buildString { + fun next() { + val c = last() + if (c != '[' && c != ';') { + append(';') + } + } + + append("\u001b[") + if (bold) { + next() + append('1') + } + if (fg != null) { + next() + append('3') + append(fg.code.toString()) + } + if (bg != null) { + next() + append('4') + append(bg.code.toString()) + } + append('m') + append(msg) + append("\u001b[0m") + } + } + + fun red(msg: String) = fmt(msg, Color.RED) + fun green(msg: String) = fmt(msg, Color.GREEN) + fun yellow(msg: String) = fmt(msg, Color.YELLOW) + fun magenta(msg: String) = fmt(msg, Color.MAGENTA) + fun bold(msg: String) = fmt(msg, bold = true) +} +\ No newline at end of file diff --git a/common/src/main/kotlin/Table.kt b/common/src/main/kotlin/Table.kt @@ -0,0 +1,85 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 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 + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.common + +import kotlin.math.max + +data class ColumnStyle( + val alignLeft: Boolean = true +) { + companion object { + val DEFAULT = ColumnStyle() + } +} + +fun printTable( + columns: List<String>, + rows: List<List<String>>, + separator: Char = '|', + colStyle: List<ColumnStyle> = listOf() +) { + val cols: List<Pair<String, Int>> = columns.mapIndexed { i, name -> + val maxRow: Int = rows.asSequence().map { + ANSI.espacedSize(it[i]) + }.maxOrNull() ?: 0 + Pair(name, max(ANSI.espacedSize(name), maxRow)) + } + val table = buildString { + fun padding(length: Int) { + repeat(length) { append (" ") } + } + var first = true + for ((name, len) in cols) { + if (!first) { + append(separator) + } else { + first = false + } + val pad = len - ANSI.espacedSize(name) + padding(pad / 2) + append(name) + padding(pad / 2 + if (pad % 2 == 0) { 0 } else { 1 }) + } + append("\n") + for (row in rows) { + var first = true + cols.forEachIndexed { i, met -> + val str = row[i] + val style = colStyle.getOrNull(i) ?: ColumnStyle.DEFAULT + if (!first) { + append(separator) + } else { + first = false + } + val (name, len) = met + val pad = len - ANSI.espacedSize(str) + if (style.alignLeft) { + append(str) + padding(pad) + } else { + padding(pad) + append(str) + } + } + append("\n") + } + } + print(table) +} +\ No newline at end of file diff --git a/database-versioning/libeufin-bank-0006.sql b/database-versioning/libeufin-bank-0006.sql @@ -0,0 +1,38 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2024 Taler Systems SA +-- +-- TALER is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY +-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +-- A PARTICULAR PURPOSE. See the GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +BEGIN; + +SELECT _v.register_patch('libeufin-bank-0006', NULL, NULL); +SET search_path TO libeufin_bank; + +-- Add missing index for common queries + +CREATE INDEX bank_accounts_public_index ON bank_accounts (bank_account_id) WHERE is_public = true; +COMMENT ON INDEX bank_accounts_public_index IS 'for listing public accounts'; + +CREATE INDEX bank_account_transactions_index ON bank_account_transactions (bank_account_id, bank_transaction_id); +COMMENT ON INDEX bank_accounts_public_index IS 'for listing bank account''s transaction'; + +CREATE INDEX bearer_tokens_index ON bearer_tokens USING btree (bank_customer, bearer_token_id); +COMMENT ON INDEX bearer_tokens_index IS 'for listing bank customer''s bearer token'; + +CREATE INDEX cashout_operations_index ON cashout_operations USING btree (bank_account, cashout_id); +COMMENT ON INDEX cashout_operations_index IS 'for listing bank customer''s cashout operations'; + +CREATE INDEX customers_deleted_index ON customers (customer_id) WHERE deleted_at IS NOT NULL; +COMMENT ON INDEX customers_deleted_index IS 'for garbage collection'; + +COMMIT; diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt @@ -53,7 +53,6 @@ import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter -import kotlin.math.max internal val logger: Logger = LoggerFactory.getLogger("libeufin-nexus") @@ -393,44 +392,7 @@ class ListCmd: CliktCommand("List nexus transactions", name = "list") { ) } } - val cols: List<Pair<String, Int>> = columnNames.mapIndexed { i, name -> - val maxRow: Int = rows.asSequence().map { it[i].length }.maxOrNull() ?: 0 - Pair(name, max(name.length, maxRow)) - } - val table = buildString { - fun padding(length: Int) { - repeat(length) { append (" ") } - } - var first = true - for ((name, len) in cols) { - if (!first) { - append("|") - } else { - first = false - } - val pad = len - name.length - padding(pad / 2) - append(name) - padding(pad / 2 + if (pad % 2 == 0) { 0 } else { 1 }) - } - append("\n") - for (row in rows) { - var first = true - for ((met, str) in cols.zip(row)) { - if (!first) { - append("|") - } else { - first = false - } - val (name, len) = met - val pad = len - str.length - append(str) - padding(pad) - } - append("\n") - } - } - print(table) + printTable(columnNames, rows) } } } diff --git a/testbench/src/main/kotlin/Main.kt b/testbench/src/main/kotlin/Main.kt @@ -34,21 +34,22 @@ import tech.libeufin.nexus.loadBankKeys import tech.libeufin.nexus.loadClientKeys import tech.libeufin.nexus.loadConfig import tech.libeufin.nexus.loadJsonFile +import tech.libeufin.common.ANSI import kotlin.io.path.* val nexusCmd = LibeufinNexusCommand() val client = HttpClient(CIO) fun step(name: String) { - println("\u001b[35m$name\u001b[0m") + println(ANSI.magenta(name)) } fun msg(msg: String) { - println("\u001b[33m$msg\u001b[0m") + println(ANSI.yellow(msg)) } fun ask(question: String): String? { - print("\u001b[;1m$question\u001b[0m") + print(ANSI.bold(question)) System.out.flush() return readlnOrNull() } @@ -57,9 +58,9 @@ fun CliktCommand.run(arg: String): Boolean { val res = this.test(arg) print(res.output) if (res.statusCode != 0) { - println("\u001b[;31mERROR ${res.statusCode}\u001b[0m") + println(ANSI.red("ERROR ${res.statusCode}")) } else { - println("\u001b[;32mOK\u001b[0m") + println(ANSI.green("OK")) } return res.statusCode == 0 }