mirror of
https://github.com/penpot/penpot.git
synced 2026-10-30 06:20:32 +00:00
693 lines
30 KiB
Clojure
693 lines
30 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.climit-test
|
|
(:require
|
|
[app.common.logging :as l]
|
|
[app.common.uuid :as uuid]
|
|
[app.main :as main]
|
|
[app.metrics :as mtx]
|
|
[app.rpc :as-alias rpc]
|
|
[app.rpc.climit :as climit]
|
|
[app.util.services :as-alias sv]
|
|
[backend-tests.helpers :as th]
|
|
[clojure.edn :as edn]
|
|
[clojure.java.io :as io]
|
|
[clojure.test :as t]
|
|
[integrant.core :as ig]))
|
|
|
|
(def ^:private calls (atom []))
|
|
|
|
(defn- record-metrics
|
|
"Run `f` recording every metrics call it makes."
|
|
[f]
|
|
(reset! calls [])
|
|
(with-redefs [mtx/run!
|
|
(fn [_ & {:keys [id labels val]}]
|
|
(swap! calls conj {:id id :labels (vec labels) :val val}))]
|
|
(f)))
|
|
|
|
(defn- acquired-in-order
|
|
"Limiter names acquired so far, in acquisition order."
|
|
[]
|
|
(->> @calls
|
|
(filter #(= :rpc-climit-timing (:id %)))
|
|
(mapcat :labels)
|
|
(vec)))
|
|
|
|
(defn- saw-call?
|
|
"Wait until a metrics call mentions `label`."
|
|
[label timeout]
|
|
(let [deadline (+ (System/currentTimeMillis) timeout)]
|
|
(loop []
|
|
(cond
|
|
(some (fn [call] (some #{label} (:labels call))) @calls)
|
|
true
|
|
|
|
(> (System/currentTimeMillis) deadline)
|
|
false
|
|
|
|
:else
|
|
(do (Thread/sleep 10) (recur))))))
|
|
|
|
(defn- failed-type
|
|
"Run `f` and return its value, or the type of the error it raised."
|
|
[f]
|
|
(try
|
|
(f)
|
|
(catch Throwable cause
|
|
(:type (ex-data cause)))))
|
|
|
|
(def ^:private no-timeout-hint
|
|
"limiter without timeout, a saturated one parks its threads forever")
|
|
|
|
(defn- warned-ids
|
|
"Ids of the captured log records that warn about a missing timeout."
|
|
[records]
|
|
(->> records
|
|
(keep (fn [record]
|
|
(when (= no-timeout-hint (get (::l/props record) :hint))
|
|
(get (::l/props record) :id))))
|
|
(set)))
|
|
|
|
(defn- warned?
|
|
"Wait until a captured log record warns about a missing timeout."
|
|
[records timeout]
|
|
(let [deadline (+ (System/currentTimeMillis) timeout)]
|
|
(loop []
|
|
(cond
|
|
(seq (warned-ids @records)) true
|
|
(> (System/currentTimeMillis) deadline) false
|
|
:else (do (Thread/sleep 10) (recur))))))
|
|
|
|
;; One permit and a bounded wait per limiter: a call either gets its permit, or
|
|
;; fails with a concurrency limit error, never parks a thread forever.
|
|
(def ^:private test-config
|
|
{:root/global {:permits 1 :timeout 3000}
|
|
:root/by-profile {:permits 1 :timeout 3000}
|
|
:process-image/global {:permits 1 :timeout 3000}
|
|
:process-image/by-profile {:permits 1 :timeout 3000}})
|
|
|
|
;; The limits `upload-file-media-object` and `assemble-file-media-object`
|
|
;; declare, per profile.
|
|
(def ^:private image-limits
|
|
[[:process-image/by-profile ::rpc/profile-id]
|
|
[:process-image/global]])
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; ACQUISITION ORDER
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(t/deftest acquires-root-limits-before-the-command-own-limits
|
|
;; A command declaring its own limits must meet the root limiters first.
|
|
;; The nested `climit/invoke!` calls take only the command's own limits and
|
|
;; run inside the root ones, so any other order lets two calls of the same
|
|
;; profile hold the same limiters in opposite order and park for good.
|
|
(record-metrics
|
|
(fn []
|
|
(th/with-climit [climit test-config]
|
|
(let [mdata {::sv/name "test-command" ::climit/id image-limits}
|
|
wrapped (climit/wrap {::rpc/climit climit} (fn [_ _] :ok) mdata)
|
|
result (wrapped {::rpc/climit climit} {::rpc/profile-id (uuid/next)})]
|
|
(t/is (= :ok result))
|
|
(t/is (= ["root/by-profile"
|
|
"root/global"
|
|
"process-image/by-profile"
|
|
"process-image/global"]
|
|
(acquired-in-order))))))))
|
|
|
|
(t/deftest nested-limits-do-not-deadlock-with-the-command-own-limits
|
|
;; Two paths over one profile: a command declaring its own limits (like
|
|
;; `upload-file-media-object`) and a command that takes the root limiters and
|
|
;; then asks for the same limits from inside its handler (like
|
|
;; `create-file-media-object-from-url`). Each path holds what the other one
|
|
;; waits for the moment the order differs, and nothing releases.
|
|
(record-metrics
|
|
(fn []
|
|
(th/with-climit [climit test-config]
|
|
(let [pid (uuid/next)
|
|
gate (promise)
|
|
entered (promise)
|
|
|
|
handler-b (fn [cfg params]
|
|
(deliver entered true)
|
|
@gate
|
|
(-> cfg
|
|
(assoc ::climit/id [[:process-image/by-profile pid]
|
|
[:process-image/global]])
|
|
(assoc ::climit/label "path-b")
|
|
(climit/invoke! (fn [_ _] :nested-ok) params)))
|
|
|
|
wrapped-a (climit/wrap {::rpc/climit climit}
|
|
(fn [_ _] :ok)
|
|
{::sv/name "path-a" ::climit/id image-limits})
|
|
|
|
wrapped-b (climit/wrap {::rpc/climit climit}
|
|
handler-b
|
|
{::sv/name "path-b"})]
|
|
|
|
;; Path B takes the root limiters and waits inside its handler.
|
|
(let [fb (future (failed-type #(wrapped-b {::rpc/climit climit}
|
|
{::rpc/profile-id pid})))]
|
|
(t/is (true? (deref entered 5000 false)))
|
|
|
|
;; Path A, started now, takes its own limiters and then waits for the
|
|
;; root ones, which B holds. The recorded metrics are how the test
|
|
;; knows A got that far instead of guessing with a sleep.
|
|
(reset! calls [])
|
|
(let [fa (future (failed-type #(wrapped-a {::rpc/climit climit}
|
|
{::rpc/profile-id pid})))]
|
|
(t/is (true? (saw-call? "root/by-profile" 5000)))
|
|
|
|
;; Let B ask for the limits A holds: if the order differs, each path
|
|
;; now waits for a permit the other one holds.
|
|
(deliver gate true)
|
|
|
|
(t/is (= :ok (deref fa 15000 :timeout)))
|
|
(t/is (= :nested-ok (deref fb 15000 :timeout))))))))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; REENTRANCY
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(t/deftest reentrant-invoke-does-not-consume-a-second-permit
|
|
;; A handler entering climit for a limit it already holds must not queue
|
|
;; behind itself: it runs inline and takes no second permit. Another thread
|
|
;; asking for the same limiter still waits, so the limit stays in place.
|
|
(th/with-climit [climit {:process-image/by-profile
|
|
{:permits 1 :timeout 2000}}]
|
|
(let [pid (uuid/next)
|
|
cfg {::rpc/climit climit
|
|
::climit/id [[:process-image/by-profile pid]]}
|
|
inner (promise)
|
|
other (promise)
|
|
handler (fn [cfg params]
|
|
(deliver inner
|
|
(failed-type #(climit/invoke! cfg (fn [_ _] :inner-ok) params)))
|
|
(deliver other (future (climit/invoke! cfg (fn [_ _] :other-ok) params)))
|
|
(t/is (= :waiting (deref @other 300 :waiting)))
|
|
:outer-ok)
|
|
|
|
outcome (failed-type #(climit/invoke! cfg handler {}))]
|
|
|
|
;; With no reentrancy handling the inner call waits for the permit this very
|
|
;; thread holds and ends failing by timeout.
|
|
(t/is (= :inner-ok (deref inner 5000 :timeout)))
|
|
(t/is (= :outer-ok outcome))
|
|
|
|
;; once the outer call releases, the other thread gets its permit
|
|
(t/is (= :other-ok (deref @other 5000 :timeout))))))
|
|
|
|
(t/deftest reentrant-tracking-is-cleaned-up-when-the-handler-fails
|
|
;; If the mark is not removed, the thread keeps believing it holds the
|
|
;; limiter and every later call of that key would skip the limit.
|
|
(th/with-climit [climit {:process-image/by-profile
|
|
{:permits 1 :timeout 1000}}]
|
|
(let [pid (uuid/next)
|
|
cfg {::rpc/climit climit
|
|
::climit/id [[:process-image/by-profile pid]]}
|
|
holding (promise)
|
|
gate (promise)]
|
|
|
|
;; The failing call runs on this thread, so this thread is the one that
|
|
;; must forget it held the limiter.
|
|
(t/is (thrown? clojure.lang.ExceptionInfo
|
|
(climit/invoke! cfg (fn [_ _] (throw (ex-info "boom" {}))) {})))
|
|
|
|
;; another thread takes the only permit
|
|
(let [fb (future (climit/invoke! cfg (fn [_ _]
|
|
(deliver holding true)
|
|
@gate
|
|
:held)
|
|
{}))]
|
|
(t/is (true? (deref holding 5000 false)))
|
|
|
|
;; this thread must wait for it, not skip the limiter
|
|
(t/is (= :concurrency-limit
|
|
(failed-type #(climit/invoke! cfg (fn [_ _] :inner-ok) {}))))
|
|
|
|
(deliver gate true)
|
|
(t/is (= :held (deref fb 5000 :timeout)))))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; SHIPPED CONFIG
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(t/deftest example-config-bounds-every-wait
|
|
;; A limiter without `:timeout` parks the calling thread forever, which is
|
|
;; how a limit nobody releases turns into lost worker threads and days of
|
|
;; silent 429s. Every entry of the shipped config must carry one.
|
|
(let [path (io/resource "climit.edn")]
|
|
(t/is (some? path))
|
|
(let [data (edn/read-string (slurp path))]
|
|
(t/is (seq data))
|
|
(doseq [[id config] data]
|
|
(t/is (int? (:timeout config))
|
|
(str id " must declare a timeout"))))))
|
|
|
|
(t/deftest warns-when-a-limiter-has-no-timeout
|
|
;; The config that production loads lives outside the repository and is
|
|
;; maintained by hand, so a limiter without a timeout has to say so at
|
|
;; startup instead of parking threads in silence months later.
|
|
(let [records (atom [])]
|
|
(add-watch l/log-record ::capture
|
|
(fn [_ _ _ record] (swap! records conj record)))
|
|
(try
|
|
(th/with-climit [_climit {:process-image/by-profile {:permits 1 :timeout 1000}
|
|
:process-image/global {:permits 8}}]
|
|
(t/is (true? (warned? records 2000)))
|
|
(t/is (= #{:process-image/global} (warned-ids @records))))
|
|
|
|
(finally
|
|
(remove-watch l/log-record ::capture)))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; WAITING COUNTER
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defn- waiting-value-of
|
|
"Values published to the waiting gauge for one limiter name, in order."
|
|
[name]
|
|
(->> @calls
|
|
(filter #(= :rpc-climit-waiting (:id %)))
|
|
(filter #(= name (first (:labels %))))
|
|
(map :val)
|
|
(vec)))
|
|
|
|
(defn- waiting-last
|
|
"Last value published to the waiting gauge for one limiter name. How many
|
|
publications land inside a window is not part of the contract, the value is,
|
|
so this is what the assertions read."
|
|
[name]
|
|
(last (waiting-value-of name)))
|
|
|
|
(defn- waiting-count
|
|
"Current waiting count of a limiter name, read from its component."
|
|
[climit name]
|
|
(.get ^java.util.concurrent.atomic.AtomicLong
|
|
(#'climit/waiting-counter (:counters climit) name)))
|
|
|
|
(defn- wait-until
|
|
"Wait until `pred` holds, or give up."
|
|
[pred timeout]
|
|
(let [deadline (+ (System/currentTimeMillis) timeout)]
|
|
(loop []
|
|
(cond
|
|
(pred) true
|
|
(> (System/currentTimeMillis) deadline) false
|
|
:else (do (Thread/sleep 10) (recur))))))
|
|
|
|
(defn- limiter-name
|
|
"The name climit publishes for a limiter id: the keyword without the colon."
|
|
[id]
|
|
(subs (str id) 1))
|
|
|
|
(defn- limiter-cfg
|
|
[climit name key]
|
|
{::rpc/climit climit
|
|
::climit/id [[name key]]})
|
|
|
|
(t/deftest waiting-gauge-follows-the-queue
|
|
;; The value is the number of calls waiting for a permit: up when a call
|
|
;; starts waiting, down when the wait ends. The gauges that sampled the
|
|
;; limiter stats could not show this, because the value of whichever limiter
|
|
;; of the name ran last was the one that survived.
|
|
(record-metrics
|
|
(fn []
|
|
(th/with-climit [climit {:test-waiting/by-profile {:permits 1 :timeout 3000}}]
|
|
(let [name (limiter-name :test-waiting/by-profile)
|
|
entered (promise)
|
|
gate (promise)
|
|
holder (future (climit/invoke! (limiter-cfg climit :test-waiting/by-profile "a")
|
|
(fn [_ _] (deliver entered true) @gate :held)
|
|
{}))]
|
|
|
|
(t/is (true? (deref entered 5000 false)))
|
|
|
|
;; the holder is running, not waiting
|
|
(reset! calls [])
|
|
(let [waiter (future (climit/invoke! (limiter-cfg climit :test-waiting/by-profile "a")
|
|
(fn [_ _] :waited)
|
|
{}))]
|
|
(t/is (true? (wait-until #(= 1 (waiting-last name)) 5000)))
|
|
(t/is (= 1 (waiting-count climit name)))
|
|
|
|
(deliver gate true)
|
|
(t/is (= :waited (deref waiter 5000 :timeout)))
|
|
(t/is (= :held (deref holder 5000 :timeout)))
|
|
(t/is (true? (wait-until #(= 0 (waiting-last name)) 5000)))
|
|
(t/is (= 0 (waiting-count climit name)))))))))
|
|
|
|
(t/deftest waiting-gauge-sums-every-key-of-a-name
|
|
;; Two profiles queueing on the same limiter name add up. That is the shape
|
|
;; that stayed invisible in production: one series per name, counting the
|
|
;; entries and the exits, instead of one value per limiter overwritten by the
|
|
;; next one.
|
|
(record-metrics
|
|
(fn []
|
|
(th/with-climit [climit {:test-waiting-keys/by-profile {:permits 1 :timeout 3000}}]
|
|
(let [name (limiter-name :test-waiting-keys/by-profile)
|
|
gates {:a (promise) :b (promise)}
|
|
holders
|
|
(into {}
|
|
(map (fn [k]
|
|
(let [entered (promise)]
|
|
[k {:entered entered
|
|
:future (future (climit/invoke! (limiter-cfg climit :test-waiting-keys/by-profile k)
|
|
(fn [_ _]
|
|
(deliver entered true)
|
|
@(gates k)
|
|
:held)
|
|
{}))}])))
|
|
[:a :b])]
|
|
|
|
(doseq [k [:a :b]]
|
|
(t/is (true? (deref (:entered (holders k)) 5000 false))))
|
|
|
|
;; one call per key, waiting behind its own holder
|
|
(let [waiters (into {}
|
|
(map (fn [k]
|
|
[k (future (climit/invoke! (limiter-cfg climit :test-waiting-keys/by-profile k)
|
|
(fn [_ _] :waited)
|
|
{}))]))
|
|
[:a :b])]
|
|
|
|
(t/is (true? (wait-until #(= 2 (waiting-count climit name)) 5000)))
|
|
;; the increment that reaches 2 always publishes 2, whichever waiter
|
|
;; does it last: the order of two concurrent publications is not the
|
|
;; contract, the value is.
|
|
(t/is (some #(= 2 %) (waiting-value-of name)))
|
|
|
|
(doseq [k [:a :b]] (deliver (gates k) true))
|
|
|
|
(doseq [k [:a :b]]
|
|
(t/is (= :waited (deref (waiters k) 5000 :timeout)))
|
|
(t/is (= :held (deref (:future (holders k)) 5000 :timeout))))
|
|
|
|
(t/is (true? (wait-until #(= 0 (waiting-count climit name)) 5000)))
|
|
(t/is (some #(= 0 %) (waiting-value-of name)))))))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; REJECTIONS
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defn- rejections
|
|
"Rejections published, as [name reason] pairs."
|
|
[]
|
|
(->> @calls
|
|
(filter #(= :rpc-climit-rejected (:id %)))
|
|
(map (fn [call] [(first (:labels call)) (second (:labels call))]))
|
|
(vec)))
|
|
|
|
(t/deftest rejection-counter-tells-why
|
|
;; Rejections had no metric: the panel counted them from the logs. Now a call
|
|
;; rejected because the queue is full and a call rejected because it waited
|
|
;; too long are counted apart.
|
|
(record-metrics
|
|
(fn []
|
|
(th/with-climit [climit {:test-rejected/by-profile {:permits 1 :queue 1 :timeout 60000}}]
|
|
(let [name (limiter-name :test-rejected/by-profile)
|
|
cfg (limiter-cfg climit :test-rejected/by-profile "a")
|
|
entered (promise)
|
|
gate (promise)
|
|
holder (future (climit/invoke! cfg
|
|
(fn [_ _] (deliver entered true) @gate :held)
|
|
{}))]
|
|
|
|
(t/is (true? (deref entered 5000 false)))
|
|
(reset! calls [])
|
|
|
|
;; the queue holds one waiter, so the next call is rejected right away
|
|
(let [queued (future (failed-type #(climit/invoke! cfg (fn [_ _] :queued) {})))]
|
|
(t/is (true? (wait-until #(= 1 (waiting-last name)) 5000)))
|
|
(t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :extra) {}))))
|
|
(t/is (= [[name "queue-full"]] (rejections)))
|
|
|
|
(deliver gate true)
|
|
(t/is (= :held (deref holder 5000 :timeout)))
|
|
(t/is (= :queued (deref queued 5000 :timeout))))))
|
|
|
|
;; a wait that times out is counted as such, not as a full queue
|
|
(th/with-climit [climit {:test-timeout/by-profile {:permits 1 :timeout 300}}]
|
|
(let [name (limiter-name :test-timeout/by-profile)
|
|
cfg (limiter-cfg climit :test-timeout/by-profile "a")
|
|
entered (promise)
|
|
gate (promise)
|
|
holder (future (climit/invoke! cfg
|
|
(fn [_ _] (deliver entered true) @gate :held)
|
|
{}))]
|
|
|
|
(t/is (true? (deref entered 5000 false)))
|
|
(reset! calls [])
|
|
(t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :late) {}))))
|
|
(t/is (= [[name "timeout"]] (rejections)))
|
|
|
|
(deliver gate true)
|
|
(t/is (= :held (deref holder 5000 :timeout))))))))
|
|
|
|
(t/deftest the-waiting-count-comes-back-after-a-rejection
|
|
;; A rejected call increments and decrements exactly once: a limiter that
|
|
;; rejects under pressure must not drift the panel up.
|
|
(record-metrics
|
|
(fn []
|
|
(th/with-climit [climit {:test-rejected-baseline/by-profile {:permits 1 :queue 0 :timeout 3000}}]
|
|
(let [name (limiter-name :test-rejected-baseline/by-profile)
|
|
cfg (limiter-cfg climit :test-rejected-baseline/by-profile "a")
|
|
entered (promise)
|
|
gate (promise)
|
|
holder (future (climit/invoke! cfg
|
|
(fn [_ _] (deliver entered true) @gate :held)
|
|
{}))]
|
|
|
|
(t/is (true? (deref entered 5000 false)))
|
|
(reset! calls [])
|
|
|
|
(t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :extra) {}))))
|
|
(t/is (= 0 (waiting-count climit name)))
|
|
(t/is (true? (wait-until #(= 0 (waiting-last name)) 5000)))
|
|
|
|
(deliver gate true)
|
|
(t/is (= :held (deref holder 5000 :timeout))))))))
|
|
|
|
(t/deftest the-waiting-count-comes-back-when-the-handler-throws
|
|
;; A handler that throws still leaves the count where it was.
|
|
(record-metrics
|
|
(fn []
|
|
(th/with-climit [climit {:test-handler-throws/by-profile {:permits 1 :timeout 3000}}]
|
|
(let [name (limiter-name :test-handler-throws/by-profile)
|
|
cfg (limiter-cfg climit :test-handler-throws/by-profile "a")]
|
|
|
|
(reset! calls [])
|
|
(t/is (thrown? clojure.lang.ExceptionInfo
|
|
(climit/invoke! cfg (fn [_ _] (throw (ex-info "boom" {}))) {})))
|
|
|
|
(t/is (= 0 (waiting-count climit name)))
|
|
(t/is (true? (wait-until #(= 0 (waiting-last name)) 5000))))))))
|
|
|
|
(defn- failing-first-waiting-publish
|
|
"A `mtx/run!` replacement that fails the first waiting publication and records
|
|
the rest, so the decrement of the unwinding path still lands."
|
|
[calls]
|
|
(let [pending (atom true)]
|
|
(fn [_ & {:keys [id labels val]}]
|
|
(if (and @pending (= :rpc-climit-waiting id))
|
|
(do (reset! pending false)
|
|
(throw (ex-info "publishing is down" {})))
|
|
(swap! calls conj {:id id :labels (vec labels) :val val})))))
|
|
|
|
(t/deftest a-throwable-while-waiting-leaves-no-mark-and-no-count
|
|
;; `waiting-inc` sits inside the `try`: a throwable from the publishing path
|
|
;; unwinds through the `finally`, so the thread neither keeps the limiter
|
|
;; marked as held (it would skip that limiter for that key for ever) nor
|
|
;; leaves the count up.
|
|
(th/with-climit [climit {:test-throw-waiting/by-profile {:permits 1 :timeout 3000}}]
|
|
(let [name (limiter-name :test-throw-waiting/by-profile)
|
|
cfg (limiter-cfg climit :test-throw-waiting/by-profile "a")]
|
|
|
|
(reset! calls [])
|
|
(with-redefs [mtx/run! (failing-first-waiting-publish calls)]
|
|
(t/is (thrown? clojure.lang.ExceptionInfo
|
|
(climit/invoke! cfg (fn [_ _] :never) {}))))
|
|
|
|
;; the count came back down instead of staying up for good
|
|
(t/is (= 0 (waiting-count climit name)))
|
|
(t/is (= [0] (waiting-value-of name)))
|
|
|
|
;; and the thread dropped the mark: with it left behind, this call would
|
|
;; run inline instead of waiting for the permit another thread holds.
|
|
(let [entered (promise)
|
|
gate (promise)
|
|
holder (future (climit/invoke! cfg
|
|
(fn [_ _] (deliver entered true) @gate :held)
|
|
{}))]
|
|
(t/is (true? (deref entered 5000 false)))
|
|
(t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :inline) {}))))
|
|
(deliver gate true)
|
|
(t/is (= :held (deref holder 5000 :timeout)))))))
|
|
|
|
(defn- gauge-value
|
|
"Current value of the waiting gauge for one limiter name, read from a real
|
|
prometheus collector."
|
|
[metrics name]
|
|
(let [collector (:app.metrics.definition/instance
|
|
(mtx/get-collector metrics :rpc-climit-waiting))]
|
|
(.get ^io.prometheus.client.Gauge$Child
|
|
(.labels ^io.prometheus.client.Gauge collector (into-array String [name])))))
|
|
|
|
(t/deftest the-waiting-gauge-reaches-a-real-registry
|
|
;; The recorded calls prove the arguments, not that a collector with that name
|
|
;; and those labels exists: a typo in the metric id or a wrong label arity
|
|
;; would only show up in production. Here the value is read back from a real
|
|
;; prometheus registry.
|
|
(let [metrics (ig/init-key :app.metrics/metrics {:default main/default-metrics})
|
|
name (limiter-name :test-registry/by-profile)]
|
|
|
|
(th/with-climit [climit {:test-registry/by-profile {:permits 1 :timeout 3000}} metrics]
|
|
(let [cfg (limiter-cfg climit :test-registry/by-profile "a")
|
|
entered (promise)
|
|
gate (promise)
|
|
holder (future (climit/invoke! cfg
|
|
(fn [_ _] (deliver entered true) @gate :held)
|
|
{}))]
|
|
|
|
(t/is (true? (deref entered 5000 false)))
|
|
|
|
(let [waiter (future (climit/invoke! cfg (fn [_ _] :waited) {}))]
|
|
(t/is (true? (wait-until #(= 1.0 (gauge-value metrics name)) 5000)))
|
|
(deliver gate true)
|
|
(t/is (= :waited (deref waiter 5000 :timeout)))
|
|
(t/is (= :held (deref holder 5000 :timeout)))
|
|
(t/is (true? (wait-until #(= 0.0 (gauge-value metrics name)) 5000))))))))
|
|
|
|
(defn- rejected-record
|
|
"The first captured log record that reports a rejection with `reason`."
|
|
[records reason]
|
|
(let [deadline (+ (System/currentTimeMillis) 5000)]
|
|
(loop []
|
|
(let [record (first (filter (fn [record]
|
|
(let [props (::l/props record)]
|
|
(and (= "rejected" (get props :hint))
|
|
(= reason (get props :reason)))))
|
|
@records))]
|
|
(cond
|
|
record record
|
|
(> (System/currentTimeMillis) deadline) nil
|
|
:else (do (Thread/sleep 10) (recur)))))))
|
|
|
|
(t/deftest rejection-logs-say-why-and-how-big-the-limits-are
|
|
;; The line has to say whether the queue was full or the wait timed out, and
|
|
;; how many permits and how much queue the limiter has, or nobody can tell
|
|
;; whether the limiter did its job.
|
|
(let [records (atom [])]
|
|
(add-watch l/log-record ::capture
|
|
(fn [_ _ _ record] (swap! records conj record)))
|
|
(try
|
|
(record-metrics
|
|
(fn []
|
|
(th/with-climit [climit {:test-log/by-profile {:permits 1 :queue 2 :timeout 60000}}]
|
|
(let [name (limiter-name :test-log/by-profile)
|
|
cfg (limiter-cfg climit :test-log/by-profile "a")
|
|
entered (promise)
|
|
gate (promise)
|
|
holder (future (climit/invoke! cfg
|
|
(fn [_ _] (deliver entered true) @gate :held)
|
|
{}))]
|
|
|
|
(t/is (true? (deref entered 5000 false)))
|
|
(reset! calls [])
|
|
|
|
(let [q1 (future (climit/invoke! cfg (fn [_ _] :q1) {}))
|
|
q2 (future (climit/invoke! cfg (fn [_ _] :q2) {}))]
|
|
|
|
;; the two of them fill the queue, so the next one is rejected
|
|
(t/is (true? (wait-until #(= 2 (waiting-count climit name)) 5000)))
|
|
(t/is (some #(= 2 %) (waiting-value-of name)))
|
|
(t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :x) {}))))
|
|
|
|
(let [props (::l/props (rejected-record records "queue-full"))]
|
|
(t/is (= 1 (get props :permits)))
|
|
(t/is (= 2 (get props :queue-max)))
|
|
(t/is (= :capacity-limit-reached (get props :code)))
|
|
(t/is (= "queue-full" (get props :reason))))
|
|
|
|
(deliver gate true)
|
|
(t/is (= :held (deref holder 5000 :timeout)))
|
|
(deref q1 5000 nil)
|
|
(deref q2 5000 nil))))
|
|
|
|
;; A wait that times out carries its own reason in the line, next to the
|
|
;; code the limiter raised. The line is emitted at TRACE when nothing
|
|
;; else is left queued behind it (the caller was the only one waiting),
|
|
;; so the call to `log` is captured instead of the record, which the
|
|
;; logger would drop.
|
|
(th/with-climit [climit {:test-log-timeout/by-profile {:permits 1 :timeout 300}}]
|
|
(let [cfg (limiter-cfg climit :test-log-timeout/by-profile "a")
|
|
lines (atom [])
|
|
entered (promise)
|
|
gate (promise)
|
|
capture (fn [_ action _req-id _stats _elapsed code reason]
|
|
(swap! lines conj [action code reason]))
|
|
holder (future (climit/invoke! cfg
|
|
(fn [_ _] (deliver entered true) @gate :held)
|
|
{}))]
|
|
|
|
(t/is (true? (deref entered 5000 false)))
|
|
|
|
(with-redefs [climit/log capture]
|
|
(t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :late) {}))))
|
|
(t/is (= [["rejected" :capacity-limit-reached "timeout"]]
|
|
(filterv #(= "rejected" (first %)) @lines))))
|
|
|
|
(deliver gate true)
|
|
(t/is (= :held (deref holder 5000 :timeout)))))))
|
|
|
|
(finally
|
|
(remove-watch l/log-record ::capture)))))
|
|
|
|
(t/deftest republish-writes-the-current-waiting-count
|
|
;; A call that never comes back keeps the count up, but if it is also the
|
|
;; last thing that happened nothing rewrites that series and it goes stale in
|
|
;; the scrape. The republish is what keeps it visible. The counters are built
|
|
;; on their own: with a component, its sampler would also publish the same
|
|
;; count the moment its first tick lands inside the window.
|
|
(let [counters (#'climit/create-waiting-counters)
|
|
name (limiter-name :test-republish/by-profile)
|
|
counter ^java.util.concurrent.atomic.AtomicLong (#'climit/waiting-counter counters name)]
|
|
|
|
(.set counter 3)
|
|
|
|
(record-metrics
|
|
(fn []
|
|
(#'climit/republish-waiting (th/metrics-stub) counters)
|
|
(t/is (= [3] (waiting-value-of name)))))))
|
|
(t/deftest halting-the-component-stops-the-republisher
|
|
;; The republisher is a daemon thread started with the component: halting it
|
|
;; has to stop the thread, or every restart leaves another one running.
|
|
(let [climit (th/init-climit {:test-halt/by-profile {:permits 1 :timeout 3000}})
|
|
sampler ^java.util.concurrent.ScheduledThreadPoolExecutor (:sampler climit)]
|
|
|
|
(t/is (false? (.isShutdown sampler)))
|
|
|
|
(ig/halt-key! ::rpc/climit climit)
|
|
|
|
(t/is (true? (.isShutdown sampler)))))
|
|
|
|
(t/deftest the-climit-metrics-that-remain-are-defined
|
|
;; A typo in a metric id, or a definition that is not there, makes the
|
|
;; publishing silent, which is the failure mode this work removes. And the
|
|
;; gauges that could not tell the truth must be gone.
|
|
(let [metrics (ig/init-key :app.metrics/metrics {:default main/default-metrics})]
|
|
|
|
(t/is (= ["name"]
|
|
(:app.metrics.definition/labels (mtx/get-collector metrics :rpc-climit-waiting))))
|
|
(t/is (= ["name" "reason"]
|
|
(:app.metrics.definition/labels (mtx/get-collector metrics :rpc-climit-rejected))))
|
|
(t/is (some? (mtx/get-collector metrics :rpc-climit-timing)))
|
|
|
|
(t/is (nil? (mtx/get-collector metrics :rpc-climit-queue)))
|
|
(t/is (nil? (mtx/get-collector metrics :rpc-climit-permits)))
|
|
(t/is (nil? (mtx/get-collector metrics :http-worker-busy-threads)))))
|