|
| 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 | +============================================================================ |
0 commit comments