Skip to content

Commit aa35ea8

Browse files
authored
fix: backward compatibility for replication regarding HNSW index seri… (#6664)
1 parent cc19afb commit aa35ea8

8 files changed

Lines changed: 152 additions & 21 deletions

File tree

src/server/detail/save_stages_controller.cc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ GenericError RdbSnapshot::Start(SaveMode save_mode, const std::string& path,
116116

117117
is_linux_file_ = file_type & FileType::IO_URING;
118118
bool align_writes = (file_type & FileType::DIRECT) != 0;
119-
saver_.reset(new RdbSaver(io_sink_.get(), save_mode, align_writes, snapshot_id));
119+
saver_.reset(
120+
new RdbSaver(io_sink_.get(), save_mode, align_writes, snapshot_id, DflyVersion::CURRENT_VER));
120121

121122
return saver_->SaveHeader(std::move(glob_data));
122123
}

src/server/dflycmd.cc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -683,7 +683,8 @@ OpStatus DflyCmd::StartFullSyncInThread(FlowInfo* flow, ExecutionState* exec_st,
683683
// of the flows also contain them.
684684
SaveMode save_mode =
685685
shard->shard_id() == 0 ? SaveMode::SINGLE_SHARD_WITH_SUMMARY : SaveMode::SINGLE_SHARD;
686-
flow->saver = std::make_unique<RdbSaver>(flow->conn->socket(), save_mode, false, "");
686+
flow->saver =
687+
std::make_unique<RdbSaver>(flow->conn->socket(), save_mode, false, "", flow->version);
687688

688689
flow->cleanup = [flow, shard]() {
689690
// socket shutdown is needed before calling saver->Cancel(). Because

src/server/rdb_save.cc

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1117,7 +1117,7 @@ class RdbSaver::Impl final : public SliceSnapshot::SnapshotDataConsumerInterface
11171117
// We pass K=sz to say how many producers are pushing data in order to maintain
11181118
// correct closing semantics - channel is closing when K producers marked it as closed.
11191119
Impl(bool align_writes, unsigned producers_len, CompressionMode compression_mode,
1120-
SaveMode save_mode, io::Sink* sink);
1120+
SaveMode save_mode, io::Sink* sink, DflyVersion replica_dfly_version);
11211121

11221122
~Impl();
11231123

@@ -1182,12 +1182,13 @@ class RdbSaver::Impl final : public SliceSnapshot::SnapshotDataConsumerInterface
11821182
// make snapshot size smaller and opreation faster.
11831183
CompressionMode compression_mode_;
11841184
SaveMode save_mode_;
1185+
DflyVersion replica_dfly_version_ = DflyVersion::CURRENT_VER;
11851186
};
11861187

11871188
// We pass K=sz to say how many producers are pushing data in order to maintain
11881189
// correct closing semantics - channel is closing when K producers marked it as closed.
11891190
RdbSaver::Impl::Impl(bool align_writes, unsigned producers_len, CompressionMode compression_mode,
1190-
SaveMode sm, io::Sink* sink)
1191+
SaveMode sm, io::Sink* sink, DflyVersion replica_dfly_version)
11911192
: sink_(sink),
11921193
shard_snapshots_(producers_len),
11931194
meta_serializer_(CompressionMode::NONE), // Note: I think there is not need for compression
@@ -1201,6 +1202,7 @@ RdbSaver::Impl::Impl(bool align_writes, unsigned producers_len, CompressionMode
12011202
channel_.emplace(kChannelLen, producers_len);
12021203
}
12031204
save_mode_ = sm;
1205+
replica_dfly_version_ = replica_dfly_version;
12041206
}
12051207

