fix: make check-then-hold atomic against concurrent bookings (#4)

Two simultaneous requests for the same nights could both pass is_available()
before either wrote its held row, double-booking the dates. Wrap the
is_available -> create_booking pair in a per-room asyncio.Lock
(_room_locks[room_id]) in services.request_booking, which both the HTTP and
RPC doors funnel through. FX + invoice creation stay outside the lock, so it
covers only the DB critical section.

Single-loop scope (LNbits runs one worker); documented the multi-worker
caveat (needs a DB-level guard) in event-flow.md and the crud.is_available
note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
This commit is contained in:
Padreug 2026-07-19 17:08:43 +02:00
commit 9d87c129ad
3 changed files with 48 additions and 19 deletions

View file

@ -12,6 +12,8 @@ error message — relays them to the caller verbatim; `BookingError` is a
backend failure and surfaces as a generic error over RPC (logged server-side).
"""
import asyncio
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from lnbits.core.services import create_invoice
@ -29,6 +31,13 @@ from .models import (
RoomStatus,
)
# Per-room lock serializing the availability read + the `held` write, so two
# concurrent requests for the same nights can't both pass the check before
# either commits the hold (double-booking). Keyed by room id; the dict grows
# by distinct rooms only (bounded for a castle). Single-asyncio-loop scope —
# see the note in request_booking on the multi-worker caveat.
_room_locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
class NotFound(ValueError):
"""Referenced entity does not exist (-> HTTP 404)."""
@ -90,10 +99,8 @@ async def request_booking(data: BookingRequestData) -> BookingQuote:
if data.num_guests > room.max_guests:
raise ValueError(f"Max {room.max_guests} guests")
# TODO(#4): wrap is_available + create_booking in a per-room lock / txn.
if not await crud.is_available(data.room_id, data.check_in, data.check_out):
raise Unavailable("Those dates are no longer available")
# Compute the canonical amount up front (FX call) so the lock below wraps
# only the DB check + insert, never the slow network work.
settings = await crud.get_or_create_settings()
price_fiat = round(room.price_amount * nights, 2)
amount_sat = await to_sats(price_fiat, room.price_currency) # canonical
@ -116,7 +123,22 @@ async def request_booking(data: BookingRequestData) -> BookingQuote:
expires_at=datetime.now(timezone.utc)
+ timedelta(minutes=settings.default_hold_minutes),
)
await crud.create_booking(booking)
# Atomic check-then-hold. The `held` row is itself the lock on the dates
# (is_available counts held as occupying), so serializing the read+insert
# per room means the first request to commit wins and every later one sees
# it and gets Unavailable. FX + invoice creation stay outside the lock.
#
# Scope: a single asyncio loop. LNbits runs one worker, so an asyncio.Lock
# is sufficient; if it ever runs multi-worker/multi-process this must move
# to a DB-level guard (Postgres exclusion constraint or SELECT ... FOR
# UPDATE) — noted in issue #4 / event-flow.md.
async with _room_locks[room.id]:
if not await crud.is_available(
data.room_id, data.check_in, data.check_out
):
raise Unavailable("Those dates are no longer available")
await crud.create_booking(booking)
# Sats-denominated (deposit_sat locked at quote time) so FX drift before
# payment can't change what's owed. tag+booking_id let