Skip to content

Commit 44ebfcc

Browse files
committed
feat(formal/tla): TLA+ pool model — JsWorkerPool crash-isolation + route-consistency
Adds JsWorkerPool.tla: the pool-level formal model over N JsWorker slots. Composes with JsWorker.tla (same status/reply/replyCount discipline) and lifts it to N workers by adding `assigned` (which slot) and `wup` (per-worker Deno-process liveness). New properties beyond the single-worker model: - CRASH-ISOLATION: Crash(w) is guarded on `assigned[r] = w`, so a crash at one slot is structurally incapable of touching requests at other slots. Verified by TLC under every interleaving (ReplyOnce still holds pool-wide). - ROUTE-CONSISTENCY: a pool request is always at Route[r] — the phash2 target — never a different slot. Enforces the consistent-hash invariant. - ArriveFallback: when the hashed slot is down, JsInvoker (fork-per-call) delivers "fallback" atomically; WF on ArriveFallback ensures a stranded "new" request is not stuck forever. Sanity controls: ReachOk/Timeout/Crashed/Fallback are expected violations — all four terminal outcomes are reachable (non-vacuity check). Updates README to document the pool model and removes JsWorkerPool from "Not yet modelled". Invoker (fork-per-request) remains future work. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8pqMfJViUaKabWKNQ9wUg
1 parent a677019 commit 44ebfcc

3 files changed

