This is an automated email from the ASF dual-hosted git repository.
PragmaTwice pushed a commit to branch unstable
in repository https://gitbox.apache.org/repos/asf/kvrocks.git
The following commit(s) were added to refs/heads/unstable by this push:
new 65518a761 feat(kvrocks2redis): support HFE in full export and
migration WAL (#3644)
65518a761 is described below
commit 65518a761dc384e8dd1956b87704df2229ae5ebf
Author: Twice <[email protected]>
AuthorDate: Sat Sep 26 15:44:09 2026 +0800
feat(kvrocks2redis): support HFE in full export and migration WAL (#3644)
Adds HFE-aware full export, skipping expired fields and restoring live
field expirations with `HPEXPIREAT`.
Also preserves per-key hash encoding in slot-migration snapshot WAL
using the existing `2` / `2 13 1` LogData format; generic rebatching
(#3645) and the existing RedisWriter startup race remain out of scope.
Refs #3436.
Assisted-by: Codex
---
CMakeLists.txt | 12 +-
src/cluster/slot_migrate.cc | 5 +
tests/cppunit/kvrocks2redis_parser_test.cc | 135 +++++++++++++++++++++
.../slotmigrate/hash_field_expiration_test.go | 70 +++++++++++
utils/kvrocks2redis/README.md | 8 +-
utils/kvrocks2redis/parser.cc | 44 +++++--
utils/kvrocks2redis/parser.h | 2 +-
7 files changed, 257 insertions(+), 19 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index b074b52a8..bbcfe7dc5 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -312,13 +312,17 @@ target_link_libraries(kvrocks PRIVATE kvrocks_objs
${EXTERNAL_LIBS})
# kvrocks2redis sync tool
file(GLOB KVROCKS2REDIS_SRCS utils/kvrocks2redis/*.cc)
-add_executable(kvrocks2redis ${KVROCKS2REDIS_SRCS})
+list(FILTER KVROCKS2REDIS_SRCS EXCLUDE REGEX utils/kvrocks2redis/main.cc)
-target_link_libraries(kvrocks2redis PRIVATE kvrocks_objs ${EXTERNAL_LIBS})
+add_library(kvrocks2redis_objs OBJECT ${KVROCKS2REDIS_SRCS})
+target_link_libraries(kvrocks2redis_objs PUBLIC kvrocks_objs ${EXTERNAL_LIBS})
+
+add_executable(kvrocks2redis utils/kvrocks2redis/main.cc)
+target_link_libraries(kvrocks2redis PRIVATE kvrocks_objs kvrocks2redis_objs
${EXTERNAL_LIBS})
# kvrocks unit tests
file(GLOB_RECURSE TESTS_SRCS tests/cppunit/*.cc)
add_executable(unittest ${TESTS_SRCS})
-target_include_directories(unittest PRIVATE tests/cppunit)
+target_include_directories(unittest PRIVATE tests/cppunit utils)
-target_link_libraries(unittest PRIVATE kvrocks_objs gtest_main gmock
${EXTERNAL_LIBS})
+target_link_libraries(unittest PRIVATE kvrocks_objs kvrocks2redis_objs
gtest_main gmock ${EXTERNAL_LIBS})
diff --git a/src/cluster/slot_migrate.cc b/src/cluster/slot_migrate.cc
index 28907306b..66228058b 100644
--- a/src/cluster/slot_migrate.cc
+++ b/src/cluster/slot_migrate.cc
@@ -595,6 +595,11 @@ Status SlotMigrator::sendSnapshot() {
if (redis_type == RedisType::kRedisList) {
redis::WriteBatchLogData batch_log_data(redis_type,
{std::to_string(RedisCommand::kRedisCmdRPush)});
log_data = batch_log_data.Encode();
+ } else if (redis_type == RedisType::kRedisHash) {
+ HashMetadata metadata(false);
+ auto s = metadata.Decode(iter.Value());
+ if (!s.ok()) return {Status::NotOK, s.ToString()};
+ log_data = redis::WriteBatchLogData(metadata.mode).Encode();
} else {
redis::WriteBatchLogData batch_log_data(redis_type);
log_data = batch_log_data.Encode();
diff --git a/tests/cppunit/kvrocks2redis_parser_test.cc
b/tests/cppunit/kvrocks2redis_parser_test.cc
new file mode 100644
index 000000000..3183ae9e5
--- /dev/null
+++ b/tests/cppunit/kvrocks2redis_parser_test.cc
@@ -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.
+ */
+
+#include <algorithm>
+#include <tuple>
+
+#include "kvrocks2redis/parser.h"
+#include "server/redis_reply.h"
+#include "test_base.h"
+
+class RecordingWriter : public Writer {
+ public:
+ RecordingWriter() : Writer(nullptr) {}
+ Status Write(const std::string &ns, const std::vector<std::string> &aofs)
override {
+ if (fail) return {Status::NotOK, "test writer failure"};
+ auto &output = commands[ns];
+ output.insert(output.end(), aofs.begin(), aofs.end());
+ return Status::OK();
+ }
+ std::map<std::string, std::vector<std::string>> commands;
+ bool fail = false;
+};
+
+class Kvrocks2RedisParserTest : public TestFixture, public
::testing::TestWithParam<std::tuple<bool, bool>> {
+ protected:
+ void SetUp() override { config_.slot_id_encoded = std::get<0>(GetParam()); }
+
+ using Field = std::tuple<std::string, std::string, uint64_t>;
+ void putHash(const std::string &ns, const std::string &key,
HashSubkeyEncodingMode mode,
+ const std::vector<Field> &fields, uint64_t key_expire = 0) {
+ HashMetadata metadata(false, mode);
+ metadata.flags = kRedisHash | (std::get<1>(GetParam()) ?
METADATA_64BIT_ENCODING_MASK : 0);
+ metadata.version = 42;
+ metadata.expire = key_expire;
+ metadata.size = fields.size();
+ auto ns_key = ComposeNamespaceKey(ns, key, storage_->IsSlotIdEncoded());
+ rocksdb::WriteBatch batch;
+ for (const auto &[field, value, expire] : fields) {
+ if (expire == 0) {
+ ++metadata.persist;
+ } else {
+ metadata.lower = metadata.lower == 0 ? expire :
std::min(metadata.lower, expire);
+ metadata.upper = std::max(metadata.upper, expire);
+ }
+ auto subkey = InternalKey(ns_key, field, metadata.version,
storage_->IsSlotIdEncoded()).Encode();
+ ASSERT_TRUE(batch.Put(subkey, metadata.EncodeSubkeyValue(value,
expire)).ok());
+ }
+ std::string bytes;
+ metadata.Encode(&bytes);
+ ASSERT_TRUE(batch.Put(storage_->GetCFHandle(ColumnFamilyID::Metadata),
ns_key, bytes).ok());
+ ASSERT_TRUE(storage_->GetDB()->Write(storage_->DefaultWriteOptions(),
&batch).ok());
+ }
+};
+
+TEST_P(Kvrocks2RedisParserTest,
FullExportUsesPerKeyEncodingAndAbsoluteExpiration) {
+ const auto hfe = HashSubkeyEncodingMode::kFieldExpiration;
+ const auto legacy = HashSubkeyEncodingMode::kLegacy;
+ const uint64_t field_expire = 4000000000123ULL;
+ const uint64_t key_expire = 4000000060456ULL;
+ const std::string binary("value\0\xff", 7);
+ auto legacy_value = HashMetadata(false, hfe).EncodeSubkeyValue(binary,
field_expire);
+ putHash("ns", "a{hash}", legacy, {{"field", legacy_value, 0}});
+ putHash("ns", "b{hash}", hfe,
+ {{"a-binary", binary, 0}, {"b-empty", "", 0}, {"c-expired", "gone",
1}, {"d-live", "live", field_expire}},
+ key_expire);
+ putHash("ns", "c{hash}", hfe, {{"field", "expired-key", 0}}, 1);
+ putHash("ns", "d{hash}", hfe, {{"field", "expired-field", 1}});
+ putHash("other", "b{hash}", legacy, {{"field", legacy_value, 0}});
+
+ // Ignore subkeys from an obsolete version, even when their payload is not
valid HFE encoding.
+ auto stale = InternalKey(ComposeNamespaceKey("ns", "b{hash}",
storage_->IsSlotIdEncoded()), "old", 41,
+ storage_->IsSlotIdEncoded())
+ .Encode();
+ ASSERT_TRUE(storage_->GetDB()->Put(storage_->DefaultWriteOptions(), stale,
"stale").ok());
+
+ for (auto default_mode : {legacy, hfe}) {
+ config_.hash_encoding_mode = default_mode;
+ RecordingWriter writer;
+ Parser parser(storage_.get(), &writer);
+ ASSERT_TRUE(parser.ParseFullDB().IsOK());
+ auto stored_key_expire = std::get<1>(GetParam()) ? key_expire :
Metadata::ExpireMsToS(key_expire) * 1000;
+ std::vector<std::string> expected = {
+ redis::ArrayOfBulkStrings({"HSET", "a{hash}", "field", legacy_value}),
+ redis::ArrayOfBulkStrings({"HSET", "b{hash}", "a-binary", binary}),
+ redis::ArrayOfBulkStrings({"HSET", "b{hash}", "b-empty", ""}),
+ redis::ArrayOfBulkStrings({"HSET", "b{hash}", "d-live", "live"}),
+ redis::ArrayOfBulkStrings({"HPEXPIREAT", "b{hash}",
std::to_string(field_expire), "FIELDS", "1", "d-live"}),
+ redis::ArrayOfBulkStrings({"EXPIREAT", "b{hash}",
std::to_string(stored_key_expire / 1000)})};
+ ASSERT_EQ(writer.commands.size(), 2);
+ EXPECT_EQ(writer.commands.at("ns"), expected);
+ EXPECT_EQ(writer.commands.at("other"),
+ std::vector<std::string>{redis::ArrayOfBulkStrings({"HSET",
"b{hash}", "field", legacy_value})});
+ }
+}
+
+TEST_P(Kvrocks2RedisParserTest, InvalidHashDataAndWriterErrorsAreNotIgnored) {
+ putHash("ns", "hash", HashSubkeyEncodingMode::kFieldExpiration, {{"field",
"value", 0}});
+ RecordingWriter writer;
+ Parser parser(storage_.get(), &writer);
+ writer.fail = true;
+ EXPECT_FALSE(parser.ParseFullDB().IsOK());
+ writer.fail = false;
+
+ auto ns_key = ComposeNamespaceKey("ns", "hash", storage_->IsSlotIdEncoded());
+ auto key = InternalKey(ns_key, "field", 42,
storage_->IsSlotIdEncoded()).Encode();
+ ASSERT_TRUE(storage_->GetDB()->Put(storage_->DefaultWriteOptions(), key,
std::string(7, '\0')).ok());
+ EXPECT_FALSE(parser.ParseFullDB().IsOK());
+ EXPECT_TRUE(writer.commands.empty());
+
+ std::string metadata;
+ auto *cf = storage_->GetCFHandle(ColumnFamilyID::Metadata);
+ ASSERT_TRUE(storage_->GetDB()->Get(storage_->DefaultScanOptions(), cf,
ns_key, &metadata).ok());
+ metadata.resize(metadata.size() - 1);
+ ASSERT_TRUE(storage_->GetDB()->Put(storage_->DefaultWriteOptions(), cf,
ns_key, metadata).ok());
+ EXPECT_FALSE(parser.ParseFullDB().IsOK());
+ EXPECT_TRUE(writer.commands.empty());
+}
+
+INSTANTIATE_TEST_SUITE_P(Encodings, Kvrocks2RedisParserTest,
::testing::Combine(::testing::Bool(), ::testing::Bool()));
diff --git a/tests/gocase/integration/slotmigrate/hash_field_expiration_test.go
b/tests/gocase/integration/slotmigrate/hash_field_expiration_test.go
index 1cf489ae3..a2299b71d 100644
--- a/tests/gocase/integration/slotmigrate/hash_field_expiration_test.go
+++ b/tests/gocase/integration/slotmigrate/hash_field_expiration_test.go
@@ -23,6 +23,7 @@ import (
"context"
"errors"
"fmt"
+ "strconv"
"strings"
"testing"
"time"
@@ -205,3 +206,72 @@ func TestSlotMigrateHashFieldExpiration(t *testing.T) {
})
}
}
+
+func TestSlotMigrateSnapshotHashEncodingInWAL(t *testing.T) {
+ for _, mode := range []string{"legacy", "field-expiration"} {
+ ctx := context.Background()
+ source := util.StartServer(t, util.KvrocksServerConfigs{
+ "cluster-enabled": "yes",
+ "hash-encoding-mode": mode,
+ "migrate-batch-size-kb": "16",
+ })
+ defer source.Close()
+ src := source.NewClient()
+ defer func() { require.NoError(t, src.Close()) }()
+ destination := util.StartServer(t, util.KvrocksServerConfigs{
+ "cluster-enabled": "yes",
+ "hash-encoding-mode": "legacy",
+ })
+ defer destination.Close()
+ dst := destination.NewClient()
+ defer func() { require.NoError(t, dst.Close()) }()
+
+ sourceID, destinationID := strings.Repeat("a", 40),
strings.Repeat("b", 40)
+ require.NoError(t, src.Do(ctx, "CLUSTERX", "SETNODEID",
sourceID).Err())
+ require.NoError(t, dst.Do(ctx, "CLUSTERX", "SETNODEID",
destinationID).Err())
+ nodes := fmt.Sprintf("%s %s %d master - 0-10000\n%s %s %d
master - 10001-16383",
+ sourceID, source.Host(), source.Port(), destinationID,
destination.Host(), destination.Port())
+ require.NoError(t, src.Do(ctx, "CLUSTERX", "SETNODES", nodes,
"1").Err())
+ require.NoError(t, dst.Do(ctx, "CLUSTERX", "SETNODES", nodes,
"1").Err())
+
+ t.Run(mode, func(t *testing.T) {
+ // A single small hash keeps this test independent of
migration batch splitting and merging.
+ key := fmt.Sprintf("hash{%s}", util.SlotTable[0])
+ binaryValue := strings.Repeat("\x00", 8) +
"value\x00\xff"
+ values := map[string]string{"live": "v", "persistent":
binaryValue}
+ require.NoError(t, src.HSet(ctx, key, values).Err())
+ wantCommands := []any{[]any{"HSET", key, "live", "v"}}
+ var expireAt int64 = -1
+ if mode == "field-expiration" {
+ expireAt = time.Now().Add(10 *
time.Minute).UnixMilli()
+ result, err := src.Do(ctx, "HPEXPIREAT", key,
expireAt, "FIELDS", 1, "live").Int64Slice()
+ require.NoError(t, err)
+ require.Equal(t, []int64{1}, result)
+ wantCommands = append(wantCommands,
[]any{"HPEXPIREAT", key, strconv.FormatInt(expireAt, 10), "FIELDS", "1",
"live"})
+ require.NoError(t, src.ConfigSet(ctx,
"hash-encoding-mode", "legacy").Err())
+ } else {
+ require.NoError(t, src.ConfigSet(ctx,
"hash-encoding-mode", "field-expiration").Err())
+ }
+ wantCommands = append(wantCommands, []any{"HSET", key,
"persistent", binaryValue})
+
+ require.NoError(t, src.Do(ctx, "CLUSTERX", "MIGRATE",
0, destinationID).Err())
+ waitForMigrateState(t, src, 0,
SlotMigrationStateSuccess)
+ waitForImportState(t, dst, 0, SlotImportStateSuccess)
+ got, err := dst.HGetAll(ctx, key).Result()
+ require.NoError(t, err)
+ require.Equal(t, values, got)
+ if mode == "field-expiration" {
+ expires, err := dst.Do(ctx, "HPEXPIRETIME",
key, "FIELDS", 2, "live", "persistent").Int64Slice()
+ require.NoError(t, err)
+ require.Equal(t, []int64{expireAt, -1}, expires)
+ }
+
+ // Raw KV correctness alone does not ensure that
downstream WAL consumers decode the fields correctly.
+ result, err := dst.Do(ctx, "POLLUPDATES", 0, "MAX",
1000, "FORMAT", "RESP").Result()
+ require.NoError(t, err)
+ batch := result.(map[any]any)
+ require.Equal(t, batch["latest_sequence"],
batch["next_sequence"])
+ require.Equal(t, []any{"default", wantCommands},
batch["updates"])
+ })
+ }
+}
diff --git a/utils/kvrocks2redis/README.md b/utils/kvrocks2redis/README.md
index a7047265f..4507fa3d4 100644
--- a/utils/kvrocks2redis/README.md
+++ b/utils/kvrocks2redis/README.md
@@ -10,4 +10,10 @@ Another thread (named `redis-writer`) reads the AOF
continuously and sends the c
When the program runs, the following files are generated:
1. xxx_appendonly.aof: parsed data will be saved in this file.
2. xxx_last_next_offset.txt: indicates the position of the AOF file read by
the `redis-writer` thread.
-3. last_next_seq.txt: indicates the sequence number parsed by `kvrocks2redis`
to record the synchronization location and check whether incremental
synchronization can be performed.
\ No newline at end of file
+3. last_next_seq.txt: indicates the sequence number parsed by `kvrocks2redis`
to record the synchronization location and check whether incremental
synchronization can be performed.
+
+## Hash field expiration
+
+Full synchronization decodes each hash using its stored encoding mode, skips
expired fields, and preserves live field expirations using `HSET` followed by
`HPEXPIREAT` with the original absolute timestamp.
+Incremental synchronization uses the same commands to replay HFE writes from
the WAL.
+The target Redis server must support `HPEXPIREAT` when synchronizing hashes
with field expirations.
diff --git a/utils/kvrocks2redis/parser.cc b/utils/kvrocks2redis/parser.cc
index a80f349ab..25ba98174 100644
--- a/utils/kvrocks2redis/parser.cc
+++ b/utils/kvrocks2redis/parser.cc
@@ -29,6 +29,7 @@
#include "logging.h"
#include "server/redis_reply.h"
#include "storage/redis_metadata.h"
+#include "time_util.h"
#include "types/redis_string.h"
Status Parser::ParseFullDB() {
@@ -55,7 +56,7 @@ Status Parser::ParseFullDB() {
if (metadata.Type() == kRedisString) {
s = parseSimpleKV(iter->key(), iter->value(), metadata.expire);
} else {
- s = parseComplexKV(iter->key(), metadata);
+ s = parseComplexKV(iter->key(), metadata, iter->value());
}
if (!s.IsOK()) return s;
}
@@ -79,12 +80,18 @@ Status Parser::parseSimpleKV(const Slice &ns_key, const
Slice &value, uint64_t e
return s;
}
-Status Parser::parseComplexKV(const Slice &ns_key, const Metadata &metadata) {
+Status Parser::parseComplexKV(const Slice &ns_key, const Metadata &metadata,
const Slice &metadata_bytes) {
RedisType type = metadata.Type();
if (type < kRedisHash || type > kRedisSortedint) {
return {Status::NotOK, "unknown metadata type: " + std::to_string(type)};
}
+ HashMetadata hash_metadata(false);
+ if (type == kRedisHash) {
+ auto ds = hash_metadata.Decode(metadata_bytes);
+ if (!ds.ok()) return {Status::NotOK, ds.ToString()};
+ }
+
auto [ns, user_key] = ExtractNamespaceKey<std::string>(ns_key,
slot_id_encoded_);
std::string prefix_key = InternalKey(ns_key, "", metadata.version,
slot_id_encoded_).Encode();
std::string next_version_prefix_key = InternalKey(ns_key, "",
metadata.version + 1, slot_id_encoded_).Encode();
@@ -93,7 +100,6 @@ Status Parser::parseComplexKV(const Slice &ns_key, const
Metadata &metadata) {
rocksdb::Slice upper_bound(next_version_prefix_key);
read_options.iterate_upper_bound = &upper_bound;
- std::string output;
auto no_txn_ctx = engine::Context::NoTransactionContext(storage_);
auto iter = util::UniqueIterator(no_txn_ctx, read_options);
for (iter->Seek(prefix_key); iter->Valid(); iter->Next()) {
@@ -104,19 +110,31 @@ Status Parser::parseComplexKV(const Slice &ns_key, const
Metadata &metadata) {
InternalKey ikey(iter->key(), slot_id_encoded_);
std::string sub_key = ikey.GetSubKey().ToString();
std::string value = iter->value().ToString();
+ std::vector<std::string> commands;
switch (type) {
- case kRedisHash:
- output = redis::ArrayOfBulkStrings({"HSET", user_key, sub_key, value});
+ case kRedisHash: {
+ Slice field_value(value);
+ uint64_t expire = 0;
+ auto ds = hash_metadata.DecodeSubkeyValue(&field_value, &expire);
+ if (!ds.ok()) return {Status::NotOK, ds.ToString()};
+ // Full sync starts with an empty target; expired fields need not be
recreated.
+ if (expire > 0 && expire <= util::GetTimeStampMS()) continue;
+ commands.emplace_back(redis::ArrayOfBulkStrings({"HSET", user_key,
sub_key, field_value.ToString()}));
+ if (expire > 0) {
+ commands.emplace_back(
+ redis::ArrayOfBulkStrings({"HPEXPIREAT", user_key,
std::to_string(expire), "FIELDS", "1", sub_key}));
+ }
break;
+ }
case kRedisSet:
- output = redis::ArrayOfBulkStrings({"SADD", user_key, sub_key});
+ commands.emplace_back(redis::ArrayOfBulkStrings({"SADD", user_key,
sub_key}));
break;
case kRedisList:
- output = redis::ArrayOfBulkStrings({"RPUSH", user_key, value});
+ commands.emplace_back(redis::ArrayOfBulkStrings({"RPUSH", user_key,
value}));
break;
case kRedisZSet: {
double score = DecodeDouble(value.data());
- output = redis::ArrayOfBulkStrings({"ZADD", user_key,
util::Float2String(score), sub_key});
+ commands.emplace_back(redis::ArrayOfBulkStrings({"ZADD", user_key,
util::Float2String(score), sub_key}));
break;
}
case kRedisBitmap: {
@@ -127,21 +145,21 @@ Status Parser::parseComplexKV(const Slice &ns_key, const
Metadata &metadata) {
}
case kRedisSortedint: {
std::string val =
std::to_string(DecodeFixed64(ikey.GetSubKey().data()));
- output = redis::ArrayOfBulkStrings({"ZADD", user_key, val, val});
+ commands.emplace_back(redis::ArrayOfBulkStrings({"ZADD", user_key,
val, val}));
break;
}
default:
break; // should never get here
}
- if (type != kRedisBitmap) {
- auto s = writer_->Write(ns, {output});
- if (!s.IsOK()) return s.Prefixed(fmt::format("failed to write the '{}'
command to AOF", output));
+ if (!commands.empty()) {
+ auto s = writer_->Write(ns, commands);
+ if (!s.IsOK()) return s.Prefixed("failed to write commands to AOF");
}
}
if (metadata.expire > 0) {
- output = redis::ArrayOfBulkStrings({"EXPIREAT", user_key,
std::to_string(metadata.expire / 1000)});
+ auto output = redis::ArrayOfBulkStrings({"EXPIREAT", user_key,
std::to_string(metadata.expire / 1000)});
Status s = writer_->Write(ns, {output});
if (!s.IsOK()) return s.Prefixed("failed to write the EXPIREAT command to
AOF");
}
diff --git a/utils/kvrocks2redis/parser.h b/utils/kvrocks2redis/parser.h
index 09ba2f17c..690bed37d 100644
--- a/utils/kvrocks2redis/parser.h
+++ b/utils/kvrocks2redis/parser.h
@@ -48,6 +48,6 @@ class Parser {
bool slot_id_encoded_ = false;
Status parseSimpleKV(const Slice &ns_key, const Slice &value, uint64_t
expire);
- Status parseComplexKV(const Slice &ns_key, const Metadata &metadata);
+ Status parseComplexKV(const Slice &ns_key, const Metadata &metadata, const
Slice &metadata_bytes);
Status parseBitmapSegment(const Slice &ns, const Slice &user_key, int index,
const Slice &bitmap);
};