Compare commits

..

No commits in common. "63e60fe2bc0fe18b620f2410078fe667e53156f0" and "6e1d67b3c0b880774940f33b3d0a8b1597448767" have entirely different histories.

10 changed files with 48 additions and 80 deletions

View File

@ -1,6 +1,3 @@
PRISMA_SLOW_LOGS_MS=
GRAPHQL_SLOW_LOGS_MS=
############################################################################ ############################################################################
# OPTIONAL SECRETS # # OPTIONAL SECRETS #
# put these in .env.local, and don't commit them to git # # put these in .env.local, and don't commit them to git #

View File

@ -17,5 +17,3 @@ NEXT_PUBLIC_LONG_POLL_INTERVAL=60000
NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL=300000 NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL=300000
NEXT_PUBLIC_URL=https://stacker.news NEXT_PUBLIC_URL=https://stacker.news
TOR_PROXY=http://127.0.0.1:7050/ TOR_PROXY=http://127.0.0.1:7050/
PRISMA_SLOW_LOGS_MS=50
GRAPHQL_SLOW_LOGS_MS=50

View File

@ -6,7 +6,7 @@ const prisma = global.prisma || (() => {
log: [{ level: 'query', emit: 'event' }, 'warn', 'error'] log: [{ level: 'query', emit: 'event' }, 'warn', 'error']
}) })
prisma.$on('query', (e) => { prisma.$on('query', (e) => {
if (process.env.PRISMA_SLOW_LOGS_MS && e.duration > process.env.PRISMA_SLOW_LOGS_MS) { if (e.duration > 50) {
console.log('Query: ' + e.query) console.log('Query: ' + e.query)
console.log('Params: ' + e.params) console.log('Params: ' + e.params)
console.log('Duration: ' + e.duration + 'ms') console.log('Duration: ' + e.duration + 'ms')

View File

@ -130,11 +130,9 @@ async function performOptimisticAction (actionType, args, context) {
const { models } = context const { models } = context
const action = paidActions[actionType] const action = paidActions[actionType]
context.optimistic = true
context.lndInvoice = await createLndInvoice(actionType, args, context)
return await models.$transaction(async tx => { return await models.$transaction(async tx => {
context.tx = tx context.tx = tx
context.optimistic = true
const invoice = await createDbInvoice(actionType, args, context) const invoice = await createDbInvoice(actionType, args, context)
@ -162,20 +160,15 @@ async function performPessimisticAction (actionType, args, context) {
const invoice = await verifyPayment(context) const invoice = await verifyPayment(context)
args.invoiceId = invoice.id args.invoiceId = invoice.id
// make sure to perform before settling so we don't race with worker to onPaid
const result = await action.perform(args, context)
// XXX this might cause the interactive tx to time out
await settleHodlInvoice({ secret: invoice.preimage, lnd }) await settleHodlInvoice({ secret: invoice.preimage, lnd })
return { return {
result, result: await action.perform(args, context),
paymentMethod: 'PESSIMISTIC' paymentMethod: 'PESSIMISTIC'
} }
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
} else { } else {
// just create the invoice and complete action when it's paid // just create the invoice and complete action when it's paid
context.lndInvoice = await createLndInvoice(actionType, args, context)
return { return {
invoice: await createDbInvoice(actionType, args, context), invoice: await createDbInvoice(actionType, args, context),
paymentMethod: 'PESSIMISTIC' paymentMethod: 'PESSIMISTIC'
@ -208,18 +201,13 @@ export async function retryPaidAction (actionType, args, context) {
throw new Error(`retryPaidAction - missing invoiceId ${actionType}`) throw new Error(`retryPaidAction - missing invoiceId ${actionType}`)
} }
context.optimistic = true
context.user = await models.user.findUnique({ where: { id: me.id } }) context.user = await models.user.findUnique({ where: { id: me.id } })
const { msatsRequested } = await models.invoice.findUnique({ where: { id: invoiceId, actionState: 'FAILED' } })
context.cost = BigInt(msatsRequested)
context.lndInvoice = await createLndInvoice(actionType, args, context)
return await models.$transaction(async tx => { return await models.$transaction(async tx => {
context.tx = tx context.tx = tx
context.optimistic = true
// update the old invoice to RETRYING, so that it's not confused with FAILED // update the old invoice to RETRYING, so that it's not confused with FAILED
const { actionId } = await tx.invoice.update({ const { msatsRequested, actionId } = await tx.invoice.update({
where: { where: {
id: invoiceId, id: invoiceId,
actionState: 'FAILED' actionState: 'FAILED'
@ -229,6 +217,7 @@ export async function retryPaidAction (actionType, args, context) {
} }
}) })
context.cost = BigInt(msatsRequested)
context.actionId = actionId context.actionId = actionId
// create a new invoice // create a new invoice
@ -245,14 +234,13 @@ export async function retryPaidAction (actionType, args, context) {
const OPTIMISTIC_INVOICE_EXPIRE = { minutes: 10 } const OPTIMISTIC_INVOICE_EXPIRE = { minutes: 10 }
const PESSIMISTIC_INVOICE_EXPIRE = { minutes: 10 } const PESSIMISTIC_INVOICE_EXPIRE = { minutes: 10 }
// we seperate the invoice creation into two functions because async function createDbInvoice (actionType, args, context) {
// because if lnd is slow, it'll timeout the interactive tx const { user, models, tx, lnd, cost, optimistic, actionId } = context
async function createLndInvoice (actionType, args, context) {
const { user, lnd, cost, optimistic } = context
const action = paidActions[actionType] const action = paidActions[actionType]
const [createLNDInvoice, expirePivot] = optimistic const db = tx ?? models
? [createInvoice, OPTIMISTIC_INVOICE_EXPIRE] const [createLNDInvoice, expirePivot, actionState] = optimistic
: [createHodlInvoice, PESSIMISTIC_INVOICE_EXPIRE] ? [createInvoice, OPTIMISTIC_INVOICE_EXPIRE, 'PENDING']
: [createHodlInvoice, PESSIMISTIC_INVOICE_EXPIRE, 'PENDING_HELD']
if (cost < 1000n) { if (cost < 1000n) {
// sanity check // sanity check
@ -260,33 +248,19 @@ async function createLndInvoice (actionType, args, context) {
} }
const expiresAt = datePivot(new Date(), expirePivot) const expiresAt = datePivot(new Date(), expirePivot)
return await createLNDInvoice({ const lndInv = await createLNDInvoice({
description: user?.hideInvoiceDesc ? undefined : await action.describe(args, context), description: user?.hideInvoiceDesc ? undefined : await action.describe(args, context),
lnd, lnd,
mtokens: String(cost), mtokens: String(cost),
expires_at: expiresAt expires_at: expiresAt
}) })
}
async function createDbInvoice (actionType, args, context) {
const { user, models, tx, lndInvoice, cost, optimistic, actionId } = context
const db = tx ?? models
const [expirePivot, actionState] = optimistic
? [OPTIMISTIC_INVOICE_EXPIRE, 'PENDING']
: [PESSIMISTIC_INVOICE_EXPIRE, 'PENDING_HELD']
if (cost < 1000n) {
// sanity check
throw new Error('The cost of the action must be at least 1 sat')
}
const expiresAt = datePivot(new Date(), expirePivot)
const invoice = await db.invoice.create({ const invoice = await db.invoice.create({
data: { data: {
hash: lndInvoice.id, hash: lndInv.id,
msatsRequested: cost, msatsRequested: cost,
preimage: optimistic ? undefined : lndInvoice.secret, preimage: optimistic ? undefined : lndInv.secret,
bolt11: lndInvoice.request, bolt11: lndInv.request,
userId: user?.id || USER_ID.anon, userId: user?.id || USER_ID.anon,
actionType, actionType,
actionState, actionState,
@ -299,7 +273,7 @@ async function createDbInvoice (actionType, args, context) {
await db.$executeRaw` await db.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein, priority) INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein, priority)
VALUES ('checkInvoice', VALUES ('checkInvoice',
jsonb_build_object('hash', ${lndInvoice.id}::TEXT), 21, true, jsonb_build_object('hash', ${lndInv.id}::TEXT), 21, true,
${expiresAt}::TIMESTAMP WITH TIME ZONE, ${expiresAt}::TIMESTAMP WITH TIME ZONE,
${expiresAt}::TIMESTAMP WITH TIME ZONE - now() + interval '10m', 100)` ${expiresAt}::TIMESTAMP WITH TIME ZONE - now() + interval '10m', 100)`

View File

@ -218,14 +218,14 @@ export async function onPaid ({ invoice, id }, context) {
FROM ancestors, comment FROM ancestors, comment
WHERE ancestors."userId" <> comment."userId"` WHERE ancestors."userId" <> comment."userId"`
notifyItemParents({ item, models }).catch(console.error) notifyItemParents({ item, me: item.userId, models }).catch(console.error)
} }
for (const { userId } of item.mentions) { for (const { userId } of item.mentions) {
notifyMention({ models, item, userId }).catch(console.error) notifyMention({ models, item, userId }).catch(console.error)
} }
for (const { refereeItem } of item.itemReferrers) { for (const { referee } of item.itemReferrers) {
notifyItemMention({ models, referrerItem: item, refereeItem }).catch(console.error) notifyItemMention({ models, referrerItem: item, refereeItem: referee }).catch(console.error)
} }
notifyUserSubscribers({ models, item }).catch(console.error) notifyUserSubscribers({ models, item }).catch(console.error)
notifyTerritorySubscribers({ models, item }).catch(console.error) notifyTerritorySubscribers({ models, item }).catch(console.error)

View File

@ -133,13 +133,13 @@ export async function perform (args, context) {
// TODO: referals for boost // TODO: referals for boost
// compare timestamps to only notify if mention or item referral was just created to avoid duplicates on edits // notify all the mentions if the mention is new
for (const { userId, createdAt } of item.mentions) { for (const { userId, createdAt } of item.mentions) {
if (item.updatedAt.getTime() !== createdAt.getTime()) continue if (item.updatedAt.getTime() === createdAt.getTime()) continue
notifyMention({ models, item, userId }).catch(console.error) notifyMention({ models, item, userId }).catch(console.error)
} }
for (const { refereeItem, createdAt } of item.itemReferrers) { for (const { refereeItem, createdAt } of item.itemReferrers) {
if (item.updatedAt.getTime() !== createdAt.getTime()) continue if (item.updatedAt.getTime() === createdAt.getTime()) continue
notifyItemMention({ models, referrerItem: item, refereeItem }).catch(console.error) notifyItemMention({ models, referrerItem: item, refereeItem }).catch(console.error)
} }

View File

@ -89,7 +89,7 @@ export async function onPaid ({ invoice, actIds }, { models, tx }) {
// perform denomormalized aggregates: weighted votes, upvotes, msats, lastZapAt // perform denomormalized aggregates: weighted votes, upvotes, msats, lastZapAt
// NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking // NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking
const [item] = await tx.$queryRaw` await tx.$executeRaw`
WITH zapper AS ( WITH zapper AS (
SELECT trust FROM users WHERE id = ${itemAct.userId}::INTEGER SELECT trust FROM users WHERE id = ${itemAct.userId}::INTEGER
), zap AS ( ), zap AS (
@ -107,8 +107,7 @@ export async function onPaid ({ invoice, actIds }, { models, tx }) {
msats = "Item".msats + ${msats}::BIGINT, msats = "Item".msats + ${msats}::BIGINT,
"lastZapAt" = now() "lastZapAt" = now()
FROM zap, zapper FROM zap, zapper
WHERE "Item".id = ${itemAct.itemId}::INTEGER WHERE "Item".id = ${itemAct.itemId}::INTEGER`
RETURNING "Item".*`
// record potential bounty payment // record potential bounty payment
// NOTE: we are at least guaranteed that we see the update "ItemUserAgg" from our tx so we can trust // NOTE: we are at least guaranteed that we see the update "ItemUserAgg" from our tx so we can trust
@ -140,7 +139,7 @@ export async function onPaid ({ invoice, actIds }, { models, tx }) {
WHERE "Item".path @> zapped.path AND "Item".id <> zapped.id` WHERE "Item".path @> zapped.path AND "Item".id <> zapped.id`
// TODO: referrals // TODO: referrals
notifyZapped({ models, item }).catch(console.error) notifyZapped({ models, id: itemAct.itemId }).catch(console.error)
} }
export async function onFail ({ invoice }, { tx }) { export async function onFail ({ invoice }, { tx }) {

View File

@ -88,11 +88,11 @@ export function usePaidMutation (mutation,
hash: invoice.hash hash: invoice.hash
} }
})) }))
ourOnCompleted?.(data)
onPaid?.(client.cache, { data })
// block until the invoice to be marked as paid // block until the invoice to be marked as paid
// for pessimisitic actions, they won't show up on navigation until they are marked as paid // for pessimisitic actions, they won't show up on navigation until they are marked as paid
await invoiceWaiter.waitUntilPaid(invoice, inv => inv?.actionState === 'PAID') await invoiceWaiter.waitUntilPaid(invoice, inv => inv?.actionState === 'PAID')
ourOnCompleted?.(data)
onPaid?.(client.cache, { data })
} catch (e) { } catch (e) {
console.error('usePaidMutation: failed to pay invoice', e) console.error('usePaidMutation: failed to pay invoice', e)
onPayError?.(e, client.cache, { data }) onPayError?.(e, client.cache, { data })

View File

@ -1,6 +1,6 @@
import webPush from 'web-push' import webPush from 'web-push'
import removeMd from 'remove-markdown' import removeMd from 'remove-markdown'
import { COMMENT_DEPTH_LIMIT, FOUND_BLURBS, LOST_BLURBS } from './constants' import { USER_ID, COMMENT_DEPTH_LIMIT, FOUND_BLURBS, LOST_BLURBS } from './constants'
import { msatsToSats, numWithUnits } from './format' import { msatsToSats, numWithUnits } from './format'
import models from '@/api/models' import models from '@/api/models'
import { isMuted } from '@/lib/user' import { isMuted } from '@/lib/user'
@ -178,9 +178,9 @@ export const notifyTerritorySubscribers = async ({ models, item }) => {
} }
} }
export const notifyItemParents = async ({ models, item }) => { export const notifyItemParents = async ({ models, item, me }) => {
try { try {
const user = await models.user.findUnique({ where: { id: item.userId } }) const user = await models.user.findUnique({ where: { id: me?.id || USER_ID.anon } })
const parents = await models.$queryRawUnsafe( const parents = await models.$queryRawUnsafe(
'SELECT DISTINCT p."userId" FROM "Item" i JOIN "Item" p ON p.path @> i.path WHERE i.id = $1 and p."userId" <> $2 ' + 'SELECT DISTINCT p."userId" FROM "Item" i JOIN "Item" p ON p.path @> i.path WHERE i.id = $1 and p."userId" <> $2 ' +
'AND NOT EXISTS (SELECT 1 FROM "Mute" m WHERE m."muterId" = p."userId" AND m."mutedId" = $2)', 'AND NOT EXISTS (SELECT 1 FROM "Mute" m WHERE m."muterId" = p."userId" AND m."mutedId" = $2)',
@ -198,48 +198,48 @@ export const notifyItemParents = async ({ models, item }) => {
} }
} }
export const notifyZapped = async ({ models, item }) => { export const notifyZapped = async ({ models, id }) => {
try { try {
const forwards = await models.itemForward.findMany({ where: { itemId: item.id } }) const updatedItem = await models.item.findUnique({ where: { id: Number(id) } })
const forwards = await models.itemForward.findMany({ where: { itemId: Number(id) } })
const userPromises = forwards.map(fwd => models.user.findUnique({ where: { id: fwd.userId } })) const userPromises = forwards.map(fwd => models.user.findUnique({ where: { id: fwd.userId } }))
const userResults = await Promise.allSettled(userPromises) const userResults = await Promise.allSettled(userPromises)
const mappedForwards = forwards.map((fwd, index) => ({ ...fwd, user: userResults[index].value ?? null })) const mappedForwards = forwards.map((fwd, index) => ({ ...fwd, user: userResults[index].value ?? null }))
let forwardedSats = 0 let forwardedSats = 0
let forwardedUsers = '' let forwardedUsers = ''
if (mappedForwards.length) { if (mappedForwards.length) {
forwardedSats = Math.floor(msatsToSats(item.msats) * mappedForwards.map(fwd => fwd.pct).reduce((sum, cur) => sum + cur) / 100) forwardedSats = Math.floor(msatsToSats(updatedItem.msats) * mappedForwards.map(fwd => fwd.pct).reduce((sum, cur) => sum + cur) / 100)
forwardedUsers = mappedForwards.map(fwd => `@${fwd.user.name}`).join(', ') forwardedUsers = mappedForwards.map(fwd => `@${fwd.user.name}`).join(', ')
} }
let notificationTitle let notificationTitle
if (item.title) { if (updatedItem.title) {
if (forwards.length > 0) { if (forwards.length > 0) {
notificationTitle = `your post forwarded ${numWithUnits(forwardedSats)} to ${forwardedUsers}` notificationTitle = `your post forwarded ${numWithUnits(forwardedSats)} to ${forwardedUsers}`
} else { } else {
notificationTitle = `your post stacked ${numWithUnits(msatsToSats(item.msats))}` notificationTitle = `your post stacked ${numWithUnits(msatsToSats(updatedItem.msats))}`
} }
} else { } else {
if (forwards.length > 0) { if (forwards.length > 0) {
// I don't think this case is possible // I don't think this case is possible
notificationTitle = `your reply forwarded ${numWithUnits(forwardedSats)} to ${forwardedUsers}` notificationTitle = `your reply forwarded ${numWithUnits(forwardedSats)} to ${forwardedUsers}`
} else { } else {
notificationTitle = `your reply stacked ${numWithUnits(msatsToSats(item.msats))}` notificationTitle = `your reply stacked ${numWithUnits(msatsToSats(updatedItem.msats))}`
} }
} }
await sendUserNotification(updatedItem.userId, {
await sendUserNotification(item.userId, {
title: notificationTitle, title: notificationTitle,
body: item.title ? item.title : item.text, body: updatedItem.title ? updatedItem.title : updatedItem.text,
item, item: updatedItem,
tag: `TIP-${item.id}` tag: `TIP-${updatedItem.id}`
}) })
// send push notifications to forwarded recipients // send push notifications to forwarded recipients
if (mappedForwards.length) { if (mappedForwards.length) {
await Promise.allSettled(mappedForwards.map(forward => sendUserNotification(forward.user.id, { await Promise.allSettled(mappedForwards.map(forward => sendUserNotification(forward.user.id, {
title: `you were forwarded ${numWithUnits(Math.round(msatsToSats(item.msats) * forward.pct / 100))}`, title: `you were forwarded ${numWithUnits(Math.round(msatsToSats(updatedItem.msats) * forward.pct / 100))}`,
body: item.title ?? item.text, body: updatedItem.title ?? updatedItem.text,
item, item: updatedItem,
tag: `FORWARDEDTIP-${item.id}` tag: `FORWARDEDTIP-${updatedItem.id}`
}))) })))
} }
} catch (err) { } catch (err) {

View File

@ -26,7 +26,7 @@ const apolloServer = new ApolloServer({
return (error, result) => { return (error, result) => {
const end = process.hrtime.bigint() const end = process.hrtime.bigint()
const ms = (end - start) / 1000000n const ms = (end - start) / 1000000n
if (process.env.GRAPHQL_SLOW_LOGS_MS && ms > process.env.GRAPHQL_SLOW_LOGS_MS) { if (ms > 50) {
console.log(`Field ${info.parentType.name}.${info.fieldName} took ${ms}ms`) console.log(`Field ${info.parentType.name}.${info.fieldName} took ${ms}ms`)
} }
if (error) { if (error) {