/* This file is part of GNU Taler (C) 2020 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 */ /** * Helpers for dealing with retry timeouts. */ /** * Imports. */ import { AbsoluteTime, Duration, Logger, TalerErrorDetail, } from "@gnu-taler/taler-util"; import { BackupProviderRecord, DepositGroupRecord, ExchangeRecord, PeerPullPaymentIncomingRecord, PeerPullPaymentInitiationRecord, PeerPushPaymentIncomingRecord, PeerPushPaymentInitiationRecord, PurchaseRecord, RecoupGroupRecord, RefreshGroupRecord, TipRecord, WalletStoresV1, WithdrawalGroupRecord, } from "../db.js"; import { TalerError } from "@gnu-taler/taler-util"; import { InternalWalletState } from "../internal-wallet-state.js"; import { PendingTaskType, TaskId } from "../pending-types.js"; import { GetReadWriteAccess } from "./query.js"; import { assertUnreachable } from "./assertUnreachable.js"; const logger = new Logger("util/retries.ts"); export enum OperationAttemptResultType { Finished = "finished", Pending = "pending", Error = "error", Longpoll = "longpoll", } export type OperationAttemptResult = | OperationAttemptFinishedResult | OperationAttemptErrorResult | OperationAttemptLongpollResult | OperationAttemptPendingResult; export namespace OperationAttemptResult { export function finishedEmpty(): OperationAttemptResult { return { type: OperationAttemptResultType.Finished, result: undefined, }; } export function pendingEmpty(): OperationAttemptResult { return { type: OperationAttemptResultType.Pending, result: undefined, }; } export function longpoll(): OperationAttemptResult { return { type: OperationAttemptResultType.Longpoll, }; } } export interface OperationAttemptFinishedResult { type: OperationAttemptResultType.Finished; result: T; } export interface OperationAttemptPendingResult { type: OperationAttemptResultType.Pending; result: T; } export interface OperationAttemptErrorResult { type: OperationAttemptResultType.Error; errorDetail: TalerErrorDetail; } export interface OperationAttemptLongpollResult { type: OperationAttemptResultType.Longpoll; } export interface RetryInfo { firstTry: AbsoluteTime; nextRetry: AbsoluteTime; retryCounter: number; } export interface RetryPolicy { readonly backoffDelta: Duration; readonly backoffBase: number; readonly maxTimeout: Duration; } const defaultRetryPolicy: RetryPolicy = { backoffBase: 1.5, backoffDelta: Duration.fromSpec({ seconds: 1 }), maxTimeout: Duration.fromSpec({ minutes: 2 }), }; function updateTimeout( r: RetryInfo, p: RetryPolicy = defaultRetryPolicy, ): void { const now = AbsoluteTime.now(); if (now.t_ms === "never") { throw Error("assertion failed"); } if (p.backoffDelta.d_ms === "forever") { r.nextRetry = { t_ms: "never" }; return; } const nextIncrement = p.backoffDelta.d_ms * Math.pow(p.backoffBase, r.retryCounter); const t = now.t_ms + (p.maxTimeout.d_ms === "forever" ? nextIncrement : Math.min(p.maxTimeout.d_ms, nextIncrement)); r.nextRetry = { t_ms: t }; } export namespace RetryInfo { export function getDuration( r: RetryInfo | undefined, p: RetryPolicy = defaultRetryPolicy, ): Duration { if (!r) { // If we don't have any retry info, run immediately. return { d_ms: 0 }; } if (p.backoffDelta.d_ms === "forever") { return { d_ms: "forever" }; } const t = p.backoffDelta.d_ms * Math.pow(p.backoffBase, r.retryCounter); return { d_ms: p.maxTimeout.d_ms === "forever" ? t : Math.min(p.maxTimeout.d_ms, t), }; } export function reset(p: RetryPolicy = defaultRetryPolicy): RetryInfo { const now = AbsoluteTime.now(); const info = { firstTry: now, nextRetry: now, retryCounter: 0, }; updateTimeout(info, p); return info; } export function increment( r: RetryInfo | undefined, p: RetryPolicy = defaultRetryPolicy, ): RetryInfo { if (!r) { return reset(p); } const r2 = { ...r }; r2.retryCounter++; updateTimeout(r2, p); return r2; } } /** * Parsed representation of task identifiers. */ export type ParsedTaskIdentifier = | { tag: PendingTaskType.Withdraw; withdrawalGroupId: string; } | { tag: PendingTaskType.ExchangeUpdate; exchangeBaseUrl: string } | { tag: PendingTaskType.Backup; backupProviderBaseUrl: string } | { tag: PendingTaskType.Deposit; depositGroupId: string } | { tag: PendingTaskType.ExchangeCheckRefresh; exchangeBaseUrl: string } | { tag: PendingTaskType.ExchangeUpdate; exchangeBaseUrl: string } | { tag: PendingTaskType.PeerPullDebit; peerPullPaymentIncomingId: string } | { tag: PendingTaskType.PeerPullCredit; pursePub: string } | { tag: PendingTaskType.PeerPushCredit; peerPushPaymentIncomingId: string } | { tag: PendingTaskType.PeerPushDebit; pursePub: string } | { tag: PendingTaskType.Purchase; proposalId: string } | { tag: PendingTaskType.Recoup; recoupGroupId: string } | { tag: PendingTaskType.TipPickup; walletTipId: string } | { tag: PendingTaskType.Refresh; refreshGroupId: string }; export function parseTaskIdentifier(x: string): ParsedTaskIdentifier { throw Error("not yet implemented"); } export function constructTaskIdentifier(p: ParsedTaskIdentifier): TaskId { switch (p.tag) { case PendingTaskType.Backup: return `${p.tag}:${p.backupProviderBaseUrl}` as TaskId; case PendingTaskType.Deposit: return `${p.tag}:${p.depositGroupId}` as TaskId; case PendingTaskType.ExchangeCheckRefresh: return `${p.tag}:${p.exchangeBaseUrl}` as TaskId; case PendingTaskType.ExchangeUpdate: return `${p.tag}:${p.exchangeBaseUrl}` as TaskId; case PendingTaskType.PeerPullDebit: return `${p.tag}:${p.peerPullPaymentIncomingId}` as TaskId; case PendingTaskType.PeerPushCredit: return `${p.tag}:${p.peerPushPaymentIncomingId}` as TaskId; case PendingTaskType.PeerPullCredit: return `${p.tag}:${p.pursePub}` as TaskId; case PendingTaskType.PeerPushDebit: return `${p.tag}:${p.pursePub}` as TaskId; case PendingTaskType.Purchase: return `${p.tag}:${p.proposalId}` as TaskId; case PendingTaskType.Recoup: return `${p.tag}:${p.recoupGroupId}` as TaskId; case PendingTaskType.Refresh: return `${p.tag}:${p.refreshGroupId}` as TaskId; case PendingTaskType.TipPickup: return `${p.tag}:${p.walletTipId}` as TaskId; case PendingTaskType.Withdraw: return `${p.tag}:${p.withdrawalGroupId}` as TaskId; default: assertUnreachable(p); } } export namespace TaskIdentifiers { export function forWithdrawal(wg: WithdrawalGroupRecord): TaskId { return `${PendingTaskType.Withdraw}:${wg.withdrawalGroupId}` as TaskId; } export function forExchangeUpdate(exch: ExchangeRecord): TaskId { return `${PendingTaskType.ExchangeUpdate}:${exch.baseUrl}` as TaskId; } export function forExchangeUpdateFromUrl(exchBaseUrl: string): TaskId { return `${PendingTaskType.ExchangeUpdate}:${exchBaseUrl}` as TaskId; } export function forExchangeCheckRefresh(exch: ExchangeRecord): TaskId { return `${PendingTaskType.ExchangeCheckRefresh}:${exch.baseUrl}` as TaskId; } export function forTipPickup(tipRecord: TipRecord): TaskId { return `${PendingTaskType.TipPickup}:${tipRecord.walletTipId}` as TaskId; } export function forRefresh(refreshGroupRecord: RefreshGroupRecord): TaskId { return `${PendingTaskType.Refresh}:${refreshGroupRecord.refreshGroupId}` as TaskId; } export function forPay(purchaseRecord: PurchaseRecord): TaskId { return `${PendingTaskType.Purchase}:${purchaseRecord.proposalId}` as TaskId; } export function forRecoup(recoupRecord: RecoupGroupRecord): TaskId { return `${PendingTaskType.Recoup}:${recoupRecord.recoupGroupId}` as TaskId; } export function forDeposit(depositRecord: DepositGroupRecord): TaskId { return `${PendingTaskType.Deposit}:${depositRecord.depositGroupId}` as TaskId; } export function forBackup(backupRecord: BackupProviderRecord): TaskId { return `${PendingTaskType.Backup}:${backupRecord.baseUrl}` as TaskId; } export function forPeerPushPaymentInitiation( ppi: PeerPushPaymentInitiationRecord, ): TaskId { return `${PendingTaskType.PeerPushDebit}:${ppi.pursePub}` as TaskId; } export function forPeerPullPaymentInitiation( ppi: PeerPullPaymentInitiationRecord, ): TaskId { return `${PendingTaskType.PeerPullCredit}:${ppi.pursePub}` as TaskId; } export function forPeerPullPaymentDebit( ppi: PeerPullPaymentIncomingRecord, ): TaskId { return `${PendingTaskType.PeerPullDebit}:${ppi.peerPullPaymentIncomingId}` as TaskId; } export function forPeerPushCredit( ppi: PeerPushPaymentIncomingRecord, ): TaskId { return `${PendingTaskType.PeerPushCredit}:${ppi.peerPushPaymentIncomingId}` as TaskId; } } export async function scheduleRetryInTx( ws: InternalWalletState, tx: GetReadWriteAccess<{ operationRetries: typeof WalletStoresV1.operationRetries; }>, opId: string, errorDetail?: TalerErrorDetail, ): Promise { let retryRecord = await tx.operationRetries.get(opId); if (!retryRecord) { retryRecord = { id: opId, retryInfo: RetryInfo.reset(), }; if (errorDetail) { retryRecord.lastError = errorDetail; } } else { retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo); if (errorDetail) { retryRecord.lastError = errorDetail; } else { delete retryRecord.lastError; } } await tx.operationRetries.put(retryRecord); } export async function scheduleRetry( ws: InternalWalletState, opId: string, errorDetail?: TalerErrorDetail, ): Promise { return await ws.db .mktx((x) => [x.operationRetries]) .runReadWrite(async (tx) => { tx.operationRetries; scheduleRetryInTx(ws, tx, opId, errorDetail); }); } /** * Run an operation handler, expect a success result and extract the success value. */ export async function unwrapOperationHandlerResultOrThrow( res: OperationAttemptResult, ): Promise { switch (res.type) { case OperationAttemptResultType.Finished: return res.result; case OperationAttemptResultType.Error: throw TalerError.fromUncheckedDetail(res.errorDetail); default: throw Error(`unexpected operation result (${res.type})`); } }