Skip to content

Instantly share code, notes, and snippets.

@mourjo
Forked from gfredericks/with-local-redefs.clj
Last active July 27, 2024 12:07
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")))))
@rdivyanshu
Copy link

rdivyanshu commented Apr 2, 2019

@mourjo It is not regression. It is due to laziness https://cemerick.com/2009/11/03/be-mindful-of-clojures-binding/

@mourjo
Copy link
Author

mourjo commented May 13, 2019

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

@rdivyanshu
Copy link

rdivyanshu commented May 16, 2019

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.

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

@rutchkiwi
Copy link

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?

@mourjo
Copy link
Author

mourjo commented Jul 24, 2024

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

@rutchkiwi
Copy link

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.

@mourjo
Copy link
Author

mourjo commented Jul 27, 2024

Absolutely, I would love your contribution!

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