Compare commits

...

4 Commits

Author SHA1 Message Date
keyan
63e60fe2bc subtle change to usePaidMutation callback order 2024-07-01 17:04:10 -05:00
keyan
0aa5ba4955 move invoice creation outside of interactive tx 2024-07-01 17:03:25 -05:00
keyan
6e8d7ef1b8 allow slog logs to be disabled/configured 2024-07-01 16:48:54 -05:00
ekzyis
e57c930f0c
Fix push notifications (#1249)
* Fix wrong author in reply push notification

* Fix duplicate mentions push notifications on save

* Fix item mention push notification argument

* Fix zap push notification using stale msats
2024-07-01 15:51:59 -05:00
10 changed files with 80 additions and 48 deletions

View File

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

View File

@ -16,4 +16,6 @@ NEXT_PUBLIC_NORMAL_POLL_INTERVAL=30000
NEXT_PUBLIC_LONG_POLL_INTERVAL=60000
NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL=300000
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']
})
prisma.$on('query', (e) => {
if (e.duration > 50) {
if (process.env.PRISMA_SLOW_LOGS_MS && e.duration > process.env.PRISMA_SLOW_LOGS_MS) {
console.log('Query: ' + e.query)
console.log('Params: ' + e.params)
console.log('Duration: ' + e.duration + 'ms')

View File

@ -130,9 +130,11 @@ async function performOptimisticAction (actionType, args, context) {
const { models } = context
const action = paidActions[actionType]
context.optimistic = true
context.lndInvoice = await createLndInvoice(actionType, args, context)
return await models.$transaction(async tx => {
context.tx = tx
context.optimistic = true
const invoice = await createDbInvoice(actionType, args, context)
@ -160,15 +162,20 @@ async function performPessimisticAction (actionType, args, context) {
const invoice = await verifyPayment(context)
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 })
return {
result: await action.perform(args, context),
result,
paymentMethod: 'PESSIMISTIC'
}
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
} else {
// just create the invoice and complete action when it's paid
context.lndInvoice = await createLndInvoice(actionType, args, context)
return {
invoice: await createDbInvoice(actionType, args, context),
paymentMethod: 'PESSIMISTIC'
@ -201,13 +208,18 @@ export async function retryPaidAction (actionType, args, context) {
throw new Error(`retryPaidAction - missing invoiceId ${actionType}`)
}
context.optimistic = true
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 => {
context.tx = tx
context.optimistic = true
// update the old invoice to RETRYING, so that it's not confused with FAILED
const { msatsRequested, actionId } = await tx.invoice.update({
const { actionId } = await tx.invoice.update({
where: {
id: invoiceId,
actionState: 'FAILED'
@ -217,7 +229,6 @@ export async function retryPaidAction (actionType, args, context) {
}
})
context.cost = BigInt(msatsRequested)
context.actionId = actionId
// create a new invoice
@ -234,13 +245,14 @@ export async function retryPaidAction (actionType, args, context) {
const OPTIMISTIC_INVOICE_EXPIRE = { minutes: 10 }
const PESSIMISTIC_INVOICE_EXPIRE = { minutes: 10 }
async function createDbInvoice (actionType, args, context) {
const { user, models, tx, lnd, cost, optimistic, actionId } = context
// we seperate the invoice creation into two functions because
// because if lnd is slow, it'll timeout the interactive tx
async function createLndInvoice (actionType, args, context) {
const { user, lnd, cost, optimistic } = context
const action = paidActions[actionType]
const db = tx ?? models
const [createLNDInvoice, expirePivot, actionState] = optimistic
? [createInvoice, OPTIMISTIC_INVOICE_EXPIRE, 'PENDING']
: [createHodlInvoice, PESSIMISTIC_INVOICE_EXPIRE, 'PENDING_HELD']
const [createLNDInvoice, expirePivot] = optimistic
? [createInvoice, OPTIMISTIC_INVOICE_EXPIRE]
: [createHodlInvoice, PESSIMISTIC_INVOICE_EXPIRE]
if (cost < 1000n) {
// sanity check
@ -248,19 +260,33 @@ async function createDbInvoice (actionType, args, context) {
}
const expiresAt = datePivot(new Date(), expirePivot)
const lndInv = await createLNDInvoice({
return await createLNDInvoice({
description: user?.hideInvoiceDesc ? undefined : await action.describe(args, context),
lnd,
mtokens: String(cost),
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({
data: {
hash: lndInv.id,
hash: lndInvoice.id,
msatsRequested: cost,
preimage: optimistic ? undefined : lndInv.secret,
bolt11: lndInv.request,
preimage: optimistic ? undefined : lndInvoice.secret,
bolt11: lndInvoice.request,
userId: user?.id || USER_ID.anon,
actionType,
actionState,
@ -273,7 +299,7 @@ async function createDbInvoice (actionType, args, context) {
await db.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, expirein, priority)
VALUES ('checkInvoice',
jsonb_build_object('hash', ${lndInv.id}::TEXT), 21, true,
jsonb_build_object('hash', ${lndInvoice.id}::TEXT), 21, true,
${expiresAt}::TIMESTAMP WITH TIME ZONE,
${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
WHERE ancestors."userId" <> comment."userId"`
notifyItemParents({ item, me: item.userId, models }).catch(console.error)
notifyItemParents({ item, models }).catch(console.error)
}
for (const { userId } of item.mentions) {
notifyMention({ models, item, userId }).catch(console.error)
}
for (const { referee } of item.itemReferrers) {
notifyItemMention({ models, referrerItem: item, refereeItem: referee }).catch(console.error)
for (const { refereeItem } of item.itemReferrers) {
notifyItemMention({ models, referrerItem: item, refereeItem }).catch(console.error)
}
notifyUserSubscribers({ 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
// notify all the mentions if the mention is new
// compare timestamps to only notify if mention or item referral was just created to avoid duplicates on edits
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)
}
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)
}

View File

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

View File

@ -88,11 +88,11 @@ export function usePaidMutation (mutation,
hash: invoice.hash
}
}))
ourOnCompleted?.(data)
onPaid?.(client.cache, { data })
// 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
await invoiceWaiter.waitUntilPaid(invoice, inv => inv?.actionState === 'PAID')
ourOnCompleted?.(data)
onPaid?.(client.cache, { data })
} catch (e) {
console.error('usePaidMutation: failed to pay invoice', e)
onPayError?.(e, client.cache, { data })

View File

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

View File

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