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
it inside the same request path that writes the `held` booking.
NOTE: for production, wrap the check+hold in a transaction (or a
per-room asyncio lock) so two simultaneous requests can't both pass
the read before either writes. See docs/event-flow.md § Concurrency.
NOTE: this read + the subsequent `held` write must be atomic per room.
`services.request_booking` holds a per-room `asyncio.Lock` around this
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)
if not room or room.status != RoomStatus.active: