Skip to content

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 NULL category capacity means unlimited (availability is NULL).
  • A slot may also carry an overall cap; when set, a hold must fit both the category and the slot-wide total.
  • Only held and confirmed consume capacity. released, expired and cancelled free 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_expires is NULL) 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 with release().

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