taler-typescript-core

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

commit 159fa81d9dd96c992a66f3ce216b7647f8505908
parent d8965250c44721c2adea59db5e80a4332dd142f8
Author: Florian Dold <dold@taler.net>
Date:   Thu, 20 Aug 2026 13:52:50 +0200

wallet-core: restart native migrations with record progress

Diffstat:
Mpackages/taler-util/src/notifications.ts | 4++--
Mpackages/taler-wallet-core/src/db-converter.test.ts | 58+++++++++++++++++++++++++++++++++++++++-------------------
Mpackages/taler-wallet-core/src/db-converter.ts | 67++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mpackages/taler-wallet-core/src/db-native-migration.test.ts | 187++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mpackages/taler-wallet-core/src/db-native-migration.ts | 24+++++++++++++++++++-----
5 files changed, 263 insertions(+), 77 deletions(-)

diff --git a/packages/taler-util/src/notifications.ts b/packages/taler-util/src/notifications.ts @@ -392,9 +392,9 @@ export interface DatabaseMaintenanceProgressNotification { step?: string; completedSteps: number; totalSteps: number; - /** Records completed in the current store. */ + /** Records completed in the current phase across the whole migration. */ processedRecords?: number; - /** Known after copying a store, and therefore available while verifying it. */ + /** Total records in the whole migration, known before copying starts. */ totalRecords?: number; } diff --git a/packages/taler-wallet-core/src/db-converter.test.ts b/packages/taler-wallet-core/src/db-converter.test.ts @@ -28,6 +28,7 @@ import assert from "node:assert"; import { test } from "node:test"; import { + DatabaseMaintenanceProgressNotification, encodeCrock, getRandomBytes, NotificationType, @@ -45,7 +46,11 @@ import { WalletPurchase, } from "./db-common.js"; import { SQLITE_BASELINE_SCHEMA } from "./db-sqlite-schema.js"; -import { convertWalletDb, DB_CONVERSION_BATCH_SIZE } from "./db-converter.js"; +import { + convertWalletDb, + DB_CONVERSION_BATCH_SIZE, + DB_CONVERSION_PROGRESS_RECORDS, +} from "./db-converter.js"; import { applyFixups, WalletIndexedDbStoresV1 } from "./db-indexeddb.js"; import { IdbWalletDbHandle } from "./dbtx-handle-impl.js"; import { conformanceCases } from "./dbtx-conformance-cases.js"; @@ -95,7 +100,7 @@ test("converter: IndexedDB to sqlite, populated by the conformance corpus", asyn // independent records, so this tests batching without manufacturing a // large graph of otherwise unrelated wallet operations. await src.runReadWriteTx(async (tx) => { - for (let i = 0; i < DB_CONVERSION_BATCH_SIZE * 2 + 17; i++) { + for (let i = 0; i < DB_CONVERSION_PROGRESS_RECORDS * 2 + 17; i++) { await tx.upsertTombstone({ id: `bounded-conversion-${i}` }); } }); @@ -164,26 +169,41 @@ test("converter: IndexedDB to sqlite, populated by the conformance corpus", asyn Math.max(...pageSizes) <= DB_CONVERSION_BATCH_SIZE, `conversion retained a page of ${Math.max(...pageSizes)} records`, ); - assert.ok( - progress.some( - (n) => - n.type === NotificationType.DatabaseMaintenanceProgress && - n.operation === "indexeddb-to-native-migration" && - n.phase === "copy" && - n.step === "tombstones" && - (n.processedRecords ?? 0) > DB_CONVERSION_BATCH_SIZE, - ), - "copy progress did not advance across batches", + const maintenanceProgress = progress.filter( + (n): n is DatabaseMaintenanceProgressNotification => + n.type === NotificationType.DatabaseMaintenanceProgress && + n.operation === "indexeddb-to-native-migration", ); assert.ok( - progress.some( - (n) => - n.type === NotificationType.DatabaseMaintenanceProgress && - n.operation === "indexeddb-to-native-migration" && - n.phase === "verify", - ), - "verification progress was not reported", + maintenanceProgress.every((n) => n.totalRecords === report.totalRecords), + "progress did not carry the global record total", ); + for (const phase of ["copy", "verify"] as const) { + const records = maintenanceProgress + .filter((n) => n.phase === phase && n.processedRecords !== undefined) + .map((n) => n.processedRecords!); + assert.strictEqual( + records[0], + 0, + `${phase} progress did not start at zero`, + ); + assert.strictEqual( + records.at(-1), + report.totalRecords, + `${phase} progress did not reach the global total`, + ); + assert.ok( + records.length > 2, + `${phase} progress had no intermediate event`, + ); + for (let i = 1; i < records.length - 1; i++) { + assert.ok( + Math.floor(records[i] / DB_CONVERSION_PROGRESS_RECORDS) > + Math.floor(records[i - 1] / DB_CONVERSION_PROGRESS_RECORDS), + `${phase} record progress was reported too frequently`, + ); + } + } assert.strictEqual( (src as any).idbHandle._transactions.length, retainedTransactionsBefore, diff --git a/packages/taler-wallet-core/src/db-converter.ts b/packages/taler-wallet-core/src/db-converter.ts @@ -518,6 +518,9 @@ export interface DbConversionReport { /** Small enough to bound retained records while amortising transaction setup. */ export const DB_CONVERSION_BATCH_SIZE = 128; +/** Record interval at which migration progress is reported. */ +export const DB_CONVERSION_PROGRESS_RECORDS = 100; + /** * JSON stringification with sorted object keys, so structurally equal * records compare equal regardless of property insertion order -- the two @@ -639,7 +642,22 @@ export async function convertWalletDb( dst: WalletDbHandle, ): Promise<DbConversionReport> { const copied: Record<string, number> = {}; - let total = 0; + + // Inventorying the source up front makes the progress denominator known + // before the first write. Keep the digests: verification can compare the + // destination against these instead of scanning the source a second time, + // so global progress does not add another full database pass. + const sourceDigests = new Map<WalletDbMigrationStore, RecordMultisetDigest>(); + let totalRecords = 0; + for (const group of COPY_PLAN) { + for (const st of group) { + const digest = await digestStore(src, st); + sourceDigests.set(st.name, digest); + totalRecords += digest.count; + } + } + + const progressInterval = DB_CONVERSION_PROGRESS_RECORDS; const notify = ( phase: "copy" | "verify", @@ -655,13 +673,33 @@ export async function convertWalletDb( totalSteps: DB_CONVERSION_STEP_COUNT, ...(step ? { step: step.name } : {}), ...(processedRecords !== undefined ? { processedRecords } : {}), - ...(step && copied[step.name] !== undefined - ? { totalRecords: copied[step.name] } - : {}), + totalRecords, }); }; + const makeRecordProgress = (phase: "copy" | "verify") => { + let next = progressInterval; + let last = -1; + return ( + completedSteps: number, + processedRecords: number, + step?: CopyStep, + force = false, + ): void => { + if (!force && processedRecords < next) return; + if (processedRecords === last) return; + notify(phase, completedSteps, step, processedRecords); + last = processedRecords; + next = + (Math.floor(processedRecords / progressInterval) + 1) * + progressInterval; + }; + }; + + const copyProgress = makeRecordProgress("copy"); + let copiedRecords = 0; let stepIndex = 0; + copyProgress(stepIndex, 0, undefined, true); for (const group of COPY_PLAN) { for (const st of group) { let cursor: unknown | undefined; @@ -675,27 +713,32 @@ export async function convertWalletDb( } }); storeCount += page.records.length; - notify("copy", stepIndex, st, storeCount); + copiedRecords += page.records.length; + copyProgress(stepIndex, copiedRecords, st); cursor = page.nextCursor; if (cursor === undefined) break; } copied[st.name] = storeCount; - total += storeCount; stepIndex++; - notify("copy", stepIndex, st, storeCount); + notify("copy", stepIndex, st); logger.trace(`copied ${storeCount} ${st.name}`); } } + copyProgress(stepIndex, copiedRecords, undefined, true); // Verify using fixed-size multiset digests. Source and destination have // different primary keys/orderings for some entities, so comparing page // boundaries would be incorrect even though both scans are bounded. + const verifyProgress = makeRecordProgress("verify"); + let verifiedRecords = 0; stepIndex = 0; + verifyProgress(stepIndex, 0, undefined, true); for (const group of COPY_PLAN) { for (const st of group) { - const sourceDigest = await digestStore(src, st); + const sourceDigest = sourceDigests.get(st.name)!; + const beforeStore = verifiedRecords; const destinationDigest = await digestStore(dst, st, (processed) => - notify("verify", stepIndex, st, processed), + verifyProgress(stepIndex, beforeStore + processed, st), ); if (!sourceDigest.equals(destinationDigest)) { throw Error( @@ -704,9 +747,11 @@ export async function convertWalletDb( ` ${destinationDigest.describe()})`, ); } + verifiedRecords += destinationDigest.count; stepIndex++; - notify("verify", stepIndex, st, destinationDigest.count); + notify("verify", stepIndex, st); } } - return { copied, totalRecords: total }; + verifyProgress(stepIndex, verifiedRecords, undefined, true); + return { copied, totalRecords: copiedRecords }; } diff --git a/packages/taler-wallet-core/src/db-native-migration.test.ts b/packages/taler-wallet-core/src/db-native-migration.test.ts @@ -48,9 +48,14 @@ import { resolveAmbiguousWalletDb, restoreMigrationBackup, } from "./db-native-migration.js"; -import { IDB_BACKUP_PREFIX, IDB_EMULATION_TABLES } from "./db-sqlite-schema.js"; +import { + IDB_BACKUP_PREFIX, + IDB_EMULATION_TABLES, + schemaMigrations, +} from "./db-sqlite-schema.js"; +import { DB_CONVERSION_PROGRESS_RECORDS } from "./db-converter.js"; import { IdbWalletDbHandle } from "./dbtx-handle-impl.js"; -import { openNativeSqliteWalletDb } from "./dbtx-sqlite.js"; +import { initSqliteWalletDb, openNativeSqliteWalletDb } from "./dbtx-sqlite.js"; import { inspectWalletDbPath, resolveWalletDbMigration, @@ -110,15 +115,23 @@ async function makePopulatedIdbDb(filename = ":memory:"): Promise<{ return { db, handle }; } -async function makeMinimalIdbDb(): Promise<{ +async function openIdbDb(filename: string): Promise<{ db: Sqlite3Database; handle: IdbWalletDbHandle; }> { const imp = await createNodeHelperSqlite3Impl({ enableTracing: false }); - const db = await imp.open(":memory:"); + const db = await imp.open(filename); const backend = await createSqliteBackendOverDb(imp, db); const handle = new IdbWalletDbHandle(new BridgeIDBFactory(backend)); await handle.ensureOpen(); + return { db, handle }; +} + +async function makeMinimalIdbDb(filename = ":memory:"): Promise<{ + db: Sqlite3Database; + handle: IdbWalletDbHandle; +}> { + const { db, handle } = await openIdbDb(filename); await handle.runReadWriteTx((tx) => tx.upsertConfig({ key: "fault-test" as any, value: 1 }), ); @@ -290,48 +303,142 @@ test("native migration: the retained backup can be put back", async () => { ); }); -test("native migration: an interrupted attempt keeps the old database", async () => { - const { db, handle } = await makePopulatedIdbDb(); +test("native migration: an interrupted attempt restarts after reopening", async () => { + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), "wallet-db-migration-restart-"), + ); + const filename = path.join(directory, "wallet.sqlite3"); + let firstDb: Sqlite3Database | undefined; + let reopenedDb: Sqlite3Database | undefined; + try { + const first = await makeMinimalIdbDb(filename); + firstDb = first.db; + const expectedTombstones = DB_CONVERSION_PROGRESS_RECORDS * 2 + 17; + await first.handle.runReadWriteTx(async (tx) => { + for (let i = 0; i < expectedTombstones; i++) { + await tx.upsertTombstone({ id: `restart-${i}` }); + } + }); + + // Throw only after a committed destination batch has advanced global + // progress. This leaves the same durable state as process termination: + // untouched IndexedDB tables, a running marker and partial native rows. + let interruptionInjected = false; + first.handle.setNotificationSink((n) => { + if ( + !interruptionInjected && + n.type === NotificationType.DatabaseMaintenanceProgress && + n.operation === "indexeddb-to-native-migration" && + n.phase === "copy" && + (n.processedRecords ?? 0) >= DB_CONVERSION_PROGRESS_RECORDS + ) { + interruptionInjected = true; + throw Error("simulated migration interruption"); + } + }); + await assert.rejects( + () => migrateWalletDbToNative(first.db, first.handle), + /simulated migration interruption/, + ); + assert.ok( + interruptionInjected, + "migration was not interrupted after a copy", + ); + const interrupted = await inspectWalletDbFileDetails(first.db); + assert.strictEqual(interrupted.kind, "indexeddb"); + assert.ok(interrupted.nativeRecords > 0, "no partial native copy was left"); + assert.strictEqual( + (await readNativeMigrationInfo(first.db))?.status, + "running", + ); - // What an interruption leaves behind: the native tables exist and hold a - // partial copy, the bookkeeping says an attempt was under way, and the - // emulation's tables are untouched because the renames never ran. - await migrateWalletDbToNative(db, handle); - // Undo the completion so the file looks interrupted rather than migrated: - // the bookkeeping says an attempt was under way and the emulation's tables - // are back where a migration that never reached its renames left them. - await ( - await db.prepare( - "UPDATE idb_migration SET status = 'running', finished_at = NULL," + - " backup_status = NULL, backup_expires_at = NULL WHERE id = 1", - ) - ).run({}); - for (const t of IDB_EMULATION_TABLES) { + await first.handle.close(); + await first.db.close(); + firstDb = undefined; + + // A new factory and connection exercise the path that previously surfaced + // only as "database opening error", rather than reusing an already-open + // IndexedDB handle as the old regression did. + const reopened = await openIdbDb(filename); + reopenedDb = reopened.db; + assert.strictEqual(await inspectWalletDbFile(reopened.db), "indexeddb"); + const { handle: native, report } = await migrateWalletDbToNative( + reopened.db, + reopened.handle, + ); + const tombstones = await native.runReadWriteTx((tx) => + tx.listAllTombstones(), + ); + assert.strictEqual(tombstones.length, expectedTombstones); + assert.strictEqual( + new Set(tombstones.map((t) => t.id)).size, + expectedTombstones, + "the restarted copy duplicated records", + ); + assert.ok(report.totalRecords >= expectedTombstones); + assert.strictEqual(await inspectWalletDbFile(reopened.db), "native"); + await native.close(); + reopenedDb = undefined; + } finally { + await firstDb?.close().catch(() => {}); + await reopenedDb?.close().catch(() => {}); + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test("native migration: a legacy interrupted attempt remains restartable", async () => { + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), "wallet-db-migration-legacy-"), + ); + const filename = path.join(directory, "wallet.sqlite3"); + let firstDb: Sqlite3Database | undefined; + let reopenedDb: Sqlite3Database | undefined; + try { + const first = await makeMinimalIdbDb(filename); + firstDb = first.db; + await initSqliteWalletDb( + first.db, + schemaMigrations.filter((m) => m.version < 7), + ); await ( - await db.prepare( - `ALTER TABLE "${IDB_BACKUP_PREFIX}${t}" RENAME TO "${t}"`, + await first.db.prepare( + "INSERT INTO config (key, value) VALUES ('partial-only', '\"discard\"')", ) ).run({}); - } + await ( + await first.db.prepare( + "INSERT INTO idb_migration (id, status, started_at)" + + " VALUES (1, 'running', 1)", + ) + ).run({}); + assert.strictEqual( + (await readNativeMigrationInfo(first.db))?.cleanupSafe, + undefined, + ); + assert.strictEqual(await inspectWalletDbFile(first.db), "indexeddb"); - assert.strictEqual( - await inspectWalletDbFile(db), - "indexeddb", - "an interrupted migration must leave the emulation authoritative", - ); + await first.handle.close(); + await first.db.close(); + firstDb = undefined; - // Retrying discards the partial copy rather than adding to it. - const { handle: native, report } = await migrateWalletDbToNative(db, handle); - const coins = await native.runReadWriteTx((tx) => tx.listAllCoins()); - const seen = new Set(coins.map((c) => c.coinPub)); - assert.strictEqual( - seen.size, - coins.length, - "the retry duplicated records from the interrupted attempt", - ); - assert.ok(report.totalRecords >= 100); - assert.strictEqual(await inspectWalletDbFile(db), "native"); - await native.close(); + const reopened = await openIdbDb(filename); + reopenedDb = reopened.db; + const { handle: native, info } = await migrateWalletDbToNative( + reopened.db, + reopened.handle, + ); + assert.strictEqual(info.status, "complete"); + assert.strictEqual(info.cleanupSafe, true); + const config = await native.runReadWriteTx((tx) => tx.listAllConfig()); + assert.ok(config.some((r) => r.key === ("fault-test" as any))); + assert.ok(!config.some((r) => r.key === ("partial-only" as any))); + await native.close(); + reopenedDb = undefined; + } finally { + await firstDb?.close().catch(() => {}); + await reopenedDb?.close().catch(() => {}); + fs.rmSync(directory, { recursive: true, force: true }); + } }); test("native migration: mixed schemas without ownership fail closed", async () => { diff --git a/packages/taler-wallet-core/src/db-native-migration.ts b/packages/taler-wallet-core/src/db-native-migration.ts @@ -108,7 +108,11 @@ export interface NativeMigrationInfo { recordsCopied?: number; backupStatus?: MigrationBackupStatus; backupExpiresAt?: number; - /** Whether native rows are known to be only a disposable partial copy. */ + /** + * Whether native rows are known to be only a disposable partial copy. + * Undefined identifies the released legacy schema from before this column + * was added; its running marker carried the same cleanup guarantee. + */ cleanupSafe?: boolean; } @@ -220,7 +224,7 @@ export async function inspectWalletDbFileDetails( }); if (info?.status === "complete") return result("native"); - if (info?.status === "running" && info.cleanupSafe) + if (info?.status === "running" && info.cleanupSafe !== false) return result("indexeddb"); if (info?.status === "running" && nativeRecords > 0) { return result( @@ -304,11 +308,19 @@ export async function migrateWalletDbToNative( db: Sqlite3Database, src: WalletDbHandle, ): Promise<NativeMigrationResult> { + // Read this before native initialization upgrades the schema. A running + // marker written by versions before cleanup_safe existed is trustworthy: + // those versions also cleared the native tables before recording it. Once + // migration 7 adds the column its DEFAULT 0 deliberately cannot make that + // distinction for us anymore. + const previous = await readNativeMigrationInfo(db); + const previousPartialIsCleanupSafe = + previous?.status === "running" && previous.cleanupSafe !== false; + const ndb = await openNativeSqliteWalletDb(db); const dst = new SqliteWalletDbHandle(ndb); const txc = ndb.txc; - const previous = await readNativeMigrationInfo(db); if (previous?.status === "complete") { throw Error( "this wallet database has already been migrated to the native schema", @@ -321,9 +333,11 @@ export async function migrateWalletDbToNative( ); } if (previous?.status === "running") { - if (previous.cleanupSafe) { + if (previousPartialIsCleanupSafe) { logger.warn( - "discarding the cleanup-safe partial copy left by an interrupted migration attempt", + previous.cleanupSafe === undefined + ? "discarding the partial copy left by an interrupted legacy migration attempt" + : "discarding the cleanup-safe partial copy left by an interrupted migration attempt", ); await clearNativeSqliteWalletDb(ndb); } else if ((await countNativeRecords(db)) !== 0) {