fix: 限定捞瓶查询与重试成本

This commit is contained in:
root
2026-09-15 21:13:28 +08:00
parent 0c5cbd75d9
commit c94d7b1af0
7 changed files with 425 additions and 49 deletions
+107 -48
View File
@@ -1,7 +1,7 @@
import { randomBytes, randomInt, randomUUID } from "node:crypto";
import { HttpStatus, Inject, Injectable } from "@nestjs/common";
import { ErrorCode } from "@drift/contracts";
import { Prisma } from "@prisma/client";
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";
@@ -11,6 +11,12 @@ import {
leaseHmac,
} from "../auth/auth.config.js";
import { CandidateRepository, type Candidate } from "./candidate.repository.js";
import {
CandidateBatchContended,
classifyPickError,
readMatchRetryConfig,
transactionLimits,
} from "./match-retry-policy.js";
type LeaseWithBottle = {
id: string;
@@ -48,6 +54,7 @@ export class MatchService {
300_000,
"BOTTLE_LEASE_TTL_MS",
);
private readonly retry = readMatchRetryConfig(process.env);
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@@ -56,29 +63,14 @@ export class MatchService {
) {}
async pick(pickerId: string, requestId: string) {
for (let attempt = 0; attempt < 100; attempt += 1) {
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 [lock] = await tx.$queryRaw<Array<{ locked: boolean }>>`
SELECT pg_try_advisory_xact_lock(
hashtextextended(${`pick:${pickerId}`}, 0)
) AS "locked"`;
if (!lock?.locked) return null;
const previous = await tx.bottlePickRequest.findUnique({
where: { pickerId_requestId: { pickerId, requestId } },
include: {
lease: {
include: {
bottle: {
include: {
author: { include: { anonymousProfile: true } },
},
},
},
},
},
});
const previous = await this.findPrevious(tx, pickerId, requestId);
if (previous?.lease) {
let token: string;
try {
@@ -97,17 +89,13 @@ export class MatchService {
}
await this.assertEligible(tx, pickerId);
const usageDate = utc8UsageDate(new Date());
const current = await tx.dailyUsage.findUnique({
where: {
accountId_usageDate: {
accountId: pickerId,
usageDate: new Date(`${usageDate}T00:00:00.000Z`),
},
},
});
if ((current?.bottlesPicked ?? 0) >= 20) this.limit();
const batch = await this.candidates.findBatch(tx, pickerId);
if (!batch.length)
throw new DomainException(
ErrorCode.BOTTLE_POOL_EMPTY,
"Bottle pool empty",
HttpStatus.NOT_FOUND,
);
while (batch.length) {
const index = Math.floor(Math.random() * batch.length);
const candidate = batch.splice(index, 1)[0]!;
@@ -120,23 +108,51 @@ export class MatchService {
);
if (result) return result;
}
throw new DomainException(
ErrorCode.BOTTLE_POOL_EMPTY,
"Bottle pool empty",
HttpStatus.NOT_FOUND,
);
throw new CandidateBatchContended();
},
{
isolationLevel: "ReadCommitted",
maxWait: 10_000,
timeout: 10_000,
...limits,
},
);
if (result) return result;
return result;
} catch (error) {
if (!this.isContention(error)) throw error;
if (error instanceof DomainException) {
if (error.code === ErrorCode.BOTTLE_POOL_EMPTY) {
const winner = await this.readWinnerUntil(
pickerId,
requestId,
Math.min(deadline, performance.now() + 250),
);
if (winner) return winner;
}
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);
if (winner) return winner;
} catch (readError) {
if (classifyPickError(readError) === "SERVICE_UNAVAILABLE") break;
throw readError;
}
}
}
await new Promise((resolve) => setTimeout(resolve, randomInt(5, 21)));
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,
@@ -145,13 +161,56 @@ export class MatchService {
);
}
private isContention(error: unknown) {
return (
error instanceof Prisma.PrismaClientKnownRequestError &&
(error.code === "P2028" ||
error.code === "P2034" ||
error.code === "P2002")
);
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) {
const previous = await this.findPrevious(this.prisma, pickerId, requestId);
if (previous?.lease) {
try {
const token = decryptLeaseToken(
previous.lease.leaseTokenCiphertext,
previous.lease.id,
);
return this.response(previous.lease, token);
} catch {
throw new DomainException(
ErrorCode.SERVICE_UNAVAILABLE,
"Bottle pick temporarily unavailable",
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
return null;
}
private async readWinnerUntil(
pickerId: string,
requestId: string,
deadline: number,
) {
while (performance.now() < deadline) {
const winner = await this.readWinner(pickerId, requestId);
if (winner) return winner;
await new Promise((resolve) => setTimeout(resolve, 5));
}
return null;
}
private async claim(