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

@ -178,9 +178,10 @@ async def is_available(room_id: str, check_in: str, check_out: str) -> bool:
overlaps [check_in, check_out). This is the authoritative check; call overlaps [check_in, check_out). This is the authoritative check; call
it inside the same request path that writes the `held` booking. it inside the same request path that writes the `held` booking.
NOTE: for production, wrap the check+hold in a transaction (or a NOTE: this read + the subsequent `held` write must be atomic per room.
per-room asyncio lock) so two simultaneous requests can't both pass `services.request_booking` holds a per-room `asyncio.Lock` around this
the read before either writes. See docs/event-flow.md § Concurrency. call and `create_booking` for exactly that reason don't call this as
the basis for a hold outside that lock. See docs/event-flow.md § Concurrency.
""" """
room = await get_room(room_id) room = await get_room(room_id)
if not room or room.status != RoomStatus.active: if not room or room.status != RoomStatus.active:

View file

@ -120,17 +120,23 @@ the result.
## Concurrency — the check-then-hold lock ## Concurrency — the check-then-hold lock
`is_available()` followed by writing the `held` row is the critical section. `is_available()` followed by writing the `held` row is the critical section:
Two simultaneous requests for the same nights could both pass the read two simultaneous requests for the same nights could both pass the read before
before either writes. Required mitigation (marked `TODO(concurrency)` in either writes. Because the check counts *occupying* bookings (`held` included),
`views_api.py`): once one request wins and writes `held`, the loser's re-check fails →
`Unavailable`/`409` — so the fix only needs to make that read+write atomic.
- wrap check + insert in a DB transaction, **or** **Implemented** (`services.request_booking`): a per-room `asyncio.Lock`
- take a per-room `asyncio.Lock` around the section. (`_room_locks[room_id]`) wraps exactly the `is_available``create_booking`
pair. FX conversion and invoice creation are computed outside the lock so it's
held only for the DB critical section. Both doors (HTTP + RPC) go through
`services.request_booking`, so the lock covers every entry point.
Because the check reads *occupying* bookings (`held` included), once one **Scope / caveat:** an `asyncio.Lock` only serializes within one event loop.
request wins and writes `held`, the loser's re-check fails → `409`. The DB LNbits runs a single worker, so this is sufficient today. If it ever runs
is the arbiter; the lock just makes the read-write atomic. multi-worker/multi-process, this must become a DB-level guard — a Postgres
exclusion constraint on the date range, or `SELECT … FOR UPDATE` on the room
row — since separate processes don't share the lock.
## Cancellation & refunds (design intent) ## Cancellation & refunds (design intent)

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). 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 datetime import datetime, timedelta, timezone
from lnbits.core.services import create_invoice from lnbits.core.services import create_invoice
@ -29,6 +31,13 @@ from .models import (
RoomStatus, 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): class NotFound(ValueError):
"""Referenced entity does not exist (-> HTTP 404).""" """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: if data.num_guests > room.max_guests:
raise ValueError(f"Max {room.max_guests} guests") raise ValueError(f"Max {room.max_guests} guests")
# TODO(#4): wrap is_available + create_booking in a per-room lock / txn. # Compute the canonical amount up front (FX call) so the lock below wraps
if not await crud.is_available(data.room_id, data.check_in, data.check_out): # only the DB check + insert, never the slow network work.
raise Unavailable("Those dates are no longer available")
settings = await crud.get_or_create_settings() settings = await crud.get_or_create_settings()
price_fiat = round(room.price_amount * nights, 2) price_fiat = round(room.price_amount * nights, 2)
amount_sat = await to_sats(price_fiat, room.price_currency) # canonical 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) expires_at=datetime.now(timezone.utc)
+ timedelta(minutes=settings.default_hold_minutes), + 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 # Sats-denominated (deposit_sat locked at quote time) so FX drift before
# payment can't change what's owed. tag+booking_id let # payment can't change what's owed. tag+booking_id let