Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ exclude = [
"examples/rocksdb-cluster",
"examples/sled-cluster",
"examples/three-nodes-cluster",
"examples/single-node-expansion",
"benches/d-engine-bench",
]

Expand Down
15 changes: 12 additions & 3 deletions d-engine-core/src/election/election_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,26 @@ where
) -> Result<()> {
debug!("broadcast_vote_requests...");

// Single-node cluster: no peers to vote, automatically win election
if membership.is_single_node_cluster().await {
debug!(
"Single-node cluster detected (node_id={}): automatically winning election",
self.my_id
);
return Ok(());
}

let members = membership.voters().await;
if members.is_empty() {
error!("my(id={}) peers is empty.", self.my_id);
error!("No voting members found for node {}", self.my_id);
return Err(ElectionError::NoVotingMemberFound {
candidate_id: self.my_id,
}
.into());
} else {
debug!("going to send_vote_requests to: {:?}", &members);
}

debug!("Sending vote requests to peers: {:?}", &members);

let LogId {
index: last_log_index,
term: last_log_term,
Expand Down
153 changes: 153 additions & 0 deletions d-engine-core/src/election/election_handler_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -576,3 +576,156 @@ async fn test_handle_vote_request_large_term_numbers() {
// Assert
assert_eq!(state_update.term_update, Some(large_term));
}

// ================================================================================================
// Tests for Single-Node Cluster Support (Issue #179)
// ================================================================================================

#[cfg(test)]
mod single_node_election_tests {
use super::*;
use crate::ElectionError;
use crate::Error;
use crate::MockMembership;
use crate::MockTransport;
use crate::RaftNodeConfig;
use crate::VoteResult;
use d_engine_proto::server::cluster::NodeMeta;
use d_engine_proto::server::election::VoteResponse;
use std::collections::HashSet;

#[tokio::test]
async fn test_single_node_auto_wins_election() {
// Arrange
let handler = ElectionHandler::<MockTypeConfig>::new(1);
let mut mock_membership = MockMembership::new();

// Mock: is_single_node_cluster() returns true for single-node
mock_membership.expect_is_single_node_cluster().times(1).returning(|| true);

// voters() should NOT be called (early return before this check)
mock_membership.expect_voters().times(0);

let membership = Arc::new(mock_membership);
let raft_log = Arc::new(create_mock_raft_log(None));
let mock_transport = MockTransport::new();
let transport = Arc::new(mock_transport);
let settings = Arc::new(RaftNodeConfig::default());

// Act
let result = handler
.broadcast_vote_requests(1, membership, &raft_log, &transport, &settings)
.await;

// Assert
assert!(
result.is_ok(),
"Single-node should automatically win election"
);
}

#[tokio::test]
async fn test_three_node_cluster_goes_through_normal_election() {
// Arrange
let handler = ElectionHandler::<MockTypeConfig>::new(1);
let mut mock_membership = MockMembership::new();

// Mock: is_single_node_cluster() returns false for multi-node
mock_membership.expect_is_single_node_cluster().times(1).returning(|| false);

// Mock: voters() returns 2 peers
mock_membership.expect_voters().times(1).returning(|| {
vec![
NodeMeta {
id: 2,
address: "127.0.0.1:9082".to_string(),
role: 0,
status: 2,
},
NodeMeta {
id: 3,
address: "127.0.0.1:9083".to_string(),
role: 0,
status: 2,
},
]
});

let membership = Arc::new(mock_membership);
let raft_log = Arc::new(create_mock_raft_log(None));

let mut mock_transport = MockTransport::new();
// Mock transport to return majority votes
mock_transport.expect_send_vote_requests().times(1).returning(
|_req, _retry, _membership| {
Ok(VoteResult {
peer_ids: HashSet::from([2, 3]),
responses: vec![
Ok(VoteResponse {
term: 1,
vote_granted: true,
last_log_index: 0,
last_log_term: 0,
}),
Ok(VoteResponse {
term: 1,
vote_granted: true,
last_log_index: 0,
last_log_term: 0,
}),
],
})
},
);

let transport = Arc::new(mock_transport);
let settings = Arc::new(RaftNodeConfig::default());

// Act
let result = handler
.broadcast_vote_requests(1, membership, &raft_log, &transport, &settings)
.await;

// Assert
assert!(
result.is_ok(),
"Three-node cluster should complete normal election"
);
}

#[tokio::test]
async fn test_network_partition_with_empty_voters_still_reports_error() {
// Arrange
let handler = ElectionHandler::<MockTypeConfig>::new(1);
let mut mock_membership = MockMembership::new();

// Mock: is_single_node_cluster() returns false for multi-node (network partition scenario)
mock_membership.expect_is_single_node_cluster().times(1).returning(|| false);

// Mock: voters() returns empty (network partition)
mock_membership.expect_voters().times(1).returning(Vec::new);

let membership = Arc::new(mock_membership);
let raft_log = Arc::new(create_mock_raft_log(None));
let mock_transport = MockTransport::new();
let transport = Arc::new(mock_transport);
let settings = Arc::new(RaftNodeConfig::default());

// Act
let result = handler
.broadcast_vote_requests(1, membership, &raft_log, &transport, &settings)
.await;

// Assert
assert!(result.is_err(), "Network partition should return error");
assert!(
matches!(
result.unwrap_err(),
Error::Consensus(crate::ConsensusError::Election(
ElectionError::NoVotingMemberFound { .. }
))
),
"Should return NoVotingMemberFound error"
);
}
}
34 changes: 34 additions & 0 deletions d-engine-core/src/membership.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,40 @@ where
/// All non-self nodes in Active state
async fn voters(&self) -> Vec<NodeMeta>;

/// Get the initial cluster size from configuration
///
/// This value is determined at node startup from the `initial_cluster` configuration
/// and remains constant throughout the node's lifetime. It represents the designed
/// cluster size, not the current runtime membership state.
///
/// Used for:
/// - Quorum calculations
/// - Cluster topology decisions
///
/// # Safety
/// This method is safe to use for cluster topology decisions as it's based on
/// immutable configuration rather than runtime state that could be affected by
/// network partitions or bugs.
async fn initial_cluster_size(&self) -> usize;

/// Check if this is a single-node cluster
///
/// Returns `true` if the initial cluster size is 1, indicating this node
/// was configured to run in standalone mode without any peers.
///
/// This is a convenience method equivalent to `initial_cluster_size() == 1`.
///
/// # Use Cases
/// - Skip Raft election in single-node mode (no peers to vote)
/// - Skip log replication in single-node mode (no peers to replicate to)
/// - Optimize performance by avoiding unnecessary network operations
///
/// # Safety
/// Safe for all cluster topology decisions as it's based on immutable configuration.
async fn is_single_node_cluster(&self) -> bool {
self.initial_cluster_size().await == 1
}

/// All pending active nodes in Active state
#[allow(unused)]
async fn nodes_with_status(
Expand Down
21 changes: 21 additions & 0 deletions d-engine-core/src/raft_role/leader_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1501,6 +1501,18 @@ impl<T: TypeConfig> LeaderState<T> {

if !ready_learners.is_empty() {
debug!("Ready learners: {:?}", ready_learners);

// Print promotion messages for each ready learner (Plan B)
for learner_id in &ready_learners {
let match_index = learner_progress.get(learner_id).and_then(|mi| *mi).unwrap_or(0);
crate::utils::cluster_printer::print_leader_promoting_learner(
self.node_id(),
*learner_id,
match_index,
leader_commit_index,
);
}

// Add to pending queue
let promotions = ready_learners
.into_iter()
Expand Down Expand Up @@ -1867,6 +1879,15 @@ impl<T: TypeConfig> LeaderState<T> {
"Node {} ({}) successfully added as learner",
node_id, address
);

// Print leader accepting new node message (Plan B)
crate::utils::cluster_printer::print_leader_accepting_new_node(
self.node_id(),
node_id,
address,
d_engine_proto::common::NodeRole::Learner as i32,
);

Ok(())
}

Expand Down
10 changes: 10 additions & 0 deletions d-engine-core/src/raft_role/learner_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ impl<T: TypeConfig> RaftRoleState for LearnerState<T> {
self.node_id(),
self.current_term(),
);

// Print promotion message (Plan B)
crate::utils::cluster_printer::print_learner_promoted_to_voter(self.node_id());

Ok(RaftRole::Follower(Box::new(self.into())))
}
fn become_learner(&self) -> Result<RaftRole<T>> {
Expand Down Expand Up @@ -485,6 +489,12 @@ impl<T: TypeConfig> RaftRoleState for LearnerState<T> {
// 4. mark leader_id
membership.mark_leader_id(leader_id).await?;

// Print join success message (Plan B)
crate::utils::cluster_printer::print_learner_join_success(
self.shared_state.node_id,
leader_id,
);

Ok(())
}

Expand Down
14 changes: 14 additions & 0 deletions d-engine-core/src/replication/replication_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,20 @@ where
// Phase 1: Pre-Checks
// ----------------------
let membership = ctx.membership();

// Single-node cluster: no replication needed, quorum automatically achieved
if membership.is_single_node_cluster().await {
debug!(
"Single-node cluster (leader={}): no replication needed, quorum automatically achieved",
self.my_id
);
return Ok(AppendResults {
commit_quorum_achieved: true,
peer_updates: HashMap::new(),
learner_progress: HashMap::new(),
});
}

let replication_targets = membership.replication_peers().await;
if replication_targets.is_empty() {
warn!("no peer found for leader({})", self.my_id);
Expand Down
3 changes: 3 additions & 0 deletions d-engine-core/src/test_utils/mock/mock_raft_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,9 @@ pub fn mock_membership() -> MockMembership<MockTypeConfig> {
membership.expect_get_peers_id_with_condition().returning(|_| vec![]);
// Mock methods used by create_not_leader_response()
membership.expect_current_leader_id().returning(|| None);
// Mock single-node cluster detection (default to multi-node with no peers)
membership.expect_is_single_node_cluster().returning(|| false);
membership.expect_initial_cluster_size().returning(|| 3);
membership
}

Expand Down
Loading