taler-typescript-core

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

commit f98e841eeeeeff81cbcb4ba468b3e3b7ba9ca324
parent 88dd48fb841c5303e5eb9c83ae61fe3eb16957e1
Author: Florian Dold <florian@dold.me>
Date:   Mon, 17 Feb 2025 19:52:51 +0100

wallet-core: improve DB transaction cancellation error reporting

Diffstat:
Mpackages/taler-wallet-core/src/balance.ts | 2+-
Mpackages/taler-wallet-core/src/query.ts | 256+++++++++++++++++++++++++++++++++++--------------------------------------------
2 files changed, 113 insertions(+), 145 deletions(-)

diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts @@ -720,7 +720,7 @@ export async function getPaymentBalanceDetailsInTx( balanceExchangeDepositable: Amounts.zeroOfCurrency(req.currency), }; - logger.info(`computing balance details for ${j2s(req)}`); + logger.trace(`computing balance details for ${j2s(req)}`); const availableCoins = await tx.coinAvailability.getAll(); diff --git a/packages/taler-wallet-core/src/query.ts b/packages/taler-wallet-core/src/query.ts @@ -85,7 +85,10 @@ const logExtra = false; let idbRequestPromId = 1; -function requestToPromise(req: IDBRequest): Promise<any> { +function requestToPromise( + req: IDBRequest, + internalContext: InternalTransactionContext, +): Promise<any> { const myId = idbRequestPromId++; if (logExtra) { logger.trace(`started db request ${myId}`); @@ -102,6 +105,14 @@ function requestToPromise(req: IDBRequest): Promise<any> { if (logExtra) { logger.trace(`finished db request ${myId} with error`); } + if (internalContext.isAborted) { + reject( + new TransactionAbortedError( + internalContext.abortExn?.message ?? "Aborted", + ), + ); + return; + } if ( req.error != null && "name" in req.error && @@ -594,15 +605,23 @@ function runTx<Arg, Res>( tx: IDBTransaction, arg: Arg, f: (t: Arg, t2: IDBTransaction) => Promise<Res>, - triggerContext: InternalTriggerContext, - cancellationToken: CancellationToken, + internalContext: InternalTransactionContext, ): Promise<Res> { // Create stack trace in case we need to to print later where // the transaction was started. const stack = Error("Failed transaction was started here."); + const cancellationToken = internalContext.cancellationToken; + const unregisterOnCancelled = cancellationToken.onCancelled(() => { logger.trace("aborting transaction due to cancellation"); + if (!internalContext.isAborted) { + internalContext.isAborted = true; + const abortExn = new CancellationToken.CancellationError( + cancellationToken.reason, + ); + internalContext.abortExn = abortExn; + } tx.abort(); }); @@ -612,7 +631,6 @@ function runTx<Arg, Res>( let funResult: any = undefined; let gotFunResult = false; let transactionException: any = undefined; - let aborted = false; tx.oncomplete = () => { logger.trace("transaction completed"); // This is a fatal error: The transaction completed *before* @@ -630,7 +648,7 @@ function runTx<Arg, Res>( } else { resolve(funResult); } - triggerContext.handleAfterCommit(); + internalContext.handleAfterCommit(); unregisterOnCancelled(); }; tx.onerror = () => { @@ -653,9 +671,10 @@ function runTx<Arg, Res>( tx.onabort = () => { logger.trace("transaction was aborted"); if (cancellationToken.isCancelled) { - reject( - new CancellationToken.CancellationError(cancellationToken.reason), + const abortExn = new CancellationToken.CancellationError( + cancellationToken.reason, ); + reject(abortExn); return; } let msg: string; @@ -666,11 +685,13 @@ function runTx<Arg, Res>( } else { msg = "Transaction aborted (no DB error)"; } - aborted = true; + const abortExn = new TransactionAbortedError(msg); + internalContext.isAborted = true; + internalContext.abortExn = abortExn; unregisterOnCancelled(); logger.error(msg); logger.error(`${stack.stack ?? stack}`); - reject(new TransactionAbortedError(msg)); + reject(abortExn); }; const resP = Promise.resolve().then(() => f(arg, tx)); resP @@ -702,83 +723,14 @@ function runTx<Arg, Res>( }); } -function makeReadContext( - tx: IDBTransaction, - storePick: { [n: string]: StoreWithIndexes<any, any, any> }, - triggerContext: InternalTriggerContext, -): any { - const ctx: { [s: string]: StoreReadOnlyAccessor<any, any> } = {}; - for (const storeAlias in storePick) { - const indexes: { [s: string]: IndexReadOnlyAccessor<any> } = {}; - const swi = storePick[storeAlias]; - const storeName = swi.storeName; - for (const indexAlias in storePick[storeAlias].indexMap) { - const indexDescriptor: IndexDescriptor = - storePick[storeAlias].indexMap[indexAlias]; - const indexName = indexDescriptor.name; - indexes[indexAlias] = { - get(key) { - triggerContext.storesAccessed.add(storeName); - const req = tx.objectStore(storeName).index(indexName).get(key); - return requestToPromise(req); - }, - iter(query) { - triggerContext.storesAccessed.add(storeName); - const req = tx - .objectStore(storeName) - .index(indexName) - .openCursor(query); - return new ResultStream<any>(req); - }, - getAll(query, count) { - triggerContext.storesAccessed.add(storeName); - const req = tx - .objectStore(storeName) - .index(indexName) - .getAll(query, count); - return requestToPromise(req); - }, - getAllKeys(query, count) { - triggerContext.storesAccessed.add(storeName); - const req = tx - .objectStore(storeName) - .index(indexName) - .getAllKeys(query, count); - return requestToPromise(req); - }, - count(query) { - triggerContext.storesAccessed.add(storeName); - const req = tx.objectStore(storeName).index(indexName).count(query); - return requestToPromise(req); - }, - }; - } - ctx[storeAlias] = { - indexes, - get(key) { - triggerContext.storesAccessed.add(storeName); - const req = tx.objectStore(storeName).get(key); - return requestToPromise(req); - }, - getAll(query, count) { - triggerContext.storesAccessed.add(storeName); - const req = tx.objectStore(storeName).getAll(query, count); - return requestToPromise(req); - }, - iter(query) { - triggerContext.storesAccessed.add(storeName); - const req = tx.objectStore(storeName).openCursor(query); - return new ResultStream<any>(req); - }, - }; - } - return ctx; -} - -function makeWriteContext( +/** + * Create a transaction handle that will be passed + * to the main handler for the transaction. + */ +function makeTxContext( tx: IDBTransaction, storePick: { [n: string]: StoreWithIndexes<any, any, any> }, - triggerContext: InternalTriggerContext, + internalContext: InternalTransactionContext, ): any { const ctx: { [s: string]: StoreReadWriteAccessor<any, any> } = {}; for (const storeAlias in storePick) { @@ -791,12 +743,14 @@ function makeWriteContext( const indexName = indexDescriptor.name; indexes[indexAlias] = { get(key) { - triggerContext.storesAccessed.add(storeName); + internalContext.throwIfInactive(); + internalContext.storesAccessed.add(storeName); const req = tx.objectStore(storeName).index(indexName).get(key); - return requestToPromise(req); + return requestToPromise(req, internalContext); }, iter(query) { - triggerContext.storesAccessed.add(storeName); + internalContext.throwIfInactive(); + internalContext.storesAccessed.add(storeName); const req = tx .objectStore(storeName) .index(indexName) @@ -804,68 +758,86 @@ function makeWriteContext( return new ResultStream<any>(req); }, getAll(query, count) { - triggerContext.storesAccessed.add(storeName); + internalContext.throwIfInactive(); + internalContext.storesAccessed.add(storeName); const req = tx .objectStore(storeName) .index(indexName) .getAll(query, count); - return requestToPromise(req); + return requestToPromise(req, internalContext); }, getAllKeys(query, count) { - triggerContext.storesAccessed.add(storeName); + internalContext.throwIfInactive(); + internalContext.storesAccessed.add(storeName); const req = tx .objectStore(storeName) .index(indexName) .getAllKeys(query, count); - return requestToPromise(req); + return requestToPromise(req, internalContext); }, count(query) { - triggerContext.storesAccessed.add(storeName); + internalContext.throwIfInactive(); + internalContext.storesAccessed.add(storeName); const req = tx.objectStore(storeName).index(indexName).count(query); - return requestToPromise(req); + return requestToPromise(req, internalContext); }, }; } ctx[storeAlias] = { indexes, get(key) { - triggerContext.storesAccessed.add(storeName); + internalContext.throwIfInactive(); + internalContext.storesAccessed.add(storeName); const req = tx.objectStore(storeName).get(key); - return requestToPromise(req); + return requestToPromise(req, internalContext); }, getAll(query, count) { - triggerContext.storesAccessed.add(storeName); + internalContext.throwIfInactive(); + internalContext.storesAccessed.add(storeName); const req = tx.objectStore(storeName).getAll(query, count); - return requestToPromise(req); + return requestToPromise(req, internalContext); }, iter(query) { - triggerContext.storesAccessed.add(storeName); + internalContext.throwIfInactive(); + internalContext.storesAccessed.add(storeName); const req = tx.objectStore(storeName).openCursor(query); return new ResultStream<any>(req); }, async add(r, k) { - triggerContext.storesAccessed.add(storeName); - triggerContext.storesModified.add(storeName); + internalContext.throwIfInactive(); + if (!internalContext.allowWrite) { + throw Error("attempting write in a read-only transaction"); + } + internalContext.storesAccessed.add(storeName); + internalContext.storesModified.add(storeName); const req = tx.objectStore(storeName).add(r, k); - const key = await requestToPromise(req); + const key = await requestToPromise(req, internalContext); return { key: key, }; }, async put(r, k) { - triggerContext.storesAccessed.add(storeName); - triggerContext.storesModified.add(storeName); + internalContext.throwIfInactive(); + if (!internalContext.allowWrite) { + throw Error("attempting write in a read-only transaction"); + } + internalContext.storesAccessed.add(storeName); + internalContext.storesModified.add(storeName); const req = tx.objectStore(storeName).put(r, k); - const key = await requestToPromise(req); + const key = await requestToPromise(req, internalContext); return { key: key, }; }, delete(k) { - triggerContext.storesAccessed.add(storeName); - triggerContext.storesModified.add(storeName); + internalContext.throwIfInactive(); + if (!internalContext.allowWrite) { + throw Error("attempting write in a read-only transaction"); + } + internalContext.storesAccessed.add(storeName); + internalContext.storesModified.add(storeName); const req = tx.objectStore(storeName).delete(k); - return requestToPromise(req); + return requestToPromise(req, internalContext); }, }; } @@ -969,17 +941,26 @@ export interface TriggerSpec { // beforeCommit<State>? (tx: Transaction, s: State | undefined) => Promise<void>; } -class InternalTriggerContext { +/** + * Additional state we store for every IndexedDB transaction opened + * via the query helper. + */ +class InternalTransactionContext { + isAborted = false; storesScope: Set<string>; storesAccessed: Set<string> = new Set(); storesModified: Set<string> = new Set(); + allowWrite: boolean; + abortExn: TransactionAbortedError | undefined; constructor( private triggerSpec: TriggerSpec, private mode: IDBTransactionMode, scope: string[], + public cancellationToken: CancellationToken, ) { this.storesScope = new Set(scope); + this.allowWrite = mode === "readwrite" || mode === "versionchange"; } handleAfterCommit() { @@ -992,6 +973,13 @@ class InternalTriggerContext { }); } } + + throwIfInactive() { + this.cancellationToken.throwIfCancelled(); + if (this.isAborted) { + throw this.abortExn; + } + } } /** @@ -1029,20 +1017,15 @@ export class DbAccessImpl<StoreMap> implements DbAccess<StoreMap> { accessibleStores[swi.storeName] = swi; } const mode = "readwrite"; - const triggerContext = new InternalTriggerContext( + const triggerContext = new InternalTransactionContext( this.triggers, mode, strStoreNames, - ); - const tx = this.db.transaction(strStoreNames, mode); - const writeContext = makeWriteContext(tx, accessibleStores, triggerContext); - return await runTx( - tx, - writeContext, - txf, - triggerContext, this.cancellationToken, ); + const tx = this.db.transaction(strStoreNames, mode); + const writeContext = makeTxContext(tx, accessibleStores, triggerContext); + return await runTx(tx, writeContext, txf, triggerContext); } async runAllStoresReadOnlyTx<T>( @@ -1063,20 +1046,15 @@ export class DbAccessImpl<StoreMap> implements DbAccess<StoreMap> { accessibleStores[swi.storeName] = swi; } const mode = "readonly"; - const triggerContext = new InternalTriggerContext( + const triggerContext = new InternalTransactionContext( this.triggers, mode, strStoreNames, - ); - const tx = this.db.transaction(strStoreNames, mode); - const writeContext = makeReadContext(tx, accessibleStores, triggerContext); - const res = await runTx( - tx, - writeContext, - txf, - triggerContext, this.cancellationToken, ); + const tx = this.db.transaction(strStoreNames, mode); + const writeContext = makeTxContext(tx, accessibleStores, triggerContext); + const res = await runTx(tx, writeContext, txf, triggerContext); return res; } @@ -1096,20 +1074,15 @@ export class DbAccessImpl<StoreMap> implements DbAccess<StoreMap> { accessibleStores[swi.storeName] = swi; } const mode = "readwrite"; - const triggerContext = new InternalTriggerContext( + const triggerContext = new InternalTransactionContext( this.triggers, mode, strStoreNames, - ); - const tx = this.db.transaction(strStoreNames, mode); - const writeContext = makeWriteContext(tx, accessibleStores, triggerContext); - const res = await runTx( - tx, - writeContext, - txf, - triggerContext, this.cancellationToken, ); + const tx = this.db.transaction(strStoreNames, mode); + const writeContext = makeTxContext(tx, accessibleStores, triggerContext); + const res = await runTx(tx, writeContext, txf, triggerContext); return res; } @@ -1129,20 +1102,15 @@ export class DbAccessImpl<StoreMap> implements DbAccess<StoreMap> { accessibleStores[swi.storeName] = swi; } const mode = "readonly"; - const triggerContext = new InternalTriggerContext( + const triggerContext = new InternalTransactionContext( this.triggers, mode, strStoreNames, - ); - const tx = this.db.transaction(strStoreNames, mode); - const readContext = makeReadContext(tx, accessibleStores, triggerContext); - const res = await runTx( - tx, - readContext, - txf, - triggerContext, this.cancellationToken, ); + const tx = this.db.transaction(strStoreNames, mode); + const readContext = makeTxContext(tx, accessibleStores, triggerContext); + const res = await runTx(tx, readContext, txf, triggerContext); return res; } }