-
-
Save mourjo/c7fc03e59eb96f8a342dfcabd350a927 to your computer and use it in GitHub Desktop.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; | |
;; Commentary ;; | |
;; ;; | |
;; The goal for writing this started with the idea to have tests run in ;; | |
;; parallel using the leiningen plugin eftest ;; | |
;; https://github.com/weavejester/eftest. ;; | |
;; ;; | |
;; With tests using with-redefs, it was not possible to run them in ;; | |
;; parallel if they were changing the root binding of the same ;; | |
;; vars. Here, we are binding the root of the var to one function that ;; | |
;; respects per-thread rebindings, if any exist. ;; | |
;; ;; | |
;; Known caveats: ;; | |
;; - This per-therad rebinding will only work with clojure concurrency ;; | |
;; primitives which copy per-thread bindings to newly spawned threads, ;; | |
;; eg, using clojure futures. But will not work for, say a ;; | |
;; java.lang.Thread. ;; | |
;; - As of now this only supports functions being bound and not other ;; | |
;; vars which store values, say (def x 19) for example. ;; | |
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; | |
(def ^:dynamic local-redefinitions {}) | |
(defn current->original-definition | |
[v] | |
(when (var? v) | |
(get (meta v) ::original))) | |
(defn redefiniton-fn | |
[a-var] | |
(fn [& args] | |
(let [current-f (get local-redefinitions | |
a-var | |
(current->original-definition a-var))] | |
(apply current-f args)))) | |
(defn dynamic-redefs | |
[vars func] | |
(let [un-redefs (remove #(::already-bound? (meta %)) vars)] | |
(doseq [a-var un-redefs] | |
(locking a-var | |
(when-not (::already-bound? (meta a-var)) | |
(let [old-val (.getRawRoot ^clojure.lang.Var a-var)] | |
(.bindRoot ^clojure.lang.Var a-var | |
(redefiniton-fn a-var)) | |
(alter-meta! a-var | |
(fn [m] | |
(assoc m | |
::already-bound? true | |
::original old-val)))))))) | |
(func)) | |
(defn xs->map | |
[xs] | |
(reduce (fn [acc [k v]] (assoc acc `(var ~k) v)) | |
{} | |
(partition 2 xs))) | |
(defmacro with-dynamic-redefs | |
[bindings & body] | |
;; @TODO: Add support for non-functions | |
(let [map-bindings (xs->map bindings)] | |
`(let [old-rebindings# local-redefinitions] | |
(binding [local-redefinitions (merge old-rebindings# ~map-bindings)] | |
(dynamic-redefs ~(vec (keys map-bindings)) | |
(fn [] ~@body)))))) | |
(comment ;; for testing | |
(defn funk [& args] {:original-args args}) | |
(dotimes [i 1000] | |
(let [f1 (future (with-dynamic-redefs [funk (constantly -100)] | |
(Thread/sleep (rand-int 10)) | |
{:100 (funk) :t (.getName (Thread/currentThread))})) | |
f2 (future (with-dynamic-redefs [funk (constantly -200)] | |
(Thread/sleep (rand-int 1000)) | |
{:200 (funk 9) :t (.getName (Thread/currentThread))})) | |
f3 (future (do | |
(Thread/sleep (rand-int 1000)) | |
{:orig (funk 9) :t (.getName (Thread/currentThread))}))] | |
(when (or (not= (:100 @f1) -100) | |
(not= (:200 @f2) -200) | |
(not= (:orig @f3) {:original-args '(9)})) | |
(println "FAIL") | |
(prn @f1) | |
(prn @f2) | |
(println "----------------\n\n"))))) |
@rdivyanshu I tried your approach but it doesn't seem to work if the Vars have already been compiled:
;; this is the source
(ns ptesttrial.core)
(defn funk [a]
a)
;; this is the test
(ns ptesttrial.core-test
(:require [clojure.test :refer :all]
[ptesttrial.core :as pc]))
(deftest b-test
(is (= 99 (pc/funk 99)))
(.setDynamic #'pc/funk true)
(binding [pc/funk (constantly -1)]
(is (= -1 (pc/funk 99)))))
This test case fails:
expected: (= -1 (pc/funk 99))
actual: (not (= -1 99))
Clojure docs say the following https://clojure.org/reference/vars#interning :
If a def expression does not find an interned entry in the current namespace for the symbol being def-ed, it creates one, otherwise it uses the existing Var. This find-or-create process is called interning. This means that, unless they have been unmap-ed, Var objects are stable references and need not be looked up every time.
I also tried using ns-unmap
and it does not seem to work.
(ns ptesttrial.core-test
(:require [clojure.test :refer :all]
[ptesttrial.core :as pc]))
(deftest b-test
(is (= 99 (pc/funk 99)))
(.setDynamic #'pc/funk true)
(ns-unmap 'ptesttrial.core 'funk)
(binding [pc/funk (constantly -1)]
(is (= -1 (pc/funk 99)))))
;; FAIL in (b-test) (core_test.clj:11)
;; expected: (= -1 (pc/funk 99))
;; actual: (not (= -1 99))
This works fine.
(ns ptesttrial.core-test
(:require [clojure.test :refer :all]
[ptesttrial.core :as pc]))
(defmacro with-dynamic-redefs
[bindings & body]
(let [vars (map (fn [x] `(var ~x)) (take-nth 2 bindings))]
(doall (map #(.setDynamic (eval %)) vars))
`(binding ~bindings ~@body)))
(deftest b-test
(is (= 99 (pc/funk 99)))
(with-dynamic-redefs [pc/funk (constantly -1)]
(is (= -1 (pc/funk 99)))))
Any reason for not doing this. Problem with your approach is setDynamic was not run during compilation so compiler emitted getRawRoot at call site (https://github.com/clojure/clojure/blob/b19b781b1f0f3f46aee5e951f415e0456a39cbcb/src/jvm/clojure/lang/Compiler.java#L5191). deftest put content of body in function which don't get invoked during compilation where as use of
defmacro make it run at compile.
@mourjo Just had this issue pop up on us. Thanks for sharing!
@rdivyanshu was trying to use your macro as a drop-in replacement to with-redefs
, but think I've hit a problem.
The direct invocation usage works great:
(defn get-thing []
:none)
;; fails, usually
(deftest with-redefs-not-thread-safe
(let [results (pmap (fn [i]
(with-redefs [get-thing (constantly i)]
(get-thing)))
(range 100))]
(is (= (range 100) results))))
(defmacro with-dynamic-redefs
[bindings & body]
(let [vars (map (fn [x] `(var ~x)) (take-nth 2 bindings))]
(doall (map #(.setDynamic (eval %)) vars))
`(binding ~bindings ~@body)))
;; doesn't seem to fail?
(deftest with-dynamic-redefs-is-thread-safe
(let [results (pmap (fn [i]
(with-dynamic-redefs [get-thing (constantly i)]
(get-thing)))
(range 100))]
(is (= (range 100) results))))
But if I add indirection, it won't work until I re-evaluate the caller:
(defn get-thing []
:none)
(defn get-indirect-thing []
(get-thing))
;; fails, usually
(deftest with-redefs-not-thread-safe
(let [results (pmap (fn [i]
(with-redefs [get-thing (constantly i)]
(get-indirect-thing)))
(range 100))]
(is (= (range 100) results))))
(defmacro with-dynamic-redefs
[bindings & body]
(let [vars (map (fn [x] `(var ~x)) (take-nth 2 bindings))]
(doall (map #(.setDynamic (eval %)) vars))
`(binding ~bindings ~@body)))
;; fails too
(deftest with-dynamic-redefs-is-thread-safe
(let [results (pmap (fn [i]
(with-dynamic-redefs [get-thing (constantly i)]
(get-indirect-thing)))
(range 100))]
(is (= (range 100) results))))
;; evaluating the caller again will make the test work
(defn get-indirect-thing []
(get-thing))
;; now it doesn't fail
(deftest with-dynamic-redefs-is-thread-safe
(let [results (pmap (fn [i]
(with-dynamic-redefs [get-thing (constantly i)]
(get-indirect-thing)))
(range 100))]
(is (= (range 100) results))))
It looks like the same class of problem as before: callers that were defined before the macro runs get a non-dynamic version of the var.
For whoever finds this, the author actually made a lib that works with my example above: https://github.com/mourjo/dynamic-redef
@filipesilva It is happening due to there is no dynamic invocation of function get-thing. In get-indirect-thing
you are invoking during definition. if you define get-indirect-thing according to below
(defn get-indirect-thing [f]
(f))
And then call (get-indirect-thing get-thing)
test passes. What you want in get-indirect-thing
at run time is call current definition of get-thing but it is calling get-thing at time of definition. I am not sure if clojure provides such mechanism for functions with no arguments, it has been long since I wrote clojure code.
Hey thanks for getting back to me, wasn't expecting it at all :D
https://github.com/mourjo/dynamic-redef seems to handle the indirection case well so trying it out atm.
Thanks for this! Trying to understand this code - how are redefs reset when you go out of scope of the macro? looks like the var is permanently changed?
Yes the var is permanently changed. That is probably a good enough reason to only use this in tests. How it works is: the redefined var uses a dynamic (thread local) var to store local redefinitions.
As an aside: You might want to check out this repository (created based on this gist): https://github.com/mourjo/dynamic-redef
Ah okay. Would you accept a PR to have it remove itself once done, if I do that? (to the repo that is).
For my workflow permanently changing it means the changes leak out into my local dev env.
Absolutely, I would love your contribution!
@mourjo It is not regression. It is due to laziness https://cemerick.com/2009/11/03/be-mindful-of-clojures-binding/