From f7b550a79a0af8383f9ae624a3511499bfd5f919 Mon Sep 17 00:00:00 2001 From: Austin Barrington Date: Fri, 4 Sep 2026 22:53:07 +0100 Subject: [PATCH] Allow a one-member sharded cluster to own regions. Bootstrap now takes RF = min(configured, live members), so n=1 gets peers=[self] even when configured RF is 3. --- hyperbytedb/src/application/shard_routing.rs | 50 ++++++++++++++++++- hyperbytedb/tests/common/sharding_cluster.rs | 11 ++++ .../tests/sharding_cluster_integration.rs | 47 +++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/hyperbytedb/src/application/shard_routing.rs b/hyperbytedb/src/application/shard_routing.rs index b31f40a..f224e8d 100644 --- a/hyperbytedb/src/application/shard_routing.rs +++ b/hyperbytedb/src/application/shard_routing.rs @@ -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 @@ -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, @@ -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| { @@ -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) } @@ -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); + } } diff --git a/hyperbytedb/tests/common/sharding_cluster.rs b/hyperbytedb/tests/common/sharding_cluster.rs index b9d56bb..02f537c 100644 --- a/hyperbytedb/tests/common/sharding_cluster.rs +++ b/hyperbytedb/tests/common/sharding_cluster.rs @@ -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, diff --git a/hyperbytedb/tests/sharding_cluster_integration.rs b/hyperbytedb/tests/sharding_cluster_integration.rs index 577ab0c..93a37f3 100644 --- a/hyperbytedb/tests/sharding_cluster_integration.rs +++ b/hyperbytedb/tests/sharding_cluster_integration.rs @@ -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;