penpot/backend/test/backend_tests/rpc_climit_test.clj
Andrey Antukh f242ff9574 🐛 Keep concurrency limits from deadlocking on nested calls
An instance left the audit events pipeline dead: every push-audit-events
answered with 429 and 69 worker threads parked for good inside a limiter.

Two code paths of one profile acquired the same two limiters in opposite
order: a command declaring its own limits (upload-file-media-object) took
process-image/by-profile and then root/by-profile, while a command with no
limits of its own took the roots first and then re-entered climit from
inside its handler with the same ids (create-file-media-object-from-url).
Each path held what the other one waited for, and no limiter had a timeout
to break the wait.

Now wrap puts the root limiters after the command's own, so they end up
outermost and every path meets them first: the nested chains carry only the
command's own limits and run inside them, which puts both paths in the same
order. impl-invoke also keeps, per thread, the set of limiters that thread
is inside of and runs the handler inline when a call asks for one it
already holds, so a handler can no longer queue behind itself.

The shipped climit.edn sets a timeout on every limiter (60s for operations
that can be slow, 30s otherwise), so no wait can park a thread for ever.
The production file lives outside the repository and has to be mirrored.

Tests cover the acquisition order, the deadlock, the reentrancy and the
timeouts of the shipped config.

AI-assisted-by: deepseek-v4.1-flash
2026-10-13 14:33:32 +02:00

52 lines
2.0 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.rpc-climit-test
(:require
[app.rpc.climit :as-alias climit]
[app.util.services :as-alias sv]
[backend-tests.helpers :as th]
[clojure.test :as t]))
(t/use-fixtures :once th/state-init)
(defn- method-limit-declarations
"The `::climit/id` declarations of every RPC command, as the running system
wraps them."
[]
(->> (:app.rpc/methods th/*system*)
(vals)
(map first)
(keep (fn [mdata]
(when-let [limits (::climit/id mdata)]
[mdata limits])))))
(t/deftest commands-declare-the-by-profile-limit-before-the-global-one
;; `climit/get-limits` reverses a declared vector of pairs, so the first one
;; declared is acquired first, and the nested `climit/invoke!` chains read the
;; same declaration. A pair declared global first puts a command back in the
;; inverted order that parked two paths of one profile for good.
(let [declarations (method-limit-declarations)]
;; the system wraps every command, so a short list means the scan went stale
(t/is (<= 10 (count declarations)))
(doseq [[mdata limits] declarations
:when (and (vector? limits) (vector? (first limits)))]
(let [ids (mapv first limits)
index (into {} (map-indexed (fn [idx id] [id idx]) ids))
where (str (::sv/name mdata))]
(t/is (= (count ids) (count (distinct ids)))
(str where " declares the same limit twice"))
(doseq [[id idx] index
:when (= "by-profile" (name id))]
(when-let [gidx (get index (keyword (namespace id) "global"))]
(t/is (< idx gidx)
(str where " must declare " id " before "
(keyword (namespace id) "global")))))))))