attempts at serializable transactions

This commit is contained in:
keyan 2021-05-19 20:09:32 -05:00
parent 208980f302
commit 0eabe1463d
11 changed files with 182 additions and 105 deletions

View File

@ -1,4 +1,5 @@
import { UserInputError, AuthenticationError } from 'apollo-server-micro'
import serialize from './serial'
async function comments (models, id) {
const flat = await models.$queryRaw(`
@ -106,15 +107,7 @@ export default {
throw new UserInputError('sats must be positive', { argumentName: 'sats' })
}
try {
await models.$queryRaw`SELECT vote(${Number(id)}, ${me.name}, ${Number(sats)})`
} catch (error) {
const { meta: { message } } = error
if (message.includes('SN_INSUFFICIENT_FUNDS')) {
throw new UserInputError('insufficient funds')
}
throw error
}
await serialize(models, models.$queryRaw`SELECT vote(${Number(id)}, ${me.name}, ${Number(sats)})`)
return sats
}
},
@ -178,19 +171,11 @@ const createItem = async (parent, { title, url, text, parentId }, { me, models }
throw new AuthenticationError('you must be logged in')
}
try {
const [item] = await models.$queryRaw(
`${SELECT} FROM create_item($1, $2, $3, $4, $5) AS "Item"`,
title, url, text, Number(parentId), me.name)
item.comments = []
return item
} catch (error) {
const { meta: { message } } = error
if (message.includes('SN_INSUFFICIENT_FUNDS')) {
throw new UserInputError('insufficient funds')
}
throw error
}
const [item] = await serialize(models, models.$queryRaw(
`${SELECT} FROM create_item($1, $2, $3, $4, $5) AS "Item"`,
title, url, text, Number(parentId), me.name))
item.comments = []
return item
}
function nestComments (flat, parentId) {

24
api/resolvers/serial.js Normal file
View File

@ -0,0 +1,24 @@
const { UserInputError } = require('apollo-server-micro')
async function serialize (models, call) {
try {
const [, result] = await models.$transaction([
models.$executeRaw(SERIALIZE),
call
])
return result
} catch (error) {
console.log(error)
if (error.message.includes('SN_INSUFFICIENT_FUNDS')) {
throw new UserInputError('insufficient funds')
}
if (error.message.includes('SN_NOT_SERIALIZABLE')) {
throw new Error('wallet transaction isolation level is not serializable')
}
throw error
}
}
const SERIALIZE = 'SET TRANSACTION ISOLATION LEVEL SERIALIZABLE'
module.exports = serialize

View File

@ -1,24 +1,59 @@
import { createInvoice, decodePaymentRequest, subscribeToPayViaRequest } from 'ln-service'
import { UserInputError, AuthenticationError } from 'apollo-server-micro'
import serialize from './serial'
export default {
Query: {
invoice: async (parent, { id }, { me, models, lnd }) => {
return await models.invoice.findUnique({ where: { id: Number(id) } })
if (!me) {
throw new AuthenticationError('you must be logged in')
}
const inv = await models.invoice.findUnique({
where: {
id: Number(id)
},
include: {
user: true
}
})
if (inv.user.name !== me.name) {
throw new AuthenticationError('not ur invoice')
}
return inv
},
withdrawl: async (parent, { id }, { me, models, lnd }) => {
return await models.withdrawl.findUnique({ where: { id: Number(id) } })
if (!me) {
throw new AuthenticationError('you must be logged in')
}
const wdrwl = await models.withdrawl.findUnique({
where: {
id: Number(id)
},
include: {
user: true
}
})
if (wdrwl.user.name !== me.name) {
throw new AuthenticationError('not ur withdrawl')
}
return wdrwl
}
},
Mutation: {
createInvoice: async (parent, { amount }, { me, models, lnd }) => {
if (!me) {
throw new AuthenticationError('You must be logged in')
throw new AuthenticationError('you must be logged in')
}
if (!amount || amount <= 0) {
throw new UserInputError('Amount must be positive', { argumentName: 'amount' })
throw new UserInputError('amount must be positive', { argumentName: 'amount' })
}
// set expires at to 3 hours into future
@ -47,65 +82,58 @@ export default {
},
createWithdrawl: async (parent, { invoice, maxFee }, { me, models, lnd }) => {
if (!me) {
throw new AuthenticationError('You must be logged in')
throw new AuthenticationError('you must be logged in')
}
// decode invoice to get amount
const decoded = await decodePaymentRequest({ lnd, request: invoice })
const msatsFee = Number(maxFee) * 1000
// create withdrawl transactionally (id, bolt11, amount, fee)
try {
const [withdrawl] =
await models.$queryRaw`SELECT * FROM create_withdrawl(${decoded.id}, ${invoice},
${Number(decoded.mtokens)}, ${msatsFee}, ${me.name})`
const [withdrawl] = await serialize(models,
models.$queryRaw`SELECT * FROM create_withdrawl(${decoded.id}, ${invoice},
${Number(decoded.mtokens)}, ${msatsFee}, ${me.name})`)
// create the payment, subscribing to its status
const sub = subscribeToPayViaRequest({
lnd,
request: invoice,
// can't use max_fee_mtokens https://github.com/alexbosworth/ln-service/issues/141
max_fee: Number(maxFee),
pathfinding_timeout: 30000
})
// create the payment, subscribing to its status
const sub = subscribeToPayViaRequest({
lnd,
request: invoice,
// can't use max_fee_mtokens https://github.com/alexbosworth/ln-service/issues/141
max_fee: Number(maxFee),
pathfinding_timeout: 30000
})
// if it's confirmed, update confirmed returning extra fees to user
sub.on('confirmed', async e => {
console.log(e)
// mtokens also contains the fee
const fee = Number(e.fee_mtokens)
const paid = Number(e.mtokens) - fee
await models.$queryRaw`
SELECT confirm_withdrawl(${withdrawl.id}, ${paid}, ${fee})`
})
// if it's confirmed, update confirmed returning extra fees to user
sub.on('confirmed', async e => {
console.log(e)
// mtokens also contains the fee
const fee = Number(e.fee_mtokens)
const paid = Number(e.mtokens) - fee
await serialize(models, models.$queryRaw`
SELECT confirm_withdrawl(${withdrawl.id}, ${paid}, ${fee})`)
})
// if the payment fails, we need to
// 1. return the funds to the user
// 2. update the widthdrawl as failed
sub.on('failed', async e => {
console.log(e)
let status = 'UNKNOWN_FAILURE'
if (e.is_insufficient_balance) {
status = 'INSUFFICIENT_BALANCE'
} else if (e.is_invalid_payment) {
status = 'INVALID_PAYMENT'
} else if (e.is_pathfinding_timeout) {
status = 'PATHFINDING_TIMEOUT'
} else if (e.is_route_not_found) {
status = 'ROUTE_NOT_FOUND'
}
await models.$queryRaw`
SELECT reverse_withdrawl(${withdrawl.id}, ${status})`
})
return withdrawl
} catch (error) {
const { meta: { message } } = error
if (message.includes('SN_INSUFFICIENT_FUNDS')) {
throw new UserInputError('insufficient funds')
// if the payment fails, we need to
// 1. return the funds to the user
// 2. update the widthdrawl as failed
sub.on('failed', async e => {
console.log(e)
let status = 'UNKNOWN_FAILURE'
if (e.is_insufficient_balance) {
status = 'INSUFFICIENT_BALANCE'
} else if (e.is_invalid_payment) {
status = 'INVALID_PAYMENT'
} else if (e.is_pathfinding_timeout) {
status = 'PATHFINDING_TIMEOUT'
} else if (e.is_route_not_found) {
status = 'ROUTE_NOT_FOUND'
}
throw error
}
await serialize(models, models.$queryRaw`
SELECT reverse_withdrawl(${withdrawl.id}, ${status})`)
})
return withdrawl
}
},

View File

@ -90,7 +90,10 @@ export default function Comment ({ item, children, replyOpen, includeParent, cac
</div>}
{reply &&
<div className={styles.replyWrapper}>
<Reply parentId={item.id} onSuccess={() => setReply(replyOpen || false)} cacheId={cacheId} />
<Reply
parentId={item.id} autoFocus={!replyOpen}
onSuccess={() => setReply(replyOpen || false)} cacheId={cacheId}
/>
</div>}
{children}
<div className={`${styles.comments} ml-sm-1 ml-md-3`}>

View File

@ -93,7 +93,7 @@ export function Form ({
validationSchema={schema}
validateOnBlur={false}
onSubmit={async (...args) =>
onSubmit && onSubmit(...args).catch(e => setError(e.message))}
onSubmit && onSubmit(...args).catch(e => setError(e.message || e))}
>
<FormikForm {...props} noValidate>
{error && <Alert variant='danger' onClose={() => setError(undefined)} dismissible>{error}</Alert>}

View File

@ -8,7 +8,7 @@ export const CommentSchema = Yup.object({
text: Yup.string().required('required').trim()
})
export default function Reply ({ parentId, onSuccess }) {
export default function Reply ({ parentId, onSuccess, autoFocus }) {
const [createComment] = useMutation(
gql`
${COMMENTS}
@ -63,6 +63,7 @@ export default function Reply ({ parentId, onSuccess }) {
name='text'
as='textarea'
rows={4}
autoFocus={autoFocus}
required
/>
<SubmitButton variant='secondary' className='mt-1'>reply</SubmitButton>

View File

@ -46,4 +46,4 @@
"prisma": "^2.22.1",
"standard": "^16.0.3"
}
}
}

