fix: 限定捞瓶查询与重试成本
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user