First claim wins, and a retry is not a second claim
PHP · app/Services/FoodRescueService.phpWhen cooked food from a cancelled order goes up for rescue, several nearby customers can tap Claim at the same moment, so I lock the offer row and let the first committed claim win. A second tap from the same customer gets back the claim they already own instead of an error, which covers double taps and app retries on a weak network. If a claim is the first thing to notice that the timer ran out, it records the expiry and commits before reporting the error, because throwing inside the transaction would roll the expiry back and leave a dead offer looking available.
public function claimAtomically(int $offerId, int $userId): OrderRescueOffer
{
// Set when THIS call is the first to see the expiry; acted on after commit.
$expiredOffer = null;
$offer = DB::transaction(function () use ($offerId, $userId, &$expiredOffer) {
$row = OrderRescueOffer::where('id', $offerId)->lockForUpdate()->first();
if (!$row) {
throw new \RuntimeException(translate('Rescue offer not found.'));
}
// Same user re-claiming: hand back what they already own
// (double-tap on Place Order, app retry after a flaky network).
if ($row->status === 'claimed'
&& (int) $row->claimed_by_user_id === $userId) {
return $row;
}
if ($row->status !== 'available') {
throw new \RuntimeException(translate('This rescue offer has already been claimed.'));
}
if (!$row->expires_at || $row->expires_at->isPast()) {
// Don't throw here: that would roll back the status write.
$row->status = 'expired';
$row->save();
$expiredOffer = $row->fresh();
return null;
}
$row->status = 'claimed';
$row->claimed_by_user_id = $userId;
$row->claimed_at = now();
$row->save();
return $row;
});
if ($expiredOffer !== null) {
// ... run the expiry side effects (tell the rider, settle) in a try/catch
throw new \RuntimeException(translate('This rescue offer has expired.'));
}
return $offer;
}public function claimAtomically(int $offerId, int $userId): OrderRescueOffer
{
// Set when THIS call is the first to see the expiry; acted on after commit.
$expiredOffer = null;
$offer = DB::transaction(function () use ($offerId, $userId, &$expiredOffer) {
$row = OrderRescueOffer::where('id', $offerId)->lockForUpdate()->first();
if (!$row) {
throw new \RuntimeException(translate('Rescue offer not found.'));
}
// Same user re-claiming: hand back what they already own
// (double-tap on Place Order, app retry after a flaky network).
if ($row->status === 'claimed'
&& (int) $row->claimed_by_user_id === $userId) {
return $row;
}
if ($row->status !== 'available') {
throw new \RuntimeException(translate('This rescue offer has already been claimed.'));
}
if (!$row->expires_at || $row->expires_at->isPast()) {
// Don't throw here: that would roll back the status write.
$row->status = 'expired';
$row->save();
$expiredOffer = $row->fresh();
return null;
}
$row->status = 'claimed';
$row->claimed_by_user_id = $userId;
$row->claimed_at = now();
$row->save();
return $row;
});
if ($expiredOffer !== null) {
// ... run the expiry side effects (tell the rider, settle) in a try/catch
throw new \RuntimeException(translate('This rescue offer has expired.'));
}
return $offer;
}