Implement really obvious optimizations on placebet, sellbet, sellshares (#452)

* Change authed endpoints to not look up users unnecessarily

* Parallelize some extremely parallelizable DB requests

* Clean up overcomplicated sellshares logic
This commit is contained in:
Marshall Polaris 2022-06-10 17:51:55 -07:00 committed by GitHub
parent ee816d6552
commit 6956f0d730
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 59 additions and 71 deletions

View File

@ -6,14 +6,17 @@ import { onRequest, Request } from 'firebase-functions/v2/https'
import * as Cors from 'cors'
import { z } from 'zod'
import { User, PrivateUser } from '../../common/user'
import { PrivateUser } from '../../common/user'
import {
CORS_ORIGIN_MANIFOLD,
CORS_ORIGIN_LOCALHOST,
} from '../../common/envs/constants'
type Output = Record<string, unknown>
type AuthedUser = [User, PrivateUser]
type AuthedUser = {
uid: string
creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser })
}
type Handler = (req: Request, user: AuthedUser) => Promise<Output>
type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken }
type KeyCredentials = { kind: 'key'; data: string }
@ -60,24 +63,13 @@ export const parseCredentials = async (req: Request): Promise<Credentials> => {
export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
const firestore = admin.firestore()
const users = firestore.collection('users')
const privateUsers = firestore.collection('private-users')
switch (creds.kind) {
case 'jwt': {
const { user_id } = creds.data
if (typeof user_id !== 'string') {
if (typeof creds.data.user_id !== 'string') {
throw new APIError(403, 'JWT must contain Manifold user ID.')
}
const [userSnap, privateUserSnap] = await Promise.all([
users.doc(user_id).get(),
privateUsers.doc(user_id).get(),
])
if (!userSnap.exists || !privateUserSnap.exists) {
throw new APIError(403, 'No user exists with the provided ID.')
}
const user = userSnap.data() as User
const privateUser = privateUserSnap.data() as PrivateUser
return [user, privateUser]
return { uid: creds.data.user_id, creds }
}
case 'key': {
const key = creds.data
@ -86,13 +78,8 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
throw new APIError(403, `No private user exists with API key ${key}.`)
}
const privateUserSnap = privateUserQ.docs[0]
const userSnap = await users.doc(privateUserSnap.id).get()
if (!userSnap.exists) {
throw new APIError(403, `No user exists with ID ${privateUserSnap.id}.`)
}
const user = userSnap.data() as User
const privateUser = privateUserSnap.data() as PrivateUser
return [user, privateUser]
return { uid: privateUser.id, creds: { privateUser, ...creds } }
}
default:
throw new APIError(500, 'Invalid credential type.')

View File

@ -27,6 +27,7 @@ import {
import { getNoneAnswer } from '../../common/answer'
import { getNewContract } from '../../common/new-contract'
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
import { User } from '../../common/user'
const bodySchema = z.object({
question: z.string().min(1).max(MAX_QUESTION_LENGTH),
@ -48,7 +49,7 @@ const numericSchema = z.object({
max: z.number(),
})
export const createmarket = newEndpoint(['POST'], async (req, [user, _]) => {
export const createmarket = newEndpoint(['POST'], async (req, auth) => {
const { question, description, tags, closeTime, outcomeType } = validate(
bodySchema,
req.body
@ -70,9 +71,15 @@ export const createmarket = newEndpoint(['POST'], async (req, [user, _]) => {
freeMarketResetTime = freeMarketResetTime - 24 * 60 * 60 * 1000
}
const userDoc = await firestore.collection('users').doc(auth.uid).get()
if (!userDoc.exists) {
throw new APIError(400, 'No user exists with the authenticated user ID.')
}
const user = userDoc.data() as User
const userContractsCreatedTodaySnapshot = await firestore
.collection(`contracts`)
.where('creatorId', '==', user.id)
.where('creatorId', '==', auth.uid)
.where('createdTime', '>=', freeMarketResetTime)
.get()
console.log('free market reset time: ', freeMarketResetTime)

View File

@ -1,11 +1,8 @@
import { newEndpoint } from './api'
export const health = newEndpoint(['GET'], async (_req, [user, _]) => {
export const health = newEndpoint(['GET'], async (_req, auth) => {
return {
message: 'Server is working.',
user: {
id: user.id,
username: user.username,
},
uid: auth.uid,
}
})

View File

@ -32,21 +32,23 @@ const numericSchema = z.object({
value: z.number(),
})
export const placebet = newEndpoint(['POST'], async (req, [bettor, _]) => {
export const placebet = newEndpoint(['POST'], async (req, auth) => {
const { amount, contractId } = validate(bodySchema, req.body)
const result = await firestore.runTransaction(async (trans) => {
const userDoc = firestore.doc(`users/${bettor.id}`)
const userSnap = await trans.get(userDoc)
const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`)
const [contractSnap, userSnap] = await Promise.all([
trans.get(contractDoc),
trans.get(userDoc),
])
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
if (!userSnap.exists) throw new APIError(400, 'User not found.')
const contract = contractSnap.data() as Contract
const user = userSnap.data() as User
if (user.balance < amount) throw new APIError(400, 'Insufficient balance.')
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await trans.get(contractDoc)
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
const contract = contractSnap.data() as Contract
const loanAmount = 0
const { closeTime, outcomeType, mechanism, collectedFees, volume } =
contract
@ -110,7 +112,7 @@ export const placebet = newEndpoint(['POST'], async (req, [bettor, _]) => {
return { betId: betDoc.id }
})
await redeemShares(bettor.id, contractId)
await redeemShares(auth.uid, contractId)
return result
})

View File

@ -13,20 +13,26 @@ const bodySchema = z.object({
betId: z.string(),
})
export const sellbet = newEndpoint(['POST'], async (req, [bettor, _]) => {
export const sellbet = newEndpoint(['POST'], async (req, auth) => {
const { contractId, betId } = validate(bodySchema, req.body)
// run as transaction to prevent race conditions
return await firestore.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${bettor.id}`)
const userSnap = await transaction.get(userDoc)
if (!userSnap.exists) throw new APIError(400, 'User not found.')
const user = userSnap.data() as User
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await transaction.get(contractDoc)
const userDoc = firestore.doc(`users/${auth.uid}`)
const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`)
const [contractSnap, userSnap, betSnap] = await Promise.all([
transaction.get(contractDoc),
transaction.get(userDoc),
transaction.get(betDoc),
])
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
if (!userSnap.exists) throw new APIError(400, 'User not found.')
if (!betSnap.exists) throw new APIError(400, 'Bet not found.')
const contract = contractSnap.data() as Contract
const user = userSnap.data() as User
const bet = betSnap.data() as Bet
const { closeTime, mechanism, collectedFees, volume } = contract
if (mechanism !== 'dpm-2')
@ -34,12 +40,7 @@ export const sellbet = newEndpoint(['POST'], async (req, [bettor, _]) => {
if (closeTime && Date.now() > closeTime)
throw new APIError(400, 'Trading is closed.')
const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`)
const betSnap = await transaction.get(betDoc)
if (!betSnap.exists) throw new APIError(400, 'Bet not found.')
const bet = betSnap.data() as Bet
if (bettor.id !== bet.userId)
if (auth.uid !== bet.userId)
throw new APIError(400, 'The specified bet does not belong to you.')
if (bet.isSold)
throw new APIError(400, 'The specified bet is already sold.')

View File

@ -1,4 +1,4 @@
import { partition, sumBy } from 'lodash'
import { sumBy } from 'lodash'
import * as admin from 'firebase-admin'
import { z } from 'zod'
@ -16,20 +16,25 @@ const bodySchema = z.object({
outcome: z.enum(['YES', 'NO']),
})
export const sellshares = newEndpoint(['POST'], async (req, [bettor, _]) => {
export const sellshares = newEndpoint(['POST'], async (req, auth) => {
const { contractId, shares, outcome } = validate(bodySchema, req.body)
// Run as transaction to prevent race conditions.
return await firestore.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${bettor.id}`)
const userSnap = await transaction.get(userDoc)
const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`)
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
const [contractSnap, userSnap, userBets] = await Promise.all([
transaction.get(contractDoc),
transaction.get(userDoc),
getValues<Bet>(betsQ), // TODO: why is this not in the transaction??
])
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
if (!userSnap.exists) throw new APIError(400, 'User not found.')
const contract = contractSnap.data() as Contract
const user = userSnap.data() as User
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await transaction.get(contractDoc)
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
const contract = contractSnap.data() as Contract
const { closeTime, mechanism, collectedFees, volume } = contract
if (mechanism !== 'cpmm-1')
@ -37,22 +42,11 @@ export const sellshares = newEndpoint(['POST'], async (req, [bettor, _]) => {
if (closeTime && Date.now() > closeTime)
throw new APIError(400, 'Trading is closed.')
const userBets = await getValues<Bet>(
contractDoc.collection('bets').where('userId', '==', bettor.id)
)
const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
const [yesBets, noBets] = partition(
userBets ?? [],
(bet) => bet.outcome === 'YES'
)
const [yesShares, noShares] = [
sumBy(yesBets, (bet) => bet.shares),
sumBy(noBets, (bet) => bet.shares),
]
const outcomeBets = userBets.filter((bet) => bet.outcome == outcome)
const maxShares = sumBy(outcomeBets, (bet) => bet.shares)
const maxShares = outcome === 'YES' ? yesShares : noShares
if (shares > maxShares + 0.000000000001)
throw new APIError(400, `You can only sell up to ${maxShares} shares.`)