Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automated retries #1776

Merged
merged 32 commits into from
Feb 15, 2025
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
9a22c7a
Poll failed invoices with visibility timeout
ekzyis Dec 30, 2024
0dbdd22
Don't return intermediate failed invoices
ekzyis Jan 2, 2025
c897061
Don't retry too old invoices
ekzyis Jan 6, 2025
073c5b2
Retry invoices on client
ekzyis Jan 7, 2025
6e48349
Only attempt payment 3 times
ekzyis Jan 7, 2025
c101b60
Fix fallbacks during last retry
ekzyis Jan 8, 2025
64e3efc
Rename retry column to paymentAttempt
ekzyis Jan 8, 2025
dc72954
Fix no index used
ekzyis Jan 9, 2025
6707777
Resolve TODOs
ekzyis Jan 9, 2025
d448a1a
Use expiring locks
ekzyis Jan 13, 2025
7c10859
Better comments for constants
ekzyis Jan 17, 2025
5c81a69
Acquire lock during retry
ekzyis Jan 21, 2025
1bbd465
Use expiring lock in retry mutation
ekzyis Feb 4, 2025
0363043
Use now() instead of CURRENT_TIMESTAMP
ekzyis Feb 4, 2025
3603d57
Cosmetic changes
ekzyis Feb 6, 2025
dbe6f1c
Immediately show failed post payments in notifications
ekzyis Feb 9, 2025
f3f67c1
Update hasNewNotes
ekzyis Feb 9, 2025
a25242e
Never retry on user cancel
ekzyis Feb 12, 2025
4a4658d
Fix notifications without pending retries missing if no send wallets
ekzyis Feb 12, 2025
f95d71b
Stop hiding userCancel in notifications
ekzyis Feb 12, 2025
a90eb5d
Also consider invoice.cancelledAt in notifications
ekzyis Feb 12, 2025
dea2e7a
Always retry failed payments, even without send wallets
ekzyis Feb 12, 2025
ddc115e
Fix notification indicator on retry timeout
ekzyis Feb 13, 2025
a485faf
Set invoice.updated_at to date slightly in the future
ekzyis Feb 13, 2025
8fc36ba
Use default job priority
ekzyis Feb 13, 2025
fa26f73
Stop retrying after one hour
ekzyis Feb 13, 2025
1778ece
Merge branch 'master' into automated-retries
huumn Feb 13, 2025
d971229
Remove special case for ITEM_CREATE
ekzyis Feb 14, 2025
f57c37d
Replace retryTimeout job with notification indicator query
ekzyis Feb 14, 2025
e63650e
Fix sortTime
ekzyis Feb 14, 2025
f796208
Merge branch 'master' into automated-retries
huumn Feb 14, 2025
39c44b5
Merge branch 'master' into automated-retries
huumn Feb 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion api/paidAction/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ async function createSNInvoice (actionType, args, context) {
}

