From 62a14853024829c74392294b2f46b04107a3e0ab Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 19:24:35 +0000 Subject: [PATCH] =?UTF-8?q?persist:=20atomic=20batch=20append=20=E2=80=94?= =?UTF-8?q?=20contiguous=20block=20+=20transactional=20guard=20+=2010=20te?= =?UTF-8?q?sts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit batch.sx: persist/append-batch commits (type at data) specs as one contiguous block; persist/append-batch-expect checks the stream is still at expected before writing any event, so the batch is all-or-nothing under a concurrent writer (conflict is a value, not a partial write). 162/162. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/persist/batch.sx | 40 ++++++++++++ lib/persist/conformance.sh | 3 +- lib/persist/scoreboard.json | 5 +- lib/persist/scoreboard.md | 3 +- lib/persist/tests/batch.sx | 122 ++++++++++++++++++++++++++++++++++++ plans/persist-on-sx.md | 12 +++- 6 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 lib/persist/batch.sx create mode 100644 lib/persist/tests/batch.sx diff --git a/lib/persist/batch.sx b/lib/persist/batch.sx new file mode 100644 index 00000000..56a0eeed --- /dev/null +++ b/lib/persist/batch.sx @@ -0,0 +1,40 @@ +; persist/batch — commit several events to a stream as one contiguous block. +; Each spec is (type at data). Plain append-batch always appends; the -expect +; form is the transactional commit: it checks the stream is still at `expected` +; before writing ANY event, so a batch is all-or-nothing under a concurrent +; writer (conflict is a value, not a partial write). For an order + its line +; items, an audit entry + its reason, etc. Requires: lib/persist/log.sx. + +; append a list of (type at data) specs as one block; returns the stored events +; (a real cons-list, in order, with contiguous seqs) +(define + persist/append-batch + (fn + (b stream specs) + (reverse + (reduce + (fn + (acc spec) + (cons + (persist/append + b + stream + (first spec) + (nth spec 1) + (nth spec 2)) + acc)) + (list) + specs)))) + +; transactional batch: commit all specs only if the stream is still at expected, +; else return a conflict and write nothing +(define + persist/append-batch-expect + (fn + (b stream expected specs) + (let + ((actual (persist/last-seq b stream))) + (if + (= actual expected) + (persist/append-batch b stream specs) + {:actual actual :expected expected :conflict true})))) diff --git a/lib/persist/conformance.sh b/lib/persist/conformance.sh index 56aeb14a..e25b865b 100755 --- a/lib/persist/conformance.sh +++ b/lib/persist/conformance.sh @@ -13,7 +13,7 @@ if [ ! -x "$SX_SERVER" ]; then exit 1 fi -SUITES=(event log kv project subscribe concurrency snapshot compaction durable blob view cas catalog query recovery) +SUITES=(event log kv project subscribe concurrency snapshot compaction durable blob view cas catalog query batch recovery) OUT_JSON="lib/persist/scoreboard.json" OUT_MD="lib/persist/scoreboard.md" @@ -40,6 +40,7 @@ run_suite() { (load "lib/persist/view.sx") (load "lib/persist/catalog.sx") (load "lib/persist/query.sx") +(load "lib/persist/batch.sx") (load "lib/persist/subscribe.sx") (load "lib/persist/api.sx") (epoch 2) diff --git a/lib/persist/scoreboard.json b/lib/persist/scoreboard.json index df52825a..6c07cf9d 100644 --- a/lib/persist/scoreboard.json +++ b/lib/persist/scoreboard.json @@ -14,9 +14,10 @@ "cas": {"pass": 11, "fail": 0}, "catalog": {"pass": 10, "fail": 0}, "query": {"pass": 9, "fail": 0}, + "batch": {"pass": 10, "fail": 0}, "recovery": {"pass": 6, "fail": 0} }, - "total_pass": 152, + "total_pass": 162, "total_fail": 0, - "total": 152 + "total": 162 } diff --git a/lib/persist/scoreboard.md b/lib/persist/scoreboard.md index 5274bc6f..5fa603da 100644 --- a/lib/persist/scoreboard.md +++ b/lib/persist/scoreboard.md @@ -18,5 +18,6 @@ _Generated by `lib/persist/conformance.sh`_ | cas | 11 | 0 | 11 | | catalog | 10 | 0 | 10 | | query | 9 | 0 | 9 | +| batch | 10 | 0 | 10 | | recovery | 6 | 0 | 6 | -| **Total** | **152** | **0** | **152** | +| **Total** | **162** | **0** | **162** | diff --git a/lib/persist/tests/batch.sx b/lib/persist/tests/batch.sx new file mode 100644 index 00000000..79dd0b34 --- /dev/null +++ b/lib/persist/tests/batch.sx @@ -0,0 +1,122 @@ +; Extension — atomic batch append: contiguous seqs, transactional all-or-nothing. + +(persist-test + "batch assigns contiguous seqs" + (let + ((b (persist/open))) + (let + ((evs (persist/append-batch b "s" (list (list "a" 0 {}) (list "b" 0 {}) (list "c" 0 {}))))) + (list + (persist/event-seq (first evs)) + (persist/event-seq (nth evs 2))))) + (list 1 3)) +(persist-test + "batch returns events in order" + (let + ((b (persist/open))) + (let + ((evs (persist/append-batch b "s" (list (list "a" 0 {}) (list "b" 0 {}))))) + (list + (persist/event-type (first evs)) + (persist/event-type (nth evs 1))))) + (list "a" "b")) +(persist-test + "batch grows the stream by its size" + (let + ((b (persist/open))) + (begin + (persist/append-batch + b + "s" + (list + (list "a" 0 {}) + (list "b" 0 {}) + (list "c" 0 {}))) + (persist/count b "s"))) + 3) +(persist-test + "batch continues an existing stream" + (let + ((b (persist/open))) + (begin + (persist/append b "s" "x" 0 {}) + (let + ((evs (persist/append-batch b "s" (list (list "a" 0 {}) (list "b" 0 {}))))) + (persist/event-seq (first evs))))) + 2) +(persist-test + "empty batch is a no-op" + (let + ((b (persist/open))) + (begin (persist/append-batch b "s" (list)) (persist/count b "s"))) + 0) +(persist-test + "batch-expect with correct seq commits all" + (let + ((b (persist/open))) + (begin + (persist/append-batch-expect + b + "s" + 0 + (list + (list "a" 0 {}) + (list "b" 0 {}))) + (persist/count b "s"))) + 2) +(persist-test + "batch-expect with stale seq writes nothing" + (let + ((b (persist/open))) + (begin + (persist/append b "s" "x" 0 {}) + (persist/append-batch-expect + b + "s" + 0 + (list + (list "a" 0 {}) + (list "b" 0 {}))) + (persist/count b "s"))) + 1) +(persist-test + "batch-expect stale returns a conflict" + (let + ((b (persist/open))) + (begin + (persist/append b "s" "x" 0 {}) + (persist/conflict? + (persist/append-batch-expect + b + "s" + 0 + (list (list "a" 0 {})))))) + true) +(persist-test + "batch data is preserved" + (let + ((b (persist/open))) + (begin + (persist/append-batch + b + "order" + (list + (list "placed" 0 {:id 1}) + (list "line" 0 {:sku "x"}))) + (get + (persist/event-data (nth (persist/read b "order") 1)) + :sku))) + "x") +(persist-test + "batch works on the durable backend" + (let + ((db (persist/mock-durable (persist/mem-backend)))) + (begin + (persist/append-batch + db + "s" + (list + (list "a" 0 {}) + (list "b" 0 {}))) + (persist/last-seq db "s"))) + 2) diff --git a/plans/persist-on-sx.md b/plans/persist-on-sx.md index 34ba72a8..a8f94222 100644 --- a/plans/persist-on-sx.md +++ b/plans/persist-on-sx.md @@ -42,7 +42,7 @@ read models (feeds, indices, audit logs) update incrementally. ## Status (rolling) -`bash lib/persist/conformance.sh` → **152/152** (Phases 1–4 complete + extensions) +`bash lib/persist/conformance.sh` → **162/162** (Phases 1–4 complete + extensions) ## Ground rules @@ -162,11 +162,21 @@ over an in-process disk (the mock-IO harness). `read-window` (by `:at`), `read-by-type`, `read-where`, `count-where`. Pure reads for audit windows / type filters / since-cursors. +- [x] `batch.sx` — `persist/append-batch` commits a list of `(type at data)` + specs as one contiguous block; `persist/append-batch-expect` is transactional + (all-or-nothing guarded by optimistic concurrency). For an order + its line + items as one commit. + ## Consumers (post-foundation, not in scope here) feed/-log, flow store, mod/audit, search index, acl grants, identity sessions all become `persist` log or kv. Track each migration in that subsystem's plan. ## Progress log +- **Ext: atomic batch append (162/162).** `batch.sx` — `persist/append-batch` + commits `(type at data)` specs as one contiguous block (real cons-list, in + order); `persist/append-batch-expect` checks the stream is still at expected + before writing any event, so the batch is all-or-nothing under a concurrent + writer. 10 tests incl. conflict-writes-nothing + durable. - **Ext: read-side query helpers (152/152).** `query.sx` — `read-between` (seq range), `read-since`/`read-window` (by `:at`), `read-by-type`, `read-where`, `count-where`. Pure scans over `persist/read`; for ad-hoc relational queries