Fix expired seat

This commit is contained in:
Roba Boru
2026-07-15 08:13:23 +03:00
parent ae2db7feb4
commit 64b362ff51
2 changed files with 132 additions and 27 deletions

View File

@@ -1,4 +1,4 @@
import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
import { Injectable, ConflictException, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { HoldSeatsDto, JourneyDirection } from './seats.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
@@ -7,6 +7,8 @@ import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config
@Injectable()
export class SeatsService {
private readonly logger = new Logger(SeatsService.name);
constructor(
private prisma: PrismaService,
private segmentsService: SegmentsService,
@@ -891,30 +893,83 @@ export class SeatsService {
);
}
// Runs every minute, but is also safe to call on-demand (e.g. right after a hold's
// TTL is read back to the client) — expiresAt/now are both absolute UTC instants
// (Date objects, not wall-clock strings), so this is correct regardless of the
// server's or a client's local timezone; there's no wall-clock parsing involved.
@Cron(CronExpression.EVERY_MINUTE)
async expireHolds() {
try {
const result = await this.expireHoldsCore();
if (result.expiredHolds > 0) {
this.logger.log(
`Expired ${result.expiredHolds} hold(s): released ${result.releasedSeatIds.length} seat(s), ` +
`skipped ${result.skippedSeatIds.length} still held by another active hold on the same schedule`,
);
}
} catch (error) {
// A failed run must not crash the process or silently go unnoticed — the next
// scheduled run one minute later will retry the same (still-expired) holds,
// since nothing here is deleted/updated until the queries above succeed.
this.logger.error('Failed to expire seat holds', error instanceof Error ? error.stack : error);
}
}
async expireHoldsCore(now: Date = new Date()): Promise<{
expiredHolds: number;
releasedSeatIds: string[];
skippedSeatIds: string[];
}> {
const expired = await this.prisma.seatHold.findMany({
where: { expiresAt: { lt: new Date() } },
select: { id: true, seatIds: true },
where: { expiresAt: { lt: now } },
select: { id: true, scheduleId: true, seatIds: true },
});
if (expired.length === 0) return;
if (expired.length === 0) {
return { expiredHolds: 0, releasedSeatIds: [], skippedSeatIds: [] };
}
const expiredSeatIds = expired.flatMap(h => h.seatIds as string[]);
// Only reset seats that have no remaining active holds
const stillHeld = await this.prisma.seatHold.findMany({
where: { expiresAt: { gte: new Date() }, seatIds: { hasSome: expiredSeatIds } },
select: { seatIds: true },
// Still-active holds — scoped per (scheduleId, seatId), not just seatId. The same
// physical Seat row is reused across every recurring date a coach runs, so the
// same seatId legitimately appears in unrelated holds for other schedules; without
// this scoping, an unrelated active hold on a DIFFERENT schedule would wrongly
// block release of a seat whose hold expired on THIS schedule, leaving it stuck at
// status 'HELD' indefinitely.
const activeHolds = await this.prisma.seatHold.findMany({
where: { expiresAt: { gte: now } },
select: { scheduleId: true, seatIds: true },
});
const stillHeldIds = new Set(stillHeld.flatMap(h => h.seatIds as string[]));
const toRelease = expiredSeatIds.filter(id => !stillHeldIds.has(id));
const stillHeldKeys = new Set(
activeHolds.flatMap(h => (h.seatIds as string[]).map(seatId => `${h.scheduleId}:${seatId}`)),
);
if (toRelease.length > 0) {
const releasedSeatIds = new Set<string>();
const skippedSeatIds = new Set<string>();
for (const hold of expired) {
for (const seatId of hold.seatIds as string[]) {
if (stillHeldKeys.has(`${hold.scheduleId}:${seatId}`)) {
skippedSeatIds.add(seatId);
} else {
releasedSeatIds.add(seatId);
}
}
}
if (releasedSeatIds.size > 0) {
await this.prisma.seat.updateMany({
where: { id: { in: toRelease }, status: 'HELD' },
data: { status: 'AVAILABLE' },
where: { id: { in: Array.from(releasedSeatIds) }, status: 'HELD' },
// heldUntil is cleared alongside status — leaving a stale (past) heldUntil on an
// AVAILABLE seat is stale data that any future code reading heldUntil directly
// (instead of re-deriving availability live) would misinterpret.
data: { status: 'AVAILABLE', heldUntil: null },
});
}
await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: now } } });
return {
expiredHolds: expired.length,
releasedSeatIds: Array.from(releasedSeatIds),
skippedSeatIds: Array.from(skippedSeatIds),
};
}
}