From bee824ddd728c4d24cb6ba6559129916a4857404 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Mon, 27 Jul 2026 17:20:11 +0200 Subject: [PATCH 1/3] Add automated tests for zero-token node tolerance --- .../Control/TopologyRefresherTests.cs | 47 +++++ src/Cassandra.Tests/ZeroTokenNodeUnitTests.cs | 179 ++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 src/Cassandra.Tests/ZeroTokenNodeUnitTests.cs diff --git a/src/Cassandra.Tests/Connections/Control/TopologyRefresherTests.cs b/src/Cassandra.Tests/Connections/Control/TopologyRefresherTests.cs index 8a65876e5..c09276774 100644 --- a/src/Cassandra.Tests/Connections/Control/TopologyRefresherTests.cs +++ b/src/Cassandra.Tests/Connections/Control/TopologyRefresherTests.cs @@ -16,6 +16,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Net; using System.Threading.Tasks; @@ -261,6 +262,52 @@ await topologyRefresher.RefreshNodeListAsync( Assert.AreEqual(Version.Parse("2.1.5"), host3.CassandraVersion); } + [Test] + public async Task Should_TolerateZeroTokenPeer_WithoutWarnings() + { + var oldLevel = Diagnostics.CassandraTraceSwitch.Level; + var listener = new TestTraceListener(); + Trace.Listeners.Add(listener); + Diagnostics.CassandraTraceSwitch.Level = TraceLevel.Verbose; + try + { + var zeroTokenPeer = IPAddress.Parse("127.0.0.2"); + var normalPeer = IPAddress.Parse("127.0.0.3"); + var rows = TestHelper.CreateRows(new List> + { + // zero-token node: advertises NULL tokens (e.g. a Scylla coordinator-only node) + new Dictionary{{"rpc_address", zeroTokenPeer}, {"peer", zeroTokenPeer}, { "data_center", "ut-dc" }, { "rack", "ut-rack" }, {"tokens", null}, {"release_version", "3.0.8"}}, + // regular node that owns a token + new Dictionary{{"rpc_address", normalPeer}, {"peer", normalPeer}, { "data_center", "ut-dc" }, { "rack", "ut-rack" }, {"tokens", new [] { "0" }}, {"release_version", "3.0.8"}} + }); + var topologyRefresher = CreateTopologyRefresher(peersRows: rows); + + await topologyRefresher.RefreshNodeListAsync( + new FakeConnectionEndPoint("127.0.0.1", 9042), Mock.Of(), _serializer).ConfigureAwait(false); + + // The zero-token node is a valid, routable host and is part of the cluster metadata. + Assert.AreEqual(3, _metadata.AllHosts().Count); + + var zeroTokenHost = _metadata.GetHost(new IPEndPoint(zeroTokenPeer, ProtocolOptions.DefaultPort)); + Assert.NotNull(zeroTokenHost); + Assert.IsFalse(zeroTokenHost.Tokens.Any(), "zero-token host should have no tokens"); + + var normalHost = _metadata.GetHost(new IPEndPoint(normalPeer, ProtocolOptions.DefaultPort)); + Assert.NotNull(normalHost); + Assert.IsTrue(normalHost.Tokens.Any(), "regular host should keep its tokens"); + + // Processing a zero-token node must not produce any warnings or errors. + Trace.Flush(); + var offending = listener.Queue.Where(m => m.Contains("#ERROR") || m.Contains("#WARNING")).ToList(); + Assert.AreEqual(0, offending.Count, string.Join(Environment.NewLine, offending)); + } + finally + { + Trace.Listeners.Remove(listener); + Diagnostics.CassandraTraceSwitch.Level = oldLevel; + } + } + [Test] public async Task Should_IgnoreNullRpcAddress() { diff --git a/src/Cassandra.Tests/ZeroTokenNodeUnitTests.cs b/src/Cassandra.Tests/ZeroTokenNodeUnitTests.cs new file mode 100644 index 000000000..b68a81dc4 --- /dev/null +++ b/src/Cassandra.Tests/ZeroTokenNodeUnitTests.cs @@ -0,0 +1,179 @@ +// +// Copyright (C) DataStax Inc. +// +// Licensed 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. +// + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Cassandra.Tests.MetadataHelpers.TestHelpers; +using Moq; +using NUnit.Framework; +using Assert = NUnit.Framework.Legacy.ClassicAssert; +using CollectionAssert = NUnit.Framework.Legacy.CollectionAssert; + +namespace Cassandra.Tests +{ + /// + /// Verifies that the driver tolerates zero-token nodes (Scylla coordinator-only nodes that advertise + /// an empty token set). Expected behavior: + /// + /// The node stays a valid, routable host: it is reported with + /// and appears in load balancing query plans, so it can act as a coordinator for non token-aware queries. + /// The node is never selected as a replica for token-aware routing. + /// Processing such a node never produces warnings or errors. + /// + /// + [TestFixture] + public class ZeroTokenNodeUnitTests + { + private static Host NormalHost(string address, string dc, params string[] tokens) + { + return TestHelper.CreateHost(address, dc, "rack1", tokens); + } + + private static Host ZeroTokenHost(string address, string dc) + { + return TestHelper.CreateHost(address, dc, "rack1", new string[0]); + } + + [Test] + public void RoundRobinPolicy_Should_KeepZeroTokenHostRoutable() + { + var normal = NormalHost("0.0.0.1", "dc1", "1"); + var zeroToken = ZeroTokenHost("0.0.0.2", "dc1"); + var hosts = new List { normal, zeroToken }; + var clusterMock = new Mock(); + clusterMock.Setup(c => c.AllHosts()).Returns(hosts); + + var policy = new RoundRobinPolicy(); + policy.Initialize(clusterMock.Object); + + // The zero-token host is still a valid coordinator... + Assert.AreEqual(HostDistance.Local, policy.Distance(zeroToken)); + + // ...and still appears in the query plan alongside the normal host. + var plan = policy.NewQueryPlan(null, new SimpleStatement()).Select(h => h.Host).ToList(); + CollectionAssert.Contains(plan, zeroToken); + CollectionAssert.Contains(plan, normal); + } + + [Test] + public void DCAwareRoundRobinPolicy_Should_KeepLocalZeroTokenHostRoutable() + { + var normal = NormalHost("0.0.0.1", "dc1", "1"); + var zeroToken = ZeroTokenHost("0.0.0.2", "dc1"); + var hosts = new List { normal, zeroToken }; + var clusterMock = new Mock(); + clusterMock.Setup(c => c.AllHosts()).Returns(hosts); + + var policy = new DCAwareRoundRobinPolicy("dc1"); + policy.Initialize(clusterMock.Object); + + Assert.AreEqual(HostDistance.Local, policy.Distance(zeroToken)); + + var plan = policy.NewQueryPlan(null, new SimpleStatement()).Select(h => h.Host).ToList(); + CollectionAssert.Contains(plan, zeroToken); + CollectionAssert.Contains(plan, normal); + } + + [Test] + public void TokenMap_Should_NotSelectZeroTokenHost_AsReplica() + { + var withTokens1 = NormalHost("192.168.0.0", "dc1", "0"); + var zeroToken = ZeroTokenHost("192.168.0.1", "dc1"); + var withTokens2 = NormalHost("192.168.0.2", "dc1", "20"); + var hosts = new List { withTokens1, zeroToken, withTokens2 }; + var keyspaces = new List + { + FakeSchemaParserFactory.CreateSimpleKeyspace("ks1", 2) + }; + + var tokenMap = TokenMap.Build("Murmur3Partitioner", hosts, keyspaces); + + // A token-less host can never enter primaryReplicas; pin the expected token owners. + foreach (var tokenValue in new long[] { -100, 0, 5, 20, 500000 }) + { + var replicas = tokenMap.GetReplicas("ks1", new M3PToken(tokenValue)).Select(r => r.Host).ToList(); + CollectionAssert.AreEquivalent(new[] { withTokens1, withTokens2 }, replicas); + CollectionAssert.DoesNotContain(replicas, zeroToken); + } + } + + [Test] + public void TokenMap_Build_Should_NotThrow_When_HostHasNoTokens() + { + var hosts = new List + { + NormalHost("192.168.0.0", "dc1", "0"), + ZeroTokenHost("192.168.0.1", "dc1"), + }; + var keyspaces = new List + { + FakeSchemaParserFactory.CreateSimpleKeyspace("ks1", 1) + }; + + Assert.DoesNotThrow(() => TokenMap.Build("Murmur3Partitioner", hosts, keyspaces)); + } + + [Test] + public void SetInfo_Should_YieldEmptyTokens_When_TokensAreNullOrEmpty() + { + // Scylla may report the empty token set either as NULL or as an empty collection. + var nullTokenHost = TestHelper.CreateHost("0.0.0.1", "dc1", "rack1", tokens: null); + var emptyTokenHost = TestHelper.CreateHost("0.0.0.2", "dc1", "rack1", tokens: new string[0]); + + Assert.IsNotNull(nullTokenHost.Tokens, "Tokens must never be null"); + Assert.IsNotNull(emptyTokenHost.Tokens, "Tokens must never be null"); + Assert.IsEmpty(nullTokenHost.Tokens); + Assert.IsEmpty(emptyTokenHost.Tokens); + } + + [Test] + public void ZeroTokenNode_Processing_Should_NotLogWarningOrError() + { + var previousLevel = Diagnostics.CassandraTraceSwitch.Level; + var listener = new TestTraceListener(); + Diagnostics.CassandraTraceSwitch.Level = TraceLevel.Verbose; + Trace.Listeners.Add(listener); + try + { + var hosts = new List + { + NormalHost("192.168.0.0", "dc1", "0"), + ZeroTokenHost("192.168.0.1", "dc1"), + NormalHost("192.168.0.2", "dc1", "20"), + }; + var keyspaces = new List + { + FakeSchemaParserFactory.CreateSimpleKeyspace("ks1", 2) + }; + + TokenMap.Build("Murmur3Partitioner", hosts, keyspaces); + + Trace.Flush(); + var offending = listener.Queue + .Where(m => m.Contains("#ERROR") || m.Contains("#WARNING")) + .ToList(); + Assert.AreEqual(0, offending.Count, string.Join(Environment.NewLine, offending)); + } + finally + { + Trace.Listeners.Remove(listener); + Diagnostics.CassandraTraceSwitch.Level = previousLevel; + } + } + } +} From b8b1f84af6846f7b5aacbfe2a5e62bc94008b7f2 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Mon, 27 Jul 2026 18:52:07 +0200 Subject: [PATCH 2/3] Add integration tests for zero-token nodes --- .../Core/ZeroTokenNodeTests.cs | 373 ++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 src/Cassandra.IntegrationTests/Core/ZeroTokenNodeTests.cs diff --git a/src/Cassandra.IntegrationTests/Core/ZeroTokenNodeTests.cs b/src/Cassandra.IntegrationTests/Core/ZeroTokenNodeTests.cs new file mode 100644 index 000000000..bee3c18f6 --- /dev/null +++ b/src/Cassandra.IntegrationTests/Core/ZeroTokenNodeTests.cs @@ -0,0 +1,373 @@ +// +// Copyright (C) DataStax Inc. +// +// Licensed 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. +// + +using System.Collections.Generic; +using System.Linq; +using Cassandra.IntegrationTests.TestBase; +using Cassandra.IntegrationTests.TestClusterManagement; +using Cassandra.Tests; +using NUnit.Framework; +using Assert = NUnit.Framework.Legacy.ClassicAssert; +using CollectionAssert = NUnit.Framework.Legacy.CollectionAssert; + +namespace Cassandra.IntegrationTests.Core +{ + /// + /// Common setup for the fixtures that exercise a real cluster containing a zero-token node, i.e. a + /// node started with join_ring: false that therefore does not own any token. + /// + public abstract class ZeroTokenNodeTestBase : SharedClusterTest + { + protected const int NormalNodes = 2; + protected const int ZeroTokenNodeId = ZeroTokenNodeTestBase.NormalNodes + 1; + protected const string TableName = "zero_token_routing"; + + /// + /// Amount of distinct partition keys used to probe the token map. With a handful of token + /// owners this is more than enough to cover every token range at least once. + /// + protected const int RoutingKeySamples = 60; + + private PreparedStatement _insert; + + protected ZeroTokenNodeTestBase() : base(ZeroTokenNodeTestBase.NormalNodes) + { + } + + protected override string[] SetupQueries => new[] + { + $"CREATE TABLE {ZeroTokenNodeTestBase.TableName} (k int PRIMARY KEY, v int)" + }; + + protected string ZeroTokenAddress => TestCluster.ClusterIpPrefix + ZeroTokenNodeTestBase.ZeroTokenNodeId; + + public override void OneTimeSetUp() + { + base.OneTimeSetUp(); + + // Add a node that does not join the token ring. It has to be configured before being + // started, otherwise it would bootstrap as a regular token owner. + TestCluster.BootstrapNode(ZeroTokenNodeTestBase.ZeroTokenNodeId, false); + TestCluster.UpdateConfig(ZeroTokenNodeTestBase.ZeroTokenNodeId, "join_ring: false"); + TestCluster.Start(ZeroTokenNodeTestBase.ZeroTokenNodeId); + TestUtils.WaitForUp(ZeroTokenAddress, SharedClusterTest.DefaultCassandraPort, 120); + + TestHelper.RetryAssert( + () => Assert.AreEqual(ZeroTokenNodeTestBase.NormalNodes + 1, Cluster.AllHosts().Count), + 1000, + 120); + + _insert = Session.Prepare($"INSERT INTO {ZeroTokenNodeTestBase.TableName} (k, v) VALUES (?, ?)"); + } + + protected Host GetZeroTokenHost() + { + return Cluster.AllHosts().Single(h => h.Address.Address.ToString() == ZeroTokenAddress); + } + + protected byte[] GetRoutingKey(int partitionKey) + { + return _insert.Bind(partitionKey, partitionKey).RoutingKey.RawRoutingKey; + } + } + + /// + /// Verifies the driver behaviour against a real cluster that contains a zero-token node. + /// + /// Expected semantics (aligned with the Rust driver): + /// + /// a zero-token node is an ordinary, routable node: it is discovered, kept in the load + /// balancing plans, can act as a coordinator and can be used as a contact point; + /// a zero-token node never owns data, so it must never be returned as a replica by the + /// token map, and consequently token aware routing must never target it. + /// + /// + /// + [TestFixture] + [Category(TestCategory.Short), Category(TestCategory.RealCluster)] + [TestScyllaVersion(2025, 1)] + public class ZeroTokenNodeTests : ZeroTokenNodeTestBase + { + /// + /// Keyspace whose replication factor is higher than the amount of token owners in the cluster. + /// + private const string RfKeyspace = "zero_token_rf"; + + public override void OneTimeSetUp() + { + base.OneTimeSetUp(); + + // Created once the zero-token node is up so that the server does not complain about a + // replication factor higher than the amount of nodes. + Session.Execute( + $"CREATE KEYSPACE {ZeroTokenNodeTests.RfKeyspace} WITH replication = " + + "{'class': 'NetworkTopologyStrategy', 'replication_factor': '3'}"); + } + + [Test] + [Order(1)] + public void Should_DiscoverZeroTokenNode_Without_Tokens() + { + var hosts = Cluster.AllHosts(); + Assert.AreEqual(ZeroTokenNodeTests.NormalNodes + 1, hosts.Count); + + var zeroTokenHost = GetZeroTokenHost(); + Assert.IsTrue(zeroTokenHost.IsUp, "the zero-token node should be seen as UP"); + Assert.IsFalse( + zeroTokenHost.Tokens.Any(), + $"the zero-token node {ZeroTokenAddress} must not own any token"); + + foreach (var host in hosts.Where(h => !h.Equals(zeroTokenHost))) + { + Assert.IsTrue(host.Tokens.Any(), $"the regular node {host.Address} should own tokens"); + } + } + + [Test] + [Order(2)] + public void Should_NeverSelectZeroTokenNode_As_Replica() + { + AssertZeroTokenNodeIsNeverAReplica(); + } + + [Test] + [Order(3)] + public void Should_NeverUseZeroTokenNode_As_Coordinator_When_TokenAware() + { + var localDc = Cluster.AllHosts().First(h => h.Tokens.Any()).Datacenter; + var cluster = GetNewTemporaryCluster( + b => b.WithLoadBalancingPolicy(new TokenAwarePolicy(new DCAwareRoundRobinPolicy(localDc)))); + var session = cluster.Connect(KeyspaceName); + var insert = session.Prepare($"INSERT INTO {ZeroTokenNodeTests.TableName} (k, v) VALUES (?, ?)"); + + for (var i = 0; i < ZeroTokenNodeTests.RoutingKeySamples; i++) + { + var rs = session.Execute(insert.Bind(i, i)); + + Assert.AreNotEqual( + ZeroTokenAddress, + rs.Info.QueriedHost.Address.ToString(), + $"the zero-token node coordinated a token aware query for partition key {i}"); + } + } + + [Test] + [Order(4)] + public void Should_UseZeroTokenNode_As_Coordinator_When_RoutingIsNotTokenAware() + { + var cluster = GetNewTemporaryCluster(b => b.WithLoadBalancingPolicy(new RoundRobinPolicy())); + var session = cluster.Connect(KeyspaceName); + var coordinators = new HashSet(); + + for (var i = 0; i < ZeroTokenNodeTests.RoutingKeySamples; i++) + { + var rs = session.Execute($"SELECT k, v FROM {ZeroTokenNodeTests.TableName} WHERE k = {i}"); + coordinators.Add(rs.Info.QueriedHost.Address.ToString()); + } + + CollectionAssert.Contains( + coordinators, + ZeroTokenAddress, + "a zero-token node owns no data but is still a usable coordinator"); + } + + [Test] + [Order(5)] + public void Should_RetrieveSchemaMetadata_When_ZeroTokenNodeIsPresent() + { + Assert.IsTrue(Cluster.RefreshSchema(KeyspaceName)); + + var keyspace = Cluster.Metadata.GetKeyspace(KeyspaceName); + Assert.IsNotNull(keyspace); + CollectionAssert.Contains(keyspace.GetTablesNames(), ZeroTokenNodeTests.TableName); + + // Note: table-level metadata is deliberately not inspected here. + // Reproduced on Scylla release:2026.1.6 even on clusters with no zero-token nodes: + // SchemaParser V2 can throw when table fields are null for + // bloom_filter_fp_chance, dclocal_read_repair_chance, read_repair_chance. + // TODO: replace with tracking issue link when available. + } + + [Test] + [Order(6)] + public void Should_Connect_When_ZeroTokenNodeIsTheOnlyContactPoint() + { + var cluster = ClusterBuilder() + .AddContactPoint(ZeroTokenAddress) + .WithQueryTimeout(60000) + .WithSocketOptions(new SocketOptions().SetConnectTimeoutMillis(30000).SetReadTimeoutMillis(22000)) + .Build(); + ClusterInstances.Add(cluster); + + var session = cluster.Connect(KeyspaceName); + var insert = session.Prepare($"INSERT INTO {ZeroTokenNodeTests.TableName} (k, v) VALUES (?, ?)"); + + Assert.AreEqual( + ZeroTokenNodeTests.NormalNodes + 1, + cluster.AllHosts().Count, + "the whole topology should be discovered through the zero-token node"); + Assert.IsFalse( + cluster.AllHosts().Single(h => h.Address.Address.ToString() == ZeroTokenAddress).Tokens.Any()); + + for (var i = 0; i < ZeroTokenNodeTests.RoutingKeySamples; i++) + { + session.Execute(insert.Bind(i, i)); + } + } + + [Test] + [Order(7)] + [Category(TestCategory.RealClusterLong)] + public void Should_KeepServingQueries_When_ZeroTokenNodeGoesDownAndUp() + { + // The driver trusts the connection pools rather than the server status change events, + // so the node has to be part of the query plan for its state to be tracked. + var cluster = GetNewTemporaryCluster(b => b.WithLoadBalancingPolicy(new RoundRobinPolicy())); + var session = cluster.Connect(KeyspaceName); + var zeroTokenHost = cluster.AllHosts().Single(h => h.Address.Address.ToString() == ZeroTokenAddress); + var query = new SimpleStatement($"SELECT k, v FROM {ZeroTokenNodeTests.TableName} WHERE k = 1"); + + TestCluster.Stop(ZeroTokenNodeTests.ZeroTokenNodeId); + try + { + // RetryAssert only retries failed assertions, so a query that cannot be served by + // the remaining nodes fails the test right away. + TestHelper.RetryAssert( + () => + { + session.Execute(query); + Assert.IsFalse(zeroTokenHost.IsUp, "the zero-token node should be seen as DOWN"); + }, + 1000, + 120); + } + finally + { + TestCluster.Start(ZeroTokenNodeTests.ZeroTokenNodeId); + TestUtils.WaitForUp(ZeroTokenAddress, SharedClusterTest.DefaultCassandraPort, 120); + } + + TestHelper.RetryAssert( + () => + { + session.Execute(query); + Assert.IsTrue(zeroTokenHost.IsUp, "the zero-token node should be seen as UP again"); + }, + 1000, + 120); + + Assert.IsFalse( + GetZeroTokenHost().Tokens.Any(), + "the node should still be a zero-token node after a restart"); + AssertZeroTokenNodeIsNeverAReplica(); + } + + [Test] + [Order(8)] + public void Should_ReturnOnlyTokenOwners_When_RfExceedsNumberOfTokenOwners() + { + // Characterization test: the shared keyspace is created with RF=1, so this is the only + // place where the multi replica NetworkTopologyStrategy placement loop runs end to end. + // RF is 3 but only two nodes own tokens, so the zero-token node must not be used to pad + // the replica set. + var tokenOwners = Cluster.AllHosts().Where(h => h.Tokens.Any()).ToList(); + Assert.AreEqual(ZeroTokenNodeTests.NormalNodes, tokenOwners.Count); + + // The token map for the keyspace is refreshed asynchronously through a schema event. + TestHelper.RetryAssert( + () => + { + for (var i = 0; i < ZeroTokenNodeTests.RoutingKeySamples; i++) + { + var replicas = Cluster.Metadata + .GetReplicas(ZeroTokenNodeTests.RfKeyspace, GetRoutingKey(i)) + .Select(r => r.Host) + .ToList(); + + CollectionAssert.AreEquivalent(tokenOwners, replicas); + } + }, + 1000, + 30); + } + + private void AssertZeroTokenNodeIsNeverAReplica() + { + var zeroTokenHost = GetZeroTokenHost(); + + for (var i = 0; i < ZeroTokenNodeTests.RoutingKeySamples; i++) + { + var replicas = Cluster.Metadata.GetReplicas(KeyspaceName, GetRoutingKey(i)); + + Assert.IsNotEmpty(replicas, $"no replica was computed for partition key {i}"); + Assert.IsFalse( + replicas.Any(r => r.Host.Equals(zeroTokenHost)), + $"the zero-token node {ZeroTokenAddress} was returned as a replica for partition key {i}"); + } + } + } + + [TestFixture] + [Category(TestCategory.RealCluster), Category(TestCategory.RealClusterLong)] + [TestScyllaVersion(2025, 1)] + public class ZeroTokenNodeReplacementTests : ZeroTokenNodeTestBase + { + [Test] + public void Should_StartRoutingToNode_When_ZeroTokenNodeIsReplacedByRegularNode() + { + Assert.IsFalse(GetZeroTokenHost().Tokens.Any()); + + // ScyllaDB refuses to turn a zero-token node into a token owner in place, it fails to + // start with "Cannot restart with join_ring=true because the node has already joined + // the cluster as a zero-token node". The supported upgrade path is to decommission the + // zero-token node and to add a regular node in its place. + TestCluster.DecommissionNode(ZeroTokenNodeReplacementTests.ZeroTokenNodeId); + TestCluster.Remove(ZeroTokenNodeReplacementTests.ZeroTokenNodeId); + TestHelper.RetryAssert( + () => Assert.AreEqual(ZeroTokenNodeReplacementTests.NormalNodes, Cluster.AllHosts().Count), + 1000, + 120); + + TestCluster.BootstrapNode(ZeroTokenNodeReplacementTests.ZeroTokenNodeId, true); + TestUtils.WaitForUp(ZeroTokenAddress, SharedClusterTest.DefaultCassandraPort, 120); + + // The driver has to pick up the tokens of the replacement node without being restarted. + TestHelper.RetryAssert( + () => + { + var upgraded = Cluster.AllHosts().SingleOrDefault(h => h.Address.Address.ToString() == ZeroTokenAddress); + Assert.IsNotNull(upgraded, "the replacement node should have been discovered"); + Assert.IsTrue(upgraded.IsUp); + Assert.IsTrue(upgraded.Tokens.Any(), "the replacement node should own tokens"); + }, + 1000, + 240); + + // And it has to start using it for token aware routing. + TestHelper.RetryAssert( + () => + { + var isReplica = Enumerable + .Range(0, ZeroTokenNodeReplacementTests.RoutingKeySamples) + .SelectMany(i => Cluster.Metadata.GetReplicas(KeyspaceName, GetRoutingKey(i))) + .Any(r => r.Host.Address.Address.ToString() == ZeroTokenAddress); + Assert.IsTrue(isReplica, "the node that replaced the zero-token node should now own data"); + }, + 1000, + 240); + } + } +} From 4790a4b32b8bd5d8ecd2e9ba048efead4a8c3d85 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Mon, 27 Jul 2026 18:52:07 +0200 Subject: [PATCH 3/3] Fix NTS replica placement with zero-token racks --- src/Cassandra.Tests/ZeroTokenNodeUnitTests.cs | 100 ++++++++++++++++++ src/Cassandra/TokenMap.cs | 8 +- 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/Cassandra.Tests/ZeroTokenNodeUnitTests.cs b/src/Cassandra.Tests/ZeroTokenNodeUnitTests.cs index b68a81dc4..9c6ac604f 100644 --- a/src/Cassandra.Tests/ZeroTokenNodeUnitTests.cs +++ b/src/Cassandra.Tests/ZeroTokenNodeUnitTests.cs @@ -112,6 +112,106 @@ public void TokenMap_Should_NotSelectZeroTokenHost_AsReplica() } } + [Test] + public void TokenMap_NetworkTopologyStrategy_Should_NotCountZeroTokenRackForRfPlacement() + { + // Two token owners are on rack1 and a zero-token host is on rack2. + // RF=2 in NTS must still return both token owners. + var withTokens1 = TestHelper.CreateHost("192.168.1.0", "dc1", "rack1", new[] { "0" }); + var zeroToken = TestHelper.CreateHost("192.168.1.1", "dc1", "rack2", new string[0]); + var withTokens2 = TestHelper.CreateHost("192.168.1.2", "dc1", "rack1", new[] { "20" }); + var hosts = new List { withTokens1, zeroToken, withTokens2 }; + var keyspaces = new List + { + FakeSchemaParserFactory.CreateNetworkTopologyKeyspace( + "ks_nts", + new Dictionary { { "dc1", "2" } }) + }; + + var tokenMap = TokenMap.Build("Murmur3Partitioner", hosts, keyspaces); + + foreach (var tokenValue in new long[] { -100, 0, 5, 20, 500000 }) + { + var replicas = tokenMap.GetReplicas("ks_nts", new M3PToken(tokenValue)).Select(r => r.Host).ToList(); + CollectionAssert.AreEquivalent(new[] { withTokens1, withTokens2 }, replicas); + CollectionAssert.DoesNotContain(replicas, zeroToken); + } + } + + [Test] + public void TokenMap_Should_IgnoreDatacenter_When_ItOnlyContainsZeroTokenHosts() + { + // dc2 owns no token at all, so it must not take part in replica placement and its + // replication factor must be considered satisfied instead of triggering a full ring scan. + var withTokens1 = NormalHost("192.168.2.0", "dc1", "0"); + var withTokens2 = NormalHost("192.168.2.1", "dc1", "20"); + var zeroTokenOnlyDc = ZeroTokenHost("192.168.2.2", "dc2"); + var hosts = new List { withTokens1, withTokens2, zeroTokenOnlyDc }; + var keyspaces = new List + { + FakeSchemaParserFactory.CreateNetworkTopologyKeyspace( + "ks_nts", + new Dictionary { { "dc1", "2" }, { "dc2", "1" } }) + }; + + var tokenMap = TokenMap.Build("Murmur3Partitioner", hosts, keyspaces); + + foreach (var tokenValue in new long[] { -100, 0, 5, 20, 500000 }) + { + var replicas = tokenMap.GetReplicas("ks_nts", new M3PToken(tokenValue)).Select(r => r.Host).ToList(); + CollectionAssert.AreEquivalent(new[] { withTokens1, withTokens2 }, replicas); + CollectionAssert.DoesNotContain(replicas, zeroTokenOnlyDc); + } + } + + [Test] + public void TokenMap_NetworkTopologyStrategy_Should_StartPlacingReplicasInDc_When_ZeroTokenHostIsReplaced() + { + var dc1Host1 = NormalHost("192.168.3.0", "dc1", "0"); + var dc1Host2 = NormalHost("192.168.3.1", "dc1", "20"); + var dc2ZeroToken = ZeroTokenHost("192.168.3.2", "dc2"); + var keyspaces = new List + { + FakeSchemaParserFactory.CreateNetworkTopologyKeyspace( + "ks_nts", + new Dictionary { { "dc1", "1" }, { "dc2", "1" } }) + }; + + var tokenMapBeforeReplacement = TokenMap.Build( + "Murmur3Partitioner", + new List { dc1Host1, dc1Host2, dc2ZeroToken }, + keyspaces); + + var dc2Replacement = NormalHost("192.168.3.2", "dc2", "10"); + var tokenMapAfterReplacement = TokenMap.Build( + "Murmur3Partitioner", + new List { dc1Host1, dc1Host2, dc2Replacement }, + keyspaces); + + foreach (var tokenValue in new long[] { -100, 0, 5, 10, 20, 500000 }) + { + var replicasBefore = tokenMapBeforeReplacement + .GetReplicas("ks_nts", new M3PToken(tokenValue)) + .Select(r => r.Host) + .ToList(); + + CollectionAssert.DoesNotContain(replicasBefore, dc2ZeroToken); + Assert.IsFalse(replicasBefore.Any(h => h.Datacenter == "dc2")); + Assert.AreEqual(1, replicasBefore.Count(h => h.Datacenter == "dc1")); + Assert.AreEqual(1, replicasBefore.Count); + + var replicasAfter = tokenMapAfterReplacement + .GetReplicas("ks_nts", new M3PToken(tokenValue)) + .Select(r => r.Host) + .ToList(); + + CollectionAssert.Contains(replicasAfter, dc2Replacement); + Assert.AreEqual(1, replicasAfter.Count(h => h.Datacenter == "dc2")); + Assert.AreEqual(1, replicasAfter.Count(h => h.Datacenter == "dc1")); + Assert.AreEqual(2, replicasAfter.Count); + } + } + [Test] public void TokenMap_Build_Should_NotThrow_When_HostHasNoTokens() { diff --git a/src/Cassandra/TokenMap.cs b/src/Cassandra/TokenMap.cs index 569aa2df1..b76244e7c 100644 --- a/src/Cassandra/TokenMap.cs +++ b/src/Cassandra/TokenMap.cs @@ -121,7 +121,13 @@ public static TokenMap Build(string partitioner, ICollection hosts, IColle var datacenters = new Dictionary(); foreach (var host in hosts) { - if (host.Datacenter != null) + // Only hosts that own tokens take part in replica placement, so zero-token hosts must not + // contribute to a datacenter's host count or rack set. This keeps the invariant that every + // host in primaryReplicas with a non null datacenter has an entry in `datacenters`, which + // the replication strategies rely on. A datacenter made up exclusively of zero-token hosts + // is therefore absent from `datacenters` and its replication factor is trivially satisfied. + var hasTokens = host.Tokens.Any(); + if (hasTokens && host.Datacenter != null) { if (!datacenters.TryGetValue(host.Datacenter, out var dc)) {