12061208
void RdbSaver::Impl::CleanShardSnapshots() {
@@ -1302,8 +1304,9 @@ void RdbSaver::Impl::StartSnapshotting(bool stream_journal, ExecutionState* cntx
13021304

13031305
SnapshotPtr RdbSaver::Impl::CreateSliceSnapshot(EngineShard* shard, DbSlice* db_slice,
13041306
ExecutionState* cntx) {
1305-
return SnapshotPtr(new SliceSnapshot(compression_mode_, db_slice, this, cntx),
1306-
OwnerThreadDeleter::FromShard(shard));
1307+
return SnapshotPtr(
1308+
new SliceSnapshot(compression_mode_, db_slice, this, cntx, replica_dfly_version_),
1309+
OwnerThreadDeleter::FromShard(shard));
13071310
}
13081311

13091312
// called on save flow
@@ -1532,8 +1535,9 @@ SnapshotPtr& RdbSaver::Impl::GetSnapshot(EngineShard* shard) {
15321535
return shard_snapshots_[sid];
15331536
}
15341537

1535-
RdbSaver::RdbSaver(::io::Sink* sink, SaveMode save_mode, bool align_writes, std::string snapshot_id)
1536-
: snapshot_id_(std::move(snapshot_id)) {
1538+
RdbSaver::RdbSaver(::io::Sink* sink, SaveMode save_mode, bool align_writes, std::string snapshot_id,
1539+
DflyVersion replica_dfly_version)
1540+
: replica_dfly_version_(replica_dfly_version), snapshot_id_(std::move(snapshot_id)) {
15371541
CHECK_NOTNULL(sink);
15381542
CompressionMode compression_mode = GetDefaultCompressionMode();
15391543
int producer_count = 0;
@@ -1561,7 +1565,8 @@ RdbSaver::RdbSaver(::io::Sink* sink, SaveMode save_mode, bool align_writes, std:
15611565
break;
15621566
}
15631567
VLOG(1) << "Rdb save using compression mode:" << uint32_t(compression_mode_);
1564-
impl_.reset(new Impl(align_writes, producer_count, compression_mode_, save_mode, sink));
1568+
impl_.reset(new Impl(align_writes, producer_count, compression_mode_, save_mode, sink,
1569+
replica_dfly_version_));
15651570
save_mode_ = save_mode;
15661571
}
15671572

@@ -1649,13 +1654,20 @@ error_code RdbSaver::SaveAux(const GlobalData& glob_state) {
16491654
if (!glob_state.search_indices.empty())
16501655
LOG(WARNING) << "Dragonfly search index data is incompatible with the RDB format";
16511656
} else {
1652-
// Search index definitions (simple "index_name cmd" restore commands)
1653-
for (const string& s : glob_state.search_indices)
1654-
RETURN_ON_ERR(impl_->SaveAuxFieldStrStr("search-index", s));
1657+
// Search index definitions - for non-summary shards only sent to replicas >= VER6,
1658+
// since older replicas only expect search-index from the summary shard.
1659+
bool send_search_index =
1660+
(save_mode_ != SaveMode::SINGLE_SHARD) || (replica_dfly_version_ >= DflyVersion::VER6);
1661+
if (send_search_index) {
1662+
for (const string& s : glob_state.search_indices)
1663+
RETURN_ON_ERR(impl_->SaveAuxFieldStrStr("search-index", s));
1664+
}
16551665

1656-
// HNSW index metadata (JSON, summary only)
1657-
for (const string& s : glob_state.hnsw_index_metadata)
1658-
RETURN_ON_ERR(impl_->SaveAuxFieldStrStr("hnsw-index-metadata", s));
1666+
// HNSW index metadata (JSON, summary only) - only for replicas >= VER6
1667+
if (replica_dfly_version_ >= DflyVersion::VER6) {
1668+
for (const string& s : glob_state.hnsw_index_metadata)
1669+
RETURN_ON_ERR(impl_->SaveAuxFieldStrStr("hnsw-index-metadata", s));
1670+
}
16591671

16601672
// Save synonyms only in summary file
16611673
DCHECK(save_mode_ != SaveMode::SINGLE_SHARD || glob_state.search_synonyms.empty());

src/server/rdb_save.h

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ extern "C" {
2020
#include "server/journal/serializer.h"
2121
#include "server/journal/types.h"
2222
#include "server/table.h"
23+
#include "server/version.h"
2324

2425
typedef struct rax rax;
2526
typedef struct streamCG streamCG;
@@ -97,8 +98,9 @@ class RdbSaver {
9798
// (corresponds to legacy, redis compatible mode)
9899
// if align_writes is true - writes data in aligned chunks of 4KB to fit direct I/O requirements.
99100
// snapshot_id - allows to identify that group of files belongs to the same snapshot
101+
// replica_dfly_version - upper bound for conditional serialization of new features.
100102
explicit RdbSaver(::io::Sink* sink, SaveMode save_mode, bool align_writes,
101-
std::string snapshot_id);
103+
std::string snapshot_id, DflyVersion replica_dfly_version);
102104

103105
~RdbSaver();
104106

@@ -159,6 +161,7 @@ class RdbSaver {
159161
std::unique_ptr<Impl> impl_;
160162
SaveMode save_mode_;
161163
CompressionMode compression_mode_;
164+
DflyVersion replica_dfly_version_ = DflyVersion::CURRENT_VER;
162165
std::string snapshot_id_;
163166
};
164167

src/server/snapshot.cc

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,12 @@ constexpr size_t kMinBlobSize = 8_KB;
4545
} // namespace
4646

4747
SliceSnapshot::SliceSnapshot(CompressionMode compression_mode, DbSlice* slice,
48-
SnapshotDataConsumerInterface* consumer, ExecutionState* cntx)
48+
SnapshotDataConsumerInterface* consumer, ExecutionState* cntx,
49+
DflyVersion replica_dfly_version)
4950
: db_slice_(slice),
5051
db_array_(slice->databases()),
5152
compression_mode_(compression_mode),
53+
replica_dfly_version_(replica_dfly_version),
5254
consumer_(consumer),
5355
cntx_(cntx) {
5456
tl_slice_snapshots.insert(this);
@@ -189,7 +191,8 @@ void SliceSnapshot::SerializeIndexMapping(
189191

190192
void SliceSnapshot::SerializeIndexMappings() {
191193
#ifdef WITH_SEARCH
192-
if (SaveMode() == dfly::SaveMode::RDB || !absl::GetFlag(FLAGS_serialize_hnsw_index)) {
194+
if (SaveMode() == dfly::SaveMode::RDB || !absl::GetFlag(FLAGS_serialize_hnsw_index) ||
195+
replica_dfly_version_ < DflyVersion::VER6) {
193196
return;
194197
}
195198

@@ -220,7 +223,7 @@ void SliceSnapshot::SerializeGlobalHnswIndices() {
220223
#ifdef WITH_SEARCH
221224
// Serialize HNSW global indices for shard 0 only
222225
if (db_slice_->shard_owner()->shard_id() != 0 || SaveMode() == dfly::SaveMode::RDB ||
223-
!absl::GetFlag(FLAGS_serialize_hnsw_index)) {
226+
!absl::GetFlag(FLAGS_serialize_hnsw_index) || replica_dfly_version_ < DflyVersion::VER6) {
224227
return;
225228
}
226229

src/server/snapshot.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ class SliceSnapshot : public journal::JournalConsumerInterface {
6565
};
6666

6767
SliceSnapshot(CompressionMode compression_mode, DbSlice* slice,
68-
SnapshotDataConsumerInterface* consumer, ExecutionState* cntx);
68+
SnapshotDataConsumerInterface* consumer, ExecutionState* cntx,
69+
DflyVersion replica_dfly_version);
6970
~SliceSnapshot();
7071

7172
static size_t GetThreadLocalMemoryUsage();
@@ -177,6 +178,7 @@ class SliceSnapshot : public journal::JournalConsumerInterface {
177178

178179
bool use_background_mode_ = false;
179180
bool use_snapshot_version_ = true;
181+
DflyVersion replica_dfly_version_ = DflyVersion::CURRENT_VER;
180182

181183
uint64_t rec_id_ = 1, last_pushed_id_ = 0;
182184

src/server/version.h

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,14 @@ enum class DflyVersion {
3636
// - Support partial sync from different master
3737
VER5,
3838

39+
// 1.37 <= ver
40+
// - Per-shard search index definitions (search-index AUX on every flow)
41+
// - HNSW index serialization opcodes (RDB_OPCODE_VECTOR_INDEX, RDB_OPCODE_SHARD_DOC_INDEX)
42+
// - hnsw-index-metadata AUX field
43+
VER6,
44+
3945
// Always points to the latest version
40-
CURRENT_VER = VER5,
46+
CURRENT_VER = VER6,
4147
};
4248

4349
} // namespace dfly

tests/dragonfly/replication_test.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import platform
33
import shutil
44
import signal
5+
import struct
56
import tarfile
67
import time
78
import urllib.request
@@ -2623,6 +2624,108 @@ async def check_if_empty():
26232624
assert await c_replica.execute_command(f"dbsize") == 0
26242625

26252626

2627+
async def test_replicate_search_index_to_old_replica(df_factory: DflyInstanceFactory):
2628+
"""
2629+
Test that a new master with search indices (including HNSW vector index) can
2630+
replicate to a v1.35 replica. This verifies backward compatibility of replication
2631+
when search indices are defined, ensuring the replica receives the data without
2632+
errors from new RDB AUX fields (search-index, hnsw-index-metadata, HNSW opcodes).
2633+
"""
2634+
cpu = platform.processor()
2635+
if cpu != "x86_64":
2636+
pytest.skip(f"Supported only on x64, running on {cpu}")
2637+
2638+
dfly_version = "v1.35.1"
2639+
released_dfly_path = download_dragonfly_release(dfly_version)
2640+
2641+
# New master (current version) with search index
2642+
master = df_factory.create(proactor_threads=2)
2643+
# Old replica (v1.35)
2644+
replica = df_factory.create(
2645+
version=1.35,
2646+
path=released_dfly_path,
2647+
proactor_threads=2,
2648+
)
2649+
2650+
df_factory.start_all([master, replica])
2651+
2652+
c_master = master.client()
2653+
c_replica = replica.client()
2654+
2655+
# Create a search index with HNSW vector field on the new master
2656+
await c_master.execute_command(
2657+
"FT.CREATE",
2658+
"test_idx",
2659+
"ON",
2660+
"HASH",
2661+
"PREFIX",
2662+
"1",
2663+
"item:",
2664+
"SCHEMA",
2665+
"name",
2666+
"TEXT",
2667+
"price",
2668+
"NUMERIC",
2669+
"SORTABLE",
2670+
"category",
2671+
"TAG",
2672+
"embedding",
2673+
"VECTOR",
2674+
"HNSW",
2675+
"6",
2676+
"TYPE",
2677+
"FLOAT32",
2678+
"DIM",
2679+
"2",
2680+
"DISTANCE_METRIC",
2681+
"L2",
2682+
)
2683+
2684+
# Insert test data with vector embeddings
2685+
for i in range(100):
2686+
category = "electronics" if i % 2 == 0 else "clothing"
2687+
embedding = struct.pack("<2f", float(i), float(i * 2))
2688+
await c_master.hset(
2689+
f"item:{i}",
2690+
mapping={
2691+
"name": f"Product {i}",
2692+
"price": str(i * 10),
2693+
"category": category,
2694+
"embedding": embedding,
2695+
},
2696+
)
2697+
2698+
# Verify data and index on master
2699+
assert await c_master.dbsize() == 100
2700+
master_idx = c_master.ft("test_idx")
2701+
text_result = await master_idx.search("Product 50")
2702+
assert text_result.total >= 1
2703+
2704+
# Verify KNN search on master
2705+
query_vec = struct.pack("<2f", 50.0, 100.0)
2706+
knn_result = await c_master.execute_command(
2707+
"FT.SEARCH", "test_idx", "*=>[KNN 2 @embedding $vec]", "PARAMS", "2", "vec", query_vec
2708+
)
2709+
assert knn_result[0] >= 1
2710+
assert "item:50" in knn_result
2711+
2712+
# Start replication from new master to old replica
2713+
await c_replica.execute_command(f"REPLICAOF localhost {master.port}")
2714+
await wait_available_async(c_replica)
2715+
2716+
# Verify data replicated successfully
2717+
assert await c_replica.dbsize() == 100
2718+
assert await c_replica.hget("item:0", "name") == "Product 0"
2719+
assert await c_replica.hget("item:99", "name") == "Product 99"
2720+
2721+
# Verify KNN search works on old replica (index rebuilt from replicated data)
2722+
knn_result = await c_replica.execute_command(
2723+
"FT.SEARCH", "test_idx", "*=>[KNN 2 @embedding $vec]", "PARAMS", "2", "vec", query_vec
2724+
)
2725+
assert knn_result[0] >= 1
2726+
assert "item:50" in knn_result
2727+
2728+
26262729
async def test_replicating_mc_flags(df_factory):
26272730
master = df_factory.create(memcached_port=11211, proactor_threads=1)
26282731
replica = df_factory.create(

0 commit comments

Comments
 (0)