Files
rose-ash/lib/events/booking.sx
giles 80a2dee22f
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
events: capacity-safe transactional booking on persist + 24 tests
booking.sx: per-occurrence append-only stream, roster = replay. Booking
decided against an observed (roster, last-seq) snapshot, committed via
persist/append-expect — atomic check+append, no overbooking, no lock.
Explicit last-seat race test: two bookers, one booked, one conflict, roster
capped. Idempotent per actor. 97/97 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:44:43 +00:00

102 lines
3.6 KiB
Plaintext

;; lib/events/booking.sx — transactional, capacity-safe booking on persist.
;;
;; Each bookable occurrence has an append-only booking stream. A booking is an
;; event in that stream; the roster is the stream replayed. Capacity safety is
;; the contract: two bookers racing for the last seat must NEVER both succeed.
;; The guarantee is delegated to persist's optimistic concurrency —
;; `persist/append-expect` appends only if the stream's last-seq still equals
;; what the booker observed; otherwise it returns a conflict value and the
;; booker retries against the advanced roster. So the capacity check + append
;; are atomic at the persist boundary, with no overbooking and no lock.
;;
;; A booking decision is made against an OBSERVED snapshot (roster + last-seq):
;; two concurrent bookers each see the same free seat, both attempt, and
;; append-expect lets exactly one win — the loser gets a conflict it retries.
(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))))))
;; Booked actors for an occurrence, oldest first.
(define
ev-booked-actors
(fn
(b occ-key)
(map
(fn (e) (get (persist/event-data e) :actor))
(persist/read b (ev-booking-stream occ-key)))))
(define
ev-actor-booked?
(fn (b occ-key actor) (ev-bk-member? actor (ev-booked-actors b occ-key))))
(define
ev-booking-count
(fn (b occ-key) (persist/count b (ev-booking-stream occ-key))))
;; 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)))))
;; One booking attempt decided against an OBSERVED snapshot: `observed-actors`
;; (the roster the booker saw) and `expected` (the last-seq it saw). Returns
;; :already / :full / :booked / :conflict. :conflict means a concurrent append
;; landed since the snapshot — the caller must re-observe and retry.
(define
ev/book-with-observed
(fn
(b occ-key capacity actor observed-actors expected)
(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 :booking 0 {:actor actor})))
(if (persist/conflict? r) {:actual (persist/conflict-actual r) :actor actor :status :conflict} {:seat (+ (len observed-actors) 1) :actor actor :status :booked}))))))
;; Capacity-safe booking with retry. Observes a consistent (roster, last-seq)
;; snapshot, attempts, and retries on conflict (a concurrent booker won the
;; race) — bounded by capacity, since each successful append moves the roster
;; one seat toward full.
(define
ev/book!
(fn
(b occ-key capacity actor)
(let
((res (ev/book-with-observed b occ-key capacity actor (ev-booked-actors b occ-key) (persist/last-seq b (ev-booking-stream occ-key)))))
(if
(= (get res :status) :conflict)
(ev/book! b occ-key capacity actor)
res))))
;; The roster as a plain list of actors (oldest 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)))))