Skip to content
Closed
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
50 changes: 49 additions & 1 deletion hyperbytedb/src/application/shard_routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,19 @@ pub async fn bootstrap_measurement_local(
Ok(())
}

/// Effective replica count for a region: never more than live membership.
///
/// A 1-member cluster therefore has RF=1 even when `configured` is 3.
/// RF is also never raised above the configured target.
#[must_use]
pub fn effective_replication_factor(configured: usize, active_members: usize) -> usize {
let target = configured.max(1);
if active_members == 0 {
return 0;
}
target.min(active_members)
}

/// Pick the peer set for a brand-new measurement's first region.
///
/// Candidates are ordered by `hash(measurement, node_id)` so different
Expand All @@ -282,6 +295,9 @@ pub async fn bootstrap_measurement_local(
/// region to the same RF nodes — a deterministic cluster-wide hotspot. Pure
/// function of the key + membership, so every coordinator computing the op
/// agrees on the same peer set and primary.
///
/// Peer count is [`effective_replication_factor`]: n=1 yields `[self]`,
/// not an empty set truncated against a configured RF of 2 or 3.
async fn select_bootstrap_peers(
ctx: &ShardRoutingContext,
db: &str,
Expand All @@ -298,6 +314,7 @@ async fn select_bootstrap_peers(
drop(membership);
peers.sort_unstable();
peers.dedup();
let member_count = peers.len();
// Spread placement: deterministic per-(measurement, node) hash order.
use std::hash::{Hash, Hasher};
peers.sort_by_key(|node_id| {
Expand All @@ -308,7 +325,10 @@ async fn select_bootstrap_peers(
node_id.hash(&mut h);
h.finish()
});
peers.truncate(ctx.config.replication_factor.max(1));
peers.truncate(effective_replication_factor(
ctx.config.replication_factor,
member_count,
));
Ok(peers)
}

Expand Down Expand Up @@ -996,4 +1016,32 @@ mod scatter_tests {
"primaries must spread across nodes, got {primaries:?}"
);
}

#[test]
fn effective_rf_never_exceeds_membership() {
assert_eq!(effective_replication_factor(3, 1), 1);
assert_eq!(effective_replication_factor(3, 2), 2);
assert_eq!(effective_replication_factor(2, 4), 2);
assert_eq!(effective_replication_factor(0, 3), 1);
assert_eq!(effective_replication_factor(3, 0), 0);
}

#[tokio::test]
async fn bootstrap_n1_peers_are_self() {
let membership = membership_with(&[(1, "127.0.0.1:1")]);
let config = ShardingConfig {
replication_factor: 3,
..Default::default()
};
let ctx = test_ctx(membership, config, 1);

let op = build_bootstrap_op(&ctx, "db", "autogen", "cpu")
.await
.unwrap();
let ShardMapOp::BootstrapMeasurement { region, .. } = op else {
panic!("expected bootstrap op");
};
assert_eq!(region.peers, vec![1], "n=1 must own the region");
assert_eq!(region.primary, 1);
}
}
11 changes: 11 additions & 0 deletions hyperbytedb/tests/common/sharding_cluster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,17 @@ pub async fn start_sharded_node(
}
}

pub async fn start_sharded_single_node(dir: &Path, opts: ShardedClusterOptions) -> ShardedTestNode {
let chdb_dir = dir.join("chdb-shared");
std::fs::create_dir_all(&chdb_dir).unwrap();
let chdb = SharedSession::new_eager(chdb_dir.to_str().unwrap(), 1).unwrap();

let l1 = bind_ephemeral().await;
let a1 = l1.local_addr().unwrap().to_string();
let membership = build_shared_membership(&[(1, a1)]);
start_sharded_node(dir, 1, l1, membership, &opts, chdb).await
}

pub async fn start_sharded_pair_cluster(
dir: &Path,
opts: ShardedClusterOptions,
Expand Down
47 changes: 47 additions & 0 deletions hyperbytedb/tests/sharding_cluster_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,53 @@ async fn aggregate_read_from_primary_when_replica_lags() {
);
}

/// P1.1: a 1-member cluster with sharding on accepts /write and owns the
/// first region (peers=[self], primary=self) even when configured RF is 3.
#[tokio::test]
#[serial(chdb)]
async fn one_member_cluster_owns_region_after_first_write() {
let dir = tempfile::tempdir().unwrap();
let opts = ShardedClusterOptions {
sharding: hyperbytedb::config::ShardingConfig {
enabled: true,
replication_factor: 3,
scatter_peer_timeout_ms: 500,
scatter_max_peer_attempts: 3,
..Default::default()
},
..Default::default()
};
let node = start_sharded_single_node(dir.path(), opts).await;

let client = reqwest::Client::new();
create_db(&client, &node.url, "p1db").await;
let resp = write_line(
&client,
&node.url,
"p1db",
"cpu,host=solo value=1 1000000000",
)
.await;
assert_eq!(
resp.status(),
reqwest::StatusCode::NO_CONTENT,
"n=1 sharded write must succeed: {}",
resp.status()
);

let map = node.shard_map.snapshot().await.unwrap();
let space = map
.space("p1db", "autogen", "cpu")
.expect("first write must bootstrap a region");
assert!(
!space.regions.is_empty(),
"shard map must have at least one region"
);
let region = &space.regions[0];
assert_eq!(region.peers, vec![1], "n=1 peers must be [self]");
assert_eq!(region.primary, 1, "n=1 primary must be self");
}

async fn wait_for_database(client: &reqwest::Client, url: &str, db: &str) {
for _ in 0..100 {
let resp = query_sql(client, url, db, "SHOW DATABASES").await;
Expand Down
Loading