Lines changed: 331 additions & 6 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
\* SPDX-License-Identifier: MPL-2.0
2+
\* Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
\* TLC config for the JsWorkerPool model.
4+
\* Run: java -cp /path/to/tla2tools.jar tlc2.TLC JsWorkerPool.tla
5+
\*
6+
\* Two workers, three requests.
7+
\* Route maps r1 and r3 to w0, r2 to w1.
8+
\* This captures the key topology: two requests sharing one slot (so a
9+
\* single crash at w0 terminates both r1 and r3, while r2 at w1 is
10+
\* unaffected — the CRASH-ISOLATION scenario).
11+
CONSTANTS
12+
Requests = {r1, r2, r3}
13+
Workers = {w0, w1}
14+
Route = [r1 |-> w0, r2 |-> w1, r3 |-> w0]
15+
16+
SPECIFICATION Spec
17+
18+
INVARIANTS
19+
TypeOK
20+
ReplyOnce
21+
Consistent
22+
NoPendingWhileDown
23+
RouteConsistency
24+
25+
PROPERTIES
26+
EventuallyReplied
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
-------------------------- MODULE JsWorkerPool --------------------------
2+
\* SPDX-License-Identifier: MPL-2.0
3+
\* Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
4+
(***************************************************************************)
5+
(* Formal model of `BojRest.JsWorkerPool` *)
6+
(* (elixir/lib/boj_rest/js_worker_pool.ex + js_worker.ex). *)
7+
(* *)
8+
(* A JsWorkerPool is a :one_for_one Supervisor over N JsWorker GenServers, *)
9+
(* each wrapping one persistent Deno OS process (a Port). The pool *)
10+
(* dispatches requests via :erlang.phash2 (consistent hash): the same *)
11+
(* mod_js_path always routes to the same worker slot, maximising Deno *)
12+
(* module-cache hits. If the hashed slot is down, the pool falls back to *)
13+
(* JsInvoker (fork-per-call), which always terminates independently. *)
14+
(* *)
15+
(* This model composes with JsWorker.tla: it re-uses the same *)
16+
(* status/reply/replyCount discipline but lifts it to N workers, adding *)
17+
(* two pool-level properties: *)
18+
(* *)
19+
(* CRASH-ISOLATION: a crash at worker w terminates only the requests *)
20+
(* assigned to w; every other in-flight request is completely unaffected. *)
21+
(* *)
22+
(* ROUTE-CONSISTENCY: a request that enters a pool slot is always at the *)
23+
(* slot its Route function maps it to — never a wrong slot. *)
24+
(* *)
25+
(* ReplyOnce (no double GenServer.reply) and EventuallyReplied (every *)
26+
(* pending request eventually gets a reply) continue to hold over the pool. *)
27+
(* *)
28+
(* Abstraction: *)
29+
(* - Requests are opaque ids; Route is a fixed [Requests -> Workers] *)
30+
(* constant (the phash2 function, frozen at model-check time). *)
31+
(* - Each Deno process is a nondeterministic oracle: ok / jsErr / silent *)
32+
(* (timer fires) / crash. JSON, HTTP, and Zig FFI are out of scope. *)
33+
(* - The fallback JsInvoker is modelled as an atomic "always-terminates" *)
34+
(* action; its internal state is not modelled here. *)
35+
(***************************************************************************)
36+
EXTENDS Naturals, FiniteSets
37+
38+
CONSTANTS
39+
Requests, \* finite set of opaque request ids
40+
Workers, \* finite set of worker ids (e.g., {w0,w1,w2,w3,w4})
41+
Route \* [Requests -> Workers]: the phash2 routing table (fixed)
42+
43+
ASSUME Route \in [Requests -> Workers]
44+
45+
Replies == {"none", "ok", "jsErr", "timeout", "crashed", "fallback"}
46+
Statuses == {"new", "pending", "done"}
47+
48+
\* Sentinel: a value guaranteed to be outside Workers. Used to mark
49+
\* requests that have not yet been assigned to a pool slot.
50+
NoWorker == CHOOSE x : x \notin Workers
51+
52+
VARIABLES
53+
status, \* [Requests -> Statuses]
54+
reply, \* [Requests -> Replies] (classification once done)
55+
replyCount, \* [Requests -> 0..2] (double-reply detector)
56+
assigned, \* [Requests -> Workers ∪ {NoWorker}]
57+
wup \* [Workers -> BOOLEAN] (Deno process alive?)
58+
59+
vars == <<status, reply, replyCount, assigned, wup>>
60+
61+
TypeOK ==
62+
/\ status \in [Requests -> Statuses]
63+
/\ reply \in [Requests -> Replies]
64+
/\ replyCount \in [Requests -> 0..2]
65+
/\ assigned \in [Requests -> Workers \cup {NoWorker}]
66+
/\ wup \in [Workers -> BOOLEAN]
67+
68+
Init ==
69+
/\ status = [r \in Requests |-> "new"]
70+
/\ reply = [r \in Requests |-> "none"]
71+
/\ replyCount = [r \in Requests |-> 0]
72+
/\ assigned = [r \in Requests |-> NoWorker]
73+
/\ wup = [w \in Workers |-> TRUE]
74+
75+
(*-------------------------- DISPATCH ACTIONS ----------------------------*)
76+
77+
\* pick_worker/1: hashed slot is alive → route to pool.
78+
\* js_worker_pool.ex `invoke/4` → `JsWorker.handle_call({:invoke,...})`.
79+
ArrivePool(r) ==
80+
LET w == Route[r] IN
81+
/\ status[r] = "new"
82+
/\ wup[w]
83+
/\ status' = [status EXCEPT ![r] = "pending"]
84+
/\ assigned' = [assigned EXCEPT ![r] = w]
85+
/\ UNCHANGED <<reply, replyCount, wup>>
86+
87+
\* pick_worker/1 returns nil → fall back to JsInvoker (fork-per-call).
88+
\* Modelled as atomic: JsInvoker always terminates, so we skip its
89+
\* internal state and deliver "fallback" directly.
90+
ArriveFallback(r) ==
91+
/\ status[r] = "new"
92+
/\ ~wup[Route[r]]
93+
/\ status' = [status EXCEPT ![r] = "done"]
94+
/\ reply' = [reply EXCEPT ![r] = "fallback"]
95+
/\ replyCount' = [replyCount EXCEPT ![r] = @ + 1]
96+
/\ UNCHANGED <<assigned, wup>>
97+
98+
(*-------------------------- DELIVERY ACTIONS ----------------------------*)
99+
100+
\* The single guarded reply path — the same Map.pop discipline as
101+
\* JsWorker.tla: status[r]="pending" guard + atomic move to "done"
102+
\* disables all racing actions (RespondOk/RespondErr/Timeout/Crash).
103+
Deliver(r, kind) ==
104+
LET w == assigned[r] IN
105+
/\ w \in Workers
106+
/\ wup[w]
107+
/\ status[r] = "pending"
108+
/\ status' = [status EXCEPT ![r] = "done"]
109+
/\ reply' = [reply EXCEPT ![r] = kind]
110+
/\ replyCount' = [replyCount EXCEPT ![r] = @ + 1]
111+
/\ UNCHANGED <<assigned, wup>>
112+
113+
\* js_worker.ex `dispatch_response/2`, status 2xx.
114+
RespondOk(r) == Deliver(r, "ok")
115+
116+
\* js_worker.ex `dispatch_response/2`, status not 2xx.
117+
RespondErr(r) == Deliver(r, "jsErr")
118+
119+
\* js_worker.ex `handle_info({:timeout, id})`.
120+
\* The 30s timer fires; no response arrived in time.
121+
Timeout(r) == Deliver(r, "timeout")
122+
123+
(*------------------------ CRASH AND RESTART ----------------------------*)
124+
125+
\* js_worker.ex `handle_info({port,{:exit_status,_}})`:
126+
\* reply-all-pending then :stop.
127+
\*
128+
\* Crash(w) touches ONLY requests whose assigned[r] = w. Requests at
129+
\* other workers are not mentioned in this action — CRASH-ISOLATION is
130+
\* enforced structurally, not by an external invariant.
131+
Crash(w) ==
132+
/\ wup[w]
133+
/\ wup' = [wup EXCEPT ![w] = FALSE]
134+
/\ status' = [r \in Requests |->
135+
IF status[r] = "pending" /\ assigned[r] = w
136+
THEN "done" ELSE status[r]]
137+
/\ reply' = [r \in Requests |->
138+
IF status[r] = "pending" /\ assigned[r] = w
139+
THEN "crashed" ELSE reply[r]]
140+
/\ replyCount' = [r \in Requests |->
141+
IF status[r] = "pending" /\ assigned[r] = w
142+
THEN replyCount[r] + 1 ELSE replyCount[r]]
143+
/\ UNCHANGED assigned
144+
145+
\* :one_for_one restart: a fresh Deno process for worker w only.
146+
\* The other workers' states and all already-replied requests are unchanged.
147+
Restart(w) ==
148+
/\ ~wup[w]
149+
/\ wup' = [wup EXCEPT ![w] = TRUE]
150+
/\ UNCHANGED <<status, reply, replyCount, assigned>>
151+
152+
Next ==
153+
\/ \E r \in Requests :
154+
ArrivePool(r) \/ ArriveFallback(r) \/
155+
RespondOk(r) \/ RespondErr(r) \/ Timeout(r)
156+
\/ \E w \in Workers : Crash(w) \/ Restart(w)
157+
158+
\* Fairness:
159+
\* - Each request's 30s timer eventually fires (WF on Timeout).
160+
\* - ArriveFallback is available whenever the slot is down; WF ensures it
161+
\* eventually fires for a new request stranded by a crashed slot.
162+
\* - The :one_for_one Supervisor eventually restarts every stopped worker.
163+
Spec ==
164+
/\ Init /\ [][Next]_vars
165+
/\ \A r \in Requests : WF_vars(Timeout(r))
166+
/\ \A r \in Requests : WF_vars(ArriveFallback(r))
167+
/\ \A w \in Workers : WF_vars(Restart(w))
168+
169+
(*-------------------------------- SAFETY --------------------------------*)
170+
171+
\* Inherited from JsWorker.tla — held pool-wide.
172+
ReplyOnce == \A r \in Requests : replyCount[r] <= 1
173+
174+
Consistent ==
175+
/\ \A r \in Requests : (status[r] = "pending") => (reply[r] = "none")
176+
/\ \A r \in Requests : (status[r] = "done") => (reply[r] # "none")
177+
/\ \A r \in Requests : (status[r] = "new") => (reply[r] = "none")
178+
179+
\* Pool-specific: Crash(w) atomically clears every request at w, so no
180+
\* pending request remains after the worker stops.
181+
NoPendingWhileDown ==
182+
\A w \in Workers, r \in Requests :
183+
(status[r] = "pending" /\ assigned[r] = w) => wup[w]
184+
185+
\* ROUTE-CONSISTENCY: the phash2 invariant. If a request is in-flight at
186+
\* a pool slot, it is at the slot Route maps it to — guaranteed by ArrivePool
187+
\* assigning `assigned[r] := Route[r]` and no action ever changing assigned.
188+
RouteConsistency ==
189+
\A r \in Requests :
190+
(status[r] = "pending" /\ assigned[r] \in Workers) =>
191+
assigned[r] = Route[r]
192+
193+
(*------------------------------ LIVENESS --------------------------------*)
194+
195+
\* Every pending request eventually terminates (ok / jsErr / timeout /
196+
\* crashed). Guaranteed by the 30s timer (WF_vars(Timeout)) or by
197+
\* worker crash, whichever fires first.
198+
EventuallyReplied ==
199+
\A r \in Requests : (status[r] = "pending") ~> (status[r] = "done")
200+
201+
(*------------------------ SANITY CONTROLS (non-vacuity) ----------------*)
202+
\* Each of these is EXPECTED TO BE VIOLATED when checked as an invariant
203+
\* (see the loop in README.adoc). TLC refutes them with short witness
204+
\* traces, proving all four terminal outcomes are genuinely reachable.
205+
\* They are NOT in JsWorkerPool.cfg.
206+
ReachOk == \A r \in Requests : reply[r] # "ok"
207+
ReachTimeout == \A r \in Requests : reply[r] # "timeout"
208+
ReachCrashed == \A r \in Requests : reply[r] # "crashed"
209+
ReachFallback == \A r \in Requests : reply[r] # "fallback"
210+
211+
============================================================================

specs/elixir-harness/README.adoc

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,28 +80,116 @@ are *refuted* by TLC with short witness traces, proving the ok / timeout /
8080
crashed outcomes are genuinely reachable and the invariants are not passing
8181
vacuously.
8282

83+
== JsWorkerPool.tla — models `BojRest.JsWorkerPool`
84+
85+
Source: `elixir/lib/boj_rest/js_worker_pool.ex` + `js_worker.ex`.
86+
A `JsWorkerPool` is a `:one_for_one` Supervisor over N `JsWorker` GenServers
87+
(default 5), each wrapping a persistent Deno OS process. The pool dispatches
88+
via `:erlang.phash2` (consistent hash): the same `mod_js_path` always routes to
89+
the same slot, maximising Deno module-cache hits. If the hashed slot is down,
90+
the pool falls back to `JsInvoker` (fork-per-call), which always terminates.
91+
92+
This model *composes* with `JsWorker.tla`: it re-uses the same
93+
`status`/`reply`/`replyCount` variables but lifts them to N workers, adding
94+
the `assigned` and `wup` variables for pool routing and per-worker liveness.
95+
96+
=== Abstraction
97+
98+
* `Requests` are opaque ids; `Workers` is the set of pool slots.
99+
* `Route \in [Requests -> Workers]` is a constant (the `phash2` function, fixed
100+
at model-check time — routing does not change at runtime).
101+
* Each Deno process is a nondeterministic oracle (ok / jsErr / silent / crash).
102+
* `ArriveFallback` is modelled as atomic: `JsInvoker` always terminates, so its
103+
internal state is not elaborated here.
104+
105+
=== Code → model mapping
106+
107+
[cols="1,1",options="header"]
108+
|===
109+
| `js_worker_pool.ex` / `js_worker.ex` | model action
110+
111+
| `JsWorkerPool.invoke/4` → `pick_worker/1` returns a pid → `JsWorker.handle_call({:invoke,...})`
112+
| `ArrivePool(r)`
113+
114+
| `pick_worker/1` returns `nil` → `JsInvoker.invoke/4` (fork-per-call)
115+
| `ArriveFallback(r)`
116+
117+
| `dispatch_response/2`, status 2xx / non-2xx (`Map.pop` + `GenServer.reply`)
118+
| `RespondOk(r)` / `RespondErr(r)`
119+
120+
| `handle_info({:timeout, id})`
121+
| `Timeout(r)`
122+
123+
| `handle_info({port,{:exit_status,_}})` — reply-all pending at *this* worker, `:stop`
124+
| `Crash(w)`
125+
126+
| `:one_for_one` restart — only worker `w`, others unaffected
127+
| `Restart(w)`
128+
|===
129+
130+
=== Properties verified (TLC, 2 workers, 3 requests)
131+
132+
Config: `Requests = {r1, r2, r3}`, `Workers = {w0, w1}`,
133+
`Route = [r1 |-> w0, r2 |-> w1, r3 |-> w0]`.
134+
The `Route` choice is deliberate: r1 and r3 share slot w0, while r2 is at w1.
135+
A `Crash(w0)` terminates r1 and r3 but not r2 — the critical crash-isolation
136+
scenario.
137+
138+
Safety invariants:
139+
140+
* `ReplyOnce` — no caller is ever replied twice, across all N workers and
141+
under every interleaving of response / timeout / crash at any slot.
142+
* `Consistent` — pending ⟺ no reply yet; done ⟺ exactly one reply.
143+
* `NoPendingWhileDown` — `Crash(w)` atomically clears all pending requests at w,
144+
so no stale message can be delivered after `:stop`.
145+
* `RouteConsistency` — a request in-flight at a pool slot is always at
146+
`Route[r]`, not a different slot. Enforces the `phash2` determinism invariant.
147+
148+
Liveness:
149+
150+
* `EventuallyReplied` — every pending request eventually terminates (ok / jsErr
151+
/ timeout / crashed). The 30 s `Timeout` fairness condition guarantees this
152+
even if Deno hangs; `Restart` fairness ensures crashed slots come back.
153+
154+
=== Non-vacuity (sanity controls)
155+
156+
`ReachOk`, `ReachTimeout`, `ReachCrashed`, `ReachFallback` (in
157+
`JsWorkerPool.tla`, *not* in `JsWorkerPool.cfg`) each assert a terminal reply
158+
kind is never reached. All four are *refuted* by TLC with short witness traces,
159+
proving every outcome is genuinely reachable and the invariants are not passing
160+
vacuously. `ReachFallback` is specific to the pool model: it witnesses the
161+
path where a slot is down and the request is served by `JsInvoker`.
162+
83163
== Running
84164

85165
[source,bash]
86166
----
87167
# tla2tools.jar: https://github.com/tlaplus/tlaplus/releases (needs a JRE)
168+
169+
# Single-worker model
88170
java -cp tla2tools.jar tlc2.TLC JsWorker.tla
89171
90-
# sanity controls — each is EXPECTED to report "Invariant ... is violated"
172+
# Pool model (2 workers, 3 requests)
173+
java -cp tla2tools.jar tlc2.TLC JsWorkerPool.tla
174+
175+
# Sanity controls for JsWorker — each EXPECTED to report "Invariant ... is violated"
91176
for inv in ReachOk ReachTimeout ReachCrashed; do
92177
printf 'CONSTANTS Requests = {r1, r2, r3}\nSPECIFICATION Spec\nINVARIANT %s\n' "$inv" > /tmp/ctrl.cfg
93178
java -cp tla2tools.jar tlc2.TLC -config /tmp/ctrl.cfg JsWorker.tla
94179
done
180+
181+
# Sanity controls for JsWorkerPool — each EXPECTED to report "Invariant ... is violated"
182+
for inv in ReachOk ReachTimeout ReachCrashed ReachFallback; do
183+
printf 'CONSTANTS\n Requests = {r1, r2, r3}\n Workers = {w0, w1}\n Route = [r1 |-> w0, r2 |-> w1, r3 |-> w0]\nSPECIFICATION Spec\nINVARIANT %s\n' "$inv" > /tmp/ctrl.cfg
184+
java -cp tla2tools.jar tlc2.TLC -config /tmp/ctrl.cfg JsWorkerPool.tla
185+
done
95186
----
96187

97-
Verified with TLC 2026.05.26 (tla2tools) on OpenJDK 21.
188+
`JsWorker.tla` verified with TLC 2026.05.26 (tla2tools) on OpenJDK 21:
189+
341 distinct states, search depth 8.
98190

99191
== Not yet modelled (future work)
100192

101-
* **`JsWorkerPool`** (`js_worker_pool.ex`) — hash-routing determinism (same
102-
`mod_js_path` → same worker via `:erlang.phash2`, or graceful fork-per-call
103-
fallback when the slot is dead) and `:one_for_one` crash isolation across the
104-
N-worker pool.
105193
* **`Invoker`** (Zig-FFI path) is currently fork-per-request — *no pool yet*;
106194
the ADR-0005 OS-port pool is future work (waits on ADR-0006). Model the pool's
107195
checkout/backpressure when it lands.

0 commit comments

Comments
 (0)