fix(db): prevent double-booking a slot via unique active_slot_key (H2)

A non-unique index on (doctor_id, slot_start) plus a count-then-insert check
left a TOCTOU race: two concurrent requests could both pass isSlotTaken and
both insert. wrapInTransaction alone doesn't stop the phantom under InnoDB
REPEATABLE-READ.

Add a nullable, unique active_slot_key on Appointment = "doctorId:slotStart"
while the booking occupies the slot (pending/confirmed — in lockstep with
isSlotTaken); NULL once expired/completed/no_show/cancelled (NULLs don't collide
in a MySQL unique index, so released slots rebook freely). bookAtomically now:
catches the unique violation -> SlotTakenException, and expires lapsed pendings
in-transaction so the ~1-min window before the expiry cron doesn't wrongly block
rebooking. All three booking paths (online / my / admin) routed through it.

Migration backfills one row per (doctor, slot) — the latest id — so the index
builds even on dirty historical data without destructively cancelling bookings.
(Backfill surfaced a real pre-existing double-booked slot in dev data.)

Regression: tests/Appointment/SlotUniquenessTest. Adjusted the expiry-service
test fixture to use distinct slots (one live booking per slot is now enforced).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-28 19:04:54 +03:30
co-authored by Claude Opus 4.8
parent c084571bf0
commit aa87b4a9cb
10 changed files with 343 additions and 38 deletions
@@ -6,6 +6,7 @@ use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\OptimisticLockException;
use Doctrine\Persistence\ManagerRegistry;
@@ -30,13 +31,60 @@ class AppointmentRepository extends ServiceEntityRepository
public function bookAtomically(Appointment $appointment): void
{
$em = $this->getEntityManager();
$em->wrapInTransaction(function () use ($em, $appointment): void {
if ($this->isSlotTaken($appointment->getDoctor(), $appointment->getSlotStart(), $appointment->getSlotEnd())) {
throw new SlotTakenException();
}
$em->persist($appointment);
$em->flush();
});
try {
$em->wrapInTransaction(function () use ($em, $appointment): void {
$doctor = $appointment->getDoctor();
$start = $appointment->getSlotStart();
$end = $appointment->getSlotEnd();
if ($this->isSlotTaken($doctor, $start, $end)) {
throw new SlotTakenException();
}
// Free the unique slot key of any pending booking whose payment
// window has lapsed but the expiry cron hasn't run yet, so the
// new booking can take the slot.
$this->expireLapsedPending($doctor, $start, $end);
$em->persist($appointment);
$em->flush();
});
} catch (UniqueConstraintViolationException) {
// Lost the race on the (doctor, slot_start) unique key — the
// in-transaction re-check passed for two concurrent requests but
// only one INSERT can win.
throw new SlotTakenException();
}
}
/**
* Expire pending bookings overlapping this slot whose payment window has
* lapsed, releasing their active_slot_key so the slot can be rebooked.
*/
private function expireLapsedPending(Doctor $doctor, int $slotStart, int $slotEnd): void
{
$lapsed = $this->createQueryBuilder('a')
->where('a.doctor = :doctor')
->andWhere('a.slotStart < :slotEnd')
->andWhere('a.slotEnd > :slotStart')
->andWhere('a.status = :pending')
->andWhere('a.expiresAt IS NOT NULL AND a.expiresAt <= :now')
->setParameter('doctor', $doctor)
->setParameter('pending', Appointment::STATUS_PENDING)
->setParameter('slotStart', $slotStart)
->setParameter('slotEnd', $slotEnd)
->setParameter('now', time())
->getQuery()
->getResult();
if ($lapsed === []) {
return;
}
foreach ($lapsed as $appt) {
$appt->transitionTo(Appointment::STATUS_EXPIRED);
}
// Flush the key-releasing UPDATEs before the new INSERT so they don't
// collide on the unique key within the same flush.
$this->getEntityManager()->flush();
}
/** Check if a slot is already taken (confirmed or pending) */