Fase 6: close the subs-loader in-flight dedup gap, raise batch limits, add /v1/invoke-batch

Fase 0's telemetry work had already flagged that subs-loader deduped
load! calls that landed before a flush, but not ones arriving while a
flush's own worker request was still in flight -- flush! cleared *batch
via take-batch! before the request it just sent even resolved, so a
load! for the same slot in that window fired a duplicate request. Fixed
by tracking in-flight requests in a separate *inflight map, untracked
only once the request settles (db/subs_loader.cljs), with a real e2e
test proving the dedup and that a later load! after settlement does
fire a fresh request (db/subs_loader_test.cljs).

Also raises the :children/:resources get-render-snapshots batch limits
from 25 to 200 -- 25 was calibrated for postMessage-to-a-worker being
free; over the network that trade inverts. Two existing subs-test.cljs
tests hardcoded the old 25-item batching behavior with 100 items across
4 calls; updated to the new single-call-of-100 behavior under the 200
limit.

Deliberately did NOT widen db/subs.cljs's load-batch-coalesce-window
from the same-tick 0ms setTimeout to a few ms, despite that being in
the original plan: doing so broke ~212 assertions across subs_test.cljs
that synchronize on same-tick flushing via (p/delay 0), and would be
redundant with the invoke-batch coalescer below, which already captures
the same "batch several concurrent RPCs into fewer HTTP round trips"
win at the transport layer instead.

New POST /v1/invoke-batch on the daemon (db_worker_node.cljs) runs an
array of {method, argsTransit} through the same repo-check/lock/invoke
path as /v1/invoke, but never rejects the whole batch on one item's
failure -- each item independently becomes {ok, resultTransit} or {ok:
false, error}. Verified against a live daemon: mixed success/repo-
mismatch/success in one request, 401 without auth, 405 on non-POST.

On the client, frontend.persist-db.remote/create-invoke-batcher
coalesces wrapped-worker calls (the RPC every state/<invoke-db-worker
call funnels through for a remote client) issued within a 10ms window
into one POST /v1/invoke-batch, generalizing subs-loader's own
same-method batching to every thread-api call a remote client makes.
Two invokes only land in the same batch when neither is awaited before
the other fires, so this can't reorder anything a caller depends on
sequentially.

New db_worker_node_test.cljs coverage: invoke-batch mixing success and
repo-mismatch failure in one request, and invoke-batch auth/method
enforcement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 11:13:04 +02:00
parent 902bf7f568
commit 2164bbdf1e
7 changed files with 335 additions and 15 deletions

View File

@@ -17,6 +17,10 @@
[base-url]
(str (normalize-base-url base-url) "/v1/invoke"))
(defn- invoke-batch-url
[base-url]
(str (normalize-base-url base-url) "/v1/invoke-batch"))
(defn- import-db-binary-url
[base-url repo]
(str (normalize-base-url base-url)
@@ -214,6 +218,62 @@
(on-invoke-failure method args error))
(throw error))))))
(defn create-invoke-batcher
"Coalesces thread-api invokes issued within a short window into a single
POST /v1/invoke-batch instead of one HTTP round trip per call -- the
generalization, to every wrapped-worker call a remote client makes, of the
request-group batching frontend.db.subs-loader already does for
get-render-snapshots specifically (Fase 6). Two invokes only land in the
same batch when neither is awaited before the other is issued (independent
fire-and-forget/parallel calls); a caller that does
`(p/let [a (invoke...) b (invoke...)] ...)` awaits `a` before `b` is even
requested, so sequentially-dependent invokes are never split across a
batch boundary in a way that would change their observed order."
[{:keys [base-url auth-token fetch-fn schedule-fn on-invoke-success on-invoke-failure
invoke-coalesce-ms]
:or {invoke-coalesce-ms 10}}]
(let [*pending (atom [])
do-flush!
(fn []
(let [batch @*pending]
(reset! *pending [])
(when (seq batch)
(let [payload (js/JSON.stringify
(clj->js {:invokes (mapv (fn [{:keys [method args]}]
{:method method
:argsTransit (ldb/write-transit-str args)})
batch)}))]
(-> (p/let [{:keys [status body]}
(fetch-fn {:method "POST"
:url (invoke-batch-url base-url)
:headers (base-headers auth-token)
:body payload})]
(if (<= 200 status 299)
(let [results (:results (parse-response-body body))]
(doseq [[{:keys [method args deferred]} result] (map vector batch results)]
(if (:ok result)
(let [value (ldb/read-transit-str (:resultTransit result))]
(when on-invoke-success (on-invoke-success method args value))
(p/resolve! deferred value))
(let [error (:error result)
ex (ex-info (or (:message error) "db-worker invoke failed")
(cond-> {:code (normalize-code (:code error))}
error (assoc :error error)))]
(when on-invoke-failure (on-invoke-failure method args ex))
(p/reject! deferred ex)))))
(throw (ex-info "db-worker batch invoke failed" {:status status}))))
(p/catch (fn [error]
(doseq [{:keys [method args deferred]} batch]
(when on-invoke-failure (on-invoke-failure method args error))
(p/reject! deferred error)))))))))]
(fn invoke-batched! [method args]
(let [deferred (p/deferred)
schedule? (empty? @*pending)]
(swap! *pending conj {:method method :args args :deferred deferred})
(when schedule?
(schedule-fn do-flush! invoke-coalesce-ms))
deferred))))
(defn- import-db-binary!
[{:keys [base-url auth-token fetch-fn on-invoke-success on-invoke-failure]} repo data]
(let [method "thread-api/import-db-binary"
@@ -385,8 +445,9 @@
(defn start!
[{:keys [base-url auth-token event-handler] :as opts}]
(let [client (create-client (assoc opts :base-url base-url :auth-token auth-token))
invoke-batched! (create-invoke-batcher client)
wrapped-worker (fn [qkw & args]
(invoke! client (method->str qkw) args))
(invoke-batched! (method->str qkw) args))
{:keys [disconnect!]} (connect-events-ws! (assoc client
:event-handler event-handler
:auth-token auth-token)