Фреймворк Jepsen и его минусы

Язык программирования

Jepsen написана на языке Clojure. Википедия пишет, что это “современный диалект Лиспа, язык программирования общего назначения с поддержкой разработки в интерактивном режиме, поощряющий функциональное программирование и упрощающий поддержку многопоточности. Clojure работает на платформах JVM и CLR”. Если вы захотите использовать Jepsen, то вам определённо прийдётся выучить Clojure, без этого вы не сможете ни читать чужие тесты, ни писать свои.

Типичный пример кода на Clojure:

(setup! [this test node]
(locking BankClientWithLua
  (let [conn (cl/open node test)]
   (Thread/sleep 10000) ; wait for leader election and joining to a cluster
   (when (= node (first (db/primaries test)))
     (cl/with-conn-failure-retry conn
       (info (str "Creating table " table-name))
       (j/execute! conn [(str "CREATE TABLE IF NOT EXISTS " table-name
             "(id INT NOT NULL PRIMARY KEY,
             balance INT NOT NULL)")])
       (doseq [a (:accounts test)]
       (info "Populating account")
       (sql/insert! conn table-name {:id      a
                     :balance (if (= a (first (:accounts test)))
                           (:total-amount test)
                           0)}))))
    (assoc this :conn conn :node node))))

Помимо знания Clojure желательно иметь знакомство с функциональным программированием и немного с Java. У меня до этого был только опыт использования императивных языков, а с Java меня судьба ещё не сталкивала. Поэтому задача немного усложнялась.

Clojure это совсем непопулярный язык для написания тестов. И это даже непопулярный язык для написания продуктового кода. Хотя у него много поклонников. Поэтому выбор именно Clojure для разработки тестов мне кажется странным. Учить новый язык программирования для того, чтобы написать тесты (пусть даже специальные), мне тоже кажется странной затеей.

Инженер TiDB говорит тоже самое:

Jepsen uses Clojure, a functional programming language running on the JVM. Although Clojure is powerful, I am not familiar with it and the same for most of my colleagues too. So it is hard for us to maintain TiDB in Jepsen with Clojure.

Роман Гребенников из Findify говорит:

Проблема в том, что Jepsen написан на Clojure, и тесты нужно писать тоже Clojure. Если бы была возможность писать их на чём-нибудь другом, было бы классно. Но беда, беда.

Если будете учить Clojure, то советую серию статей от Кайла в блоге. Я их собрал в электронную книжку для удобства чтения. Из книжек мне зашла “Clojure for the brave and true”.

Отсутствие тестовых примитивов

Jepsen объединяет в себе код для написания генераторов операций, код для настройки окружения и код для верификации истории операций (сейчас он вынесен в отдельный модуль Elle). Но в Jepsen абсолютно нет никаких примитивов, присущих тестовым фреймворкам, несмотря на то, что это библиотека для написания тестов. То есть код для выбора тестов, группировки, параметризации, временного исключения вам прийдётся написать самим и с нуля. Не то, чтобы это было суперсложно, но это лишняя работа, которую надо делать для каждого набора тестов.

Я видел, что для некоторых СУБД инженеры делают тесты с использованием Jepsen, а обвязку для запуска в CI на каком-нибудь скриптовом языке. Например так:

#!/bin/bash
for cacheMode in PARTITIONED REPLICATED LOCAL
do
  for cacheAtomicityMode in TRANSACTIONAL ATOMIC TRANSACTIONAL_SNAPSHOT
  do
    for cacheWriteSynchronizationMode in FULL_SYNC PRIMARY_SYNC FULL_ASYNC
    do
      for transactionIsolation in SERIALIZABLE READ_COMMITTED REPEATABLE_READ
      do
        for transactionConcurrency in OPTIMISTIC PESSIMISTIC
        do
          for readFromBackup in true false
          do
            eval lein run test --nodes-file nodes --username pprbusr --password pprbusr --concurrency 10 --time-limit 180 --name \\\"Ignite\\\" --cacheMode \\\"$cacheMode\\\" --cacheAtomicityMode \\\"$cacheAtomicityMode\\\" --cacheWriteSynchronizationMode \\\"$cacheWriteSynchronizationMode\\\"  --transactionIsolation \\\"$transactionIsolation\\\" --readFromBackup \\\"$readFromBackup\\\" --transactionConcurrency \\\"$transactionConcurrency\\\"
          done
        done
      done
    done
  done
done

Просто потому что так быстрее и проще. Я заморочился и добавил всё, что было нужно с использованием Clojure.

Дублирование кода в тестах

В Jepsen есть интерфейс клиента, который должен иметь свою реализацию для разных тестов. Но для стандартных тестов (например для которых есть готовые чекеры историй операций) можно было бы сделать упрощенную реализацию интерфейса, чтобы не дублировать код. Приведу пример, чтобы было понятнее. Клиент для теста типа register должен иметь реализации методов:

  • setup для предварительной настройки системы
  • teardown для действий после завершения тестирования
  • open для создания подключения к удалённой системе
  • close для закрытия соединения
  • invoke, который принимает операции, выполняет их и обрабатывает ошибки во время выполнения операций.

В случае теста register у нас реализации методов open и close одинаковые для всех клиентов в случае тестирования одной системы, setup и teardown отличаются незначитально (обычно там DDL-операции в случае СУБД). Метод invoke в тесте register реализует два типа операций, каждая из которых может использоваться в других клиентах.

Код выполнения операций в тесте cas-register для TiDB:

(invoke! [this test op]
(c/with-error-handling op
  (c/with-txn-aborts op
    (j/with-db-transaction [c conn]
      (let [[id val'] (:value op)]
    (case (:f op)
      :read (assoc op
               :type  :ok
               :value (independent/tuple id (read c test id)))

      :write (do (c/execute! c [(str "insert into test (id, sk, val) "
                     "values (?, ?, ?) "
                     "on duplicate key update "
                     "val = ?")
                    id id val' val'])
             (assoc op :type :ok))

      :cas (let [[expected-val new-val] val'
             v   (read c test id)]
         (if (= v expected-val)
           (do (c/update! c :test {:val new-val} ["id = ?" id])
               (assoc op :type :ok))
           (assoc op :type :fail, :error :precondition-failed)))))))))

Код выполнения операций в тесте cas-register для etcd:

  Model
  (step [model op]
    (let [[op-version op-value] (:value op)
          version' (inc version)]
      (condp = (:f op)
        :write (if (and (not (nil? op-version))
                        (not= version' op-version))
                 (model/inconsistent
                   (str "can't go from version " version " to " op-version))
                 (VersionedRegister. version' op-value))

        :cas   (let [[v v'] op-value]
                 (cond (and (not (nil? op-version))
                            (not= version' op-version))
                       (model/inconsistent
                         (str "can't go from version " version " to "
                              op-version))

                       (not= value v)
                       (model/inconsistent (str "can't CAS " value " from " v
                                                " to " v'))

                       true
                       (VersionedRegister. version' v')))

        :read (cond (and (not (nil? op-version))
                         (not= version op-version))
                    (model/inconsistent
                      (str "can't read version " op-version " from version "
                           version))

                    (and (not (nil? op-value))
                         (not= value op-value))
                    (model/inconsistent
                      (str "can't read " op-value " from register " value))

                    true
                    model)))))

Код и там и там сильно похож друг на друга и я верю, что можно было по-другому спроектировать библиотеку, чтобы уменьшить дублирование кода. Другие примеры можно найти в репозитории с тестами Jepsen.

Отчасти эта проблема решена в Jecci.

Проблемы с параллелизмом

В Jepsen для нагрузки тестируемой системы используются треды. Где-то в библиотеке код для многотредовости написан неаккуратно и при запуске тестов эти проблемы дают о себе знать. Кайл об этом знает:

jepsen.control is plagued by what I think are race conditions in Jsch, which I’ve never had the chance to dig in and fix.

Для такой популярной в своей области библиотеки код мог бы быть и получше.

Низкая производительность генераторов операций

Одна из ключевых частей библиотеки это возможность описывать паттерн нагрузки с помощью генераторов. Одна операция описывается с помощью хэшмапы. Далее приведу примеры из документации к Jepsen:

Одна операция чтения: {:f :read}.

Операция, которая выполнит запись одного случайного значения: (fn [] {:f :write, :value (rand-int 5)).

В этом примере будут сгенерированы 10 операций записи с помощью стандартной в Clojure функции clojure.core/repeat: (repeat 10 (fn [] {:f :write, :value (rand-int 5))).

Выполнить 50 операций записи с уникальными значениями. В этом примере тоже используются стандартные функции Clojure: (->> (range) (map (fn [x] {:f :write, :value (rand-int 5)})) (take 50)).

И так далее. В Jepsen есть ещё набор специальных генераторов, которые позволяют добавить паузы между выполнением операций, повторить некоторую последовательность операций, скомбинировать операции из нескольких генераторов или синхронизировать выполнение операций из нескольких потоков. Все эти генераторы можно составлять в цепочку и на выходе будут сгенерированы операции с тем паттерном нагрузки, который нам нужен.

Вот такая цепочка генераторов сгенерирует случайным образом операции записи и CAS (compare-and-set) во всех тредах, 5 тредов будет выделено только под операции чтения:

(independent/concurrent-generator
10
(range)
(fn [k]
    (->> (gen/mix [w cas])
     (gen/reserve 5 r)
     (gen/limit 100))))

Тут важно отметить, что производительность генераторов определяет скорость выполнения отправки операций на удалённую систему.

В документации на библиотеку Кайл пишет:

Pure generators perform all generator-related computation on a single thread, and create additional garbage due to their pure functional approach. However, realistic generator tests yield rates over 20,000 operations/sec, which seems more than sufficient for Jepsen’s purposes.

Всего лишь 200k операций производит генератор Джепсен! Это значит, что количество операций, которыми Джепсен будет нагружать тестируемую систему, и того меньше. Выглядит не так много по сравнению с реальной нагрузкой на распределённые системы.

Теги: testingsoftwaretarantoolfeed