Skip to content

Commit 7b8b5d5

Browse files
committed
jepsen.core/run! now wraps :generator in a Forgettable reference.
We pass the test map around liberally during setup and execution, which creates lots of opportunities to retain references. If any component keeps hold of the test, it would also inadvertently retain the head of the generator--which is usually an infinite, lazy data structure. This breaking change immediately wraps the generator in a mutable Forgettable reference type (new in jepsen.util). It responds to IDeref, in case you *intend* to manipulate the generator for some reason, but is explicitly forgotten once the generator interpreter starts--allowing it to be garbage collected. The memory effects are staggering. Tests which exhausted a 16 GB heap in an hour can now run in 256 MB with almost nothing making it into oldgen.
1 parent 7f150fd commit 7b8b5d5

File tree

5 files changed

+120
-30
lines changed

5 files changed

+120
-30
lines changed

jepsen/src/jepsen/core.clj

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,18 @@
2020
[clojure.datafy :refer [datafy]]
2121
[dom-top.core :as dt :refer [assert+]]
2222
[fipp.edn :refer [pprint]]
23-
[jepsen.util :as util :refer [with-thread-name
23+
[jepsen [checker :as checker]
24+
[client :as client]
25+
[control :as control]
26+
[db :as db]
27+
[generator :as generator]
28+
[nemesis :as nemesis]
29+
[store :as store]
30+
[os :as os]
31+
[util :as util :refer [with-thread-name
2432
fcatch
2533
real-pmap
26-
relative-time-nanos]]
27-
[jepsen.os :as os]
28-
[jepsen.db :as db]
29-
[jepsen.control :as control]
30-
[jepsen.generator :as generator]
31-
[jepsen.checker :as checker]
32-
[jepsen.client :as client]
33-
[jepsen.nemesis :as nemesis]
34-
[jepsen.store :as store]
34+
relative-time-nanos]]]
3535
[jepsen.store.format :as store.format]
3636
[jepsen.control.util :as cu]
3737
[jepsen.generator [interpreter :as gen.interpreter]]
@@ -300,11 +300,14 @@
300300
(store/stop-logging!))))
301301

302302
(defn prepare-test
303-
"Takes a test and ensures it has a :start-time, :concurrency, and :barrier
304-
field. This operation always succeeds, and is necessary for accessing a
305-
test's store directory, which depends on :start-time. You may call this
306-
yourself before calling run!, if you need access to the store directory
307-
outside the run! context."
303+
"Takes a test and prepares it for running. Ensures it has a :start-time,
304+
:concurrency, and :barrier field. Wraps its generator in a forgettable
305+
reference, to prevent us from inadvertently retaining the head.
306+
307+
This operation always succeeds, and is necessary for accessing a test's store
308+
directory, which depends on :start-time. You may call this yourself before
309+
calling run!, if you need access to the store directory outside the run!
310+
context."
308311
[test]
309312
(cond-> test
310313
(not (:start-time test)) (assoc :start-time (util/local-time))
@@ -313,7 +316,8 @@
313316
(let [c (count (:nodes test))]
314317
(if (pos? c)
315318
(CyclicBarrier. (count (:nodes test)))
316-
::no-barrier)))))
319+
::no-barrier)))
320+
true (update :generator util/forgettable)))
317321

318322
(defn run!
319323
"Runs a test. Tests are maps containing

jepsen/src/jepsen/generator.clj

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -601,7 +601,15 @@
601601
gen')]
602602

603603
; This generator is exhausted; move on
604-
(recur (next this) test ctx))))))
604+
(recur (next this) test ctx)))))
605+
606+
; Forgettables are transparently unwrapped when treated as generators.
607+
jepsen.util.Forgettable
608+
(update [this test ctx event]
609+
(update @this test ctx event))
610+
611+
(op [this test ctx]
612+
(op @this test ctx)))
605613

606614
(defmacro extend-protocol-runtime
607615
"Extends a protocol to a runtime-defined class. Helpful because some Clojure

jepsen/src/jepsen/generator/interpreter.clj

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -182,13 +182,14 @@
182182
true))
183183

