Capacity and holds¶
This is the heart of the module: how capacity is counted, how the booking lifecycle works, and how concurrency is kept safe.
Availability formula¶
Availability is computed per category:
available(category) = category.capacity โ ฮฃ quantity of bookings in {held, confirmed}
- A
NULLcategory capacity means unlimited (availability isNULL). - A slot may also carry an overall cap; when set, a hold must fit both the category and the slot-wide total.
- Only
heldandconfirmedconsume capacity.released,expiredandcancelledfree it.
Booking lifecycle¶
stateDiagram-v2
[*] --> held : hold()
held --> confirmed : confirm()
held --> released : release()
held --> expired : expire() / TTL lapsed
held --> held : extendHold()
confirmed --> cancelled : cancel()
confirmed --> [*]
released --> [*]
expired --> [*]
cancelled --> [*]
note right of held
Consumes capacity.
Carries hold_expires.
end note
note right of confirmed
Consumes capacity.
Permanent until cancelled.
end note
| From | Method | To | Frees capacity? |
|---|---|---|---|
held |
confirm() |
confirmed |
no (still consumes) |
held |
release() |
released |
yes |
held |
expire() |
expired |
yes |
held |
extendHold() |
held |
no (resets hold_expires) |
confirmed |
cancel() |
cancelled |
yes |
confirm() / extendHold() on a hold that has already lapsed throw
HoldExpiredException - the caller must re-hold(),
which re-checks capacity. Extending never silently re-acquires capacity it no
longer owns.
Concurrency: no overbooking¶
Every hold() runs inside a database transaction and takes a
SELECT โฆ FOR UPDATE row lock before reading consumption and writing the
booking. The lock serialises competing holds so two requests can never both
pass the capacity check for the last place.
sequenceDiagram
participant A as Request A
participant B as Request B
participant DB as Database
Note over A,B: 1 place left, both want it
A->>DB: BEGIN
A->>DB: SELECT category FOR UPDATE ๐
B->>DB: BEGIN
B->>DB: SELECT category FOR UPDATE (blocks โณ)
A->>DB: SUM(held+confirmed) = 0, capacity 1 โ
A->>DB: INSERT booking (held)
A->>DB: COMMIT ๐
DB-->>B: lock acquired
B->>DB: SUM(held+confirmed) = 1, capacity 1 โ
B->>DB: ROLLBACK
Note over B: CapacityExceededException
What gets locked¶
- No overall slot cap โ the engine locks the category row. Independent categories of the same slot can still be booked in parallel.
- Overall slot cap set โ the engine locks the slot row, serialising every category in that slot (so the shared total is enforced).
Picking a single anchor row per case means there is never more than one lock to take, so there is no lock-ordering deadlock.
Locking reads
The consumption SUM is taken as a locking read (FOR UPDATE) within the
hold transaction, so it always sees the latest committed rows rather than a
stale snapshot under REPEATABLE READ.
Holds and expiry¶
A held booking carries hold_expires (= now + TTL). The TTL defaults to the
resource's hold_ttl and can be overridden per hold() call or reset with
extendHold().
sequenceDiagram
participant Cron
participant Hook as #[Hook(cron)]
participant Q as yoyaku_expire_holds queue
participant W as ExpireHolds worker
participant BM as BookingManager
Cron->>Hook: run
Hook->>Hook: query held bookings where hold_expires โค now
Hook->>Q: enqueue {booking: id}
Cron->>W: processItem
W->>BM: expire(booking)
BM->>BM: still held & lapsed? โ expired (frees capacity)
Note over BM: no-op if confirmed or extended meanwhile
expire() re-checks under the row lock that the booking is still held and
still past its deadline, so a hold that was confirmed or extended between
enqueue and processing is safely left alone.
Manual validation (non-expiring holds)¶
Not every booking should confirm automatically. To model manual approval,
take a hold with a non-positive TTL (hold(category, qty, ttl: 0), or set
the resource's hold_ttl to 0):
- it gets no deadline (
hold_expiresisNULL) and is never touched by the expiry worker; - it keeps consuming capacity while pending, so the slot can't be oversold during review;
- an admin or workflow then approves it with
confirm()or rejects it withrelease().
This replaces the legacy attente โ confirme / refuse flow. Whether a resource
auto-confirms (payment-driven, finite TTL) or waits for manual validation
(hold_ttl = 0) is purely a matter of how the consumer calls hold() and who
calls confirm() - the engine never confirms on its own.
stateDiagram-v2
[*] --> held : hold(ttl = 0)
held --> confirmed : confirm() ยท admin approves
held --> released : release() ยท admin rejects
note right of held
No deadline.
Consumes capacity
while pending review.
end note