This is an automated email from the ASF dual-hosted git repository.

ibessonov pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/ignite-3.git


The following commit(s) were added to refs/heads/main by this push:
     new 22e2df000b IGNITE-19394 "peek" contract improved to avoid missing data 
on RW scans. (#2021)
22e2df000b is described below

commit 22e2df000b1e34c9594dd957fffb74879f3386ef
Author: Ivan Bessonov <[email protected]>
AuthorDate: Fri May 5 14:59:33 2023 +0300

    IGNITE-19394 "peek" contract improved to avoid missing data on RW scans. 
(#2021)
---
 .../ignite/internal/storage/index/PeekCursor.java  |  18 +-
 .../index/AbstractHashIndexStorageTest.java        | 229 ++----------
 .../storage/index/AbstractIndexStorageTest.java    | 383 +++++++++++++++++++++
 .../index/AbstractSortedIndexStorageTest.java      | 370 +++++---------------
 .../index/impl/BinaryTupleRowSerializer.java       |  16 +-
 .../storage/index/impl/TestHashIndexStorage.java   |  89 +++--
 .../storage/index/impl/TestSortedIndexStorage.java | 181 ++++------
 .../index/sorted/PageMemorySortedIndexStorage.java |  76 ++--
 .../AbstractPageMemoryHashIndexStorageTest.java    |  39 ++-
 .../rocksdb/index/RocksDbSortedIndexStorage.java   |  55 ++-
 10 files changed, 718 insertions(+), 738 deletions(-)

diff --git 
a/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/index/PeekCursor.java
 
b/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/index/PeekCursor.java
index 2ef036f690..088d69d59d 100644
--- 
a/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/index/PeekCursor.java
+++ 
b/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/index/PeekCursor.java
@@ -27,7 +27,21 @@ public interface PeekCursor<T> extends Cursor<T> {
     /**
      * Returns the next element without advancing the cursor, {@code null} if 
there is no next element.
      *
-     * <p>Usage notes:
+     * <p>The behavior of this method is tightly coupled with the behaviour of 
{@link #next()} and {@link #hasNext()}:
+     * <ul>
+     *     <li>
+     *         {@code peek()} returns exactly the same value as the {@link 
#next()} would, if called instead or right after.
+     *     </li>
+     *     <li>
+     *         This property can be applied to the end of the cursor. {@code 
peek()} returns {@code null} if {@link #next()} would
+     *         throw an {@link java.util.NoSuchElementException}, if it was 
called instead or right after.
+     *     </li>
+     *     <li>
+     *         {@link #next()}, called immediately after {@code peek()}, must 
thus return the same value, or throw an exception if the
+     *         value was {@code null}.
+     *     </li>
+     * </ul>
+     * These properties, in conjunction with basic iterator invariants, give 
strict constraints on {@link #hasNext()} usage:
      * <ul>
      *     <li>After the cursor is created, {@code #peek()} will return the 
actual (up-to-date) next element;</li>
      *     <li>After calling {@link #hasNext()}, if it returned {@code true}, 
then {@code peek()} will return the element (cached) that
@@ -36,6 +50,8 @@ public interface PeekCursor<T> extends Cursor<T> {
      *     <li>After {@link #next()} is called, but before {@link #hasNext()} 
is called, {@code peek()} will always return the actual
      *     (up-to-date) next element without advancing the cursor.</li>
      * </ul>
+     * In other words, {@link #hasNext()} call forces the cursor to return 
cached value from {@code peek()}, instead of looking for
+     * up-to-date value in the storage.
      */
     @Nullable T peek();
 }
diff --git 
a/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/AbstractHashIndexStorageTest.java
 
b/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/AbstractHashIndexStorageTest.java
index efde8f475a..8fc9e4c0c8 100644
--- 
a/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/AbstractHashIndexStorageTest.java
+++ 
b/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/AbstractHashIndexStorageTest.java
@@ -17,227 +17,77 @@
 
 package org.apache.ignite.internal.storage.index;
 
-import static java.util.stream.Collectors.toList;
 import static 
org.apache.ignite.internal.schema.testutils.SchemaConfigurationConverter.addIndex;
-import static 
org.apache.ignite.internal.schema.testutils.SchemaConfigurationConverter.convert;
-import static 
org.apache.ignite.internal.schema.testutils.builder.SchemaBuilders.column;
-import static 
org.apache.ignite.internal.schema.testutils.builder.SchemaBuilders.tableBuilder;
-import static 
org.apache.ignite.internal.storage.BaseMvStoragesTest.getOrCreateMvPartition;
 import static 
org.apache.ignite.internal.testframework.matchers.CompletableFutureMatcher.willCompleteSuccessfully;
 import static org.hamcrest.CoreMatchers.is;
 import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.Matchers.contains;
-import static org.hamcrest.Matchers.containsInAnyOrder;
 import static org.hamcrest.Matchers.empty;
-import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 
-import java.util.Collection;
 import java.util.UUID;
 import java.util.concurrent.CompletableFuture;
+import java.util.stream.Stream;
 import org.apache.ignite.internal.configuration.util.ConfigurationUtil;
-import org.apache.ignite.internal.schema.configuration.TableConfiguration;
-import org.apache.ignite.internal.schema.configuration.TablesConfiguration;
 import org.apache.ignite.internal.schema.configuration.index.TableIndexView;
 import org.apache.ignite.internal.schema.testutils.builder.SchemaBuilders;
-import org.apache.ignite.internal.schema.testutils.definition.ColumnDefinition;
 import org.apache.ignite.internal.schema.testutils.definition.ColumnType;
-import org.apache.ignite.internal.schema.testutils.definition.TableDefinition;
 import 
org.apache.ignite.internal.schema.testutils.definition.index.HashIndexDefinition;
-import org.apache.ignite.internal.storage.MvPartitionStorage;
 import org.apache.ignite.internal.storage.RowId;
-import org.apache.ignite.internal.storage.engine.MvTableStorage;
 import org.apache.ignite.internal.storage.index.impl.BinaryTupleRowSerializer;
-import org.apache.ignite.internal.util.Cursor;
 import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 
 /**
  * Base class for Hash Index storage tests.
  */
-public abstract class AbstractHashIndexStorageTest {
-    protected static final int TEST_PARTITION = 0;
-
-    private static final String INT_COLUMN_NAME = "intVal";
-
-    private static final String STR_COLUMN_NAME = "strVal";
-
-    private MvTableStorage tableStorage;
-
-    private MvPartitionStorage partitionStorage;
-
-    private HashIndexStorage indexStorage;
-
-    private BinaryTupleRowSerializer serializer;
-
-    /**
-     * Initializes the internal structures needed for tests.
-     *
-     * <p>This method *MUST* always be called in either subclass' constructor 
or setUp method.
-     */
-    protected final void initialize(MvTableStorage tableStorage, 
TablesConfiguration tablesCfg) {
-        this.tableStorage = tableStorage;
-
-        createTestTable(tableStorage.configuration());
-
-        this.partitionStorage = getOrCreateMvPartition(tableStorage, 
TEST_PARTITION);
-        this.indexStorage = createIndex(tableStorage, tablesCfg);
-        this.serializer = new 
BinaryTupleRowSerializer(indexStorage.indexDescriptor());
-    }
-
-    /**
-     * Configures a test table with some indexed columns.
-     */
-    private static void createTestTable(TableConfiguration tableCfg) {
-        ColumnDefinition pkColumn = column("pk", 
ColumnType.INT32).asNullable(false).build();
-
-        ColumnDefinition[] allColumns = {
-                pkColumn,
-                column(INT_COLUMN_NAME, 
ColumnType.INT32).asNullable(true).build(),
-                column(STR_COLUMN_NAME, 
ColumnType.string()).asNullable(true).build()
-        };
-
-        TableDefinition tableDefinition = tableBuilder("schema", "table")
-                .columns(allColumns)
-                .withPrimaryKey(pkColumn.name())
+public abstract class AbstractHashIndexStorageTest extends 
AbstractIndexStorageTest<HashIndexStorage, HashIndexDescriptor> {
+    @Override
+    protected HashIndexStorage createIndexStorage(String name, ColumnType... 
columnTypes) {
+        HashIndexDefinition indexDefinition = SchemaBuilders.hashIndex(name)
+                
.withColumns(Stream.of(columnTypes).map(AbstractIndexStorageTest::columnName).toArray(String[]::new))
                 .build();
 
-        CompletableFuture<Void> createTableFuture = tableCfg.change(cfg -> 
convert(tableDefinition, cfg));
-
-        assertThat(createTableFuture, willCompleteSuccessfully());
-    }
-
-    /**
-     * Configures and creates a storage instance for testing.
-     */
-    private static HashIndexStorage createIndex(MvTableStorage tableStorage, 
TablesConfiguration tablesConf) {
-        HashIndexDefinition indexDefinition = 
SchemaBuilders.hashIndex("hashIndex")
-                .withColumns(INT_COLUMN_NAME, STR_COLUMN_NAME)
-                .build();
-
-        CompletableFuture<Void> createIndexFuture = tablesConf.indexes()
+        CompletableFuture<Void> createIndexFuture = tablesCfg.indexes()
                 .change(chg -> chg.create(indexDefinition.name(), idx -> {
-                    UUID tableId = 
ConfigurationUtil.internalId(tablesConf.tables().value(), "foo");
+                    UUID tableId = 
ConfigurationUtil.internalId(tablesCfg.tables().value(), TABLE_NAME);
 
                     addIndex(indexDefinition, tableId, idx);
                 }));
 
         assertThat(createIndexFuture, willCompleteSuccessfully());
 
-        TableIndexView indexConfig = 
tablesConf.indexes().get(indexDefinition.name()).value();
+        TableIndexView indexConfig = 
tablesCfg.indexes().get(indexDefinition.name()).value();
 
         return tableStorage.getOrCreateHashIndex(TEST_PARTITION, 
indexConfig.id());
     }
 
-    /**
-     * Tests the {@link HashIndexStorage#get} method.
-     */
-    @Test
-    public void testGet() {
-        // First two rows have the same index key, but different row IDs
-        IndexRow row1 = serializer.serializeRow(new Object[]{ 1, "foo" }, new 
RowId(TEST_PARTITION));
-        IndexRow row2 = serializer.serializeRow(new Object[]{ 1, "foo" }, new 
RowId(TEST_PARTITION));
-        IndexRow row3 = serializer.serializeRow(new Object[]{ 2, "bar" }, new 
RowId(TEST_PARTITION));
-
-        assertThat(getAll(row1), is(empty()));
-        assertThat(getAll(row2), is(empty()));
-        assertThat(getAll(row3), is(empty()));
-
-        put(row1);
-        put(row2);
-        put(row3);
-
-        assertThat(getAll(row1), containsInAnyOrder(row1.rowId(), 
row2.rowId()));
-        assertThat(getAll(row2), containsInAnyOrder(row1.rowId(), 
row2.rowId()));
-        assertThat(getAll(row3), contains(row3.rowId()));
-    }
-
-    /**
-     * Tests that {@link HashIndexStorage#put} does not create row ID 
duplicates.
-     */
-    @Test
-    public void testPutIdempotence() {
-        IndexRow row = serializer.serializeRow(new Object[]{ 1, "foo" }, new 
RowId(TEST_PARTITION));
-
-        put(row);
-        put(row);
-
-        assertThat(getAll(row), contains(row.rowId()));
-    }
-
-    /**
-     * Tests the {@link HashIndexStorage#remove} method.
-     */
-    @Test
-    public void testRemove() {
-        IndexRow row1 = serializer.serializeRow(new Object[]{ 1, "foo" }, new 
RowId(TEST_PARTITION));
-        IndexRow row2 = serializer.serializeRow(new Object[]{ 1, "foo" }, new 
RowId(TEST_PARTITION));
-        IndexRow row3 = serializer.serializeRow(new Object[]{ 2, "bar" }, new 
RowId(TEST_PARTITION));
-
-        put(row1);
-        put(row2);
-        put(row3);
-
-        assertThat(getAll(row1), containsInAnyOrder(row1.rowId(), 
row2.rowId()));
-        assertThat(getAll(row2), containsInAnyOrder(row1.rowId(), 
row2.rowId()));
-        assertThat(getAll(row3), contains(row3.rowId()));
-
-        remove(row1);
-
-        assertThat(getAll(row1), contains(row2.rowId()));
-        assertThat(getAll(row2), contains(row2.rowId()));
-        assertThat(getAll(row3), contains(row3.rowId()));
-
-        remove(row2);
-
-        assertThat(getAll(row1), is(empty()));
-        assertThat(getAll(row2), is(empty()));
-        assertThat(getAll(row3), contains(row3.rowId()));
-
-        remove(row3);
-
-        assertThat(getAll(row1), is(empty()));
-        assertThat(getAll(row2), is(empty()));
-        assertThat(getAll(row3), is(empty()));
-    }
-
-    /**
-     * Tests that {@link HashIndexStorage#remove} works normally when removing 
a non-existent row.
-     */
-    @Test
-    public void testRemoveIdempotence() {
-        IndexRow row = serializer.serializeRow(new Object[]{ 1, "foo" }, new 
RowId(TEST_PARTITION));
-
-        assertDoesNotThrow(() -> remove(row));
-
-        put(row);
-
-        remove(row);
-
-        assertThat(getAll(row), is(empty()));
-
-        assertDoesNotThrow(() -> remove(row));
+    @Override
+    protected HashIndexDescriptor indexDescriptor(HashIndexStorage index) {
+        return index.indexDescriptor();
     }
 
     @Disabled("https://issues.apache.org/jira/browse/IGNITE-17626";)
     @Test
     public void testDestroy() {
+        HashIndexStorage index = createIndexStorage(INDEX_NAME, 
ColumnType.INT32, ColumnType.string());
+        var serializer = new BinaryTupleRowSerializer(indexDescriptor(index));
+
         IndexRow row1 = serializer.serializeRow(new Object[]{ 1, "foo" }, new 
RowId(TEST_PARTITION));
         IndexRow row2 = serializer.serializeRow(new Object[]{ 1, "foo" }, new 
RowId(TEST_PARTITION));
         IndexRow row3 = serializer.serializeRow(new Object[]{ 2, "bar" }, new 
RowId(TEST_PARTITION));
 
-        put(row1);
-        put(row2);
-        put(row3);
+        put(index, row1);
+        put(index, row2);
+        put(index, row3);
 
-        CompletableFuture<Void> destroyFuture = 
tableStorage.destroyIndex(indexStorage.indexDescriptor().id());
+        CompletableFuture<Void> destroyFuture = 
tableStorage.destroyIndex(index.indexDescriptor().id());
 
         waitForDurableCompletion(destroyFuture);
 
         //TODO IGNITE-17626 Index must be invalid, we should assert that 
getIndex returns null and that in won't surface upon restart.
         // "destroy" is not "clear", you know. Maybe "getAndCreateIndex" will 
do it for the test, idk
-        assertThat(getAll(row1), is(empty()));
-        assertThat(getAll(row2), is(empty()));
-        assertThat(getAll(row3), is(empty()));
+        assertThat(getAll(index, row1), is(empty()));
+        assertThat(getAll(index, row2), is(empty()));
+        assertThat(getAll(index, row3), is(empty()));
     }
 
     private void waitForDurableCompletion(CompletableFuture<?> future) {
@@ -250,38 +100,7 @@ public abstract class AbstractHashIndexStorageTest {
         }
     }
 
-    protected Collection<RowId> getAll(IndexRow row) {
-        try (Cursor<RowId> cursor = indexStorage.get(row.indexColumns())) {
-            return cursor.stream().collect(toList());
-        } catch (Exception e) {
-            throw new RuntimeException(e);
-        }
-    }
-
-    protected void put(IndexRow row) {
-        partitionStorage.runConsistently(locker -> {
-            indexStorage.put(row);
-
-            return null;
-        });
-    }
-
-    private void remove(IndexRow row) {
-        partitionStorage.runConsistently(locker -> {
-            indexStorage.remove(row);
-
-            return null;
-        });
-    }
-
-    /**
-     * Creates an index row.
-     *
-     * @param intVal Integer column.
-     * @param strVal String column.
-     * @param rowId Row ID.
-     */
-    protected IndexRow createIndexRow(int intVal, String strVal, RowId rowId) {
-        return serializer.serializeRow(new Object[]{ intVal, strVal }, rowId);
+    protected IndexRow createIndexRow(BinaryTupleRowSerializer serializer, 
RowId rowId, Object... values) {
+        return serializer.serializeRow(values, rowId);
     }
 }
diff --git 
a/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/AbstractIndexStorageTest.java
 
b/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/AbstractIndexStorageTest.java
new file mode 100644
index 0000000000..6f6090f8e4
--- /dev/null
+++ 
b/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/AbstractIndexStorageTest.java
@@ -0,0 +1,383 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.storage.index;
+
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toUnmodifiableList;
+import static 
org.apache.ignite.internal.schema.testutils.SchemaConfigurationConverter.convert;
+import static 
org.apache.ignite.internal.schema.testutils.builder.SchemaBuilders.column;
+import static 
org.apache.ignite.internal.schema.testutils.builder.SchemaBuilders.tableBuilder;
+import static 
org.apache.ignite.internal.storage.BaseMvStoragesTest.getOrCreateMvPartition;
+import static 
org.apache.ignite.internal.testframework.matchers.CompletableFutureMatcher.willCompleteSuccessfully;
+import static org.hamcrest.CoreMatchers.equalTo;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.anyOf;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.hasSize;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Random;
+import java.util.concurrent.CompletableFuture;
+import java.util.stream.Stream;
+import org.apache.ignite.internal.logger.IgniteLogger;
+import org.apache.ignite.internal.logger.Loggers;
+import org.apache.ignite.internal.schema.BinaryTuple;
+import org.apache.ignite.internal.schema.configuration.TableConfiguration;
+import org.apache.ignite.internal.schema.configuration.TablesConfiguration;
+import org.apache.ignite.internal.schema.testutils.definition.ColumnDefinition;
+import org.apache.ignite.internal.schema.testutils.definition.ColumnType;
+import org.apache.ignite.internal.schema.testutils.definition.TableDefinition;
+import org.apache.ignite.internal.storage.MvPartitionStorage;
+import org.apache.ignite.internal.storage.RowId;
+import org.apache.ignite.internal.storage.engine.MvTableStorage;
+import org.apache.ignite.internal.storage.index.impl.BinaryTupleRowSerializer;
+import org.apache.ignite.internal.util.Cursor;
+import org.jetbrains.annotations.Nullable;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Base class for index storage tests. Covers common methods, such as {@link 
IndexStorage#get(BinaryTuple)} or
+ * {@link IndexStorage#put(IndexRow)}.
+ *
+ * @param <S> Type of specific index implementation.
+ * @param <D> Type of index descriptor for that specific implementation.
+ */
+public abstract class AbstractIndexStorageTest<S extends IndexStorage, D 
extends IndexDescriptor> {
+    private static final IgniteLogger log = 
Loggers.forClass(AbstractIndexStorageTest.class);
+
+    /** Definitions of all supported column types. */
+    @SuppressWarnings("WeakerAccess") // May be used in "@VariableSource", 
that's why it's public.
+    public static final List<ColumnDefinition> ALL_TYPES_COLUMN_DEFINITIONS = 
allTypesColumnDefinitions();
+
+    protected static final int TEST_PARTITION = 0;
+
+    protected static final String INDEX_NAME = "TEST_IDX";
+
+    // Short name is convenient for initialization in @InjectConfiguration.
+    protected static final String TABLE_NAME = "foo";
+
+    private static List<ColumnDefinition> allTypesColumnDefinitions() {
+        Stream<ColumnType> allColumnTypes = Stream.of(
+                ColumnType.INT8,
+                ColumnType.INT16,
+                ColumnType.INT32,
+                ColumnType.INT64,
+                ColumnType.FLOAT,
+                ColumnType.DOUBLE,
+                ColumnType.UUID,
+                ColumnType.DATE,
+                ColumnType.bitmaskOf(32),
+                ColumnType.string(),
+                ColumnType.blob(),
+                ColumnType.number(),
+                ColumnType.decimal(),
+                ColumnType.time(),
+                ColumnType.datetime(),
+                ColumnType.timestamp()
+        );
+
+        return allColumnTypes
+                .map(type -> column(columnName(type), 
type).asNullable(true).build())
+                .collect(toUnmodifiableList());
+    }
+
+    private final long seed = System.currentTimeMillis();
+
+    protected final Random random = new Random(seed);
+
+    protected MvTableStorage tableStorage;
+
+    protected MvPartitionStorage partitionStorage;
+
+    protected TablesConfiguration tablesCfg;
+
+    @BeforeEach
+    void setUp() {
+        log.info("Using random seed: " + seed);
+    }
+
+    /**
+     * Returns a name of the column with given type.
+     */
+    protected static String columnName(ColumnType type) {
+        return type.typeSpec().name();
+    }
+
+    /**
+     * Initializes the internal structures needed for tests.
+     *
+     * <p>This method *MUST* always be called in either subclass' constructor 
or setUp method.
+     */
+    protected final void initialize(MvTableStorage tableStorage, 
TablesConfiguration tablesCfg) {
+        this.tablesCfg = tablesCfg;
+        this.tableStorage = tableStorage;
+        this.partitionStorage = getOrCreateMvPartition(tableStorage, 
TEST_PARTITION);
+
+        createTestTable(tableStorage.configuration());
+    }
+
+    /**
+     * Configures a test table with columns of all supported types.
+     */
+    private static void createTestTable(TableConfiguration tableCfg) {
+        ColumnDefinition pkColumn = column("pk", 
ColumnType.INT32).asNullable(false).build();
+
+        ColumnDefinition[] allColumns = Stream.concat(Stream.of(pkColumn), 
ALL_TYPES_COLUMN_DEFINITIONS.stream())
+                .toArray(ColumnDefinition[]::new);
+
+        TableDefinition tableDefinition = tableBuilder("test", TABLE_NAME)
+                .columns(allColumns)
+                .withPrimaryKey(pkColumn.name())
+                .build();
+
+        CompletableFuture<Void> createTableFuture = tableCfg.change(cfg -> 
convert(tableDefinition, cfg));
+
+        assertThat(createTableFuture, willCompleteSuccessfully());
+    }
+
+    /**
+     * Creates an IndexStorage instance using the given columns.
+     *
+     * @see #columnName(ColumnType)
+     */
+    protected abstract S createIndexStorage(String name, ColumnType... 
columnTypes);
+
+    /**
+     * Provides safe access to the index descriptor of the storage.
+     */
+    protected abstract D indexDescriptor(S index);
+
+    /**
+     * Tests the {@link IndexStorage#get} method.
+     */
+    @Test
+    public void testGet() {
+        S index = createIndexStorage(INDEX_NAME, ColumnType.INT32, 
ColumnType.string());
+        var serializer = new BinaryTupleRowSerializer(indexDescriptor(index));
+
+        // First two rows have the same index key, but different row IDs.
+        IndexRow row1 = serializer.serializeRow(new Object[]{ 1, "foo" }, new 
RowId(TEST_PARTITION));
+        IndexRow row2 = serializer.serializeRow(new Object[]{ 1, "foo" }, new 
RowId(TEST_PARTITION));
+        IndexRow row3 = serializer.serializeRow(new Object[]{ 2, "bar" }, new 
RowId(TEST_PARTITION));
+        IndexRow row4 = serializer.serializeRow(new Object[]{ 3, "baz" }, new 
RowId(TEST_PARTITION));
+
+        assertThat(getAll(index, row1), is(empty()));
+        assertThat(getAll(index, row2), is(empty()));
+        assertThat(getAll(index, row3), is(empty()));
+
+        put(index, row1);
+        put(index, row2);
+        put(index, row3);
+
+        assertThat(getAll(index, row1), containsInAnyOrder(row1.rowId(), 
row2.rowId()));
+        assertThat(getAll(index, row2), containsInAnyOrder(row1.rowId(), 
row2.rowId()));
+        assertThat(getAll(index, row3), contains(row3.rowId()));
+        assertThat(getAll(index, row4), is(empty()));
+    }
+
+    @Test
+    @Disabled("https://issues.apache.org/jira/browse/IGNITE-19422";)
+    public void testGetConcurrentPut() {
+        S index = createIndexStorage(INDEX_NAME, ColumnType.INT32, 
ColumnType.string());
+        var serializer = new BinaryTupleRowSerializer(indexDescriptor(index));
+
+        Object[] columnValues = { 1, "foo" };
+        IndexRow row1 = serializer.serializeRow(columnValues, new 
RowId(TEST_PARTITION, 1, 1));
+        IndexRow row2 = serializer.serializeRow(columnValues, new 
RowId(TEST_PARTITION, 2, 2));
+
+        try (Cursor<RowId> cursor = index.get(row1.indexColumns())) {
+            put(index, row1);
+
+            assertTrue(cursor.hasNext());
+            assertEquals(row1.rowId(), cursor.next());
+
+            put(index, row2);
+
+            assertTrue(cursor.hasNext());
+            assertEquals(row2.rowId(), cursor.next());
+
+            assertFalse(cursor.hasNext());
+            assertThrows(NoSuchElementException.class, cursor::next);
+        }
+    }
+
+    @Test
+    @Disabled("https://issues.apache.org/jira/browse/IGNITE-19422";)
+    public void testGetConcurrentReplace() {
+        S index = createIndexStorage(INDEX_NAME, ColumnType.INT32, 
ColumnType.string());
+        var serializer = new BinaryTupleRowSerializer(indexDescriptor(index));
+
+        Object[] columnValues = { 1, "foo" };
+        IndexRow row1 = serializer.serializeRow(columnValues, new 
RowId(TEST_PARTITION, 1, 1));
+        IndexRow row2 = serializer.serializeRow(columnValues, new 
RowId(TEST_PARTITION, 2, 2));
+
+        put(index, row1);
+
+        try (Cursor<RowId> cursor = index.get(row1.indexColumns())) {
+            assertTrue(cursor.hasNext());
+            assertEquals(row1.rowId(), cursor.next());
+
+            remove(index, row1);
+            put(index, row2);
+
+            assertTrue(cursor.hasNext());
+            assertEquals(row2.rowId(), cursor.next());
+
+            assertFalse(cursor.hasNext());
+            assertThrows(NoSuchElementException.class, cursor::next);
+        }
+    }
+
+    /**
+     * Tests that {@link IndexStorage#put} does not create row ID duplicates.
+     */
+    @Test
+    public void testPutIdempotence() {
+        S index = createIndexStorage(INDEX_NAME, ColumnType.INT32, 
ColumnType.string());
+        var serializer = new BinaryTupleRowSerializer(indexDescriptor(index));
+
+        IndexRow row = serializer.serializeRow(new Object[]{ 1, "foo" }, new 
RowId(TEST_PARTITION));
+
+        put(index, row);
+        put(index, row);
+
+        IndexRow actualRow = getSingle(index, row.indexColumns());
+
+        assertNotNull(actualRow);
+
+        assertThat(actualRow.rowId(), is(equalTo(row.rowId())));
+
+        assertThat(getAll(index, row), contains(row.rowId()));
+    }
+
+    /**
+     * Tests the {@link IndexStorage#remove} method.
+     */
+    @Test
+    public void testRemove() {
+        S index = createIndexStorage(INDEX_NAME, ColumnType.INT32, 
ColumnType.string());
+        var serializer = new BinaryTupleRowSerializer(indexDescriptor(index));
+
+        IndexRow row1 = serializer.serializeRow(new Object[]{ 1, "foo" }, new 
RowId(TEST_PARTITION));
+        IndexRow row2 = serializer.serializeRow(new Object[]{ 1, "foo" }, new 
RowId(TEST_PARTITION));
+        IndexRow row3 = serializer.serializeRow(new Object[]{ 2, "bar" }, new 
RowId(TEST_PARTITION));
+
+        put(index, row1);
+        put(index, row2);
+        put(index, row3);
+
+        assertThat(getAll(index, row1), containsInAnyOrder(row1.rowId(), 
row2.rowId()));
+        assertThat(getAll(index, row2), containsInAnyOrder(row1.rowId(), 
row2.rowId()));
+        assertThat(getAll(index, row3), contains(row3.rowId()));
+
+        remove(index, row1);
+
+        assertThat(getAll(index, row1), contains(row2.rowId()));
+        assertThat(getAll(index, row2), contains(row2.rowId()));
+        assertThat(getAll(index, row3), contains(row3.rowId()));
+
+        remove(index, row2);
+
+        assertThat(getAll(index, row1), is(empty()));
+        assertThat(getAll(index, row2), is(empty()));
+        assertThat(getAll(index, row3), contains(row3.rowId()));
+
+        remove(index, row3);
+
+        assertThat(getAll(index, row1), is(empty()));
+        assertThat(getAll(index, row2), is(empty()));
+        assertThat(getAll(index, row3), is(empty()));
+    }
+
+    /**
+     * Tests that {@link IndexStorage#remove} works normally when removing a 
non-existent row.
+     */
+    @Test
+    public void testRemoveIdempotence() {
+        S index = createIndexStorage(INDEX_NAME, ColumnType.INT32, 
ColumnType.string());
+        var serializer = new BinaryTupleRowSerializer(indexDescriptor(index));
+
+        IndexRow row = serializer.serializeRow(new Object[]{ 1, "foo" }, new 
RowId(TEST_PARTITION));
+
+        assertDoesNotThrow(() -> remove(index, row));
+
+        put(index, row);
+
+        remove(index, row);
+
+        assertThat(getAll(index, row), is(empty()));
+
+        assertDoesNotThrow(() -> remove(index, row));
+    }
+
+    protected static Collection<RowId> getAll(IndexStorage index, IndexRow 
row) {
+        try (Cursor<RowId> cursor = index.get(row.indexColumns())) {
+            return cursor.stream().collect(toList());
+        }
+    }
+
+    /**
+     * Extracts a single value by a given key or {@code null} if it does not 
exist.
+     */
+    protected static @Nullable IndexRow getSingle(IndexStorage indexStorage, 
BinaryTuple fullPrefix) {
+        List<RowId> rowIds = get(indexStorage, fullPrefix);
+
+        assertThat(rowIds, anyOf(empty(), hasSize(1)));
+
+        return rowIds.isEmpty() ? null : new IndexRowImpl(fullPrefix, 
rowIds.get(0));
+    }
+
+    protected static List<RowId> get(IndexStorage index, BinaryTuple key) {
+        try (Cursor<RowId> cursor = index.get(key)) {
+            return cursor.stream().collect(toUnmodifiableList());
+        }
+    }
+
+    protected final void put(S indexStorage, IndexRow row) {
+        partitionStorage.runConsistently(locker -> {
+            locker.lock(row.rowId());
+
+            indexStorage.put(row);
+
+            return null;
+        });
+    }
+
+    protected final void remove(S indexStorage, IndexRow row) {
+        partitionStorage.runConsistently(locker -> {
+            locker.lock(row.rowId());
+
+            indexStorage.remove(row);
+
+            return null;
+        });
+    }
+}
diff --git 
a/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/AbstractSortedIndexStorageTest.java
 
b/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/AbstractSortedIndexStorageTest.java
index 5a61474087..6ef872af03 100644
--- 
a/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/AbstractSortedIndexStorageTest.java
+++ 
b/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/AbstractSortedIndexStorageTest.java
@@ -21,10 +21,6 @@ import static java.util.function.Function.identity;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toUnmodifiableList;
 import static 
org.apache.ignite.internal.schema.testutils.SchemaConfigurationConverter.addIndex;
-import static 
org.apache.ignite.internal.schema.testutils.SchemaConfigurationConverter.convert;
-import static 
org.apache.ignite.internal.schema.testutils.builder.SchemaBuilders.column;
-import static 
org.apache.ignite.internal.schema.testutils.builder.SchemaBuilders.tableBuilder;
-import static 
org.apache.ignite.internal.storage.BaseMvStoragesTest.getOrCreateMvPartition;
 import static 
org.apache.ignite.internal.storage.index.SortedIndexStorage.GREATER;
 import static 
org.apache.ignite.internal.storage.index.SortedIndexStorage.GREATER_OR_EQUAL;
 import static org.apache.ignite.internal.storage.index.SortedIndexStorage.LESS;
@@ -34,9 +30,7 @@ import static org.hamcrest.CoreMatchers.equalTo;
 import static org.hamcrest.CoreMatchers.is;
 import static org.hamcrest.CoreMatchers.nullValue;
 import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.Matchers.anyOf;
 import static org.hamcrest.Matchers.contains;
-import static org.hamcrest.Matchers.containsInAnyOrder;
 import static org.hamcrest.Matchers.empty;
 import static org.hamcrest.Matchers.hasSize;
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -51,21 +45,16 @@ import java.util.Collections;
 import java.util.List;
 import java.util.NoSuchElementException;
 import java.util.Objects;
-import java.util.Random;
 import java.util.UUID;
 import java.util.concurrent.CompletableFuture;
 import java.util.function.Function;
 import java.util.function.Predicate;
 import java.util.stream.IntStream;
-import java.util.stream.Stream;
 import org.apache.ignite.internal.configuration.util.ConfigurationUtil;
 import org.apache.ignite.internal.logger.IgniteLogger;
 import org.apache.ignite.internal.logger.Loggers;
-import org.apache.ignite.internal.schema.BinaryTuple;
 import org.apache.ignite.internal.schema.BinaryTuplePrefix;
 import org.apache.ignite.internal.schema.SchemaTestUtils;
-import org.apache.ignite.internal.schema.configuration.TableConfiguration;
-import org.apache.ignite.internal.schema.configuration.TablesConfiguration;
 import org.apache.ignite.internal.schema.configuration.index.TableIndexView;
 import org.apache.ignite.internal.schema.testutils.builder.SchemaBuilders;
 import 
org.apache.ignite.internal.schema.testutils.builder.SortedIndexDefinitionBuilder;
@@ -73,12 +62,9 @@ import 
org.apache.ignite.internal.schema.testutils.builder.SortedIndexDefinition
 import org.apache.ignite.internal.schema.testutils.definition.ColumnDefinition;
 import org.apache.ignite.internal.schema.testutils.definition.ColumnType;
 import 
org.apache.ignite.internal.schema.testutils.definition.ColumnType.ColumnTypeSpec;
-import org.apache.ignite.internal.schema.testutils.definition.TableDefinition;
 import 
org.apache.ignite.internal.schema.testutils.definition.index.ColumnarIndexDefinition;
 import 
org.apache.ignite.internal.schema.testutils.definition.index.SortedIndexDefinition;
-import org.apache.ignite.internal.storage.MvPartitionStorage;
 import org.apache.ignite.internal.storage.RowId;
-import org.apache.ignite.internal.storage.engine.MvTableStorage;
 import 
org.apache.ignite.internal.storage.index.SortedIndexDescriptor.SortedIndexColumnDescriptor;
 import org.apache.ignite.internal.storage.index.impl.BinaryTupleRowSerializer;
 import org.apache.ignite.internal.storage.index.impl.TestIndexRow;
@@ -93,92 +79,25 @@ import org.junit.jupiter.params.ParameterizedTest;
 /**
  * Base class for Sorted Index storage tests.
  */
-public abstract class AbstractSortedIndexStorageTest {
+public abstract class AbstractSortedIndexStorageTest extends 
AbstractIndexStorageTest<SortedIndexStorage, SortedIndexDescriptor> {
     private static final IgniteLogger log = 
Loggers.forClass(AbstractSortedIndexStorageTest.class);
 
-    /** Definitions of all supported column types. */
-    public static final List<ColumnDefinition> ALL_TYPES_COLUMN_DEFINITIONS = 
allTypesColumnDefinitions();
-
-    protected static final int TEST_PARTITION = 0;
-
-    private static List<ColumnDefinition> allTypesColumnDefinitions() {
-        Stream<ColumnType> allColumnTypes = Stream.of(
-                ColumnType.INT8,
-                ColumnType.INT16,
-                ColumnType.INT32,
-                ColumnType.INT64,
-                ColumnType.FLOAT,
-                ColumnType.DOUBLE,
-                ColumnType.UUID,
-                ColumnType.DATE,
-                ColumnType.bitmaskOf(32),
-                ColumnType.string(),
-                ColumnType.blob(),
-                ColumnType.number(),
-                ColumnType.decimal(),
-                ColumnType.time(),
-                ColumnType.datetime(),
-                ColumnType.timestamp()
-        );
-
-        return allColumnTypes
-                .map(type -> column(type.typeSpec().name(), 
type).asNullable(true).build())
-                .collect(toUnmodifiableList());
-    }
-
-    private final Random random;
-
-    private MvTableStorage tableStorage;
-
-    private MvPartitionStorage partitionStorage;
-
-    private TablesConfiguration tablesCfg;
-
-    protected AbstractSortedIndexStorageTest() {
-        long seed = System.currentTimeMillis();
-
-        log.info("Using random seed: " + seed);
-
-        random = new Random(seed);
-    }
-
-    /**
-     * Initializes the internal structures needed for tests.
-     *
-     * <p>This method *MUST* always be called in either subclass' constructor 
or setUp method.
-     */
-    protected final void initialize(MvTableStorage tableStorage, 
TablesConfiguration tablesCfg) {
-        this.tablesCfg = tablesCfg;
-        this.tableStorage = tableStorage;
-        this.partitionStorage = getOrCreateMvPartition(tableStorage, 
TEST_PARTITION);
-
-        createTestTable(tableStorage.configuration());
-    }
-
-    /**
-     * Configures a test table with columns of all supported types.
-     */
-    private static void createTestTable(TableConfiguration tableCfg) {
-        ColumnDefinition pkColumn = column("pk", 
ColumnType.INT32).asNullable(false).build();
-
-        ColumnDefinition[] allColumns = Stream.concat(Stream.of(pkColumn), 
ALL_TYPES_COLUMN_DEFINITIONS.stream())
-                .toArray(ColumnDefinition[]::new);
+    @Override
+    protected SortedIndexStorage createIndexStorage(String name, ColumnType... 
columnTypes) {
+        SortedIndexDefinitionBuilder builder = 
SchemaBuilders.sortedIndex(name);
 
-        TableDefinition tableDefinition = tableBuilder("test", "foo")
-                .columns(allColumns)
-                .withPrimaryKey(pkColumn.name())
-                .build();
-
-        CompletableFuture<Void> createTableFuture = tableCfg.change(cfg -> 
convert(tableDefinition, cfg));
+        for (ColumnType columnType : columnTypes) {
+            builder = 
builder.addIndexColumn(columnType.typeSpec().name()).asc().done();
+        }
 
-        assertThat(createTableFuture, willCompleteSuccessfully());
+        return createIndexStorage(builder.build());
     }
 
     /**
      * Creates a Sorted Index using the given columns.
      */
-    private SortedIndexStorage createIndexStorage(List<ColumnDefinition> 
indexSchema) {
-        SortedIndexDefinitionBuilder indexDefinitionBuilder = 
SchemaBuilders.sortedIndex("TEST_IDX");
+    protected SortedIndexStorage createIndexStorage(String name, 
List<ColumnDefinition> indexSchema) {
+        SortedIndexDefinitionBuilder indexDefinitionBuilder = 
SchemaBuilders.sortedIndex(name);
 
         indexSchema.forEach(column -> {
             SortedIndexColumnBuilder columnBuilder = 
indexDefinitionBuilder.addIndexColumn(column.name());
@@ -203,7 +122,7 @@ public abstract class AbstractSortedIndexStorageTest {
     protected SortedIndexStorage createIndexStorage(ColumnarIndexDefinition 
indexDefinition) {
         CompletableFuture<Void> createIndexFuture =
                 tablesCfg.indexes().change(chg -> 
chg.create(indexDefinition.name(), idx -> {
-                    UUID tableId = 
ConfigurationUtil.internalId(tablesCfg.tables().value(), "foo");
+                    UUID tableId = 
ConfigurationUtil.internalId(tablesCfg.tables().value(), TABLE_NAME);
 
                     addIndex(indexDefinition, tableId, idx);
                 }));
@@ -215,12 +134,17 @@ public abstract class AbstractSortedIndexStorageTest {
         return tableStorage.getOrCreateSortedIndex(TEST_PARTITION, 
indexConfig.id());
     }
 
+    @Override
+    protected SortedIndexDescriptor indexDescriptor(SortedIndexStorage index) {
+        return index.indexDescriptor();
+    }
+
     /**
      * Tests that columns of all types are correctly serialized and 
deserialized.
      */
     @Test
     void testRowSerialization() {
-        SortedIndexStorage indexStorage = 
createIndexStorage(ALL_TYPES_COLUMN_DEFINITIONS);
+        SortedIndexStorage indexStorage = createIndexStorage(INDEX_NAME, 
ALL_TYPES_COLUMN_DEFINITIONS);
 
         Object[] columns = indexStorage.indexDescriptor().columns().stream()
                 .map(SortedIndexColumnDescriptor::type)
@@ -238,40 +162,11 @@ public abstract class AbstractSortedIndexStorageTest {
 
     @Test
     void testEmpty() {
-        SortedIndexStorage index = 
createIndexStorage(shuffledRandomDefinitions());
+        SortedIndexStorage index = createIndexStorage(INDEX_NAME, 
shuffledRandomDefinitions());
 
         assertThat(scan(index, null, null, 0), is(empty()));
     }
 
-    @Test
-    void testGet() {
-        SortedIndexDefinition indexDefinition = 
SchemaBuilders.sortedIndex("TEST_INDEX")
-                .addIndexColumn(ColumnTypeSpec.STRING.name()).asc().done()
-                .addIndexColumn(ColumnTypeSpec.INT32.name()).asc().done()
-                .build();
-
-        SortedIndexStorage index = createIndexStorage(indexDefinition);
-
-        var serializer = new BinaryTupleRowSerializer(index.indexDescriptor());
-
-        IndexRow val1090 = serializer.serializeRow(new Object[]{ "10", 90 }, 
new RowId(TEST_PARTITION));
-        IndexRow otherVal1090 = serializer.serializeRow(new Object[]{ "10", 90 
}, new RowId(TEST_PARTITION));
-        IndexRow val1080 = serializer.serializeRow(new Object[]{ "10", 80 }, 
new RowId(TEST_PARTITION));
-        IndexRow val2090 = serializer.serializeRow(new Object[]{ "20", 90 }, 
new RowId(TEST_PARTITION));
-
-        put(index, val1090);
-        put(index, otherVal1090);
-        put(index, val1080);
-
-        assertThat(get(index, val1090.indexColumns()), 
containsInAnyOrder(val1090.rowId(), otherVal1090.rowId()));
-
-        assertThat(get(index, otherVal1090.indexColumns()), 
containsInAnyOrder(val1090.rowId(), otherVal1090.rowId()));
-
-        assertThat(get(index, val1080.indexColumns()), 
containsInAnyOrder(val1080.rowId()));
-
-        assertThat(get(index, val2090.indexColumns()), is(empty()));
-    }
-
     /**
      * Tests the Put-Get-Remove case when an index is created using a single 
column.
      */
@@ -281,44 +176,12 @@ public abstract class AbstractSortedIndexStorageTest {
         testPutGetRemove(List.of(columnDefinition));
     }
 
-    /**
-     * Tests that appending an already existing row does no harm.
-     */
-    @Test
-    void testPutIdempotence() {
-        SortedIndexDefinition indexDefinition = 
SchemaBuilders.sortedIndex("TEST_INDEX")
-                .addIndexColumn(ColumnTypeSpec.STRING.name()).asc().done()
-                .addIndexColumn(ColumnTypeSpec.INT32.name()).asc().done()
-                .build();
-
-        SortedIndexStorage index = createIndexStorage(indexDefinition);
-
-        var columnValues = new Object[] { "foo", 1 };
-        var rowId = new RowId(TEST_PARTITION);
-
-        var serializer = new BinaryTupleRowSerializer(index.indexDescriptor());
-
-        IndexRow row = serializer.serializeRow(columnValues, rowId);
-
-        put(index, row);
-        put(index, row);
-
-        IndexRow actualRow = getSingle(index, row.indexColumns());
-
-        assertThat(actualRow.rowId(), is(equalTo(row.rowId())));
-    }
-
     /**
      * Tests that it is possible to add rows with the same columns but 
different Row IDs.
      */
     @Test
     void testMultiplePuts() {
-        SortedIndexDefinition indexDefinition = 
SchemaBuilders.sortedIndex("TEST_INDEX")
-                .addIndexColumn(ColumnTypeSpec.STRING.name()).asc().done()
-                .addIndexColumn(ColumnTypeSpec.INT32.name()).asc().done()
-                .build();
-
-        SortedIndexStorage index = createIndexStorage(indexDefinition);
+        SortedIndexStorage index = createIndexStorage(INDEX_NAME, 
ColumnType.string(), ColumnType.INT32);
 
         var columnValues1 = new Object[] { "foo", 1 };
         var columnValues2 = new Object[] { "bar", 3 };
@@ -345,13 +208,8 @@ public abstract class AbstractSortedIndexStorageTest {
      * Tests the {@link SortedIndexStorage#remove} method.
      */
     @Test
-    void testRemove() {
-        SortedIndexDefinition indexDefinition = 
SchemaBuilders.sortedIndex("TEST_INDEX")
-                .addIndexColumn(ColumnTypeSpec.STRING.name()).asc().done()
-                .addIndexColumn(ColumnTypeSpec.INT32.name()).asc().done()
-                .build();
-
-        SortedIndexStorage index = createIndexStorage(indexDefinition);
+    void testRemoveAndScan() {
+        SortedIndexStorage index = createIndexStorage(INDEX_NAME, 
ColumnType.string(), ColumnType.INT32);
 
         var columnValues1 = new Object[] { "foo", 1 };
         var columnValues2 = new Object[] { "bar", 3 };
@@ -408,7 +266,7 @@ public abstract class AbstractSortedIndexStorageTest {
      */
     @RepeatedTest(5)
     void testScan() {
-        SortedIndexStorage indexStorage = 
createIndexStorage(shuffledDefinitions());
+        SortedIndexStorage indexStorage = createIndexStorage(INDEX_NAME, 
shuffledDefinitions());
 
         List<TestIndexRow> entries = IntStream.range(0, 10)
                 .mapToObj(i -> {
@@ -538,7 +396,7 @@ public abstract class AbstractSortedIndexStorageTest {
     void testEmptyRange() {
         List<ColumnDefinition> indexSchema = shuffledRandomDefinitions();
 
-        SortedIndexStorage indexStorage = createIndexStorage(indexSchema);
+        SortedIndexStorage indexStorage = createIndexStorage(INDEX_NAME, 
indexSchema);
 
         TestIndexRow entry1 = TestIndexRow.randomRow(indexStorage);
         TestIndexRow entry2 = TestIndexRow.randomRow(indexStorage);
@@ -560,7 +418,7 @@ public abstract class AbstractSortedIndexStorageTest {
     @ParameterizedTest
     @VariableSource("ALL_TYPES_COLUMN_DEFINITIONS")
     void testNullValues(ColumnDefinition columnDefinition) {
-        SortedIndexStorage storage = 
createIndexStorage(List.of(columnDefinition));
+        SortedIndexStorage storage = createIndexStorage(INDEX_NAME, 
List.of(columnDefinition));
 
         TestIndexRow entry1 = TestIndexRow.randomRow(storage);
 
@@ -594,11 +452,7 @@ public abstract class AbstractSortedIndexStorageTest {
      */
     @Test
     void testScanSimple() {
-        SortedIndexDefinition indexDefinition = 
SchemaBuilders.sortedIndex("TEST_IDX")
-                
.addIndexColumn(ColumnType.INT32.typeSpec().name()).asc().done()
-                .build();
-
-        SortedIndexStorage indexStorage = createIndexStorage(indexDefinition);
+        SortedIndexStorage indexStorage = createIndexStorage(INDEX_NAME, 
ColumnType.INT32);
 
         BinaryTupleRowSerializer serializer = new 
BinaryTupleRowSerializer(indexStorage.indexDescriptor());
 
@@ -750,11 +604,7 @@ public abstract class AbstractSortedIndexStorageTest {
 
     @Test
     void testScanContractAddRowBeforeInvokeHasNext() {
-        SortedIndexDefinition indexDefinition = 
SchemaBuilders.sortedIndex("TEST_IDX")
-                
.addIndexColumn(ColumnType.INT32.typeSpec().name()).asc().done()
-                .build();
-
-        SortedIndexStorage indexStorage = createIndexStorage(indexDefinition);
+        SortedIndexStorage indexStorage = createIndexStorage(INDEX_NAME, 
ColumnType.INT32);
 
         BinaryTupleRowSerializer serializer = new 
BinaryTupleRowSerializer(indexStorage.indexDescriptor());
 
@@ -777,11 +627,7 @@ public abstract class AbstractSortedIndexStorageTest {
 
     @Test
     void testScanContractAddRowAfterInvokeHasNext() {
-        SortedIndexDefinition indexDefinition = 
SchemaBuilders.sortedIndex("TEST_IDX")
-                
.addIndexColumn(ColumnType.INT32.typeSpec().name()).asc().done()
-                .build();
-
-        SortedIndexStorage indexStorage = createIndexStorage(indexDefinition);
+        SortedIndexStorage indexStorage = createIndexStorage(INDEX_NAME, 
ColumnType.INT32);
 
         BinaryTupleRowSerializer serializer = new 
BinaryTupleRowSerializer(indexStorage.indexDescriptor());
 
@@ -830,11 +676,7 @@ public abstract class AbstractSortedIndexStorageTest {
 
     @Test
     void testScanContractInvokeOnlyNext() {
-        SortedIndexDefinition indexDefinition = 
SchemaBuilders.sortedIndex("TEST_IDX")
-                
.addIndexColumn(ColumnType.INT32.typeSpec().name()).asc().done()
-                .build();
-
-        SortedIndexStorage indexStorage = createIndexStorage(indexDefinition);
+        SortedIndexStorage indexStorage = createIndexStorage(INDEX_NAME, 
ColumnType.INT32);
 
         BinaryTupleRowSerializer serializer = new 
BinaryTupleRowSerializer(indexStorage.indexDescriptor());
 
@@ -866,11 +708,7 @@ public abstract class AbstractSortedIndexStorageTest {
 
     @Test
     void testScanContractAddRowsOnly() {
-        SortedIndexDefinition indexDefinition = 
SchemaBuilders.sortedIndex("TEST_IDX")
-                
.addIndexColumn(ColumnType.INT32.typeSpec().name()).asc().done()
-                .build();
-
-        SortedIndexStorage indexStorage = createIndexStorage(indexDefinition);
+        SortedIndexStorage indexStorage = createIndexStorage(INDEX_NAME, 
ColumnType.INT32);
 
         BinaryTupleRowSerializer serializer = new 
BinaryTupleRowSerializer(indexStorage.indexDescriptor());
 
@@ -929,11 +767,7 @@ public abstract class AbstractSortedIndexStorageTest {
 
     @Test
     void testScanContractForFinishCursor() {
-        SortedIndexDefinition indexDefinition = 
SchemaBuilders.sortedIndex("TEST_IDX")
-                
.addIndexColumn(ColumnType.INT32.typeSpec().name()).asc().done()
-                .build();
-
-        SortedIndexStorage indexStorage = createIndexStorage(indexDefinition);
+        SortedIndexStorage indexStorage = createIndexStorage(INDEX_NAME, 
ColumnType.INT32);
 
         BinaryTupleRowSerializer serializer = new 
BinaryTupleRowSerializer(indexStorage.indexDescriptor());
 
@@ -979,11 +813,7 @@ public abstract class AbstractSortedIndexStorageTest {
 
     @Test
     void testScanContractNextMethodOnly() {
-        SortedIndexDefinition indexDefinition = 
SchemaBuilders.sortedIndex("TEST_IDX")
-                
.addIndexColumn(ColumnType.INT32.typeSpec().name()).asc().done()
-                .build();
-
-        SortedIndexStorage indexStorage = createIndexStorage(indexDefinition);
+        SortedIndexStorage indexStorage = createIndexStorage(INDEX_NAME, 
ColumnType.INT32);
 
         BinaryTupleRowSerializer serializer = new 
BinaryTupleRowSerializer(indexStorage.indexDescriptor());
 
@@ -1030,11 +860,7 @@ public abstract class AbstractSortedIndexStorageTest {
 
     @Test
     void testScanContractRemoveRowsOnly() {
-        SortedIndexDefinition indexDefinition = 
SchemaBuilders.sortedIndex("TEST_IDX")
-                
.addIndexColumn(ColumnType.INT32.typeSpec().name()).asc().done()
-                .build();
-
-        SortedIndexStorage indexStorage = createIndexStorage(indexDefinition);
+        SortedIndexStorage indexStorage = createIndexStorage(INDEX_NAME, 
ColumnType.INT32);
 
         BinaryTupleRowSerializer serializer = new 
BinaryTupleRowSerializer(indexStorage.indexDescriptor());
 
@@ -1088,11 +914,7 @@ public abstract class AbstractSortedIndexStorageTest {
 
     @Test
     void testScanContractReplaceRow() {
-        SortedIndexDefinition indexDefinition = 
SchemaBuilders.sortedIndex("TEST_IDX")
-                
.addIndexColumn(ColumnType.INT32.typeSpec().name()).asc().done()
-                .build();
-
-        SortedIndexStorage indexStorage = createIndexStorage(indexDefinition);
+        SortedIndexStorage indexStorage = createIndexStorage(INDEX_NAME, 
ColumnType.INT32);
 
         BinaryTupleRowSerializer serializer = new 
BinaryTupleRowSerializer(indexStorage.indexDescriptor());
 
@@ -1133,11 +955,7 @@ public abstract class AbstractSortedIndexStorageTest {
 
     @Test
     void testScanContractRemoveCachedRow() {
-        SortedIndexDefinition indexDefinition = 
SchemaBuilders.sortedIndex("TEST_IDX")
-                
.addIndexColumn(ColumnType.INT32.typeSpec().name()).asc().done()
-                .build();
-
-        SortedIndexStorage indexStorage = createIndexStorage(indexDefinition);
+        SortedIndexStorage indexStorage = createIndexStorage(INDEX_NAME, 
ColumnType.INT32);
 
         BinaryTupleRowSerializer serializer = new 
BinaryTupleRowSerializer(indexStorage.indexDescriptor());
 
@@ -1201,11 +1019,7 @@ public abstract class AbstractSortedIndexStorageTest {
 
     @Test
     void testScanContractRemoveNextAndAddFirstRow() {
-        SortedIndexDefinition indexDefinition = 
SchemaBuilders.sortedIndex("TEST_IDX")
-                
.addIndexColumn(ColumnType.INT32.typeSpec().name()).asc().done()
-                .build();
-
-        SortedIndexStorage indexStorage = createIndexStorage(indexDefinition);
+        SortedIndexStorage indexStorage = createIndexStorage(INDEX_NAME, 
ColumnType.INT32);
 
         BinaryTupleRowSerializer serializer = new 
BinaryTupleRowSerializer(indexStorage.indexDescriptor());
 
@@ -1236,14 +1050,9 @@ public abstract class AbstractSortedIndexStorageTest {
         assertThrows(NoSuchElementException.class, scan::next);
     }
 
-
     @Test
     void testScanPeekForFinishedCursor() {
-        SortedIndexDefinition indexDefinition = 
SchemaBuilders.sortedIndex("TEST_IDX")
-                
.addIndexColumn(ColumnType.INT32.typeSpec().name()).asc().done()
-                .build();
-
-        SortedIndexStorage indexStorage = createIndexStorage(indexDefinition);
+        SortedIndexStorage indexStorage = createIndexStorage(INDEX_NAME, 
ColumnType.INT32);
 
         BinaryTupleRowSerializer serializer = new 
BinaryTupleRowSerializer(indexStorage.indexDescriptor());
 
@@ -1306,11 +1115,7 @@ public abstract class AbstractSortedIndexStorageTest {
 
     @Test
     void testScanPeekAddRowsOnly() {
-        SortedIndexDefinition indexDefinition = 
SchemaBuilders.sortedIndex("TEST_IDX")
-                
.addIndexColumn(ColumnType.INT32.typeSpec().name()).asc().done()
-                .build();
-
-        SortedIndexStorage indexStorage = createIndexStorage(indexDefinition);
+        SortedIndexStorage indexStorage = createIndexStorage(INDEX_NAME, 
ColumnType.INT32);
 
         BinaryTupleRowSerializer serializer = new 
BinaryTupleRowSerializer(indexStorage.indexDescriptor());
 
@@ -1387,11 +1192,7 @@ public abstract class AbstractSortedIndexStorageTest {
 
     @Test
     void testScanPeekRemoveRows() {
-        SortedIndexDefinition indexDefinition = 
SchemaBuilders.sortedIndex("TEST_IDX")
-                
.addIndexColumn(ColumnType.INT32.typeSpec().name()).asc().done()
-                .build();
-
-        SortedIndexStorage indexStorage = createIndexStorage(indexDefinition);
+        SortedIndexStorage indexStorage = createIndexStorage(INDEX_NAME, 
ColumnType.INT32);
 
         BinaryTupleRowSerializer serializer = new 
BinaryTupleRowSerializer(indexStorage.indexDescriptor());
 
@@ -1450,11 +1251,7 @@ public abstract class AbstractSortedIndexStorageTest {
 
     @Test
     void testScanPeekReplaceRow() {
-        SortedIndexDefinition indexDefinition = 
SchemaBuilders.sortedIndex("TEST_IDX")
-                
.addIndexColumn(ColumnType.INT32.typeSpec().name()).asc().done()
-                .build();
-
-        SortedIndexStorage indexStorage = createIndexStorage(indexDefinition);
+        SortedIndexStorage indexStorage = createIndexStorage(INDEX_NAME, 
ColumnType.INT32);
 
         BinaryTupleRowSerializer serializer = new 
BinaryTupleRowSerializer(indexStorage.indexDescriptor());
 
@@ -1506,6 +1303,45 @@ public abstract class AbstractSortedIndexStorageTest {
         assertNull(scan.peek());
     }
 
+    @Test
+    void testScanPeekRemoveNext() {
+        SortedIndexStorage indexStorage = createIndexStorage(INDEX_NAME, 
ColumnType.INT32);
+
+        BinaryTupleRowSerializer serializer = new 
BinaryTupleRowSerializer(indexStorage.indexDescriptor());
+
+        PeekCursor<IndexRow> scan = indexStorage.scan(null, null, 0);
+
+        RowId rowId = new RowId(TEST_PARTITION);
+
+        // index  =  [0]
+        // cursor = ^ with no cached row
+        put(indexStorage, serializer.serializeRow(new Object[]{0}, rowId));
+
+        // index  =  [0]
+        // cursor = ^ with no cached row
+        assertEquals(SimpleRow.of(0, rowId), SimpleRow.of(scan.peek(), 
firstColumn(serializer)));
+
+        // index  =
+        // cursor = ^ with no cached row (but it remembers the last peek call)
+        remove(indexStorage, serializer.serializeRow(new Object[]{0}, rowId));
+
+        // "hasNext" and "next" must return the result of last "peek" 
operation. This is crucial for RW scans.
+        assertTrue(scan.hasNext());
+        assertEquals(SimpleRow.of(0, rowId), SimpleRow.of(scan.next(), 
firstColumn(serializer)));
+
+        // index  =
+        // cursor = ^ with no cached row
+        assertNull(scan.peek());
+
+        // index  =  [1]
+        // cursor = ^ with no cached row (points before [1] because last 
returned value was [0])
+        put(indexStorage, serializer.serializeRow(new Object[]{1}, rowId));
+
+        // But, "hasNext" must return "false" to be consistent with the result 
of last "peek" operation. This is crucial for RW scans.
+        assertFalse(scan.hasNext());
+        assertThrows(NoSuchElementException.class, scan::next);
+    }
+
     private List<ColumnDefinition> shuffledRandomDefinitions() {
         return shuffledDefinitions(d -> random.nextBoolean());
     }
@@ -1539,7 +1375,7 @@ public abstract class AbstractSortedIndexStorageTest {
      * be removed.
      */
     private void testPutGetRemove(List<ColumnDefinition> indexSchema) {
-        SortedIndexStorage indexStorage = createIndexStorage(indexSchema);
+        SortedIndexStorage indexStorage = createIndexStorage(INDEX_NAME, 
indexSchema);
 
         TestIndexRow entry1 = TestIndexRow.randomRow(indexStorage);
         TestIndexRow entry2;
@@ -1567,18 +1403,6 @@ public abstract class AbstractSortedIndexStorageTest {
         assertThat(getSingle(indexStorage, entry1.indexColumns()), 
is(nullValue()));
     }
 
-    /**
-     * Extracts a single value by a given key or {@code null} if it does not 
exist.
-     */
-    @Nullable
-    private static IndexRow getSingle(SortedIndexStorage indexStorage, 
BinaryTuple fullPrefix) {
-        List<RowId> rowIds = get(indexStorage, fullPrefix);
-
-        assertThat(rowIds, anyOf(empty(), hasSize(1)));
-
-        return rowIds.isEmpty() ? null : new IndexRowImpl(fullPrefix, 
rowIds.get(0));
-    }
-
     private static BinaryTuplePrefix prefix(SortedIndexStorage index, 
Object... vals) {
         var serializer = new BinaryTupleRowSerializer(index.indexDescriptor());
 
@@ -1611,42 +1435,6 @@ public abstract class AbstractSortedIndexStorageTest {
         }
     }
 
-    protected static List<RowId> get(SortedIndexStorage index, BinaryTuple 
key) {
-        try (Cursor<RowId> cursor = index.get(key)) {
-            return cursor.stream().collect(toUnmodifiableList());
-        }
-    }
-
-    protected void put(SortedIndexStorage indexStorage, IndexRow row) {
-        partitionStorage.runConsistently(locker -> {
-            locker.lock(row.rowId());
-
-            indexStorage.put(row);
-
-            return null;
-        });
-    }
-
-    private void remove(SortedIndexStorage indexStorage, IndexRow row) {
-        partitionStorage.runConsistently(locker -> {
-            locker.lock(row.rowId());
-
-            indexStorage.remove(row);
-
-            return null;
-        });
-    }
-
-    private static <T> List<T> getRemaining(Cursor<IndexRow> scanCursor, 
Function<IndexRow, T> mapper) {
-        List<T> result = new ArrayList<>();
-
-        while (scanCursor.hasNext()) {
-            result.add(mapper.apply(scanCursor.next()));
-        }
-
-        return result;
-    }
-
     private static <T> Function<IndexRow, T> 
firstColumn(BinaryTupleRowSerializer serializer) {
         return indexRow -> (T) serializer.deserializeColumns(indexRow)[0];
     }
diff --git 
a/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/impl/BinaryTupleRowSerializer.java
 
b/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/impl/BinaryTupleRowSerializer.java
index 5435522aa5..612b80ab5f 100644
--- 
a/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/impl/BinaryTupleRowSerializer.java
+++ 
b/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/impl/BinaryTupleRowSerializer.java
@@ -30,10 +30,9 @@ import 
org.apache.ignite.internal.schema.BinaryTupleSchema.Element;
 import org.apache.ignite.internal.schema.NativeType;
 import org.apache.ignite.internal.schema.NativeTypeSpec;
 import org.apache.ignite.internal.storage.RowId;
-import org.apache.ignite.internal.storage.index.HashIndexDescriptor;
+import org.apache.ignite.internal.storage.index.IndexDescriptor;
 import org.apache.ignite.internal.storage.index.IndexRow;
 import org.apache.ignite.internal.storage.index.IndexRowImpl;
-import org.apache.ignite.internal.storage.index.SortedIndexDescriptor;
 
 /**
  * Class for converting an array of objects into a {@link BinaryTuple} and 
vice-versa using a given index schema.
@@ -55,18 +54,9 @@ public class BinaryTupleRowSerializer {
     private final BinaryTupleSchema tupleSchema;
 
     /**
-     * Creates a new instance for a Sorted Index.
+     * Creates a new instance for an index.
      */
-    public BinaryTupleRowSerializer(SortedIndexDescriptor descriptor) {
-        this(descriptor.columns().stream()
-                .map(colDesc -> new ColumnDescriptor(colDesc.type(), 
colDesc.nullable()))
-                .collect(toUnmodifiableList()));
-    }
-
-    /**
-     * Creates a new instance for a Hash Index.
-     */
-    public BinaryTupleRowSerializer(HashIndexDescriptor descriptor) {
+    public BinaryTupleRowSerializer(IndexDescriptor descriptor) {
         this(descriptor.columns().stream()
                 .map(colDesc -> new ColumnDescriptor(colDesc.type(), 
colDesc.nullable()))
                 .collect(toUnmodifiableList()));
diff --git 
a/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/impl/TestHashIndexStorage.java
 
b/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/impl/TestHashIndexStorage.java
index c31cb3b898..591d79c160 100644
--- 
a/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/impl/TestHashIndexStorage.java
+++ 
b/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/impl/TestHashIndexStorage.java
@@ -17,26 +17,28 @@
 
 package org.apache.ignite.internal.storage.index.impl;
 
-import static org.apache.ignite.internal.util.IgniteUtils.capacity;
-
 import java.nio.ByteBuffer;
-import java.util.HashSet;
 import java.util.Iterator;
+import java.util.NavigableSet;
+import java.util.NoSuchElementException;
+import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.ConcurrentSkipListSet;
 import java.util.stream.Collectors;
 import org.apache.ignite.internal.schema.BinaryTuple;
 import org.apache.ignite.internal.storage.RowId;
 import org.apache.ignite.internal.storage.index.HashIndexDescriptor;
 import org.apache.ignite.internal.storage.index.HashIndexStorage;
 import org.apache.ignite.internal.storage.index.IndexRow;
+import org.jetbrains.annotations.Nullable;
 
 /**
  * Test-only implementation of a {@link HashIndexStorage}.
  */
 public class TestHashIndexStorage extends AbstractTestIndexStorage implements 
HashIndexStorage {
-    private final ConcurrentMap<ByteBuffer, Set<RowId>> index = new 
ConcurrentHashMap<>();
+    private final ConcurrentMap<ByteBuffer, NavigableSet<RowId>> index = new 
ConcurrentHashMap<>();
 
     private final HashIndexDescriptor descriptor;
 
@@ -56,7 +58,7 @@ public class TestHashIndexStorage extends 
AbstractTestIndexStorage implements Ha
 
     @Override
     Iterator<RowId> getRowIdIteratorForGetByBinaryTuple(BinaryTuple key) {
-        return index.getOrDefault(key.byteBuffer(), Set.of()).iterator();
+        return new RowIdIterator(key.byteBuffer());
     }
 
     @Override
@@ -65,17 +67,12 @@ public class TestHashIndexStorage extends 
AbstractTestIndexStorage implements Ha
 
         index.compute(row.indexColumns().byteBuffer(), (k, v) -> {
             if (v == null) {
-                return Set.of(row.rowId());
-            } else if (v.contains(row.rowId())) {
-                return v;
-            } else {
-                var result = new HashSet<RowId>(capacity(v.size() + 1));
+                v = new ConcurrentSkipListSet<>();
+            }
 
-                result.addAll(v);
-                result.add(row.rowId());
+            v.add(row.rowId());
 
-                return result;
-            }
+            return v;
         });
     }
 
@@ -84,19 +81,11 @@ public class TestHashIndexStorage extends 
AbstractTestIndexStorage implements Ha
         checkStorageClosedOrInProcessOfRebalance();
 
         index.computeIfPresent(row.indexColumns().byteBuffer(), (k, v) -> {
-            if (v.contains(row.rowId())) {
-                if (v.size() == 1) {
-                    return null;
-                } else {
-                    var result = new HashSet<>(v);
-
-                    result.remove(row.rowId());
-
-                    return result;
-                }
-            } else {
-                return v;
+            if (v.remove(row.rowId()) && v.isEmpty()) {
+                return null;
             }
+
+            return v;
         });
     }
 
@@ -111,4 +100,52 @@ public class TestHashIndexStorage extends 
AbstractTestIndexStorage implements Ha
     public Set<RowId> allRowsIds() {
         return 
index.values().stream().flatMap(Set::stream).collect(Collectors.toSet());
     }
+
+    /**
+     * Row IDs iterator that always returns up-to-date values.
+     */
+    private class RowIdIterator implements Iterator<RowId> {
+        private final ByteBuffer key;
+
+        @Nullable Boolean hasNext;
+
+        @Nullable RowId rowId;
+
+        RowIdIterator(ByteBuffer key) {
+            this.key = key;
+        }
+
+        @Override
+        public boolean hasNext() {
+            if (hasNext != null) {
+                return hasNext;
+            }
+
+            // Yes, we must read it every time, because concurrency.
+            NavigableSet<RowId> rowIds = index.get(key);
+
+            if (rowIds == null) {
+                rowId = null;
+            } else if (rowId == null) {
+                rowId = rowIds.stream().findFirst().orElse(null);
+            } else {
+                rowId = rowIds.higher(rowId);
+            }
+
+            hasNext = rowId != null;
+
+            return hasNext;
+        }
+
+        @Override
+        public RowId next() {
+            if (!hasNext()) {
+                throw new NoSuchElementException();
+            }
+
+            hasNext = null;
+
+            return Objects.requireNonNull(rowId);
+        }
+    }
 }
diff --git 
a/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/impl/TestSortedIndexStorage.java
 
b/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/impl/TestSortedIndexStorage.java
index 6961c07765..44f96fa4e5 100644
--- 
a/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/impl/TestSortedIndexStorage.java
+++ 
b/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/index/impl/TestSortedIndexStorage.java
@@ -17,17 +17,17 @@
 
 package org.apache.ignite.internal.storage.index.impl;
 
-import static java.util.Collections.emptyNavigableMap;
+import static java.util.Collections.emptyNavigableSet;
+import static java.util.Comparator.comparing;
+import static org.apache.ignite.internal.storage.RowId.highestRowId;
+import static org.apache.ignite.internal.storage.RowId.lowestRowId;
 
 import java.nio.ByteBuffer;
 import java.util.Iterator;
-import java.util.Map.Entry;
-import java.util.NavigableMap;
 import java.util.NavigableSet;
 import java.util.NoSuchElementException;
 import java.util.Set;
-import java.util.concurrent.ConcurrentNavigableMap;
-import java.util.concurrent.ConcurrentSkipListMap;
+import java.util.concurrent.ConcurrentSkipListSet;
 import java.util.stream.Collectors;
 import org.apache.ignite.internal.binarytuple.BinaryTupleCommon;
 import org.apache.ignite.internal.schema.BinaryTuple;
@@ -39,19 +39,16 @@ import 
org.apache.ignite.internal.storage.index.IndexRowImpl;
 import org.apache.ignite.internal.storage.index.PeekCursor;
 import org.apache.ignite.internal.storage.index.SortedIndexDescriptor;
 import org.apache.ignite.internal.storage.index.SortedIndexStorage;
+import org.apache.ignite.internal.util.TransformingIterator;
 import org.jetbrains.annotations.Nullable;
 
 /**
  * Test implementation of MV sorted index storage.
  */
 public class TestSortedIndexStorage extends AbstractTestIndexStorage 
implements SortedIndexStorage {
-    private static final Object NULL = new Object();
+    private final int partitionId;
 
-    /**
-     * {@code NavigableMap<RowId, Object>} is used as a {@link NavigableSet}, 
but map was chosen because methods like
-     * {@link NavigableSet#first()} throw an {@link NoSuchElementException} if 
the set is empty.
-     */
-    private final ConcurrentNavigableMap<ByteBuffer, NavigableMap<RowId, 
Object>> index;
+    private final NavigableSet<IndexRow> index;
 
     private final SortedIndexDescriptor descriptor;
 
@@ -61,8 +58,14 @@ public class TestSortedIndexStorage extends 
AbstractTestIndexStorage implements
     public TestSortedIndexStorage(int partitionId, SortedIndexDescriptor 
descriptor) {
         super(partitionId);
 
+        BinaryTupleComparator binaryTupleComparator = new 
BinaryTupleComparator(descriptor);
+
+        this.partitionId = partitionId;
         this.descriptor = descriptor;
-        this.index = new ConcurrentSkipListMap<>(new 
BinaryTupleComparator(descriptor));
+        this.index = new ConcurrentSkipListSet<>(
+                comparing((IndexRow indexRow) -> 
indexRow.indexColumns().byteBuffer(), binaryTupleComparator)
+                        .thenComparing(IndexRow::rowId)
+        );
     }
 
     @Override
@@ -72,31 +75,27 @@ public class TestSortedIndexStorage extends 
AbstractTestIndexStorage implements
 
     @Override
     Iterator<RowId> getRowIdIteratorForGetByBinaryTuple(BinaryTuple key) {
-        return index.getOrDefault(key.byteBuffer(), 
emptyNavigableMap()).keySet().iterator();
+        // These must be two different instances, because "scan" call messes 
up headers.
+        BinaryTuplePrefix lowerBound = BinaryTuplePrefix.fromBinaryTuple(key);
+        BinaryTuplePrefix higherBound = BinaryTuplePrefix.fromBinaryTuple(key);
+
+        PeekCursor<IndexRow> peekCursor = scan(lowerBound, higherBound, 
GREATER_OR_EQUAL | LESS_OR_EQUAL);
+
+        return new TransformingIterator<>(peekCursor, IndexRow::rowId);
     }
 
     @Override
     public void put(IndexRow row) {
         checkStorageClosed();
 
-        index.compute(row.indexColumns().byteBuffer(), (k, v) -> {
-            NavigableMap<RowId, Object> rowIds = v == null ? new 
ConcurrentSkipListMap<>() : v;
-
-            rowIds.put(row.rowId(), NULL);
-
-            return rowIds;
-        });
+        index.add(row);
     }
 
     @Override
     public void remove(IndexRow row) {
         checkStorageClosedOrInProcessOfRebalance();
 
-        index.computeIfPresent(row.indexColumns().byteBuffer(), (k, v) -> {
-            v.remove(row.rowId());
-
-            return v.isEmpty() ? null : v;
-        });
+        index.remove(row);
     }
 
     @Override
@@ -118,23 +117,35 @@ public class TestSortedIndexStorage extends 
AbstractTestIndexStorage implements
             setEqualityFlag(upperBound);
         }
 
-        NavigableMap<ByteBuffer, NavigableMap<RowId, Object>> navigableMap;
+        NavigableSet<IndexRow> navigableSet;
 
         if (lowerBound == null && upperBound == null) {
-            navigableMap = index;
+            navigableSet = index;
         } else if (lowerBound == null) {
-            navigableMap = index.headMap(upperBound.byteBuffer());
+            navigableSet = index.headSet(prefixToIndexRow(upperBound, 
highestRowId(partitionId)), true);
         } else if (upperBound == null) {
-            navigableMap = index.tailMap(lowerBound.byteBuffer());
+            navigableSet = index.tailSet(prefixToIndexRow(lowerBound, 
lowestRowId(partitionId)), true);
         } else {
             try {
-                navigableMap = index.subMap(lowerBound.byteBuffer(), 
upperBound.byteBuffer());
+                navigableSet = index.subSet(
+                        prefixToIndexRow(lowerBound, lowestRowId(partitionId)),
+                        true,
+                        prefixToIndexRow(upperBound, 
highestRowId(partitionId)),
+                        true
+                );
             } catch (IllegalArgumentException e) {
-                navigableMap = emptyNavigableMap();
+                // Upper bound is below the lower bound.
+                navigableSet = emptyNavigableSet();
             }
         }
 
-        return new ScanCursor(navigableMap);
+        return new ScanCursor(navigableSet);
+    }
+
+    private IndexRowImpl prefixToIndexRow(BinaryTuplePrefix prefix, RowId 
rowId) {
+        var binaryTuple = new BinaryTuple(descriptor.binaryTupleSchema(), 
prefix.byteBuffer());
+
+        return new IndexRowImpl(binaryTuple, rowId);
     }
 
     private static void setEqualityFlag(BinaryTuplePrefix prefix) {
@@ -150,20 +161,22 @@ public class TestSortedIndexStorage extends 
AbstractTestIndexStorage implements
         index.clear();
     }
 
+    private static final IndexRow NO_PEEKED_ROW = new IndexRowImpl(null, null);
+
     private class ScanCursor implements PeekCursor<IndexRow> {
-        private final NavigableMap<ByteBuffer, NavigableMap<RowId, Object>> 
indexMap;
+        private final NavigableSet<IndexRow> indexSet;
 
         @Nullable
         private Boolean hasNext;
 
         @Nullable
-        private Entry<ByteBuffer, NavigableMap<RowId, Object>> currentEntry;
+        private IndexRow currentRow;
 
         @Nullable
-        private RowId rowId;
+        private IndexRow peekedRow = NO_PEEKED_ROW;
 
-        private ScanCursor(NavigableMap<ByteBuffer, NavigableMap<RowId, 
Object>> indexMap) {
-            this.indexMap = indexMap;
+        private ScanCursor(NavigableSet<IndexRow> indexSet) {
+            this.indexSet = indexSet;
         }
 
         @Override
@@ -175,26 +188,26 @@ public class TestSortedIndexStorage extends 
AbstractTestIndexStorage implements
         public boolean hasNext() {
             checkStorageClosedOrInProcessOfRebalance();
 
-            advanceIfNeeded();
+            if (hasNext != null) {
+                return hasNext;
+            }
+
+            currentRow = peekedRow == NO_PEEKED_ROW ? peek() : peekedRow;
+            peekedRow = NO_PEEKED_ROW;
 
+            hasNext = currentRow != null;
             return hasNext;
         }
 
         @Override
         public IndexRow next() {
-            checkStorageClosedOrInProcessOfRebalance();
-
-            advanceIfNeeded();
-
-            boolean hasNext = this.hasNext;
-
-            if (!hasNext) {
+            if (!hasNext()) {
                 throw new NoSuchElementException();
             }
 
             this.hasNext = null;
 
-            return new IndexRowImpl(new 
BinaryTuple(descriptor.binaryTupleSchema(), currentEntry.getKey()), rowId);
+            return currentRow;
         }
 
         @Override
@@ -202,78 +215,20 @@ public class TestSortedIndexStorage extends 
AbstractTestIndexStorage implements
             checkStorageClosedOrInProcessOfRebalance();
 
             if (hasNext != null) {
-                if (hasNext) {
-                    return new IndexRowImpl(new 
BinaryTuple(descriptor.binaryTupleSchema(), currentEntry.getKey()), rowId);
-                }
-
-                return null;
+                return currentRow;
             }
 
-            Entry<ByteBuffer, NavigableMap<RowId, Object>> indexMapEntry0 = 
currentEntry == null ? indexMap.firstEntry() : currentEntry;
-
-            RowId nextRowId = null;
-
-            if (rowId == null) {
-                if (indexMapEntry0 != null) {
-                    nextRowId = 
getRowId(indexMapEntry0.getValue().firstEntry());
+            if (currentRow == null) {
+                try {
+                    peekedRow = indexSet.first();
+                } catch (NoSuchElementException e) {
+                    peekedRow = null;
                 }
             } else {
-                Entry<RowId, Object> nextRowIdEntry = 
indexMapEntry0.getValue().higherEntry(rowId);
-
-                if (nextRowIdEntry != null) {
-                    nextRowId = nextRowIdEntry.getKey();
-                } else {
-                    indexMapEntry0 = 
indexMap.higherEntry(indexMapEntry0.getKey());
-
-                    if (indexMapEntry0 != null) {
-                        nextRowId = 
getRowId(indexMapEntry0.getValue().firstEntry());
-                    }
-                }
-            }
-
-            return nextRowId == null
-                    ? null : new IndexRowImpl(new 
BinaryTuple(descriptor.binaryTupleSchema(), indexMapEntry0.getKey()), 
nextRowId);
-        }
-
-        private void advanceIfNeeded() {
-            if (hasNext != null) {
-                return;
-            }
-
-            if (currentEntry == null) {
-                currentEntry = indexMap.firstEntry();
-            }
-
-            if (rowId == null) {
-                if (currentEntry != null) {
-                    rowId = getRowId(currentEntry.getValue().firstEntry());
-                }
-            } else {
-                Entry<RowId, Object> nextRowIdEntry = 
currentEntry.getValue().higherEntry(rowId);
-
-                if (nextRowIdEntry != null) {
-                    rowId = nextRowIdEntry.getKey();
-                } else {
-                    Entry<ByteBuffer, NavigableMap<RowId, Object>> 
nextIndexMapEntry = indexMap.higherEntry(currentEntry.getKey());
-
-                    if (nextIndexMapEntry == null) {
-                        hasNext = false;
-
-                        return;
-                    } else {
-                        currentEntry = nextIndexMapEntry;
-
-                        rowId = getRowId(currentEntry.getValue().firstEntry());
-                    }
-                }
+                peekedRow = indexSet.higher(this.currentRow);
             }
 
-            hasNext = rowId != null;
-        }
-
-        @Nullable
-        private RowId getRowId(@Nullable Entry<RowId, ?> rowIdEntry) {
-            return rowIdEntry == null ? null : rowIdEntry.getKey();
+            return peekedRow;
         }
     }
 
@@ -281,6 +236,6 @@ public class TestSortedIndexStorage extends 
AbstractTestIndexStorage implements
      * Returns all indexed row ids.
      */
     public Set<RowId> allRowsIds() {
-        return index.values().stream().flatMap(m -> 
m.keySet().stream()).collect(Collectors.toSet());
+        return index.stream().map(IndexRow::rowId).collect(Collectors.toSet());
     }
 }
diff --git 
a/modules/storage-page-memory/src/main/java/org/apache/ignite/internal/storage/pagememory/index/sorted/PageMemorySortedIndexStorage.java
 
b/modules/storage-page-memory/src/main/java/org/apache/ignite/internal/storage/pagememory/index/sorted/PageMemorySortedIndexStorage.java
index 801533b1e2..6183195682 100644
--- 
a/modules/storage-page-memory/src/main/java/org/apache/ignite/internal/storage/pagememory/index/sorted/PageMemorySortedIndexStorage.java
+++ 
b/modules/storage-page-memory/src/main/java/org/apache/ignite/internal/storage/pagememory/index/sorted/PageMemorySortedIndexStorage.java
@@ -178,8 +178,8 @@ public class PageMemorySortedIndexStorage extends 
AbstractPageMemoryIndexStorage
         return new SortedIndexRow(new IndexColumns(partitionId, 
tuple.byteBuffer()), rowId);
     }
 
-    private IndexRowImpl toIndexRowImpl(SortedIndexRow sortedIndexRow) {
-        return new IndexRowImpl(
+    private @Nullable IndexRowImpl toIndexRowImpl(@Nullable SortedIndexRow 
sortedIndexRow) {
+        return sortedIndexRow == null ? null : new IndexRowImpl(
                 new BinaryTuple(descriptor.binaryTupleSchema(), 
sortedIndexRow.indexColumns().valueBuffer()),
                 sortedIndexRow.rowId()
         );
@@ -224,6 +224,9 @@ public class PageMemorySortedIndexStorage extends 
AbstractPageMemoryIndexStorage
         };
     }
 
+    /** Constant that represents the absence of value in {@link ScanCursor}. */
+    private static final SortedIndexRow NO_INDEX_ROW = new 
SortedIndexRow(null, null);
+
     private class ScanCursor implements PeekCursor<IndexRow> {
         @Nullable
         private Boolean hasNext;
@@ -237,6 +240,9 @@ public class PageMemorySortedIndexStorage extends 
AbstractPageMemoryIndexStorage
         @Nullable
         private SortedIndexRow treeRow;
 
+        @Nullable
+        private SortedIndexRow peekedRow = NO_INDEX_ROW;
+
         private ScanCursor(@Nullable SortedIndexRowKey lower, @Nullable 
SortedIndexRowKey upper) {
             this.lower = lower;
             this.upper = upper;
@@ -251,9 +257,7 @@ public class PageMemorySortedIndexStorage extends 
AbstractPageMemoryIndexStorage
         public boolean hasNext() {
             return busy(() -> {
                 try {
-                    advanceIfNeeded();
-
-                    return hasNext;
+                    return advanceIfNeeded();
                 } catch (IgniteInternalCheckedException e) {
                     throw new StorageException("Error while advancing the 
cursor", e);
                 }
@@ -264,11 +268,7 @@ public class PageMemorySortedIndexStorage extends 
AbstractPageMemoryIndexStorage
         public IndexRow next() {
             return busy(() -> {
                 try {
-                    advanceIfNeeded();
-
-                    boolean hasNext = this.hasNext;
-
-                    if (!hasNext) {
+                    if (!advanceIfNeeded()) {
                         throw new NoSuchElementException();
                     }
 
@@ -286,56 +286,44 @@ public class PageMemorySortedIndexStorage extends 
AbstractPageMemoryIndexStorage
             return busy(() -> {
                 throwExceptionIfStorageInProgressOfRebalance(state.get(), 
PageMemorySortedIndexStorage.this::createStorageInfo);
 
-                if (hasNext != null) {
-                    if (hasNext) {
-                        return toIndexRowImpl(treeRow);
-                    }
-
-                    return null;
-                }
-
                 try {
-                    SortedIndexRow nextTreeRow;
-
-                    if (treeRow == null) {
-                        nextTreeRow = lower == null ? 
sortedIndexTree.findFirst() : sortedIndexTree.findNext(lower, true);
-                    } else {
-                        nextTreeRow = sortedIndexTree.findNext(treeRow, false);
-                    }
-
-                    if (nextTreeRow == null || (upper != null && 
compareRows(nextTreeRow, upper) >= 0)) {
-                        return null;
-                    }
-
-                    return toIndexRowImpl(nextTreeRow);
+                    return toIndexRowImpl(peekBusy());
                 } catch (IgniteInternalCheckedException e) {
                     throw new StorageException("Error when peeking next 
element", e);
                 }
             });
         }
 
-        private void advanceIfNeeded() throws IgniteInternalCheckedException {
-            throwExceptionIfStorageInProgressOfRebalance(state.get(), 
PageMemorySortedIndexStorage.this::createStorageInfo);
-
+        private @Nullable SortedIndexRow peekBusy() throws 
IgniteInternalCheckedException {
             if (hasNext != null) {
-                return;
+                return treeRow;
             }
 
             if (treeRow == null) {
-                treeRow = lower == null ? sortedIndexTree.findFirst() : 
sortedIndexTree.findNext(lower, true);
+                peekedRow = lower == null ? sortedIndexTree.findFirst() : 
sortedIndexTree.findNext(lower, true);
             } else {
-                SortedIndexRow next = sortedIndexTree.findNext(treeRow, false);
+                peekedRow = sortedIndexTree.findNext(treeRow, false);
+            }
 
-                if (next == null) {
-                    hasNext = false;
+            if (peekedRow == null || (upper != null && compareRows(peekedRow, 
upper) >= 0)) {
+                peekedRow = null;
+            }
 
-                    return;
-                } else {
-                    treeRow = next;
-                }
+            return peekedRow;
+        }
+
+        private boolean advanceIfNeeded() throws 
IgniteInternalCheckedException {
+            throwExceptionIfStorageInProgressOfRebalance(state.get(), 
PageMemorySortedIndexStorage.this::createStorageInfo);
+
+            if (hasNext != null) {
+                return hasNext;
             }
 
-            hasNext = treeRow != null && (upper == null || 
compareRows(treeRow, upper) < 0);
+            treeRow = (peekedRow == NO_INDEX_ROW) ? peekBusy() : peekedRow;
+            peekedRow = NO_INDEX_ROW;
+
+            hasNext = treeRow != null;
+            return hasNext;
         }
 
         private int compareRows(SortedIndexRowKey key1, SortedIndexRowKey 
key2) {
diff --git 
a/modules/storage-page-memory/src/test/java/org/apache/ignite/internal/storage/pagememory/index/AbstractPageMemoryHashIndexStorageTest.java
 
b/modules/storage-page-memory/src/test/java/org/apache/ignite/internal/storage/pagememory/index/AbstractPageMemoryHashIndexStorageTest.java
index dcd71bb088..badac4a78b 100644
--- 
a/modules/storage-page-memory/src/test/java/org/apache/ignite/internal/storage/pagememory/index/AbstractPageMemoryHashIndexStorageTest.java
+++ 
b/modules/storage-page-memory/src/test/java/org/apache/ignite/internal/storage/pagememory/index/AbstractPageMemoryHashIndexStorageTest.java
@@ -26,11 +26,13 @@ import static org.hamcrest.Matchers.empty;
 import java.util.Random;
 import org.apache.ignite.internal.pagememory.PageMemory;
 import org.apache.ignite.internal.schema.configuration.TablesConfiguration;
+import org.apache.ignite.internal.schema.testutils.definition.ColumnType;
 import org.apache.ignite.internal.storage.RowId;
 import org.apache.ignite.internal.storage.engine.MvTableStorage;
 import org.apache.ignite.internal.storage.index.AbstractHashIndexStorageTest;
 import org.apache.ignite.internal.storage.index.HashIndexStorage;
 import org.apache.ignite.internal.storage.index.IndexRow;
+import org.apache.ignite.internal.storage.index.impl.BinaryTupleRowSerializer;
 import 
org.apache.ignite.internal.storage.pagememory.configuration.schema.BasePageMemoryStorageEngineConfiguration;
 import org.junit.jupiter.api.Test;
 
@@ -59,29 +61,38 @@ abstract class AbstractPageMemoryHashIndexStorageTest 
extends AbstractHashIndexS
 
     @Test
     void testWithStringsLargerThanMaximumInlineSize() {
-        IndexRow indexRow0 = createIndexRow(1, randomString(random, 
MAX_BINARY_TUPLE_INLINE_SIZE), new RowId(TEST_PARTITION));
-        IndexRow indexRow1 = createIndexRow(1, randomString(random, 
MAX_BINARY_TUPLE_INLINE_SIZE), new RowId(TEST_PARTITION));
+        HashIndexStorage index = createIndexStorage(INDEX_NAME, 
ColumnType.INT32, ColumnType.string());
+        var serializer = new BinaryTupleRowSerializer(indexDescriptor(index));
 
-        put(indexRow0);
-        put(indexRow1);
+        IndexRow indexRow0 = createIndexRow(serializer, new 
RowId(TEST_PARTITION), 1, randomString(random, MAX_BINARY_TUPLE_INLINE_SIZE));
+        IndexRow indexRow1 = createIndexRow(serializer, new 
RowId(TEST_PARTITION), 1, randomString(random, MAX_BINARY_TUPLE_INLINE_SIZE));
 
-        assertThat(getAll(indexRow0), contains(indexRow0.rowId()));
-        assertThat(getAll(indexRow1), contains(indexRow1.rowId()));
+        put(index, indexRow0);
+        put(index, indexRow1);
 
-        assertThat(getAll(createIndexRow(1, "foo", new 
RowId(TEST_PARTITION))), empty());
+        assertThat(getAll(index, indexRow0), contains(indexRow0.rowId()));
+        assertThat(getAll(index, indexRow1), contains(indexRow1.rowId()));
+
+        assertThat(getAll(index, createIndexRow(serializer, new 
RowId(TEST_PARTITION), 1, "foo")), empty());
     }
 
     @Test
     void testFragmentedIndexColumns() {
-        IndexRow indexRow0 = createIndexRow(1, randomString(random, 
baseEngineConfig.pageSize().value() * 2), new RowId(TEST_PARTITION));
-        IndexRow indexRow1 = createIndexRow(1, randomString(random, 
baseEngineConfig.pageSize().value() * 2), new RowId(TEST_PARTITION));
+        HashIndexStorage index = createIndexStorage(INDEX_NAME, 
ColumnType.INT32, ColumnType.string());
+        var serializer = new BinaryTupleRowSerializer(indexDescriptor(index));
+
+        String longString0 = randomString(random, 
baseEngineConfig.pageSize().value() * 2);
+        String longString1 = randomString(random, 
baseEngineConfig.pageSize().value() * 2);
+
+        IndexRow indexRow0 = createIndexRow(serializer, new 
RowId(TEST_PARTITION), 1, longString0);
+        IndexRow indexRow1 = createIndexRow(serializer, new 
RowId(TEST_PARTITION), 1, longString1);
 
-        put(indexRow0);
-        put(indexRow1);
+        put(index, indexRow0);
+        put(index, indexRow1);
 
-        assertThat(getAll(indexRow0), contains(indexRow0.rowId()));
-        assertThat(getAll(indexRow1), contains(indexRow1.rowId()));
+        assertThat(getAll(index, indexRow0), contains(indexRow0.rowId()));
+        assertThat(getAll(index, indexRow1), contains(indexRow1.rowId()));
 
-        assertThat(getAll(createIndexRow(1, "foo", new 
RowId(TEST_PARTITION))), empty());
+        assertThat(getAll(index, createIndexRow(serializer, new 
RowId(TEST_PARTITION), 1, "foo")), empty());
     }
 }
diff --git 
a/modules/storage-rocksdb/src/main/java/org/apache/ignite/internal/storage/rocksdb/index/RocksDbSortedIndexStorage.java
 
b/modules/storage-rocksdb/src/main/java/org/apache/ignite/internal/storage/rocksdb/index/RocksDbSortedIndexStorage.java
index afe0c9c295..3cc782171d 100644
--- 
a/modules/storage-rocksdb/src/main/java/org/apache/ignite/internal/storage/rocksdb/index/RocksDbSortedIndexStorage.java
+++ 
b/modules/storage-rocksdb/src/main/java/org/apache/ignite/internal/storage/rocksdb/index/RocksDbSortedIndexStorage.java
@@ -202,6 +202,8 @@ public class RocksDbSortedIndexStorage extends 
AbstractRocksDbIndexStorage imple
 
             private byte @Nullable [] key;
 
+            private byte @Nullable [] peekedKey = BYTE_EMPTY_ARRAY;
+
             @Override
             public void close() {
                 try {
@@ -213,21 +215,13 @@ public class RocksDbSortedIndexStorage extends 
AbstractRocksDbIndexStorage imple
 
             @Override
             public boolean hasNext() {
-                return busy(() -> {
-                    advanceIfNeeded();
-
-                    return hasNext;
-                });
+                return busy(this::advanceIfNeeded);
             }
 
             @Override
             public T next() {
                 return busy(() -> {
-                    advanceIfNeeded();
-
-                    boolean hasNext = this.hasNext;
-
-                    if (!hasNext) {
+                    if (!advanceIfNeeded()) {
                         throw new NoSuchElementException();
                     }
 
@@ -242,31 +236,19 @@ public class RocksDbSortedIndexStorage extends 
AbstractRocksDbIndexStorage imple
                 return busy(() -> {
                     throwExceptionIfStorageInProgressOfRebalance(state.get(), 
RocksDbSortedIndexStorage.this::createStorageInfo);
 
-                    if (hasNext != null) {
-                        if (hasNext) {
-                            return 
mapper.apply(ByteBuffer.wrap(key).order(KEY_BYTE_ORDER));
-                        }
-
-                        return null;
-                    }
-
-                    refreshAndPrepareRocksIterator();
-
-                    if (!it.isValid()) {
-                        RocksUtils.checkIterator(it);
+                    byte[] res = peek0();
 
+                    if (res == null) {
                         return null;
                     } else {
-                        return 
mapper.apply(ByteBuffer.wrap(it.key()).order(KEY_BYTE_ORDER));
+                        return 
mapper.apply(ByteBuffer.wrap(res).order(KEY_BYTE_ORDER));
                     }
                 });
             }
 
-            private void advanceIfNeeded() throws StorageException {
-                throwExceptionIfStorageInProgressOfRebalance(state.get(), 
RocksDbSortedIndexStorage.this::createStorageInfo);
-
+            private byte @Nullable [] peek0() {
                 if (hasNext != null) {
-                    return;
+                    return key;
                 }
 
                 refreshAndPrepareRocksIterator();
@@ -274,12 +256,23 @@ public class RocksDbSortedIndexStorage extends 
AbstractRocksDbIndexStorage imple
                 if (!it.isValid()) {
                     RocksUtils.checkIterator(it);
 
-                    hasNext = false;
+                    peekedKey = null;
                 } else {
-                    key = it.key();
-
-                    hasNext = true;
+                    peekedKey = it.key();
                 }
+
+                return peekedKey;
+            }
+
+            private boolean advanceIfNeeded() throws StorageException {
+                throwExceptionIfStorageInProgressOfRebalance(state.get(), 
RocksDbSortedIndexStorage.this::createStorageInfo);
+
+                //noinspection ArrayEquality
+                key = (peekedKey == BYTE_EMPTY_ARRAY) ? peek0() : peekedKey;
+                peekedKey = BYTE_EMPTY_ARRAY;
+
+                hasNext = key != null;
+                return hasNext;
             }
 
             private void refreshAndPrepareRocksIterator() {


Reply via email to