taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit bbed91fb176f78fe41086cfb32b95a24a37e6396
parent 0512d76b0a7ca0c417b360630a2b3fcdeae21ea5
Author: Florian Dold <dold@taler.net>
Date:   Sun, 19 Jul 2026 13:08:59 +0200

harness: add 'advanced bench-walletdb'

Times each backend on a synthetic wallet of a given size.

Diffstat:
Apackages/taler-harness/src/benchWalletDb.ts | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/index.ts | 42+++++++++++++++++++++++++++++++++++++++++-
Apackages/taler-wallet-core/src/dbtx-bench.ts | 447+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/dbtx-runners.ts | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/dbtx.test.ts | 100++-----------------------------------------------------------------------------
Mpackages/taler-wallet-core/src/index.node.ts | 6++++++
6 files changed, 774 insertions(+), 99 deletions(-)

diff --git a/packages/taler-harness/src/benchWalletDb.ts b/packages/taler-harness/src/benchWalletDb.ts @@ -0,0 +1,139 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU 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. + + GNU 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 + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * `taler-harness advanced bench-walletdb` + * + * Times the wallet's storage layer directly, on a synthetic wallet of a given + * size, against every {@link WalletDbTransaction} backend. + * + * Separate from the integration tests on purpose: those measure the whole + * wallet including network long-polls, and cannot resolve a storage change of + * a few per cent. They also never build a wallet large enough for index size + * to matter. + */ + +import { Logger } from "@gnu-taler/taler-util"; +import { + benchmarkOneBackend, + DbBenchOptions, + DbBenchResult, + defaultDbBenchOptions, + formatDbBenchResults, + makeIdbRunner, + makeSqliteRunner, +} from "@gnu-taler/taler-wallet-core"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const logger = new Logger("benchWalletDb.ts"); + +export interface BenchWalletDbArgs extends Partial<DbBenchOptions> { + /** Only run these backends, by substring match on the backend name. */ + backends?: string[]; + /** Emit the raw results as JSON instead of a table. */ + json?: boolean; +} + +/** + * File-backed rather than in-memory, so the database size is measurable -- + * which is one of the things the benchmark exists to report. + */ +function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "taler-walletdb-bench-")); +} + +function dirSize(dir: string): number { + let total = 0; + for (const name of fs.readdirSync(dir)) { + const st = fs.statSync(path.join(dir, name)); + if (st.isFile()) { + total += st.size; + } + } + return total; +} + +export async function runBenchWalletDb( + args: BenchWalletDbArgs = {}, +): Promise<void> { + const opts: DbBenchOptions = { + ...defaultDbBenchOptions, + ...Object.fromEntries( + Object.entries(args).filter( + ([k, v]) => v !== undefined && k in defaultDbBenchOptions, + ), + ), + }; + + const factories: Array<{ + name: string; + make: (filename: string) => Promise<any>; + }> = [ + { name: "IdbWalletTransaction", make: makeIdbRunner }, + { name: "SqliteWalletTransaction", make: makeSqliteRunner }, + ]; + + const selected = args.backends?.length + ? factories.filter((f) => + args.backends!.some((b) => + f.name.toLowerCase().includes(b.toLowerCase()), + ), + ) + : factories; + + if (selected.length === 0) { + throw Error( + `no backend matched ${JSON.stringify(args.backends)}; known: ` + + factories.map((f) => f.name).join(", "), + ); + } + + const results: DbBenchResult[] = []; + for (const f of selected) { + const dir = makeTempDir(); + const dbPath = path.join(dir, "bench.sqlite3"); + const runner = await f.make(dbPath); + try { + results.push( + await benchmarkOneBackend(runner, opts, () => { + try { + // The whole directory: a WAL database is more than one file, and + // reporting only the main file would understate it. + return dirSize(dir); + } catch (e) { + logger.warn(`could not measure database size: ${e}`); + return undefined; + } + }), + ); + } finally { + await runner.close(); + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch (e) { + logger.warn(`could not clean up ${dir}: ${e}`); + } + } + } + + if (args.json) { + console.log(JSON.stringify(results, undefined, 2)); + } else { + console.log(formatDbBenchResults(results)); + } +} diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts @@ -83,6 +83,7 @@ import os from "node:os"; import path from "node:path"; import postgres from "postgres"; import { runBench1 } from "./bench1.js"; +import { runBenchWalletDb } from "./benchWalletDb.js"; import { runBench2 } from "./bench2.js"; import { runBench3 } from "./bench3.js"; import { runEnvFull } from "./env-full.js"; @@ -280,7 +281,9 @@ advancedCli console.log( `full base64: ${encodeCrock(Paytos.hashFull(args.hashPayto.payto))}`, ); - console.log(`full hex: ${toHexString(Paytos.hashFull(args.hashPayto.payto))}`); + console.log( + `full hex: ${toHexString(Paytos.hashFull(args.hashPayto.payto))}`, + ); }); advancedCli @@ -303,6 +306,43 @@ advancedCli }); advancedCli + .subcommand("benchWalletDb", "bench-walletdb", { + help: + "Benchmark the wallet storage layer (all WalletDbTransaction backends) " + + "on a synthetic wallet. Touches no services.", + }) + .maybeOption("numCoins", ["--num-coins"], clk.INT, { + help: "Coins to insert (default 20000)", + }) + .maybeOption("numDenominations", ["--num-denominations"], clk.INT, { + help: "Denominations to spread them over (default 200)", + }) + .maybeOption("numExchanges", ["--num-exchanges"], clk.INT, { + help: "Exchanges to spread those over (default 3)", + }) + .maybeOption("repeats", ["--repeats"], clk.INT, { + help: "Runs per query; the median is reported (default 5)", + }) + .maybeOption("backend", ["--backend"], clk.STRING, { + help: "Only run backends whose name contains this (e.g. 'sqlite')", + }) + .flag("json", ["--json"], { + help: "Emit raw results as JSON instead of a table", + }) + .action(async (args) => { + await runBenchWalletDb({ + numCoins: args.benchWalletDb.numCoins, + numDenominations: args.benchWalletDb.numDenominations, + numExchanges: args.benchWalletDb.numExchanges, + repeats: args.benchWalletDb.repeats, + backends: args.benchWalletDb.backend + ? [args.benchWalletDb.backend] + : undefined, + json: args.benchWalletDb.json, + }); + }); + +advancedCli .subcommand("bench1", "bench1", { help: "Run the 'bench1' benchmark", }) diff --git a/packages/taler-wallet-core/src/dbtx-bench.ts b/packages/taler-wallet-core/src/dbtx-bench.ts @@ -0,0 +1,447 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU 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. + + GNU 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 + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Benchmark for {@link WalletDbTransaction} implementations. + * + * Populates a synthetic wallet of a given size and times the queries the + * wallet actually runs on its hot paths, against every backend. + * + * This exists because the integration suite cannot answer questions about the + * storage layer. Its timings are dominated by network long-polls -- the task + * shepherd sleeps 10s whenever a long-poller returns sooner than 20s, and both + * backends hit that in some runs -- so a storage change of a few per cent is + * invisible under that noise. It also never builds a wallet large enough for + * index size to matter. This benchmark touches no services and no scheduler. + * + * Every measurement is reported as a median of repeated runs, not a single + * sample: single samples of this system have repeatedly proved misleading. + */ + +import { + AmountString, + CoinStatus, + DenomKeyType, + Logger, +} from "@gnu-taler/taler-util"; +import { + CoinSourceType, + DenominationVerificationStatus, + DbProtocolTimestamp, + WalletCoin, + WalletCoinAvailability, + WalletDenomination, +} from "./db-common.js"; +import { DbTxRunner } from "./dbtx-conformance.js"; + +const logger = new Logger("dbtx-bench.ts"); + +export interface DbBenchOptions { + /** Coins to insert. The default is small enough to run in seconds. */ + numCoins: number; + /** Denominations to spread those coins over. */ + numDenominations: number; + /** Exchanges to spread the denominations over. */ + numExchanges: number; + /** How many times each query is repeated; the median is reported. */ + repeats: number; +} + +export const defaultDbBenchOptions: DbBenchOptions = { + numCoins: 20000, + numDenominations: 200, + numExchanges: 3, + repeats: 5, +}; + +export interface DbBenchQueryResult { + name: string; + /** Median wall-clock time over `repeats` runs. */ + medianMs: number; + minMs: number; + maxMs: number; + /** Rows the query returned, to catch a "fast because it found nothing". */ + rows: number; +} + +export interface DbBenchResult { + backend: string; + options: DbBenchOptions; + populateMs: number; + /** Size of the database on disk, when it is file-backed. */ + dbSizeBytes?: number; + queries: DbBenchQueryResult[]; +} + +function median(xs: number[]): number { + const s = [...xs].sort((a, b) => a - b); + const mid = Math.floor(s.length / 2); + return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2; +} + +/** + * Deterministic pseudo-Crockford value of the right length. + * + * Fixed length matters: the point of the benchmark is partly to compare + * against a schema that stores these as BLOBs, so the strings have to be the + * size the real ones are (52 characters for a 32-byte key, 103 for a 64-byte + * hash) or the comparison measures the wrong thing. + */ +function crockLike(seed: string, len: number): string { + const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + let out = ""; + // Math.imul, not `*`: 32-bit hash multiplication overflows a double and + // silently loses the low bits, which collapses distinct seeds onto the same + // output. An earlier version did that and produced 4 distinct coin public + // keys for 500 coins -- every insert after the fourth was an upsert over an + // existing row, and the benchmark then measured a nearly empty database. + let h = 2166136261; + for (let i = 0; i < seed.length; i++) { + h = Math.imul(h ^ seed.charCodeAt(i), 16777619); + } + for (let i = 0; i < len; i++) { + h = Math.imul(h ^ (h >>> 15), 2246822519); + h = (h ^ (h >>> 13)) >>> 0; + out += alphabet[h % 32]; + } + return out; +} + +const key = (s: string) => crockLike(s, 52); +const hash = (s: string) => crockLike(s, 103); + +function exchangeUrl(i: number): string { + return `https://exchange-${i}.test/`; +} + +async function populate( + runner: DbTxRunner, + opts: DbBenchOptions, +): Promise<void> { + const denomsPerExchange = Math.max( + 1, + Math.floor(opts.numDenominations / opts.numExchanges), + ); + + // Denominations and their availability rows. + await runner.runTx(async (tx) => { + for (let d = 0; d < opts.numDenominations; d++) { + const ex = exchangeUrl(d % opts.numExchanges); + const dph = hash(`denom-${d}`); + const denom: WalletDenomination = { + denomPubHash: dph, + denomPub: { + cipher: DenomKeyType.Rsa, + rsa_public_key: `rsa-${d}`, + age_mask: 0, + }, + exchangeBaseUrl: ex, + exchangeMasterPub: key(`master-${d % opts.numExchanges}`), + currency: "TESTKUDOS", + value: "TESTKUDOS:1" as AmountString, + denominationFamilySerial: (d % denomsPerExchange) + 1, + stampStart: (1000 + d) as DbProtocolTimestamp, + stampExpireWithdraw: (2000 + d) as DbProtocolTimestamp, + stampExpireDeposit: (3000 + d) as DbProtocolTimestamp, + stampExpireLegal: (4000 + d) as DbProtocolTimestamp, + fees: { + feeDeposit: "TESTKUDOS:0.01" as AmountString, + feeRefresh: "TESTKUDOS:0.01" as AmountString, + feeRefund: "TESTKUDOS:0.01" as AmountString, + feeWithdraw: "TESTKUDOS:0.01" as AmountString, + }, + isOffered: true, + isRevoked: false, + masterSig: hash(`msig-${d}`), + verificationStatus: DenominationVerificationStatus.VerifiedGood, + }; + await tx.upsertDenomination(denom); + const avail: WalletCoinAvailability = { + exchangeBaseUrl: ex, + denomPubHash: dph, + maxAge: d % 2 === 0 ? 0 : 21, + currency: "TESTKUDOS", + value: "TESTKUDOS:1" as AmountString, + freshCoinCount: 10, + visibleCoinCount: 10, + }; + await tx.upsertCoinAvailability(avail); + } + }); + + // Coins, in batches so a single transaction does not grow unbounded. + const batch = 2000; + for (let start = 0; start < opts.numCoins; start += batch) { + await runner.runTx(async (tx) => { + for (let i = start; i < Math.min(start + batch, opts.numCoins); i++) { + const d = i % opts.numDenominations; + const coin: WalletCoin = { + coinPub: key(`coin-${i}`), + coinPriv: key(`coinpriv-${i}`), + exchangeBaseUrl: exchangeUrl(d % opts.numExchanges), + denomPubHash: hash(`denom-${d}`), + denomSig: { cipher: DenomKeyType.Rsa, rsa_signature: `sig-${i}` }, + blindingKey: key(`bk-${i}`), + coinEvHash: hash(`evh-${i}`), + // Derived from the row *within* a denomination, not from i: with + // `i % 4` the dormant coins land on multiples of 4, which for many + // denomination counts means every coin of some denominations is + // dormant and the fresh-coin query then measures an empty result. + status: + Math.floor(i / opts.numDenominations) % 4 === 0 + ? CoinStatus.Dormant + : CoinStatus.Fresh, + maxAge: d % 2 === 0 ? 0 : 21, + ageCommitmentProof: undefined, + coinSource: { + type: CoinSourceType.Withdraw, + withdrawalGroupId: key(`wg-${i % 100}`), + coinIndex: i, + reservePub: key(`rp-${i % 100}`), + }, + }; + await tx.upsertCoin(coin); + } + }); + } +} + +/** + * Run the benchmark against one already-populated runner. + */ +async function measure( + runner: DbTxRunner, + opts: DbBenchOptions, +): Promise<DbBenchQueryResult[]> { + const results: DbBenchQueryResult[] = []; + + const time = async ( + name: string, + f: () => Promise<number>, + ): Promise<void> => { + const samples: number[] = []; + let rows = 0; + for (let i = 0; i < opts.repeats; i++) { + const t0 = performance.now(); + rows = await f(); + samples.push(performance.now() - t0); + } + results.push({ + name, + medianMs: median(samples), + minMs: Math.min(...samples), + maxMs: Math.max(...samples), + rows, + }); + }; + + // A point lookup on the primary key, the single most common operation. + const someCoin = key(`coin-${Math.floor(opts.numCoins / 2)}`); + await time("getCoin (point lookup)", async () => + runner.runTx(async (tx) => ((await tx.getCoin(someCoin)) ? 1 : 0)), + ); + + // Batch lookup: this is the shape refresh uses, and was an N+1 until + // recently, so it is worth keeping an eye on. + const pubs: string[] = []; + for (let i = 0; i < Math.min(200, opts.numCoins); i++) { + pubs.push(key(`coin-${i}`)); + } + await time("getCoinsByPubs (200)", async () => + runner.runTx(async (tx) => (await tx.getCoinsByPubs(pubs)).length), + ); + + await time("getCoinsByExchange", async () => + runner.runTx( + async (tx) => (await tx.getCoinsByExchange(exchangeUrl(0))).length, + ), + ); + + await time("countCoinsByExchange", async () => + runner.runTx(async (tx) => tx.countCoinsByExchange(exchangeUrl(0))), + ); + + await time("getCoinsByDenomPubHash", async () => + runner.runTx( + async (tx) => (await tx.getCoinsByDenomPubHash(hash("denom-0"))).length, + ), + ); + + // Indexed multi-column lookup with a limit -- coin selection's hot path. + await time("getFreshCoinsByDenomAndAge (limit 10)", async () => + runner.runTx( + async (tx) => + ( + await tx.getFreshCoinsByDenomAndAge( + exchangeUrl(0), + hash("denom-0"), + 0, + 10, + ) + ).length, + ), + ); + + await time("getCoinAvailabilityByExchangeAndAgeRange", async () => + runner.runTx( + async (tx) => + ( + await tx.getCoinAvailabilityByExchangeAndAgeRange( + exchangeUrl(0), + 0, + 21, + ) + ).length, + ), + ); + + // The early-terminating keyset scan. Deliberately matches nothing until + // late, so a backend that materialises the whole family shows up here. + await time("findDenominationByFamilyFromExpiry", async () => + runner.runTx(async (tx) => { + const found = await tx.findDenominationByFamilyFromExpiry( + 1, + 0 as DbProtocolTimestamp, + () => true, + ); + return found ? 1 : 0; + }), + ); + + await time("getDenominationsByExchange", async () => + runner.runTx( + async (tx) => + (await tx.getDenominationsByExchange(exchangeUrl(0))).length, + ), + ); + + // Full scans: the wallet does these on balance computation and purge. + await time("listAllCoins (full scan)", async () => + runner.runTx(async (tx) => (await tx.listAllCoins()).length), + ); + + await time("getCoinAvailabilities (full scan)", async () => + runner.runTx(async (tx) => (await tx.getCoinAvailabilities()).length), + ); + + // A write-heavy transaction, to keep an eye on commit cost. + await time("upsertCoin x100 (one tx)", async () => + runner.runTx(async (tx) => { + for (let i = 0; i < 100; i++) { + const coin = await tx.getCoin(key(`coin-${i}`)); + if (coin) { + coin.status = + coin.status === CoinStatus.Fresh + ? CoinStatus.Dormant + : CoinStatus.Fresh; + await tx.upsertCoin(coin); + } + } + return 100; + }), + ); + + return results; +} + +/** + * Populate and measure one backend. + */ +export async function benchmarkOneBackend( + runner: DbTxRunner, + opts: DbBenchOptions, + dbSizeBytes?: () => number | undefined, +): Promise<DbBenchResult> { + logger.info(`populating ${runner.name}: ${opts.numCoins} coins`); + const t0 = performance.now(); + await populate(runner, opts); + const populateMs = performance.now() - t0; + logger.info(`populated in ${populateMs.toFixed(0)} ms, measuring`); + + // Verify the database really holds what was asked for. A benchmark on a + // database that silently failed to populate reports beautiful numbers for + // queries that match nothing, which is worse than no benchmark at all. + const actualCoins = await runner.runTx( + async (tx) => (await tx.listAllCoins()).length, + ); + if (actualCoins !== opts.numCoins) { + throw Error( + `benchmark population is wrong: asked for ${opts.numCoins} coins, ` + + `database holds ${actualCoins}. Refusing to report timings.`, + ); + } + const queries = await measure(runner, opts); + return { + backend: runner.name, + options: opts, + populateMs, + dbSizeBytes: dbSizeBytes?.(), + queries, + }; +} + +/** + * Render results as a table, with the second and later backends shown + * relative to the first. + */ +export function formatDbBenchResults(results: DbBenchResult[]): string { + const lines: string[] = []; + const base = results[0]; + lines.push(""); + lines.push( + `wallet DB benchmark: ${base.options.numCoins} coins, ` + + `${base.options.numDenominations} denominations, ` + + `${base.options.numExchanges} exchanges, ` + + `median of ${base.options.repeats}`, + ); + lines.push(""); + for (const r of results) { + const size = + r.dbSizeBytes != null + ? `, db ${(r.dbSizeBytes / 1024 / 1024).toFixed(1)} MiB` + : ""; + lines.push( + `${r.backend}: populate ${(r.populateMs / 1000).toFixed(1)} s${size}`, + ); + } + lines.push(""); + const nameWidth = Math.max( + ...base.queries.map((q) => q.name.length), + "query".length, + ); + const head = ["query".padEnd(nameWidth), ...results.map((r) => r.backend)]; + lines.push(head.join(" | ")); + lines.push("-".repeat(head.join(" | ").length)); + for (let i = 0; i < base.queries.length; i++) { + const cells = [base.queries[i].name.padEnd(nameWidth)]; + for (let j = 0; j < results.length; j++) { + const q = results[j].queries[i]; + let cell = `${q.medianMs.toFixed(2)} ms (${q.rows})`; + if (j > 0) { + const ratio = q.medianMs / base.queries[i].medianMs; + cell += ` ${ratio.toFixed(2)}x`; + } + cells.push(cell); + } + lines.push(cells.join(" | ")); + } + lines.push(""); + lines.push( + "Row counts are shown in parentheses: a query that got faster by " + + "returning nothing is a bug, not a win.", + ); + return lines.join("\n"); +} diff --git a/packages/taler-wallet-core/src/dbtx-runners.ts b/packages/taler-wallet-core/src/dbtx-runners.ts @@ -0,0 +1,139 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU 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. + + GNU 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 + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Backend runners shared by the conformance suite and the benchmark. + * + * Both must exercise the two implementations through exactly the same setup, + * or a measured difference could be an artefact of how the database was + * opened rather than of the implementation. + */ + +import { BridgeIDBFactory, createSqliteBackend } from "@gnu-taler/idb-bridge"; +import { createNodeHelperSqlite3Impl } from "@gnu-taler/idb-bridge/node-helper-sqlite3-impl"; +import { CancellationToken, Logger } from "@gnu-taler/taler-util"; + +import { openTalerDatabase, WalletIndexedDbStoresV1 } from "./db-indexeddb.js"; +import { DbAccessImpl } from "./query.js"; +import { DbTxRunner } from "./dbtx-conformance.js"; +import { IdbWalletTransaction } from "./dbtx-indexeddb.js"; +import { + initSqliteWalletDb, + SqliteTxControl, + SqliteWalletTransaction, +} from "./dbtx-sqlite.js"; + +const logger = new Logger("dbtx-runners.ts"); + +/** + * Runner for the IndexedDB implementation, on an in-memory sqlite-backed + * BridgeIDB. Each runner gets a fresh database so cases cannot leak into + * each other. + */ +export async function makeIdbRunner( + filename = ":memory:", +): Promise<DbTxRunner> { + const sqlite3Impl = await createNodeHelperSqlite3Impl({ + enableTracing: false, + }); + const backend = await createSqliteBackend(sqlite3Impl, { + filename, + }); + backend.trackStats = true; + BridgeIDBFactory.enableTracing = false; + const idbFactory = new BridgeIDBFactory(backend); + const dbHandle = await openTalerDatabase(idbFactory as any, () => {}); + const db = new DbAccessImpl( + dbHandle, + WalletIndexedDbStoresV1, + {}, + CancellationToken.CONTINUE, + ); + return { + name: "IdbWalletTransaction", + async runTx<T>(f: (tx: any) => Promise<T>): Promise<T> { + return await db.runAllStoresReadWriteTx({}, async (mytx) => { + return await f(new IdbWalletTransaction(mytx)); + }); + }, + recordsRead() { + const st = backend.accessStats; + let n = 0; + for (const k of Object.keys(st.readItemsPerIndex)) { + n += st.readItemsPerIndex[k]; + } + for (const k of Object.keys(st.readItemsPerStore)) { + n += st.readItemsPerStore[k]; + } + return n; + }, + async close() { + dbHandle.close(); + }, + }; +} + +/** + * Runner for the native sqlite3 implementation. + * + * Only a subset of WalletDbTransaction exists so far, so the suite runs the + * cases that the implemented methods cover and reports the rest as skipped + * rather than failed. That keeps the suite honest about progress without + * turning the checklist red. + */ +export async function makeSqliteRunner( + filename = ":memory:", +): Promise<DbTxRunner> { + const sqlite3Impl = await createNodeHelperSqlite3Impl({ + enableTracing: false, + }); + const db = await sqlite3Impl.open(filename); + await initSqliteWalletDb(db); + const txc = await SqliteTxControl.create(db); + return { + name: "SqliteWalletTransaction", + async runTx<T>(f: (tx: any) => Promise<T>): Promise<T> { + const tx = new SqliteWalletTransaction(db); + await txc.begin(); + let res: T; + try { + res = await f(tx); + } catch (e) { + // Roll back best-effort: if ROLLBACK itself fails the original error + // is the interesting one and must not be masked by it. + try { + await txc.rollback(); + } catch (rollbackErr) { + logger.warn(`rollback failed: ${rollbackErr}`); + } + throw e; + } + await txc.commit(); + // Notifications and commit hooks are released only after COMMIT. + for (const h of tx.afterCommitHandlers) { + h(); + } + return res; + }, + async close() { + await db.close(); + }, + }; +} + +export const runnerFactories: Array< + (filename?: string) => Promise<DbTxRunner> +> = [makeIdbRunner, makeSqliteRunner]; diff --git a/packages/taler-wallet-core/src/dbtx.test.ts b/packages/taler-wallet-core/src/dbtx.test.ts @@ -32,7 +32,8 @@ import { Logger } from "@gnu-taler/taler-util"; import { openTalerDatabase, WalletIndexedDbStoresV1 } from "./db-indexeddb.js"; import { DbAccessImpl } from "./query.js"; import { conformanceCases } from "./dbtx-conformance-cases.js"; -import { ConformanceAsserts, DbTxRunner } from "./dbtx-conformance.js"; +import { ConformanceAsserts } from "./dbtx-conformance.js"; +import { runnerFactories } from "./dbtx-runners.js"; import { IdbWalletTransaction } from "./dbtx-indexeddb.js"; import { initSqliteWalletDb, @@ -50,103 +51,6 @@ const asserts: ConformanceAsserts = { }; /** - * Runner for the IndexedDB implementation, on an in-memory sqlite-backed - * BridgeIDB. Each runner gets a fresh database so cases cannot leak into - * each other. - */ -async function makeIdbRunner(): Promise<DbTxRunner> { - const sqlite3Impl = await createNodeHelperSqlite3Impl({ - enableTracing: false, - }); - const backend = await createSqliteBackend(sqlite3Impl, { - filename: ":memory:", - }); - backend.trackStats = true; - BridgeIDBFactory.enableTracing = false; - const idbFactory = new BridgeIDBFactory(backend); - const dbHandle = await openTalerDatabase(idbFactory as any, () => {}); - const db = new DbAccessImpl( - dbHandle, - WalletIndexedDbStoresV1, - {}, - CancellationToken.CONTINUE, - ); - return { - name: "IdbWalletTransaction", - async runTx<T>(f: (tx: any) => Promise<T>): Promise<T> { - return await db.runAllStoresReadWriteTx({}, async (mytx) => { - return await f(new IdbWalletTransaction(mytx)); - }); - }, - recordsRead() { - const st = backend.accessStats; - let n = 0; - for (const k of Object.keys(st.readItemsPerIndex)) { - n += st.readItemsPerIndex[k]; - } - for (const k of Object.keys(st.readItemsPerStore)) { - n += st.readItemsPerStore[k]; - } - return n; - }, - async close() { - dbHandle.close(); - }, - }; -} - -/** - * Runner for the native sqlite3 implementation. - * - * Only a subset of WalletDbTransaction exists so far, so the suite runs the - * cases that the implemented methods cover and reports the rest as skipped - * rather than failed. That keeps the suite honest about progress without - * turning the checklist red. - */ -async function makeSqliteRunner(): Promise<DbTxRunner> { - const sqlite3Impl = await createNodeHelperSqlite3Impl({ - enableTracing: false, - }); - const db = await sqlite3Impl.open(":memory:"); - await initSqliteWalletDb(db); - const txc = await SqliteTxControl.create(db); - return { - name: "SqliteWalletTransaction", - async runTx<T>(f: (tx: any) => Promise<T>): Promise<T> { - const tx = new SqliteWalletTransaction(db); - await txc.begin(); - let res: T; - try { - res = await f(tx); - } catch (e) { - // Roll back best-effort: if ROLLBACK itself fails the original error - // is the interesting one and must not be masked by it. - try { - await txc.rollback(); - } catch (rollbackErr) { - logger.warn(`rollback failed: ${rollbackErr}`); - } - throw e; - } - await txc.commit(); - // Notifications and commit hooks are released only after COMMIT. - for (const h of tx.afterCommitHandlers) { - h(); - } - return res; - }, - async close() { - await db.close(); - }, - }; -} - -const runnerFactories: Array<() => Promise<DbTxRunner>> = [ - makeIdbRunner, - makeSqliteRunner, -]; - -/** * A case is reported as skipped, not failed, when the implementation under * test has not reached that method yet. */ diff --git a/packages/taler-wallet-core/src/index.node.ts b/packages/taler-wallet-core/src/index.node.ts @@ -21,3 +21,9 @@ export { SynchronousCryptoWorkerPlain } from "./crypto/workers/synchronousWorker export type { AccessStats } from "@gnu-taler/idb-bridge"; export * from "./crypto/workers/synchronousWorkerFactoryPlain.js"; + +// Storage-layer benchmark. Node-only: the runners spawn the sqlite helper +// process, so this must not reach the browser entry point. +export * from "./dbtx-bench.js"; +export { makeIdbRunner, makeSqliteRunner } from "./dbtx-runners.js"; +export type { DbTxRunner } from "./dbtx-conformance.js";