commit f62394fc3e88e0059d3416a532b6c3ddfb888571 from: Sergey Bronnikov via: Sergey Bronnikov date: Tue May 16 13:37:44 2023 UTC Initial version 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 . + "143/debug", + -- Accessing an undefined field of a global variable . + "143/os", + -- Accessing an undefined field of a global variable . + "143/string", + -- Accessing an undefined field of a global variable . + "143/table", + -- Unused argument . + "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('', '', '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 = '', + __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('') + + 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('') + -- TODO + return true +end + +local function join(self) + dev_checks('') + -- TODO + return true +end + +local function yield() + coroutine.yield() + return true +end + +local mt = { + __type = '', + __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('') + + 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('') + + 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('') + + if self.fiber_obj ~= nil and self.fiber_obj:status() ~= 'dead' then + self.fiber_obj:join() + end + + return true +end + +local mt = { + __type = '', + __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 = '', + __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 '' + 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('') + + 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('') + + 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('') + + 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('') + + 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('', '|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 = '', + __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('|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('|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('|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. +-- +-- +-- +--### 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 = '', + __tostring = function(self) + return '' + end, + }) +end + +-- Function that describes a 'write' operation. +local function op_w() + return setmetatable({ + f = 'txn', + value = {{ + 'w', + 'x', + math.random(1, 100), + }} + }, { + __type = '', + __tostring = function(self) + return '' + 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 = '', + __tostring = function(self) + return '' + 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('') + + 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('') + + for i = 1, self.thread_num do + self.pool[i]:cancel() + end + + return true +end + +local function start(self, ...) + dev_checks('') + + 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 = '', + __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 ', + 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)