async function createDbInvoice (actionType, args, context) {
const { me, models, tx, cost, optimistic, actionId, invoiceArgs, predecessorId } = context
const { me, models, tx, cost, optimistic, actionId, invoiceArgs, paymentAttempt, predecessorId } = context
const { bolt11, wrappedBolt11, preimage, wallet, maxFee } = invoiceArgs

const db = tx ?? models
Expand All @@ -445,6 +445,7 @@ async function createDbInvoice (actionType, args, context) {
actionArgs: args,
expiresAt,
actionId,
paymentAttempt,
predecessorId
}

Expand Down
8 changes: 8 additions & 0 deletions api/resolvers/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { pushSubscriptionSchema, validateSchema } from '@/lib/validate'
import { replyToSubscription } from '@/lib/webPush'
import { getSub } from './sub'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { WALLET_MAX_RETRIES, WALLET_RETRY_BEFORE_MS } from '@/lib/constants'

export default {
Query: {
Expand Down Expand Up @@ -350,6 +351,13 @@ export default {
WHERE "Invoice"."userId" = $1
AND "Invoice"."updated_at" < $2
AND "Invoice"."actionState" = 'FAILED'
AND (
-- this is the inverse of the filter for automated retries
"Invoice"."paymentAttempt" >= ${WALLET_MAX_RETRIES}
OR "Invoice"."actionType" = 'ITEM_CREATE'
OR "Invoice"."userCancel" = true
OR "Invoice"."cancelledAt" <= now() - interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}'
)
AND (
"Invoice"."actionType" = 'ITEM_CREATE' OR
"Invoice"."actionType" = 'ZAP' OR
Expand Down
28 changes: 18 additions & 10 deletions api/resolvers/paidAction.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { retryPaidAction } from '../paidAction'
import { USER_ID } from '@/lib/constants'
import { USER_ID, WALLET_MAX_RETRIES, WALLET_RETRY_TIMEOUT_MS } from '@/lib/constants'

function paidActionType (actionType) {
switch (actionType) {
Expand Down Expand Up @@ -50,24 +50,32 @@ export default {
}
},
Mutation: {
retryPaidAction: async (parent, { invoiceId }, { models, me, lnd }) => {
retryPaidAction: async (parent, { invoiceId, newAttempt }, { models, me, lnd }) => {
if (!me) {
throw new Error('You must be logged in')
}

const invoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me.id } })
// make sure only one client at a time can retry by acquiring a lock that expires
const [invoice] = await models.$queryRaw`
UPDATE "Invoice"
SET "retryPendingSince" = now()
WHERE
id = ${invoiceId} AND
"userId" = ${me.id} AND
"actionState" = 'FAILED' AND
("retryPendingSince" IS NULL OR "retryPendingSince" < now() - ${`${WALLET_RETRY_TIMEOUT_MS} milliseconds`}::interval)
RETURNING *`
if (!invoice) {
throw new Error('Invoice not found')
throw new Error('Invoice not found or retry pending')
}

if (invoice.actionState !== 'FAILED') {
if (invoice.actionState === 'PAID') {
throw new Error('Invoice is already paid')
}
throw new Error(`Invoice is not in failed state: ${invoice.actionState}`)
huumn marked this conversation as resolved.
Show resolved Hide resolved
// do we want to retry a payment from the beginning with all sender and receiver wallets?
const paymentAttempt = newAttempt ? invoice.paymentAttempt + 1 : invoice.paymentAttempt
if (paymentAttempt > WALLET_MAX_RETRIES) {
throw new Error('Payment has been retried too many times')
}

const result = await retryPaidAction(invoice.actionType, { invoice }, { models, me, lnd })
const result = await retryPaidAction(invoice.actionType, { invoice }, { paymentAttempt, models, me, lnd })

return {
...result,
Expand Down
24 changes: 21 additions & 3 deletions api/resolvers/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { msatsToSats } from '@/lib/format'
import { bioSchema, emailSchema, settingsSchema, validateSchema, userSchema } from '@/lib/validate'
import { getItem, updateItem, filterClause, createItem, whereClause, muteClause, activeOrMine } from './item'
import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS, INVOICE_ACTION_NOTIFICATION_TYPES } from '@/lib/constants'
import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS, INVOICE_ACTION_NOTIFICATION_TYPES, WALLET_MAX_RETRIES, WALLET_RETRY_BEFORE_MS } from '@/lib/constants'
import { viewGroup } from './growth'
import { timeUnitForRange, whenRange } from '@/lib/time'
import { datePivot, timeUnitForRange, whenRange } from '@/lib/time'
import assertApiKeyNotPermitted from './apiKey'
import { hashEmail } from '@/lib/crypto'
import { isMuted } from '@/lib/user'
Expand Down Expand Up @@ -543,7 +543,25 @@ export default {
actionType: {
in: INVOICE_ACTION_NOTIFICATION_TYPES
},
actionState: 'FAILED'
actionState: 'FAILED',
OR: [
{
paymentAttempt: {
gte: WALLET_MAX_RETRIES
}
},
{
actionType: 'ITEM_CREATE'
},
{
userCancel: true
},
{
cancelledAt: {
lte: datePivot(new Date(), { milliseconds: -WALLET_RETRY_BEFORE_MS })
}
}
]
}
})

Expand Down
22 changes: 21 additions & 1 deletion api/resolvers/wallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import { formatMsats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib
import {
USER_ID, INVOICE_RETENTION_DAYS,
PAID_ACTION_PAYMENT_METHODS,
WALLET_CREATE_INVOICE_TIMEOUT_MS
WALLET_CREATE_INVOICE_TIMEOUT_MS,
WALLET_RETRY_AFTER_MS,
WALLET_RETRY_BEFORE_MS,
WALLET_MAX_RETRIES
} from '@/lib/constants'
import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
import assertGofacYourself from './ofac'
Expand Down Expand Up @@ -456,6 +459,23 @@ const resolvers = {
cursor: nextCursor,
entries: logs
}
},
failedInvoices: async (parent, args, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
return await models.$queryRaw`
SELECT * FROM "Invoice"
WHERE "userId" = ${me.id}
AND "actionState" = 'FAILED'
-- never retry if user has cancelled the invoice manually
AND "userCancel" = false
AND "cancelledAt" < now() - ${`${WALLET_RETRY_AFTER_MS} milliseconds`}::interval
AND "cancelledAt" > now() - ${`${WALLET_RETRY_BEFORE_MS} milliseconds`}::interval
AND "paymentAttempt" < ${WALLET_MAX_RETRIES}
-- never retry failed posts because we always immediately show them in notifications
AND "actionType" <> 'ITEM_CREATE'
ekzyis marked this conversation as resolved.
Show resolved Hide resolved
ORDER BY id DESC`
}
},
Wallet: {
Expand Down
2 changes: 1 addition & 1 deletion api/typeDefs/paidAction.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ extend type Query {
}

extend type Mutation {
retryPaidAction(invoiceId: Int!): PaidAction!
retryPaidAction(invoiceId: Int!, newAttempt: Boolean): PaidAction!
}

enum PaymentMethod {
Expand Down
1 change: 1 addition & 0 deletions api/typeDefs/wallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ const typeDefs = `
wallet(id: ID!): Wallet
walletByType(type: String!): Wallet
walletLogs(type: String, from: String, to: String, cursor: String): WalletLog!
failedInvoices: [Invoice!]!
}

extend type Mutation {
Expand Down
8 changes: 4 additions & 4 deletions components/use-invoice.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useApolloClient, useMutation } from '@apollo/client'
import { useCallback } from 'react'
import { useCallback, useMemo } from 'react'
import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/errors'
import { RETRY_PAID_ACTION } from '@/fragments/paidAction'
import { INVOICE, CANCEL_INVOICE } from '@/fragments/wallet'
Expand Down Expand Up @@ -42,9 +42,9 @@ export default function useInvoice () {
return data.cancelInvoice
}, [cancelInvoice])

const retry = useCallback(async ({ id, hash, hmac }, { update }) => {
const retry = useCallback(async ({ id, hash, hmac, newAttempt = false }, { update } = {}) => {
console.log('retrying invoice:', hash)
const { data, error } = await retryPaidAction({ variables: { invoiceId: Number(id) }, update })
const { data, error } = await retryPaidAction({ variables: { invoiceId: Number(id), newAttempt }, update })
if (error) throw error

const newInvoice = data.retryPaidAction.invoice
Expand All @@ -53,5 +53,5 @@ export default function useInvoice () {
return newInvoice
}, [retryPaidAction])

return { cancel, retry, isInvoice }
return useMemo(() => ({ cancel, retry, isInvoice }), [cancel, retry, isInvoice])
ekzyis marked this conversation as resolved.
Show resolved Hide resolved
}
4 changes: 2 additions & 2 deletions fragments/paidAction.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,8 @@ export const RETRY_PAID_ACTION = gql`
${PAID_ACTION}
${ITEM_PAID_ACTION_FIELDS}
${ITEM_ACT_PAID_ACTION_FIELDS}
mutation retryPaidAction($invoiceId: Int!) {
retryPaidAction(invoiceId: $invoiceId) {
mutation retryPaidAction($invoiceId: Int!, $newAttempt: Boolean) {
retryPaidAction(invoiceId: $invoiceId, newAttempt: $newAttempt) {
__typename
...PaidActionFields
... on ItemPaidAction {
Expand Down
9 changes: 9 additions & 0 deletions fragments/wallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,12 @@ export const CANCEL_INVOICE = gql`
}
}
`

export const FAILED_INVOICES = gql`
${INVOICE_FIELDS}
query FailedInvoices {
failedInvoices {
...InvoiceFields
}
}
`
6 changes: 6 additions & 0 deletions lib/apollo.js
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,12 @@ function getClient (uri) {
facts: [...(existing?.facts || []), ...incoming.facts]
}
}
},
failedInvoices: {
keyArgs: [],
merge (existing, incoming) {
return incoming
}
}
}
},
Expand Down
10 changes: 10 additions & 0 deletions lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -198,4 +198,14 @@ export const ZAP_UNDO_DELAY_MS = 5_000
export const WALLET_SEND_PAYMENT_TIMEOUT_MS = 150_000
export const WALLET_CREATE_INVOICE_TIMEOUT_MS = 45_000

// interval between which failed invoices are returned to a client for automated retries.
// retry-after must be high enough such that intermediate failed invoices that will already
// be retried by the client due to sender or receiver fallbacks are not returned to the client.
export const WALLET_RETRY_AFTER_MS = 60_000 // 1 minute
export const WALLET_RETRY_BEFORE_MS = 3_600_000 // 1 hour
// we want to attempt a payment three times so we retry two times
export const WALLET_MAX_RETRIES = 2
// when a pending retry for an invoice should be considered expired and can be attempted again
export const WALLET_RETRY_TIMEOUT_MS = 60_000 // 1 minute
huumn marked this conversation as resolved.
Show resolved Hide resolved

export const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Invoice" ADD COLUMN "paymentAttempt" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Invoice" ADD COLUMN "retryPendingSince" TIMESTAMP(3);
CREATE INDEX "Invoice_cancelledAt_idx" ON "Invoice"("cancelledAt");
3 changes: 3 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,8 @@ model Invoice {
cancelled Boolean @default(false)
cancelledAt DateTime?
userCancel Boolean?
paymentAttempt Int @default(0)
retryPendingSince DateTime?
msatsRequested BigInt
msatsReceived BigInt?
desc String?
Expand Down Expand Up @@ -956,6 +958,7 @@ model Invoice {
@@index([confirmedIndex], map: "Invoice.confirmedIndex_index")
@@index([isHeld])
@@index([confirmedAt])
@@index([cancelledAt])
@@index([actionType])
@@index([actionState])
}
Expand Down
89 changes: 83 additions & 6 deletions wallets/index.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { useMe } from '@/components/me'
import { SET_WALLET_PRIORITY, WALLETS } from '@/fragments/wallet'
import { SSR } from '@/lib/constants'
import { useApolloClient, useMutation, useQuery } from '@apollo/client'
import { FAILED_INVOICES, SET_WALLET_PRIORITY, WALLETS } from '@/fragments/wallet'
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
import { useApolloClient, useLazyQuery, useMutation, useQuery } from '@apollo/client'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { getStorageKey, getWalletByType, walletPrioritySort, canSend, isConfigured, upsertWalletVariables, siftConfig, saveWalletLocally } from './common'
import useVault from '@/components/vault/use-vault'
import walletDefs from '@/wallets/client'
import { generateMutation } from './graphql'
import { useWalletPayment } from './payment'
import useInvoice from '@/components/use-invoice'
import { WalletConfigurationError } from './errors'

const WalletsContext = createContext({
wallets: []
Expand Down Expand Up @@ -204,7 +207,9 @@ export function WalletsProvider ({ children }) {
removeLocalWallets
}}
>
{children}
<RetryHandler>
{children}
</RetryHandler>
huumn marked this conversation as resolved.
Show resolved Hide resolved
</WalletsContext.Provider>
)
}
Expand All @@ -221,7 +226,79 @@ export function useWallet (name) {
export function useSendWallets () {
const { wallets } = useWallets()
// return all enabled wallets that are available and can send
return wallets
return useMemo(() => wallets
.filter(w => !w.def.isAvailable || w.def.isAvailable())
.filter(w => w.config?.enabled && canSend(w))
.filter(w => w.config?.enabled && canSend(w)), [wallets])
}

function RetryHandler ({ children }) {
const wallets = useSendWallets()
const waitForWalletPayment = useWalletPayment()
const invoiceHelper = useInvoice()
const [getFailedInvoices] = useLazyQuery(FAILED_INVOICES, { fetchPolicy: 'network-only', nextFetchPolicy: 'network-only' })

const retry = useCallback(async (invoice) => {
const newInvoice = await invoiceHelper.retry({ ...invoice, newAttempt: true })

try {
await waitForWalletPayment(newInvoice)
} catch (err) {
if (err instanceof WalletConfigurationError) {
// consume attempt by canceling invoice
await invoiceHelper.cancel(newInvoice)
}
throw err
}
}, [invoiceHelper, waitForWalletPayment])

useEffect(() => {
// we always retry failed invoices, even if the user has no wallets on any client
// to make sure that failed payments will always show up in notifications eventually

const retryPoll = async () => {
let failedInvoices
try {
const { data, error } = await getFailedInvoices()
if (error) throw error
failedInvoices = data.failedInvoices
} catch (err) {
console.error('failed to fetch invoices to retry:', err)
return
}

for (const inv of failedInvoices) {
try {
await retry(inv)
} catch (err) {
// some retries are expected to fail since only one client at a time is allowed to retry
// these should show up as 'invoice not found' errors
console.error('retry failed:', err)
}
}
}

let timeout, stopped
const queuePoll = () => {
timeout = setTimeout(async () => {
try {
await retryPoll()
} catch (err) {
// every error should already be handled in retryPoll
// but this catch is a safety net to not trigger an unhandled promise rejection
console.error('retry poll failed:', err)
}
if (!stopped) queuePoll()
}, NORMAL_POLL_INTERVAL)
}

const stopPolling = () => {
stopped = true
clearTimeout(timeout)
}

queuePoll()
return stopPolling
}, [wallets, getFailedInvoices, retry])

return children
}
Loading