diff options
-rw-r--r-- | packages/taler-util/src/fnutils.ts | 38 | ||||
-rw-r--r-- | packages/taler-util/src/index.browser.ts | 3 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/db.ts | 28 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/backup/export.ts | 3 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/backup/import.ts | 7 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/pending.ts | 8 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/refresh.ts | 94 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/util/http.ts | 33 |
8 files changed, 174 insertions, 40 deletions
diff --git a/packages/taler-util/src/fnutils.ts b/packages/taler-util/src/fnutils.ts new file mode 100644 index 000000000..85fac6680 --- /dev/null +++ b/packages/taler-util/src/fnutils.ts @@ -0,0 +1,38 @@ +/* + This file is part of GNU Taler + (C) 2021 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/> + */ + +/** + * Functional programming utilities. + */ +export namespace fnutil { + export function all<T>(arr: T[], f: (x: T) => boolean): boolean { + for (const x of arr) { + if (!f(x)) { + return false; + } + } + return true; + } + + export function any<T>(arr: T[], f: (x: T) => boolean): boolean { + for (const x of arr) { + if (f(x)) { + return true; + } + } + return false; + } +}
\ No newline at end of file diff --git a/packages/taler-util/src/index.browser.ts b/packages/taler-util/src/index.browser.ts index a4b5cc8db..1c379bd93 100644 --- a/packages/taler-util/src/index.browser.ts +++ b/packages/taler-util/src/index.browser.ts @@ -18,4 +18,5 @@ export * from "./transactionsTypes.js"; export * from "./walletTypes.js"; export * from "./i18n.js"; export * from "./logging.js"; -export * from "./url.js";
\ No newline at end of file +export * from "./url.js"; +export { fnutil } from "./fnutils.js";
\ No newline at end of file diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 093332e84..ef6b45c11 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -915,6 +915,17 @@ export interface TipRecord { retryInfo: RetryInfo; } +export enum RefreshCoinStatus { + Pending = "pending", + Finished = "finished", + + /** + * The refresh for this coin has been frozen, because of a permanent error. + * More info in lastErrorPerCoin. + */ + Frozen = "frozen", +} + export interface RefreshGroupRecord { /** * Retry info, even present when the operation isn't active to allow indexing @@ -926,8 +937,15 @@ export interface RefreshGroupRecord { lastErrorPerCoin: { [coinIndex: number]: TalerErrorDetails }; + /** + * Unique, randomly generated identifier for this group of + * refresh operations. + */ refreshGroupId: string; + /** + * Reason why this refresh group has been created. + */ reason: RefreshReason; oldCoinPubs: string[]; @@ -946,7 +964,7 @@ export interface RefreshGroupRecord { * it will be marked as finished, but no refresh session will * be created. */ - finishedPerCoin: boolean[]; + statusPerCoin: RefreshCoinStatus[]; timestampCreated: Timestamp; @@ -954,6 +972,11 @@ export interface RefreshGroupRecord { * Timestamp when the refresh session finished. */ timestampFinished: Timestamp | undefined; + + /** + * No coins are pending, but at least one is frozen. + */ + frozen?: boolean; } /** @@ -1162,6 +1185,9 @@ export interface PurchaseRecord { /** * Downloaded and parsed proposal data. + * + * FIXME: Move this into another object store, + * to improve read/write perf on purchases. */ download: ProposalDownload; diff --git a/packages/taler-wallet-core/src/operations/backup/export.ts b/packages/taler-wallet-core/src/operations/backup/export.ts index 4d9ca6697..0410ab3af 100644 --- a/packages/taler-wallet-core/src/operations/backup/export.ts +++ b/packages/taler-wallet-core/src/operations/backup/export.ts @@ -66,6 +66,7 @@ import { CoinSourceType, CoinStatus, ProposalStatus, + RefreshCoinStatus, RefundState, WALLET_BACKUP_STATE_KEY, } from "../../db.js"; @@ -440,7 +441,7 @@ export async function exportBackup( estimated_output_amount: Amounts.stringify( rg.estimatedOutputPerCoin[i], ), - finished: rg.finishedPerCoin[i], + finished: rg.statusPerCoin[i] === RefreshCoinStatus.Finished, input_amount: Amounts.stringify(rg.inputPerCoin[i]), refresh_session: refreshSession, }); diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts index 8ba4e4db3..a694d9f4d 100644 --- a/packages/taler-wallet-core/src/operations/backup/import.ts +++ b/packages/taler-wallet-core/src/operations/backup/import.ts @@ -45,6 +45,7 @@ import { RefreshSessionRecord, WireInfo, WalletStoresV1, + RefreshCoinStatus, } from "../../db.js"; import { PayCoinSelection } from "../../util/coinSelection.js"; import { j2s } from "@gnu-taler/taler-util"; @@ -831,8 +832,10 @@ export async function importBackup( lastError: undefined, lastErrorPerCoin: {}, oldCoinPubs: backupRefreshGroup.old_coins.map((x) => x.coin_pub), - finishedPerCoin: backupRefreshGroup.old_coins.map( - (x) => x.finished, + statusPerCoin: backupRefreshGroup.old_coins.map((x) => + x.finished + ? RefreshCoinStatus.Finished + : RefreshCoinStatus.Pending, ), inputPerCoin: backupRefreshGroup.old_coins.map((x) => Amounts.parseOrThrow(x.input_amount), diff --git a/packages/taler-wallet-core/src/operations/pending.ts b/packages/taler-wallet-core/src/operations/pending.ts index 200e6ccbd..a4ca972a7 100644 --- a/packages/taler-wallet-core/src/operations/pending.ts +++ b/packages/taler-wallet-core/src/operations/pending.ts @@ -27,6 +27,7 @@ import { AbortStatus, WalletStoresV1, BackupProviderStateTag, + RefreshCoinStatus, } from "../db.js"; import { PendingOperationsResponse, @@ -111,12 +112,17 @@ async function gatherRefreshPending( if (r.timestampFinished) { return; } + if (r.frozen) { + return; + } resp.pendingOperations.push({ type: PendingTaskType.Refresh, givesLifeness: true, timestampDue: r.retryInfo.nextRetry, refreshGroupId: r.refreshGroupId, - finishedPerCoin: r.finishedPerCoin, + finishedPerCoin: r.statusPerCoin.map( + (x) => x === RefreshCoinStatus.Finished, + ), retryInfo: r.retryInfo, }); }); diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts index 5c4ed4f70..8926559e3 100644 --- a/packages/taler-wallet-core/src/operations/refresh.ts +++ b/packages/taler-wallet-core/src/operations/refresh.ts @@ -20,6 +20,7 @@ import { CoinSourceType, CoinStatus, DenominationRecord, + RefreshCoinStatus, RefreshGroupRecord, RefreshPlanchet, WalletStoresV1, @@ -28,6 +29,7 @@ import { codecForExchangeMeltResponse, codecForExchangeRevealResponse, CoinPublicKey, + fnutil, NotificationType, RefreshGroupId, RefreshReason, @@ -37,7 +39,11 @@ import { } from "@gnu-taler/taler-util"; import { AmountJson, Amounts } from "@gnu-taler/taler-util"; import { amountToPretty } from "@gnu-taler/taler-util"; -import { readSuccessResponseJsonOrThrow } from "../util/http.js"; +import { + HttpResponseStatus, + readSuccessResponseJsonOrThrow, + readUnexpectedResponseDetails, +} from "../util/http.js"; import { checkDbInvariant } from "../util/invariants.js"; import { Logger } from "@gnu-taler/taler-util"; import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js"; @@ -99,6 +105,26 @@ export function getTotalRefreshCost( return totalCost; } +function updateGroupStatus(rg: RefreshGroupRecord): void { + let allDone = fnutil.all( + rg.statusPerCoin, + (x) => x === RefreshCoinStatus.Finished || x === RefreshCoinStatus.Frozen, + ); + let anyFrozen = fnutil.any( + rg.statusPerCoin, + (x) => x === RefreshCoinStatus.Frozen, + ); + if (allDone) { + if (anyFrozen) { + rg.frozen = true; + rg.retryInfo = initRetryInfo(); + } else { + rg.timestampFinished = getTimestampNow(); + rg.retryInfo = initRetryInfo(); + } + } +} + /** * Create a refresh session for one particular coin inside a refresh group. */ @@ -121,7 +147,9 @@ async function refreshCreateSession( if (!refreshGroup) { return; } - if (refreshGroup.finishedPerCoin[coinIndex]) { + if ( + refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished + ) { return; } const existingRefreshSession = @@ -211,18 +239,9 @@ async function refreshCreateSession( if (!rg) { return; } - rg.finishedPerCoin[coinIndex] = true; - let allDone = true; - for (const f of rg.finishedPerCoin) { - if (!f) { - allDone = false; - break; - } - } - if (allDone) { - rg.timestampFinished = getTimestampNow(); - rg.retryInfo = initRetryInfo(); - } + rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished; + updateGroupStatus(rg); + await tx.refreshGroups.put(rg); }); ws.notify({ type: NotificationType.RefreshUnwarranted }); @@ -358,6 +377,31 @@ async function refreshMelt( }); }); + if (resp.status === HttpResponseStatus.NotFound) { + const errDetails = await readUnexpectedResponseDetails(resp); + await ws.db + .mktx((x) => ({ + refreshGroups: x.refreshGroups, + })) + .runReadWrite(async (tx) => { + const rg = await tx.refreshGroups.get(refreshGroupId); + if (!rg) { + return; + } + if (rg.timestampFinished) { + return; + } + if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) { + return; + } + rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Frozen; + rg.lastErrorPerCoin[coinIndex] = errDetails; + updateGroupStatus(rg); + await tx.refreshGroups.put(rg); + }); + return; + } + const meltResponse = await readSuccessResponseJsonOrThrow( resp, codecForExchangeMeltResponse(), @@ -598,18 +642,8 @@ async function refreshReveal( if (!rs) { return; } - rg.finishedPerCoin[coinIndex] = true; - let allDone = true; - for (const f of rg.finishedPerCoin) { - if (!f) { - allDone = false; - break; - } - } - if (allDone) { - rg.timestampFinished = getTimestampNow(); - rg.retryInfo = initRetryInfo(); - } + rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished; + updateGroupStatus(rg); for (const coin of coins) { await tx.coins.put(coin); } @@ -728,7 +762,7 @@ async function processRefreshSession( if (!refreshGroup) { return; } - if (refreshGroup.finishedPerCoin[coinIndex]) { + if (refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished) { return; } if (!refreshGroup.refreshSessionPerCoin[coinIndex]) { @@ -744,7 +778,7 @@ async function processRefreshSession( } const refreshSession = refreshGroup.refreshSessionPerCoin[coinIndex]; if (!refreshSession) { - if (!refreshGroup.finishedPerCoin[coinIndex]) { + if (refreshGroup.statusPerCoin[coinIndex] !== RefreshCoinStatus.Finished) { throw Error( "BUG: refresh session was not created and coin not marked as finished", ); @@ -826,13 +860,13 @@ export async function createRefreshGroup( const refreshGroup: RefreshGroupRecord = { timestampFinished: undefined, - finishedPerCoin: oldCoinPubs.map((x) => false), + statusPerCoin: oldCoinPubs.map(() => RefreshCoinStatus.Pending), lastError: undefined, lastErrorPerCoin: {}, oldCoinPubs: oldCoinPubs.map((x) => x.coinPub), reason, refreshGroupId, - refreshSessionPerCoin: oldCoinPubs.map((x) => undefined), + refreshSessionPerCoin: oldCoinPubs.map(() => undefined), retryInfo: initRetryInfo(), inputPerCoin, estimatedOutputPerCoin, diff --git a/packages/taler-wallet-core/src/util/http.ts b/packages/taler-wallet-core/src/util/http.ts index 68a63e124..ce507465a 100644 --- a/packages/taler-wallet-core/src/util/http.ts +++ b/packages/taler-wallet-core/src/util/http.ts @@ -24,10 +24,7 @@ /** * Imports */ -import { - OperationFailedError, - makeErrorDetails, -} from "../errors.js"; +import { OperationFailedError, makeErrorDetails } from "../errors.js"; import { Logger, Duration, @@ -68,6 +65,7 @@ export enum HttpResponseStatus { Gone = 210, NotModified = 304, PaymentRequired = 402, + NotFound = 404, Conflict = 409, } @@ -158,6 +156,33 @@ export async function readTalerErrorResponse( return errJson; } +export async function readUnexpectedResponseDetails( + httpResponse: HttpResponse, +): Promise<TalerErrorDetails> { + const errJson = await httpResponse.json(); + const talerErrorCode = errJson.code; + if (typeof talerErrorCode !== "number") { + return makeErrorDetails( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + "Error response did not contain error code", + { + requestUrl: httpResponse.requestUrl, + requestMethod: httpResponse.requestMethod, + httpStatusCode: httpResponse.status, + }, + ); + } + return makeErrorDetails( + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + "Unexpected error code in response", + { + requestUrl: httpResponse.requestUrl, + httpStatusCode: httpResponse.status, + errorResponse: errJson, + }, + ); +} + export async function readSuccessResponseJsonOrErrorCode<T>( httpResponse: HttpResponse, codec: Codec<T>, |