Skip to content

Commit 8a4739d

Browse files
committed
Use route-level FIFO coercion cache (100)
Coercion cache is now at route-level for better isolation. Cache is for 100 entries (schema + matcher pairs), which should be more than enough for standard use. But using anonymous, route-specific coercers might cause route-spefic cache to fill up, which is reported via a log.
1 parent 5194c1f commit 8a4739d

File tree

2 files changed

+79
-10
lines changed

2 files changed

+79
-10
lines changed

src/compojure/api/meta.clj

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
[schema.core :as s]
1414
[schema.coerce :as sc]
1515
[schema.utils :as su]
16-
[schema-tools.core :as st]))
16+
[schema-tools.core :as st]
17+
[linked.core :as linked]
18+
[compojure.api.impl.logging :as logging]))
1719

1820
;;
1921
;; Meta Evil
@@ -27,6 +29,10 @@
2729
"lexically bound meta-data for handlers."
2830
'+compojure-api-meta+)
2931

32+
(def +compojure-api-coercer+
33+
"lexically bound (caching) coercer for handlers."
34+
'+compojure-api-coercer+)
35+
3036
(defmacro meta-container [meta & form]
3137
`(let [accumulated-meta# (get-local +compojure-api-meta+)
3238
~'+compojure-api-meta+ (deep-merge accumulated-meta# ~meta)]
@@ -47,7 +53,38 @@
4753
;; Schema
4854
;;
4955

50-
(def memoized-coercer (memoize sc/coercer))
56+
(defn memoized-coercer
57+
"Returns a memoized version of a referentially transparent coercer fn. The
58+
memoized version of the function keeps a cache of the mapping from arguments
59+
to results and, when calls with the same arguments are repeated often, has
60+
higher performance at the expense of higher memory use. FIFO with 100 entries.
61+
If the cached if filled, there will be a WARNING logged at least once -> root cause
62+
is most propably that the route is using anonymous coercer, which doesn't hit
63+
the cache. It will produce slower performance, but works otherwise as expected."
64+
[name]
65+
(let [cache (atom {:mem (linked/map), :overflow false})
66+
cache-size 100]
67+
(fn [& args]
68+
(or (-> @cache :mem (get args))
69+
(let [coercer (apply sc/coercer args)]
70+
(swap! cache (fn [cache]
71+
(let [mem (assoc (:mem cache) args coercer)]
72+
(if (>= (count mem) cache-size)
73+
(do
74+
(when-not (:overflow cache)
75+
;; side-effecting within a swap! might cause multiple writes.
76+
;; it's ok'ish as we are just reporting something that should be
77+
;; fixes at development time
78+
(logging/log! :warning (str "Coercion memoization cache for " name
79+
" maxing at " cache-size ". "
80+
"You might recreate the coercer "
81+
"matcher on each request, causing "
82+
"coercer re-compilation per request, "
83+
"effecting coercion performance.")))
84+
{:mem (dissoc mem (-> mem first first))
85+
:overflow true})
86+
(assoc cache :mem mem)))))
87+
coercer)))))
5188

5289
(defn strict [schema]
5390
(dissoc schema 'schema.core/Keyword))
@@ -57,12 +94,12 @@
5794
(fnk-impl/letk-input-schema-and-body-form
5895
nil (with-meta bind {:schema s/Any}) [] nil)))
5996

60-
(defn body-coercer-middleware [handler responses]
97+
(defn body-coercer-middleware [handler coercer responses]
6198
(fn [request]
6299
(if-let [{:keys [status] :as response} (handler request)]
63100
(if-let [schema (:schema (responses status))]
64101
(if-let [matcher (:response (mw/get-coercion-matcher-provider request))]
65-
(let [coerce (memoized-coercer (value-of schema) matcher)
102+
(let [coerce (coercer (value-of schema) matcher)
66103
body (coerce (:body response))]
67104
(if (su/error? body)
68105
(throw+ (assoc body :type ::ex/response-validation))
@@ -79,7 +116,7 @@
79116
(assert (not (#{:query :json} type)) (str type " is DEPRECATED since 0.22.0. Use :body or :string instead."))
80117
`(let [value# (keywordize-keys (~key ~+compojure-api-request+))]
81118
(if-let [matcher# (~type (mw/get-coercion-matcher-provider ~+compojure-api-request+))]
82-
(let [coerce# (memoized-coercer ~schema matcher#)
119+
(let [coerce# (~+compojure-api-coercer+ ~schema matcher#)
83120
result# (coerce# value#)]
84121
(if (su/error? result#)
85122
(throw+ (assoc result# :type ::ex/request-validation))
@@ -115,9 +152,9 @@
115152
;;
116153

117154
(defmulti restructure-param
118-
"Restructures a key value pair in smart routes. By default the key
119-
is consumed form the :parameters map in acc. k = given key, v = value."
120-
(fn [k v acc] k))
155+
"Restructures a key value pair in smart routes. By default the key
156+
is consumed form the :parameters map in acc. k = given key, v = value."
157+
(fn [k v acc] k))
121158

122159
;;
123160
;; Pass-through swagger metadata
@@ -321,6 +358,7 @@
321358
(defn restructure [method [path arg & args] & [{:keys [body-wrap]}]]
322359
(let [body-wrap (or body-wrap 'do)
323360
method-symbol (symbol (str (-> method meta :ns) "/" (-> method meta :name)))
361+
coercer-name (str (keyword (.toLowerCase (name method-symbol))) " " path)
324362
[parameters body] (extract-parameters args)
325363
[lets letks responses middlewares] [[] [] nil nil]
326364
[lets arg-with-request arg] (destructure-compojure-api-request lets arg)
@@ -343,5 +381,6 @@
343381
body (if (seq middlewares) `(route-middlewares ~middlewares ~body ~arg) body)
344382
body (if (seq parameters) `(meta-container ~parameters ~body) body)
345383
body `(~method-symbol ~path ~arg-with-request ~body)
346-
body (if responses `(body-coercer-middleware ~body ~responses) body)]
384+
body (if responses `(body-coercer-middleware ~body ~+compojure-api-coercer+ ~responses) body)
385+
body `(let [~+compojure-api-coercer+ (memoized-coercer ~coercer-name)] ~body)]
347386
body))

test/compojure/api/coercion_test.clj

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,34 @@
111111
(fact "no coercion"
112112
(let [[status body] (get* app "/no-coercion" {:i 10})]
113113
status => 200
114-
body => {:i "10"})))))
114+
body => {:i "10"}))))
115+
116+
(fact "anonymous matchers, with 100+ calls to same endpoint"
117+
118+
(fact "at api-level, matcher is reused and the coercion matcher cache is not filled"
119+
(let [app (api
120+
{:coercion (assoc mw/default-coercion-matchers :string (constantly nil))}
121+
(GET* "/anonymous" []
122+
:query-params [i :- s/Str]
123+
(ok {:i i})))]
124+
125+
(dotimes [_ 200]
126+
(let [[status body] (get* app "/anonymous" {:i "10"})]
127+
status => 200
128+
body => {:i "10"})) => nil
129+
(provided
130+
(compojure.api.impl.logging/log! & anything) => irrelevant :times 0)))
131+
132+
(fact "at route-level, matcher is NOT reused and the the coercion matcher cache is filled"
133+
(let [app (api
134+
(GET* "/anonymous" []
135+
:coercion (constantly (assoc mw/default-coercion-matchers :string (constantly nil)))
136+
:query-params [i :- s/Str]
137+
(ok {:i i})))]
138+
139+
(dotimes [_ 200]
140+
(let [[status body] (get* app "/anonymous" {:i "10"})]
141+
status => 200
142+
body => {:i "10"})) => nil
143+
(provided
144+
(compojure.api.impl.logging/log! & anything) => irrelevant :times 1)))))

0 commit comments

Comments
 (0)