fed-sx-m2: Step 5c — peer-actors cache + 19 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s

New next/kernel/peer_actors.erl is the federation-side cache for
{PeerActorId, PeerActorState} entries. PeerAS is exactly the shape
envelope:verify_signature/2 reads (proplist with :public_keys), so
the inbox handler can pipe the cache hit straight into
pipeline:validate_inbound/3 from Step 5b.

Pure-functional API:
  new/0
  lookup/2(PeerId, State) -> {ok, PeerAS} | not_found
  store/3(PeerId, PeerAS, State) -> NewState
  evict/2(PeerId, State) -> NewState
  peers/1(State) -> [PeerId]
  lookup_or_fetch/3(PeerId, FetchFn, State)
      -> {ok, PeerAS, NewState}      cache hit returns unchanged State,
                                     miss stores FetchFn result.
      | {error, Reason, State}        FetchFn failure preserves cache.
      | {error, {bad_fetch_return, X}, State}

FetchFn contract: (PeerId) -> {ok, PeerAS} | {error, Reason}.
Failed fetches do NOT poison the cache so callers can retry on
transient HTTP failures.

gen_server wrapper (registered name peer_actors):
  start_link/0,1   start_link/1 accepts initial proplist for fixtures
  stop/0
  lookup_srv/1
  store_srv/2
  lookup_or_fetch_srv/2
  peers_srv/0
  evict_srv/1

handle_call dispatches mirror the pure-fn paths exactly.

The actual HTTP-GET fetch implementation (peer's actor doc -> peer
AS proplist) is Step 5d's responsibility — for 5c, FetchFn is just
the contract callers fill in.

19/19 in next/tests/peer_actors.sh:
  - new/0 -> []
  - lookup miss -> not_found
  - store + lookup round-trip
  - peers/1 in insertion order
  - evict + evict-unknown no-op
  - lookup_or_fetch miss invokes FetchFn, hits cache after
  - lookup_or_fetch hit skips FetchFn (verified by tombstone fn)
  - fetch error preserves cache state
  - bad fetch return shape captured
  - gen_server start_link + miss/hit/fetch/evict round-trips
  - start_link/1 pre-populates cache from initial state

Conformance 761/761. 139/139 across 9 Step-5-adjacent suites
(inbox_pipeline, inbox_bucket, pipeline_signature, registry_server,
projection_server, nx_kernel_multi, bootstrap_start, http_publish,
smoke_app_pure, plus the new peer_actors).
This commit is contained in:
2026-06-06 16:36:19 +00:00
parent d103ecb863
commit d481af5791
3 changed files with 334 additions and 5 deletions

165
next/tests/peer_actors.sh Executable file
View File

