From 9adeff14314e2c6efd5419395bd0e1da8288bf76 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 02:09:58 +0000 Subject: [PATCH] events: booking cancellation + seat release + 13 tests 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) --- lib/events/booking.sx | 82 ++++++++++++++++++++++++++++--------- lib/events/scoreboard.json | 8 ++-- lib/events/scoreboard.md | 4 +- lib/events/tests/booking.sx | 66 ++++++++++++++++++++++++++++- plans/events-on-sx.md | 11 ++++- 5 files changed, 142 insertions(+), 29 deletions(-) diff --git a/lib/events/booking.sx b/lib/events/booking.sx index ece9b0c4..1e3d9196 100644 --- a/lib/events/booking.sx +++ b/lib/events/booking.sx @@ -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. diff --git a/lib/events/scoreboard.json b/lib/events/scoreboard.json index 744e326e..21cf9dfe 100644 --- a/lib/events/scoreboard.json +++ b/lib/events/scoreboard.json @@ -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" } diff --git a/lib/events/scoreboard.md b/lib/events/scoreboard.md index 74b381fb..e30bb3b6 100644 --- a/lib/events/scoreboard.md +++ b/lib/events/scoreboard.md @@ -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 | diff --git a/lib/events/tests/booking.sx b/lib/events/tests/booking.sx index e030f04d..6a0f1666 100644 --- a/lib/events/tests/booking.sx +++ b/lib/events/tests/booking.sx @@ -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! diff --git a/plans/events-on-sx.md b/plans/events-on-sx.md index f5b1544f..648a3681 100644 --- a/plans/events-on-sx.md +++ b/plans/events-on-sx.md @@ -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