Commit Diff


commit - /dev/null
commit + f62394fc3e88e0059d3416a532b6c3ddfb888571
blob - /dev/null
blob + 1609fd86ea31d9ec6eac2356b9320869390442e6 (mode 644)
--- /dev/null
+++ .github/workflows/check.yaml
@@ -0,0 +1,27 @@
+name: Static analysis
+
+on:
+  push:
+  pull_request:
+
+jobs:
+  static-analysis:
+    if: |
+      github.event_name == 'push' ||
+      github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository
+    strategy:
+      fail-fast: false
+    runs-on: [ubuntu-latest]
+    steps:
+      - uses: actions/checkout@v3
+
+      - name: Setup luarocks
+        run: sudo apt install -y luarocks
+
+      - name: Setup dependencies
+        run: make deps
+
+      - run: echo $(luarocks path --lr-bin) >> $GITHUB_PATH
+
+      - name: Run static analysis
+        run: make check
blob - /dev/null
blob + aa884db49c25da46cb9d896c59da8949a7e7b23a (mode 644)
--- /dev/null
+++ .github/workflows/publish.yaml
@@ -0,0 +1,71 @@
+name: Publish
+
+on:
+  push:
+    branches: [master]
+    tags: ['*']
+
+jobs:
+  publish-scm-1:
+    if: github.ref == 'refs/heads/master'
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+
+      - name: Setup luarocks
+        run: sudo apt install -y luarocks
+
+      - name: Setup cjson
+        run: luarocks install --local lua-cjson
+
+      - name: Upload rockspec scm-1
+        run: luarocks upload --force --api-key=${{ secrets.LUAROCKS_API_KEY }} molly-scm-1.rockspec
+
+  publish-tag:
+    if: startsWith(github.ref, 'refs/tags/')
+    runs-on: ubuntu-latest
+    env:
+      TAG: ${GITHUB_REF##*/}
+    steps:
+      # https://github.com/luarocks/luarocks/wiki/Types-of-rocks
+      - uses: actions/checkout@v3
+
+      - name: Setup luarocks
+        run: sudo apt install -y luarocks
+
+      - name: Setup cjson
+        run: luarocks install --local lua-cjson
+
+      - name: Make a release
+        run: |
+          luarocks new_version --tag ${{ env.TAG }}
+          luarocks install --local molly-${{ env.TAG }}-1.rockspec
+          luarocks pack molly-${{ env.TAG }}-1.rockspec
+
+      - name: Upload .rockspec and .src.rock
+        run: |
+            luarocks upload --api-key=${{ secrets.LUAROCKS_API_KEY }} molly-${{ env.TAG }}-1.rockspec molly-${{ env.TAG }}-1.src.rock
+
+  publish-ldoc:
+    if: startsWith(github.ref, 'refs/tags/')
+    runs-on: ubuntu-latest
+    steps:
+      - name: Clone the module
+        uses: actions/checkout@v3
+
+      - name: Setup luarocks
+        run: sudo apt install -y luarocks
+
+      - name: Setup dependencies
+        run: make deps-dev
+
+      - run: echo $(luarocks path --lr-bin) >> $GITHUB_PATH
+
+      - name: Build documentation with LDoc
+        run: make doc
+
+      - name: Publish generated API documentation to GitHub Pages
+        uses: JamesIves/github-pages-deploy-action@4.1.4
+        with:
+          branch: gh-pages
+          folder: doc/html
blob - /dev/null
blob + 55f52e56172bcdeae4defa94d6059ad7d9150f2c (mode 644)
--- /dev/null
+++ .github/workflows/test.yaml
@@ -0,0 +1,53 @@
+name: Testing
+
+on:
+  push:
+  pull_request:
+
+jobs:
+  testing:
+    if: |
+      github.event_name == 'push' ||
+      github.event_name == 'pull_request' &&
+      github.event.pull_request.head.repo.full_name != github.repository
+    strategy:
+      matrix:
+        LUA: ['luajit-2.0.5', 'tarantool']
+      fail-fast: false
+    runs-on: [ubuntu-latest]
+    steps:
+      - uses: actions/checkout@v3
+
+      - name: Setup Tarantool
+        if: matrix.LUA == 'tarantool'
+        uses: tarantool/setup-tarantool@v1
+        with:
+          tarantool-version: '2.10'
+
+      - name: Setup LuaJIT (${{ matrix.LUA }})
+        if: matrix.LUA != 'tarantool'
+        uses: leafo/gh-actions-lua@v8
+        with:
+          luaVersion: ${{ matrix.LUA }}
+
+      - name: Setup luarocks
+        run: sudo apt install -y luarocks
+
+      - name: Setup SQLite devepelopment package
+        run: sudo apt install -y sqlite3 libsqlite3-dev
+
+      - name: Setup dependencies
+        run: make deps
+
+      - run: echo $(luarocks path --lr-bin) >> $GITHUB_PATH
+
+      - name: Run tests with Tarantool and send coverage to Coveralls.io
+        if: matrix.LUA == 'tarantool'
+        run: make coveralls
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          DEV: ON
+
+      - name: Run tests with LuaJIT (${{ matrix.LUA }})
+        if: matrix.LUA != 'tarantool'
+        run: LUAJIT_BIN=$(pwd)/.lua/bin/luajit DEV=ON make test-luajit
blob - /dev/null
blob + f588a57f4dddda1496718c50e87382843a466cd8 (mode 644)
--- /dev/null
+++ .gitignore
@@ -0,0 +1,10 @@
+luacov.stats.out
+luacov.report.out
+history.txt
+history.json
+history.edn
+.rocks
+00000000000000000000.snap
+00000000000000000000.xlog
+doc/html
+tags
blob - /dev/null
blob + 1533d796195ce153f9ef3f31a5275ccd01a33c78 (mode 644)
--- /dev/null
+++ .luacheckrc
@@ -0,0 +1,39 @@
+globals = {
+    "box",
+    "checkers",
+    "package",
+}
+
+ignore = {
+    -- Accessing an undefined field of a global variable <debug>.
+    "143/debug",
+    -- Accessing an undefined field of a global variable <os>.
+    "143/os",
+    -- Accessing an undefined field of a global variable <string>.
+    "143/string",
+    -- Accessing an undefined field of a global variable <table>.
+    "143/table",
+    -- Unused argument <self>.
+    "212/self",
+    -- Shadowing an upvalue.
+    "431",
+}
+
+files["molly/tests.lua"] = {
+    ignore = {
+        -- Line is too long.
+        "631"
+    }
+}
+
+include_files = {
+    '.luacheckrc',
+    '*.rockspec',
+    '**/*.lua',
+}
+
+exclude_files = {
+    '.rocks',
+    'test/tap.lua',
+    '3rd-party-tests',
+}
blob - /dev/null
blob + 6d7828f06380445ce9710a4d79c7bc7b0bde2d9a (mode 644)
--- /dev/null
+++ .luacov
@@ -0,0 +1,6 @@
+exclude = {
+    '/test/',
+    '/.rocks/',
+    '/.luarocks',
+}
+tick = true
blob - /dev/null
blob + 7ecb96da9aabbc3f19a7c4e7349b7fdacb96c1d2 (mode 644)
--- /dev/null
+++ CHANGELOG.md
@@ -0,0 +1,20 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+Initial version of a Jepsen-like framework written in Lua programming language.
+
+### Added
+
+- Compatibility with a Jepsen-history format.
+- Support of Tarantool fibers.
+- Support of Lua coroutines.
+- GH Actions workflows with check, testing, publishing actions.
+- Luarocks spec.
+- Examples with SQLite tests.
+- Generators with `list-append` and `rw-register` operations.
blob - /dev/null
blob + 2052f941632b40374b8b5909cbcdeada8c67bbd0 (mode 644)
--- /dev/null
+++ LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2021-2023 Sergey Bronnikov
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
blob - /dev/null
blob + 4ed5e36d8dcf6b41c74e99ea271f03dbd73c445a (mode 644)
--- /dev/null
+++ Makefile
@@ -0,0 +1,92 @@
+# This way everything works as expected ever for
+# `make -C /path/to/project` or
+# `make -f /path/to/project/Makefile`.
+MAKEFILE_PATH := $(abspath $(lastword $(MAKEFILE_LIST)))
+PROJECT_DIR := $(patsubst %/,%,$(dir $(MAKEFILE_PATH)))
+
+LUACOV_REPORT := $(PROJECT_DIR)/luacov.report.out
+LUACOV_STATS := $(PROJECT_DIR)/luacov.stats.out
+
+CLEANUP_FILES  = ${LUACOV_STATS}
+CLEANUP_FILES += ${LUACOV_REPORT}
+CLEANUP_FILES += history.txt
+CLEANUP_FILES += history.json
+
+TEST_FILES ?= test/tests.lua
+
+TARANTOOL_BIN ?= /usr/bin/tarantool
+LUAJIT_BIN ?= /usr/bin/luajit
+
+LUA_PATH ?= "?/init.lua;./?.lua;$(shell luarocks path --lr-path)"
+LUA_CPATH ?= "$(shell luarocks path --lr-cpath)"
+
+DEV ?= OFF
+
+all: check test
+
+doc:
+	@ldoc -c $(PROJECT_DIR)/doc/config.ld -v \
+              -d $(PROJECT_DIR)/doc/html/ \
+                 $(PROJECT_DIR)/molly
+
+deps: deps-runtime deps-dev
+
+deps-dev:
+	@echo "Setup development dependencies"
+	luarocks install --local luacheck 1.1.0
+	luarocks install --local luacov 0.15.0
+	luarocks install --local cluacov 0.1.1
+	luarocks install --local luacov-coveralls 0.2.3
+	luarocks install --local ldoc 1.4.2
+	luarocks install --local lsqlite3 0.9.5
+
+deps-runtime:
+	@echo "Setup runtime dependencies"
+	luarocks install --local lua-cjson 2.1.0.10-1
+	luarocks install --local https://raw.githubusercontent.com/luafun/luafun/master/fun-scm-1.rockspec
+	luarocks make --local molly-scm-1.rockspec
+
+install:
+	@install -d -m 755 $(LUADIR)/molly
+	@install -m 644 $(PROJECT_DIR)/molly/*.lua \
+		        $(LUADIR)/molly
+	@install -d -m 755 $(LUADIR)/molly/compat
+	@install -m 644 $(PROJECT_DIR)/molly/compat/*.lua \
+		        $(LUADIR)/molly/compat
+
+check: luacheck
+
+luacheck:
+	@luacheck --config $(PROJECT_DIR)/.luacheckrc --codes $(PROJECT_DIR)
+
+test-example:
+	@echo "Run SQLite examples with Tarantool"
+	@$(TARANTOOL_BIN) test/examples/sqlite-rw-register.lua
+	@$(TARANTOOL_BIN) test/examples/sqlite-list-append.lua
+
+test-tarantool:
+	@echo "Run regression tests with Tarantool"
+	@DEV=$(DEV) $(TARANTOOL_BIN) $(TEST_FILES)
+
+test-luajit:
+	@echo "Run regression tests with LuaJIT"
+	@DEV=$(DEV) LUA_PATH=$(LUA_PATH) LUA_CPATH=$(LUA_CPATH) $(LUAJIT_BIN) $(TEST_FILES)
+
+test: test-tarantool test-luajit
+
+$(LUACOV_STATS): test-tarantool test-example
+
+coverage: $(LUACOV_STATS)
+	@sed -i -e 's@'"$$(realpath .)"'/@@' $(LUACOV_STATS)
+	@cd $(PROJECT_DIR) && luacov ^molly
+	@grep -A999 '^Summary' $(LUACOV_REPORT)
+
+coveralls: coverage
+	@echo "Send code coverage data to the coveralls.io service"
+	@luacov-coveralls --include ^molly --verbose --repo-token ${GITHUB_TOKEN}
+
+clean:
+	@rm -f ${CLEANUP_FILES}
+
+.PHONY: test test-example test-tarantool test-luajit install coveralls coverage
+.PHONY: luacheck check doc deps-dev deps-runtime deps
blob - /dev/null
blob + 9459077213deea9e9870412cfc29da6f14687a21 (mode 644)
--- /dev/null
+++ README.md
@@ -0,0 +1,94 @@
+[![Static analysis](https://github.com/ligurio/molly/actions/workflows/check.yaml/badge.svg)](https://github.com/ligurio/molly/actions/workflows/check.yaml)
+[![Testing](https://github.com/ligurio/molly/actions/workflows/test.yaml/badge.svg)](https://github.com/ligurio/molly/actions/workflows/test.yaml)
+[![Coverage Status](https://coveralls.io/repos/github/ligurio/molly/badge.svg)](https://coveralls.io/github/ligurio/molly)
+[![Luarocks](https://img.shields.io/luarocks/v/ligurio/molly/scm-1)](https://luarocks.org/modules/ligurio/molly)
+
+## Molly
+
+is a framework for distributed systems verification, with fault injection.
+
+### Prerequisites
+
+- Lua interpreter: LuaJIT or LuaJIT-based is recommended.
+- [luafun](https://luafun.github.io/) - Lua functional library, built-in into
+  Tarantool.
+- [lua-cjson](https://github.com/mpx/lua-cjson) - Lua library for fast JSON
+  encoding and decoding, built-in into Tarantool.
+- (optional) Jepsen-compatible consistency checker. For example
+  [elle-cli](https://github.com/ligurio/elle-cli), based on Jepsen, Elle and
+  Knossos.
+
+### Installation
+
+- Download and setup Lua interpreter, [LuaJIT](https://luajit.org/install.html)
+  or LuaJIT-based is recommended (for example
+  [Tarantool](https://www.tarantool.io/download/)).
+- Install library using LuaRocks:
+
+```sh
+$ luarocks install --local molly
+```
+
+NOTE: Installation of modules `luafun` and `lua-cjson` is not required when
+Tarantool is used, both modules are built-in there. Install them manually in
+case of using LuaJIT:
+
+```sh
+$ make deps-runtime
+```
+
+### Documentation
+
+See documentation in https://ligurio.github.io/molly/.
+
+### Examples
+
+See also an examples in [test/examples/](/test/examples/) for SQLite database
+engine:
+- `sqlite-rw-register.lua` contains a simple test that concurrently runs `get`
+  and `set` operations on SQLite DB
+- `sqlite-list-append.lua` contains a simple test that concurrently runs `read`
+  and `append` operations on SQLite DB
+
+For running examples you need installed an SQLite development package and
+[LuaRocks](https://github.com/luarocks/luarocks/wiki/Download).
+
+```sh
+$ sudo apt install -y sqlite3 libsqlite3-dev
+$ make deps
+$ make test-example
+```
+
+Example produces two files with history: `history.txt` and `history.json`. With
+[elle-cli](https://github.com/ligurio/elle-cli#usage) history can be checked
+for consistency:
+
+```sh
+$ VER=0.1.4
+$ curl -O -L https://github.com/ligurio/elle-cli/releases/download/${VER}/elle-cli-bin-${VER}.zip
+$ unzip elle-cli-bin-${VER}.zip
+$ java -jar ./target/elle-cli-${VER}-standalone.jar -m elle-rw-register history.json
+history.json        true
+```
+
+See tests that uses Molly library in https://github.com/ligurio/molly-tests.
+
+### Hacking
+
+For developing `molly` you need to install: either LuaJIT or LuaJIT-based
+and [LuaRocks](https://github.com/luarocks/luarocks/wiki/Download).
+
+```sh
+$ make deps
+$ export PATH=$PATH:$(luarocks path --lr-bin)
+$ make check
+$ make test
+```
+
+You are ready to make patches!
+
+### License
+
+Copyright © 2021-2023 [Sergey Bronnikov](https://bronevichok.ru/)
+
+Distributed under the ISC License.
blob - /dev/null
blob + f2dd3a0cc2de59e6658fced151b60ebc3f6c748a (mode 644)
--- /dev/null
+++ doc/config.ld
@@ -0,0 +1,21 @@
+project = 'molly'
+title = 'Molly documentation'
+description = 'A framework for distributed systems verification, with fault injection'
+no_lua_ref = false
+no_summary = false
+ignore = true
+all = true
+not_luadoc = true
+format = 'discount'
+readme = 'README.md'
+
+examples = {
+    'test/examples/sqlite-rw-register.lua',
+    'test/examples/sqlite-list-append.lua',
+}
+
+tparam_alias('table', 'table')
+tparam_alias('integer', 'integer')
+tparam_alias('boolean', 'boolean')
+
+-- vim: ft=lua:
blob - /dev/null
blob + 23ba86e17476af1582d634161ace1ef6236b4037 (mode 644)
--- /dev/null
+++ molly/checks.lua
@@ -0,0 +1,57 @@
+-- Alternative implementation of checks() in Lua.
+-- Slower than the C counterpart, but no compilation and porting concerns.
+--
+-- Copyright (c) 2006-2013 Fabien Fleutot and others.
+--
+-- All rights reserved.
+--
+-- This program and the accompanying materials are made available
+-- under the terms of the Eclipse Public License v1.0 which
+-- accompanies this distribution, and is available at
+-- http://www.eclipse.org/legal/epl-v10.html
+--
+-- This program and the accompanying materials are also made available
+-- under the terms of the MIT public license which accompanies this
+-- distribution, and is available at http://www.lua.org/license.html
+--
+-- Contributors:
+--     Fabien Fleutot - API and implementation
+--
+
+checkers = { }
+
+local function check_one(expected, val)
+    if type(val)==expected then return true end
+    local mt = getmetatable(val)
+    if mt and mt.__type==expected then return true end
+    local f = checkers[expected]
+    if f and f(val) then return true end
+    return false
+end
+
+local function check_many(_, expected, val)
+    if expected=='?' then return true
+    elseif expected=='!' then return (val~=nil)
+    elseif type(expected) ~= 'string' then
+        error 'strings expected by checks()'
+    elseif val==nil and expected :sub(1,1) == '?' then return true end
+    for one in expected :gmatch "[^|?]+" do
+        if check_one(one, val) then return true end
+    end
+    return false
+end
+
+local function checks(...)
+    for i, arg in ipairs{...} do
+        local name, val = debug.getlocal(2, i)
+        local success = check_many(name, arg, val)
+        if not success then
+            local fname = debug.getinfo(2, 'n').name
+            local fmt = "bad argument #%d to '%s' (%s expected, got %s)"
+            local msg = string.format(fmt, i, fname or "?", arg, type(val))
+            error(msg, 3)
+        end
+    end
+end
+
+return checks
blob - /dev/null
blob + 226b07db3953549d976d8f8565fe7f6f5a67346a (mode 644)
--- /dev/null
+++ molly/client.lua
@@ -0,0 +1,166 @@
+---- Module with default Molly client.
+-- @module molly.client
+--
+-- @see molly.gen
+-- @see molly.tests
+
+local clock = require('molly.clock')
+local dev_checks = require('molly.dev_checks')
+local log = require('molly.log')
+local op_lib = require('molly.op')
+
+local shared_gen_state
+local op_index = 1
+
+local function process_operation(client, history, op, thread_id_str, thread_id)
+    dev_checks('<client>', '<history>', 'function|table', 'string', 'number')
+
+    if type(op) == 'function' then -- FIXME: check for callable object
+        op = op()
+    end
+
+    assert(type(op) == 'table', 'Type of operation is not a Lua table.')
+
+    op.type = 'invoke'
+    op.process = thread_id
+    op.index = op_index
+    op_index = op_index + 1
+    op.time = clock.monotonic64()
+    log.debug('%-4s %s', thread_id_str, op_lib.to_string(op))
+    history:add(op)
+    local ok, res = pcall(client.invoke, client, op)
+    if not ok then
+        log.warn('Process %d crashed (%s)', thread_id, res)
+        res.type = 'fail'
+        return
+    end
+    if res.type == nil then
+        error('Operation type is empty.')
+    end
+    res.index = op_index
+    op_index = op_index + 1
+    res.process = thread_id
+    res.time = clock.monotonic64()
+    log.debug('%-4s %s', thread_id_str, op_lib.to_string(res))
+    history:add(res)
+end
+
+local function invoke(thread_id, opts)
+    dev_checks('number', 'table')
+
+    local client = opts.client
+    local ops_generator = opts.gen
+    local history = opts.history
+
+    local nth = math.random(1, table.getn(opts.nodes)) -- TODO: Use fun.cycle() and closure.
+    local addr = opts.nodes[nth]
+
+    log.debug('Opening connection by thread %d to DB (%s)', thread_id, addr)
+    local ok, err = pcall(client.open, client, addr)
+    if not ok then
+        return false, err
+    end
+
+    log.debug('Setting up DB (%s) by thread %d', addr, thread_id)
+    ok, err = pcall(client.setup, client)
+    if not ok then
+        return false, err
+    end
+
+    -- TODO: Add barrier here.
+
+    local gen, param, state = ops_generator:unwrap()
+    shared_gen_state = state
+    local op
+    local thread_id_str = '[' .. tostring(thread_id) .. ']'
+    while true do
+        state, op = gen(param, shared_gen_state)
+        if state == nil then
+            break
+        end
+        shared_gen_state = state
+        ok, err = pcall(process_operation, client, history, op, thread_id_str, thread_id)
+        if ok == false then
+            error('Failed to process an operation', err)
+        end
+
+        require('fiber').yield()
+    end
+
+    -- TODO: Add barrier here.
+
+    log.debug('Tearing down DB (%s) by thread %d', addr, thread_id)
+    ok, err = pcall(client.teardown, client)
+    if not ok then
+        return false, err
+    end
+
+    log.debug('Closing connection to DB (%s) by thread %d', addr, thread_id)
+    ok, err = pcall(client.close, client)
+    if not ok then
+        return false, err
+    end
+
+    return true, nil
+end
+
+-- https://www.lua.org/pil/16.2.html
+
+local client_mt = {
+    __type = '<client>',
+    __index = {
+        open = function() return true end,
+        setup = function() return true end,
+        invoke = function() return {} end,
+        teardown = function() return true end,
+        close = function() return true end,
+    }
+}
+
+--- Function that returns a default client implementation.
+--
+-- Default implementation of a client defines open, setup, teardown and close
+-- methods with empty implementation that always returns true.
+--
+-- Client must implement the following methods:
+--
+-- **open** - function that open a connection to a database instance. Function
+-- must return a boolean value, true in case of success and false otherwise.
+--
+-- **setup** - function that set up a database instance. Function must return a
+-- boolean value, true in case of success and false otherwise.
+--
+-- **invoke** - function that accept an operation and invoke it on database
+-- instance, function should process user-defined types of operations and
+-- execute intended actions on databases. Function must return an operation
+-- after invokation.
+--
+-- **teardown** - function that tear down a database instance. Function must
+-- return a boolean value, true in case of success and false otherwise.
+--
+-- **close** - function that close connection to a database instance. Function
+-- must return a boolean value, true in case of success and false otherwise.
+--
+-- In general it is recommended to raise an error in case of fatal errors like
+-- failed database setup, teardown or connection and set status of operation to
+-- 'fail' when key is not found in database table etc.
+--
+-- @return client
+-- @usage
+-- local client = require('molly').client.new()
+-- client.invoke = function(op)
+--     return true
+-- end
+--
+-- @function new
+local function new()
+    return setmetatable({
+        storage = {},
+    }, client_mt)
+end
+
+return {
+    new = new,
+
+    invoke = invoke,        -- A wrapper for user-defined invoke.
+}
blob - /dev/null
blob + f2398ccc6ad15773ced1b413e709239e0cedead8 (mode 644)
--- /dev/null
+++ molly/clock.lua
@@ -0,0 +1,84 @@
+-- Module with helpers to use with clocks, time and date.
+-- @module molly.clock
+
+local is_tarantool = require('molly.utils').is_tarantool
+
+local clock = {}
+
+if is_tarantool() then
+    local clock_lib = require('clock')
+    clock.monotonic64 = clock_lib.monotonic64
+    clock.monotonic = clock_lib.monotonic
+    clock.proc = clock_lib.proc
+    clock.sleep = require('fiber').sleep
+else
+    clock = require('molly.compat.clock_ffi')
+end
+
+-- Sleep for the specified number of seconds. `clock.sleep` works as
+-- `fiber.sleep` when Tarantool is used, because with fibers it additionally
+-- yields control to the scheduler, see
+-- [Tarantool documentation](https://www.tarantool.io/en/doc/latest/reference/reference_lua/fiber/#fiber-sleep).
+-- @number time Number of seconds to sleep.
+-- @return nil
+--
+-- @function sleep
+
+-- The processor time. Derived from C function
+-- `clock_gettime(CLOCK_PROCESS_CPUTIME_ID)`. This is the best function to use
+-- with benchmarks that need to calculate how much time has been spent within a
+-- CPU.
+-- @return number, seconds or nanoseconds since processor start.
+-- @usage
+-- -- This will print nanoseconds in the CPU since the start.
+-- > local clock = require('molly.clock')
+-- > print(clock.proc())
+-- 0.062237105
+--
+-- @function proc
+
+-- The monotonic time. Derived from C function `clock_gettime(CLOCK_MONOTONIC)`.
+-- Monotonic time is similar to wall clock time but is not affected by changes
+-- to or from daylight saving time, or by changes done by a user. This is the
+-- best function to use with benchmarks that need to calculate elapsed time.
+-- @return number, seconds or nanoseconds since the last time that the computer was booted.
+-- @usage
+-- > local clock = require('molly.clock')
+-- > print(clock.monotonic())
+-- 92096.202142013
+--
+-- @function monotonic
+
+-- The monotonic time. Derived from C function `clock_gettime(CLOCK_MONOTONIC)`.
+-- Monotonic time is similar to wall clock time but is not affected by changes
+-- to or from daylight saving time, or by changes done by a user. This is the
+-- best function to use with benchmarks that need to calculate elapsed time.
+-- @return seconds or nanoseconds since the last time that the computer was booted.
+-- @usage
+-- > local clock = require('molly.clock')
+-- > print(clock.monotonic64())
+-- 60112772175711
+--
+-- @function monotonic64
+
+-- Get datetime with milliseconds.
+-- @return string, string with datetime with milliseconds precision.
+-- @usage
+-- > local clock = require('molly.clock')
+-- > print(clock.dt())
+-- 2022-06-01 10:38:07:081899
+--
+-- @function dt
+function clock.dt()
+    local ms = string.match(tostring(os.clock()), '%d%.(%d+)')
+    local dt = os.date('*t')
+    return ('%d-%.2d-%.2d %.2d:%.2d:%.2d:%-6d'):format(dt.year,
+                                           dt.month,
+                                           dt.day,
+                                           dt.hour,
+                                           dt.min,
+                                           dt.sec,
+                                           ms)
+end
+
+return clock
blob - /dev/null
blob + e9320a1d8d5c15fc50103a358f1f7781c70eda10 (mode 644)
--- /dev/null
+++ molly/compat/clock_ffi.lua
@@ -0,0 +1,62 @@
+local ffi = require('ffi')
+local math = require('math')
+
+ffi.cdef[[
+typedef long time_t;
+typedef int clockid_t;
+
+typedef struct timespec {
+    time_t   tv_sec;        /* seconds */
+    long     tv_nsec;       /* nanoseconds */
+} ts;
+
+int clock_gettime(clockid_t clk_id, struct timespec *tp);
+
+int clock_nanosleep(clockid_t clock_id, int flags,
+                    const struct timespec *rqtp,
+                    struct timespec *rmtp);
+]]
+
+local clock = {}
+
+-- luacheck: push no unused
+-- The IDs of the various system clocks (for POSIX.1b interval timers).
+local CLOCK_REALTIME = 0
+local CLOCK_MONOTONIC = 1
+local CLOCK_PROCESS_CPUTIME_ID = 2
+local CLOCK_THREAD_CPUTIME_ID = 3
+local CLOCK_MONOTONIC_RAW = 4
+local CLOCK_REALTIME_COARSE = 5
+local CLOCK_MONOTONIC_COARSE = 6
+local CLOCK_BOOTTIME = 7
+local CLOCK_REALTIME_ALARM = 8
+local CLOCK_BOOTTIME_ALARM = 9
+-- luacheck: pop
+
+function clock.sleep(time)
+    local ts = assert(ffi.new("ts[?]", 1))
+    ts[0].tv_sec = math.floor(time / 1000)
+    ts[0].tv_nsec = (time % 1000) * 1000000
+    ffi.C.clock_nanosleep(1, 0, ts, nil)
+end
+
+function clock.monotonic()
+    local ts = assert(ffi.new("ts[?]", 1))
+    ffi.C.clock_gettime(CLOCK_MONOTONIC, ts)
+    return tonumber(ts[0].tv_sec * 1000 +
+           math.floor(tonumber(ts[0].tv_nsec / 1000000)))
+end
+
+function clock.monotonic64()
+    local ts = assert(ffi.new("ts[?]", 1))
+    ffi.C.clock_gettime(CLOCK_MONOTONIC, ts)
+    return tonumber(ts[0].tv_sec * 10^9 + ts[0].tv_nsec)
+end
+
+function clock.proc()
+    local ts = assert(ffi.new("ts[?]", 1))
+    ffi.C.clock_gettime(CLOCK_PROCESS_CPUTIME_ID, ts)
+    return tonumber(ts[0].tv_sec) + tonumber(ts[0].tv_nsec) / 10^9
+end
+
+return clock
blob - /dev/null
blob + 8e1a7b443ccd76d2fa94bd4de133bc5d898ec534 (mode 644)
--- /dev/null
+++ molly/compat/thread_coroutine.lua
@@ -0,0 +1,85 @@
+---- Module with implementation of threads based on coroutines.
+-- @module molly.thread_coroutine
+--
+--### References
+--
+-- - [Programming in Lua, Coroutines](http://www.lua.org/pil/9.html) -
+-- Roberto Ierusalimschy
+-- - [Coroutines in Lua](https://www.lua.org/doc/jucs04.pdf) - Ana L´ucia de
+-- Moura, Noemi Rodriguez, Roberto Ierusalimschy
+
+local math = require('math')
+
+local dev_checks = require('molly.dev_checks')
+local utils = require('molly.utils')
+
+local threads = {}
+
+local function scheduler()
+    while true do
+        local n = table.getn(threads)
+        if n == 0 then break end   -- No more threads to run.
+        local id = math.random(1, n)
+        local thread = threads[id]
+        local co = thread['coro']
+        local func_args = thread['func_args']
+        if coroutine.status(co) == 'suspended' then
+            coroutine.resume(co, unpack(func_args))
+        end
+        if coroutine.status(co) == 'dead' then
+            table.remove(threads, id)
+        end
+    end
+end
+
+local function create(self, ...)
+    dev_checks('<thread>')
+
+    local fn, func_args = ...
+    rawset(self, 'coro', coroutine.create(fn, self.thread_id, utils.pack(func_args)))
+    rawset(self, 'func_args', func_args)
+    table.insert(threads, self)
+
+    return true
+end
+
+local function cancel(self)
+    dev_checks('<thread>')
+    -- TODO
+    return true
+end
+
+local function join(self)
+    dev_checks('<thread>')
+    -- TODO
+    return true
+end
+
+local function yield()
+    coroutine.yield()
+    return true
+end
+
+local mt = {
+    __type = '<thread>',
+    __index = {
+        create = create,
+        cancel = cancel,
+        join = join,
+        yield = yield,
+    },
+}
+
+local function new(thread_id)
+    dev_checks('number')
+
+    return setmetatable({
+        thread_id = thread_id,
+    }, mt)
+end
+
+return {
+    new = new,
+    yield = yield,
+    scheduler = scheduler,
+}
blob - /dev/null
blob + 5fc8b20d2a75caf0fb3e137ca6a00be07468c585 (mode 644)
--- /dev/null
+++ molly/compat/thread_fiber.lua
@@ -0,0 +1,96 @@
+---- Module with implementation of threads based on fibers.
+-- @module molly.thread_fiber
+--
+-- Fibers are a unique Tarantool feature - 'green' threads or coroutines that
+-- run independently of operating system threads. Fibers nicely illustrate
+-- Tarantool's theoretical grounding in the actor model, which is based on the
+-- concept of a number of light processes that cooperatively multitask and
+-- communicate with one another through messaging. An advantageous feature of
+-- Tarantool fibers is that they include local storage.
+--
+-- Tarantool is programmed using Lua, which has its own coroutines, but due to
+-- the way that fibers interface with Tarantool's asynchronous event loop, it is
+-- better to use fibers rather than Lua coroutines. Fibers work well with all of
+-- the non-blocking I/O that is built into Tarantool's application server and
+-- fibers yield implicitly to other fibers, whereas this logic would have to be
+-- added for coroutines.
+--
+--### References
+--
+-- - [Tarantool Documentation: Module fiber](https://www.tarantool.io/en/doc/latest/reference/reference_lua/fiber/)
+-- - [Tarantool Documentation: Transaction Control](https://www.tarantool.io/en/doc/latest/book/box/atomic/)
+-- - [Tarantool Internals: Fibers](https://docs.tarantool.dev/en/latest/fiber.html)
+-- - [Separate roles of fibers that do network I/O and execute
+-- requests](https://blueprints.launchpad.net/tarantool/+spec/fiber-specialization)
+-- - [When to use fibers and when to use co-routines in
+-- Tarantool?](https://stackoverflow.com/questions/36152489/when-to-use-fibers-and-when-to-use-co-routines-in-tarantool)
+
+local has_fiber, fiber = pcall(require, 'fiber')
+if not has_fiber then
+    return nil
+end
+
+local dev_checks = require('molly.dev_checks')
+
+local function create(self, ...)
+    dev_checks('<thread>')
+
+    local func, opts = ...
+    local fiber_obj = fiber.new(func, self.thread_id, opts)
+    if fiber_obj:status() ~= 'dead' then
+        fiber_obj:set_joinable(true)
+        fiber_obj:name(('thread id %d'):format(self.thread_id))
+        fiber_obj:wakeup() -- Needed for backward compatibility with 1.7.
+        rawset(self, 'fiber_obj', fiber_obj)
+    end
+
+    return true
+end
+
+-- TODO: should be a module function (fiber.yield()).
+local function yield()
+    fiber.yield()
+    return true
+end
+
+local function cancel(self)
+    dev_checks('<thread>')
+
+    if self.fiber_obj ~= nil and self.fiber_obj:status() ~= 'dead' then
+        self.fiber_obj:kill()
+    end
+
+    return true
+end
+
+local function join(self)
+    dev_checks('<thread>')
+
+    if self.fiber_obj ~= nil and self.fiber_obj:status() ~= 'dead' then
+        self.fiber_obj:join()
+    end
+
+    return true
+end
+
+local mt = {
+    __type = '<thread>',
+    __index = {
+        create = create,
+        cancel = cancel,
+        join = join,
+        yield = yield,
+    },
+}
+
+local function new(thread_id)
+    dev_checks('number')
+
+    return setmetatable({
+        thread_id = thread_id,
+    }, mt)
+end
+
+return {
+    new = new,
+}
blob - /dev/null
blob + 267988ef7eb095c1b7c06902e6c75e561e18c69f (mode 644)
--- /dev/null
+++ molly/db.lua
@@ -0,0 +1,31 @@
+---- Module with default DB implementation.
+-- @module molly.db
+--
+-- Allows Molly to set up and tear down databases.
+
+local db_mt = {
+    __type = '<db>',
+    __index = {
+        setup = function() return true end,
+        teardown = function() return true end,
+    }
+}
+
+--- Function that returns a default db implementation.
+-- Molly db must implement following methods:
+--
+-- - **setup** - function that set up a database instance
+-- - **teardown** - function that tear down a database instance
+--
+-- Default implementation of a DB defines setup and teardown methods with empty
+-- implementation that always returns true.
+-- @return db
+--
+-- @function new
+local function new()
+    return setmetatable({}, db_mt)
+end
+
+return {
+    new = new,
+}
blob - /dev/null
blob + 6563a715de3dc2dc60408af2bca5a7fa7c28283c (mode 644)
--- /dev/null
+++ molly/dev_checks.lua
@@ -0,0 +1,8 @@
+local checks = require('molly.checks')
+
+local dev_checks = function() end
+if os.getenv('DEV') == 'ON' then
+    dev_checks = checks
+end
+
+return dev_checks
blob - /dev/null
blob + f8c292a599c74439586a35ba6fea821f217c5439 (mode 644)
--- /dev/null
+++ molly/gen.lua
@@ -0,0 +1,611 @@
+---- Module with functions for generators.
+-- @module molly.gen
+--
+-- One of the key pieces of a Molly test is a generators for client and
+-- nemesis operations. These generators will create a finite or infinite
+-- sequence of operations. It is often test have its own nemesis generator, but
+-- most likely shares a common client generator with other tests. A nemesis
+-- generator, for instance, might be a sequence of partition, sleep, and
+-- restore, repeated infinitely. A client generator will specify a random,
+-- infinite sequence of client operation, as well as the associated parameters
+-- such as durability level, the document key, the new value to write or CAS
+-- (Compare-And-Set), etc. When a test starts, the client generator feeds
+-- client operations to the client and the nemesis generator feeds operations
+-- to the nemesis. The test will continue until either the nemesis generator
+-- has completed a specified number of operations, a time limit is reached, or
+-- an error is thrown.
+--
+-- Example of generator that generates two operations `w` and `r`:
+--
+--    local w = function() return { f = 'w', v = math.random(1, 10) } end
+--    local r = function() return { f = 'r', v = nil } end
+--    gen.rands(0, 2):map(function(x)
+--                            return (x == 0 and r()) or
+--                                   (x == 1 and w()))
+--                        end):take(100)
+--
+--    local w = function(x) return { f = 'w', v = x } end
+--    gen.map(w, gen.rands(1, 10):take(50))
+--
+--### References:
+--
+-- - [Lua Functional Library documentation](https://luafun.github.io/)
+-- - [Lua Functional Library documentation: Under the Hood](https://luafun.github.io/under_the_hood.html)
+-- - [Lua iterators tutorial](http://lua-users.org/wiki/IteratorsTutorial)
+-- - [Jepsen generators in a nutshell](http://jepsen-io.github.io/jepsen/jepsen.generator.html)
+--
+-- @see molly.op
+
+local fun = require('fun')
+local clock = require('molly.clock')
+
+local methods = {}
+local exports = {}
+
+local iterator_mt = {
+    __call = function(self, param, state)
+        return self.gen(param, state)
+    end,
+    __index = methods,
+    __tostring = function(self)
+        return '<generator>'
+    end
+}
+
+local wrap = function(gen, param, state)
+    return setmetatable({
+        gen = gen,
+        param = param,
+        state = state
+    }, iterator_mt), param, state
+end
+exports.wrap = wrap
+
+local unwrap = function(self)
+    return self.gen, self.param, self.state
+end
+methods.unwrap = unwrap
+
+-- Helpers
+
+local nil_gen = function(_param, _state) -- luacheck: no unused
+    return nil
+end
+
+--- Basic Functions
+-- @section
+
+--- Make an iterator from the iterable object.
+-- See [fun.iter](https://luafun.github.io/basic.html#fun.iter).
+--
+-- @param object - an iterable object (map, array, string).
+-- @return an iterator
+-- @usage
+-- > for _it, v in gen.iter({1, 2, 3}) do print(v) end
+-- 1
+-- 2
+-- 3
+-- ---
+-- ...
+--
+-- > for _it, v in gen.iter({a = 1, b = 2, c = 3}) do print(v) end
+-- b
+-- a
+-- c
+-- ---
+-- ...
+--
+-- > for _it, v in gen.iter("abc") do print(v) end
+-- a
+-- b
+-- c
+-- ---
+-- ...
+--
+--- @function iter
+local iter = fun.iter
+exports.iter = iter
+
+--- Execute the function `fun` for each iteration value.
+-- See [fun.each](https://luafun.github.io/basic.html#fun.each).
+-- @param function
+-- @param iterator - an iterator or iterable object
+-- @return none
+-- @usage
+--
+-- > gen.each(print, { a = 1, b = 2, c = 3})
+-- b       2
+-- a       1
+-- c       3
+-- ---
+-- ...
+--
+-- @function each
+local each = fun.each
+methods.each = each
+exports.each = each
+--- An alias for each().
+-- See `gen.each`.
+-- @function for_each
+methods.for_each = each
+exports.for_each = each
+--- An alias for each().
+-- See `gen.each`.
+-- @function foreach
+methods.foreach = each
+exports.foreach = each
+
+--- Generators: Finite Generators
+-- @section
+
+--- The iterator to create arithmetic progressions.
+-- Iteration values are generated within closed interval `[start, stop]` (i.e.
+-- `stop` is included). If the `start` argument is omitted, it defaults to 1 (`stop
+-- > 0`) or to -1 (`stop < 0`). If the `step` argument is omitted, it defaults to 1
+-- (`start <= stop`) or to -1 (`start > stop`). If `step` is positive, the last
+-- element is the largest `start + i * step` less than or equal to `stop`; if `step`
+-- is negative, the last element is the smallest `start + i * step` greater than
+-- or equal to `stop`. `step` must not be zero (or else an error is raised).
+-- `range(0)` returns empty iterator.
+-- See [fun.range](https://luafun.github.io/generators.html#fun.range).
+--
+-- @number[opt] start – an endpoint of the interval.
+-- @number stop – an endpoint of the interval.
+-- @number[opt] step – a step.
+-- @return an iterator
+--
+-- @usage
+-- > for _it, v in gen.range(1, 6) do print(v) end
+-- 1
+-- 2
+-- 3
+-- 4
+-- 5
+-- 6
+-- ---
+-- ...
+--
+-- > for _it, v in gen.range(1, 6, 2) do print(v) end
+-- 1
+-- 3
+-- 5
+-- ---
+-- ...
+--
+-- @function range
+local range = fun.range
+exports.range = range
+
+--- Generators: Infinity Generators
+-- @section
+
+--- The iterator returns values over and over again indefinitely. All values
+-- that passed to the iterator are returned as-is during the iteration.
+-- See [fun.duplicate](https://luafun.github.io/generators.html#fun.duplicate).
+--
+-- @usage
+-- > gen.each(print, gen.take(3, gen.duplicate('a', 'b', 'c')))
+-- a       b       c
+-- a       b       c
+-- a       b       c
+-- ---
+-- ...
+--
+-- @function duplicate
+local duplicate = fun.duplicate
+exports.duplicate = duplicate
+--- An alias for duplicate().
+-- @function xrepeat
+-- See `gen.duplicate`.
+exports.xrepeat = duplicate
+--- An alias for duplicate().
+-- @function replicate
+-- See `gen.duplicate`.
+exports.replicate = duplicate
+
+--- Return `fun(0)`, `fun(1)`, `fun(2)`, ... values indefinitely.
+-- @function tabulate
+-- See [fun.tabulate](https://luafun.github.io/generators.html#fun.tabulate).
+local tabulate = fun.tabulate
+exports.tabulate = tabulate
+
+--- Generators: Random sampling
+-- @section
+
+--- @function rands
+-- See [fun.rands](https://luafun.github.io/generators.html#fun.rands).
+local rands = fun.rands
+methods.rands = rands
+exports.rands = rands
+
+--- Slicing: Subsequences
+-- @section
+
+--- @function take_n
+-- See [fun.take_n](https://luafun.github.io/slicing.html#fun.take_n).
+local take_n = fun.take_n
+methods.take_n = take_n
+exports.take_n = take_n
+
+--- @function take_while
+-- See [fun.take_while](https://luafun.github.io/slicing.html#fun.take_while).
+local take_while = fun.take_while
+methods.take_while = take_while
+exports.take_while = take_while
+
+--- @function take
+-- See [fun.take](https://luafun.github.io/slicing.html#fun.take).
+local take = fun.take
+methods.take = take
+exports.take = take
+
+--- @function drop_n
+-- See [fun.drop_n](https://luafun.github.io/slicing.html#fun.drop_n).
+local drop_n = fun.drop_n
+methods.drop_n = drop_n
+exports.drop_n = drop_n
+
+--- @function drop_while
+-- See [fun.drop_while](https://luafun.github.io/slicing.html#fun.drop_while).
+local drop_while = fun.drop_while
+methods.drop_while = drop_while
+exports.drop_while = drop_while
+
+--- @function drop
+-- See [fun.drop](https://luafun.github.io/slicing.html#fun.drop).
+local drop = fun.drop
+methods.drop = drop
+exports.drop = drop
+
+--- @function span
+-- See [fun.span](https://luafun.github.io/slicing.html#fun.span).
+local span = fun.span
+methods.span = span
+exports.span = span
+--- An alias for span().
+-- See `fun.span`.
+-- @function split
+methods.split = span
+exports.split = span
+--- An alias for span().
+-- See `fun.span`.
+-- @function split_at
+methods.split_at = span
+exports.split_at = span
+
+--- Indexing
+-- @section
+
+--- @function index
+-- See [fun.index](https://luafun.github.io/indexing.html#fun.index).
+local index = fun.index
+methods.index = index
+exports.index = index
+--- An alias for index().
+-- See `fun.index`.
+-- @function index_of
+methods.index_of = index
+exports.index_of = index
+--- An alias for index().
+-- See `fun.index`.
+-- @function elem_index
+methods.elem_index = index
+exports.elem_index = index
+
+--- @function indexes
+-- See [fun.indexes](https://luafun.github.io/indexing.html#fun.indexes).
+local indexes = fun.indexes
+methods.indexes = indexes
+exports.indexes = indexes
+--- An alias for indexes().
+-- See `fun.indexes`.
+-- @function indices
+methods.indices = indexes
+exports.indices = indexes
+--- An alias for indexes().
+-- See `fun.indexes`.
+-- @function elem_indexes
+methods.elem_indexes = indexes
+exports.elem_indexes = indexes
+--- An alias for indexes().
+-- See `fun.indexes`.
+-- @function elem_indices
+methods.elem_indices = indexes
+exports.elem_indices = indexes
+
+--- Filtering
+-- @section
+
+--- Return a new iterator of those elements that satisfy the `predicate`.
+-- See [fun.filter](https://luafun.github.io/filtering.html#fun.filter).
+-- @function filter
+local filter = fun.filter
+methods.filter = filter
+exports.filter = filter
+--- An alias for filter().
+-- See `gen.filter`.
+-- @function remove_if
+methods.remove_if = filter
+exports.remove_if = filter
+
+--- If `regexp_or_predicate` is string then the parameter is used as a regular
+-- expression to build filtering predicate. Otherwise the function is just an
+-- alias for gen.filter().
+-- @function grep
+-- See [fun.grep](https://luafun.github.io/filtering.html#fun.grep).
+local grep = fun.grep
+methods.grep = grep
+exports.grep = grep
+
+--- The function returns two iterators where elements do and do not satisfy the
+-- predicate.
+-- @function partition
+-- See [fun.partition](https://luafun.github.io/filtering.html#fun.partition).
+local partition = fun.partition
+methods.partition = partition
+exports.partition = partition
+
+--- Reducing: Folds
+-- @section
+
+--- The function reduces the iterator from left to right using the binary
+-- operator `accfun` and the initial value `initval`.
+-- @function foldl
+-- See [fun.foldl](https://luafun.github.io/reducing.html#fun.foldl).
+local foldl = fun.foldl
+methods.foldl = foldl
+exports.foldl = foldl
+--- An alias to foldl().
+-- See `gen.foldl`.
+-- @function reduce
+methods.reduce = foldl
+exports.reduce = foldl
+
+--- Return a number of elements in `gen, param, state` iterator.
+-- @function length
+-- See [fun.length](https://luafun.github.io/reducing.html#fun.length).
+local length = fun.length
+methods.length = length
+exports.length = length
+
+--- Return a new table (array) from iterated values.
+-- @function totable
+-- See [fun.totable](https://luafun.github.io/reducing.html#fun.totable).
+local totable = fun.totable
+methods.totable = totable
+exports.totable = totable
+
+--- Return a new table (map) from iterated values.
+-- @function tomap
+-- See [fun.tomap](https://luafun.github.io/reducing.html#fun.tomap).
+local tomap = fun.tomap
+methods.tomap = tomap
+exports.tomap = tomap
+
+--- Reducing: Predicates
+-- @section
+
+--- @function is_prefix_of
+-- See [fun.is_prefix_of](https://luafun.github.io/reducing.html#fun.is_prefix_of).
+local is_prefix_of = fun.is_prefix_of
+methods.is_prefix_of = is_prefix_of
+exports.is_prefix_of = is_prefix_of
+
+--- @function is_null
+-- See [fun.is_null](https://luafun.github.io/reducing.html#fun.is_null).
+local is_null = fun.is_null
+methods.is_null = is_null
+exports.is_null = is_null
+
+--- @function all
+-- See [fun.all](https://luafun.github.io/reducing.html#fun.all).
+local all = fun.all
+methods.all = all
+exports.all = all
+
+--- An alias for all().
+-- See `fun.all`.
+-- @function every
+methods.every = all
+exports.every = all
+
+--- @function any
+-- See [fun.any](https://luafun.github.io/reducing.html#fun.any).
+local any = fun.any
+methods.any = any
+exports.any = any
+--- An alias for any().
+-- See `fun.any`.
+-- @function some
+methods.some = any
+exports.some = any
+
+--- Transformations
+-- @section
+
+--- @function map
+-- See [fun.map](https://luafun.github.io/transformations.html#fun.map).
+local map = fun.map
+methods.map = map
+exports.map = map
+
+--- @function enumerate
+-- See [fun.enumerate](https://luafun.github.io/transformations.html#fun.enumerate).
+local enumerate = fun.enumerate
+methods.enumerate = enumerate
+exports.enumerate = enumerate
+
+--- @function intersperse
+-- See [fun.intersperse](https://luafun.github.io/transformations.html#fun.intersperse).
+local intersperse = fun.intersperse
+methods.intersperse = intersperse
+exports.intersperse = intersperse
+
+--- Compositions
+-- @section
+
+--- Return a new iterator where i-th return value contains the i-th element
+-- from each of the iterators. The returned iterator is truncated in length to
+-- the length of the shortest iterator. For multi-return iterators only the
+-- first variable is used.
+-- See [fun.zip](https://luafun.github.io/compositions.html#fun.zip).
+-- @param ... - an iterators
+-- @return an iterator
+-- @function zip
+local zip = fun.zip
+methods.zip = zip
+exports.zip = zip
+
+--- A cycled version of an iterator.
+-- Make a new iterator that returns elements from `{gen, param, state}` iterator
+-- until the end and then "restart" iteration using a saved clone of `{gen,
+-- param, state}`. The returned iterator is constant space and no return values
+-- are buffered. Instead of that the function make a clone of the source `{gen,
+-- param, state}` iterator. Therefore, the source iterator must be pure
+-- functional to make an indentical clone. Infinity iterators are supported,
+-- but are not recommended.
+-- @param iterator - an iterator
+-- @return an iterator
+-- See [fun.cycle](https://luafun.github.io/compositions.html#fun.cycle).
+-- @function cycle
+local cycle = fun.cycle
+methods.cycle = cycle
+exports.cycle = cycle
+
+--- Make an iterator that returns elements from the first iterator until it is
+-- exhausted, then proceeds to the next iterator, until all of the iterators are
+-- exhausted. Used for treating consecutive iterators as a single iterator.
+-- Infinity iterators are supported, but are not recommended.
+-- See [fun.chain](https://luafun.github.io/compositions.html#fun.chain).
+-- @param ... - an iterators
+-- @return an iterator, a consecutive iterator from sources (left from right).
+-- @usage
+-- > fun.each(print, fun.chain(fun.range(5, 1, -1), fun.range(1, 5)))
+-- 5
+-- 4
+-- 3
+-- 2
+-- 1
+-- 1
+-- 2
+-- 3
+-- 4
+-- 5
+-- ---
+-- ...
+--
+-- @function chain
+local chain = fun.chain
+methods.chain = chain
+exports.chain = chain
+
+--- (TODO) Cycles between several generators on a rotating schedule.
+-- Takes a flat series of [time, generator] pairs.
+-- @param ... - an iterators
+-- @return an iterator
+-- @function cycle_times
+local cycle_times = function()
+    -- TODO
+end
+methods.cycle_times = cycle_times
+
+--- (TODO) A random mixture of several generators. Takes a collection of generators
+-- and chooses between them uniformly.
+--
+-- To be precise, a mix behaves like a sequence of one-time, randomly selected
+-- generators from the given collection. This is efficient and prevents
+-- multiple generators from competing for the next slot, making it hard to
+-- control the mixture of operations.
+--
+-- @param ... - an iterators
+-- @return an iterator
+-- @function mix
+local mix = function()
+    -- TODO
+end
+methods.mix = mix
+exports.mix = mix
+
+--- (TODO) Emits an operation from generator A, then B, then A again, then B again,
+-- etc. Stops as soon as any gen is exhausted.
+-- @number a generator A.
+-- @number b generator B.
+-- @return an iterator
+--
+-- @function flip_flop
+local flip_flop = (function()
+    -- TODO
+end)
+methods.flip_flop = flip_flop
+
+--- Special generators
+-- @section
+
+--- (TODO) A generator which, when asked for an operation, logs a message and yields
+--  nil. Occurs only once; use `repeat` to repeat.
+-- @return an iterator
+--
+-- @function log
+local log = function()
+    -- TODO
+end
+exports.log = log
+
+--- (TODO) Operations from that generator are scheduled at uniformly random intervals
+-- between `0` to `2 * (dt seconds)`.
+-- @number dt Number of seconds.
+-- @return an iterator
+--
+-- @function stagger
+local stagger = (function()
+    -- TODO
+end)
+methods.stagger = stagger
+
+--- Stops generating items when time limit is exceeded.
+-- @number duration Number of seconds.
+-- @return an iterator
+--
+-- @usage
+-- >  for _it, v in gen.time_limit(gen.range(1, 100), 0.0001) do print(v) end
+-- 1
+-- 2
+-- 3
+-- 4
+-- 5
+-- 6
+-- 7
+-- 8
+-- 9
+-- 10
+-- 11
+-- 12
+-- ---
+-- ...
+--
+-- @function time_limit
+local time_limit = (function(fn)
+    return function(self, arg1)
+	return fn(arg1, self.gen, self.param, self.state)
+    end
+end)(function(timeout, gen, param, state)
+    if type(timeout) ~= 'number' or timeout == 0 then
+        error("bad argument with duration to time_limit", 2)
+    end
+    local get_time = clock.monotonic
+    local start_time = get_time()
+    local time_is_exceed = false
+    return wrap(function(ctx, state_x)
+        local gen_x, param_x, duration, cnt = ctx[1], ctx[2], ctx[3], ctx[4] + 1
+        ctx[4] = cnt
+        if time_is_exceed == false then
+            time_is_exceed = get_time() - start_time >= duration
+            return gen_x(param_x, state_x)
+        end
+	return nil_gen(nil, nil)
+    end, {gen, param, timeout, 0}, state)
+end)
+methods.time_limit = time_limit
+exports.time_limit = time_limit
+
+return exports
blob - /dev/null
blob + c1c71de2b1717aa671a075dcee6cb72c37c96615 (mode 644)
--- /dev/null
+++ molly/history.lua
@@ -0,0 +1,105 @@
+---- Module with functions that processes history of operations.
+-- @module molly.history
+
+local dev_checks = require('molly.dev_checks')
+local gen = require('molly.gen')
+local json = require('molly.json')
+local op = require('molly.op')
+
+-- Get a string representation of history.
+-- @return string
+-- @function to_txt
+local function to_txt(self)
+    dev_checks('<history>')
+
+    local history_str = ''
+    for _, operation in ipairs(self.history) do
+        local op_str = ('%3d    %s'):format(operation.process, op.to_string(operation))
+        history_str = ('%s\n%s'):format(history_str, op_str)
+    end
+
+    return history_str
+end
+
+-- Get a string representation of history encoded to JSON.
+-- @return string
+-- @function to_json
+local function to_json(self)
+    dev_checks('<history>')
+
+    return json.encode(self.history)
+end
+
+-- Get a table with completed operations in a history.
+-- @return table
+-- @function ops_completed
+local function ops_completed(self)
+    dev_checks('<history>')
+
+    return gen.filter(op.is_completed, self.history):length()
+end
+
+-- Get a table with planned operations in a history.
+-- @return table
+-- @function ops_planned
+local function ops_planned(self)
+    dev_checks('<history>')
+
+    return gen.filter(op.is_planned, self.history):length()
+end
+
+-- Add an operation to a history.
+-- @return true
+-- @function add
+local function add(self, operation)
+    dev_checks('<history>', '<operation>|table')
+    table.insert(self.history, operation)
+
+    return true
+end
+
+-- Get a HdrHistogram (High Dynamic Range (HDR) Histogram)
+--
+-- Since Molly uses HdrHistogram and produces HdrHistogram logs, various tools
+-- that plot and view histogram logs can be used to analyze Molly's data. Some
+-- common tools include HistggramLogAnalyzer, HdrHistogramVisualizer, hdr-plot,
+-- and a Javascript-based in-browser histogram log parser.
+--
+-- 1. https://github.com/HdrHistogram/HdrHistogram
+-- 2. https://github.com/HdrHistogram/HistogramLogAnalyzer
+-- 3. https://github.com/ennerf/HdrHistogramVisualizer
+-- 4. https://hdrhistogram.github.io/HdrHistogramJSDemo/logparser.html
+-- 5. https://github.com/BrunoBonacci/hdr-plot
+--
+-- Format: https://github.com/HdrHistogram/HdrHistogram/blob/master/GoogleChartsExample/example1.txt
+--
+-- @return table
+-- @function hdr_histogram
+local function hdr_histogram(self)
+    return {}
+end
+
+local mt = {
+    __type = '<history>',
+    __index = {
+        add = add,
+        ops_planned = ops_planned,
+        ops_completed = ops_completed,
+        to_json = to_json,
+        to_txt = to_txt,
+        hdr_histogram = hdr_histogram,
+    },
+}
+
+-- Create a new history object.
+-- @return history
+-- @function new
+local function new()
+    return setmetatable({
+        history = {},
+    }, mt)
+end
+
+return {
+    new = new,
+}
blob - /dev/null
blob + 36c6469948387b7e0161748c0c435e9e8a851dce (mode 644)
--- /dev/null
+++ molly/init.lua
@@ -0,0 +1,25 @@
+---- Framework for distributed system's verification, with fault injection.
+-- @module molly
+-- @author Sergey Bronnikov
+-- @license ISC
+-- @copyright Sergey Bronnikov, 2021-2022
+
+local client = require('molly.client')
+local db = require('molly.db')
+local gen = require('molly.gen')
+local log = require('molly.log')
+local nemesis = require('molly.nemesis')
+local runner = require('molly.runner')
+local tests = require('molly.tests')
+local utils = require('molly.utils')
+
+return {
+    client = client,
+    db = db,
+    gen = gen,
+    log = log,
+    nemesis = nemesis,
+    runner = runner,
+    tests = tests,
+    utils = utils,
+}
blob - /dev/null
blob + 3db761f3f18a97e8d3dab9ae74761d336c56167e (mode 644)
--- /dev/null
+++ molly/json.lua
@@ -0,0 +1,10 @@
+local utils = require('molly.utils')
+
+local json
+if utils.is_tarantool() then
+    json = require('json')
+else
+    json = require('cjson')
+end
+
+return json
blob - /dev/null
blob + b045dbff3b1c41cc0f2465921639d06367d1bd0a (mode 644)
--- /dev/null
+++ molly/log.lua
@@ -0,0 +1,114 @@
+---- Module with functions for logging.
+-- @module molly.log
+
+-- log.lua
+--
+-- Copyright (c) 2016 rxi
+--
+-- This library is free software; you can redistribute it and/or modify it
+-- under the terms of the MIT license. See LICENSE for details.
+--
+-- Source: https://github.com/rxi/log.lua
+
+local clock = require('molly.clock')
+local utils = require('molly.utils')
+
+local log = { _version = "0.1.0" }
+
+log.outfile = nil
+log.level = "info"
+
+local modes = {
+    { name = "trace" },
+    { name = "debug" },
+    { name = "info" },
+    { name = "warn" },
+    { name = "error" },
+    { name = "fatal" },
+}
+
+local levels = {}
+for i, v in ipairs(modes) do
+    levels[v.name] = i
+end
+
+--- Log message with verbose level 'trace'.
+-- @string message Message.
+-- @return nil
+--
+-- @function trace
+
+--- Log message with verbose level 'debug'.
+-- @string message Message.
+-- @usage
+-- > local log = require('molly.log')
+-- > log.debug('Total planned requests: 1010')
+-- [DEBUG 2021-12-1 12:26:8:689379] /home/sergeyb/sources/molly/jepsen/runner.lua:80: Total planned requests: 1010
+--
+-- @return nil
+--
+-- @function debug
+
+--- Log message with verbose level 'info'.
+-- @string message Message.
+-- @return nil
+-- @usage
+-- > local log = require('molly.log')
+-- > log.info('Message')
+-- [INFO  2021-12-7 13:17:46:073544]: Message
+--
+-- @function info
+
+--- Log message with verbose level 'warn'.
+-- @string message Message.
+-- @return nil
+-- @usage
+-- > local log = require('jepsen.log')
+-- > log.warn('Message')
+-- [WARN  2021-12-7 13:17:46:073544]: Message
+--
+-- @function warn
+
+--- Log message with verbose level 'error'.
+-- @string message Message.
+-- @return nil
+--
+-- @function error
+
+--- Log message with verbose level 'fatal'.
+-- @string message Message.
+-- @return nil
+--
+-- @function fatal
+
+for i, x in ipairs(modes) do
+    local nameupper = x.name:upper()
+    log[x.name] = function(...)
+        -- Return early if we're below the log level.
+        if i < levels[log.level] then
+          return
+        end
+
+        local msg = string.format(...)
+        local lineinfo = ''
+        if log.level == 'debug' then
+            local debug_info = debug.getinfo(2, "Sl")
+            local filename = utils.basename(debug_info.short_src)
+            lineinfo = (' %s:%d'):format(filename, debug_info.currentline)
+        end
+
+        local timestamp = clock.dt()
+        local str = ('[%-6s%-24s]%s: %s\n'):format(nameupper, timestamp, lineinfo, msg)
+        -- Output to console.
+        io.write(str)
+
+        -- Output to log file.
+        if log.outfile then
+            local fp = io.open(log.outfile, "a")
+            fp:write(str)
+            fp:close()
+        end
+    end
+end
+
+return log
blob - /dev/null
blob + b85b9ddba7cab47869048e44909ff312781d3fa3 (mode 644)
--- /dev/null
+++ molly/nemesis.lua
@@ -0,0 +1,29 @@
+---- Module with nemeses.
+-- @module molly.nemesis
+--
+-- A nemesis is a process that will be fed operations from a generator process
+-- and then take action against the system accordingly.
+--
+--    { type = "info", f = "start", process = { "nemesis", time = 5326396898, index = 169 }}
+--    { type = "info", f = "start", process = { "nemesis", time = 5328551016, index = 170 }}
+
+--- Nemesis that do nothing.
+-- @return None
+-- @function noop
+local function noop()
+
+    -- Do nothing.
+
+    return {
+        type = 'info',
+        f = 'start',            -- possible values are 'start' and 'stop'
+        process = 'nemesis',    -- always is 'nemesis'
+        time = 5326396898,      -- time of start or end of nemesis
+        index = 169,            -- nemesis's index
+        value = nil,            -- payload
+    }
+end
+
+return {
+    noop = noop,
+}
blob - /dev/null
blob + 0afabad64dcdfe094559db1d4b35ab0a8e970afb (mode 644)
--- /dev/null
+++ molly/op.lua
@@ -0,0 +1,101 @@
+---- Module with helpers that processes operations.
+-- @module molly.op
+--
+-- An operation is a transition from state to state. For instance, a
+-- single-variable system might have operations like read and write, which get
+-- and set the value of that variable, respectively. A counter might have
+-- operations like increments, decrements, and reads. An SQL store might have
+-- operations like selects and updates.
+--
+-- An observed history should be a list of operations in real-time order, where
+-- each operation is a map of the form:
+--
+--     {
+--         type    One of `invoke`, `ok`, `info`, `fail`.
+--         process A logical identifier for a single thread of execution.
+--         value   A transaction; structure and semantics vary.
+--     }
+--
+-- Each process should perform alternating `invoke` and `ok`, `info`, `fail`
+-- operations. `ok` indicates the operation definitely committed. `fail`
+-- indicates it definitely did not occur - e.g. it was aborted, was never
+-- submitted to the database, etc. `info` indicates an indeterminate state; the
+-- transaction may or may not have taken place. After an `info`, a process may
+-- not perform another operation; the invocation remains open for the rest of
+-- the history.
+--
+-- We define each operation as a table, that contains following keys:
+--
+--  - state, can be nil (invoke), true (ok) or false (fail);
+--  - f is an action defined in a test, for example 'transfer', 'read' or
+--  'write';
+--  - v is a valued defined in a test, usually it is generated automatically;
+--
+--  For example an operation with 'read' action, value is 'nil' because it is
+--  unknown before invoking of operation:
+--
+--    {
+--        f = 'read',
+--        value = nil,
+--    }
+--
+--    4  ok       read       {0 5, 1 10, 2 12, 8 10, 9 17}
+--    4  invoke   read       nil
+--    3  ok       read       {0 5, 1 9, 2 12, 3 10, 4 11}
+--    3  invoke   read       nil
+--
+--  or operation with 'transfer' action for test that transfers money between
+--  accounts:
+--
+--    {
+--        f = 'transfer',
+--        value = {
+--            from = math.random(1, 10),
+--            to = math.random(1, 10),
+--            amount = math.random(1, 100),
+--        }
+--    }
+--
+--    3  ok       transfer   {from = 8, to = 2, amount = 3}
+--    0  ok       transfer   {from = 1, to = 9, amount = 1}
+--    0  invoke   transfer   {from = 3, to = 9, amount = 5}
+
+local dev_checks = require('molly.dev_checks')
+local pprint = require('molly.json').encode
+
+-- Define whether operation is in planned state.
+-- @table op Operation.
+-- @return boolean
+--
+-- @function is_planned
+local function is_planned(op)
+    dev_checks('<operation>|table')
+    return op.type == 'invoke'
+end
+
+-- Define whether operation is in completed state.
+-- @table op Operation.
+-- @return boolean
+--
+-- @function is_completed
+local function is_completed(op)
+    dev_checks('<operation>|table')
+    return op.type == 'ok' or
+           op.type == 'fail'
+end
+
+-- Get a string representation of operation.
+-- @table op Operation.
+-- @return string
+--
+-- @function to_string
+local function to_string(op)
+    dev_checks('<operation>|table')
+    return ('%-10s %-10s %-10s'):format(op.type, op.f, pprint(op.value))
+end
+
+return {
+    is_planned = is_planned,
+    is_completed = is_completed,
+    to_string = to_string,
+}
blob - /dev/null
blob + 8cd2efbeeca276ce73b3886aa3d8045d159930da (mode 644)
--- /dev/null
+++ molly/runner.lua
@@ -0,0 +1,230 @@
+---- Module with main functions that runs tests.
+-- @module molly.runner
+--
+--### Design Overview
+--
+-- A Molly test runs as a Lua program on a control node. That program may use
+-- remote access to log into a bunch of DB nodes, where it sets up the
+-- distributed system you're going to test or use local DB instances.
+--
+--```
+--             +-------------+
+--    +------- | controller  | -------+
+--    |        +-------------+        |
+--    |          |    |    |          |
+--    |     +----+    |    |          |
+--    v     v         |    |          v
+--  +----+----+----+  |    |  +----+----+
+--  | n1 | n2 | n3 | <+    +> | n4 | n5 |
+--  +----+----+----+          +----+----+
+--```
+--
+-- Once the system is running, the control node spins up a set of logically
+-- single-threaded processes (see `molly.thread`), each with its own client for
+-- the distributed system.
+--
+-- A generator (see `molly.gen`) generates new operations (see `molly.op`)
+-- for each process to perform. Processes then apply those operations to the
+-- system using their clients, see `molly.client`. The start and end of each
+-- operation is recorded in a history, see `molly.history`. While performing
+-- operations, a special nemesis process (see `molly.nemesis`) introduces
+-- faults into the system - also scheduled by the generator.
+--
+-- Finally, the DB is turn down. Molly uses a checker (see `molly.checker`)
+-- to analyze the test's history for correctness, and to generate reports,
+-- graphs, etc. The test, history, analysis, and any supplementary results are
+-- written to the filesystem for later review.
+--
+-- <!-- TODO: https://jepsen.io/consistency -->
+--
+--### Performance Tips
+--
+-- **Disable debug mode**: by default tests enables type checking, see
+-- statements with `dev_checks()` in source code, and code coverage gathering.
+-- This requires using Lua `debug` module, that can significantly slowdown of
+-- execution (about 1.6 times). You can control it with environment variable
+-- `DEV`, to run tests with disabled type checking and code coverage: `DEV=OFF
+-- make test`.
+--
+-- **Disable verbose mode**: see description of `verbose` mode.
+--
+-- **Use fibers**: TODO: performance of fibers vs coroutines.
+
+local checks = require('molly.dev_checks')
+local math = require('math')
+
+local clock = require('molly.clock')
+local history_lib = require('molly.history')
+local log = require('molly.log')
+local threadpool = require('molly.threadpool')
+local wrapper = require('molly.client')
+local is_tarantool = require('molly.utils').is_tarantool
+
+local has_fun, _ = pcall(require, 'fun')
+local has_json, _ = pcall(require, 'molly.json')
+
+local function print_summary(total_time, history, opts)
+    checks('number', 'table', 'table')
+
+    log.debug('Running test %.3fs with %d thread(s)', total_time, opts.threads)
+    if opts.nodes ~= nil then
+        for _, addr in pairs(opts.nodes) do
+            log.debug('- %s', addr)
+        end
+        log.debug('')
+    end
+
+    local ops_completed = history:ops_completed()
+    local ops_planned = history:ops_planned()
+    log.debug('Total planned requests: %-35s', tostring(ops_planned))
+    if ops_completed ~= 0 then
+        log.debug('Total completed requests: %-35s', tostring(ops_completed))
+        local rps = math.floor(ops_completed / total_time)
+        log.debug('Requests per sec: %-35s', tostring(rps))
+    end
+end
+
+function checkers.workload_opts(opts)
+    return opts.client ~= nil and type(opts.client) == 'table' and
+           opts.generator ~= nil and type(opts.generator) == 'table' and
+           (opts.checker == nil or type(opts.generator) == 'table')
+end
+
+function checkers.test_opts(opts)
+    return (opts.create_reports == nil or type(opts.create_reports) == 'boolean') and
+           (opts.threads == nil or type(opts.threads) == 'number') and
+           (opts.thread_type == nil or
+            opts.thread_type == 'fiber' or
+            opts.thread_type == 'coroutine') and
+           (opts.time_limit == nil or type(opts.time_limit) == 'number') and
+           (opts.nodes == nil or type(opts.nodes) == 'table')
+end
+
+--- Create test and run.
+--
+-- @table workload Table with workload options.
+-- @param workload.client Workload client. Learn more about creating clients in
+-- `molly.client`.
+-- @table workload.generator Generator of operations used in test workload.
+-- Generator must be a table with `unwrap()` method that returns an iterator
+-- triplet. You can make generator youself or use `molly.gen` module.
+-- @param[opt] workload.checker Function for checking history in workload.
+-- @table[opt] opts Table with test options.
+-- @boolean[opt] opts.create_reports Option to control creating reports,
+-- disabled by default. When enabled a number of files created:
+--
+--  - history.txt with plain history;
+--  - history.json with history encoded to JSON;
+--
+-- @number[opt] opts.threads Number of threads in a test workload, default
+-- value is 1.
+-- @boolean[opt] opts.verbose shows details about the results and progress of
+-- running test. This can be especially useful when the results might not be
+-- obvious. For example, if you want to see the progress of testing as it
+-- setup, teardown or invokes operations, you can use the 'verbose' option. In
+-- the beginning, you may find it useful to use 'verbose' at all times; when
+-- you are more accustomed to `molly`, you will likely want to use it at
+-- certain times but not at others. Disabled by default.
+-- Take into account that logging to standart output is a slow operation and
+-- with enabled verbose mode `molly` logs status of every operation before
+-- and after it's invocation and this may slowdown overall testing performance
+-- significantly. It is recommended to disable verbose mode in a final testing
+-- and use it only for debugging.
+-- @string[opt] opts.thread_type Type of threads used in a test workload.
+-- Possible values are 'fiber' (see `molly.thread_fiber`) and 'coroutine' (see
+-- `molly.thread_coroutine`), default value is 'fiber' on Tarantool and
+-- 'coroutine' on LuaJIT. Learn more about possible thread types in
+-- `molly.thread`.
+-- @number[opt] opts.time_limit Number of seconds to limit time of testing. By
+-- default testing time is endless and limited by a number of operations
+-- produced by generator.
+-- @table[opt] opts.nodes A table that contains IP addresses of nodes
+-- participated in testing.
+--
+-- @see molly.gen
+-- @see molly.client
+--
+-- @return true on success or nil with error
+--
+-- @usage
+--
+-- local test_options = {
+--     create_reports = true,
+--     thread_type = 'fiber',
+--     threads = 5,
+--     nodes = {
+--         '127.0.0.1'
+--     }
+-- }
+-- local ok, err = runner.run_test({
+--     client = client.new(),
+--     generator = gen_lib.cycle(gen_lib.iter({ r, w })):take(1000)
+-- }, test_options)
+--
+-- @function run_test
+local function run_test(workload, opts)
+    checks('workload_opts', 'test_opts')
+
+    if not has_json then
+        error('JSON module is not available')
+    end
+    if not has_fun then
+        error('Lua functional module is not available')
+    end
+
+    opts = opts or {}
+    opts.logging = opts.create_reports or false
+    opts.nodes = opts.nodes or {}
+    opts.threads = opts.threads or 1
+    opts.thread_type = opts.thread_type or
+                       (is_tarantool() and 'fiber') or 'coroutine'
+
+    if opts.verbose or os.getenv('DEV') == 'ON' then
+        log.level = 'debug'
+    end
+
+    local unwrap = workload.generator.unwrap
+    if unwrap == nil or type(workload.generator.unwrap) ~= 'function' then
+        error('Generator must have an unwrap method')
+    end
+
+    -- Start workload.
+    local history = history_lib.new()
+    local total_time_begin = clock.proc()
+    local pool = threadpool.new(opts.thread_type, opts.threads)
+    local ok, err = pool:start(wrapper.invoke, {
+        client = workload.client,
+        gen = workload.generator,
+        history = history,
+        nodes = opts.nodes,
+    })
+    if not ok then
+        return nil, err
+    end
+    local total_passed_sec = clock.proc() - total_time_begin
+
+    -- Summary.
+    print_summary(total_passed_sec, history, opts)
+
+    if opts.create_reports == true then
+        local log_txt = 'history.txt'
+        local log_json = 'history.json'
+
+        local fp = io.open(log_txt, 'w')
+        fp:write(history:to_txt())
+        fp:close()
+
+        fp = io.open(log_json, 'w')
+        fp:write(history:to_json())
+        fp:close()
+
+        log.debug('File with operations history (plain text):    %s', log_txt)
+        log.debug('File with operations history (JSON):          %s', log_json)
+    end
+
+    return true
+end
+
+return {
+    run_test = run_test,
+}
blob - /dev/null
blob + bc5e68c30d250835f86364b8754d5bb97388b4b3 (mode 644)
--- /dev/null
+++ molly/tests.lua
@@ -0,0 +1,237 @@
+---- Module with test generators and checkers.
+-- @module molly.tests
+--
+--### List-Append
+--
+-- list-append operations are either appends or reads
+--
+-- Detects cycles in histories where operations are transactions over named
+-- lists lists, and operations are either appends or reads.
+--
+-- The *append* test models the database as a collection of named lists,
+-- and performs transactions comprised of read and append operations. A
+-- read returns the value of a particular list, and an append adds a single
+-- unique element to the end of a particular list. We derive ordering
+-- dependencies between these transactions, and search for cycles in that
+-- dependency graph to identify consistency anomalies.
+--
+-- In terms of Molly, values in operation are lists of integers. Each operation
+-- performs a transaction, comprised of micro-operations which are either reads
+-- of some value (returning the entire list) or appends (adding a single number
+-- to whatever the present value of the given list is). We detect cycles in
+-- these transactions using Elle's cycle-detection system.
+--
+-- Generator `molly.tests.list_append_gen` produces an operations
+-- compatible with Molly:
+--
+--     { index = 2, type = "invoke", value = {{ "append", 255, 8 } { "r", 253, null }}}
+--     { index = 3, type = "ok",     value = {{ "append", 255, 8 } { "r", 253, { 1, 3, 4 }}}}
+--     { index = 4, type = "invoke", value = {{ "append", 256, 4 } { "r", 255, null } { "r", 256, nil } { "r", 253, null }}}
+--     { index = 5, type = "ok",     value = {{ "append", 256, 4 } { "r", 255, { 2, 3, 4, 5, 8 }} { "r", 256, { 1, 2, 4 }} {{ "r", 253, { 1, 3, 4 }}}}
+--     { index = 6, type = "invoke", value = {{ "append", 250, 10 } { "r", 253, null }{ "r", 255, null } { "append", 256, 3 }}}
+--
+-- A partial test, including a generator and checker. You'll need to provide a
+-- client which can understand operations of the form:
+--
+--     { type = "invoke", f = "txn", value = {{ "r", 3, null } { "append", 3, 2 } { "r", 3, null }}}
+--
+-- and return completions like:
+--
+--     { type = "invoke", f = "txn", value = {{ "r", 3, { 1 }} { "append", 3, 2 } { "r", 3, { 1, 2 }}}}
+--
+-- where the key `3` identifies some list, whose value is initially `[1]`, and
+-- becomes `[1 2]`.
+--
+-- Lists are encoded as rows in a table; key names are table names, and the set
+-- of all rows determines the list contents.
+--
+-- This test requires a way to order table contents.
+--
+--### RW Register
+--
+-- Generator produces concurrent atomic updates to a shared register. Writes
+-- are assumed to be unique, but this is the only constraint.
+--
+-- Operations are of two forms:
+--
+--     { "r", "x", 1 } denotes a read of `x` observing the value 1.
+--     { "w", "x", 2 } denotes a write of `x`, settings its value to 2.
+--
+-- Example of history:
+--
+--     { type = "invoke", f = "txn", value = {{ "w", "x", 1 }},   process = 0, index = 1}
+--     { type = "ok",     f = "txn", value = {{ "w", "x", 1 }},   process = 0, index = 2}
+--     { type = "invoke", f = "txn", value = {{ "r", "x", null }}, process = 0, index = 3}
+--     { type = "ok",     f = "txn", value = {{ "r", "x", 2 }},   process = 0, index = 4}
+--
+-- Note that in Lua associative array is an array that can be indexed not only
+-- with numbers, but also with strings or any other value of the language,
+-- except nil. Null values in Lua tables are represented as JSON null
+-- (`json.NULL`, a Lua `lightuserdata` NULL pointer). is provided for
+-- comparison.
+
+local math = require('math')
+
+local dev_checks = require('molly.dev_checks')
+local gen_lib = require('molly.gen')
+local json = require('molly.json')
+
+-- Function that describes a 'read' operation.
+local function op_r()
+    return setmetatable({
+        f = 'txn',
+        value = {{
+            'r',
+            'x',
+            json.NULL,
+        }},
+    }, {
+        __type = '<operation>',
+        __tostring = function(self)
+            return '<read>'
+        end,
+    })
+end
+
+-- Function that describes a 'write' operation.
+local function op_w()
+    return setmetatable({
+        f = 'txn',
+        value = {{
+            'w',
+            'x',
+            math.random(1, 100),
+        }}
+    }, {
+        __type = '<operation>',
+        __tostring = function(self)
+            return '<write>'
+        end,
+    })
+end
+
+--- Write/Read operations generator.
+--
+-- @usage
+--
+-- > log = require('log')
+-- > tests = require('molly.tests')
+-- > for _it, v in tests.rw_register_gen() do log.info(v()) end
+-- {"f":"txn","value":[["r","x",null]]}
+-- {"f":"txn","value":[["w","x",58]]}
+-- {"f":"txn","value":[["r","x",null]]}
+-- {"f":"txn","value":[["w","x",80]]}
+-- {"f":"txn","value":[["r","x",null]]}
+-- {"f":"txn","value":[["w","x",46]]}
+-- {"f":"txn","value":[["r","x",null]]}
+-- {"f":"txn","value":[["w","x",19]]}
+-- {"f":"txn","value":[["r","x",null]]}
+-- {"f":"txn","value":[["w","x",66]]}
+-- ---
+-- ...
+--
+-- @return an iterator
+--
+-- @function rw_register_gen
+local function rw_register_gen()
+    return gen_lib.cycle(gen_lib.iter({ op_r, op_w }))
+end
+
+-- Function that describes a 'list' micro operation.
+local function mop_list(key_count)
+    return {
+        'r',
+        math.random(key_count),
+        json.NULL,
+    }
+end
+
+local function counter()
+    local i = 0
+    return function()
+               i = i + 1
+               return i
+           end
+end
+
+local c = counter()
+
+-- Function that describes an 'append' micro operation.
+local function mop_append(key_count)
+    return {
+        'append',
+        math.random(key_count),
+        c(),
+    }
+end
+
+local function list_append_op(param)
+    local mops = {}
+    for _ = 1, math.random(param.min_txn_len, param.max_txn_len) do
+        if math.random(1, 2) == 1 then
+            table.insert(mops, mop_list(param.key_count))
+        else
+            table.insert(mops, mop_append(param.key_count))
+        end
+    end
+    return setmetatable({
+        f = 'txn',
+        value = mops,
+    }, {
+        __type = '<operation>',
+        __tostring = function(self)
+            return '<list-append>'
+        end,
+    })
+end
+
+--- List-Append operations generator.
+--
+-- A generator for operations where values are transactions made up of reads
+-- and appends to various integer keys.
+-- @table[opt] opts Table with options.
+-- @number[opt] opts.key_count Number of distinct keys at any point. Default is
+-- 3.
+-- @number[opt] opts.min_txn_len Minimum number of operations per txn. Default
+-- is 1.
+-- @number[opt] opts.max_txn_len Maximum number of operations per txn. Default
+-- is 2.
+-- @number[opt] opts.max_writes_per_key Maximum number of operations per key.
+-- Default is 32.
+-- @usage
+--
+-- > log = require('log')
+-- > tests = require('molly.tests')
+-- > for _it, v in tests.list_append_gen() do log.info(v()) end
+-- {"f":"txn","value":[["r",3,null]]}
+-- {"f":"txn","value":[["append",3,1]]}
+-- {"f":"txn","value":[["r",2,null]]}
+-- {"f":"txn","value":[["append",3,2]]}
+-- {"f":"txn","value":[["r",1,null]]}
+-- {"f":"txn","value":[["append",2,3]]}
+-- {"f":"txn","value":[["r",2,null]]}
+-- {"f":"txn","value":[["append",3,4]]}
+-- {"f":"txn","value":[["r",2,null]]}
+-- {"f":"txn","value":[["append",2,5]]}
+-- ---
+-- ...
+--
+-- @return an iterator
+--
+-- @function list_append_gen
+local function list_append_gen(opts)
+    dev_checks('?number', '?table')
+
+    opts = opts or {}
+    local param = {}
+    param.key_count = opts.key_count or 3
+    param.min_txn_len = opts.min_txn_len or 1
+    param.max_txn_len = opts.max_txn_len or 2
+    param.max_writes_per_key = opts.max_writes_per_key or 32
+    return gen_lib.wrap(list_append_op, param, 0)
+end
+
+return {
+    list_append_gen = list_append_gen,
+    rw_register_gen = rw_register_gen,
+}
blob - /dev/null
blob + 3677489cc7cd7988b4ec6539cfa52ee45a888f85 (mode 644)
--- /dev/null
+++ molly/thread.lua
@@ -0,0 +1,13 @@
+---- Module with thread implementation.
+-- @module molly.thread
+--
+-- - `jepsen.compat.thread_fiber` - threads, based on Tarantool fibers.
+-- - `jepsen.compat.thread_coroutine` - threads, based on Lua coroutines.
+
+local thread_coroutine = require('molly.compat.thread_coroutine')
+local thread_fiber = require('molly.compat.thread_fiber')
+
+return {
+    ['fiber'] = thread_fiber,
+    ['coroutine'] = thread_coroutine,
+}
blob - /dev/null
blob + 101297b1e82517237c1380e9013c29a5dcb4ca6f (mode 644)
--- /dev/null
+++ molly/threadpool.lua
@@ -0,0 +1,92 @@
+-- A thread pool used to execute functions in parallel.
+-- Spawns a specified number of worker threads and replenishes the pool if any
+-- worker threads panic.
+
+local log = require('molly.log')
+
+local dev_checks = require('molly.dev_checks')
+local thread_lib = require('molly.thread')
+
+local THREAD_TYPE
+
+local function join(self)
+    dev_checks('<threadpool>')
+
+    for i = 1, self.thread_num do
+        self.pool[i]:join()
+    end
+
+    if THREAD_TYPE == 'coroutine' then
+        thread_lib[THREAD_TYPE].scheduler()
+    end
+
+    return true
+end
+
+local function cancel(self)
+    dev_checks('<threadpool>')
+
+    for i = 1, self.thread_num do
+        self.pool[i]:cancel()
+    end
+
+    return true
+end
+
+local function start(self, ...)
+    dev_checks('<threadpool>')
+
+    local func, opts = ...
+    for thread_id = 1, self.thread_num do
+        log.debug('Spawn a new thread %d', thread_id)
+        local ok = self.pool[thread_id]:create(func, opts)
+        if not ok then
+            error('Failed to start thread')
+        end
+        if THREAD_TYPE == 'fiber' then
+            self.pool[thread_id]:yield()
+        end
+    end
+
+    local ok = self:join()
+    if not ok then
+        error('Failed to wait completion')
+    end
+
+    return true
+end
+
+local mt = {
+    __type = '<threadpool>',
+    __index = {
+        start = start,
+        cancel = cancel,
+        join = join,
+    },
+}
+
+local function new(thread_type, thread_num)
+    dev_checks('string', 'number')
+
+    THREAD_TYPE = thread_type
+    local thread = thread_lib[thread_type]
+    -- TODO: check thread type in runner.lua
+    if type(thread) ~= 'table' then
+        error(('No thread library with type "%s"'):format(thread_type))
+    end
+
+    local pool = {}
+    for thread_id = 1, thread_num do
+        pool[thread_id] = thread.new(thread_id)
+    end
+
+    return setmetatable({
+        pool = pool,
+        thread_num = thread_num,
+        thread_type = thread_type,
+    }, mt)
+end
+
+return {
+    new = new,
+}
blob - /dev/null
blob + 17c56fc31107f0283686df81481d2c0d8bd7cb09 (mode 644)
--- /dev/null
+++ molly/utils.lua
@@ -0,0 +1,93 @@
+---- Helpers.
+-- @module molly.utils
+
+local ffi = require('ffi')
+
+ffi.cdef[[
+extern char **environ;
+
+int setenv(const char *name, const char *value, int overwrite);
+int unsetenv(const char *name);
+int chdir(const char *dirname);
+char* getcwd(char *buffer, int maxlen);
+]]
+
+--- Set and unset environment variable.
+-- @string key Variable name.
+-- @string value Variable value.
+-- @raise error
+-- @return true
+--
+-- @function setenv
+local function setenv(key, value)
+    local rc
+    if value ~= nil then
+        rc = ffi.C.setenv(key, tostring(value), 1)
+    else
+        rc = ffi.C.unsetenv(key)
+    end
+    if rc == -1 then
+	error(('Error: %s'):format(ffi.errno().errstring()))
+    end
+
+    return true
+end
+
+--- Get current directory.
+-- @return string, absolute path to a current directory
+--
+-- @function cwd
+local function cwd()
+    local length = 2048
+    local dir = ffi.new("char[?]", length)
+    ffi.C.getcwd(dir, length)
+    return ffi.string(dir)
+end
+
+--- Change current directory.
+-- @string dir
+-- @return boolean
+--
+-- @function chdir
+local function chdir(dir)
+    return ffi.C.chdir(dir) == 0
+end
+
+--- Defines whether we run under Tarantool.
+-- @return boolean
+--
+-- @function is_tarantool
+local function is_tarantool()
+    return _G['_TARANTOOL'] ~= nil
+end
+
+--- Function equivalent to basename in POSIX systems
+-- @string path
+-- @return string
+--
+-- @function basename
+local function basename(str)
+    local name = string.gsub(str, "(.*/)(.*)", "%2")
+    return name
+end
+
+--- Packs the given arguments into a table with an `n` key
+-- denoting the number of elements.
+-- @return table
+--
+-- @function pack
+local function pack(...)
+    return {
+        n = select("#", ...), ...
+    }
+end
+
+return {
+    basename = basename,
+    chdir = chdir,
+    cwd = cwd,
+    setenv = setenv,
+    pack = pack,
+
+    is_tarantool = is_tarantool,
+}
blob - /dev/null
blob + ef9ea765136901e19b78d7b2661f40b2ba05d31a (mode 644)
--- /dev/null
+++ molly-scm-1.rockspec
@@ -0,0 +1,28 @@
+package = 'molly'
+version = 'scm-1'
+source = {
+    url = 'git+https://github.com/ligurio/molly',
+    branch = 'master',
+}
+
+description = {
+    summary = 'A framework for distributed systems verification, with fault injection',
+    homepage = 'https://github.com/ligurio/molly',
+    maintainer = 'Sergey Bronnikov <estetus@gmail.com>',
+    license = 'ISC',
+}
+
+dependencies = {
+    'lua >= 5.1',
+}
+
+build = {
+    type = 'make',
+    -- Nothing to build.
+    build_pass = false,
+    variables = {
+        LUADIR='$(LUADIR)',
+    },
+    copy_directories = {
+    },
+}
blob - /dev/null
blob + be9fe1c0a7ac519a87483e86e0fa57f69eda626c (mode 644)
--- /dev/null
+++ test/coverage.lua
@@ -0,0 +1,45 @@
+local utils = require('molly.utils')
+local runner = require('luacov.runner')
+
+-- Module with utilities for collecting code coverage from external processes.
+local export = {
+    DEFAULT_EXCLUDE = {
+        '^builtin/',
+        '/luarocks/',
+        '/build.luarocks/',
+        '/.rocks/',
+    },
+}
+
+local function with_cwd(dir, fn)
+    local old = utils.cwd()
+    assert(utils.chdir(dir), 'Failed to chdir to ' .. dir)
+    fn()
+    assert(utils.chdir(old), 'Failed to chdir to ' .. old)
+end
+
+local function coverage_enable()
+    local root = utils.cwd()
+    -- Change directory to the original root so luacov can find default config
+    -- and resolve relative filenames.
+    with_cwd(root, function()
+        local config = runner.load_config()
+        config.exclude = config.exclude or {}
+        for _, item in pairs(export.DEFAULT_EXCLUDE) do
+            table.insert(config.exclude, item)
+        end
+        runner.init(config)
+    end)
+end
+
+function export.enable()
+        coverage_enable()
+end
+
+function export.shutdown()
+    if runner.initialized then
+        runner.shutdown()
+    end
+end
+
+return export
blob - /dev/null
blob + 45499684569da4aae4dd29049674c94529723041 (mode 644)
--- /dev/null
+++ test/examples/sqlite-list-append.lua
@@ -0,0 +1,117 @@
+-- https://www.sqlite.org/isolation.html
+-- https://www.sqlite.org/threadsafe.html
+-- https://www.sqlite.org/atomiccommit.html
+
+local sqlite3 = require('lsqlite3')
+local molly = require('molly')
+local os = require('os')
+
+print('SQLite version:', sqlite3.version())
+print('lsqlite3 library version:', sqlite3.lversion())
+
+local function insert(stmt, key, val) -- luacheck: no unused
+    local ok = stmt:bind_values(key, val)
+    if ok ~= sqlite3.OK then
+        return false
+    end
+    ok = stmt:step()
+    if ok ~= sqlite3.DONE then
+        return false
+    end
+    stmt:reset()
+
+    return true
+end
+
+local function select(stmt, key) -- luacheck: no unused
+    local values = {}
+    for row in stmt:nrows() do
+        if row.key == key then
+            table.insert(values, row.val)
+        end
+    end
+    return values
+end
+
+local sqlite_list_append = molly.client.new()
+
+sqlite_list_append.open = function(self)
+    self.db = assert(sqlite3.open_memory(), 'database handle is nil')
+    if self.db == nil then
+        error('database handle is nil')
+    end
+    -- See explanation in https://www.sqlite.org/pragma.html
+    assert(sqlite3.OK == self.db:exec('PRAGMA journal_mode = WAL'))
+    assert(sqlite3.OK == self.db:exec('PRAGMA synchronous = normal'))
+    assert(sqlite3.OK == self.db:exec('PRAGMA mmap_size = 30000000000'))
+    assert(sqlite3.OK == self.db:exec('PRAGMA page_size = 32768'))
+    --self.insert_stmt = assert(self.db:prepare('INSERT INTO list_append VALUES (?, ?)'))
+    --self.select_stmt = assert(self.db:prepare('SELECT key, val FROM list_append ORDER BY key'))
+    return true
+end
+
+sqlite_list_append.setup = function(self)
+    assert(sqlite3.OK == self.db:exec('CREATE TABLE IF NOT EXISTS list_append (key INT NOT NULL, val INT)'))
+    return true
+end
+
+local IDX_MOP_TYPE = 1
+local IDX_MOP_KEY = 2
+local IDX_MOP_VAL = 3
+
+sqlite_list_append.invoke = function(self, op)
+    local mop = op.value[1] -- TODO: Support more than one mop in operation.
+    local mop_key = mop[IDX_MOP_KEY]
+    local type = 'ok'
+    if mop[IDX_MOP_TYPE] == 'r' then
+        --mop[IDX_MOP_VAL] = select(self.select_stmt, mop_key)
+        mop[IDX_MOP_VAL] = self.db:exec('SELECT key, val FROM list_append ORDER BY key')
+    elseif mop[IDX_MOP_TYPE] == 'append' then
+        local ok = self.db:exec(string.format('INSERT INTO list_append VALUES (%s, %s)', mop_key, mop[IDX_MOP_VAL]))
+        --local ok = insert(self.insert_stmt, mop_key, mop[IDX_MOP_VAL])
+        if ok == false then
+            type = 'fail'
+        end
+    else
+        error('Unknown operation')
+    end
+
+    return {
+        value = { mop },
+        f = op.f,
+        process = op.process,
+        type = type,
+    }
+end
+
+sqlite_list_append.teardown = function(self)
+    --self.insert_stmt:finalize()
+    --self.select_stmt:finalize()
+    local changes = self.db:total_changes()
+    --assert(changes == 500, string.format('Number of operations is wrong (%d != 500)', changes))
+    print('Total changes in SQLite DB:', changes)
+    return true
+end
+
+sqlite_list_append.close = function(self)
+    self.db:close()
+    return true
+end
+
+local test_options = {
+    create_reports = true,
+    threads = 1,
+}
+local ok, err = molly.runner.run_test({
+    client = sqlite_list_append,
+    generator = molly.tests.list_append_gen()
+}, test_options)
+
+if not ok then
+    print('Test has failed:', err)
+end
+
+if os.getenv('DEV') ~= 'ON' then
+    os.remove('history.json')
+    os.remove('history.txt')
+end
blob - /dev/null
blob + d31eac653acd39e8ed058a2a8efb35e13f3b714c (mode 644)
--- /dev/null
+++ test/examples/sqlite-rw-register.lua
@@ -0,0 +1,138 @@
+-- https://www.sqlite.org/isolation.html
+-- https://www.sqlite.org/threadsafe.html
+-- https://www.sqlite.org/atomiccommit.html
+
+local sqlite3 = require('lsqlite3')
+local molly = require('molly')
+local os = require('os')
+
+print('SQLite version:', sqlite3.version())
+print('lsqlite3 library version:', sqlite3.lversion())
+
+local function call_insert(stmt, key, val) -- luacheck: no unused
+    assert(stmt:isopen() == true, 'statement has been finalized')
+    local ok = stmt:bind_values(key, val)
+    if ok ~= sqlite3.OK then
+        return false
+    end
+    ok = stmt:step()
+    if ok ~= sqlite3.DONE then
+        return false
+    end
+    stmt:reset()
+
+    return true
+end
+
+local function call_select(stmt, key) -- luacheck: no unused
+    assert(stmt:isopen() == true, 'statement has been finalized')
+    local val, ok
+    if stmt:bind_values(key) ~= sqlite3.OK then
+        return false
+    end
+    if stmt:step() == sqlite3.ROW then
+        val = stmt:get_value(0)
+        ok = true
+    else
+        ok = false
+    end
+    stmt:reset()
+
+    return ok, val
+end
+
+-- `sqlite_rw_register` is a client that performs on database two operations:
+-- `read` and `write`. Method `invoke` must apply these operations to a
+-- database instance.
+local sqlite_rw_register = molly.client.new()
+
+sqlite_rw_register.open = function(self)
+    self.db = assert(sqlite3.open_memory(), 'database handle is nil')
+    -- For explanation see https://www.sqlite.org/pragma.html
+    assert(sqlite3.OK == self.db:exec('PRAGMA journal_mode = WAL'))
+    assert(sqlite3.OK == self.db:exec('PRAGMA synchronous = normal'))
+    assert(sqlite3.OK == self.db:exec('PRAGMA mmap_size = 30000000000'))
+    assert(sqlite3.OK == self.db:exec('PRAGMA page_size = 32768'))
+
+    --self.insert_stmt = assert(self.db:prepare('INSERT INTO rw_register VALUES (?, ?)'), 'statement prepare')
+    --self.select_stmt = assert(self.db:prepare('SELECT val FROM rw_register WHERE id = ?'), 'statement prepare')
+    return true
+end
+
+sqlite_rw_register.setup = function(self)
+    assert(sqlite3.OK == self.db:exec('CREATE TABLE IF NOT EXISTS rw_register (id, val)'))
+    return true
+end
+
+local OP_TYPE = 1
+local OP_VAL = 3
+
+local KEY_ID = 1
+
+sqlite_rw_register.invoke = function(self, op)
+    local val = op.value[1]
+    local type = 'ok'
+    if val[OP_TYPE] == 'r' then
+        --[[
+        assert(self.select_stmt:isopen() == true, 'statement has been finalized')
+        local ok, v = call_select(self.select_stmt, KEY_ID)
+        val[OP_VAL] = v
+        if ok == false then
+            type = 'fail'
+        end
+        ]]
+        val[OP_VAL] = self.db:exec(string.format('SELECT val FROM rw_register WHERE id = %d', KEY_ID))
+    elseif val[OP_TYPE] == 'w' then
+        --assert(self.insert_stmt:isopen() == true, 'statement has been finalized')
+        --local ok = call_insert(self.insert_stmt, KEY_ID, val[OP_VAL])
+        local ok = self.db:exec(string.format('INSERT INTO rw_register VALUES (?, ?)', KEY_ID, val[OP_VAL]))
+        if ok == false then
+            type = 'fail'
+        end
+    else
+        error('Unknown operation')
+    end
+
+    return {
+        value = { val },
+        f = op.f,
+        process = op.process,
+        type = type,
+    }
+end
+
+sqlite_rw_register.teardown = function(self)
+    --self.insert_stmt:finalize()
+    --self.select_stmt:finalize()
+    local changes = self.db:total_changes()
+    assert(changes == 500, string.format('Number of operations is wrong (%d != 500)', changes))
+    print('Total changes in SQLite DB:', changes)
+    return true
+end
+
+sqlite_rw_register.close = function(self)
+    self.db:close()
+    return true
+end
+
+local test_options = {
+    create_reports = true,
+    threads = 5,
+    nodes = {
+        '1',
+    },
+}
+
+local ok, err = molly.runner.run_test({
+    client = sqlite_rw_register,
+    generator = molly.tests.rw_register_gen():take(100),
+}, test_options)
+
+if not ok then
+    print('Test has failed:', err)
+end
+
+if os.getenv('DEV') ~= 'ON' then
+    os.remove('history.json')
+    os.remove('history.txt')
+end
blob - /dev/null
blob + 85b66a58d812803ad70f60b63feccb8190e047b7 (mode 644)
--- /dev/null
+++ test/helpers.lua
@@ -0,0 +1,23 @@
+-- local setenv = require('molly.utils').setenv
+
+-- if os.getenv('DEV') == nil then
+--     setenv('DEV', 'ON')
+-- end
+
+local function file_exists(name)
+    if name == nil then
+        return false
+    end
+
+    local f = io.open(name, 'r')
+    if f ~= nil then
+        io.close(f)
+        return true
+    else
+        return false
+    end
+end
+
+return {
+    file_exists = file_exists,
+}
blob - /dev/null
blob + 8d43d1affa654cb2e3879c04fa4ba30e3266e71c (mode 644)
--- /dev/null
+++ test/tap.lua
@@ -0,0 +1,309 @@
+-- The Test Anything Protocol vesion 13 producer
+-- Copyright 2010-2020 Tarantool AUTHORS.
+
+-- yaml formatter must be able to encode any Lua variable
+--[[
+local yaml = require('yaml').new()
+yaml.cfg{
+    encode_invalid_numbers = true;
+    encode_load_metatables = true;
+    encode_use_tostring    = true;
+    encode_invalid_as_nil  = true;
+}
+]]
+
+local ffi = require('ffi') -- for iscdata
+
+local function traceback(level)
+    local trace = {}
+    level = level or 3
+    while true do
+        local info = debug.getinfo(level, "nSl")
+        if not info then break end
+        local frame = {
+            source = info.source;
+            src = info.short_src;
+            line = info.linedefined or 0;
+            what = info.what;
+            name = info.name;
+            namewhat = info.namewhat;
+            filename = info.source:sub(1, 1) == "@" and info.source:sub(2) or
+                'eval'
+        }
+        table.insert(trace, frame)
+        level = level + 1
+    end
+    return trace
+end
+
+local function diag(test, fmt, ...)
+    io.write(string.rep(' ', 4 * test.level), "# ", string.format(fmt, ...),
+        "\n")
+end
+
+local function ok(test, cond, message, extra)
+    test.total = test.total + 1
+    io.write(string.rep(' ', 4 * test.level))
+    if cond then
+        io.write(string.format("ok - %s\n", message))
+        return true
+    end
+
+    test.failed = test.failed + 1
+    io.write(string.format("not ok - %s\n", message))
+    extra = extra or {}
+    if test.trace then
+        extra.trace = traceback()
+        extra.filename = extra.trace[#extra.trace].filename
+        extra.line = extra.trace[#extra.trace].line
+    end
+    if next(extra) == nil then
+        return false -- don't have extra information
+    end
+    -- print aligned yaml output
+    --[[
+    for line in yaml.encode(extra):gmatch("[^\n]+") do
+        io.write(string.rep(' ', 2 + 4 * test.level), line, "\n")
+    end
+    ]]
+    return false
+end
+
+local function fail(test, message, extra)
+    return ok(test, false, message, extra)
+end
+
+local function skip(test, message, extra)
+    ok(test, true, message.." # skip", extra)
+end
+
+local function cmpdeeply(got, expected, extra)
+    if type(expected) == "number" or type(got) == "number" then
+        extra.got = got
+        extra.expected = expected
+        if got ~= got and expected ~= expected then
+            return true -- nan
+        end
+        return got == expected
+    end
+
+    if ffi.istype('bool', got) then got = (got == 1) end
+    if ffi.istype('bool', expected) then expected = (expected == 1) end
+
+    if extra.strict and type(got) ~= type(expected) then
+        extra.got = type(got)
+        extra.expected = type(expected)
+        return false
+    end
+
+    if type(got) ~= 'table' or type(expected) ~= 'table' then
+        extra.got = got
+        extra.expected = expected
+        return got == expected
+    end
+
+    local path = extra.path or '/'
+    local visited_keys = {}
+
+    for i, v in pairs(got) do
+        visited_keys[i] = true
+        extra.path = path .. '/' .. i
+        if not cmpdeeply(v, expected[i], extra) then
+            return false
+        end
+    end
+
+    -- check if expected contains more keys then got
+    for i, v in pairs(expected) do
+        if visited_keys[i] ~= true and (extra.strict or v ~= box.NULL) then
+            extra.expected = 'key ' .. tostring(i)
+            extra.got = 'nil'
+            return false
+        end
+    end
+
+    extra.path = path
+
+    return true
+end
+
+local function like(test, got, pattern, message, extra)
+    extra = extra or {}
+    extra.got = got
+    extra.expected = pattern
+    return ok(test, string.match(tostring(got), pattern) ~= nil, message, extra)
+end
+
+local function unlike(test, got, pattern, message, extra)
+    extra = extra or {}
+    extra.got = got
+    extra.expected = pattern
+    return ok(test, string.match(tostring(got), pattern) == nil, message, extra)
+end
+
+local function is(test, got, expected, message, extra)
+    extra = extra or {}
+    extra.got = got
+    extra.expected = expected
+    local rc = (test.strict == false or type(got) == type(expected)) and
+               got == expected
+    return ok(test, rc, message, extra)
+end
+
+local function isnt(test, got, unexpected, message, extra)
+    extra = extra or {}
+    extra.got = got
+    extra.unexpected = unexpected
+    local rc = (test.strict == true and type(got) ~= type(unexpected)) or
+               got ~= unexpected
+    return ok(test, rc, message, extra)
+end
+
+
+local function is_deeply(test, got, expected, message, extra)
+    extra = extra or {}
+    extra.got = got
+    extra.expected = expected
+    extra.strict = test.strict
+    return ok(test, cmpdeeply(got, expected, extra), message, extra)
+end
+
+local function isnil(test, v, message, extra)
+    return is(test, not v and 'nil' or v, 'nil', message, extra)
+end
+
+local function isnumber(test, v, message, extra)
+    return is(test, type(v), 'number', message, extra)
+end
+
+local function isstring(test, v, message, extra)
+    return is(test, type(v), 'string', message, extra)
+end
+
+local function istable(test, v, message, extra)
+    return is(test, type(v), 'table', message, extra)
+end
+
+local function isboolean(test, v, message, extra)
+    return is(test, type(v), 'boolean', message, extra)
+end
+
+local function isfunction(test, v, message, extra)
+    return is(test, type(v), 'function', message, extra)
+end
+
+local function isudata(test, v, utype, message, extra)
+    extra = extra or {}
+    extra.expected = 'userdata<'..utype..'>'
+    if type(v) == 'userdata' then
+        extra.got = 'userdata<'..getmetatable(v)..'>'
+        return ok(test, getmetatable(v) == utype, message, extra)
+    else
+        extra.got = type(v)
+        return fail(test, message, extra)
+    end
+end
+
+local function iscdata(test, v, ctype, message, extra)
+    extra = extra or {}
+    extra.expected = ffi.typeof(ctype)
+    if type(v) == 'cdata' then
+        extra.got = ffi.typeof(v)
+        return ok(test, ffi.istype(ctype, v), message, extra)
+    else
+        extra.got = type(v)
+        return fail(test, message, extra)
+    end
+end
+
+local test_mt
+local function test(parent, name, fun, ...)
+    local level = parent ~= nil and parent.level + 1 or 0
+    local test = setmetatable({
+        parent  = parent;
+        name    = name;
+        level   = level;
+        total   = 0;
+        failed  = 0;
+        planned = 0;
+        trace   = parent == nil and true or parent.trace;
+        strict = parent ~= nil and parent.strict or false;
+    }, test_mt)
+    if fun ~= nil then
+        test:diag('%s', test.name)
+        fun(test, ...)
+        test:diag('%s: end', test.name)
+        return test:check()
+    else
+        return test
+    end
+end
+
+local function plan(test, planned)
+    test.planned = planned
+    io.write(string.rep(' ', 4 * test.level), string.format("1..%d\n", planned))
+end
+
+local function check(test)
+    if test.checked then
+        error('check called twice')
+    end
+    test.checked = true
+    if test.planned ~= test.total then
+        if test.parent ~= nil then
+            ok(test.parent, false, "bad plan", { planned = test.planned;
+                run = test.total})
+        else
+            diag(test, string.format("bad plan: planned %d run %d",
+                test.planned, test.total))
+        end
+    elseif test.failed > 0 then
+        if test.parent ~= nil then
+            ok(test.parent, false, "failed subtests", {
+                failed = test.failed;
+                planned = test.planned;
+            })
+        else
+            diag(test, "failed subtest: %d", test.failed)
+        end
+    else
+        if test.parent ~= nil then
+            ok(test.parent, true, test.name)
+        end
+    end
+    return test.planned == test.total and test.failed == 0
+end
+
+test_mt = {
+    __index = {
+        test      = test;
+        plan      = plan;
+        check     = check;
+        diag      = diag;
+        ok        = ok;
+        fail      = fail;
+        skip      = skip;
+        is        = is;
+        isnt      = isnt;
+        isnil     = isnil;
+        isnumber  = isnumber;
+        isstring  = isstring;
+        istable   = istable;
+        isboolean = isboolean;
+        isfunction = isfunction;
+        isudata   = isudata;
+        iscdata   = iscdata;
+        is_deeply = is_deeply;
+        like      = like;
+        unlike    = unlike;
+    }
+}
+
+local function root_test(...)
+    io.write('TAP version 13', '\n')
+    return test(nil, ...)
+end
+
+return {
+    test = root_test;
+}
blob - /dev/null
blob + a50cbc758768bf76840618bd998d0e2dee3c18b0 (mode 644)
--- /dev/null
+++ test/tests.lua
@@ -0,0 +1,350 @@
+--
+-- Unit and integration tests for Molly.
+--
+
+require('test.coverage').enable()
+
+local math = require('math')
+local os = require('os')
+
+local helpers = require('test.helpers')
+local test = require('test.tap').test('molly')
+
+local molly = require('molly')
+
+local client = require('molly.client')
+local clock = require('molly.clock')
+local gen_lib = molly.gen
+local history = require('molly.history')
+local json = require('molly.json')
+local log = molly.log
+local op_lib = require('molly.op')
+local runner = molly.runner
+local tests = molly.tests
+local threadpool = require('molly.threadpool')
+local utils = molly.utils
+
+local seed = os.time()
+math.randomseed(seed)
+
+test:plan(10)
+
+test:test('clock', function(test)
+    test:plan(5)
+
+    local res = clock.monotonic64()
+    local res_tarantool = type(res) == 'cdata' and utils.is_tarantool()
+    local res_luajit = type(res) == 'number' and not utils.is_tarantool()
+    test:is(res_tarantool or res_luajit, true, "clock.monotonic64()")
+    test:isnumber(clock.monotonic(), "clock.monotonic()")
+    test:isnumber(clock.proc(), "clock.proc()")
+    test:isnil(clock.sleep(0.1), "clock.sleep()")
+    test:isstring(clock.dt(), "clock.dt()")
+end)
+
+test:test('history', function(test)
+    test:plan(9)
+    test:isnt(history.new(), nil, "history.new()")
+
+    local h = history.new()
+    local ok = h:add({ type = 'invoke', f = 'ok', value = 10 })
+    test:is(ok, true, "history_obj:add()")
+
+    h = history.new()
+    ok = h:add({ f = 'read', type = 'invoke', process = 1, })
+    test:is(ok, true, "history_obj:add()")
+    test:is(h:to_txt(), "\n  1    invoke     read       null      ", "history_obj:to_txt()")
+
+    h = history.new()
+    h:add({ type = 'ok', value = 2 })
+    h:add({ type = 'fail', value = 5 })
+    h:add({ type = 'fail', value = 3 })
+    h:add({ type = 'invoke', value = 6 })
+    test:is(h:ops_completed(), 3, "history_obj:ops_completed()")
+
+    h = history.new()
+    h:add({ type = 'invoke', value = 2 })
+    h:add({ type = 'fail', value = 5 })
+    h:add({ type = 'fail', value = 3 })
+    h:add({ type = 'ok', value = 6 })
+    test:is(h:ops_planned(), 1, "history_obj:ops_planned()")
+
+    h = history.new()
+    h:add({ type = 'ok', value = 2 })
+    local ref_str = '[{"type":"ok","value":2}]'
+    test:is(h:to_json(), ref_str, "history_obj:to_json(): { type = 'ok' }")
+
+    h = history.new()
+    h:add({ type = 'fail', value = 3 })
+    ref_str = '[{"type":"fail","value":3}]'
+    test:is(h:to_json(), ref_str, "history_obj:to_json(): { type = 'fail' }")
+
+    h = history.new()
+    h:add({ type = 'invoke', value = 6 })
+    ref_str = '[{"type":"invoke","value":6}]'
+    test:is(h:to_json(), ref_str, "history_obj:to_json(): { type = 'invoke' }")
+end)
+
+test:test('op', function(test)
+    test:plan(9)
+    local op = { f = 'read', value = 10, type = 'invoke' }
+    local str = op_lib.to_string(op)
+    test:is(str, 'invoke     read       10        ', "op.to_string() { type = 'invoke'}")
+
+    op = { f = 'read', value = 10, type = 'ok' }
+    str = op_lib.to_string(op)
+    test:is(str, 'ok         read       10        ', "op.to_string() { type = 'ok' }")
+
+    op = { f = 'read', value = 10, type = 'fail' }
+    str = op_lib.to_string(op)
+    test:is(str, 'fail       read       10        ', "op.to_string() { type = 'fail' }")
+
+    op = { type = 'ok' }
+    test:is(op_lib.is_completed(op), true, "op.is_completed() { type = 'ok' }")
+
+    op = { type = 'fail' }
+    test:is(op_lib.is_completed(op), true, "op.is_completed() { type = 'fail' }")
+
+    op = { type = 'invoke' }
+    test:is(op_lib.is_completed(op), false, "op.is_completed() { type = 'invoke' }")
+
+    op = { type = 'ok' }
+    test:is(op_lib.is_planned(op), false, "op.is_planned() { type = 'ok' }")
+
+    op = { type = 'fail' }
+    test:is(op_lib.is_planned(op), false, "op.is_planned() { type = 'fail' }")
+
+    op = { type = 'invoke' }
+    test:is(op_lib.is_completed(op), false, "op.is_planned() { type = 'invoke' }")
+end)
+
+test:test('utils', function(test)
+    test:plan(10)
+
+    test:isnt(utils.cwd(), '', "utils.cwd()")
+    local cwd = utils.cwd()
+    test:is(utils.chdir('/tmp'), true, "utils.chdir()")
+    test:is(utils.cwd(), '/tmp', "utils.cwd()")
+    test:is(utils.chdir(cwd), true, "utils.chdir()")
+    test:is(utils.chdir('xxx'), false, "utils.chdir()")
+
+    test:is(utils.setenv('MOLLY', 1), true, "utils.setenv()")
+    test:is(os.getenv('MOLLY'), '1', "os.getenv()")
+    test:is(utils.setenv('MOLLY'), true, "utils.setenv()")
+    test:isnil(os.getenv('MOLLY'), "os.getenv()")
+
+    test:is(utils.basename('/home/sergeyb/sources/molly/README.md'), 'README.md', "utils.basename()")
+end)
+
+local OP_TYPE = 1
+local OP_VAL = 3
+
+test:test('gen', function(test)
+    test:plan(2)
+
+    local gen, param, state = gen_lib.range(1, 2)
+    local item = gen(param, state)
+    test:is(item, 1, "gen.range()")
+
+    local timeout = 0.01
+    local start_time = clock.proc()
+    for _ in gen_lib.time_limit(gen_lib.range(1, 1000), timeout) do
+        -- Nothing.
+    end
+    local passed_time = clock.proc() - start_time
+    local eps = 1
+    test:ok(passed_time - timeout < eps, "gen.time_limit()")
+end)
+
+local IDX_MOP_TYPE = 1
+local IDX_MOP_KEY = 2
+local IDX_MOP_VAL = 3
+
+test:test('tests.rw_register_gen', function(test)
+    test:plan(5)
+
+    local num = 5
+    local n = tests.rw_register_gen():take(5):length()
+    test:is(n, num, "tests.rw_register_gen(): length")
+
+    local gen, param, state = tests.rw_register_gen()
+    local _, op_func, _ = gen(param, state)
+    local op = op_func()
+    local mop = op.value[1]
+    test:is(type(mop), 'table', "tests.rw_register_gen(): mop")
+    local mop_key = mop[IDX_MOP_KEY]
+    test:is(type(mop_key), 'string', "tests.rw_register_gen(): mop key")
+    local mop_type = mop[IDX_MOP_TYPE]
+    test:is(mop_type == 'r' or mop_type == 'append', true, "tests.rw_register_gen(): mop type")
+    local mop_val = mop[IDX_MOP_VAL]
+    test:is(mop_val == 'number' or mop_val == json.NULL, true, "tests.rw_register_gen(): mop value")
+end)
+
+test:test('tests.list_append_gen', function(test)
+    test:plan(2)
+
+    local gen, param, state = tests.list_append_gen()
+    local val = gen(param, state)
+    local mop = val.value[1]
+    local op_type = mop[OP_TYPE]
+    local op_val = mop[OP_VAL]
+    test:is(op_type == 'r' or op_type == 'append', true, "tests.list_append_gen(): op type")
+    test:is(type(op_val) == 'number' or op_val == json.NULL, true, "tests.list_append_gen(): op value")
+end)
+
+test:test('runner', function(test)
+    test:plan(4)
+
+    local workload_opts = {
+        client = {},
+        generator = {},
+    }
+    local test_opts = {}
+    local ok, err = pcall(runner.run_test, workload_opts, test_opts)
+    test:is(ok, false, "runner.run_test(): invalid generator")
+    local res = string.find(err, 'Generator must have an unwrap method')
+    test:isnt(res, nil, "runner.run_test(): err is not nil")
+
+    local cl = client.new()
+    cl.setup = function() assert(nil, 'broken setup') end
+    workload_opts = {
+        client = cl,
+        generator = {
+            unwrap = function() return end
+        },
+    }
+    test_opts = {
+        nodes = {
+            'a',
+        }
+    }
+    ok = runner.run_test(workload_opts, test_opts)
+    test:is(ok, true, "runner.run_test(): broken setup")
+
+    cl = client.new()
+    cl.teardown = function()
+        assert(nil, 'broken teardown')
+    end
+    workload_opts = {
+        client = cl,
+        generator = {
+            unwrap = function() return end
+        },
+    }
+    test_opts = {
+        nodes = {
+            'a',
+        }
+    }
+    ok = runner.run_test(workload_opts, test_opts)
+    test:is(ok, true, "runner.run_test(): broken teardown")
+end)
+
+------------------------
+-- Integration tests  --
+------------------------
+
+local test_dict = {}
+
+local client_dict = client.new()
+
+client_dict.invoke = function(self, op)
+    local k = 42
+    local val = op.value[1]
+    if val[OP_TYPE] == 'r' then
+        val[OP_VAL] = test_dict[k]
+    elseif val[OP_TYPE] == 'w' then
+        test_dict.k = op.v
+    else
+        error('Unknown operation')
+    end
+
+    return {
+        value = { val },
+        f = op.f,
+        type = 'ok',
+        process = op.process,
+    }
+end
+
+-- Run a test that generates random read and write operations for Lua
+-- dictionary.
+local run_test_dict = function(thread_type)
+    if utils.is_tarantool() == false and thread_type == 'fiber' then
+        return false
+    end
+
+    local test_options = {
+        create_reports = true,
+        thread_type = thread_type,
+        threads = 5,
+        nodes = { 'a', 'b', 'c' }, -- Required for better code coverage.
+    }
+    local ok, err = runner.run_test({
+        client = client_dict,
+        generator = tests.rw_register_gen():take(100)
+    }, test_options)
+
+    assert(err == nil, "run_test_dict: err is not nil")
+    assert(ok, "run_test_dict: ok is not true")
+
+    if ok == true and test_options.create_reports == true then
+        assert(helpers.file_exists('history.txt'), "history.txt does not exist")
+        assert(helpers.file_exists('history.json'), "history.json does not exist")
+
+        -- Cleanup.
+        if os.getenv('DEV') ~= 'ON' then
+            os.remove('history.json')
+            os.remove('history.txt')
+        end
+    end
+    log.debug('Random seed: %s', seed)
+
+    return true
+end
+
+test:test("threadpool", function(test)
+    test:plan(5)
+
+    local pool = threadpool.new('coroutine', 1)
+    local res = pool:cancel()
+    test:is(res, true, "threadpool.new('coroutine'): cancel")
+
+    pool = threadpool.new('coroutine', 1)
+    test:is(type(pool), 'table', "threadpool.new('coroutine'): type")
+
+    local ok
+    ok, pool = pcall(threadpool.new, 'fiber', 1)
+    local res_tarantool = ok == true and
+                          type(pool) == 'table' and
+                          utils.is_tarantool() == true
+    local res_luajit = ok == false and
+                       string.find(pool, 'No thread library with type "fiber"') ~= nil and
+                       utils.is_tarantool() == false
+    test:is(res_luajit or res_tarantool, true, "threadpool.new('fiber'): type")
+
+    local err
+    ok, err = pcall(threadpool.new, 'xxx', 1)
+    test:is(ok, false, "threadpool.new('xxx'): ok is true")
+    res = string.find(err, 'No thread library with type "xxx"')
+    test:isnt(res, nil, "threadpool.new('xxx'): error is correct")
+end)
+
+test:test("threads", function(test)
+    test:plan(2)
+    test:is(run_test_dict('coroutine'), true, "run_test_dict: coroutine")
+
+    local res = false
+    if utils.is_tarantool() then
+        res = true
+    end
+    test:is(run_test_dict('fiber'), res, "run_test_dict: fiber")
+end)
+
+------------------------
+---- Run the tests  ----
+------------------------
+
+require('test.coverage').shutdown()
+
+os.exit(test:check() == true and 0 or 1)