@@ -0,0 +1,165 @@
#!/usr/bin/env bash
# next/tests/peer_actors.sh — m2 Step 5c test.
#
# Peer-actors cache for the federation inbox handler. Tracks
# {PeerActorId, PeerActorState} pairs so signature verification
# can be done against a peer's :public_keys without re-fetching
# their actor doc on every inbound. lookup_or_fetch/3 is the
# load-bearing entry point: cache hit returns cached AS, miss
# invokes the caller-supplied FetchFn and stores its result.
set -uo pipefail
cd "$(git rev-parse --show-toplevel)"
SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}"
if [ ! -x "$SX_SERVER" ]; then
SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe"
fi
if [ ! -x "$SX_SERVER" ]; then
echo "ERROR: sx_server.exe not found." >&2
exit 1
fi
VERBOSE="${1:-}"
PASS=0; FAIL=0; ERRORS=""
TMPFILE=$(mktemp); trap "rm -f $TMPFILE" EXIT
SETUP='K1 = <<1,2,3,4>>, BobAS = [{public_keys,[[{id,k1},{created,0},{value,K1}]]}], K2 = <<5,6,7,8>>, CarolAS = [{public_keys,[[{id,k1},{created,0},{value,K2}]]}], OkFetch = fun(bob) -> {ok, BobAS}; (carol) -> {ok, CarolAS}; (_) -> {error, not_found} end,'
cat > "$TMPFILE" <<EPOCHS
(epoch 1)
(load "lib/erlang/tokenizer.sx")
(load "lib/erlang/parser.sx")
(load "lib/erlang/parser-core.sx")
(load "lib/erlang/parser-expr.sx")
(load "lib/erlang/parser-module.sx")
(load "lib/erlang/transpile.sx")
(load "lib/erlang/runtime.sx")
(load "lib/erlang/vm/dispatcher.sx")
(epoch 2)
(eval "(er-load-gen-server!)")
(epoch 3)
(eval "(get (erlang-load-module (file-read \"next/kernel/peer_actors.erl\")) :name)")
;; new/0 returns []
(epoch 10)
(eval "(get (erlang-eval-ast \"peer_actors:new() =:= []\") :name)")
;; lookup on empty cache returns not_found
(epoch 11)
(eval "(get (erlang-eval-ast \"peer_actors:lookup(bob, peer_actors:new()) =:= not_found\") :name)")
;; store + lookup round-trip
(epoch 12)
(eval "(get (erlang-eval-ast \"${SETUP} S = peer_actors:store(bob, BobAS, peer_actors:new()), peer_actors:lookup(bob, S) =:= {ok, BobAS}\") :name)")
;; peers/1 lists cached peer IDs in insertion order
(epoch 13)
(eval "(get (erlang-eval-ast \"${SETUP} S0 = peer_actors:new(), S1 = peer_actors:store(bob, BobAS, S0), S2 = peer_actors:store(carol, CarolAS, S1), peer_actors:peers(S2) =:= [bob, carol]\") :name)")
;; evict removes the entry
(epoch 14)
(eval "(get (erlang-eval-ast \"${SETUP} S0 = peer_actors:store(bob, BobAS, peer_actors:new()), S1 = peer_actors:evict(bob, S0), peer_actors:lookup(bob, S1) =:= not_found\") :name)")
;; evict unknown peer is a no-op
(epoch 15)
(eval "(get (erlang-eval-ast \"peer_actors:evict(ghost, peer_actors:new()) =:= []\") :name)")
;; lookup_or_fetch miss invokes FetchFn and stores the result
(epoch 16)
(eval "(get (erlang-eval-ast \"${SETUP} case peer_actors:lookup_or_fetch(bob, OkFetch, peer_actors:new()) of {ok, BobAS, [{bob, BobAS}]} -> ok; _ -> bad end\") :name)")
;; lookup_or_fetch hit returns cached value without invoking FetchFn
(epoch 17)
(eval "(get (erlang-eval-ast \"${SETUP} TombstoneFetch = fun(_) -> {error, should_not_be_called} end, S = peer_actors:store(bob, BobAS, peer_actors:new()), case peer_actors:lookup_or_fetch(bob, TombstoneFetch, S) of {ok, BobAS, S} -> ok; _ -> bad end\") :name)")
;; lookup_or_fetch error from FetchFn does NOT store anything
(epoch 18)
(eval "(get (erlang-eval-ast \"${SETUP} BadFetch = fun(_) -> {error, http_404} end, case peer_actors:lookup_or_fetch(ghost, BadFetch, peer_actors:new()) of {error, http_404, []} -> ok; _ -> bad end\") :name)")
;; lookup_or_fetch bad return shape is captured
(epoch 19)
(eval "(get (erlang-eval-ast \"${SETUP} JunkFetch = fun(_) -> garbage end, case peer_actors:lookup_or_fetch(ghost, JunkFetch, peer_actors:new()) of {error, {bad_fetch_return, garbage}, []} -> ok; _ -> bad end\") :name)")
;; gen_server: start_link + lookup_srv miss returns not_found
(epoch 20)
(eval "(get (erlang-eval-ast \"peer_actors:start_link(), peer_actors:lookup_srv(bob) =:= not_found\") :name)")
;; gen_server: store_srv + lookup_srv round-trip
(epoch 21)
(eval "(get (erlang-eval-ast \"${SETUP} peer_actors:start_link(), peer_actors:store_srv(bob, BobAS), peer_actors:lookup_srv(bob) =:= {ok, BobAS}\") :name)")
;; gen_server: peers_srv reflects stored entries
(epoch 22)
(eval "(get (erlang-eval-ast \"${SETUP} peer_actors:start_link(), peer_actors:store_srv(bob, BobAS), peer_actors:store_srv(carol, CarolAS), peer_actors:peers_srv() =:= [bob, carol]\") :name)")
;; gen_server: lookup_or_fetch_srv miss invokes FetchFn + caches
(epoch 23)
(eval "(get (erlang-eval-ast \"${SETUP} peer_actors:start_link(), R = peer_actors:lookup_or_fetch_srv(bob, OkFetch), R =:= {ok, BobAS} andalso peer_actors:peers_srv() =:= [bob]\") :name)")
;; gen_server: subsequent lookup uses cached value (FetchFn would error)
(epoch 24)
(eval "(get (erlang-eval-ast \"${SETUP} TombstoneFetch = fun(_) -> {error, should_not_be_called} end, peer_actors:start_link(), peer_actors:store_srv(bob, BobAS), R = peer_actors:lookup_or_fetch_srv(bob, TombstoneFetch), R =:= {ok, BobAS}\") :name)")
;; gen_server: fetch error doesn't poison cache
(epoch 25)
(eval "(get (erlang-eval-ast \"${SETUP} BadFetch = fun(_) -> {error, http_404} end, peer_actors:start_link(), R = peer_actors:lookup_or_fetch_srv(ghost, BadFetch), R =:= {error, http_404} andalso peer_actors:peers_srv() =:= []\") :name)")
;; gen_server: evict_srv removes the entry
(epoch 26)
(eval "(get (erlang-eval-ast \"${SETUP} peer_actors:start_link(), peer_actors:store_srv(bob, BobAS), peer_actors:evict_srv(bob), peer_actors:lookup_srv(bob) =:= not_found\") :name)")
;; Initial-state argument: start_link/1 pre-populates the cache
(epoch 27)
(eval "(get (erlang-eval-ast \"${SETUP} peer_actors:start_link([{bob, BobAS}]), peer_actors:lookup_srv(bob) =:= {ok, BobAS}\") :name)")
EPOCHS
OUTPUT=$(timeout 240 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
check() {
local epoch="$1" desc="$2" expected="$3"
local actual
actual=$(echo "$OUTPUT" | awk -v e="$epoch" '
$0 ~ "^\\(ok-len " e " " { getline; print; exit }
$0 ~ "^\\(ok " e " " { print; exit }
$0 ~ "^\\(error " e " " { print; exit }
')
[ -z "$actual" ] && actual="<no output for epoch $epoch>"
if echo "$actual" | grep -qF -- "$expected"; then
PASS=$((PASS+1))
[ "$VERBOSE" = "-v" ] && echo " ok $desc"
else
FAIL=$((FAIL+1))
ERRORS+=" FAIL [$desc] (epoch $epoch) expected: $expected | actual: $actual
"
fi
}
check 3 "peer_actors module loaded" "peer_actors"
check 10 "new/0 -> []" "true"
check 11 "lookup on empty -> not_found" "true"
check 12 "store + lookup round-trip" "true"
check 13 "peers/1 lists in insertion order" "true"
check 14 "evict removes entry" "true"
check 15 "evict unknown -> no-op" "true"
check 16 "lookup_or_fetch miss fetches" "ok"
check 17 "lookup_or_fetch hit skips fetch" "ok"
check 18 "fetch error doesn't store" "ok"
check 19 "bad fetch return shape captured" "ok"
check 20 "gen_server lookup miss" "true"
check 21 "gen_server store + lookup" "true"
check 22 "gen_server peers_srv lists" "true"
check 23 "gen_server fetch + cache" "true"
check 24 "gen_server cached skips fetch" "true"
check 25 "gen_server fetch error pristine" "true"
check 26 "gen_server evict removes" "true"
check 27 "start_link/1 pre-populates" "true"
TOTAL=$((PASS+FAIL))
if [ $FAIL -eq 0 ]; then
echo "ok $PASS/$TOTAL next/tests/peer_actors.sh passed"
else
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
echo "$ERRORS"
fi
[ $FAIL -eq 0 ]