-
Notifications
You must be signed in to change notification settings - Fork 89
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
feat(backend): manage webhook event lifecycle in webhook service #228
Conversation
return await Invoice.transaction(async (trx) => { | ||
await this.$query(trx).patch({ | ||
active: false | ||
}) | ||
const event = await InvoiceEvent.query(trx).insertAndFetch({ | ||
type: InvoiceEventType.InvoicePaid, | ||
data: this.toData(balance), | ||
// TODO: | ||
// Add 30 seconds to allow a prepared (but not yet fulfilled/rejected) packet to finish before being deactivated. | ||
processAt: new Date() | ||
}) | ||
const error = await createWithdrawal({ | ||
id: event.id, | ||
account: this, | ||
amount: balance, | ||
timeout: BigInt(RETRY_LIMIT_MS) * BigInt(1e6) // ms -> ns | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As is, there's the possibility of the withdrawal transfer being created and the postgres transaction commit failing.
In which case, you'd need onCredit
to be called again after the withdrawal times out or count on the balance to be withdrawn when the invoice expires. But there's a chance the invoice expiration handling happens before this withdrawal times out...
Alternatives include:
- the webhook service creates withdrawals on the first attempt to send the event
- every service with liquidity balance triggered webhooks (including asset, peer, open payment accounts) has workers handling accounts with recently updated balances, like outgoing payment and invoice services do/did.
👆 I forgot to actually specify the withdrawal details when creating invoice and outgoing payments webhook events. |
If invoice event withdrawal hasn't been created, update the amount. Otherwise, create new invoice event for subsequent received amounts.
docs/transaction-api.md
Outdated
@@ -87,47 +87,45 @@ An expired invoice that has never received money is deleted. | |||
|
|||
Rafiki sends webhook events to notify the wallet of payment lifecycle states that require liquidity to be added or removed. | |||
|
|||
Webhook event handlers must be idempotent. | |||
Webhook event handlers must be idempotent and return `200`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this be "return 200
on success"? What happens if they don't return 200; is the webhook retried by Rafiki?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes and yes
@@ -55,9 +58,15 @@ export interface Withdrawal extends Deposit { | |||
timeout: bigint | |||
} | |||
|
|||
export interface TransferAccount extends LiquidityAccount { | |||
asset: LiquidityAccount['asset'] & { | |||
asset: LiquidityAccount['asset'] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is the reason for the nested asset
property?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It needed for createTransfer
to populate source/destination accounts an the asset liquidity account in order to call onCredit
/onDebit
with a LiquidityAccount
.
https://github.com/interledger/rafiki/pull/228/files#diff-72a5530c2c89f105f75e071479a382e8f8408459d37968fb93b546cca4783b31R233-R251
(It happens to also prevent createTransfer
from being called with an asset liquidity account.)
That said we're not currently calling onCredit
/onDebit
for asset liquidity accounts...
if (account.onDebit) { | ||
const balance = await getAccountBalance(deps, account.id) | ||
assert.ok(balance !== undefined) | ||
await account.onDebit(balance) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where is onDebit
implemented? I grepped the code and didn't find it. Or is it just for symmetry with onCredit
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It will likely be used for asset and peer liquidity webhooks, but is currently unused and could be removed.
for (const account of destinationAccounts) { | ||
if (account.onCredit) { | ||
const balance = await getAccountBalance(deps, account.id) | ||
assert.ok(balance !== undefined) | ||
await account.onCredit(balance) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What happens if a getAccountBalance
or onCredit
fails?
Right now:
commit
's promise will reject.- Any other
onCredit
functions later in the loop were never executed.
Is that safe here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
07661cf makes this only call onCredit
for createTransfer
's single destinationAccount
.
@matdehaast mentioned elsewhere that we don't need balance-triggered liquidity webhook events for assets or peers in the short term. This should suffice for invoice and web monetization events for now.
table.timestamp('createdAt').defaultTo(knex.fn.now()) | ||
table.timestamp('updatedAt').defaultTo(knex.fn.now()) | ||
|
||
table.index(['accountId', 'createdAt', 'id']) | ||
|
||
table.index('processAt') | ||
table.index('expiresAt') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In packages/backend/src/open_payments/invoice/service.ts
, processNextInvoice
queries the next "job" via expiresAt < now AND active=true
. I think this index should be table.index(['expiresAt', 'active'])
so that it doesn't need to scan all expired invoices to find the active ones.
And eventually the active=true
part could be a partial index.
Remove onDebit. Remove TransferAccount. Update invoices index. Update transaction-api.md docs.
Replace error with statusCode in webhookEvents table.
@matdehaast
|
@wilsonianb so interesting you bring it up. When I was first looking at the webhook stuff there were proposals by some people that actually advocate for a pull based model rather than pushed based. There is an interesting HN thread on it. From our last call as I understood, a successful 200 would withdraw/add liquidity to the webhook. I haven't reviewed this PR yet so not sure if that has changed. How does that work in a query based model? ie if I query the event and get the webhooks, is that just assumed to be processed? If we are sticking to auto liquidity with a webhook, we can't do events IMO.
Can you elaborate more here? ie, who does the bulk operation and how is it triggered? |
This pr changed it so Rafiki just retries sending events if there isn't a successful 200 response. The wallet is expected call
For time-based web monetization withdrawals, either the wallet or a (kubernetes) cron or Rafiki internally could trigger a query in Rafiki for accounts with a web monetization balance (in both postgres and tigerbeetle). That could either produce one event per account or we could support the wallet taking the result of that query and having Rafiki withdraw from all of those balances at once. Looking at that HN thread, I'm leaning towards one attempt to send a webhook event and support event querying. However, it seems like querying for events could actually just be querying for accounts, invoices, outgoing payments with outstanding balances to be withdrawn (as opposed to Rafiki storing point in time events in postgres). (@sentientwaffle suggested that we could store hints on postgres rows about whether there's a non-empty tigerbeetle balance to lookup.) |
.catch((err) => { | ||
this.logger.warn({ error: err.message }, 'processWebhook error') | ||
return true | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If a single one fails, does this whole process crash?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think returning true in the .catch
causes the .then
👇 to be called
@@ -70,6 +72,8 @@ export const resolvers: Resolvers = { | |||
createPeerLiquidityWithdrawal: createPeerLiquidityWithdrawal, | |||
createAccountWithdrawal, | |||
finalizeLiquidityWithdrawal: finalizeLiquidityWithdrawal, | |||
rollbackLiquidityWithdrawal: rollbackLiquidityWithdrawal | |||
rollbackLiquidityWithdrawal: rollbackLiquidityWithdrawal, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this the equivalent of rejecting the 2 phase commit?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, it rejects a withdrawal transfer from createAssetLiquidityWithdrawal
/createPeerLiquidityWithdrawal
/createAccountWithdrawal
outcome: { | ||
amountSent: amountSent.toString() | ||
}, | ||
balance: balance.toString() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this the balance remaining in the payment account?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
✔️ yes
Is it expected they call that whilst processing the event? Or can that be called after the 200?
Ok so we should keep this internal if we do go this way so its agnostic to how people are running Rafiki, ie more self-contained. Ideally they handle each one atomically individually as doing bulk operations like that can be difficult for ledgers and replayability etc.
Single attempt with query support seems good. We just need to make sure the client knows where they are and if they need to go a query to catchup. How would that mechanism work to sync it? Seems tricky to write code to handle that nicely.
Again I'm trying to think how we handle this from the client side consuming this. It feels like you can easily make a mistake known if an account needs to be withdrawn/deposit liquidity based if its completed incoming payment or failed outgoing payment etc. One nice thing about events is it makes it easier to switch and do logic against those cases. Happy to discuss some more |
It shouldn't make a difference from Rafiki's point of view. The "event" (invoice update) isn't considered "delivered" until the balance has been withdrawn. Though as mentioned later, Rafiki only makes one push attempt, after which it assumes the invoice will be picked up by periodic queries from the wallet.
I think the client (wallet) should just make catchup queries constantly in the background (i.e. every few seconds or minutes, depending on how much delay in withdrawal is acceptable). I'm not quite sure what you refer to by
|
Should Rafiki support one general |
👆 We discussed that a single |
@sentientwaffle @matdehaast |
@@ -52,7 +54,7 @@ export interface Deposit { | |||
} | |||
|
|||
export interface Withdrawal extends Deposit { | |||
timeout: bigint | |||
timeout?: bigint |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This (and other) timeout
parameters should be named with the unit; e.g. timeoutNano
, timeoutMilli
.
JS timeouts are typically milliseconds (e.g. setTimeout
) but TigerBeetle uses nanoseconds. Since we need to use both (in different contexts), I think its worth disambiguating them in the property name so that there's no confusion.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if we kept the name timeout
but keep it consistent as milliseconds and convert to nano-seconds where passing to tigerbeetle-node?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 That works.
|
||
expect(response.success).toBe(true) | ||
expect(response.code).toEqual('200') | ||
expect(response.error).toBeNull() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Verify that the deposit was created?
Changes proposed in this pull request
depositEventLiquidity
andwithdrawEventLiquidity
mutation resolvers for updating account liquidity based on webhook events. (Partially reverts fix(backend): manage payment liquidity on webhook response #214) Re-addOutgoingPaymentService.fund
, but notrequote
orcancel
yet since those may end up in the Open Payments API.onCredit
function to LiquidityAccount interface for triggering balance based webhook events. (This will be used for Web monetization liquidity withdrawal #220).Context
Checklist
fixes #number