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:
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";