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

Open
wants to merge 30 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 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
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
17 changes: 16 additions & 1 deletion 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 @@ -345,11 +346,25 @@ export default {
)

queries.push(
`(SELECT "Invoice".id::text, "Invoice"."updated_at" AS "sortTime", NULL as "earnedSats", 'Invoicification' AS type
`(SELECT "Invoice".id::text,
CASE
WHEN
"Invoice"."paymentAttempt" < ${WALLET_MAX_RETRIES}
AND "Invoice"."userCancel" = false
AND "Invoice"."cancelledAt" <= now() - interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}'
THEN "Invoice"."cancelledAt" + interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}'
ELSE "Invoice"."updated_at"
END AS "sortTime", NULL as "earnedSats", 'Invoicification' AS type
FROM "Invoice"
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"."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
41 changes: 38 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,17 @@ export default {
actionType: {
in: INVOICE_ACTION_NOTIFICATION_TYPES
},
actionState: 'FAILED'
actionState: 'FAILED',
OR: [
{
paymentAttempt: {
gte: WALLET_MAX_RETRIES
}
},
{
userCancel: true
}
]
}
})

Expand All @@ -552,6 +562,31 @@ export default {
return true
}

const invoiceActionFailed2 = await models.invoice.findFirst({
Copy link
Member Author

@ekzyis ekzyis Feb 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I struggled with writing a good comment since I already struggled with grasping this query, lol.

I think this didn't break anything but I didn't fully QA everything again.

Will do so after some sleep.

where: {
userId: me.id,
updatedAt: {
gt: datePivot(lastChecked, { milliseconds: -WALLET_RETRY_BEFORE_MS })
},
actionType: {
in: INVOICE_ACTION_NOTIFICATION_TYPES
},
actionState: 'FAILED',
paymentAttempt: {
lt: WALLET_MAX_RETRIES
},
userCancel: false,
cancelledAt: {
lte: datePivot(new Date(), { milliseconds: -WALLET_RETRY_BEFORE_MS })
}
}
})

if (invoiceActionFailed2) {
foundNotes()
return true
}

// update checkedNotesAt to prevent rechecking same time period
models.user.update({
where: { id: me.id },
Expand Down
20 changes: 19 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,21 @@ 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}
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
Loading