-
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
Changes from 7 commits
6c97ba2
aa68573
e964ca8
3075f08
cdee2bb
1786e7d
e47695c
07661cf
5f1fbbd
42d82ea
c13231a
9ac5b15
f5a2ae9
1cc89ce
5757cbb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
exports.up = function (knex) { | ||
return knex.schema.createTable('webhookEvents', function (table) { | ||
table.uuid('id').notNullable().primary() | ||
|
||
table.string('type').notNullable() | ||
table.json('data').notNullable() | ||
table.integer('attempts').notNullable().defaultTo(0) | ||
table.string('error').nullable() | ||
|
||
table.uuid('withdrawalAccountId').nullable() | ||
table.uuid('withdrawalAssetId').nullable() | ||
table.foreign('withdrawalAssetId').references('assets.id') | ||
table.bigInteger('withdrawalAmount').nullable() | ||
|
||
table.timestamp('processAt').notNullable().defaultTo(knex.fn.now()) | ||
|
||
table.timestamp('createdAt').defaultTo(knex.fn.now()) | ||
table.timestamp('updatedAt').defaultTo(knex.fn.now()) | ||
|
||
table.index('processAt') | ||
}) | ||
} | ||
|
||
exports.down = function (knex) { | ||
return knex.schema.dropTableIfExists('webhookEvents') | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
exports.up = function (knex) { | ||
return knex.schema.createTable('invoices', function (table) { | ||
table.uuid('id').notNullable().primary() | ||
|
||
// Open payments account id | ||
table.uuid('accountId').notNullable() | ||
table.foreign('accountId').references('accounts.id') | ||
table.boolean('active').notNullable() | ||
table.string('description').nullable() | ||
table.timestamp('expiresAt').notNullable() | ||
table.bigInteger('amount').notNullable() | ||
table.uuid('eventId').nullable() | ||
table.foreign('eventId').references('webhookEvents.id') | ||
|
||
table.timestamp('createdAt').defaultTo(knex.fn.now()) | ||
table.timestamp('updatedAt').defaultTo(knex.fn.now()) | ||
|
||
table.index(['accountId', 'createdAt', 'id']) | ||
|
||
table.index('expiresAt') | ||
/* | ||
TODO: The latest version of knex supports "partial indexes", which would be more efficient for the deactivateInvoice use case. Unfortunately, the only version of 'objection' that supports this version of knex is still in alpha. | ||
// This is a 'partial index' -- expiresAt is only indexed when active=true. | ||
table.index('expiresAt', 'idx_active_expiresAt', { | ||
// Knex partial indices are a very new feature in Knex and appear to be buggy. | ||
// | ||
// Use a 'raw' condition because "knex.where('active',true)" fails with: | ||
// > migration failed with error: create index "idx_active_expiresAt" on "invoices" ("expiresAt") where "active" = $1 - there is no parameter $1 | ||
predicate: knex.whereRaw('active = TRUE') | ||
}) | ||
*/ | ||
}) | ||
} | ||
|
||
exports.down = function (knex) { | ||
return knex.schema.dropTableIfExists('invoices') | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
import assert from 'assert' | ||
import { | ||
Client, | ||
CreateAccountError as CreateAccountErrorCode | ||
|
@@ -43,6 +44,8 @@ export interface LiquidityAccount { | |
id: string | ||
unit: number | ||
} | ||
onCredit?: (balance: bigint) => Promise<LiquidityAccount> | ||
onDebit?: (balance: bigint) => Promise<LiquidityAccount> | ||
} | ||
|
||
export interface Deposit { | ||
|
@@ -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 commentThe reason will be displayed to describe this comment to others. Learn more. What is the reason for the nested There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It needed for |
||
} | ||
} | ||
|
||
export interface TransferOptions { | ||
sourceAccount: LiquidityAccount | ||
destinationAccount: LiquidityAccount | ||
sourceAccount: TransferAccount | ||
destinationAccount: TransferAccount | ||
sourceAmount: bigint | ||
destinationAmount?: bigint | ||
timeout: bigint // nano-seconds | ||
|
@@ -218,38 +227,54 @@ export async function createTransfer( | |
return TransferError.InvalidDestinationAmount | ||
} | ||
const transfers: Required<CreateTransferOptions>[] = [] | ||
const sourceAccounts: LiquidityAccount[] = [] | ||
const destinationAccounts: LiquidityAccount[] = [] | ||
|
||
// Same asset | ||
if (sourceAccount.asset.unit === destinationAccount.asset.unit) { | ||
const addTransfer = ({ | ||
sourceAccount, | ||
destinationAccount, | ||
amount | ||
}: { | ||
sourceAccount: LiquidityAccount | ||
destinationAccount: LiquidityAccount | ||
amount: bigint | ||
}) => { | ||
transfers.push({ | ||
id: uuid(), | ||
sourceAccountId: sourceAccount.id, | ||
destinationAccountId: destinationAccount.id, | ||
amount, | ||
timeout | ||
}) | ||
sourceAccounts.push(sourceAccount) | ||
destinationAccounts.push(destinationAccount) | ||
} | ||
|
||
// Same asset | ||
if (sourceAccount.asset.unit === destinationAccount.asset.unit) { | ||
addTransfer({ | ||
sourceAccount, | ||
destinationAccount, | ||
amount: | ||
destinationAmount && destinationAmount < sourceAmount | ||
? destinationAmount | ||
: sourceAmount, | ||
timeout | ||
: sourceAmount | ||
}) | ||
// Same asset, different amounts | ||
if (destinationAmount && sourceAmount !== destinationAmount) { | ||
// Send excess source amount to liquidity account | ||
if (destinationAmount < sourceAmount) { | ||
transfers.push({ | ||
id: uuid(), | ||
sourceAccountId: sourceAccount.id, | ||
destinationAccountId: sourceAccount.asset.id, | ||
amount: sourceAmount - destinationAmount, | ||
timeout | ||
addTransfer({ | ||
sourceAccount, | ||
destinationAccount: sourceAccount.asset, | ||
amount: sourceAmount - destinationAmount | ||
}) | ||
// Deliver excess destination amount from liquidity account | ||
} else { | ||
transfers.push({ | ||
id: uuid(), | ||
sourceAccountId: destinationAccount.asset.id, | ||
destinationAccountId: destinationAccount.id, | ||
amount: destinationAmount - sourceAmount, | ||
timeout | ||
addTransfer({ | ||
sourceAccount: destinationAccount.asset, | ||
destinationAccount, | ||
amount: destinationAmount - sourceAmount | ||
}) | ||
} | ||
} | ||
|
@@ -261,22 +286,16 @@ export async function createTransfer( | |
} | ||
// Send to source liquidity account | ||
// Deliver from destination liquidity account | ||
transfers.push( | ||
{ | ||
id: uuid(), | ||
sourceAccountId: sourceAccount.id, | ||
destinationAccountId: sourceAccount.asset.id, | ||
amount: sourceAmount, | ||
timeout | ||
}, | ||
{ | ||
id: uuid(), | ||
sourceAccountId: destinationAccount.asset.id, | ||
destinationAccountId: destinationAccount.id, | ||
amount: destinationAmount, | ||
timeout | ||
} | ||
) | ||
addTransfer({ | ||
sourceAccount, | ||
destinationAccount: sourceAccount.asset, | ||
amount: sourceAmount | ||
}) | ||
addTransfer({ | ||
sourceAccount: destinationAccount.asset, | ||
destinationAccount, | ||
amount: destinationAmount | ||
}) | ||
} | ||
const error = await createTransfers(deps, transfers) | ||
if (error) { | ||
|
@@ -308,6 +327,20 @@ export async function createTransfer( | |
if (error) { | ||
return error.error | ||
} | ||
for (const account of sourceAccounts) { | ||
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 commentThe reason will be displayed to describe this comment to others. Learn more. Where is There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 commentThe reason will be displayed to describe this comment to others. Learn more. What happens if a Right now:
Is that safe here? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 07661cf makes this only call |
||
} | ||
} | ||
}, | ||
rollback: async (): Promise<void | TransferError> => { | ||
const error = await rollbackTransfers( | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -143,6 +143,9 @@ export class App { | |
for (let i = 0; i < this.config.invoiceWorkers; i++) { | ||
process.nextTick(() => this.processInvoice()) | ||
} | ||
for (let i = 0; i < this.config.webhookWorkers; i++) { | ||
process.nextTick(() => this.processWebhook()) | ||
} | ||
} | ||
} | ||
|
||
|
@@ -288,4 +291,22 @@ export class App { | |
).unref() | ||
}) | ||
} | ||
|
||
private async processWebhook(): Promise<void> { | ||
const webhookService = await this.container.use('webhookService') | ||
return webhookService | ||
.processNext() | ||
.catch((err) => { | ||
this.logger.warn({ error: err.message }, 'processWebhook error') | ||
return true | ||
}) | ||
Comment on lines
+299
to
+302
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 commentThe reason will be displayed to describe this comment to others. Learn more. I think returning true in the |
||
.then((hasMoreWork) => { | ||
if (hasMoreWork) process.nextTick(() => this.processWebhook()) | ||
else | ||
setTimeout( | ||
() => this.processWebhook(), | ||
this.config.webhookWorkerIdle | ||
).unref() | ||
}) | ||
} | ||
} |
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