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