diff --git a/test/fuzz/lua/test_engine.lua b/test/fuzz/lua/test_engine.lua
new file mode 100644
index 0000000000000000000000000000000000000000..f193ce4c38cd142fdb04840c817c15b692848b30
--- /dev/null
+++ b/test/fuzz/lua/test_engine.lua
@@ -0,0 +1,1431 @@
+--[[
+The test for Tarantool allows you to randomly generate DDL and DML
+operations for spaces uses vinyl and memtx engines and toggle
+random error injections. All random operations and settings depend
+on the seed, which is generated at the very beginning of the test.
+
+by default the script uses a directory `test_engine_dir` in
+a current directory. Custom test directory can be specified with
+option `--test_dir`. The script will clean up the directory before
+testing if it exists.
+
+Usage: tarantool test_engine.lua
+]]
+
+local fiber = require('fiber')
+local fio = require('fio')
+local fun = require('fun')
+local json = require('json')
+local log = require('log')
+local math = require('math')
+
+-- Tarantool datatypes.
+local datetime = require('datetime')
+local decimal = require('decimal')
+local uuid = require('uuid')
+
+local test_dir_name = 'test_engine_dir'
+local DEFAULT_TEST_DIR = fio.pathjoin(fio.cwd(), test_dir_name)
+
+local params = require('internal.argparse').parse(arg, {
+    { 'engine', 'string' },
+    { 'h', 'boolean' },
+    { 'seed', 'number' },
+    { 'test_duration', 'number' },
+    { 'test_dir', 'string' },
+    { 'verbose', 'boolean' },
+    { 'workers', 'number' },
+})
+
+local function counter()
+    local i = 0
+    return function()
+        i = i + 1
+        return i
+    end
+end
+
+local index_id_func = counter()
+
+if params.help or params.h then
+    print(([[
+
+ Usage: tarantool test_engine.lua [options]
+
+ Options can be used with '--', followed by the value if it's not
+ a boolean option. The options list with default values:
+
+   workers <number, 50>                  - number of fibers to run in parallel
+   test_duration <number, 2*60>          - test duration time (sec)
+   test_dir <string, ./%s>  - path to a test directory
+   engine <string, 'vinyl'>              - engine ('vinyl', 'memtx')
+   seed <number>                         - set a PRNG seed
+   verbose <boolean, false>              - enable verbose logging
+   help (same as -h)                     - print this message
+]]):format(test_dir_name))
+    os.exit(0)
+end
+
+-- Number of workers.
+local arg_num_workers = params.workers or 50
+
+-- Test duration time.
+local arg_test_duration = params.test_duration or 2*60
+
+-- Test directory.
+local arg_test_dir = params.test_dir or DEFAULT_TEST_DIR
+
+-- Tarantool engine.
+local arg_engine = params.engine or 'vinyl'
+
+local arg_verbose = params.verbose or false
+
+local seed = params.seed or os.time()
+math.randomseed(seed)
+log.info(string.format('Random seed: %d', seed))
+
+-- The table contains a whitelist of errors that will be ignored
+-- by test. Each item is a Lua pattern, special characters
+-- should be escaped: ^ $ ( ) % . [ ] * + - ?
+-- These characters can also be used in the pattern as normal
+-- characters by prefixing them with a "%" character, so "%%"
+-- becomes "%", "%[" becomes "[", etc.
+local err_pat_whitelist = {
+    -- Multi-engine transactions aren't supported, see
+    -- https://github.com/tarantool/tarantool/issues/1958 and
+    -- https://github.com/tarantool/tarantool/issues/1803.
+    "Can not perform index build in a multi-statement transaction",
+    -- DDL on a space is locked until the end of the current DDL
+    -- operation.
+    "the space is already being modified",
+    -- The test actively uses transactions that concurrently
+    -- changes a data in a space, this can lead to errors below.
+    "Transaction has been aborted by conflict",
+    "Vinyl does not support rebuilding the primary index of a non%-empty space",
+    "fiber is cancelled",
+    "fiber slice is exceeded",
+    "A multi%-statement transaction can not use multiple storage engines",
+    "Can not perform index build in a multi%-statement transaction",
+    "Index '[%w_]+' %(HASH%) of space '[%w_]+' %(memtx%) does not support pagination",
+    "Can't create or modify index '[%w_]+' in space '[%w_]+': primary key must be unique",
+    "Can't create or modify index '[%w_]+' in space '[%w_]+': hint is only reasonable with memtx tree index",
+    "Get%(%) doesn't support partial keys and non%-unique indexes",
+    "Index '[%w_]+' %(RTREE%) of space '[%w_]+' %(memtx%) does not support max%(%)",
+    "Index '[%w_]+' %(RTREE%) of space '[%w_]+' %(memtx%) does not support min%(%)",
+    -- Blocked by tarantool#10262.
+    "attempt to index local 'tuple' %(a nil value%)",
+    "Failed to allocate %d+ bytes in [%w_]+ for [%w_]+",
+}
+
+local function keys(t)
+    assert(next(t) ~= nil)
+    local table_keys = {}
+    for k, _ in pairs(t) do
+        table.insert(table_keys, k)
+    end
+    return table_keys
+end
+
+local function rmtree(path)
+    log.info(('CLEANUP %s'):format(path))
+    if (fio.path.is_file(path) or fio.path.is_link(path)) then
+        fio.unlink(path)
+        return
+    end
+    if fio.path.is_dir(path) then
+        for _, p in pairs(fio.listdir(path)) do
+            rmtree(fio.pathjoin(path, p))
+        end
+    end
+end
+
+local function rand_char()
+    return string.char(math.random(97, 97 + 25))
+end
+
+local function rand_string(length)
+    length = length or 10
+    local res = ''
+    for _ = 1, length do
+        res = res .. rand_char()
+    end
+    return res
+end
+
+local function oneof(tbl)
+    assert(type(tbl) == 'table')
+    assert(next(tbl) ~= nil)
+
+    local n = table.getn(tbl)
+    local idx = math.random(1, n)
+    return tbl[idx]
+end
+
+local function unique_ids(max_num_ids)
+    local ids = {}
+    for i = 1, max_num_ids do
+        table.insert(ids, i)
+    end
+    return function()
+        local id = math.random(#ids)
+        local v = ids[id]
+        assert(v)
+        table.remove(ids, id)
+        return v
+    end
+end
+
+-- Forward declaration.
+local index_create_op
+
+local function random_int()
+    return math.floor(math.random() * 10^12)
+end
+
+-- Maximal possible R-tree dimension,
+-- see <src/lib/salad/rtree.h>.
+local RTREE_MAX_DIMENSION = 20
+
+local RTREE_DIMENSION = math.random(RTREE_MAX_DIMENSION)
+
+-- RTREE is a single index that support arrays, length of arrays
+-- depends on a RTREE's dimension.
+local function random_array()
+    local n = RTREE_DIMENSION * 2
+    local arr = {}
+    for i = 1, n do
+        table.insert(arr, i)
+    end
+    return arr
+end
+
+local function random_map()
+    local n = math.random(1, 10)
+    local t = {}
+    for i = 1, n do
+        t[tostring(i)] = i
+    end
+    return t
+end
+
+-- '+' - Numeric.
+-- '-' - Numeric.
+-- '&' - Numeric.
+-- '|' - Numeric.
+-- '^' - Numeric.
+-- '#' - For deletion.
+-- '=' - For assignment.
+-- ':' - For string splice.
+-- '!' - For insertion of a new field.
+-- https://www.tarantool.io/en/doc/latest/concepts/data_model/indexes/#indexes-tree
+-- TODO: support varbinary.
+-- NOTE: scalar type may include nil, boolean, integer, unsigned,
+-- number, decimal, string, varbinary, or uuid values. All these
+-- datatypes tested separately, except varbinary, so scalar is
+-- unused.
+-- NOTE: map is cannot be indexed, so it is unused.
+local tarantool_type = {
+    ['array'] = {
+        generator = random_array,
+        operations = {'=', '!'},
+    },
+    ['boolean'] = {
+        generator = function()
+            return oneof({true, false})
+        end,
+        operations = {'=', '!'},
+    },
+    ['decimal'] = {
+        generator = function()
+            return decimal.new(random_int())
+        end,
+        operations = {'+', '-'},
+    },
+    ['datetime'] = {
+        generator = function()
+            return datetime.new({timestamp = os.time()})
+        end,
+        operations = {'=', '!'},
+    },
+    ['double'] = {
+        generator = function()
+            return math.random() * 10^12
+        end,
+        operations = {'-'},
+    },
+    ['integer'] = {
+        generator = random_int,
+        operations = {'+', '-'},
+    },
+    ['map'] = {
+        generator = random_map,
+        operations = {'=', '!'},
+    },
+    ['number'] = {
+        generator = random_int,
+        operations = {'+', '-'},
+    },
+    ['string'] = {
+        generator = rand_string,
+        operations = {'=', '!'}, -- XXX: ':'
+    },
+    ['unsigned'] = {
+        generator = function()
+            return math.abs(random_int())
+        end,
+        operations = {'#', '+', '-', '&', '|', '^'},
+    },
+    ['uuid'] = {
+        generator = uuid.new,
+        operations = {'=', '!'},
+    },
+}
+
+-- The name value may be any string, provided that two fields
+-- do not have the same name.
+-- The type value may be any of allowed types:
+-- any | unsigned | string | integer | number | varbinary |
+-- boolean | double | decimal | uuid | array | map | scalar,
+-- but for creating an index use only indexed fields;
+-- (Optional) The is_nullable boolean value specifies whether
+-- nil can be used as a field value. See also: key_part.is_nullable.
+-- (Optional) The collation string value specifies the collation
+-- used to compare field values. See also: key_part.collation.
+-- (Optional) The constraint table specifies the constraints that
+-- the field value must satisfy.
+-- (Optional) The foreign_key table specifies the foreign keys
+-- for the field.
+--
+-- See https://www.tarantool.io/ru/doc/latest/reference/reference_lua/box_space/format/.
+local function random_space_format()
+    local space_format = {}
+    local min_num_fields = table.getn(keys(tarantool_type))
+    local max_num_fields = min_num_fields + 10
+    local num_fields = math.random(min_num_fields, max_num_fields)
+    for i, datatype in ipairs(keys(tarantool_type)) do
+        table.insert(space_format, {
+            name =('field_%d'):format(i),
+            type = datatype,
+        })
+    end
+    for i = min_num_fields - 1, num_fields - min_num_fields - 1 do
+        table.insert(space_format, {
+            name =('field_%d'):format(i),
+            type = oneof(keys(tarantool_type)),
+        })
+    end
+
+    return space_format
+end
+
+-- Iterator types for indexes.
+-- See https://www.tarantool.io/en/doc/latest/reference/reference_lua/box_index/pairs/#box-index-iterator-types
+-- TODO: support `is_nullable`.
+-- TODO: support `multikey`.
+-- TODO: support `exclude_null`.
+-- TODO: support `pagination`.
+local tarantool_indices = {
+    HASH = {
+        iterator_type = {
+            'ALL',
+            'EQ',
+        },
+        data_type = {
+            ['boolean'] = true,
+            ['decimal'] = true,
+            ['double'] = true,
+            ['integer'] = true,
+            ['number'] = true,
+            ['scalar'] = true,
+            ['string'] = true,
+            ['unsigned'] = true,
+            ['uuid'] = true,
+            ['varbinary'] = true,
+        },
+        is_multipart = true,
+        is_min_support = false,
+        is_max_support = false,
+        is_unique_support = true,
+        is_non_unique_support = false,
+        is_primary_key_support = true,
+        is_partial_search_support = false,
+    },
+    BITSET = {
+        iterator_type = {
+            'ALL',
+            'BITS_ALL_NOT_SET',
+            'BITS_ALL_SET',
+            'BITS_ANY_SET',
+            'EQ',
+        },
+        data_type = {
+            ['string'] = true,
+            ['unsigned'] = true,
+            ['varbinary'] = true,
+        },
+        is_multipart = false,
+        is_min_support = false,
+        is_max_support = false,
+        is_unique_support = false,
+        is_non_unique_support = true,
+        is_primary_key_support = false,
+        is_partial_search_support = false,
+    },
+    TREE = {
+        iterator_type = {
+            'ALL',
+            'EQ',
+            'GE',
+            'GT',
+            'LE',
+            'LT',
+            'REQ',
+        },
+        data_type = {
+            ['boolean'] = true,
+            ['datetime'] = true,
+            ['decimal'] = true,
+            ['double'] = true,
+            ['integer'] = true,
+            ['number'] = true,
+            ['scalar'] = true,
+            ['string'] = true,
+            ['unsigned'] = true,
+            ['uuid'] = true,
+            ['varbinary'] = true,
+        },
+        is_multipart = true,
+        is_min_support = true,
+        is_max_support = true,
+        is_unique_support = true,
+        is_non_unique_support = true,
+        is_primary_key_support = true,
+        is_partial_search_support = true,
+    },
+    RTREE = {
+        iterator_type = {
+            'ALL',
+            'EQ',
+            'GE',
+            'GT',
+            'LE',
+            'LT',
+            'NEIGHBOR',
+            'OVERLAPS',
+        },
+        data_type = {
+            ['array'] = true,
+        },
+        is_multipart = false,
+        is_min_support = true,
+        is_max_support = true,
+        is_unique_support = false,
+        is_non_unique_support = true,
+        is_primary_key_support = false,
+        is_partial_search_support = true,
+    },
+}
+
+local function select_op(space, idx_type, key)
+    local select_opts = {
+        iterator = oneof(tarantool_indices[idx_type].iterator_type),
+        -- The maximum number of tuples.
+        limit = math.random(100, 500),
+        -- The number of tuples to skip.
+        offset = math.random(100),
+        -- A tuple or the position of a tuple (tuple_pos) after
+        -- which select starts the search.
+        after = box.NULL,
+        -- If true, the select method returns the position of
+        -- the last selected tuple as the second value.
+        fetch_pos = oneof({true, false}),
+    }
+    space:select(key, select_opts)
+end
+
+local function get_op(space, key)
+    space:get(key)
+end
+
+local function put_op(space, tuple)
+    space:put(tuple)
+end
+
+local function delete_op(space, tuple)
+    space:delete(tuple)
+end
+
+local function insert_op(space, tuple)
+    space:insert(tuple)
+end
+
+local function upsert_op(space, tuple, tuple_ops)
+    assert(next(tuple_ops) ~= nil)
+    space:upsert(tuple, tuple_ops)
+end
+
+local function update_op(space, key, tuple_ops)
+    assert(next(tuple_ops) ~= nil)
+    space:update(key, tuple_ops)
+end
+
+local function replace_op(space, tuple)
+    space:replace(tuple)
+end
+
+local function bsize_op(space)
+    space:bsize()
+end
+
+local function len_op(space)
+    space:len()
+end
+
+local function format_op(space, space_format)
+    space:format(space_format)
+end
+
+local function setup(engine_name, space_id_func, test_dir, verbose)
+    log.info('SETUP')
+    assert(engine_name == 'memtx' or
+           engine_name == 'vinyl')
+    -- Configuration reference (box.cfg),
+    -- https://www.tarantool.io/en/doc/latest/reference/configuration/
+    local box_cfg_options = {
+        checkpoint_count = math.random(5),
+        checkpoint_interval = math.random(60),
+        checkpoint_wal_threshold = math.random(1024),
+        iproto_threads = math.random(1, 10),
+        memtx_allocator = oneof({'system', 'small'}),
+        memtx_memory = 1024 * 1024,
+        memtx_sort_threads = math.random(1, 256),
+        memtx_use_mvcc_engine = oneof({true, false}),
+        readahead = 16320,
+        slab_alloc_factor = math.random(1, 2),
+        vinyl_bloom_fpr = math.random(50) / 100,
+        vinyl_cache = oneof({0, 2}) * 1024 * 1024,
+        vinyl_max_tuple_size = math.random(0, 100000),
+        vinyl_memory = 800 * 1024 * 1024,
+        vinyl_page_size = math.random(1024, 2048),
+        vinyl_range_size = 128 * 1024,
+        vinyl_read_threads = math.random(2, 10),
+        vinyl_run_count_per_level = math.random(1, 10),
+        vinyl_run_size_ratio = math.random(2, 5),
+        vinyl_timeout = math.random(1, 5),
+        vinyl_write_threads = math.random(2, 10),
+        wal_cleanup_delay = 14400,
+        wal_dir_rescan_delay = math.random(1, 20),
+        wal_max_size = math.random(1024 * 1024 * 1024),
+        wal_mode = oneof({'write', 'fsync'}),
+        wal_queue_max_size = 16777216,
+        work_dir = test_dir,
+        worker_pool_threads = math.random(1, 10),
+    }
+    if verbose then
+        box_cfg_options.log_level = 'verbose'
+    end
+    box.cfg(box_cfg_options)
+    log.info('FINISH BOX.CFG')
+
+    log.info('CREATE A SPACE')
+    local space_format = random_space_format()
+    -- TODO: support `constraint`.
+    -- TODO: support `foreign_key`.
+    local space_opts = {
+        engine = engine_name,
+        field_count = oneof({0, table.getn(space_format)}),
+        format = space_format,
+        if_not_exists = oneof({true, false}),
+        is_local = oneof({true, false}),
+    }
+    if space_opts.engine ~= 'vinyl' then
+        space_opts.temporary = oneof({true, false})
+    end
+    local space_name = ('test_%d'):format(space_id_func())
+    local space = box.schema.space.create(space_name, space_opts)
+    index_create_op(space)
+    index_create_op(space)
+    log.info('FINISH SETUP')
+    return space
+end
+
+local function cleanup_dir(dir)
+    log.info('CLEANUP')
+    if dir ~= nil then
+        rmtree(dir)
+        dir = nil -- luacheck: ignore
+    end
+end
+
+local function teardown(space)
+    log.info('TEARDOWN')
+    space:drop()
+end
+
+-- Indexes,
+-- https://www.tarantool.io/en/doc/latest/concepts/data_model/indexes/
+-- space_object:create_index(),
+-- https://www.tarantool.io/en/doc/latest/reference/reference_lua/box_space/create_index/
+local function index_opts(space, is_primary)
+    assert(space ~= nil)
+    local opts = {
+        if_not_exists = false,
+        -- TODO: support `sequence`,
+        -- TODO: support functional indices.
+    }
+
+    if space.engine == 'vinyl' then
+        opts.bloom_fpr = math.random(50) / 100
+        opts.page_size = math.random(10) * 1024
+        opts.range_size = 1073741824
+    end
+
+    local indices = fun.iter(keys(tarantool_indices)):filter(
+        function(x)
+            if tarantool_indices[x].is_primary_key_support == is_primary then
+                return x
+            end
+        end):totable()
+
+    if space.engine == 'vinyl' then
+        indices = {'TREE'}
+    end
+
+    opts.type = oneof(indices)
+    -- Primary key must be unique.
+    opts.unique = is_primary and
+                  true or
+                  tarantool_indices[opts.type].is_unique_support
+
+    -- 'hint' is only reasonable with memtx tree index.
+    if space.engine == 'memtx' and
+       opts.type == 'TREE' then
+        opts.hint = true
+    end
+
+    if opts.type == 'RTREE' then
+        opts.distance = oneof({'euclid', 'manhattan'})
+        opts.dimension = RTREE_DIMENSION
+    end
+
+    opts.parts = {}
+    local space_format = space:format()
+    local idx = opts.type
+    local possible_fields = fun.iter(space_format):filter(
+        function(x)
+            if tarantool_indices[idx].data_type[x.type] == true then
+                return x
+            end
+        end):totable()
+    local n_parts = math.random(1, table.getn(possible_fields))
+    local id = unique_ids(n_parts)
+    for i = 1, n_parts do
+        local field_id = id()
+        local field = possible_fields[field_id]
+        table.insert(opts.parts, { field.name })
+        if not tarantool_indices[opts.type].is_multipart and
+           i == 1 then
+            break
+        end
+    end
+
+    return opts
+end
+
+function index_create_op(space)
+    local idx_id = index_id_func()
+    local idx_name = 'idx_' .. idx_id
+    local is_primary = idx_id == 1
+    local opts = index_opts(space, is_primary)
+    space:create_index(idx_name, opts)
+end
+
+local function index_drop_op(space)
+    if not space.enabled then return end
+    local idx = oneof(space.index)
+    if idx ~= nil then idx:drop() end
+end
+
+local function index_alter_op(_, idx, opts)
+    assert(idx)
+    assert(opts)
+    opts.if_not_exists = nil
+    idx:alter(opts)
+end
+
+local function index_compact_op(_, idx)
+    assert(idx)
+    idx:compact()
+end
+
+local function index_max_op(_, idx)
+    assert(idx)
+    if not tarantool_indices[idx.type].is_max_support then
+        return
+    end
+    idx:max()
+end
+
+local function index_min_op(_, idx)
+    assert(idx)
+    if not tarantool_indices[idx.type].is_min_support then
+        return
+    end
+    idx:min()
+end
+
+local function index_random_op(_, idx)
+    assert(idx)
+    if idx.type ~= 'TREE' and
+       idx.type ~= 'BITSET' and
+       idx.type ~= 'RTREE' then
+        idx:random()
+    end
+end
+
+local function index_rename_op(_, idx, idx_name)
+    assert(idx)
+    idx:rename(idx_name)
+end
+
+local function index_stat_op(_, idx)
+    assert(idx)
+    idx:stat()
+end
+
+local function index_get_op(_space, idx, key)
+    assert(idx)
+    assert(key)
+    local index_opts = tarantool_indices[idx.type]
+    if not index_opts.is_partial_search_support or
+       not index_opts.is_non_unique_support then
+        return
+    end
+    idx:get(key)
+end
+
+local function index_select_op(_space, idx, key)
+    assert(idx)
+    assert(key)
+    idx:select(key)
+end
+
+local function index_count_op(_, idx)
+    assert(idx)
+    idx:count()
+end
+
+local function index_update_op(_space, key, idx, tuple_ops)
+    assert(idx)
+    assert(key)
+    assert(tuple_ops)
+    assert(next(tuple_ops) ~= nil)
+    local index_opts = tarantool_indices[idx.type]
+    if not index_opts.is_partial_search_support or
+       not index_opts.is_non_unique_support then
+        return
+    end
+    idx:update(key, tuple_ops)
+end
+
+local function index_delete_op(_space, idx, key)
+    assert(idx)
+    assert(key)
+    local index_opts = tarantool_indices[idx.type]
+    if not index_opts.is_partial_search_support or
+       not index_opts.is_non_unique_support then
+        return
+    end
+    idx:delete(key)
+end
+
+local function random_field_value(field_type)
+    local type_gen = tarantool_type[field_type].generator
+    assert(type(type_gen) == 'function', field_type)
+    return type_gen()
+end
+
+-- TODO: support `is_nullable`.
+local function random_tuple(space_format)
+    local tuple = {}
+    for _, field in ipairs(space_format) do
+        table.insert(tuple, random_field_value(field.type))
+    end
+
+    return tuple
+end
+
+-- Example of tuple operations: {{'=', 3, 'a'}, {'=', 4, 'b'}}.
+--  - operator (string) – operation type represented in string.
+--  - field_identifier (number) – what field the operation will
+--    apply to.
+--  - value (lua_value) – what value will be applied.
+local function random_tuple_operations(space)
+    local space_format = space:format()
+    local num_fields = math.random(table.getn(space_format))
+    local tuple_ops = {}
+    local id = unique_ids(num_fields)
+    for _ = 1, math.random(num_fields) do
+        local field_id = id()
+        local field_type = space_format[field_id].type
+        local operator = oneof(tarantool_type[field_type].operations)
+        local value = random_field_value(field_type)
+        table.insert(tuple_ops, {operator, field_id, value})
+    end
+
+    return tuple_ops
+end
+
+local function random_key(space, idx)
+    assert(idx, ('indices: %s'):format(json.encode(space.index)))
+    local parts = idx.parts
+    local key = {}
+    for _, field in ipairs(parts) do
+        local type_gen = tarantool_type[field.type].generator
+        assert(type(type_gen) == 'function')
+        table.insert(key, type_gen())
+    end
+    return key
+end
+
+local function box_snapshot()
+    local in_progress = box.info.gc().checkpoint_is_in_progress
+    if not in_progress then
+        box.snapshot()
+    end
+end
+
+local ops = {
+    -- DML.
+    DELETE_OP = {
+        func = delete_op,
+        args = function(space) return random_key(space, space.index[0]) end,
+    },
+    INSERT_OP = {
+        func = insert_op,
+        args = function(space) return random_tuple(space:format()) end,
+    },
+    SELECT_OP = {
+        func = select_op,
+        args = function(space)
+            local idx = space.index[0]
+            return idx.type, random_key(space, idx)
+        end,
+    },
+    GET_OP = {
+        func = get_op,
+        args = function(space) return random_key(space, space.index[0]) end,
+    },
+    PUT_OP = {
+        func = put_op,
+        args = function(space) return random_tuple(space:format()) end,
+    },
+    REPLACE_OP = {
+        func = replace_op,
+        args = function(space) return random_tuple(space:format()) end,
+    },
+    UPDATE_OP = {
+        func = update_op,
+        args = function(space)
+            local pk = space.index[0]
+            return random_key(space, pk), random_tuple_operations(space)
+        end,
+    },
+    UPSERT_OP = {
+        func = upsert_op,
+        args = function(space)
+            return random_tuple(space:format()), random_tuple_operations(space)
+        end,
+    },
+    BSIZE_OP = {
+        func = bsize_op,
+        args = function(_) return end,
+    },
+    LEN_OP = {
+        func = len_op,
+        args = function(_) return end,
+    },
+    FORMAT_OP = {
+        func = format_op,
+        args = function(_space) return random_space_format() end,
+    },
+
+    -- DDL.
+    INDEX_ALTER_OP = {
+        func = index_alter_op,
+        args = function(space)
+            local idx_n = oneof(keys(space.index))
+            local is_primary = idx_n == 0
+            return space.index[idx_n], index_opts(space, is_primary)
+        end,
+    },
+    INDEX_COMPACT_OP = {
+        func = index_compact_op,
+        args = function(space)
+            local idx_n = oneof(keys(space.index))
+            return space.index[idx_n]
+        end,
+    },
+    INDEX_CREATE_OP = {
+        func = index_create_op,
+        args = function(_) return end,
+    },
+    INDEX_DROP_OP = {
+        func = index_drop_op,
+        args = function(space)
+            local indices = keys(space.index)
+            -- Don't touch primary index.
+            table.remove(indices, 0)
+            local idx_n = oneof(indices)
+            return space.index[idx_n]
+        end,
+    },
+    INDEX_GET_OP = {
+        func = index_get_op,
+        args = function(space)
+            local idx_n = oneof(keys(space.index))
+            local idx = space.index[idx_n]
+            return idx, random_key(space, idx)
+        end,
+    },
+    INDEX_SELECT_OP = {
+        func = index_select_op,
+        args = function(space)
+            local idx_n = oneof(keys(space.index))
+            local idx = space.index[idx_n]
+            return idx, random_key(space, idx)
+        end,
+    },
+    INDEX_MIN_OP = {
+        func = index_min_op,
+        args = function(space)
+            local idx_n = oneof(keys(space.index))
+            return space.index[idx_n]
+        end,
+    },
+    INDEX_MAX_OP = {
+        func = index_max_op,
+        args = function(space)
+            local idx_n = oneof(keys(space.index))
+            return space.index[idx_n]
+        end,
+    },
+    INDEX_RANDOM_OP = {
+        func = index_random_op,
+        args = function(space)
+            local idx_n = oneof(keys(space.index))
+            return space.index[idx_n]
+        end,
+    },
+    INDEX_COUNT_OP = {
+        func = index_count_op,
+        args = function(space)
+            local idx_n = oneof(keys(space.index))
+            return space.index[idx_n]
+        end,
+    },
+    INDEX_UPDATE_OP = {
+        func = index_update_op,
+        args = function(space)
+            local idx_n = oneof(keys(space.index))
+            local idx = space.index[idx_n]
+            return random_key(space, idx), idx, random_tuple_operations(space)
+        end,
+    },
+    INDEX_DELETE_OP = {
+        func = index_delete_op,
+        args = function(space)
+            local idx_n = oneof(keys(space.index))
+            local idx = space.index[idx_n]
+            return idx, random_key(space, idx)
+        end,
+    },
+    INDEX_RENAME_OP = {
+        func = index_rename_op,
+        args = function(space)
+            local idx_name = rand_string()
+            local idx_n = oneof(keys(space.index))
+            return space.index[idx_n], idx_name
+        end,
+    },
+    INDEX_STAT_OP = {
+        func = index_stat_op,
+        args = function(space)
+            local idx_n = oneof(keys(space.index))
+            return space.index[idx_n]
+        end,
+    },
+
+    TX_BEGIN = {
+        func = function()
+            if not box.is_in_txn() then
+                box.begin()
+            end
+        end,
+        args = function(_) return end,
+    },
+    TX_COMMIT = {
+        func = function()
+            if box.is_in_txn() then
+                box.commit()
+            end
+        end,
+        args = function(_) return end,
+    },
+    TX_ROLLBACK = {
+        func = function()
+            if box.is_in_txn() then
+                box.rollback()
+            end
+        end,
+        args = function(_) return end,
+    },
+
+    SNAPSHOT_OP = {
+        func = box_snapshot,
+        args = function(_) return end,
+    },
+}
+
+local function apply_op(space, op_name)
+    local func = ops[op_name].func
+    local args = { ops[op_name].args(space) }
+    log.info(('%s %s'):format(op_name, json.encode(args)))
+    local pcall_args = {func, space, unpack(args)}
+    local ok, err = pcall(unpack(pcall_args))
+    if ok ~= true then
+        log.info(('ERROR: opname "%s", err "%s", args %s'):
+                 format(op_name, err, json.encode(args)))
+    end
+    return err
+end
+
+local shared_gen_state
+
+local function worker_func(id, space, test_gen, test_duration)
+    log.info(('Worker #%d has started.'):format(id))
+    local start = os.clock()
+    local gen, param, state = test_gen:unwrap()
+    shared_gen_state = state
+    local errors = {}
+    while os.clock() - start <= test_duration do
+        local operation_name
+        state, operation_name = gen(param, shared_gen_state)
+        if state == nil then
+            break
+        end
+        shared_gen_state = state
+        local err = apply_op(space, operation_name)
+        table.insert(errors, err)
+    end
+    log.info(('Worker #%d has finished.'):format(id))
+    return errors
+end
+
+local function toggle_random_errinj(errinj, max_enabled, space)
+    local enabled_errinj = fun.iter(errinj):
+                           filter(function(i, x)
+                               if x.is_enabled then
+                                   return i
+                               end
+                           end):totable()
+    log.info(('Enabled fault injections: %s'):format(
+             json.encode(enabled_errinj)))
+    local errinj_val, errinj_name
+    if table.getn(enabled_errinj) >= max_enabled then
+        errinj_name = oneof(enabled_errinj)
+        errinj_val = errinj[errinj_name].disable(space)
+        errinj[errinj_name].is_enabled = false
+    else
+        errinj_name = oneof(keys(errinj))
+        errinj_val = errinj[errinj_name].enable(space)
+        errinj[errinj_name].is_enabled = true
+    end
+    log.info(string.format('TOGGLE RANDOM ERROR INJECTION: %s -> %s',
+                           errinj_name, tostring(errinj_val)))
+    local ok, err = pcall(box.error.injection.set, errinj_name, errinj_val)
+    if not ok then
+        log.info(('Failed to toggle fault injection: %s'):format(err))
+    end
+end
+
+local enable_errinj_boolean = function(_space) return true end
+local disable_errinj_boolean = function(_space) return false end
+local enable_errinj_timeout = function(_space)
+    return math.random(1, 3)
+end
+local disable_errinj_timeout = function(_space) return 0 end
+
+-- Tarantool fault injections described in a table returned by
+-- `box.error.injection.info()`. However, some fault injections
+-- are not safe to use and could lead to false positive bugs.
+-- The table below contains fault injections that are useful
+-- in fuzzing testing, see details in [1].
+--
+-- 1. https://github.com/tarantool/tarantool/issues/10236#issuecomment-2225347088
+local errinj_set = {
+    -- Set to index id (0, 1, 2, ...) to fail index (re)build on
+    -- alter.
+    ERRINJ_BUILD_INDEX = {
+        enable = function(space)
+            return math.random(#(keys(space.index)))
+        end,
+        disable = function(_space)
+            return -1
+        end,
+    },
+    -- Set to true to inject delay during index (re)build on alter.
+    ERRINJ_BUILD_INDEX_DELAY = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to fail index (re)build on alter.
+    ERRINJ_BUILD_INDEX_ON_ROLLBACK_ALLOC = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to timeout in seconds to inject after each tuple
+    -- processed on index (re)build on alter.
+    ERRINJ_BUILD_INDEX_TIMEOUT = {
+        enable = enable_errinj_timeout,
+        disable = disable_errinj_timeout,
+    },
+    -- Set to true to inject delay during space format check on
+    -- alter.
+    ERRINJ_CHECK_FORMAT_DELAY = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to inject OOM while allocating an index extend
+    -- in memtx.
+    ERRINJ_INDEX_ALLOC = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to fail index iterator creation.
+    ERRINJ_INDEX_ITERATOR_NEW = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to fail insertion into memtx hash index.
+    ERRINJ_HASH_INDEX_REPLACE = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to delay freeing memory after dropped memtx
+    -- index.
+    ERRINJ_MEMTX_DELAY_GC = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to delay materialization of memtx snapshot.
+    ERRINJ_SNAP_COMMIT_DELAY = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to delay write of memtx snapshot.
+    ERRINJ_SNAP_WRITE_DELAY = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to timeout in seconds to inject after each tuple
+    -- written to memtx snapshot.
+    ERRINJ_SNAP_WRITE_TIMEOUT = {
+        enable = enable_errinj_timeout,
+        disable = disable_errinj_timeout,
+    },
+    -- Set to true to fail index select.
+    ERRINJ_TESTING = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to fail allocation of memtx tuple.
+    ERRINJ_TUPLE_ALLOC = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to timeout to inject before processing each internal
+    -- cbus message.
+    ERRINJ_TX_DELAY_PRIO_ENDPOINT = {
+        enable = enable_errinj_timeout,
+        disable = disable_errinj_timeout,
+    },
+    -- Set to true to fail reading vinyl page from disk.
+    ERRINJ_VYRUN_DATA_READ = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to delay vinyl compaction.
+    ERRINJ_VY_COMPACTION_DELAY = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to delay lookup of tuple in primary index by
+    -- secondary key.
+    ERRINJ_VY_DELAY_PK_LOOKUP = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to delay vinyl dump.
+    ERRINJ_VY_DUMP_DELAY = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to disable vinyl garbage collection.
+    ERRINJ_VY_GC = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to timeout to fail vinyl index dump.
+    ERRINJ_VY_INDEX_DUMP = {
+        enable = enable_errinj_timeout,
+        disable = function() return -1 end,
+    },
+    -- Set to true to fail materialization of vinyl index file.
+    ERRINJ_VY_INDEX_FILE_RENAME = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to fail materialization of vinyl log file.
+    ERRINJ_VY_LOG_FILE_RENAME = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to fail write to vinyl log file.
+    ERRINJ_VY_LOG_FLUSH = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to timeout to inject before consuming vinyl memory
+    -- quota.
+    ERRINJ_VY_QUOTA_DELAY = {
+        enable = enable_errinj_timeout,
+        disable = disable_errinj_timeout,
+    },
+    -- Set to true to fail reading vinyl page from disk.
+    ERRINJ_VY_READ_PAGE = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to delay reading vinyl page from disk.
+    ERRINJ_VY_READ_PAGE_DELAY = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to timeout to inject while reading vinyl page from disk.
+    ERRINJ_VY_READ_PAGE_TIMEOUT = {
+        enable = enable_errinj_timeout,
+        disable = disable_errinj_timeout,
+    },
+    -- Set to true to fail read view merge during vinyl
+    -- dump/compaction due to OOM.
+    ERRINJ_VY_READ_VIEW_MERGE_FAIL = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to disable purging empty/failed run files from
+    -- log after vinyl dump/compaction.
+    ERRINJ_VY_RUN_DISCARD = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to fail materialization of vinyl run file.
+    ERRINJ_VY_RUN_FILE_RENAME = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to fail vinyl run file write.
+    ERRINJ_VY_RUN_WRITE = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to delay vinyl run file write.
+    ERRINJ_VY_RUN_WRITE_DELAY = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to timeout to inject after writing each tuple during
+    -- vinyl dump/compaction.
+    ERRINJ_VY_RUN_WRITE_STMT_TIMEOUT = {
+        enable = enable_errinj_timeout,
+        disable = disable_errinj_timeout,
+    },
+    -- Set to timeout to throttle scheduler for after failed vinyl
+    -- dump/compaction.
+    ERRINJ_VY_SCHED_TIMEOUT = {
+        enable = enable_errinj_timeout,
+        disable = disable_errinj_timeout,
+    },
+    -- Set to timeout to inject before squashing vinyl upsert in
+    -- background.
+    ERRINJ_VY_SQUASH_TIMEOUT = {
+        enable = enable_errinj_timeout,
+        disable = disable_errinj_timeout,
+    },
+    -- Set to timeout to fail allocation of vinyl tuple.
+    ERRINJ_VY_STMT_ALLOC = {
+        enable = enable_errinj_timeout,
+        disable = disable_errinj_timeout,
+    },
+    -- Set to true to fail completion of vinyl dump/compaction.
+    ERRINJ_VY_TASK_COMPLETE = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to fail creation of vinyl dump/compaction task
+    -- due to OOM.
+    ERRINJ_VY_WRITE_ITERATOR_START_FAIL = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to delay write to WAL.
+    ERRINJ_WAL_DELAY = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to timeout to fail WAL write due to error allocating disk
+    -- space.
+    ERRINJ_WAL_FALLOCATE = {
+        enable = enable_errinj_timeout,
+        disable = disable_errinj_timeout,
+    },
+    -- Set to true to fail WAL write.
+    ERRINJ_WAL_IO = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to fail creation of new WAL file.
+    ERRINJ_WAL_ROTATE = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to fail WAL sync.
+    ERRINJ_WAL_SYNC = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to inject delay after WAL sync.
+    ERRINJ_WAL_SYNC_DELAY = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to fail write to xlog file.
+    ERRINJ_WAL_WRITE = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to true to fail write to xlog file.
+    ERRINJ_WAL_WRITE_DISK = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to timeout to fail write to xlog file.
+    ERRINJ_WAL_WRITE_PARTIAL = {
+        enable = enable_errinj_timeout,
+        disable = disable_errinj_timeout,
+    },
+    -- Set to true to fail xlog meta read.
+    ERRINJ_XLOG_META = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+    -- Set to timeout to fail xlog data read.
+    ERRINJ_XLOG_READ = {
+        enable = enable_errinj_timeout,
+        disable = disable_errinj_timeout,
+    },
+    -- Set to true to delay xlog file materialization.
+    ERRINJ_XLOG_RENAME_DELAY = {
+        enable = enable_errinj_boolean,
+        disable = disable_errinj_boolean,
+    },
+}
+
+local function is_error_expected(err_msg, err_whitelist)
+    local is_expected = false
+    for _, err_pat in ipairs(err_whitelist) do
+        if err_msg:match(err_pat) then
+            is_expected = true
+            break
+        end
+    end
+    return is_expected
+end
+
+local function process_errors(error_messages)
+    print('Unexpected errors:')
+    local found_unexpected_errors = false
+    for err_msg, _ in pairs(error_messages) do
+        local is_expected = is_error_expected(err_msg, err_pat_whitelist)
+        if not is_expected then
+            found_unexpected_errors = true
+            print(('\t- %s'):format(err_msg))
+        end
+    end
+    if not found_unexpected_errors then
+        print('None')
+    end
+    return found_unexpected_errors
+end
+
+local function run_test(num_workers, test_duration, test_dir,
+                        engine_name, verbose_mode)
+
+    if fio.path.exists(test_dir) then
+        cleanup_dir(test_dir)
+    else
+        fio.mkdir(test_dir)
+    end
+
+    local workers = {}
+    local space_id_func = counter()
+    local space = setup(engine_name, space_id_func, test_dir, verbose_mode)
+
+    local test_gen = fun.cycle(fun.iter(keys(ops)))
+    local f
+    for id = 1, num_workers do
+        f = fiber.new(worker_func, id, space, test_gen, test_duration)
+        f:set_joinable(true)
+        f:name('WRK #' .. id)
+        table.insert(workers, f)
+    end
+
+    local errinj_f = fiber.new(function(test_duration)
+        log.info('Fault injection fiber has started.')
+        local max_errinj_in_parallel = 5
+        local start = os.clock()
+        while os.clock() - start <= test_duration do
+            toggle_random_errinj(errinj_set, max_errinj_in_parallel, space)
+            fiber.sleep(2)
+        end
+        log.info('Fault injection fiber has finished.')
+    end, arg_test_duration)
+    errinj_f:set_joinable(true)
+    errinj_f:name('ERRINJ')
+    table.insert(workers, errinj_f)
+
+    local error_messages = {}
+    for _, fb in ipairs(workers) do
+        local ok, res = fiber.join(fb)
+        if not ok then
+            log.info('ERROR: ' .. json.encode(res))
+        end
+        if fiber.status(fb) ~= 'dead' then
+            fiber.kill(fb)
+        end
+        if type(res) == 'table' then
+            for _, v in ipairs(res) do
+                local msg = tostring(v)
+                error_messages[msg] = error_messages[msg] or 1
+            end
+        end
+    end
+
+    teardown(space)
+
+    local exit_code = process_errors(error_messages) and 1 or 0
+    os.exit(exit_code)
+end
+
+run_test(arg_num_workers, arg_test_duration, arg_test_dir,
+         arg_engine, arg_verbose)