Skip to content

Instantly share code, notes, and snippets.

@mourjo
Forked from gfredericks/with-local-redefs.clj
Last active October 10, 2023 13:25
Show Gist options
  • Save mourjo/c7fc03e59eb96f8a342dfcabd350a927 to your computer and use it in GitHub Desktop.
Save mourjo/c7fc03e59eb96f8a342dfcabd350a927 to your computer and use it in GitHub Desktop.
thread-local version of with-redefs
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; 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")))))
@uwo
Copy link

uwo commented Jun 10, 2019

@mourjo Just had this issue pop up on us. Thanks for sharing!

@filipesilva
Copy link

@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.

@filipesilva
Copy link

For whoever finds this, the author actually made a lib that works with my example above: https://github.com/mourjo/dynamic-redef

@rdivyanshu
Copy link

rdivyanshu commented Oct 10, 2023

@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.

@filipesilva
Copy link

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment