You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ignite.apache.org by tk...@apache.org on 2022/08/18 09:31:47 UTC

[ignite-3] branch main updated: IGNITE-17534 Introduce HashIndexStorage interface and test implementation (#1014)

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

tkalkirill 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 473a34e096 IGNITE-17534 Introduce HashIndexStorage interface and test implementation (#1014)
473a34e096 is described below

commit 473a34e0963601ff8a1fe82c2a4591944f08f6f4
Author: Alexander Polovtcev <al...@gmail.com>
AuthorDate: Thu Aug 18 12:31:43 2022 +0300

    IGNITE-17534 Introduce HashIndexStorage interface and test implementation (#1014)
---
 .../ignite/internal/schema/BinaryTupleParser.java  |  11 +-
 .../ignite/internal/storage/StorageUtils.java      |  52 -----
 .../internal/storage/engine/MvTableStorage.java    |  25 ++-
 .../storage/index/HashIndexDescriptor.java         | 135 +++++++++++++
 ...dexRowSerializer.java => HashIndexStorage.java} |  31 ++-
 .../internal/storage/index/SortedIndexStorage.java |  18 +-
 .../storage/AbstractMvTableStorageTest.java        | 101 +++++++---
 .../ConcurrentHashMapMvTableStorageTest.java       |   2 +
 .../chm/TestConcurrentHashMapMvTableStorage.java   |  61 ++++--
 .../index/AbstractHashIndexStorageTest.java        | 224 +++++++++++++++++++++
 .../index/AbstractSortedIndexStorageTest.java      |  57 ++++--
 .../storage/index/TestHashIndexStorageTest.java}   |  18 +-
 .../storage/index/TestSortedIndexStorageTest.java  |   2 +-
 .../index/impl/BinaryTupleRowDeserializer.java     |  52 -----
 .../index/impl/BinaryTupleRowSerializer.java       |  87 ++++++--
 .../storage/index/impl/TestHashIndexStorage.java   |  95 +++++++++
 .../internal/storage/index/impl/TestIndexRow.java  |  13 +-
 .../storage/index/impl/TestSortedIndexStorage.java |  14 +-
 .../pagememory/AbstractPageMemoryTableStorage.java |   6 +-
 .../storage/rocksdb/RocksDbTableStorage.java       |   6 +-
 20 files changed, 759 insertions(+), 251 deletions(-)

diff --git a/modules/schema/src/main/java/org/apache/ignite/internal/schema/BinaryTupleParser.java b/modules/schema/src/main/java/org/apache/ignite/internal/schema/BinaryTupleParser.java
index 414dce3037..d83bb53bdd 100644
--- a/modules/schema/src/main/java/org/apache/ignite/internal/schema/BinaryTupleParser.java
+++ b/modules/schema/src/main/java/org/apache/ignite/internal/schema/BinaryTupleParser.java
@@ -59,7 +59,7 @@ public class BinaryTupleParser {
     private final int valueBase;
 
     /** Binary tuple. */
-    protected final ByteBuffer buffer;
+    private final ByteBuffer buffer;
 
     /**
      * Constructor.
@@ -67,7 +67,7 @@ public class BinaryTupleParser {
      * @param numElements Number of tuple elements.
      * @param buffer Buffer with a binary tuple.
      */
-    public BinaryTupleParser(int numElements, ByteBuffer buffer) {
+    BinaryTupleParser(int numElements, ByteBuffer buffer) {
         this.numElements = numElements;
 
         assert buffer.order() == ByteOrder.LITTLE_ENDIAN;
@@ -107,6 +107,13 @@ public class BinaryTupleParser {
         return entryBase > BinaryTupleSchema.HEADER_SIZE;
     }
 
+    /**
+     * Returns the content of this tuple as a byte buffer.
+     */
+    public ByteBuffer byteBuffer() {
+        return buffer;
+    }
+
     /**
      * Locate the specified tuple element.
      *
diff --git a/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/StorageUtils.java b/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/StorageUtils.java
deleted file mode 100644
index ca45d634d3..0000000000
--- a/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/StorageUtils.java
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * 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;
-
-import java.nio.ByteBuffer;
-
-/**
- * Utility class for storages.
- */
-public class StorageUtils {
-    /**
-     * Returns byte buffer hash that matches corresponding array hash.
-     *
-     * @param buf Byte buffer.
-     */
-    public static int hashCode(ByteBuffer buf) {
-        int result = 1;
-        for (int i = buf.position(); i < buf.limit(); i++) {
-            result = 31 * result + buf.get(i);
-        }
-        return result;
-    }
-
-    /**
-     * Converts to an array of bytes.
-     *
-     * @param buf Byte buffer.
-     */
-    // TODO: IGNITE-16350 Get rid of copying byte arrays.
-    public static byte[] toByteArray(ByteBuffer buf) {
-        byte[] arr = new byte[buf.limit()];
-
-        buf.get(arr);
-
-        return arr;
-    }
-}
diff --git a/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/engine/MvTableStorage.java b/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/engine/MvTableStorage.java
index 70cdacb21c..79085da53f 100644
--- a/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/engine/MvTableStorage.java
+++ b/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/engine/MvTableStorage.java
@@ -21,6 +21,7 @@ import java.util.concurrent.CompletableFuture;
 import org.apache.ignite.configuration.schemas.table.TableConfiguration;
 import org.apache.ignite.internal.storage.MvPartitionStorage;
 import org.apache.ignite.internal.storage.StorageException;
+import org.apache.ignite.internal.storage.index.HashIndexStorage;
 import org.apache.ignite.internal.storage.index.SortedIndexStorage;
 import org.jetbrains.annotations.Nullable;
 
@@ -57,24 +58,32 @@ public interface MvTableStorage {
     CompletableFuture<Void> destroyPartition(int partitionId) throws StorageException;
 
     /**
-     * Creates an index with the given name.
+     * Returns an already created Sorted Index with the given name or creates a new one if it does not exist.
      *
-     * <p>A prerequisite for calling this method is to have the index already configured under the same name in the Table Configuration
+     * <p>In order for an index to be created, it should be already configured under the same name in the Table Configuration
      * (see {@link #configuration()}).
      *
+     * @param partitionId Partition ID for which this index has been configured.
      * @param indexName Index name.
-     * @throws StorageException if no index has been configured under the given name.
+     * @return Sorted Index storage.
+     * @throws StorageException If the given partition does not exist, or if the given index does not exist or is not configured as
+     *         a sorted index.
      */
-    void createIndex(String indexName);
+    SortedIndexStorage getOrCreateSortedIndex(int partitionId, String indexName);
 
     /**
-     * Returns an already created Sorted Index with the given name or {@code null} if it does not exist.
+     * Returns an already created Hash Index with the given name or creates a new one if it does not exist.
      *
-     * @param partitionId Partition ID for which this index has been built.
+     * <p>In order for an index to be created, it should be already configured under the same name in the Table Configuration
+     * (see {@link #configuration()}).
+     *
+     * @param partitionId Partition ID for which this index has been configured.
      * @param indexName Index name.
-     * @return Sorted Index storage or {@code null} if it does not exist..
+     * @return Hash Index storage.
+     * @throws StorageException If the given partition does not exist, or the given index does not exist or is not configured as a
+     *         hash index.
      */
-    @Nullable SortedIndexStorage getSortedIndex(int partitionId, String indexName);
+    HashIndexStorage getOrCreateHashIndex(int partitionId, String indexName);
 
     /**
      * Destroys the index under the given name and all data in it.
diff --git a/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/index/HashIndexDescriptor.java b/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/index/HashIndexDescriptor.java
new file mode 100644
index 0000000000..dd86345dbd
--- /dev/null
+++ b/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/index/HashIndexDescriptor.java
@@ -0,0 +1,135 @@
+/*
+ * 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.toUnmodifiableList;
+
+import java.util.Arrays;
+import java.util.List;
+import org.apache.ignite.configuration.schemas.table.ColumnView;
+import org.apache.ignite.configuration.schemas.table.HashIndexView;
+import org.apache.ignite.configuration.schemas.table.TableIndexView;
+import org.apache.ignite.configuration.schemas.table.TableView;
+import org.apache.ignite.internal.schema.NativeType;
+import org.apache.ignite.internal.schema.configuration.SchemaConfigurationConverter;
+import org.apache.ignite.internal.schema.configuration.SchemaDescriptorConverter;
+import org.apache.ignite.internal.storage.StorageException;
+import org.apache.ignite.internal.tostring.S;
+
+/**
+ * Descriptor for creating a Hash Index Storage.
+ *
+ * @see HashIndexStorage
+ */
+public class HashIndexDescriptor {
+    /**
+     * Descriptor of a Hash Index column.
+     */
+    public static class ColumnDescriptor {
+        private final String name;
+
+        private final NativeType type;
+
+        private final boolean nullable;
+
+        ColumnDescriptor(ColumnView tableColumnView) {
+            this.name = tableColumnView.name();
+            this.type = SchemaDescriptorConverter.convert(SchemaConfigurationConverter.convert(tableColumnView.type()));
+            this.nullable = tableColumnView.nullable();
+        }
+
+        /**
+         * Returns the name of an index column.
+         */
+        public String name() {
+            return name;
+        }
+
+        /**
+         * Returns a column type.
+         */
+        public NativeType type() {
+            return type;
+        }
+
+        /**
+         * Returns {@code true} if this column can contain null values or {@code false} otherwise.
+         */
+        public boolean nullable() {
+            return nullable;
+        }
+
+        @Override
+        public String toString() {
+            return S.toString(this);
+        }
+    }
+
+    private final String name;
+
+    private final List<ColumnDescriptor> columns;
+
+    /**
+     * Creates an Index Descriptor from a given Table Configuration.
+     *
+     * @param name index name.
+     * @param tableConfig table configuration.
+     */
+    public HashIndexDescriptor(String name, TableView tableConfig) {
+        this.name = name;
+
+        TableIndexView indexConfig = tableConfig.indices().get(name);
+
+        if (indexConfig == null) {
+            throw new StorageException(String.format("Index configuration for \"%s\" could not be found", name));
+        }
+
+        if (!(indexConfig instanceof HashIndexView)) {
+            throw new StorageException(String.format(
+                    "Index \"%s\" is not configured as a Hash Index. Actual type: %s",
+                    name, indexConfig.type()
+            ));
+        }
+
+        String[] indexColumns = ((HashIndexView) indexConfig).columnNames();
+
+        columns = Arrays.stream(indexColumns)
+                .map(columnName -> {
+                    ColumnView columnView = tableConfig.columns().get(columnName);
+
+                    assert columnView != null : "Incorrect index column configuration. " + columnName + " column does not exist";
+
+                    return new ColumnDescriptor(columnView);
+                })
+                .collect(toUnmodifiableList());
+    }
+
+    /**
+     * Returns this index' name.
+     */
+    public String name() {
+        return name;
+    }
+
+    /**
+     * Returns the Column Descriptors that comprise a row of this index.
+     */
+    public List<ColumnDescriptor> indexColumns() {
+        return columns;
+    }
+}
diff --git a/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/index/IndexRowSerializer.java b/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/index/HashIndexStorage.java
similarity index 53%
rename from modules/storage-api/src/main/java/org/apache/ignite/internal/storage/index/IndexRowSerializer.java
rename to modules/storage-api/src/main/java/org/apache/ignite/internal/storage/index/HashIndexStorage.java
index a5054b475b..b35611b5a6 100644
--- a/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/index/IndexRowSerializer.java
+++ b/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/index/HashIndexStorage.java
@@ -17,21 +17,38 @@
 
 package org.apache.ignite.internal.storage.index;
 
+import java.util.Collection;
 import org.apache.ignite.internal.schema.BinaryTuple;
 import org.apache.ignite.internal.storage.RowId;
 
 /**
- * Temporary API for creating Index rows from a list of column values. All columns must be sorted according to the index columns order,
- * specified by the {@link SortedIndexDescriptor#indexColumns()}.
+ * Storage for a Hash Index.
+ *
+ * <p>This storage serves as an unordered mapping from a subset of a table's columns (a.k.a. index columns) to a set of {@link RowId}s
+ * from a single {@link org.apache.ignite.internal.storage.MvPartitionStorage} from the same table.
+ *
+ * @see org.apache.ignite.schema.definition.index.HashIndexDefinition
  */
-public interface IndexRowSerializer {
+public interface HashIndexStorage {
+    /**
+     * Returns the Index Descriptor of this storage.
+     */
+    HashIndexDescriptor indexDescriptor();
+
+    /**
+     * Returns a collection of {@code RowId}s that correspond to the given index key.
+     */
+    Collection<RowId> get(BinaryTuple key);
+
     /**
-     * Creates an Index row from a list of column values.
+     * Adds the given index row to the index.
      */
-    IndexRow createIndexRow(Object[] columnValues, RowId rowId);
+    void put(IndexRow row);
 
     /**
-     * Creates an Prefix row from a list of column values.
+     * Removes the given row from the index.
+     *
+     * <p>Removing a non-existent row is a no-op.
      */
-    BinaryTuple createIndexRowPrefix(Object[] prefixColumnValues);
+    void remove(IndexRow row);
 }
diff --git a/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/index/SortedIndexStorage.java b/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/index/SortedIndexStorage.java
index 39a29e1917..8c9090542f 100644
--- a/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/index/SortedIndexStorage.java
+++ b/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/index/SortedIndexStorage.java
@@ -26,8 +26,8 @@ import org.jetbrains.annotations.Nullable;
 /**
  * Storage for a Sorted Index.
  *
- * <p>This storage serves as a sorted mapping from a subset of a table's columns (a.k.a. index columns) to a {@link RowId}
- * from a {@link org.apache.ignite.internal.storage.MvPartitionStorage} from the same table.
+ * <p>This storage serves as a sorted mapping from a subset of a table's columns (a.k.a. index columns) to a set of {@link RowId}s
+ * from a single {@link org.apache.ignite.internal.storage.MvPartitionStorage} from the same table.
  *
  * @see org.apache.ignite.schema.definition.index.SortedIndexDefinition
  */
@@ -55,25 +55,15 @@ public interface SortedIndexStorage {
      */
     SortedIndexDescriptor indexDescriptor();
 
-    /**
-     * Returns a factory for creating index rows for this storage.
-     */
-    IndexRowSerializer indexRowSerializer();
-
-    /**
-     * Returns a class deserializing index columns.
-     */
-    IndexRowDeserializer indexRowDeserializer();
-
     /**
      * Adds the given index row to the index.
      */
     void put(IndexRow row);
 
     /**
-     * Removes the given key from the index.
+     * Removes the given row from the index.
      *
-     * <p>Removing a non-existent key is a no-op.
+     * <p>Removing a non-existent row is a no-op.
      */
     void remove(IndexRow row);
 
diff --git a/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/AbstractMvTableStorageTest.java b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/AbstractMvTableStorageTest.java
index cf8faaaced..b173067062 100644
--- a/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/AbstractMvTableStorageTest.java
+++ b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/AbstractMvTableStorageTest.java
@@ -22,6 +22,7 @@ import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.is;
 import static org.hamcrest.Matchers.notNullValue;
 import static org.hamcrest.Matchers.nullValue;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 
 import java.util.concurrent.CompletableFuture;
 import org.apache.ignite.internal.schema.configuration.SchemaConfigurationConverter;
@@ -39,9 +40,9 @@ import org.junit.jupiter.api.Test;
 // TODO: Use this to test RocksDB-based storage, see https://issues.apache.org/jira/browse/IGNITE-17318
 // TODO: Use this to test B+tree-based storage, see https://issues.apache.org/jira/browse/IGNITE-17320
 public abstract class AbstractMvTableStorageTest {
-    private static final String SORTED_INDEX_NAME_1 = "SORTED_IDX_1";
+    private static final String SORTED_INDEX_NAME = "SORTED_IDX";
 
-    private static final String SORTED_INDEX_NAME_2 = "SORTED_IDX_2";
+    private static final String HASH_INDEX_NAME = "HASH_IDX";
 
     private MvTableStorage tableStorage;
 
@@ -82,37 +83,41 @@ public abstract class AbstractMvTableStorageTest {
 
         assertThat(tableStorage.getOrCreateMvPartition(partitionId), is(notNullValue()));
 
-        tableStorage.createIndex(SORTED_INDEX_NAME_1);
-
         assertThat(tableStorage.getMvPartition(partitionId), is(notNullValue()));
 
-        assertThat(tableStorage.getSortedIndex(partitionId, SORTED_INDEX_NAME_1), is(notNullValue()));
-
-        // Validate that destroying a partition also destroys associated indices.
         assertThat(tableStorage.destroyPartition(partitionId), willCompleteSuccessfully());
 
         assertThat(tableStorage.getMvPartition(partitionId), is(nullValue()));
-
-        assertThat(tableStorage.getSortedIndex(partitionId, SORTED_INDEX_NAME_1), is(nullValue()));
     }
 
     /**
-     * Test creating an index.
+     * Test creating a Sorted Index.
      */
     @Test
     public void testCreateSortedIndex() {
         int partitionId = 0;
 
-        assertThat(tableStorage.getSortedIndex(partitionId, SORTED_INDEX_NAME_1), is(nullValue()));
+        assertThrows(StorageException.class, () -> tableStorage.getOrCreateSortedIndex(partitionId, SORTED_INDEX_NAME));
 
-        tableStorage.createIndex(SORTED_INDEX_NAME_1);
+        // Index should only be available after the associated partition has been created.
+        tableStorage.getOrCreateMvPartition(partitionId);
 
-        assertThat(tableStorage.getSortedIndex(partitionId, SORTED_INDEX_NAME_1), is(nullValue()));
+        assertThat(tableStorage.getOrCreateSortedIndex(partitionId, SORTED_INDEX_NAME), is(notNullValue()));
+    }
+
+    /**
+     * Test creating a Hash Index.
+     */
+    @Test
+    public void testCreateHashIndex() {
+        int partitionId = 0;
+
+        assertThrows(StorageException.class, () -> tableStorage.getOrCreateHashIndex(partitionId, HASH_INDEX_NAME));
 
         // Index should only be available after the associated partition has been created.
         tableStorage.getOrCreateMvPartition(partitionId);
 
-        assertThat(tableStorage.getSortedIndex(partitionId, SORTED_INDEX_NAME_1), is(notNullValue()));
+        assertThat(tableStorage.getOrCreateHashIndex(partitionId, HASH_INDEX_NAME), is(notNullValue()));
     }
 
     /**
@@ -124,26 +129,62 @@ public abstract class AbstractMvTableStorageTest {
 
         tableStorage.getOrCreateMvPartition(partitionId);
 
-        tableStorage.createIndex(SORTED_INDEX_NAME_1);
-        tableStorage.createIndex(SORTED_INDEX_NAME_2);
+        assertThat(tableStorage.getOrCreateSortedIndex(partitionId, SORTED_INDEX_NAME), is(notNullValue()));
+        assertThat(tableStorage.getOrCreateHashIndex(partitionId, HASH_INDEX_NAME), is(notNullValue()));
 
-        assertThat(tableStorage.getSortedIndex(partitionId, SORTED_INDEX_NAME_1), is(notNullValue()));
-        assertThat(tableStorage.getSortedIndex(partitionId, SORTED_INDEX_NAME_2), is(notNullValue()));
+        assertThat(tableStorage.destroyIndex(SORTED_INDEX_NAME), willCompleteSuccessfully());
+        assertThat(tableStorage.destroyIndex(HASH_INDEX_NAME), willCompleteSuccessfully());
+    }
 
-        assertThat(tableStorage.destroyIndex(SORTED_INDEX_NAME_1), willCompleteSuccessfully());
+    /**
+     * Tests that exceptions are thrown if indices are not configured correctly.
+     */
+    @Test
+    public void testMisconfiguredIndices() {
+        int partitionId = 0;
+
+        Exception e = assertThrows(
+                StorageException.class,
+                () -> tableStorage.getOrCreateSortedIndex(partitionId, SORTED_INDEX_NAME)
+        );
+
+        assertThat(e.getMessage(), is("Partition ID " + partitionId + " does not exist"));
 
-        assertThat(tableStorage.getSortedIndex(partitionId, SORTED_INDEX_NAME_1), is(nullValue()));
-        assertThat(tableStorage.getSortedIndex(partitionId, SORTED_INDEX_NAME_2), is(notNullValue()));
+        e = assertThrows(
+                StorageException.class,
+                () -> tableStorage.getOrCreateHashIndex(partitionId, HASH_INDEX_NAME)
+        );
 
-        // Check that destroying an index twice is not an error
-        assertThat(tableStorage.destroyIndex(SORTED_INDEX_NAME_1), willCompleteSuccessfully());
+        assertThat(e.getMessage(), is("Partition ID " + partitionId + " does not exist"));
 
-        assertThat(tableStorage.getSortedIndex(partitionId, SORTED_INDEX_NAME_1), is(nullValue()));
+        tableStorage.getOrCreateMvPartition(partitionId);
+
+        e = assertThrows(
+                StorageException.class,
+                () -> tableStorage.getOrCreateHashIndex(partitionId, "INVALID_NAME")
+        );
+
+        assertThat(e.getMessage(), is("Index configuration for \"INVALID_NAME\" could not be found"));
 
-        assertThat(tableStorage.destroyIndex(SORTED_INDEX_NAME_2), willCompleteSuccessfully());
+        e = assertThrows(
+                StorageException.class,
+                () -> tableStorage.getOrCreateHashIndex(partitionId, SORTED_INDEX_NAME)
+        );
 
-        assertThat(tableStorage.getSortedIndex(partitionId, SORTED_INDEX_NAME_1), is(nullValue()));
-        assertThat(tableStorage.getSortedIndex(partitionId, SORTED_INDEX_NAME_2), is(nullValue()));
+        assertThat(
+                e.getMessage(),
+                is(String.format("Index \"%s\" is not configured as a Hash Index. Actual type: SORTED", SORTED_INDEX_NAME))
+        );
+
+        e = assertThrows(
+                StorageException.class,
+                () -> tableStorage.getOrCreateSortedIndex(partitionId, HASH_INDEX_NAME)
+        );
+
+        assertThat(
+                e.getMessage(),
+                is(String.format("Index \"%s\" is not configured as a Sorted Index. Actual type: HASH", HASH_INDEX_NAME))
+        );
     }
 
     private void createTestTable() {
@@ -154,13 +195,13 @@ public abstract class AbstractMvTableStorageTest {
                 )
                 .withPrimaryKey("ID")
                 .withIndex(
-                        SchemaBuilders.sortedIndex(SORTED_INDEX_NAME_1)
+                        SchemaBuilders.sortedIndex(SORTED_INDEX_NAME)
                                 .addIndexColumn("COLUMN0").done()
                                 .build()
                 )
                 .withIndex(
-                        SchemaBuilders.sortedIndex(SORTED_INDEX_NAME_2)
-                                .addIndexColumn("COLUMN0").done()
+                        SchemaBuilders.hashIndex(HASH_INDEX_NAME)
+                                .withColumns("COLUMN0")
                                 .build()
                 )
                 .build();
diff --git a/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/ConcurrentHashMapMvTableStorageTest.java b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/ConcurrentHashMapMvTableStorageTest.java
index c07dbce79d..a3b32fe95a 100644
--- a/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/ConcurrentHashMapMvTableStorageTest.java
+++ b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/ConcurrentHashMapMvTableStorageTest.java
@@ -17,6 +17,7 @@
 
 package org.apache.ignite.internal.storage;
 
+import org.apache.ignite.configuration.schemas.table.HashIndexConfigurationSchema;
 import org.apache.ignite.configuration.schemas.table.NullValueDefaultConfigurationSchema;
 import org.apache.ignite.configuration.schemas.table.SortedIndexConfigurationSchema;
 import org.apache.ignite.configuration.schemas.table.TableConfiguration;
@@ -38,6 +39,7 @@ public class ConcurrentHashMapMvTableStorageTest extends AbstractMvTableStorageT
             polymorphicExtensions = {
                     TestConcurrentHashMapDataStorageConfigurationSchema.class,
                     SortedIndexConfigurationSchema.class,
+                    HashIndexConfigurationSchema.class,
                     NullValueDefaultConfigurationSchema.class,
                     UnlimitedBudgetConfigurationSchema.class
             },
diff --git a/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/chm/TestConcurrentHashMapMvTableStorage.java b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/chm/TestConcurrentHashMapMvTableStorage.java
index 1f2aca83a5..65274307b2 100644
--- a/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/chm/TestConcurrentHashMapMvTableStorage.java
+++ b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/chm/TestConcurrentHashMapMvTableStorage.java
@@ -24,8 +24,11 @@ import org.apache.ignite.configuration.schemas.table.TableConfiguration;
 import org.apache.ignite.internal.storage.MvPartitionStorage;
 import org.apache.ignite.internal.storage.StorageException;
 import org.apache.ignite.internal.storage.engine.MvTableStorage;
+import org.apache.ignite.internal.storage.index.HashIndexDescriptor;
+import org.apache.ignite.internal.storage.index.HashIndexStorage;
 import org.apache.ignite.internal.storage.index.SortedIndexDescriptor;
 import org.apache.ignite.internal.storage.index.SortedIndexStorage;
+import org.apache.ignite.internal.storage.index.impl.TestHashIndexStorage;
 import org.apache.ignite.internal.storage.index.impl.TestSortedIndexStorage;
 import org.jetbrains.annotations.Nullable;
 
@@ -37,17 +40,19 @@ public class TestConcurrentHashMapMvTableStorage implements MvTableStorage {
 
     private final Map<Integer, MvPartitionStorage> partitions = new ConcurrentHashMap<>();
 
-    private final Map<String, Indices> sortedIndicesByName = new ConcurrentHashMap<>();
+    private final Map<String, SortedIndices> sortedIndicesByName = new ConcurrentHashMap<>();
+
+    private final Map<String, HashIndices> hashIndicesByName = new ConcurrentHashMap<>();
 
     /**
      * Class for storing Sorted Indices for a particular partition.
      */
-    private static class Indices {
+    private static class SortedIndices {
         private final SortedIndexDescriptor descriptor;
 
         final Map<Integer, SortedIndexStorage> storageByPartitionId = new ConcurrentHashMap<>();
 
-        Indices(SortedIndexDescriptor descriptor) {
+        SortedIndices(SortedIndexDescriptor descriptor) {
             this.descriptor = descriptor;
         }
 
@@ -56,6 +61,23 @@ public class TestConcurrentHashMapMvTableStorage implements MvTableStorage {
         }
     }
 
+    /**
+     * Class for storing Hash Indices for a particular partition.
+     */
+    private static class HashIndices {
+        private final HashIndexDescriptor descriptor;
+
+        final Map<Integer, HashIndexStorage> storageByPartitionId = new ConcurrentHashMap<>();
+
+        HashIndices(HashIndexDescriptor descriptor) {
+            this.descriptor = descriptor;
+        }
+
+        HashIndexStorage getOrCreateStorage(Integer partitionId) {
+            return storageByPartitionId.computeIfAbsent(partitionId, id -> new TestHashIndexStorage(descriptor));
+        }
+    }
+
     public TestConcurrentHashMapMvTableStorage(TableConfiguration tableCfg) {
         this.tableConfig = tableCfg;
     }
@@ -78,34 +100,43 @@ public class TestConcurrentHashMapMvTableStorage implements MvTableStorage {
         partitions.remove(boxedPartitionId);
 
         sortedIndicesByName.values().forEach(indices -> indices.storageByPartitionId.remove(boxedPartitionId));
+        hashIndicesByName.values().forEach(indices -> indices.storageByPartitionId.remove(boxedPartitionId));
 
         return CompletableFuture.completedFuture(null);
     }
 
     @Override
-    public void createIndex(String indexName) {
-        sortedIndicesByName.computeIfAbsent(indexName, name -> {
-            var descriptor = new SortedIndexDescriptor(name, tableConfig.value());
+    public SortedIndexStorage getOrCreateSortedIndex(int partitionId, String indexName) {
+        if (!partitions.containsKey(partitionId)) {
+            throw new StorageException("Partition ID " + partitionId + " does not exist");
+        }
 
-            return new Indices(descriptor);
-        });
+        SortedIndices sortedIndices = sortedIndicesByName.computeIfAbsent(
+                indexName,
+                name -> new SortedIndices(new SortedIndexDescriptor(name, tableConfig.value()))
+        );
+
+        return sortedIndices.getOrCreateStorage(partitionId);
     }
 
     @Override
-    @Nullable
-    public SortedIndexStorage getSortedIndex(int partitionId, String indexName) {
-        Indices indices = sortedIndicesByName.get(indexName);
-
-        if (indices == null || !partitions.containsKey(partitionId)) {
-            return null;
+    public HashIndexStorage getOrCreateHashIndex(int partitionId, String indexName) {
+        if (!partitions.containsKey(partitionId)) {
+            throw new StorageException("Partition ID " + partitionId + " does not exist");
         }
 
-        return indices.getOrCreateStorage(partitionId);
+        HashIndices sortedIndices = hashIndicesByName.computeIfAbsent(
+                indexName,
+                name -> new HashIndices(new HashIndexDescriptor(name, tableConfig.value()))
+        );
+
+        return sortedIndices.getOrCreateStorage(partitionId);
     }
 
     @Override
     public CompletableFuture<Void> destroyIndex(String indexName) {
         sortedIndicesByName.remove(indexName);
+        hashIndicesByName.remove(indexName);
 
         return CompletableFuture.completedFuture(null);
     }
diff --git a/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/AbstractHashIndexStorageTest.java b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/AbstractHashIndexStorageTest.java
new file mode 100644
index 0000000000..c2ef16de5b
--- /dev/null
+++ b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/AbstractHashIndexStorageTest.java
@@ -0,0 +1,224 @@
+/*
+ * 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 org.apache.ignite.internal.schema.configuration.SchemaConfigurationConverter.convert;
+import static org.apache.ignite.internal.testframework.matchers.CompletableFutureMatcher.willBe;
+import static org.apache.ignite.internal.testframework.matchers.CompletableFutureMatcher.willCompleteSuccessfully;
+import static org.apache.ignite.schema.SchemaBuilders.column;
+import static org.apache.ignite.schema.SchemaBuilders.tableBuilder;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.nullValue;
+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.concurrent.CompletableFuture;
+import org.apache.ignite.configuration.schemas.table.ConstantValueDefaultConfigurationSchema;
+import org.apache.ignite.configuration.schemas.table.FunctionCallDefaultConfigurationSchema;
+import org.apache.ignite.configuration.schemas.table.HashIndexConfigurationSchema;
+import org.apache.ignite.configuration.schemas.table.NullValueDefaultConfigurationSchema;
+import org.apache.ignite.configuration.schemas.table.TableConfiguration;
+import org.apache.ignite.configuration.schemas.table.TableView;
+import org.apache.ignite.configuration.schemas.table.UnlimitedBudgetConfigurationSchema;
+import org.apache.ignite.internal.configuration.testframework.ConfigurationExtension;
+import org.apache.ignite.internal.configuration.testframework.InjectConfiguration;
+import org.apache.ignite.internal.storage.RowId;
+import org.apache.ignite.internal.storage.chm.TestConcurrentHashMapStorageEngine;
+import org.apache.ignite.internal.storage.chm.schema.TestConcurrentHashMapDataStorageConfigurationSchema;
+import org.apache.ignite.internal.storage.index.impl.BinaryTupleRowSerializer;
+import org.apache.ignite.schema.SchemaBuilders;
+import org.apache.ignite.schema.definition.ColumnDefinition;
+import org.apache.ignite.schema.definition.ColumnType;
+import org.apache.ignite.schema.definition.TableDefinition;
+import org.apache.ignite.schema.definition.index.HashIndexDefinition;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+/**
+ * Base class for Hash Index storage tests.
+ */
+@ExtendWith(ConfigurationExtension.class)
+public abstract class AbstractHashIndexStorageTest {
+    private static final String INT_COLUMN_NAME = "intVal";
+
+    private static final String STR_COLUMN_NAME = "strVal";
+
+    private TableConfiguration tableCfg;
+
+    private HashIndexStorage indexStorage;
+
+    private BinaryTupleRowSerializer serializer;
+
+    @BeforeEach
+    void setUp(@InjectConfiguration(
+            polymorphicExtensions = {
+                    HashIndexConfigurationSchema.class,
+                    TestConcurrentHashMapDataStorageConfigurationSchema.class,
+                    ConstantValueDefaultConfigurationSchema.class,
+                    FunctionCallDefaultConfigurationSchema.class,
+                    NullValueDefaultConfigurationSchema.class,
+                    UnlimitedBudgetConfigurationSchema.class
+            },
+            // This value only required for configuration validity, it's not used otherwise.
+            value = "mock.dataStorage.name = " + TestConcurrentHashMapStorageEngine.ENGINE_NAME
+    ) TableConfiguration tableCfg) {
+        createTestTable(tableCfg);
+
+        this.tableCfg = tableCfg;
+        this.indexStorage = createIndexStorage();
+        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("test", "foo")
+                .columns(allColumns)
+                .withPrimaryKey(pkColumn.name())
+                .build();
+
+        CompletableFuture<Void> createTableFuture = tableCfg.change(cfg -> convert(tableDefinition, cfg));
+
+        assertThat(createTableFuture, willCompleteSuccessfully());
+    }
+
+    /**
+     * Creates a storage instance for testing.
+     */
+    protected abstract HashIndexStorage createIndexStorage(String name, TableView tableCfg);
+
+    /**
+     * Configures and creates a storage instance for testing.
+     */
+    private HashIndexStorage createIndexStorage() {
+        HashIndexDefinition indexDefinition = SchemaBuilders.hashIndex("hashIndex")
+                .withColumns(INT_COLUMN_NAME, STR_COLUMN_NAME)
+                .build();
+
+        CompletableFuture<Void> createIndexFuture = tableCfg.change(cfg ->
+                cfg.changeIndices(idxList ->
+                        idxList.create(indexDefinition.name(), idx -> convert(indexDefinition, idx))));
+
+        assertThat(createIndexFuture, willBe(nullValue(Void.class)));
+
+        return createIndexStorage(indexDefinition.name(), tableCfg.value());
+    }
+
+    /**
+     * Tests the {@link HashIndexStorage#get} method.
+     */
+    @Test
+    void testGet() {
+        // First two rows have the same index key, but different row IDs
+        IndexRow row1 = serializer.serializeRow(new Object[]{ 1, "foo" }, new RowId(0));
+        IndexRow row2 = serializer.serializeRow(new Object[]{ 1, "foo" }, new RowId(0));
+        IndexRow row3 = serializer.serializeRow(new Object[]{ 2, "bar" }, new RowId(0));
+
+        assertThat(indexStorage.get(row1.indexColumns()), is(empty()));
+        assertThat(indexStorage.get(row2.indexColumns()), is(empty()));
+        assertThat(indexStorage.get(row3.indexColumns()), is(empty()));
+
+        indexStorage.put(row1);
+        indexStorage.put(row2);
+        indexStorage.put(row3);
+
+        assertThat(indexStorage.get(row1.indexColumns()), containsInAnyOrder(row1.rowId(), row2.rowId()));
+        assertThat(indexStorage.get(row2.indexColumns()), containsInAnyOrder(row1.rowId(), row2.rowId()));
+        assertThat(indexStorage.get(row3.indexColumns()), contains(row3.rowId()));
+    }
+
+    /**
+     * Tests that {@link HashIndexStorage#put} does not create row ID duplicates.
+     */
+    @Test
+    void testPutIdempotence() {
+        IndexRow row = serializer.serializeRow(new Object[]{ 1, "foo" }, new RowId(0));
+
+        indexStorage.put(row);
+        indexStorage.put(row);
+
+        assertThat(indexStorage.get(row.indexColumns()), contains(row.rowId()));
+    }
+
+    /**
+     * Tests the {@link HashIndexStorage#remove} method.
+     */
+    @Test
+    void testRemove() {
+        IndexRow row1 = serializer.serializeRow(new Object[]{ 1, "foo" }, new RowId(0));
+        IndexRow row2 = serializer.serializeRow(new Object[]{ 1, "foo" }, new RowId(0));
+        IndexRow row3 = serializer.serializeRow(new Object[]{ 2, "bar" }, new RowId(0));
+
+        indexStorage.put(row1);
+        indexStorage.put(row2);
+        indexStorage.put(row3);
+
+        assertThat(indexStorage.get(row1.indexColumns()), containsInAnyOrder(row1.rowId(), row2.rowId()));
+        assertThat(indexStorage.get(row2.indexColumns()), containsInAnyOrder(row1.rowId(), row2.rowId()));
+        assertThat(indexStorage.get(row3.indexColumns()), contains(row3.rowId()));
+
+        indexStorage.remove(row1);
+
+        assertThat(indexStorage.get(row1.indexColumns()), contains(row2.rowId()));
+        assertThat(indexStorage.get(row2.indexColumns()), contains(row2.rowId()));
+        assertThat(indexStorage.get(row3.indexColumns()), contains(row3.rowId()));
+
+        indexStorage.remove(row2);
+
+        assertThat(indexStorage.get(row1.indexColumns()), is(empty()));
+        assertThat(indexStorage.get(row2.indexColumns()), is(empty()));
+        assertThat(indexStorage.get(row3.indexColumns()), contains(row3.rowId()));
+
+        indexStorage.remove(row3);
+
+        assertThat(indexStorage.get(row1.indexColumns()), is(empty()));
+        assertThat(indexStorage.get(row2.indexColumns()), is(empty()));
+        assertThat(indexStorage.get(row3.indexColumns()), is(empty()));
+    }
+
+    /**
+     * Tests that {@link HashIndexStorage#remove} works normally when removing a non-existent row.
+     */
+    @Test
+    void testRemoveIdempotence() {
+        IndexRow row = serializer.serializeRow(new Object[]{ 1, "foo" }, new RowId(0));
+
+        assertDoesNotThrow(() -> indexStorage.remove(row));
+
+        indexStorage.put(row);
+
+        indexStorage.remove(row);
+
+        assertThat(indexStorage.get(row.indexColumns()), is(empty()));
+
+        assertDoesNotThrow(() -> indexStorage.remove(row));
+    }
+}
diff --git a/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/AbstractSortedIndexStorageTest.java b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/AbstractSortedIndexStorageTest.java
index fb56151651..63d89424c9 100644
--- a/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/AbstractSortedIndexStorageTest.java
+++ b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/AbstractSortedIndexStorageTest.java
@@ -67,6 +67,7 @@ import org.apache.ignite.internal.storage.RowId;
 import org.apache.ignite.internal.storage.chm.TestConcurrentHashMapStorageEngine;
 import org.apache.ignite.internal.storage.chm.schema.TestConcurrentHashMapDataStorageConfigurationSchema;
 import org.apache.ignite.internal.storage.index.SortedIndexDescriptor.ColumnDescriptor;
+import org.apache.ignite.internal.storage.index.impl.BinaryTupleRowSerializer;
 import org.apache.ignite.internal.storage.index.impl.TestIndexRow;
 import org.apache.ignite.internal.testframework.VariableSource;
 import org.apache.ignite.internal.util.Cursor;
@@ -87,7 +88,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
 import org.junit.jupiter.params.ParameterizedTest;
 
 /**
- * Base test for MV index storages.
+ * Base class for Sorted Index storage tests.
  */
 @ExtendWith(ConfigurationExtension.class)
 public abstract class AbstractSortedIndexStorageTest {
@@ -96,7 +97,7 @@ public abstract class AbstractSortedIndexStorageTest {
     /** Definitions of all supported column types. */
     public static final List<ColumnDefinition> ALL_TYPES_COLUMN_DEFINITIONS = allTypesColumnDefinitions();
 
-    protected TableConfiguration tableCfg;
+    private TableConfiguration tableCfg;
 
     private Random random;
 
@@ -170,7 +171,7 @@ public abstract class AbstractSortedIndexStorageTest {
     }
 
     /**
-     * Creates a storage instanc efor testing.
+     * Creates a storage instance for testing.
      */
     protected abstract SortedIndexStorage createIndexStorage(String name, TableView tableCfg);
 
@@ -222,9 +223,11 @@ public abstract class AbstractSortedIndexStorageTest {
                 .map(type -> SchemaTestUtils.generateRandomValue(random, type))
                 .toArray();
 
-        IndexRow row = indexStorage.indexRowSerializer().createIndexRow(columns, new RowId(0));
+        var serializer = new BinaryTupleRowSerializer(indexStorage.indexDescriptor());
 
-        Object[] actual = indexStorage.indexRowDeserializer().deserializeColumns(row);
+        IndexRow row = serializer.serializeRow(columns, new RowId(0));
+
+        Object[] actual = serializer.deserializeColumns(row);
 
         assertThat(actual, is(equalTo(columns)));
     }
@@ -265,7 +268,9 @@ public abstract class AbstractSortedIndexStorageTest {
         var columnValues = new Object[] { "foo", 1 };
         var rowId = new RowId(0);
 
-        IndexRow row = index.indexRowSerializer().createIndexRow(columnValues, rowId);
+        var serializer = new BinaryTupleRowSerializer(index.indexDescriptor());
+
+        IndexRow row = serializer.serializeRow(columnValues, rowId);
 
         index.put(row);
         index.put(row);
@@ -298,9 +303,11 @@ public abstract class AbstractSortedIndexStorageTest {
         var rowId2 = new RowId(0);
         var rowId3 = new RowId(0);
 
-        IndexRow row1 = index.indexRowSerializer().createIndexRow(columnValues1, rowId1);
-        IndexRow row2 = index.indexRowSerializer().createIndexRow(columnValues1, rowId2);
-        IndexRow row3 = index.indexRowSerializer().createIndexRow(columnValues2, rowId3);
+        var serializer = new BinaryTupleRowSerializer(index.indexDescriptor());
+
+        IndexRow row1 = serializer.serializeRow(columnValues1, rowId1);
+        IndexRow row2 = serializer.serializeRow(columnValues1, rowId2);
+        IndexRow row3 = serializer.serializeRow(columnValues2, rowId3);
 
         index.put(row1);
         index.put(row2);
@@ -334,9 +341,11 @@ public abstract class AbstractSortedIndexStorageTest {
         var rowId2 = new RowId(0);
         var rowId3 = new RowId(0);
 
-        IndexRow row1 = index.indexRowSerializer().createIndexRow(columnValues1, rowId1);
-        IndexRow row2 = index.indexRowSerializer().createIndexRow(columnValues1, rowId2);
-        IndexRow row3 = index.indexRowSerializer().createIndexRow(columnValues2, rowId3);
+        var serializer = new BinaryTupleRowSerializer(index.indexDescriptor());
+
+        IndexRow row1 = serializer.serializeRow(columnValues1, rowId1);
+        IndexRow row2 = serializer.serializeRow(columnValues1, rowId2);
+        IndexRow row3 = serializer.serializeRow(columnValues2, rowId3);
 
         index.put(row1);
         index.put(row2);
@@ -442,12 +451,12 @@ public abstract class AbstractSortedIndexStorageTest {
         Object[] val8020 = { "20", 80 };
 
         for (SortedIndexStorage index : Arrays.asList(index1, index2)) {
-            IndexRowSerializer serializer = index.indexRowSerializer();
+            var serializer = new BinaryTupleRowSerializer(index.indexDescriptor());
 
-            index.put(serializer.createIndexRow(val9010, new RowId(0)));
-            index.put(serializer.createIndexRow(val8010, new RowId(0)));
-            index.put(serializer.createIndexRow(val9020, new RowId(0)));
-            index.put(serializer.createIndexRow(val8020, new RowId(0)));
+            index.put(serializer.serializeRow(val9010, new RowId(0)));
+            index.put(serializer.serializeRow(val8010, new RowId(0)));
+            index.put(serializer.serializeRow(val9020, new RowId(0)));
+            index.put(serializer.serializeRow(val8020, new RowId(0)));
         }
 
         // Test without bounds.
@@ -591,9 +600,11 @@ public abstract class AbstractSortedIndexStorageTest {
 
         Object[] nullArray = new Object[storage.indexDescriptor().indexColumns().size()];
 
-        IndexRow nullRow = storage.indexRowSerializer().createIndexRow(nullArray, new RowId(0));
+        var serializer = new BinaryTupleRowSerializer(storage.indexDescriptor());
+
+        IndexRow nullRow = serializer.serializeRow(nullArray, new RowId(0));
 
-        TestIndexRow entry2 = new TestIndexRow(storage, nullRow, nullArray);
+        var entry2 = new TestIndexRow(storage, serializer, nullRow, nullArray);
 
         storage.put(entry1);
         storage.put(entry2);
@@ -688,7 +699,9 @@ public abstract class AbstractSortedIndexStorageTest {
     }
 
     private static BinaryTuple prefix(SortedIndexStorage index, Object... vals) {
-        return index.indexRowSerializer().createIndexRowPrefix(vals);
+        var serializer = new BinaryTupleRowSerializer(index.indexDescriptor());
+
+        return serializer.serializeRowPrefix(vals);
     }
 
     private static List<Object[]> scan(
@@ -697,11 +710,11 @@ public abstract class AbstractSortedIndexStorageTest {
             @Nullable BinaryTuple upperBound,
             @MagicConstant(flagsFromClass = SortedIndexStorage.class) int flags
     ) throws Exception {
-        IndexRowDeserializer deserializer = index.indexRowDeserializer();
+        var serializer = new BinaryTupleRowSerializer(index.indexDescriptor());
 
         try (Cursor<IndexRow> cursor = index.scan(lowerBound, upperBound, flags)) {
             return cursor.stream()
-                    .map(deserializer::deserializeColumns)
+                    .map(serializer::deserializeColumns)
                     .collect(toUnmodifiableList());
         }
     }
diff --git a/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/index/IndexRowDeserializer.java b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/TestHashIndexStorageTest.java
similarity index 65%
rename from modules/storage-api/src/main/java/org/apache/ignite/internal/storage/index/IndexRowDeserializer.java
rename to modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/TestHashIndexStorageTest.java
index 57cf83f716..929ea55f63 100644
--- a/modules/storage-api/src/main/java/org/apache/ignite/internal/storage/index/IndexRowDeserializer.java
+++ b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/TestHashIndexStorageTest.java
@@ -17,15 +17,15 @@
 
 package org.apache.ignite.internal.storage.index;
 
+import org.apache.ignite.configuration.schemas.table.TableView;
+import org.apache.ignite.internal.storage.index.impl.TestHashIndexStorage;
+
 /**
- * Class for extracting indexed column values from an {@link IndexRow}.
+ * Class for testing the {@link HashIndexStorage}.
  */
-public interface IndexRowDeserializer {
-    /**
-     * De-serializes column values that were used to create the index.
-     *
-     * @param indexRow Index row.
-     * @return Values of the indexed columns.
-     */
-    Object[] deserializeColumns(IndexRow indexRow);
+public class TestHashIndexStorageTest extends AbstractHashIndexStorageTest {
+    @Override
+    protected HashIndexStorage createIndexStorage(String name, TableView tableCfg) {
+        return new TestHashIndexStorage(new HashIndexDescriptor(name, tableCfg));
+    }
 }
diff --git a/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/TestSortedIndexStorageTest.java b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/TestSortedIndexStorageTest.java
index 74b6415351..bf9e191855 100644
--- a/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/TestSortedIndexStorageTest.java
+++ b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/TestSortedIndexStorageTest.java
@@ -21,7 +21,7 @@ import org.apache.ignite.configuration.schemas.table.TableView;
 import org.apache.ignite.internal.storage.index.impl.TestSortedIndexStorage;
 
 /**
- * MV sorted index storage test implementation for {@link TestSortedIndexStorage} class.
+ * Class for testing the {@link TestSortedIndexStorage}.
  */
 public class TestSortedIndexStorageTest extends AbstractSortedIndexStorageTest {
     @Override
diff --git a/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/impl/BinaryTupleRowDeserializer.java b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/impl/BinaryTupleRowDeserializer.java
deleted file mode 100644
index 3e86c9abbe..0000000000
--- a/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/impl/BinaryTupleRowDeserializer.java
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * 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.impl;
-
-import org.apache.ignite.internal.schema.BinaryTuple;
-import org.apache.ignite.internal.schema.NativeTypeSpec;
-import org.apache.ignite.internal.storage.index.IndexRow;
-import org.apache.ignite.internal.storage.index.IndexRowDeserializer;
-import org.apache.ignite.internal.storage.index.SortedIndexDescriptor;
-
-/**
- * {@link IndexRowDeserializer} implementation that uses {@link BinaryTuple} infrastructure for deserialization purposes.
- */
-class BinaryTupleRowDeserializer implements IndexRowDeserializer {
-    private final SortedIndexDescriptor descriptor;
-
-    BinaryTupleRowDeserializer(SortedIndexDescriptor descriptor) {
-        this.descriptor = descriptor;
-    }
-
-    @Override
-    public Object[] deserializeColumns(IndexRow indexRow) {
-        BinaryTuple tuple = indexRow.indexColumns();
-
-        assert tuple.count() == descriptor.indexColumns().size();
-
-        var result = new Object[descriptor.indexColumns().size()];
-
-        for (int i = 0; i < result.length; i++) {
-            NativeTypeSpec typeSpec = descriptor.indexColumns().get(i).type().spec();
-
-            result[i] = typeSpec.objectValue(tuple, i);
-        }
-
-        return result;
-    }
-}
diff --git a/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/impl/BinaryTupleRowSerializer.java b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/impl/BinaryTupleRowSerializer.java
index 96b382f6ae..39d62f7fac 100644
--- a/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/impl/BinaryTupleRowSerializer.java
+++ b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/impl/BinaryTupleRowSerializer.java
@@ -17,51 +17,85 @@
 
 package org.apache.ignite.internal.storage.index.impl;
 
+import static java.util.stream.Collectors.toUnmodifiableList;
+
+import java.util.List;
 import org.apache.ignite.internal.schema.BinaryTuple;
 import org.apache.ignite.internal.schema.BinaryTupleBuilder;
 import org.apache.ignite.internal.schema.BinaryTupleSchema;
 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.IndexRow;
-import org.apache.ignite.internal.storage.index.IndexRowSerializer;
 import org.apache.ignite.internal.storage.index.SortedIndexDescriptor;
 
 /**
- * {@link IndexRowSerializer} implementation that uses {@link BinaryTuple} as the index keys serialization mechanism.
+ * Class for converting an array of objects into a {@link BinaryTuple} and vice-versa using a given index schema.
  */
-class BinaryTupleRowSerializer implements IndexRowSerializer {
-    private final SortedIndexDescriptor descriptor;
+public class BinaryTupleRowSerializer {
+    private static class ColumnDescriptor {
+        final NativeType type;
+
+        final boolean nullable;
+
+        ColumnDescriptor(NativeType type, boolean nullable) {
+            this.type = type;
+            this.nullable = nullable;
+        }
+    }
+
+    private final List<ColumnDescriptor> schema;
+
+    /**
+     * Creates a new instance for a Sorted Index.
+     */
+    public BinaryTupleRowSerializer(SortedIndexDescriptor descriptor) {
+        this.schema = descriptor.indexColumns().stream()
+                .map(colDesc -> new ColumnDescriptor(colDesc.type(), colDesc.nullable()))
+                .collect(toUnmodifiableList());
+    }
 
-    BinaryTupleRowSerializer(SortedIndexDescriptor descriptor) {
-        this.descriptor = descriptor;
+    /**
+     * Creates a new instance for a Hash Index.
+     */
+    public BinaryTupleRowSerializer(HashIndexDescriptor descriptor) {
+        this.schema = descriptor.indexColumns().stream()
+                .map(colDesc -> new ColumnDescriptor(colDesc.type(), colDesc.nullable()))
+                .collect(toUnmodifiableList());
     }
 
-    @Override
-    public IndexRow createIndexRow(Object[] columnValues, RowId rowId) {
-        if (columnValues.length != descriptor.indexColumns().size()) {
+    /**
+     * Creates an {@link IndexRow} from the given index columns and a Row ID.
+     */
+    public IndexRow serializeRow(Object[] columnValues, RowId rowId) {
+        if (columnValues.length != schema.size()) {
             throw new IllegalArgumentException(String.format(
                     "Incorrect number of column values passed. Expected %d, got %d",
-                    descriptor.indexColumns().size(),
+                    schema.size(),
                     columnValues.length
             ));
         }
 
-        return new IndexRowImpl(createIndexRowPrefix(columnValues), rowId);
+        return new IndexRowImpl(serializeRowPrefix(columnValues), rowId);
     }
 
-    @Override
-    public BinaryTuple createIndexRowPrefix(Object[] prefixColumnValues) {
-        if (prefixColumnValues.length > descriptor.indexColumns().size()) {
+    /**
+     * Creates a prefix of an {@link IndexRow} using the provided columns.
+     */
+    public BinaryTuple serializeRowPrefix(Object[] prefixColumnValues) {
+        if (prefixColumnValues.length > schema.size()) {
             throw new IllegalArgumentException(String.format(
                     "Incorrect number of column values passed. Expected not more than %d, got %d",
-                    descriptor.indexColumns().size(),
+                    schema.size(),
                     prefixColumnValues.length
             ));
         }
 
-        Element[] prefixElements = descriptor.indexColumns().stream()
+        Element[] prefixElements = schema.stream()
                 .limit(prefixColumnValues.length)
-                .map(columnDescriptor -> new Element(columnDescriptor.type(), columnDescriptor.nullable()))
+                .map(columnDescriptor -> new Element(columnDescriptor.type, columnDescriptor.nullable))
                 .toArray(Element[]::new);
 
         BinaryTupleSchema prefixSchema = BinaryTupleSchema.create(prefixElements);
@@ -74,4 +108,23 @@ class BinaryTupleRowSerializer implements IndexRowSerializer {
 
         return new BinaryTuple(prefixSchema, builder.build());
     }
+
+    /**
+     * Converts a byte representation of index columns back into Java objects.
+     */
+    public Object[] deserializeColumns(IndexRow indexRow) {
+        BinaryTuple tuple = indexRow.indexColumns();
+
+        assert tuple.count() == schema.size();
+
+        var result = new Object[schema.size()];
+
+        for (int i = 0; i < result.length; i++) {
+            NativeTypeSpec typeSpec = schema.get(i).type.spec();
+
+            result[i] = typeSpec.objectValue(tuple, i);
+        }
+
+        return result;
+    }
 }
diff --git a/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/impl/TestHashIndexStorage.java b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/impl/TestHashIndexStorage.java
new file mode 100644
index 0000000000..8eefb36732
--- /dev/null
+++ b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/impl/TestHashIndexStorage.java
@@ -0,0 +1,95 @@
+/*
+ * 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.impl;
+
+import static org.apache.ignite.internal.util.IgniteUtils.capacity;
+
+import java.nio.ByteBuffer;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+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;
+
+/**
+ * Test-only implementation of a {@link HashIndexStorage}.
+ */
+public class TestHashIndexStorage implements HashIndexStorage {
+    private final ConcurrentMap<ByteBuffer, Set<RowId>> index = new ConcurrentHashMap<>();
+
+    private final HashIndexDescriptor descriptor;
+
+    /**
+     * Constructor.
+     */
+    public TestHashIndexStorage(HashIndexDescriptor descriptor) {
+        this.descriptor = descriptor;
+    }
+
+    @Override
+    public HashIndexDescriptor indexDescriptor() {
+        return descriptor;
+    }
+
+    @Override
+    public Collection<RowId> get(BinaryTuple key) {
+        return index.getOrDefault(key.byteBuffer(), Set.of());
+    }
+
+    @Override
+    public void put(IndexRow row) {
+        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));
+
+                result.addAll(v);
+                result.add(row.rowId());
+
+                return result;
+            }
+        });
+    }
+
+    @Override
+    public void remove(IndexRow row) {
+        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;
+            }
+        });
+    }
+}
diff --git a/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/impl/TestIndexRow.java b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/impl/TestIndexRow.java
index a8e1d21e27..a906c546b0 100644
--- a/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/impl/TestIndexRow.java
+++ b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/impl/TestIndexRow.java
@@ -48,9 +48,12 @@ public class TestIndexRow implements IndexRow, Comparable<TestIndexRow> {
 
     private final SortedIndexStorage indexStorage;
 
+    private final BinaryTupleRowSerializer serializer;
+
     /** Constructor. */
-    public TestIndexRow(SortedIndexStorage storage, IndexRow row, Object[] columns) {
+    public TestIndexRow(SortedIndexStorage storage, BinaryTupleRowSerializer serializer, IndexRow row, Object[] columns) {
         this.indexStorage = storage;
+        this.serializer = serializer;
         this.row = row;
         this.columns = columns;
     }
@@ -68,16 +71,18 @@ public class TestIndexRow implements IndexRow, Comparable<TestIndexRow> {
 
         var rowId = new RowId(0);
 
-        IndexRow row = indexStorage.indexRowSerializer().createIndexRow(columns, rowId);
+        var serializer = new BinaryTupleRowSerializer(indexStorage.indexDescriptor());
+
+        IndexRow row = serializer.serializeRow(columns, rowId);
 
-        return new TestIndexRow(indexStorage, row, columns);
+        return new TestIndexRow(indexStorage, serializer, row, columns);
     }
 
     /**
      * Creates an Index Key prefix of the given length.
      */
     public BinaryTuple prefix(int length) {
-        return indexStorage.indexRowSerializer().createIndexRowPrefix(Arrays.copyOf(columns, length));
+        return serializer.serializeRowPrefix(Arrays.copyOf(columns, length));
     }
 
     @Override
diff --git a/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/impl/TestSortedIndexStorage.java b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/impl/TestSortedIndexStorage.java
index 16881717e4..56d8949c65 100644
--- a/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/impl/TestSortedIndexStorage.java
+++ b/modules/storage-api/src/test/java/org/apache/ignite/internal/storage/index/impl/TestSortedIndexStorage.java
@@ -29,8 +29,6 @@ import java.util.function.ToIntFunction;
 import org.apache.ignite.internal.schema.BinaryTuple;
 import org.apache.ignite.internal.storage.RowId;
 import org.apache.ignite.internal.storage.index.IndexRow;
-import org.apache.ignite.internal.storage.index.IndexRowDeserializer;
-import org.apache.ignite.internal.storage.index.IndexRowSerializer;
 import org.apache.ignite.internal.storage.index.SortedIndexDescriptor;
 import org.apache.ignite.internal.storage.index.SortedIndexStorage;
 import org.apache.ignite.internal.util.Cursor;
@@ -57,21 +55,13 @@ public class TestSortedIndexStorage implements SortedIndexStorage {
         return descriptor;
     }
 
-    @Override
-    public IndexRowSerializer indexRowSerializer() {
-        return new BinaryTupleRowSerializer(descriptor);
-    }
-
-    @Override
-    public IndexRowDeserializer indexRowDeserializer() {
-        return new BinaryTupleRowDeserializer(descriptor);
-    }
-
     @Override
     public void put(IndexRow row) {
         index.compute(row.indexColumns(), (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));
 
diff --git a/modules/storage-page-memory/src/main/java/org/apache/ignite/internal/storage/pagememory/AbstractPageMemoryTableStorage.java b/modules/storage-page-memory/src/main/java/org/apache/ignite/internal/storage/pagememory/AbstractPageMemoryTableStorage.java
index e257ead814..fa9bc4d1f6 100644
--- a/modules/storage-page-memory/src/main/java/org/apache/ignite/internal/storage/pagememory/AbstractPageMemoryTableStorage.java
+++ b/modules/storage-page-memory/src/main/java/org/apache/ignite/internal/storage/pagememory/AbstractPageMemoryTableStorage.java
@@ -29,6 +29,7 @@ import org.apache.ignite.internal.pagememory.PageMemory;
 import org.apache.ignite.internal.storage.MvPartitionStorage;
 import org.apache.ignite.internal.storage.StorageException;
 import org.apache.ignite.internal.storage.engine.MvTableStorage;
+import org.apache.ignite.internal.storage.index.HashIndexStorage;
 import org.apache.ignite.internal.storage.index.SortedIndexStorage;
 import org.apache.ignite.internal.storage.pagememory.mv.AbstractPageMemoryMvPartitionStorage;
 import org.apache.ignite.internal.tostring.S;
@@ -141,13 +142,12 @@ public abstract class AbstractPageMemoryTableStorage implements MvTableStorage {
 
     /** {@inheritDoc} */
     @Override
-    public void createIndex(String indexName) {
+    public SortedIndexStorage getOrCreateSortedIndex(int partitionId, String indexName) {
         throw new UnsupportedOperationException("Not implemented yet");
     }
 
-    /** {@inheritDoc} */
     @Override
-    public @Nullable SortedIndexStorage getSortedIndex(int partitionId, String indexName) {
+    public HashIndexStorage getOrCreateHashIndex(int partitionId, String indexName) {
         throw new UnsupportedOperationException("Not implemented yet");
     }
 
diff --git a/modules/storage-rocksdb/src/main/java/org/apache/ignite/internal/storage/rocksdb/RocksDbTableStorage.java b/modules/storage-rocksdb/src/main/java/org/apache/ignite/internal/storage/rocksdb/RocksDbTableStorage.java
index ca6207feb0..d0b03e1b1a 100644
--- a/modules/storage-rocksdb/src/main/java/org/apache/ignite/internal/storage/rocksdb/RocksDbTableStorage.java
+++ b/modules/storage-rocksdb/src/main/java/org/apache/ignite/internal/storage/rocksdb/RocksDbTableStorage.java
@@ -45,6 +45,7 @@ import org.apache.ignite.internal.rocksdb.ColumnFamily;
 import org.apache.ignite.internal.storage.MvPartitionStorage;
 import org.apache.ignite.internal.storage.StorageException;
 import org.apache.ignite.internal.storage.engine.MvTableStorage;
+import org.apache.ignite.internal.storage.index.HashIndexStorage;
 import org.apache.ignite.internal.storage.index.SortedIndexStorage;
 import org.apache.ignite.internal.tostring.S;
 import org.apache.ignite.internal.util.IgniteSpinBusyLock;
@@ -416,13 +417,12 @@ class RocksDbTableStorage implements MvTableStorage {
 
     /** {@inheritDoc} */
     @Override
-    public void createIndex(String indexName) {
+    public SortedIndexStorage getOrCreateSortedIndex(int partitionId, String indexName) {
         throw new UnsupportedOperationException("Not implemented yet");
     }
 
-    /** {@inheritDoc} */
     @Override
-    public @Nullable SortedIndexStorage getSortedIndex(int partitionId, String indexName) {
+    public HashIndexStorage getOrCreateHashIndex(int partitionId, String indexName) {
         throw new UnsupportedOperationException("Not implemented yet");
     }