Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
When full, ev/waitlist! queues actors FIFO (:waitlist/:unwaitlist on the booking stream; waiting fold independent of the seat fold). ev/waitlist, ev/waitlist-position, ev/leave-waitlist!. ev/cancel-promote! frees a seat and auto-promotes the head of the queue to a confirmed booking. Idempotent. 240/240 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
373 lines
12 KiB
Plaintext
373 lines
12 KiB
Plaintext
;; lib/events/booking.sx — transactional, capacity-safe booking on persist.
|
|
;;
|
|
;; Each bookable occurrence has an append-only stream of booking events:
|
|
;;
|
|
;; :booking free booking — actor immediately holds a confirmed seat
|
|
;; :hold provisional hold — seat reserved while payment is pending
|
|
;; :confirm a held seat becomes confirmed (payment succeeded)
|
|
;; :release a held seat is abandoned (payment failed/expired) — seat freed
|
|
;; :cancel a held or confirmed seat is given up — seat freed
|
|
;;
|
|
;; The live state is the stream FOLDED in order into per-actor seat states
|
|
;; (:held / :confirmed); an actor in ANY state occupies a seat, so both held and
|
|
;; confirmed seats count toward capacity — a pending payment cannot be
|
|
;; oversold. A freed seat (release/cancel) reopens capacity.
|
|
;;
|
|
;; Capacity safety is the contract: two writers racing for the last seat must
|
|
;; NEVER both succeed. Seat-ACQUIRING writes (:booking, :hold) go through
|
|
;; persist's optimistic concurrency — `persist/append-expect` appends only if
|
|
;; the stream's last-seq still equals what the writer observed; else it returns
|
|
;; a conflict the writer retries. Seat-FREEING writes (:cancel, :release) and
|
|
;; the state transition (:confirm) never oversell, so they append directly.
|
|
|
|
(define ev-booking-stream (fn (occ-key) (str "booking:" occ-key)))
|
|
|
|
(define
|
|
ev-bk-member?
|
|
(fn
|
|
(x xs)
|
|
(cond
|
|
((empty? xs) false)
|
|
((= x (first xs)) true)
|
|
(else (ev-bk-member? x (rest xs))))))
|
|
|
|
(define
|
|
ev-bk-index
|
|
(fn
|
|
(xs x i)
|
|
(cond
|
|
((empty? xs) -1)
|
|
((= (first xs) x) i)
|
|
(else (ev-bk-index (rest xs) x (+ i 1))))))
|
|
|
|
(define ev-bk-append (fn (xs a) (append xs (list a))))
|
|
(define ev-bk-remove (fn (xs a) (filter (fn (x) (not (= x a))) xs)))
|
|
|
|
;; ---- per-actor state association list: ((actor state) ...) in join order ----
|
|
|
|
(define
|
|
ev-state-has?
|
|
(fn
|
|
(states actor)
|
|
(cond
|
|
((empty? states) false)
|
|
((= (first (first states)) actor) true)
|
|
(else (ev-state-has? (rest states) actor)))))
|
|
|
|
(define
|
|
ev-state-get
|
|
(fn
|
|
(states actor)
|
|
(cond
|
|
((empty? states) :none)
|
|
((= (first (first states)) actor) (first (rest (first states))))
|
|
(else (ev-state-get (rest states) actor)))))
|
|
|
|
(define
|
|
ev-state-del
|
|
(fn (states actor) (filter (fn (p) (not (= (first p) actor))) states)))
|
|
|
|
(define
|
|
ev-state-set
|
|
(fn
|
|
(states actor st)
|
|
(if
|
|
(ev-state-has? states actor)
|
|
(map (fn (p) (if (= (first p) actor) (list actor st) p)) states)
|
|
(append states (list (list actor st))))))
|
|
|
|
;; Fold the booking stream into per-actor seat states (join order preserved).
|
|
(define
|
|
ev-fold-states
|
|
(fn
|
|
(events)
|
|
(reduce
|
|
(fn
|
|
(acc e)
|
|
(let
|
|
((typ (persist/event-type e))
|
|
(actor (get (persist/event-data e) :actor)))
|
|
(cond
|
|
((= typ :booking) (ev-state-set acc actor :confirmed))
|
|
((= typ :hold) (ev-state-set acc actor :held))
|
|
((= typ :confirm)
|
|
(if
|
|
(ev-state-has? acc actor)
|
|
(ev-state-set acc actor :confirmed)
|
|
acc))
|
|
((= typ :cancel) (ev-state-del acc actor))
|
|
((= typ :release) (ev-state-del acc actor))
|
|
(else acc))))
|
|
(list)
|
|
events)))
|
|
|
|
(define
|
|
ev-states-of
|
|
(fn
|
|
(b occ-key)
|
|
(ev-fold-states (persist/read b (ev-booking-stream occ-key)))))
|
|
|
|
;; Live roster (actors holding a seat — held or confirmed), oldest active first.
|
|
(define
|
|
ev-booked-actors
|
|
(fn (b occ-key) (map (fn (p) (first p)) (ev-states-of b occ-key))))
|
|
|
|
(define
|
|
ev-actor-booked?
|
|
(fn (b occ-key actor) (ev-bk-member? actor (ev-booked-actors b occ-key))))
|
|
|
|
;; Live seat count (folded roster size — both held and confirmed seats).
|
|
(define
|
|
ev-booking-count
|
|
(fn (b occ-key) (len (ev-booked-actors b occ-key))))
|
|
|
|
;; Seat state for an actor: :held / :confirmed / :none.
|
|
(define
|
|
ev/seat-state
|
|
(fn (b occ-key actor) (ev-state-get (ev-states-of b occ-key) actor)))
|
|
|
|
;; 1-based seat number for an actor on the roster (0 if not booked).
|
|
(define
|
|
ev-seat-of
|
|
(fn
|
|
(actors actor)
|
|
(let
|
|
((i (ev-bk-index actors actor 0)))
|
|
(if (< i 0) 0 (+ i 1)))))
|
|
|
|
;; ---- seat-acquiring writes (capacity-guarded via append-expect) ----
|
|
|
|
;; One seat-acquiring attempt of `kind` (:booking or :hold) against an OBSERVED
|
|
;; snapshot (roster the writer saw + the last-seq). Returns :already / :full /
|
|
;; :conflict, or a success dict tagged with `ok-status`. :conflict means a
|
|
;; concurrent append landed since the snapshot — the caller must re-observe.
|
|
(define
|
|
ev-acquire-with-observed
|
|
(fn
|
|
(b occ-key capacity actor observed-actors expected kind ok-status)
|
|
(cond
|
|
((ev-bk-member? actor observed-actors) {:seat (ev-seat-of observed-actors actor) :actor actor :status :already})
|
|
((>= (len observed-actors) capacity) {:actor actor :capacity capacity :status :full})
|
|
(else
|
|
(let
|
|
((r (persist/append-expect b (ev-booking-stream occ-key) expected kind 0 {:actor actor})))
|
|
(if (persist/conflict? r) {:actual (persist/conflict-actual r) :actor actor :status :conflict} {:seat (+ (len observed-actors) 1) :actor actor :status ok-status}))))))
|
|
|
|
(define
|
|
ev-acquire!
|
|
(fn
|
|
(b occ-key capacity actor kind ok-status)
|
|
(let
|
|
((res (ev-acquire-with-observed b occ-key capacity actor (ev-booked-actors b occ-key) (persist/last-seq b (ev-booking-stream occ-key)) kind ok-status)))
|
|
(if
|
|
(= (get res :status) :conflict)
|
|
(ev-acquire! b occ-key capacity actor kind ok-status)
|
|
res))))
|
|
|
|
;; Capacity-safe confirmed booking (retrying on conflict).
|
|
(define
|
|
ev/book!
|
|
(fn
|
|
(b occ-key capacity actor)
|
|
(ev-acquire! b occ-key capacity actor :booking :booked)))
|
|
|
|
;; Capacity-safe provisional hold (retrying on conflict). The seat is reserved
|
|
;; (counts toward capacity) until confirmed or released.
|
|
(define
|
|
ev/hold!
|
|
(fn
|
|
(b occ-key capacity actor)
|
|
(ev-acquire! b occ-key capacity actor :hold :held)))
|
|
|
|
;; Test seam: one attempt against a caller-supplied snapshot (book or hold).
|
|
(define
|
|
ev/book-with-observed
|
|
(fn
|
|
(b occ-key capacity actor observed-actors expected)
|
|
(ev-acquire-with-observed
|
|
b
|
|
occ-key
|
|
capacity
|
|
actor
|
|
observed-actors
|
|
expected
|
|
:booking :booked)))
|
|
|
|
(define
|
|
ev/hold-with-observed
|
|
(fn
|
|
(b occ-key capacity actor observed-actors expected)
|
|
(ev-acquire-with-observed
|
|
b
|
|
occ-key
|
|
capacity
|
|
actor
|
|
observed-actors
|
|
expected
|
|
:hold :held)))
|
|
|
|
;; ---- state transitions / seat-freeing writes (no oversell, append direct) ----
|
|
|
|
;; Confirm a held seat (payment succeeded). :confirmed on success,
|
|
;; :already-confirmed if it was confirmed, :not-held otherwise.
|
|
(define
|
|
ev/confirm!
|
|
(fn
|
|
(b occ-key actor)
|
|
(let
|
|
((st (ev/seat-state b occ-key actor)))
|
|
(cond
|
|
((= st :held)
|
|
(begin
|
|
(persist/append
|
|
b
|
|
(ev-booking-stream occ-key)
|
|
:confirm 0
|
|
{:actor actor})
|
|
{:actor actor :status :confirmed}))
|
|
((= st :confirmed) {:actor actor :status :already-confirmed})
|
|
(else {:actor actor :status :not-held})))))
|
|
|
|
;; Release a held seat (payment failed/expired), freeing it. Only valid for a
|
|
;; held seat — confirmed bookings are given up via ev/cancel!.
|
|
(define
|
|
ev/release!
|
|
(fn
|
|
(b occ-key actor)
|
|
(let
|
|
((st (ev/seat-state b occ-key actor)))
|
|
(if
|
|
(= st :held)
|
|
(begin
|
|
(persist/append
|
|
b
|
|
(ev-booking-stream occ-key)
|
|
:release 0
|
|
{:actor actor})
|
|
{:actor actor :status :released})
|
|
{:actor actor :status :not-held}))))
|
|
|
|
;; Cancel a held or confirmed seat, freeing it. :cancelled or :not-booked.
|
|
(define
|
|
ev/cancel!
|
|
(fn
|
|
(b occ-key actor)
|
|
(if
|
|
(ev-bk-member? actor (ev-booked-actors b occ-key))
|
|
(begin
|
|
(persist/append
|
|
b
|
|
(ev-booking-stream occ-key)
|
|
:cancel 0
|
|
{:actor actor})
|
|
{:actor actor :status :cancelled})
|
|
{:actor actor :status :not-booked})))
|
|
|
|
;; The roster as a plain list of actors (oldest active first).
|
|
(define ev/roster (fn (b occ-key) (ev-booked-actors b occ-key)))
|
|
|
|
;; Seats remaining for an occurrence of the given capacity.
|
|
(define
|
|
ev/seats-left
|
|
(fn
|
|
(b occ-key capacity)
|
|
(max 0 (- capacity (ev-booking-count b occ-key)))))
|
|
|
|
;; ---- waitlist ----
|
|
;; When an occurrence is full, actors join a FIFO waitlist (:waitlist /
|
|
;; :unwaitlist events on the same stream). Taking a seat (:booking / :hold)
|
|
;; removes an actor from the queue, so the waitlist fold is independent of the
|
|
;; seat fold. Cancelling/releasing a seat can auto-promote the head of the
|
|
;; queue (a :booking appended for them).
|
|
|
|
(define
|
|
ev-fold-waiting
|
|
(fn
|
|
(events)
|
|
(reduce
|
|
(fn
|
|
(acc e)
|
|
(let
|
|
((typ (persist/event-type e))
|
|
(actor (get (persist/event-data e) :actor)))
|
|
(cond
|
|
((= typ :waitlist) (if (ev-bk-member? actor acc) acc (ev-bk-append acc actor)))
|
|
((= typ :unwaitlist) (ev-bk-remove acc actor))
|
|
((= typ :booking) (ev-bk-remove acc actor))
|
|
((= typ :hold) (ev-bk-remove acc actor))
|
|
(else acc))))
|
|
(list)
|
|
events)))
|
|
|
|
;; The current waitlist queue (FIFO, oldest first).
|
|
(define
|
|
ev/waitlist
|
|
(fn (b occ-key) (ev-fold-waiting (persist/read b (ev-booking-stream occ-key)))))
|
|
|
|
;; 1-based queue position for an actor (0 if not waiting).
|
|
(define
|
|
ev/waitlist-position
|
|
(fn (b occ-key actor) (ev-seat-of (ev/waitlist b occ-key) actor)))
|
|
|
|
;; Book if a seat is free, else join the waitlist. Idempotent: already seated →
|
|
;; :already; already queued → :already-waiting.
|
|
(define
|
|
ev/waitlist!
|
|
(fn
|
|
(b occ-key capacity actor)
|
|
(let
|
|
((seats (ev-booked-actors b occ-key))
|
|
(waiting (ev/waitlist b occ-key)))
|
|
(cond
|
|
((ev-bk-member? actor seats)
|
|
{:status :already :seat (ev-seat-of seats actor) :actor actor})
|
|
((ev-bk-member? actor waiting)
|
|
{:status :already-waiting :position (ev-seat-of waiting actor) :actor actor})
|
|
(else
|
|
(let
|
|
((r (ev/book! b occ-key capacity actor)))
|
|
(if
|
|
(= (get r :status) :booked)
|
|
r
|
|
(begin
|
|
(persist/append b (ev-booking-stream occ-key) :waitlist 0 {:actor actor})
|
|
{:status :waitlisted
|
|
:position (+ (len waiting) 1)
|
|
:actor actor}))))))))
|
|
|
|
;; Leave the waitlist. :left or :not-waiting.
|
|
(define
|
|
ev/leave-waitlist!
|
|
(fn
|
|
(b occ-key actor)
|
|
(if
|
|
(ev-bk-member? actor (ev/waitlist b occ-key))
|
|
(begin
|
|
(persist/append b (ev-booking-stream occ-key) :unwaitlist 0 {:actor actor})
|
|
{:status :left :actor actor})
|
|
{:status :not-waiting :actor actor})))
|
|
|
|
;; Cancel a seat and, if that frees capacity, auto-promote the head of the
|
|
;; waitlist (a confirmed booking). Returns the cancel result plus :promoted
|
|
;; (the actor promoted, or nil).
|
|
(define
|
|
ev/cancel-promote!
|
|
(fn
|
|
(b occ-key capacity actor)
|
|
(let
|
|
((c (ev/cancel! b occ-key actor)))
|
|
(if
|
|
(= (get c :status) :cancelled)
|
|
(let
|
|
((waiting (ev/waitlist b occ-key))
|
|
(seats (ev-booked-actors b occ-key)))
|
|
(if
|
|
(and (not (empty? waiting)) (< (len seats) capacity))
|
|
(let
|
|
((promoted (first waiting)))
|
|
(begin
|
|
(persist/append b (ev-booking-stream occ-key) :booking 0 {:actor promoted})
|
|
{:status :cancelled :actor actor :promoted promoted}))
|
|
{:status :cancelled :actor actor :promoted nil}))
|
|
c))))
|