events: booking cancellation + seat release + 13 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 24s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 24s
Booking stream carries :booking/:cancel events; live roster is the folded replay so cancelling frees a seat and capacity reopens. ev/cancel! (retrying append-expect), no-op on unbooked, cancelled actor may re-book. Capacity count is folded roster size. 110/110 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,19 @@
|
||||
;; 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.
|
||||
;; Each bookable occurrence has an append-only stream of :booking / :cancel
|
||||
;; events. The live roster is the stream FOLDED in order — a booking adds an
|
||||
;; actor, a cancel removes them — so a cancellation frees a seat and capacity
|
||||
;; reopens. 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 writer observed; otherwise it returns
|
||||
;; a conflict value and the writer retries against the advanced roster. So the
|
||||
;; capacity check + append are atomic at the persist boundary, 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.
|
||||
;; A booking/cancel decision is made against an OBSERVED snapshot (folded
|
||||
;; 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)))
|
||||
|
||||
@@ -33,22 +35,43 @@
|
||||
((= (first xs) x) i)
|
||||
(else (ev-bk-index (rest xs) x (+ i 1))))))
|
||||
|
||||
;; Booked actors for an occurrence, oldest first.
|
||||
(define ev-bk-remove (fn (xs a) (filter (fn (x) (not (= x a))) xs)))
|
||||
(define ev-bk-append (fn (xs a) (append xs (list a))))
|
||||
|
||||
;; Fold a booking stream into the live roster (join order, cancels removed).
|
||||
(define
|
||||
ev-fold-roster
|
||||
(fn
|
||||
(events)
|
||||
(reduce
|
||||
(fn
|
||||
(acc e)
|
||||
(let
|
||||
((typ (persist/event-type e))
|
||||
(actor (get (persist/event-data e) :actor)))
|
||||
(cond
|
||||
((= typ :booking)
|
||||
(if (ev-bk-member? actor acc) acc (ev-bk-append acc actor)))
|
||||
((= typ :cancel) (ev-bk-remove acc actor))
|
||||
(else acc))))
|
||||
(list)
|
||||
events)))
|
||||
|
||||
;; Live roster (actors currently holding a seat), oldest active 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)))))
|
||||
(ev-fold-roster (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))))
|
||||
|
||||
;; Live seat count (folded roster size — not the physical event count).
|
||||
(define
|
||||
ev-booking-count
|
||||
(fn (b occ-key) (persist/count b (ev-booking-stream occ-key))))
|
||||
(fn (b occ-key) (len (ev-booked-actors b occ-key))))
|
||||
|
||||
;; 1-based seat number for an actor on the roster (0 if not booked).
|
||||
(define
|
||||
@@ -77,8 +100,7 @@
|
||||
|
||||
;; 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.
|
||||
;; race) — bounded by capacity.
|
||||
(define
|
||||
ev/book!
|
||||
(fn
|
||||
@@ -90,7 +112,29 @@
|
||||
(ev/book! b occ-key capacity actor)
|
||||
res))))
|
||||
|
||||
;; The roster as a plain list of actors (oldest first).
|
||||
;; One cancellation attempt against an observed snapshot. :not-booked when the
|
||||
;; actor holds no seat; :conflict on a racing append (retry); else :cancelled.
|
||||
(define
|
||||
ev/cancel-with-observed
|
||||
(fn
|
||||
(b occ-key actor observed-actors expected)
|
||||
(cond
|
||||
((not (ev-bk-member? actor observed-actors)) {:actor actor :status :not-booked})
|
||||
(else
|
||||
(let
|
||||
((r (persist/append-expect b (ev-booking-stream occ-key) expected :cancel 0 {:actor actor})))
|
||||
(if (persist/conflict? r) {:actual (persist/conflict-actual r) :actor actor :status :conflict} {:actor actor :status :cancelled}))))))
|
||||
|
||||
;; Cancel an actor's seat, freeing capacity. Retries on conflict.
|
||||
(define
|
||||
ev/cancel!
|
||||
(fn
|
||||
(b occ-key actor)
|
||||
(let
|
||||
((res (ev/cancel-with-observed b occ-key actor (ev-booked-actors b occ-key) (persist/last-seq b (ev-booking-stream occ-key)))))
|
||||
(if (= (get res :status) :conflict) (ev/cancel! b occ-key actor) res))))
|
||||
|
||||
;; 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.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"lang": "events",
|
||||
"total_passed": 97,
|
||||
"total_passed": 110,
|
||||
"total_failed": 0,
|
||||
"total": 97,
|
||||
"total": 110,
|
||||
"suites": [
|
||||
{"name":"calendar","passed":37,"failed":0,"total":37},
|
||||
{"name":"availability","passed":22,"failed":0,"total":22},
|
||||
{"name":"api","passed":14,"failed":0,"total":14},
|
||||
{"name":"booking","passed":24,"failed":0,"total":24}
|
||||
{"name":"booking","passed":37,"failed":0,"total":37}
|
||||
],
|
||||
"generated": "2026-06-07T01:44:19+00:00"
|
||||
"generated": "2026-06-07T02:09:36+00:00"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# events scoreboard
|
||||
|
||||
**97 / 97 passing** (0 failure(s)).
|
||||
**110 / 110 passing** (0 failure(s)).
|
||||
|
||||
| Suite | Passed | Total | Status |
|
||||
|-------|--------|-------|--------|
|
||||
| calendar | 37 | 37 | ok |
|
||||
| availability | 22 | 22 | ok |
|
||||
| api | 14 | 14 | ok |
|
||||
| booking | 24 | 24 | ok |
|
||||
| booking | 37 | 37 | ok |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
;; lib/events/tests/booking.sx — capacity-safe transactional booking.
|
||||
;; lib/events/tests/booking.sx — capacity-safe transactional booking + cancel.
|
||||
|
||||
(define ev-bk-pass 0)
|
||||
(define ev-bk-fail 0)
|
||||
@@ -166,7 +166,69 @@
|
||||
(ev-bk-check!
|
||||
"room is now full"
|
||||
(ev/seats-left b "room" 3)
|
||||
0)))))))))
|
||||
0))))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "cx" 2 (quote a))
|
||||
(ev/book! b "cx" 2 (quote c))
|
||||
(ev-bk-check!
|
||||
"occupied to capacity before cancel"
|
||||
(ev/seats-left b "cx" 2)
|
||||
0)
|
||||
(ev-bk-check!
|
||||
"booking when full (pre-cancel) is refused"
|
||||
(get (ev/book! b "cx" 2 (quote d)) :status)
|
||||
:full)
|
||||
(ev-bk-check!
|
||||
"cancel reports cancelled"
|
||||
(get (ev/cancel! b "cx" (quote a)) :status)
|
||||
:cancelled)
|
||||
(ev-bk-check!
|
||||
"cancel removes actor from roster"
|
||||
(ev/roster b "cx")
|
||||
(list (quote c)))
|
||||
(ev-bk-check!
|
||||
"cancel frees a seat"
|
||||
(ev/seats-left b "cx" 2)
|
||||
1)
|
||||
(ev-bk-check!
|
||||
"freed seat is bookable again"
|
||||
(get (ev/book! b "cx" 2 (quote d)) :status)
|
||||
:booked)
|
||||
(ev-bk-check!
|
||||
"roster after rebook is c,d"
|
||||
(ev/roster b "cx")
|
||||
(list (quote c) (quote d)))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "ce" 3 (quote a))
|
||||
(ev-bk-check!
|
||||
"cancelling an unbooked actor is a no-op"
|
||||
(get (ev/cancel! b "ce" (quote z)) :status)
|
||||
:not-booked)
|
||||
(ev-bk-check!
|
||||
"no-op cancel leaves roster intact"
|
||||
(ev/roster b "ce")
|
||||
(list (quote a)))
|
||||
(ev/cancel! b "ce" (quote a))
|
||||
(ev-bk-check!
|
||||
"double cancel is not-booked the second time"
|
||||
(get (ev/cancel! b "ce" (quote a)) :status)
|
||||
:not-booked)
|
||||
(ev-bk-check!
|
||||
"empty roster after cancel"
|
||||
(ev/roster b "ce")
|
||||
(list))
|
||||
(ev-bk-check!
|
||||
"cancelled actor may re-book"
|
||||
(get (ev/book! b "ce" 3 (quote a)) :status)
|
||||
:booked)
|
||||
(ev-bk-check!
|
||||
"re-booked actor back on roster"
|
||||
(ev/roster b "ce")
|
||||
(list (quote a))))))))
|
||||
|
||||
(define
|
||||
ev-booking-tests-run!
|
||||
|
||||
@@ -18,7 +18,7 @@ capacity rules, transactional booking, and a flow-driven notification dispatcher
|
||||
|
||||
## Status (rolling)
|
||||
|
||||
`bash lib/events/conformance.sh` → **97/97** (Phase 1 complete + Phase 2 capacity-safe booking)
|
||||
`bash lib/events/conformance.sh` → **110/110** (Phase 1 complete + Phase 2 booking + cancellation)
|
||||
|
||||
## Ground rules
|
||||
|
||||
@@ -64,7 +64,7 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ──
|
||||
## Phase 2 — Ticketing + booking
|
||||
- [x] capacity rules; transactional booking → `persist` (no overbooking)
|
||||
- [ ] wire `booking.sx` into `api.sx` (persist-backed `ev/book`)
|
||||
- [ ] cancellation (tombstone events) + seat release
|
||||
- [x] cancellation (tombstone events) + seat release
|
||||
- [ ] paid tickets compose with `commerce` order flow
|
||||
- [x] tests: capacity edge, double-book guard, conflict detection
|
||||
|
||||
@@ -81,6 +81,13 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ──
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-06-07 — Cancellation + seat release. Booking stream now carries
|
||||
:booking / :cancel events; the live roster is the FOLDED replay (book adds,
|
||||
cancel removes) so capacity reopens when a seat is freed. `ev/cancel!`
|
||||
(retrying, append-expect), `ev/cancel-with-observed`. Edge cases: cancelling
|
||||
an unbooked actor → :not-booked (no-op), double cancel → :not-booked,
|
||||
cancelled actor may re-book. Capacity count is the folded roster size, not
|
||||
the physical event count. +13 tests, 110/110 green.
|
||||
- 2026-06-07 — **Phase 2 start: capacity-safe booking.** `booking.sx`: one
|
||||
append-only persist stream per occurrence; roster = stream replayed. Booking
|
||||
decisions made against an OBSERVED (roster, last-seq) snapshot, committed via
|
||||
|
||||
Reference in New Issue
Block a user