import { randomBytes, randomInt, randomUUID } from "node:crypto"; import { HttpStatus, Inject, Injectable } from "@nestjs/common"; import { ErrorCode } from "@drift/contracts"; import type { Prisma } from "@prisma/client"; import { DomainException } from "../common/domain.exception.js"; import { PrismaService } from "../database/prisma.service.js"; import { utc8UsageDate } from "../bottle/usage-date.js"; import { decryptLeaseToken, encryptLeaseToken, leaseHmac, } from "../auth/auth.config.js"; import { CandidateRepository, type Candidate } from "./candidate.repository.js"; import { CandidateBatchContended, classifyPickError, readMatchRetryConfig, transactionLimits, } from "./match-retry-policy.js"; import { SafetyLockService } from "../safety/safety-lock.service.js"; type LeaseWithBottle = { id: string; expiresAt: Date; leaseTokenCiphertext: Uint8Array; bottle: { id: string; authorId: string; contentText: string; author: { anonymousProfile: { publicId: string; nickname: string; avatarColor: string; bio: string | null; } | null; }; }; }; function positiveInt( value: string | undefined, fallback: number, name: string, ) { const parsed = value === undefined ? fallback : Number(value); if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${name} must be a positive integer`); return parsed; } @Injectable() export class MatchService { private readonly ttlMs = positiveInt( process.env.BOTTLE_LEASE_TTL_MS, 300_000, "BOTTLE_LEASE_TTL_MS", ); private readonly retry = readMatchRetryConfig(process.env); constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(CandidateRepository) private readonly candidates: CandidateRepository, @Inject(SafetyLockService) private readonly locks: SafetyLockService, ) {} async pick(pickerId: string, requestId: string) { const deadline = performance.now() + this.retry.budgetMs; for (let attempt = 0; attempt < this.retry.maxAttempts; attempt += 1) { const limits = transactionLimits(this.retry, performance.now(), deadline); if (!limits) break; try { const result = await this.prisma.$transaction( async (tx) => { const previous = await this.findPrevious(tx, pickerId, requestId); if (previous?.lease) { await this.locks.lockAccounts(tx, [ pickerId, previous.lease.bottle.authorId, ]); await this.assertPairAllowed( tx, pickerId, previous.lease.bottle.authorId, ); return this.replayResponse(previous.lease); } await this.assertEligible(tx, pickerId); const usageDate = utc8UsageDate(new Date()); const batch = await this.candidates.findBatch(tx, pickerId); if (!batch.length) { // An in-flight same-key winner may have claimed the sole bottle // without committing its request row yet. Let it commit before // deciding this request actually saw an empty pool. await this.locks.lockAccounts(tx, [pickerId]); if (await this.findPrevious(tx, pickerId, requestId)) throw new CandidateBatchContended(); throw new DomainException( ErrorCode.BOTTLE_POOL_EMPTY, "Bottle pool empty", HttpStatus.NOT_FOUND, ); } // Lock the entire subject set once, in global order. Locking pairs // across candidates could otherwise form A→B / B→A cycles. This // trades wider short-lived locks for correctness; the concurrent // sole-bottle and same-key retry E2E cases exercise contention. await this.locks.lockAccounts(tx, [ pickerId, ...batch.map((candidate) => candidate.authorId), ]); while (batch.length) { const index = Math.floor(Math.random() * batch.length); const candidate = batch.splice(index, 1)[0]!; const result = await this.claim( tx, pickerId, requestId, usageDate, candidate, ); if (result) return result; } throw new CandidateBatchContended(); }, { isolationLevel: "ReadCommitted", ...limits, }, ); return result; } catch (error) { if (error instanceof DomainException) { if (error.code === ErrorCode.BOTTLE_POOL_EMPTY) { try { const winner = await this.readWinnerUntil( pickerId, requestId, Math.min(deadline, performance.now() + 250), ); if (winner) return winner; } catch (readError) { if (classifyPickError(readError) === "SERVICE_UNAVAILABLE") throw error; throw readError; } } throw error; } const action = classifyPickError(error); if (action === "THROW") throw error; if (action === "SERVICE_UNAVAILABLE") break; if (action === "READ_WINNER") { try { const winner = await this.readWinner(pickerId, requestId, deadline); if (winner) return winner; } catch (readError) { if (classifyPickError(readError) === "SERVICE_UNAVAILABLE") break; throw readError; } } } const remaining = deadline - performance.now(); if (remaining <= 1) break; await new Promise((resolve) => setTimeout( resolve, Math.min( randomInt(5, 21) * 2 ** Math.min(attempt, 3), 100, remaining, ), ), ); } throw new DomainException( ErrorCode.SERVICE_UNAVAILABLE, "Bottle pick temporarily unavailable", HttpStatus.SERVICE_UNAVAILABLE, ); } private findPrevious( client: Prisma.TransactionClient | PrismaService, pickerId: string, requestId: string, ) { return client.bottlePickRequest.findUnique({ where: { pickerId_requestId: { pickerId, requestId } }, include: { lease: { include: { bottle: { include: { author: { include: { anonymousProfile: true } } }, }, }, }, }, }); } private async readWinner( pickerId: string, requestId: string, deadline: number, ) { const limits = transactionLimits(this.retry, performance.now(), deadline); if (!limits) return null; const result = await this.prisma.$transaction( async (tx) => { const previous = await this.findPrevious(tx, pickerId, requestId); if (!previous?.lease) return null; await this.locks.lockAccounts(tx, [ pickerId, previous.lease.bottle.authorId, ]); await this.assertPairAllowed( tx, pickerId, previous.lease.bottle.authorId, ); return this.replayResponse(previous.lease); }, { isolationLevel: "ReadCommitted", ...limits }, ); return result; } private async readWinnerUntil( pickerId: string, requestId: string, deadline: number, ) { while (performance.now() < deadline) { const winner = await this.readWinner(pickerId, requestId, deadline); if (winner) return winner; await new Promise((resolve) => setTimeout(resolve, 5)); } return null; } private async claim( tx: Prisma.TransactionClient, pickerId: string, requestId: string, usageDate: string, candidate: Candidate, ) { await this.assertPairAllowed(tx, pickerId, candidate.authorId); const eligible = await tx.bottle.findFirst({ where: { id: candidate.id, authorId: candidate.authorId, version: candidate.version, reviewStatus: "APPROVED", poolStatus: "IN_POOL", activeLeaseId: null, author: { anonymousProfile: { reviewStatus: "APPROVED" } }, pickHistory: { none: { pickerId } }, }, select: { id: true }, }); if (!eligible) return null; const token = randomBytes(32).toString("base64url"); const expiresAt = new Date(Date.now() + this.ttlMs); const leaseId = randomUUID(); const claimed = await tx.bottle.updateMany({ where: { id: candidate.id, version: candidate.version, poolStatus: "IN_POOL", activeLeaseId: null, }, data: { poolStatus: "LEASED", activeLeaseId: leaseId, version: { increment: 1 }, }, }); if (claimed.count !== 1) return null; const lease = await tx.bottlePickLease.create({ data: { id: leaseId, bottleId: candidate.id, pickerId, leaseTokenHash: leaseHmac(token), leaseTokenCiphertext: encryptLeaseToken(token, leaseId), expiresAt, }, }); await tx.bottlePickHistory.create({ data: { bottleId: candidate.id, pickerId }, }); const rows = await tx.$queryRaw>` INSERT INTO "daily_usage" ("id", "account_id", "usage_date", "bottles_picked", "updated_at") VALUES (gen_random_uuid(), ${pickerId}::uuid, ${usageDate}::date, 1, now()) ON CONFLICT ("account_id", "usage_date") DO UPDATE SET "bottles_picked" = "daily_usage"."bottles_picked" + 1, "updated_at" = now() WHERE "daily_usage"."bottles_picked" < 20 RETURNING "bottles_picked"`; if (!rows.length) this.limit(); await tx.bottlePickRequest.create({ data: { pickerId, requestId, leaseId: lease.id }, }); return { bottle: { id: candidate.id, contentText: candidate.contentText }, author: { publicId: candidate.publicId, nickname: candidate.nickname, avatarColor: candidate.avatarColor, bio: candidate.bio, }, lease: { id: lease.id, token, expiresAt }, }; } private async assertEligible(tx: Prisma.TransactionClient, pickerId: string) { const now = new Date(); const account = await tx.account.findUnique({ where: { id: pickerId }, select: { status: true }, }); const sanction = await tx.sanction.findFirst({ where: { accountId: pickerId, type: { in: ["SUSPENSION", "BAN"] }, revokedAt: null, startsAt: { lte: now }, OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], }, select: { id: true }, }); if (account?.status !== "ACTIVE" || sanction) throw new DomainException( ErrorCode.ACCOUNT_SANCTIONED, "Account sanctioned", HttpStatus.FORBIDDEN, ); } private async assertPairAllowed( tx: Prisma.TransactionClient, pickerId: string, authorId: string, ) { const now = new Date(); const [accounts, block, sanction] = await Promise.all([ tx.account.count({ where: { id: { in: [pickerId, authorId] }, status: "ACTIVE" }, }), tx.block.findFirst({ where: { OR: [ { blockerId: pickerId, blockedId: authorId }, { blockerId: authorId, blockedId: pickerId }, ], }, select: { id: true }, }), tx.sanction.findFirst({ where: { accountId: { in: [pickerId, authorId] }, type: { in: ["SUSPENSION", "BAN"] }, revokedAt: null, startsAt: { lte: now }, OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], }, select: { id: true }, }), ]); if (block) throw new DomainException( ErrorCode.USER_BLOCKED, "User blocked", HttpStatus.FORBIDDEN, ); if (accounts !== 2 || sanction) throw new DomainException( ErrorCode.ACCOUNT_SANCTIONED, "Account sanctioned", HttpStatus.FORBIDDEN, ); } private replayResponse(lease: LeaseWithBottle) { try { return this.response( lease, decryptLeaseToken(lease.leaseTokenCiphertext, lease.id), ); } catch (error) { if (error instanceof DomainException) throw error; throw new DomainException( ErrorCode.SERVICE_UNAVAILABLE, "Bottle pick temporarily unavailable", HttpStatus.SERVICE_UNAVAILABLE, ); } } async returnBottle( pickerId: string, bottleId: string, leaseId: string, token: string, ) { const tokenHash = leaseHmac(token); const hidden = () => new DomainException( ErrorCode.NOT_FOUND, "Not Found", HttpStatus.NOT_FOUND, ); return this.prisma.$transaction(async (tx) => { const rows = await tx.$queryRaw< Array<{ id: string; status: string; expiresAt: Date }> >` SELECT "id", "status", "expires_at" AS "expiresAt" FROM "bottle_pick_leases" WHERE "id" = ${leaseId}::uuid AND "bottle_id" = ${bottleId}::uuid AND "picker_id" = ${pickerId}::uuid AND "lease_token_hash" = ${tokenHash} FOR UPDATE`; const lease = rows[0]; if (!lease) throw hidden(); if (lease.status === "RETURNED") return { bottleId, leaseId, status: "RETURNED" as const }; if (lease.status !== "ACTIVE") throw hidden(); if (lease.expiresAt <= new Date()) throw new DomainException( ErrorCode.BOTTLE_LEASE_EXPIRED, "Bottle lease expired", HttpStatus.GONE, ); const released = await tx.bottle.updateMany({ where: { id: bottleId, poolStatus: "LEASED", activeLeaseId: leaseId }, data: { poolStatus: "IN_POOL", activeLeaseId: null, version: { increment: 1 }, }, }); if (released.count !== 1) throw hidden(); await tx.bottlePickLease.update({ where: { id: leaseId }, data: { status: "RETURNED", endedAt: new Date() }, }); return { bottleId, leaseId, status: "RETURNED" as const }; }); } private response(lease: LeaseWithBottle, token: string) { const profile = lease.bottle.author.anonymousProfile; if (!profile) throw new DomainException( ErrorCode.BOTTLE_POOL_EMPTY, "Bottle pool empty", HttpStatus.NOT_FOUND, ); return { bottle: { id: lease.bottle.id, contentText: lease.bottle.contentText }, author: { publicId: profile.publicId, nickname: profile.nickname, avatarColor: profile.avatarColor, bio: profile.bio, }, lease: { id: lease.id, token, expiresAt: lease.expiresAt }, }; } private limit(): never { throw new DomainException( ErrorCode.BOTTLE_DAILY_LIMIT, "Daily pick limit reached", HttpStatus.TOO_MANY_REQUESTS, ); } }