perf: add O(returned) raft-log-from log accessor to std/raft
ober
60f51d6b8edf5d3d577adbaf5388541411a473ef
--- a/data/api-signatures.sexp +++ b/data/api-signatures.sexp @@ -5529,8 +5529,9 @@ ("exports" "log-entry-command" "log-entry-index" "log-entry-term" "log-entry?" "make-raft-cluster" "make-raft-node" "raft-cluster-leader" "raft-cluster-nodes" - "raft-commit-index" "raft-leader?" "raft-log" - "raft-message->wire" "raft-message-from-wire" + "raft-commit-index" "raft-leader?" "raft-log" + "raft-log-from" "raft-log-last-index" + "raft-message->wire" "raft-message-from-wire" "raft-message-wire-self-test" "raft-node-add-peer!" "raft-node-inbox" "raft-node-peers" "raft-node-peers-set!" "raft-propose!" "raft-start!" "raft-state" "raft-stop!" @@ -15198,8 +15199,10 @@ ("raft-cluster-leader" "(std raft)") ("raft-cluster-nodes" "(std raft)") ("raft-commit-index" "(std raft)") - ("raft-leader?" "(std raft)") ("raft-log" "(std raft)") - ("raft-message->wire" "(std raft)") + ("raft-leader?" "(std raft)") ("raft-log" "(std raft)") + ("raft-log-from" "(std raft)") + ("raft-log-last-index" "(std raft)") + ("raft-message->wire" "(std raft)") ("raft-message-from-wire" "(std raft)") ("raft-message-wire-self-test" "(std raft)") ("raft-node-add-peer!" "(std raft)") --- a/data/changelog.sexp +++ b/data/changelog.sexp @@ -2,7 +2,19 @@ . "Machine-readable changelog of Jerboa API drift. Consumers (LLM tooling, lints, jerboa_verify) use this to invalidate stale recommendations and to suggest migrations when a symbol is renamed or relocated.") ("entries" - (("added" ("JERBOA_MCP_LOG" "JERBOA_MCP_LOG_FILE" + (("added" ("raft-log-from" "raft-log-last-index")) + ("date" . "2026-07-22") + ("modules_added") + ("moved") + ("notes" + . + "(std raft) adds raft-log-from and raft-log-last-index, backed by a parallel log-index vector kept in sync on every log mutation (append/truncate). raft-log-from returns the log suffix from a start index in O(returned) time so consumers such as the jerboa-db apply fiber no longer scan the full raft log on every 50 ms poll. The consensus log list stays the source of truth; the index vector is read-only inspection state.") + ("removed") + ("renamed") + ("tier_changes") + ("tools_added") + ("version" . "v0.2.5")) + (("added" ("JERBOA_MCP_LOG" "JERBOA_MCP_LOG_FILE" "JERBOA_MCP_SLOW_MS")) ("date" . "2026-07-19") ("modules_added") --- a/lib/std/raft.ss +++ b/lib/std/raft.ss @@ -23,6 +23,8 @@ raft-term raft-state raft-log + raft-log-from + raft-log-last-index raft-commit-index log-entry? log-entry-index @@ -333,6 +335,7 @@ (mutable current-term) ;; current term (mutable voted-for) ;; candidate voted for in current term (#f if none) (mutable log) ;; list of log-entry (index 1-based, stored in order) + (mutable log-vec) ;; vector of log-entry indexed by (log-index - 1); read accelerator for raft-log-from (kept in sync with log by set-log!) (mutable commit-index) ;; highest committed log index (mutable last-applied) ;; highest applied log index (mutable state) ;; 'follower | 'candidate | 'leader @@ -355,6 +358,7 @@ 0 ;; current-term #f ;; voted-for '() ;; log + (vector) ;; log-vec 0 ;; commit-index 0 ;; last-applied 'follower ;; state @@ -378,8 +382,41 @@ (def (raft-commit-index node) (raft-node-commit-index node)) (def (raft-leader? node) (eq? (raft-node-state node) 'leader)) + ;; raft-log-from : raft-node integer -> list + ;; Returns the log entries whose index is >= start-index, in log order, in + ;; O(returned) time. Uses the parallel log-vec index rather than scanning the + ;; log list from the head, so callers that only need the unapplied tail (e.g. + ;; the jerboa-db apply fiber) avoid an O(log-size) scan on every poll. + ;; Log indices are 1-based and contiguous, so vector position = index - 1. + (def (raft-log-from node start-index) + (let* ([vec (raft-node-log-vec node)] + [len (vector-length vec)] + [start-pos (if (< start-index 1) 0 (- start-index 1))]) + (if (>= start-pos len) + '() + (let loop ([i (- len 1)] [acc '()]) + (if (< i start-pos) + acc + (loop (- i 1) (cons (vector-ref vec i) acc))))))) + + ;; raft-log-last-index : raft-node -> integer + ;; Highest log index (0 when the log is empty), in O(1). Because indices are + ;; contiguous from 1, the last index equals the entry count. + (def (raft-log-last-index node) + (vector-length (raft-node-log-vec node))) + ;; ========== Utility Helpers ========== + ;; set-log! : raft-node list -> void + ;; Single mutation point for the consensus log. Replaces the log list AND + ;; rebuilds the parallel log-vec index so raft-log-from / raft-log-last-index + ;; stay consistent on every append / truncate. All log mutations MUST go + ;; through here. The log list remains the consensus source of truth; log-vec + ;; is only read by the inspection accessors above. + (def (set-log! node entries) + (raft-node-log-set! node entries) + (raft-node-log-vec-set! node (list->vector entries))) + (def (log-last-index node) (let ([log (raft-node-log node)]) (if (null? log) 0 (log-entry-index (car (last-pair log)))))) @@ -703,7 +740,7 @@ (<= (log-entry-index e) prev-idx)) existing)]) ;; Append entries from leader - (raft-node-log-set! node (append keep entries)))) + (set-log! node (append keep entries)))) ;; Update commit index (when (> leader-commit (raft-node-commit-index node)) (raft-node-commit-index-set! node @@ -753,7 +790,7 @@ new-index (raft-node-current-term node) cmd)]) - (raft-node-log-set! node + (set-log! node (append (raft-node-log node) (list entry))) (when reply-ch (channel-put reply-ch (cons 'ok new-index))) --- a/tests/test-raft.ss +++ b/tests/test-raft.ss @@ -139,5 +139,68 @@ #t) (raft-stop! node))) +;; Test 11: raft-log-from returns the correct suffix and raft-log-last-index is O(1)-correct +(let ([cluster (make-raft-cluster 1)]) + (let ([node (car (raft-cluster-nodes cluster))]) + (raft-start! node) + (sleep (make-time 'time-duration 400000000 0)) + (when (raft-leader? node) + (raft-propose! node 'cmd-a) + (raft-propose! node 'cmd-b) + (raft-propose! node 'cmd-c) + (sleep (make-time 'time-duration 100000000 0)) + (test "log-from-1-is-full-log" + (map log-entry-index (raft-log-from node 1)) + (map log-entry-index (raft-log node))) + (test "log-from-2-is-suffix" + (map log-entry-index (raft-log-from node 2)) + '(2 3)) + (test "log-from-3-is-tail" + (map log-entry-index (raft-log-from node 3)) + '(3)) + (test "log-from-past-end-empty" + (raft-log-from node 4) + '()) + (test "log-from-0-is-full-log" + (map log-entry-index (raft-log-from node 0)) + '(1 2 3)) + (test "log-from-suffix-commands" + (map log-entry-command (raft-log-from node 2)) + '(cmd-b cmd-c)) + (test "log-last-index" + (raft-log-last-index node) + 3)) + (raft-stop! node))) + +;; Test 12: raft-log-from stays consistent with raft-log on every node of a +;; running cluster. Followers build their log via the AppendEntries +;; truncate+append path, so this exercises set-log! on both mutation sites. +(let ([cluster (make-raft-cluster 3)]) + (for-each raft-start! (raft-cluster-nodes cluster)) + (let wait ([attempts 0]) + (if (or (>= attempts 20) (raft-cluster-leader cluster)) + (void) + (begin (sleep (make-time 'time-duration 50000000 0)) (wait (+ attempts 1))))) + (let ([leader (raft-cluster-leader cluster)]) + (when leader + (raft-propose! leader 'tx-1) + (raft-propose! leader 'tx-2) + (raft-propose! leader 'tx-3) + (sleep (make-time 'time-duration 500000000 0)) + (for-each + (lambda (n) + (test "cluster-log-from-consistent" + (map log-entry-index (raft-log-from n 1)) + (map log-entry-index (raft-log n))) + (test "cluster-log-from-suffix-consistent" + (map log-entry-index (raft-log-from n 2)) + (map log-entry-index + (filter (lambda (e) (>= (log-entry-index e) 2)) (raft-log n)))) + (test "cluster-log-last-index-consistent" + (raft-log-last-index n) + (length (raft-log n)))) + (raft-cluster-nodes cluster)))) + (for-each raft-stop! (raft-cluster-nodes cluster))) + (printf "~%Results: ~a passed, ~a failed~%" pass fail) (when (> fail 0) (exit 1))