View File

@ -68,11 +68,11 @@ function LoadWithdrawl ({ query }) {
variant = 'failed'
break
case 'PATHFINDING_TIMEOUT':
status = <>timed out trying to find route <small className='ml-3'>try increasing max fee</small></>
status = <>timed out finding route <small className='ml-3'>try increasing max fee</small></>
variant = 'failed'
break
case 'ROUTE_NOT_FOUND':
status = <>could not find route <small className='ml-3'>try increasing max fee</small></>
status = <>no route <small className='ml-3'>try increasing max fee</small></>
variant = 'failed'
break
default:

View File

@ -1,5 +1,17 @@
-- This is an empty migration.
create function now_utc() returns timestamp as $$
select now() at time zone 'utc';
$$ language sql;
create function ASSERT_SERIALIZED() returns void as $$
BEGIN
IF (select current_setting('transaction_isolation') <> 'serializable') THEN
RAISE EXCEPTION 'SN_NOT_SERIALIZABLE';
END IF;
END;
$$ language plpgsql;
CREATE OR REPLACE FUNCTION vote(item_id INTEGER, username TEXT, vote_sats INTEGER)
RETURNS INTEGER
LANGUAGE plpgsql
@ -8,6 +20,8 @@ DECLARE
user_id INTEGER;
user_sats INTEGER;
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT (msats / 1000), id INTO user_sats, user_id FROM users WHERE name = username;
IF vote_sats > user_sats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
@ -16,12 +30,15 @@ BEGIN
UPDATE users SET msats = msats - (vote_sats * 1000) WHERE id = user_id;
IF EXISTS (SELECT 1 FROM "Vote" WHERE "itemId" = item_id AND "userId" = user_id) THEN
INSERT INTO "Vote" (sats, "itemId", "userId", boost, updated_at) VALUES (vote_sats, item_id, user_id, true, 'now');
INSERT INTO "Vote" (sats, "itemId", "userId", boost, created_at, updated_at)
VALUES (vote_sats, item_id, user_id, true, now_utc(), now_utc());
ELSE
INSERT INTO "Vote" (sats, "itemId", "userId", updated_at) VALUES (1, item_id, user_id, 'now');
INSERT INTO "Vote" (sats, "itemId", "userId", created_at, updated_at)
VALUES (1, item_id, user_id, now_utc(), now_utc());
UPDATE users SET msats = msats + 1000 WHERE id = (SELECT "userId" FROM "Item" WHERE id = item_id);
IF vote_sats > 1 THEN
INSERT INTO "Vote" (sats, "itemId", "userId", boost, updated_at) VALUES (vote_sats - 1, item_id, user_id, true, 'now');
INSERT INTO "Vote" (sats, "itemId", "userId", boost, created_at, updated_at)
VALUES (vote_sats - 1, item_id, user_id, true, now_utc(), now_utc());
END IF;
END IF;
@ -38,6 +55,8 @@ DECLARE
user_sats INTEGER;
item "Item";
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT (msats / 1000), id INTO user_sats, user_id FROM users WHERE name = username;
IF 1 > user_sats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
@ -45,9 +64,11 @@ BEGIN
UPDATE users SET msats = msats - 1000 WHERE id = user_id;
INSERT INTO "Item" (title, url, text, "userId", "parentId", updated_at)
VALUES (title, url, text, user_id, parent_id, 'now') RETURNING * INTO item;
INSERT INTO "Vote" (sats, "itemId", "userId", updated_at) VALUES (1, item.id, user_id, 'now');
INSERT INTO "Item" (title, url, text, "userId", "parentId", created_at, updated_at)
VALUES (title, url, text, user_id, parent_id, now_utc(), now_utc()) RETURNING * INTO item;
INSERT INTO "Vote" (sats, "itemId", "userId", created_at, updated_at)
VALUES (1, item.id, user_id, now_utc(), now_utc());
RETURN item;
END;
@ -61,9 +82,12 @@ DECLARE
user_id INTEGER;
confirmed_at TIMESTAMP;
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT "userId", "confirmedAt" INTO user_id, confirmed_at FROM "Invoice" WHERE hash = lnd_id;
IF confirmed_at IS NULL THEN
UPDATE "Invoice" SET "msatsReceived" = lnd_received, "confirmedAt" = 'now' WHERE hash = lnd_id;
UPDATE "Invoice" SET "msatsReceived" = lnd_received, "confirmedAt" = now_utc(), updated_at = now_utc()
WHERE hash = lnd_id;
UPDATE users SET msats = msats + lnd_received WHERE id = user_id;
END IF;
RETURN 0;

View File

@ -7,13 +7,15 @@ DECLARE
user_msats INTEGER;
withdrawl "Withdrawl";
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT msats, id INTO user_msats, user_id FROM users WHERE name = username;
IF (msats_amount + msats_max_fee) > user_msats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
INSERT INTO "Withdrawl" (hash, bolt11, "msatsPaying", "msatsFeePaying", "userId", updated_at)
VALUES (lnd_id, invoice, msats_amount, msats_max_fee, user_id, 'now') RETURNING * INTO withdrawl;
INSERT INTO "Withdrawl" (hash, bolt11, "msatsPaying", "msatsFeePaying", "userId", created_at, updated_at)
VALUES (lnd_id, invoice, msats_amount, msats_max_fee, user_id, now_utc(), now_utc()) RETURNING * INTO withdrawl;
UPDATE users SET msats = msats - msats_amount - msats_max_fee WHERE id = user_id;
@ -29,8 +31,13 @@ DECLARE
msats_fee_paying INTEGER;
user_id INTEGER;
BEGIN
PERFORM ASSERT_SERIALIZED();
IF EXISTS (SELECT 1 FROM "Withdrawl" WHERE id = wid AND status IS NULL) THEN
UPDATE "Withdrawl" SET status = 'CONFIRMED', "msatsPaid" = msats_paid, "msatsFeePaid" = msats_fee_paid WHERE id = wid;
UPDATE "Withdrawl"
SET status = 'CONFIRMED', "msatsPaid" = msats_paid,
"msatsFeePaid" = msats_fee_paid, updated_at = now_utc()
WHERE id = wid;
SELECT "msatsFeePaying", "userId" INTO msats_fee_paying, user_id FROM "Withdrawl" WHERE id = wid;
UPDATE users SET msats = msats + (msats_fee_paying - msats_fee_paid) WHERE id = user_id;
END IF;
@ -48,8 +55,10 @@ DECLARE
msats_paying INTEGER;
user_id INTEGER;
BEGIN
PERFORM ASSERT_SERIALIZED();
IF EXISTS (SELECT 1 FROM "Withdrawl" WHERE id = wid AND status IS NULL) THEN
UPDATE "Withdrawl" SET status = wstatus WHERE id = wid;
UPDATE "Withdrawl" SET status = wstatus, updated_at = now_utc() WHERE id = wid;
SELECT "msatsPaying", "msatsFeePaying", "userId" INTO msats_paying, msats_fee_paying, user_id FROM "Withdrawl" WHERE id = wid;
UPDATE users SET msats = msats + msats_paying + msats_fee_paying WHERE id = user_id;
END IF;

View File

@ -1,6 +1,7 @@
const { PrismaClient } = require('@prisma/client')
const { authenticatedLndGrpc, subscribeToInvoices, getInvoice, getPayment } = require('ln-service')
const dotenv = require('dotenv')
const serialize = require('../api/resolvers/serial')
dotenv.config({ path: '..' })
@ -15,17 +16,19 @@ const models = new PrismaClient()
async function recordInvoiceStatus (inv) {
console.log(inv)
if (inv.is_confirmed) {
await models.$queryRaw`SELECT confirm_invoice(${inv.id}, ${Number(inv.received_mtokens)})`
await serialize(models,
models.$executeRaw`SELECT confirm_invoice(${inv.id}, ${Number(inv.received_mtokens)})`)
} else if (inv.is_canceled) {
// mark as cancelled
models.invoice.update({
where: {
hash: inv.id
},
data: {
cancelled: true
}
})
await serialize(models,
models.invoice.update({
where: {
hash: inv.id
},
data: {
cancelled: true
}
}))
}
}
@ -67,8 +70,8 @@ async function recordWithdrawlStatus (id, wdrwl) {
// is this true for getPayment?
const fee = Number(wdrwl.payment.fee_mtokens)
const paid = Number(wdrwl.mtokens) - fee
await models.$queryRaw`
SELECT confirm_withdrawl(${id}, ${paid}, ${fee})`
await serialize(models, models.$executeRaw`
SELECT confirm_withdrawl(${id}, ${paid}, ${fee})`)
} else if (wdrwl.is_failed) {
let status = 'UNKNOWN_FAILURE'
if (wdrwl.failed.is_insufficient_balance) {
@ -80,8 +83,8 @@ async function recordWithdrawlStatus (id, wdrwl) {
} else if (wdrwl.failed.is_route_not_found) {
status = 'ROUTE_NOT_FOUND'
}
await models.$queryRaw`
SELECT reverse_withdrawl(${id}, ${status})`
await serialize(models, models.$executeRaw`
SELECT reverse_withdrawl(${id}, ${status})`)
}
}
@ -90,7 +93,7 @@ async function checkPendingWithdrawls () {
const leftovers = await models.withdrawl.findMany({
where: {
createdAt: {
lt: new Date(new Date().setSeconds(new Date().getSeconds() + 30))
lt: new Date(new Date().setSeconds(new Date().getSeconds() - 30))
},
status: {
equals: null