Skip to content

Latest commit

 

History

History
201 lines (173 loc) · 7.52 KB

04-checker.md

File metadata and controls

201 lines (173 loc) · 7.52 KB

Checking Correctness

With our generator and clients performing operations, we've got a history to analyze for correctness. Jepsen uses a model to represent the abstract behavior of a system, and a checker to verify whether the history conforms to that model. We'll require knossos.model and jepsen.checker:

(ns jepsen.etcdemo
  (:require [clojure.tools.logging :refer :all]
            [clojure.string :as str]
            [jepsen [checker :as checker]
                    [cli :as cli]
                    [client :as client]
                    [control :as c]
                    [db :as db]
                    [generator :as gen]
                    [tests :as tests]]
            [jepsen.control.util :as cu]
            [jepsen.os.debian :as debian]
            [knossos.model :as model]
            [slingshot.slingshot :refer [try+]]
            [verschlimmbesserung.core :as v])
  (:import (knossos.model Model)))

Remember how we chose to model our operations as reads, writes, and cas operations?

(defn r   [_ _] {:type :invoke, :f :read, :value nil})
(defn w   [_ _] {:type :invoke, :f :write, :value (rand-int 5)})
(defn cas [_ _] {:type :invoke, :f :cas, :value [(rand-int 5) (rand-int 5)]})

Jepsen doesn't know what :f :read or :f :cas mean. As far as it's concerned, they're arbitrary values. However, our client understands how to interpret those operations, when it dispatches based on (case (:f op) :read ...). Now we need a model of the system which understands those same operations. Knossos defines a Model data type for us, which takes a model and an operation to apply, and returns a new model resulting from that operation. Here's that code, inside knossos.model:

(definterface+ Model
  (step [model op]
        "The job of a model is to *validate* that a sequence of operations
        applied to it is consistent. Each invocation of (step model op)
        returns a new state of the model, or, if the operation was
        inconsistent with the model's state, returns a (knossos/inconsistent
        msg). (reduce step model history) then validates that a particular
        history is valid, and returns the final state of the model.
        Models should be a pure, deterministic function of their state and an
        operation's :f and :value."))

It turns out that the Knossos checker defines some common models for things like locks and registers. Here's one for a compare-and-set register--exactly the datatype we're modeling.

(defrecord CASRegister [value]
  Model
  (step [r op]
    (condp = (:f op)
      :write (CASRegister. (:value op))
      :cas   (let [[cur new] (:value op)]
               (if (= cur value)
                 (CASRegister. new)
                 (inconsistent (str "can't CAS " value " from " cur
                                    " to " new))))
      :read  (if (or (nil? (:value op))
                     (= value (:value op)))
               r
               (inconsistent (str "can't read " (:value op)
                                  " from register " value))))))

We don't need to write these in our tests, as long as knossos provides a model for the type of thing we're checking. This is just so you can see how things work under the hood.

This defrecord defines a new data type called CASRegister, which has a single, immutable field, called value. It satisfies the Model interface we discussed earlier, and its step function takes a current register r, and an operation op. When we want to write a new value, we simply return a new CASRegister with that value assigned. To compare-and-set from one value to another, we pull apart the current and new values from the operation, and if the current and new values match, construct a fresh register with the new value. If they don't match, we return a special type of model with inconsistent, which indicates that that operation could not be applied to the register. Reads are similar, except that we always allow reads of nil to pass through. This allows us to satisfy histories which include reads that never returned.

To analyze the history, we'll specify a :checker for the test, and provide a :model to specify how the system should behave. checker/linearizable uses the Knossos linearizability checker to verify that every operation appears to take place atomically between its invocation and completion. The linearizable checker requires a model and to specify a particular algorithm which we pass to it in an options map.

(defn etcd-test
  "Given an options map from the command line runner (e.g. :nodes, :ssh,
  :concurrency ...), constructs a test map."
  [opts]
  (merge tests/noop-test
         opts
         {:pure-generators true
          :name            "etcd"
          :os              debian/os
          :db              (db "v3.1.5")
          :client          (Client. nil)
          :checker         (checker/linearizable
                             {:model     (model/cas-register)
                              :algorithm :linear})
          :generator       (->> (gen/mix [r w cas])
                                (gen/stagger 1)
                                (gen/nemesis nil)
                                (gen/time-limit 15))}))

Running the test, we can confirm the checker's results:

$ lein run test
...
INFO [2019-04-17 17:38:16,855] jepsen worker 0 - jepsen.util 0  :invoke :write  1
INFO [2019-04-17 17:38:16,861] jepsen worker 0 - jepsen.util 0  :ok :write  1
...
INFO [2019-04-18 03:53:32,714] jepsen test runner - jepsen.core {:valid? true,
 :configs
 ({:model #knossos.model.CASRegister{:value 3},
   :last-op
   {:process 1,
    :type :ok,
    :f :write,
    :value 3,
    :index 29,
    :time 14105346871},
   :pending []}),
 :analyzer :linear,
 :final-paths ()}


Everything looks good! ヽ(‘ー`)ノ

The last operation in this history was a write of 1, and sure enough, the checker's final value is also 1. This history was linearizable.

Multiple checkers

Checkers can render all kinds of output--as data structures, images, or interactive visualizations. For instance, if we have gnuplot installed, Jepsen can generate throughput and latency graphs for us. Let's use checker/compose to run both a linearizability analysis, and generate performance graphs.

         :checker (checker/compose
                     {:perf   (checker/perf)
                      :linear (checker/linearizable {:model     (model/cas-register)
                                                     :algorithm :linear})})
$ lein run test
...
$ open store/latest/latency-raw.png

We can also generate HTML visualizations of the history. Let's add the jepsen.checker.timeline namespace:

(ns jepsen.etcdemo
  (:require ...
            [jepsen.checker.timeline :as timeline]
            ...))

And add that checker to the test:

          :checker (checker/compose
                     {:perf   (checker/perf)
                      :linear (checker/linearizable
                                {:model     (model/cas-register)
                                 :algorithm :linear})
                      :timeline (timeline/html)})

Now we can plot how different processes performed operations over time--which ones were concurrent, which ones succeeded, failed, or crashed, and so on.

$ lein run test
...
$ open store/latest/timeline.html

Now that we've got a passing test, it's time to introduce failures into the system.