184184
(defn run!
185-
"Takes a test with a :store :handle open. Opens a writer for the test's
186-
history using that handle. Creates an initial context from test and evaluates
187-
all ops from (:gen test). Spawns a thread for each worker, and hands those
188-
workers operations from gen; each thread applies the operation using (:client
189-
test) or (:nemesis test), as appropriate. Invocations and completions are
190-
journaled to a history on disk. Returns a new test with no :generator and a
191-
completed :history.
185+
"Takes a test with a :store :handle open. Causes the test's reference to the
186+
:generator to be forgotten, to avoid retaining the head of infinite seqs.
187+
Opens a writer for the test's history using that handle. Creates an initial
188+
context from test and evaluates all ops from (:gen test). Spawns a thread for
189+
each worker, and hands those workers operations from gen; each thread applies
190+
the operation using (:client test) or (:nemesis test), as appropriate.
191+
Invocations and completions are journaled to a history on disk. Returns a new
192+
test with no :generator and a completed :history.
192193
193194
Generators are automatically wrapped in friendly-exception and validate.
194195
Clients are wrapped in a validator as well.
@@ -209,10 +210,11 @@
209210
worker-ids)
210211
invocations (into {} (map (juxt :id :in) workers))
211212
gen (->> (:generator test)
213+
deref
212214
gen/friendly-exceptions
213215
gen/validate)
214-
; Don't retain the head of the generator! If it's a lazy seq, this
215-
; will consume memory forever.
216+
; Forget generator
217+
_ (util/forget! (:generator test))
216218
test (dissoc test :generator)]
217219
(try+
218220
(loop [ctx ctx

jepsen/src/jepsen/util.clj

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,21 @@
33
(:refer-clojure :exclude [parse-long]) ; Clojure added this in 1.11.1
44
(:require [clojure.tools.logging :refer [info]]
55
[clojure.core.reducers :as r]
6-
[clojure [string :as str]]
7-
[clojure.pprint :refer [pprint]]
8-
[clojure.walk :as walk]
6+
[clojure [string :as str]
7+
[pprint :as pprint :refer [pprint]]
8+
[walk :as walk]]
99
[clojure.java [io :as io]
1010
[shell :as shell]]
1111
[clj-time.core :as time]
1212
[clj-time.local :as time.local]
1313
[clojure.tools.logging :refer [debug info warn]]
1414
[dom-top.core :as dt :refer [bounded-future]]
1515
[fipp [edn :as fipp]
16+
[ednize]
1617
[engine :as fipp.engine]]
1718
[jepsen [history :as h]]
1819
[jepsen.history.fold :refer [loopf]]
20+
[potemkin :refer [definterface+]]
1921
[slingshot.slingshot :refer [try+ throw+]]
2022
[tesser.core :as t])
2123
(:import (java.lang.reflect Method)
@@ -1025,3 +1027,63 @@
10251027

10261028
true
10271029
nil)))
1030+
1031+
(definterface+ IForgettable
1032+
(forget! [this]
1033+
"Allows this forgettable reference to be reclaimed by the GC at some
1034+
later time. Future attempts to dereference it may throw. Returns
1035+
self."))
1036+
1037+
(deftype Forgettable [^:unsynchronized-mutable x]
1038+
IForgettable
1039+
(forget! [this]
1040+
(set! x ::forgotten)
1041+
this)
1042+
1043+
clojure.lang.IDeref
1044+
(deref [this]
1045+
(let [x x]
1046+
(if (identical? x ::forgotten)
1047+
(throw+ {:type ::forgotten})
1048+
x)))
1049+
1050+
Object
1051+
(toString [this]
1052+
(let [x x]
1053+
(str "#<Forgettable " (if (identical? x ::forgotten)
1054+
"?"
1055+
x)
1056+
">")))
1057+
1058+
(equals [this other]
1059+
(identical? this other)))
1060+
1061+
(defn forgettable
1062+
"Constructs a deref-able reference to x which can be explicitly forgotten.
1063+
Helpful for controlling access to infinite seqs (e.g. the generator) when you
1064+
don't have firm control over everyone who might see them."
1065+
[x]
1066+
(Forgettable. x))
1067+
1068+
(defmethod pprint/simple-dispatch jepsen.util.Forgettable
1069+
[^Forgettable f]
1070+
(let [prefix (format "#<Forgettable ")]
1071+
(pprint/pprint-logical-block
1072+
:prefix prefix :suffix ">"
1073+
(pprint/pprint-indent :block (-> (count prefix) (- 2) -))
1074+
(pprint/pprint-newline :linear)
1075+
(pprint/write-out (try+ @f
1076+
(catch [:type ::forgotten] e
1077+
"?"))))))
1078+
1079+
(prefer-method pprint/simple-dispatch
1080+
jepsen.util.Forgettable clojure.lang.IDeref)
1081+
1082+
(extend-protocol fipp.ednize/IOverride jepsen.util.Forgettable)
1083+
(extend-protocol fipp.ednize/IEdn jepsen.util.Forgettable
1084+
(-edn [f]
1085+
(fipp.ednize/tagged-object f
1086+
(try+ @f
1087+
(catch [:type ::forgotten] e
1088+
'?)))))
1089+

jepsen/test/jepsen/util_test.clj

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
(ns jepsen.util-test
22
(:refer-clojure :exclude [parse-long])
3-
(:require [clojure.test :refer :all]
3+
(:require [clojure [pprint :refer [pprint]]
4+
[test :refer :all]]
5+
[fipp.edn :as fipp]
46
[jepsen [history :as h]
57
[util :refer :all]]))
68

@@ -174,3 +176,15 @@
174176
(is (< (* target-mean 0.7)
175177
mean
176178
(* target-mean 1.3)))))
179+
180+
(deftest forgettable-test
181+
(let [f (forgettable :foo)]
182+
(is (= :foo @f))
183+
(is (= "#<Forgettable :foo>" (str f)))
184+
(is (= "#<Forgettable :foo>\n" (with-out-str (pprint f))))
185+
(is (re-find #"^#object\[jepsen.util.Forgettable \"0x\w+\" :foo\]\n$"
186+
(with-out-str (fipp/pprint f))))
187+
(forget! f)
188+
(is (thrown-with-msg? clojure.lang.ExceptionInfo
189+
#"\{:type :jepsen\.util/forgotten\}"
190+
@f))))

0 commit comments

Comments
 (0)