Commit Diff


commit - 25f7a9f62d9ab38aed4bc9c37a75601c45fc4952
commit + 774b84d7e6f3d61000d64bbf91dd9b0784f30f5c
blob - /dev/null
blob + e9dd8477cc61d262efa6ff927f8362d735238b2f (mode 644)
--- /dev/null
+++ changelogs/unreleased/gh-10488-pagination-mvcc-non-unique-index.md
@@ -0,0 +1,4 @@
+## bugfix/memtx
+
+* Fixed a crash when using pagination over a non-unique index with range
+  requests and MVCC enabled (gh-10448).
blob - da9bdf2be27ee1be3f98d7ff398714d61df3c52c
blob + 791e71074372e32b574dce0411d28a3928193a90
--- src/box/memtx_tree.cc
+++ src/box/memtx_tree.cc
@@ -794,11 +794,18 @@ tree_iterator_start(struct iterator *iterator, struct 
 /********MVCC TRANSACTION MANAGER STORY GARBAGE COLLECTION BOUND START*********/
 	if (key_is_full && !eq_match)
 		memtx_tx_track_point(txn, space, index_base, it->key_data.key);
+	/*
+	 * Since MVCC operates with `key_def` of index but `start_data` can
+	 * contain key extracted with `cmp_def`, we should crop it by passing
+	 * `part_count` not greater than `key_def->part_count`.
+	 */
+	uint32_t key_part_count = index->base.def->key_def->part_count;
 	if (!key_is_full ||
 	    ((type == ITER_GE || type == ITER_LE) && !equals) ||
 	    (type == ITER_GT || type == ITER_LT))
 		memtx_tx_track_gap(txn, space, index_base, successor, type,
-				   start_data.key, start_data.part_count);
+				   start_data.key,
+				   MIN(start_data.part_count, key_part_count));
 	memtx_tx_story_gc();
 /*********MVCC TRANSACTION MANAGER STORY GARBAGE COLLECTION BOUND END**********/
 	return res == NULL || !eq_match || *ret != NULL ? 0 :
blob - /dev/null
blob + ede1e88126c8a5f146bcc92b480bba30b2bd5360 (mode 644)
--- /dev/null
+++ test/box-luatest/gh_10448_pagination_mvcc_non_unique_index_test.lua
@@ -0,0 +1,134 @@
+local server = require('luatest.server')
+local t = require('luatest')
+
+local g = t.group()
+
+g.before_all(function(cg)
+    cg.server = server:new({box_cfg = {memtx_use_mvcc_engine = true}})
+    cg.server:start()
+end)
+
+g.after_all(function(cg)
+    cg.server:drop()
+end)
+
+g.after_each(function(cg)
+    cg.server:exec(function()
+        if box.space.test ~= nil then
+            box.space.test:drop()
+        end
+    end)
+    box.internal.memtx_tx_gc(1000)
+end)
+
+-- Reproducer from gh-10448
+g.test_non_unique_index_crash = function(cg)
+    cg.server:exec(function()
+        local space = box.schema.space.create("test", {
+            format = {
+                {is_nullable = false, name = "ID", type = "unsigned"},
+                {is_nullable = true, name = "DATA", type = "map"},
+            }
+        })
+        space:create_index("ID", {
+            unique = true,
+            type = "TREE",
+            parts = {
+                {field = "ID", type = "unsigned"},
+            },
+        })
+        space:create_index("KEY", {
+            unique = false,
+            type = "TREE",
+            parts = {
+                {field = "DATA", type = "unsigned", path = "key",
+                 is_nullable = false},
+            },
+        })
+
+        box.begin({txn_isolation = "read-committed"})
+        local key = {key = 0}
+        local left_id = 1
+        local left = space:insert({left_id, key})
+        local right_id = 3
+        local right = space:insert({right_id, key})
+
+        t.assert_equals(
+            space.index.KEY:select({}, {iterator = "EQ", after = left})[1],
+            right)
+        t.assert_equals(
+            space.index.KEY:select({}, {iterator = "LE", after = right})[1],
+            left)
+
+        space:insert({left_id - 1, key})
+        space:insert({right_id + 1, key})
+        -- This insert produced crash
+        space:insert({left_id + 1, key})
+        box.commit()
+    end)
+end
+
+-- The test checks if conflicts are collected correctly when pagination over
+-- non-unique index is used and that tuples from previously iterated pages
+-- are not added to readset.
+g.test_non_unique_index_conflicts = function(cg)
+    cg.server:exec(function()
+        local txn_proxy = require('test.box.lua.txn_proxy')
+        local tx1 = txn_proxy.new()
+        local tx2 = txn_proxy.new()
+        local space = box.schema.space.create("test", {
+            format = {
+                {is_nullable = false, name = "k", type = "unsigned"},
+                {is_nullable = false, name = "v", type = "unsigned"},
+            }
+        })
+        space:create_index("k", {
+            unique = true,
+            type = "TREE",
+            parts = {
+                {field = "k", type = "unsigned"},
+            },
+        })
+        space:create_index("v", {
+            unique = false,
+            type = "TREE",
+            parts = {
+                {field = "v", type = "unsigned", is_nullable = false},
+            },
+        })
+
+        space:insert({1, 1})
+        space:insert({5, 5})
+        space:insert({10, 10})
+
+        -- The function inserts the given tuple concurrently with pagination
+        -- and returns error if the pagination transaction has conflicted
+        -- with the insertion
+        local function check_case(after_str, tuple_str)
+            local expr = 'return box.space.test.index.v:select(nil, ' ..
+                '{iterator = "GE", after = ' .. after_str .. '})'
+            tx1:begin()
+            tx2:begin()
+            local tuples = tx1(expr)[1]
+            t.assert_equals(tuples, {{10, 10}})
+            tx2('box.space.test:insert' .. tuple_str)
+            t.assert_equals(tx2:commit(), '')
+            -- Process a write so that pagination transaction is not read-only
+            -- and can actually conflict with another one
+            tx1('box.space.test:replace{0, 0}')
+            local err = tx1:commit()
+            return type(err) == 'table' and err[1].error or nil
+        end
+
+        t.assert_equals(check_case('{5, 5}', '{3, 3}'), nil,
+            "Insertion outside read page must not conflict")
+
+        t.assert_equals(check_case('{5, 5}', '{6, 6}'),
+            "Transaction has been aborted by conflict",
+            "Insertion within read page must conflict")
+
+        t.assert_equals(check_case('{8, 8}', '{7, 7}'), nil,
+            "Insertion outside read page must not conflict even if pages are" ..
+            "not delimited by existing tuple")
+    end)
+end