diff --git a/next/kernel/nx_kernel.erl b/next/kernel/nx_kernel.erl index bc70bf5a..e73002b2 100644 --- a/next/kernel/nx_kernel.erl +++ b/next/kernel/nx_kernel.erl @@ -15,7 +15,10 @@ %% gen_server API -export([start_link/3, publish/1, query/0, log_tip/0, - with_projections/1, stop/0]). + with_projections/1, stop/0, + add_actor/3, publish_to/2, log_tip_for/1, + actors/0, state_for/1, bucket_for/1, + with_projections_for/2]). -export([init/1, handle_call/3, handle_cast/2, handle_info/2]). %% Kernel orchestrator — the long-lived runtime state held by the @@ -242,9 +245,11 @@ set(K, V, [P | Rest]) -> [P | set(K, V, Rest)]. %% macro, spawned processes don't persist across separate %% erlang-eval-ast calls — tests inline start_link with operations. %% -%% Step 1a (m2) keeps the gen_server single-actor; multi-actor -%% gen_server calls (publish_to/2, log_tip_for/1, ...) land in -%% iteration 1b. +%% Step 1b (m2) adds multi-actor gen_server calls: +%% add_actor/3, publish_to/2, log_tip_for/1, actors/0, state_for/1, +%% with_projections_for/2 — all delegating to the pure-functional +%% bucket APIs. Existing single-actor calls (publish/1, log_tip/0, +%% with_projections/1) continue to route through bucket 0. start_link(ActorId, KeySpec, ActorStateProplist) -> Pid = gen_server:start_link(nx_kernel, @@ -269,6 +274,29 @@ log_tip() -> with_projections(Names) -> gen_server:call(nx_kernel, {set_projections, Names}). +%% Step 1b — multi-actor gen_server calls. + +add_actor(ActorId, KeySpec, AS) -> + gen_server:call(nx_kernel, {add_actor, ActorId, KeySpec, AS}). + +publish_to(ActorId, Request) -> + gen_server:call(nx_kernel, {publish_to, ActorId, Request}). + +log_tip_for(ActorId) -> + gen_server:call(nx_kernel, {log_tip_for, ActorId}). + +actors() -> + gen_server:call(nx_kernel, get_actors). + +state_for(ActorId) -> + gen_server:call(nx_kernel, {state_for, ActorId}). + +bucket_for(ActorId) -> + gen_server:call(nx_kernel, {bucket_for, ActorId}). + +with_projections_for(ActorId, Names) -> + gen_server:call(nx_kernel, {set_projections_for, ActorId, Names}). + %% gen_server callbacks init([ActorId, KeySpec, AS]) -> @@ -286,7 +314,30 @@ handle_call(get_state, _From, State) -> handle_call(get_log_tip, _From, State) -> {reply, log_tip(State), State}; handle_call({set_projections, Names}, _From, State) -> - {reply, ok, with_projections(Names, State)}. + {reply, ok, with_projections(Names, State)}; +handle_call({add_actor, ActorId, KeySpec, AS}, _From, State) -> + case add_actor(ActorId, KeySpec, AS, State) of + {ok, NewState} -> {reply, ok, NewState}; + {error, Reason} -> {reply, {error, Reason}, State} + end; +handle_call({publish_to, ActorId, Request}, _From, State) -> + case publish(ActorId, Request, State) of + {ok, Result, NewState} -> {reply, {ok, Result}, NewState}; + {error, Reason, SameState} -> {reply, {error, Reason}, SameState} + end; +handle_call({log_tip_for, ActorId}, _From, State) -> + {reply, actor_log_tip(ActorId, State), State}; +handle_call(get_actors, _From, State) -> + {reply, actors(State), State}; +handle_call({state_for, ActorId}, _From, State) -> + {reply, actor_state(ActorId, State), State}; +handle_call({bucket_for, ActorId}, _From, State) -> + {reply, actor_bucket(ActorId, State), State}; +handle_call({set_projections_for, ActorId, Names}, _From, State) -> + case with_actor_projections(ActorId, Names, State) of + {ok, NewState} -> {reply, ok, NewState}; + {error, Reason} -> {reply, {error, Reason}, State} + end. handle_cast(_, S) -> {noreply, S}. diff --git a/next/tests/nx_kernel_multi.sh b/next/tests/nx_kernel_multi.sh index eafae1c6..977cf92a 100755 --- a/next/tests/nx_kernel_multi.sh +++ b/next/tests/nx_kernel_multi.sh @@ -108,6 +108,47 @@ cat > "$TMPFILE" < :ok, actors/0 reflects both +(epoch 31) +(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:start_link(alice, AliceKS, AliceAS), ok = nx_kernel:add_actor(bob, BobKS, BobAS), nx_kernel:actors() =:= [alice, bob]\") :name)") + +;; add_actor/3 duplicate -> {error, already_present} +(epoch 32) +(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:start_link(alice, AliceKS, AliceAS), case nx_kernel:add_actor(alice, AliceKS, AliceAS) of {error, already_present} -> ok; _ -> bad end\") :name)") + +;; publish_to/2 advances only the named actor's log +(epoch 33) +(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:start_link(alice, AliceKS, AliceAS), nx_kernel:add_actor(bob, BobKS, BobAS), {ok, _} = nx_kernel:publish_to(alice, Req), AliceTip = nx_kernel:log_tip_for(alice), BobTip = nx_kernel:log_tip_for(bob), {AliceTip, BobTip} =:= {1, 0}\") :name)") + +;; Interleaved publishes preserve per-actor counters +(epoch 34) +(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:start_link(alice, AliceKS, AliceAS), nx_kernel:add_actor(bob, BobKS, BobAS), nx_kernel:publish_to(alice, Req), nx_kernel:publish_to(bob, Req), nx_kernel:publish_to(alice, Req), AliceTip = nx_kernel:log_tip_for(alice), BobTip = nx_kernel:log_tip_for(bob), {AliceTip, BobTip} =:= {2, 1}\") :name)") + +;; publish_to unknown actor -> {error, no_actor}, no kernel crash +(epoch 35) +(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:start_link(alice, AliceKS, AliceAS), case nx_kernel:publish_to(ghost, Req) of {error, no_actor} -> ok; _ -> bad end\") :name)") + +;; state_for/1 returns the per-actor AS +(epoch 36) +(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:start_link(alice, AliceKS, AliceAS), nx_kernel:add_actor(bob, BobKS, BobAS), {ok, ASb} = nx_kernel:state_for(bob), ASb =:= BobAS\") :name)") + +;; with_projections_for/2 sets per-actor projections, observable via bucket_for +(epoch 37) +(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:start_link(alice, AliceKS, AliceAS), nx_kernel:add_actor(bob, BobKS, BobAS), nx_kernel:with_projections_for(alice, [px]), {ok, AliceBucket} = nx_kernel:bucket_for(alice), {ok, BobBucket} = nx_kernel:bucket_for(bob), [{projections, AliceP} | _] = lists:filter(fun(P) -> element(1, P) =:= projections end, AliceBucket), [{projections, BobP} | _] = lists:filter(fun(P) -> element(1, P) =:= projections end, BobBucket), {AliceP, BobP} =:= {[px], []}\") :name)") EPOCHS OUTPUT=$(timeout 240 "$SX_SERVER" < "$TMPFILE" 2>/dev/null) @@ -148,6 +189,15 @@ check 22 "independent next_published seqs" "true" check 23 "actor_state/2 per-actor" "true" check 24 "with_actor_projections per-actor" "true" check 25 "legacy new/3 + publish/2 routes" "true" +check 26 "gen_server loaded" "gen_server" +check 30 "start_link seeds bucket 0" "true" +check 31 "add_actor/3 (srv) -> ok + actors" "true" +check 32 "add_actor/3 duplicate detected" "ok" +check 33 "publish_to/2 isolates per actor" "true" +check 34 "interleaved publishes per actor" "true" +check 35 "publish_to unknown -> no_actor" "ok" +check 36 "state_for/1 per-actor AS" "true" +check 37 "with_projections_for per-actor" "true" TOTAL=$((PASS+FAIL)) if [ $FAIL -eq 0 ]; then diff --git a/plans/fed-sx-milestone-2.md b/plans/fed-sx-milestone-2.md index ad917443..e6d0db69 100644 --- a/plans/fed-sx-milestone-2.md +++ b/plans/fed-sx-milestone-2.md @@ -132,14 +132,18 @@ actors. the substrate; local `has_keyed`/`find_keyed`/`set_keyed`/ `set_bucket` helpers handle the keyed-list ops. `next/tests/nx_kernel_multi.sh` 17/17. -- [ ] **1b** — Multi-actor gen_server. `start_link/3` still works as - the single-actor entry; add `add_actor/3` (gen_server call, - bumps bucket), `publish_to/2(ActorId, Request)`, `log_tip_for/1`, - `actors/0`, `state_for/1`, `with_projections_for/2`. Existing - `publish/1`/`log_tip/0`/etc route through bucket-0 unchanged. - Concurrent publishes to distinct actors don't serialise across the - mailbox (multiple casts queued before each is processed). New tests - extend `nx_kernel_multi.sh` with 6-8 gen_server-mediated cases. +- [x] **1b** — Multi-actor gen_server. `start_link/3` still seeds + bucket 0; new exports `add_actor/3`, `publish_to/2(ActorId, + Request)`, `log_tip_for/1`, `actors/0`, `state_for/1`, + `bucket_for/1`, `with_projections_for/2` delegate to the pure- + functional bucket APIs via fresh `handle_call` branches. Existing + `publish/1`/`log_tip/0`/`with_projections/1` route through bucket + 0 unchanged. Per-actor mailbox concurrency (one gen_server per + bucket so distinct-actor publishes don't serialise) is forward- + looking — deferred to Step 4 (multi-actor HTTP routing) where it + actually pays off. `nx_kernel_multi.sh` extended with 9 gen_server + cases (26 total), every M1 nx_kernel-adjacent + http suite still + green (134 / 134 across 12 suites). **Acceptance:** `bash next/tests/nx_kernel_multi.sh` passes 12+ cases. @@ -676,6 +680,19 @@ proceed. Newest first. +- **2026-06-06** — Step 1b: gen_server multi-actor calls. + `nx_kernel` exports `add_actor/3`, `publish_to/2`, `log_tip_for/1`, + `actors/0`, `state_for/1`, `bucket_for/1`, + `with_projections_for/2` — each is a `gen_server:call` delegating + to the pure-functional bucket API from 1a. Existing single-actor + calls untouched. `nx_kernel_multi.sh` extended with 9 gen_server + cases (26 total); 134 / 134 across 12 nx_kernel-adjacent + http + suites. Conformance 761/761 preserved. Per-actor mailbox sharding + noted as forward-looking — current single gen_server serialises + publishes across actors, which is fine for Steps 1-3 (single-actor + HTTP endpoints) and is naturally untangled by Step 4's per-actor + routing. + - **2026-06-06** — Step 1a: per-actor bucket refactor of `nx_kernel`. State shape now `[{actors, [{Id, Bucket}, …]}, {next_actor_seq, N}]`; added pure-functional multi-actor APIs (`new/0`, `add_actor/4`,