This is an automated email from the ASF dual-hosted git repository.

sarankk pushed a commit to branch mutation-tracking-support
in repository https://gitbox.apache.org/repos/asf/cassandra-analytics.git


The following commit(s) were added to refs/heads/mutation-tracking-support by 
this push:
     new 56261ab9 CASSANALYTICS-197: Source token ranges from Cassandra for 
mutation tracked keyspaces (#244)
56261ab9 is described below

commit 56261ab98c25e312beedbe5aa0e22d66f84c23cb
Author: Mansi Khara <[email protected]>
AuthorDate: Thu Oct 29 10:15:03 2026 -0700

    CASSANALYTICS-197: Source token ranges from Cassandra for mutation tracked 
keyspaces (#244)
    
     patch by Mansi Khara; reviewed by Saranya Krishnakumar, Jyothsna Konisa, 
Arjun Ashok for CASSANALYTICS-197
---
 CHANGES.txt                                        |   1 +
 .../spark/data/partitioner/CassandraRing.java      | 240 ++++++++++++++++---
 .../spark/data/partitioner/CassandraRingTests.java | 247 ++++++++++++++++++-
 .../cassandra/spark/data/CassandraDataLayer.java   | 140 ++++++++++-
 .../apache/cassandra/spark/data/ClientConfig.java  |  12 +
 .../spark/data/CassandraDataLayerTests.java        | 252 ++++++++++++++++++++
 .../distributed/impl/CassandraCluster.java         |  23 +-
 .../BulkReaderCassandraTokenRangesTest.java        | 154 ++++++++++++
 .../analytics/BulkReaderMultiRackTest.java         | 263 +++++++++++++++++++++
 9 files changed, 1278 insertions(+), 54 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 16f9ff38..4d503cbc 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,6 @@
 0.5.0
 -----
+ * Source token ranges from Cassandra for keyspaces with transient replicas 
(CASSANALYTICS-197)
  * Update 6.0 dtest version to SHA from cep-45-mutation-tracking branch 
(CASSANALYTICS-200)
  * Parse <replicas>/<transient> replication factor for witness-enabled 
keyspaces (CASSANALYTICS-194)
  * Determine whether mutation tracking is enabled for keyspace for bulk writes 
(CASSANALYTICS-160)
diff --git 
a/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/data/partitioner/CassandraRing.java
 
b/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/data/partitioner/CassandraRing.java
index 5f12e1a8..563babcc 100644
--- 
a/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/data/partitioner/CassandraRing.java
+++ 
b/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/data/partitioner/CassandraRing.java
@@ -28,7 +28,10 @@ import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Comparator;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 import java.util.Objects;
@@ -56,7 +59,10 @@ import static 
org.apache.cassandra.spark.data.ReplicationFactor.ReplicationStrat
  * As Cassandra token ranges are dependent on Replication strategy, ring makes 
sense for a specific keyspace only.
  * It is made to be immutable for the sake of simplicity.
  * <p>
- * Token ranges are calculated assuming Cassandra racks are not being used, 
but controlled by assigning tokens properly.
+ * Token ranges are either supplied by Cassandra, or calculated locally. The 
local calculation assumes Cassandra racks
+ * are not being used, but controlled by assigning tokens properly. Callers 
that cannot rely on that assumption, such
+ * as the bulk reader for a keyspace with witness replicas, supply the ranges 
instead - see
+ * {@link #CassandraRing(Partitioner, String, ReplicationFactor, Collection, 
Map)}.
  * <p>
  * {@link #equals(Object)} and {@link #hashCode()} don't take {@link 
#replicas} and {@link #tokenRangeMap}
  * into consideration as they are just derived fields.
@@ -77,16 +83,23 @@ public class CassandraRing implements Serializable
 
     /**
      * Incremented whenever the hand-rolled JDK serialization format below 
changes. Version 1 writes the
-     * ReplicationFactor as an object rather than destructuring it.
+     * ReplicationFactor as an object rather than destructuring it. Version 2 
added the optional
+     * Cassandra-reported token ranges after the instances: each instance 
followed by the ranges it replicates.
      */
-    private static final byte SERIALIZATION_FORMAT_VERSION = 1;
+    private static final byte SERIALIZATION_FORMAT_VERSION = 2;
 
     private Partitioner partitioner;
     private String keyspace;
     private ReplicationFactor replicationFactor;
     private List<CassandraInstance> instances;
+    /**
+     * Whether {@link #tokenRangeMap} was supplied by Cassandra rather than 
derived locally from tokens and the
+     * replication factor. Deriving assumes racks are not in use; Cassandra 
supplying the ranges removes that assumption.
+     */
+    private boolean rangesReportedByCassandra;
 
     private transient RangeMap<BigInteger, List<CassandraInstance>> replicas;
+    // Each instance to the token ranges it replicates: derived in init(), 
unless reported by Cassandra
     private transient Multimap<CassandraInstance, Range<BigInteger>> 
tokenRangeMap;
 
     /**
@@ -122,53 +135,141 @@ public class CassandraRing implements Serializable
                          String keyspace,
                          ReplicationFactor replicationFactor,
                          Collection<CassandraInstance> instances)
+    {
+        this(partitioner, keyspace, replicationFactor, instances, null);
+    }
+
+    /**
+     * Constructs a ring from Cassandra-reported token ranges, or derives them 
if none are supplied.
+     *
+     * @param partitioner       the partitioner
+     * @param keyspace          the keyspace this ring describes
+     * @param replicationFactor the keyspace replication factor
+     * @param instances         all instances in the ring
+     * @param rangeReplicas     range to replicas mapping as reported by 
Cassandra, or {@code null} to derive it.
+     *                          Replicas must be present in {@code instances}.
+     */
+    public CassandraRing(Partitioner partitioner,
+                         String keyspace,
+                         ReplicationFactor replicationFactor,
+                         Collection<CassandraInstance> instances,
+                         Map<Range<BigInteger>, ? extends 
Collection<CassandraInstance>> rangeReplicas)
+    {
+        this.partitioner = partitioner;
+        this.keyspace = keyspace;
+        this.replicationFactor = replicationFactor;
+        this.instances = sortedDistinct(instances);
+        this.rangesReportedByCassandra = rangeReplicas != null;
+        this.tokenRangeMap = rangeReplicas == null ? null : 
reportedTokenRanges(rangeReplicas, this.instances);
+        this.init();
+    }
+
+    /**
+     * Used by deserialization, which already holds the instances in order and 
the instance to ranges mapping
+     */
+    private CassandraRing(Partitioner partitioner,
+                          String keyspace,
+                          ReplicationFactor replicationFactor,
+                          List<CassandraInstance> instances,
+                          Multimap<CassandraInstance, Range<BigInteger>> 
reportedTokenRanges)
     {
         this.partitioner = partitioner;
         this.keyspace = keyspace;
         this.replicationFactor = replicationFactor;
-        this.instances = instances.stream()
-                                  .sorted(Comparator.comparing(instance -> new 
BigInteger(instance.token())))
-                                  
.collect(Collectors.toCollection(ArrayList::new));
+        this.instances = sortedDistinct(instances);
+        this.rangesReportedByCassandra = true;
+        this.tokenRangeMap = reportedTokenRanges;
         this.init();
     }
 
+    private static List<CassandraInstance> 
sortedDistinct(Collection<CassandraInstance> instances)
+    {
+        return instances.stream()
+                        .distinct()
+                        .sorted(Comparator.comparing(instance -> new 
BigInteger(instance.token())))
+                        .collect(Collectors.toCollection(ArrayList::new));
+    }
+
+    /**
+     * Inverts the reported range to replicas mapping into the instance to 
ranges form held by
+     * {@link #tokenRangeMap}, adding ranges in token order so that a ring and 
its deserialized copy compare equal.
+     * Ranges are normalised to open-closed.
+     */
+    private static Multimap<CassandraInstance, Range<BigInteger>> 
reportedTokenRanges(
+    Map<Range<BigInteger>, ? extends Collection<CassandraInstance>> 
rangeReplicas,
+    List<CassandraInstance> instances)
+    {
+        Set<CassandraInstance> ringInstances = new HashSet<>(instances);
+        Multimap<CassandraInstance, Range<BigInteger>> tokenRanges = 
ArrayListMultimap.create();
+        rangeReplicas.entrySet()
+                     .stream()
+                     .sorted(Comparator.comparing(entry -> 
entry.getKey().lowerEndpoint()))
+                     .forEach(entry -> {
+                         Range<BigInteger> range = entry.getKey();
+                         Range<BigInteger> openClosed = 
Range.openClosed(range.lowerEndpoint(), range.upperEndpoint());
+                         for (CassandraInstance replica : entry.getValue())
+                         {
+                             
Preconditions.checkArgument(ringInstances.contains(replica),
+                                                         "Replica %s for range 
%s is not present in the ring instances",
+                                                         replica, range);
+                             tokenRanges.put(replica, openClosed);
+                         }
+                     });
+        return tokenRanges;
+    }
+
+    /**
+     * @return {@code true} if this ring uses token ranges reported by 
Cassandra rather than locally derived ones
+     */
+    public boolean hasReportedRanges()
+    {
+        return rangesReportedByCassandra;
+    }
+
     private void init()
     {
         // Setup token range map
         replicas = TreeRangeMap.create();
-        tokenRangeMap = ArrayListMultimap.create();
 
-        // Calculate instance to token ranges mapping
-        switch (replicationFactor.getReplicationStrategy())
+        if (!rangesReportedByCassandra)
         {
-            case SimpleStrategy:
-                tokenRangeMap.putAll(RangeUtils.calculateTokenRanges(instances,
-                                                                     
replicationFactor.getTotalReplicationFactor(),
-                                                                     
partitioner));
-                break;
-            case NetworkTopologyStrategy:
-                for (String dataCenter : dataCenters())
-                {
-                    int rf = replicationFactor.getOptions().get(dataCenter);
-                    if (rf == 0)
+            // Calculate instance to token ranges mapping
+            tokenRangeMap = ArrayListMultimap.create();
+            switch (replicationFactor.getReplicationStrategy())
+            {
+                case SimpleStrategy:
+                    
tokenRangeMap.putAll(RangeUtils.calculateTokenRanges(instances,
+                                                                         
replicationFactor.getTotalReplicationFactor(),
+                                                                         
partitioner));
+                    break;
+                case NetworkTopologyStrategy:
+                    for (String dataCenter : dataCenters())
                     {
-                        continue;
+                        int rf = 
replicationFactor.getOptions().get(dataCenter);
+                        if (rf == 0)
+                        {
+                            continue;
+                        }
+                        List<CassandraInstance> dcInstances = 
instances.stream()
+                                .filter(instance -> 
instance.dataCenter().matches(dataCenter))
+                                .collect(Collectors.toList());
+                        
tokenRangeMap.putAll(RangeUtils.calculateTokenRanges(dcInstances,
+                                                                             
replicationFactor.getOptions().get(dataCenter),
+                                                                             
partitioner));
                     }
-                    List<CassandraInstance> dcInstances = instances.stream()
-                            .filter(instance -> 
instance.dataCenter().matches(dataCenter))
-                            .collect(Collectors.toList());
-                    
tokenRangeMap.putAll(RangeUtils.calculateTokenRanges(dcInstances,
-                                                                         
replicationFactor.getOptions().get(dataCenter),
-                                                                         
partitioner));
-                }
-                break;
-            default:
-                throw new UnsupportedOperationException("Unsupported 
replication strategy");
+                    break;
+                default:
+                    throw new UnsupportedOperationException("Unsupported 
replication strategy");
+            }
         }
 
-        // Calculate token range to replica mapping
+        // Calculate token range to replica mapping. Replicas are added in 
instance (token) order rather than the
+        // multimap's hash order, so each range lists its replicas in the same 
order however the ring was built.
         replicas.put(Range.openClosed(partitioner.minToken(), 
partitioner.maxToken()), Collections.emptyList());
-        tokenRangeMap.asMap().forEach((instance, ranges) -> 
ranges.forEach(range -> addReplica(instance, range, replicas)));
+        for (CassandraInstance instance : instances)
+        {
+            tokenRangeMap.get(instance).forEach(range -> addReplica(instance, 
range, replicas));
+        }
     }
 
     public Partitioner partitioner()
@@ -259,6 +360,7 @@ public class CassandraRing implements Serializable
                && Objects.equals(this.keyspace, that.keyspace)
                && Objects.equals(this.replicationFactor, 
that.replicationFactor)
                && Objects.equals(this.instances, that.instances)
+               && this.rangesReportedByCassandra == 
that.rangesReportedByCassandra
                && Objects.equals(this.replicas, that.replicas)
                && Objects.equals(this.tokenRangeMap, that.tokenRangeMap);
     }
@@ -266,7 +368,7 @@ public class CassandraRing implements Serializable
     @Override
     public int hashCode()
     {
-        return Objects.hash(partitioner, keyspace, replicationFactor, 
instances, replicas, tokenRangeMap);
+        return Objects.hash(partitioner, keyspace, replicationFactor, 
instances, rangesReportedByCassandra, replicas, tokenRangeMap);
     }
 
     private void readObject(ObjectInputStream in) throws IOException, 
ClassNotFoundException
@@ -291,6 +393,21 @@ public class CassandraRing implements Serializable
         {
             this.instances.add(new CassandraInstance(in.readUTF(), 
in.readUTF(), in.readUTF()));
         }
+
+        this.rangesReportedByCassandra = in.readBoolean();
+        if (rangesReportedByCassandra)
+        {
+            // Written as each instance followed by the ranges it replicates
+            this.tokenRangeMap = ArrayListMultimap.create();
+            for (CassandraInstance instance : this.instances)
+            {
+                int numRanges = in.readInt();
+                for (int range = 0; range < numRanges; range++)
+                {
+                    tokenRangeMap.put(instance, Range.openClosed(new 
BigInteger(in.readUTF()), new BigInteger(in.readUTF())));
+                }
+            }
+        }
         this.init();
     }
 
@@ -310,6 +427,21 @@ public class CassandraRing implements Serializable
             out.writeUTF(instance.nodeName());
             out.writeUTF(instance.dataCenter());
         }
+
+        out.writeBoolean(this.rangesReportedByCassandra);
+        if (this.rangesReportedByCassandra)
+        {
+            for (CassandraInstance instance : this.instances)
+            {
+                Collection<Range<BigInteger>> ranges = 
this.tokenRangeMap.get(instance);
+                out.writeInt(ranges.size());
+                for (Range<BigInteger> range : ranges)
+                {
+                    out.writeUTF(range.lowerEndpoint().toString());
+                    out.writeUTF(range.upperEndpoint().toString());
+                }
+            }
+        }
     }
 
     public static class Serializer extends 
com.esotericsoftware.kryo.Serializer<CassandraRing>
@@ -321,17 +453,49 @@ public class CassandraRing implements Serializable
             out.writeString(ring.keyspace);
             kryo.writeObject(out, ring.replicationFactor);
             kryo.writeObject(out, ring.instances);
+            out.writeBoolean(ring.rangesReportedByCassandra);
+            if (ring.rangesReportedByCassandra)
+            {
+                // Each instance followed by the ranges it replicates
+                for (CassandraInstance instance : ring.instances)
+                {
+                    Collection<Range<BigInteger>> ranges = 
ring.tokenRangeMap.get(instance);
+                    out.writeInt(ranges.size());
+                    for (Range<BigInteger> range : ranges)
+                    {
+                        out.writeString(range.lowerEndpoint().toString());
+                        out.writeString(range.upperEndpoint().toString());
+                    }
+                }
+            }
         }
 
         @Override
         @SuppressWarnings("unchecked")
         public CassandraRing read(Kryo kryo, Input in, Class<CassandraRing> 
type)
         {
-            return new CassandraRing(in.readByte() == 1 ? 
Partitioner.RandomPartitioner
-                                                        : 
Partitioner.Murmur3Partitioner,
-                                     in.readString(),
-                                     kryo.readObject(in, 
ReplicationFactor.class),
-                                     kryo.readObject(in, ArrayList.class));
+            Partitioner partitioner = in.readByte() == 1 ? 
Partitioner.RandomPartitioner
+                                                         : 
Partitioner.Murmur3Partitioner;
+            String keyspace = in.readString();
+            ReplicationFactor replicationFactor = kryo.readObject(in, 
ReplicationFactor.class);
+            List<CassandraInstance> instances = kryo.readObject(in, 
ArrayList.class);
+
+            if (!in.readBoolean())
+            {
+                return new CassandraRing(partitioner, keyspace, 
replicationFactor, instances);
+            }
+
+            // Instances were written in order, each followed by the ranges it 
replicates
+            Multimap<CassandraInstance, Range<BigInteger>> tokenRanges = 
ArrayListMultimap.create();
+            for (CassandraInstance instance : instances)
+            {
+                int numRanges = in.readInt();
+                for (int range = 0; range < numRanges; range++)
+                {
+                    tokenRanges.put(instance, Range.openClosed(new 
BigInteger(in.readString()), new BigInteger(in.readString())));
+                }
+            }
+            return new CassandraRing(partitioner, keyspace, replicationFactor, 
instances, tokenRanges);
         }
     }
 }
diff --git 
a/cassandra-analytics-common/src/test/java/org/apache/cassandra/spark/data/partitioner/CassandraRingTests.java
 
b/cassandra-analytics-common/src/test/java/org/apache/cassandra/spark/data/partitioner/CassandraRingTests.java
index a34e499d..dd525d9c 100644
--- 
a/cassandra-analytics-common/src/test/java/org/apache/cassandra/spark/data/partitioner/CassandraRingTests.java
+++ 
b/cassandra-analytics-common/src/test/java/org/apache/cassandra/spark/data/partitioner/CassandraRingTests.java
@@ -25,10 +25,19 @@ import java.io.IOException;
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
 import java.math.BigInteger;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
 
+import com.esotericsoftware.kryo.Kryo;
+import com.esotericsoftware.kryo.io.Input;
+import com.esotericsoftware.kryo.io.Output;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Multimap;
 import com.google.common.collect.Range;
@@ -522,10 +531,12 @@ public class CassandraRingTests
         int keyspaceIndex = indexOf(raw, keyspaceUtf);
         assertThat(keyspaceIndex).as("keyspace UTF header should be 
locatable").isGreaterThan(1);
         int versionIndex = keyspaceIndex - 2;
-        assertThat(raw[versionIndex]).as("format version 
byte").isEqualTo((byte) 1);
+        // Deliberately not asserting the current version number, so this test 
does not need updating
+        // every time the format changes
+        assertThat(raw[versionIndex]).as("format version byte should be 
positive").isGreaterThan((byte) 0);
 
-        // Simulate a stream written before the transient-replica section 
existed
-        raw[versionIndex] = 0;
+        // Simulate a stream written by a version whose format we do not 
understand
+        raw[versionIndex] = (byte) 99;
 
         try (ObjectInputStream in = new ObjectInputStream(new 
ByteArrayInputStream(raw)))
         {
@@ -551,4 +562,234 @@ public class CassandraRingTests
         }
         return -1;
     }
+
+    // Cassandra-reported token ranges, used for witness-enabled keyspaces 
where the locally
+    // derived ranges cannot be trusted because the derivation ignores racks
+
+    private static CassandraRing reportedRangeRing()
+    {
+        List<CassandraInstance> instances = Arrays.asList(new 
CassandraInstance("0", "n1", "dc1"),
+                                                         new 
CassandraInstance("100", "n2", "dc1"),
+                                                         new 
CassandraInstance("200", "n3", "dc1"));
+        // Deliberately not what the derivation would produce: boundary at 50, 
and only two of three
+        // replicas per range. A derived ring can never produce this shape.
+        Map<Range<BigInteger>, List<CassandraInstance>> reported = new 
LinkedHashMap<>();
+        
reported.put(Range.openClosed(Partitioner.Murmur3Partitioner.minToken(), 
BigInteger.valueOf(50L)),
+                     Arrays.asList(instances.get(0), instances.get(1)));
+        reported.put(Range.openClosed(BigInteger.valueOf(50L), 
Partitioner.Murmur3Partitioner.maxToken()),
+                     Arrays.asList(instances.get(1), instances.get(2)));
+        return new CassandraRing(Partitioner.Murmur3Partitioner, "test",
+                                 new ReplicationFactor(ImmutableMap.of(
+                                 "class", 
"org.apache.cassandra.locator.NetworkTopologyStrategy",
+                                 "dc1", "3/1")),
+                                 instances, reported);
+    }
+
+    private static Set<String> replicaNamesAt(CassandraRing ring, long token)
+    {
+        Set<String> names = new TreeSet<>();
+        ring.getReplicas(BigInteger.valueOf(token)).forEach(instance -> 
names.add(instance.nodeName()));
+        return names;
+    }
+
+    @Test
+    public void testReportedRangesAreHonouredInsteadOfDerived()
+    {
+        CassandraRing reportedRing = reportedRangeRing();
+        assertThat(reportedRing.hasReportedRanges()).isTrue();
+        // the supplied boundary at 50 is used, and only the supplied replicas 
are returned
+        assertThat(replicaNamesAt(reportedRing, 10L)).containsExactly("n1", 
"n2");
+        assertThat(replicaNamesAt(reportedRing, 1000L)).containsExactly("n2", 
"n3");
+    }
+
+    @Test
+    public void testDerivedRingIsUnchangedByReportedRangeSupport()
+    {
+        List<CassandraInstance> instances = Arrays.asList(new 
CassandraInstance("0", "n1", "dc1"),
+                                                         new 
CassandraInstance("100", "n2", "dc1"),
+                                                         new 
CassandraInstance("200", "n3", "dc1"));
+        CassandraRing derived = new 
CassandraRing(Partitioner.Murmur3Partitioner, "test",
+                                                  new 
ReplicationFactor(ImmutableMap.of(
+                                                  "class", 
"org.apache.cassandra.locator.NetworkTopologyStrategy",
+                                                  "dc1", "3")),
+                                                  instances);
+        assertThat(derived.hasReportedRanges()).isFalse();
+        // derivation puts boundaries at node tokens, so 50 is not a boundary
+        Set<BigInteger> upperBounds = new TreeSet<>();
+        derived.rangeMap().asMapOfRanges().forEach((range, replicas) -> {
+            if (!replicas.isEmpty())
+            {
+                upperBounds.add(range.upperEndpoint());
+            }
+        });
+        assertThat(upperBounds).doesNotContain(BigInteger.valueOf(50L));
+    }
+
+    @Test
+    public void testReportedRangesSurviveJdkSerialization() throws Exception
+    {
+        CassandraRing original = reportedRangeRing();
+        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+        try (ObjectOutputStream out = new ObjectOutputStream(bytes))
+        {
+            out.writeObject(original);
+        }
+        CassandraRing deserialized;
+        try (ObjectInputStream in = new ObjectInputStream(new 
ByteArrayInputStream(bytes.toByteArray())))
+        {
+            deserialized = (CassandraRing) in.readObject();
+        }
+        assertThat(deserialized.hasReportedRanges()).isTrue();
+        assertThat(replicaNamesAt(deserialized, 10L)).containsExactly("n1", 
"n2");
+        assertThat(replicaNamesAt(deserialized, 1000L)).containsExactly("n2", 
"n3");
+    }
+
+    @Test
+    public void testReportedRangesSurviveKryoSerialization()
+    {
+        CassandraRing original = reportedRangeRing();
+        Kryo kryo = new Kryo();
+        kryo.register(CassandraRing.class, new CassandraRing.Serializer());
+        kryo.register(ReplicationFactor.class, new 
ReplicationFactor.Serializer());
+        kryo.register(CassandraInstance.class, new 
CassandraInstance.Serializer());
+
+        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+        try (Output out = new Output(bytes))
+        {
+            kryo.writeObject(out, original);
+        }
+        CassandraRing deserialized;
+        try (Input in = new Input(new 
ByteArrayInputStream(bytes.toByteArray())))
+        {
+            deserialized = kryo.readObject(in, CassandraRing.class);
+        }
+        assertThat(deserialized.hasReportedRanges()).isTrue();
+        assertThat(replicaNamesAt(deserialized, 10L)).containsExactly("n1", 
"n2");
+        assertThat(deserialized).isEqualTo(original);
+    }
+
+    @Test
+    public void testDerivedRingStaysDerivedThroughKryo()
+    {
+        List<CassandraInstance> instances = Arrays.asList(new 
CassandraInstance("0", "n1", "dc1"),
+                                                         new 
CassandraInstance("100", "n2", "dc1"),
+                                                         new 
CassandraInstance("200", "n3", "dc1"));
+        CassandraRing derived = new 
CassandraRing(Partitioner.Murmur3Partitioner, "test",
+                                                  new 
ReplicationFactor(ImmutableMap.of(
+                                                  "class", 
"org.apache.cassandra.locator.NetworkTopologyStrategy",
+                                                  "dc1", "3")),
+                                                  instances);
+        Kryo kryo = new Kryo();
+        kryo.register(CassandraRing.class, new CassandraRing.Serializer());
+        kryo.register(ReplicationFactor.class, new 
ReplicationFactor.Serializer());
+        kryo.register(CassandraInstance.class, new 
CassandraInstance.Serializer());
+
+        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+        try (Output out = new Output(bytes))
+        {
+            kryo.writeObject(out, derived);
+        }
+        CassandraRing deserialized;
+        try (Input in = new Input(new 
ByteArrayInputStream(bytes.toByteArray())))
+        {
+            deserialized = kryo.readObject(in, CassandraRing.class);
+        }
+        assertThat(deserialized.hasReportedRanges()).isFalse();
+        assertThat(deserialized).isEqualTo(derived);
+    }
+
+    @Test
+    public void testReportedRangeWithUnknownReplicaIsRejected()
+    {
+        List<CassandraInstance> instances = Arrays.asList(new 
CassandraInstance("0", "n1", "dc1"),
+                                                         new 
CassandraInstance("100", "n2", "dc1"));
+        Map<Range<BigInteger>, List<CassandraInstance>> reported = new 
LinkedHashMap<>();
+        reported.put(Range.openClosed(BigInteger.ZERO, 
BigInteger.valueOf(100L)),
+                     Arrays.asList(new CassandraInstance("999", "nope", 
"dc1")));
+        assertThatThrownBy(() -> new 
CassandraRing(Partitioner.Murmur3Partitioner, "test",
+                                                   new 
ReplicationFactor(ImmutableMap.of(
+                                                   "class", 
"org.apache.cassandra.locator.NetworkTopologyStrategy",
+                                                   "dc1", "2")),
+                                                   instances, reported))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("not present in the ring instances");
+    }
+
+    private static CassandraRing reportedRing(Map<Range<BigInteger>, 
List<CassandraInstance>> reported,
+                                              List<CassandraInstance> 
instances)
+    {
+        return new CassandraRing(Partitioner.Murmur3Partitioner, "test",
+                                 new ReplicationFactor(ImmutableMap.of(
+                                 "class", 
"org.apache.cassandra.locator.NetworkTopologyStrategy",
+                                 "dc1", "3")),
+                                 instances, reported);
+    }
+
+    @Test
+    public void testReportedRingDoesNotDependOnReplicaOrder()
+    {
+        // Sidecar lists replicas in ring-walk order, while a deserialized 
ring lists them in instance order, so the
+        // ring must not depend on the order replicas were supplied in
+        List<CassandraInstance> instances = new ArrayList<>();
+        for (int node = 0; node < 32; node++)
+        {
+            instances.add(new CassandraInstance(String.valueOf(node * 100), 
"n" + node, "dc1"));
+        }
+        Map<Range<BigInteger>, List<CassandraInstance>> forward = new 
LinkedHashMap<>();
+        Map<Range<BigInteger>, List<CassandraInstance>> backward = new 
LinkedHashMap<>();
+        for (int node = 0; node < instances.size(); node++)
+        {
+            BigInteger lower = node == 0 ? 
Partitioner.Murmur3Partitioner.minToken() : BigInteger.valueOf((node - 1) * 
100L + 1);
+            BigInteger upper = node == instances.size() - 1 ? 
Partitioner.Murmur3Partitioner.maxToken() : BigInteger.valueOf(node * 100L);
+            List<CassandraInstance> replicas = 
Arrays.asList(instances.get(node),
+                                                             
instances.get((node + 1) % instances.size()),
+                                                             
instances.get((node + 2) % instances.size()));
+            List<CassandraInstance> reversed = new ArrayList<>(replicas);
+            Collections.reverse(reversed);
+            forward.put(Range.openClosed(lower, upper), replicas);
+            backward.put(Range.openClosed(lower, upper), reversed);
+        }
+
+        CassandraRing ring = reportedRing(forward, instances);
+        CassandraRing reversedRing = reportedRing(backward, instances);
+        assertThat(reversedRing).isEqualTo(ring);
+        // whatever order they were supplied in, each range lists its replicas 
in token order
+        reversedRing.rangeMap().asMapOfRanges().values().forEach(replicas -> {
+            List<CassandraInstance> sorted = new ArrayList<>(replicas);
+            sorted.sort((left, right) -> new 
BigInteger(left.token()).compareTo(new BigInteger(right.token())));
+            assertThat(replicas).containsExactlyElementsOf(sorted);
+        });
+
+        Kryo kryo = new Kryo();
+        kryo.register(CassandraRing.class, new CassandraRing.Serializer());
+        kryo.register(ReplicationFactor.class, new 
ReplicationFactor.Serializer());
+        kryo.register(CassandraInstance.class, new 
CassandraInstance.Serializer());
+        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+        try (Output out = new Output(bytes))
+        {
+            kryo.writeObject(out, ring);
+        }
+        try (Input in = new Input(new 
ByteArrayInputStream(bytes.toByteArray())))
+        {
+            assertThat(kryo.readObject(in, 
CassandraRing.class)).isEqualTo(ring);
+        }
+    }
+
+    @Test
+    public void testReportedRangesAreNormalisedToOpenClosed()
+    {
+        List<CassandraInstance> instances = Arrays.asList(new 
CassandraInstance("0", "n1", "dc1"),
+                                                         new 
CassandraInstance("100", "n2", "dc1"));
+        Map<Range<BigInteger>, List<CassandraInstance>> reported = new 
LinkedHashMap<>();
+        reported.put(Range.closed(Partitioner.Murmur3Partitioner.minToken(), 
BigInteger.ZERO),
+                     Arrays.asList(instances.get(0), instances.get(1)));
+        reported.put(Range.openClosed(BigInteger.ZERO, 
Partitioner.Murmur3Partitioner.maxToken()),
+                     Arrays.asList(instances.get(0), instances.get(1)));
+
+        CassandraRing ring = reportedRing(reported, instances);
+
+        assertThat(ring.tokenRanges().get(instances.get(0)))
+        
.containsExactly(Range.openClosed(Partitioner.Murmur3Partitioner.minToken(), 
BigInteger.ZERO),
+                         Range.openClosed(BigInteger.ZERO, 
Partitioner.Murmur3Partitioner.maxToken()));
+    }
 }
diff --git 
a/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/data/CassandraDataLayer.java
 
b/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/data/CassandraDataLayer.java
index ff7e0b67..9a7b29c5 100644
--- 
a/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/data/CassandraDataLayer.java
+++ 
b/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/data/CassandraDataLayer.java
@@ -51,6 +51,8 @@ import com.google.common.base.Preconditions;
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
 import com.google.common.collect.Range;
+import com.google.common.collect.RangeSet;
+import com.google.common.collect.TreeRangeSet;
 import org.apache.commons.lang3.StringUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -62,6 +64,7 @@ import 
o.a.c.sidecar.client.shaded.common.response.ListSnapshotFilesResponse;
 import o.a.c.sidecar.client.shaded.common.response.NodeSettings;
 import o.a.c.sidecar.client.shaded.common.response.RingResponse;
 import o.a.c.sidecar.client.shaded.common.response.SchemaResponse;
+import o.a.c.sidecar.client.shaded.common.response.TokenRangeReplicasResponse;
 import org.apache.cassandra.analytics.stats.Stats;
 import org.apache.cassandra.bridge.BigNumberConfig;
 import org.apache.cassandra.bridge.BigNumberConfigImpl;
@@ -327,7 +330,22 @@ public class CassandraDataLayer extends 
PartitionedDataLayer implements StartupV
         udts.forEach(udt -> LOGGER.info("Adding schema UDT: '{}'", udt));
 
         cqlTable = bridge().buildSchema(createStmt, keyspace, 
replicationFactor, partitioner, udts, null, indexCount, false);
-        CassandraRing ring = createCassandraRingFromRing(partitioner, 
replicationFactor, ringFuture.get());
+
+        // Keyspaces with transient replicas take their ranges from Cassandra; 
see createCassandraRingFromTokenRangeReplicas
+        boolean hasWitnesses = replicationFactor.hasTransientReplicas();
+        CassandraRing ring;
+        if (hasWitnesses || options.forceCassandraTokenRanges())
+        {
+            LOGGER.info("Sourcing token ranges from Cassandra keyspace={} 
transientReplicas={} forced={}",
+                        keyspace, hasWitnesses, 
options.forceCassandraTokenRanges());
+            TokenRangeReplicasResponse topology = 
sidecar.tokenRangeReplicas(new ArrayList<>(clusterConfig),
+                                                                             
maybeQuotedKeyspace).get();
+            ring = createCassandraRingFromTokenRangeReplicas(partitioner, 
replicationFactor, ringFuture.get(), topology);
+        }
+        else
+        {
+            ring = createCassandraRingFromRing(partitioner, replicationFactor, 
ringFuture.get());
+        }
 
         int effectiveNumberOfCores = sizingFuture.get();
         tokenPartitioner = new TokenPartitioner(ring, 
options.defaultParallelism(), effectiveNumberOfCores);
@@ -782,6 +800,126 @@ public class CassandraDataLayer extends 
PartitionedDataLayer implements StartupV
         return new CassandraRing(partitioner, keyspace, replicationFactor, 
instances);
     }
 
+    /**
+     * Builds the ring using the token ranges and replicas Cassandra reports, 
rather than deriving them from tokens and
+     * the replication factor. There are two independent reasons to prefer 
Cassandra's: the local derivation assumes
+     * racks are not in use, whereas Cassandra's replica assignment is rack 
aware, and for keyspaces with transient
+     * replicas the reader needs Cassandra's exact placement to tell which 
replica of a range is a witness. Only
+     * keyspaces with transient replicas use it by default, as they are 
guaranteed single-token, which this path
+     * requires.
+     * <p>
+     * Node discovery still comes from the ring response, so snapshot 
creation, sizing and the Sidecar client pool
+     * are unaffected. Only the range to replica mapping is taken from 
Cassandra.
+     *
+     * @param partitioner       the partitioner
+     * @param replicationFactor the keyspace replication factor
+     * @param ring              the ring response, used for node discovery
+     * @param topology          the token range replicas response, used for 
the range to replica mapping
+     * @return a ring whose token ranges were reported by Cassandra
+     */
+    public CassandraRing createCassandraRingFromTokenRangeReplicas(Partitioner 
partitioner,
+                                                                   
ReplicationFactor replicationFactor,
+                                                                   
RingResponse ring,
+                                                                   
TokenRangeReplicasResponse topology)
+    {
+        List<CassandraInstance> instances = ring
+                                            .stream()
+                                            .filter(status -> datacenter == 
null || datacenter.equalsIgnoreCase(status.datacenter()))
+                                            .map(status -> new 
CassandraInstance(status.token(), status.fqdn(), status.datacenter()))
+                                            .collect(Collectors.toList());
+
+        Map<String, CassandraInstance> instanceByNodeName = new 
HashMap<>(instances.size());
+        for (CassandraInstance instance : instances)
+        {
+            CassandraInstance previous = 
instanceByNodeName.put(instance.nodeName(), instance);
+            if (previous != null && !previous.equals(instance))
+            {
+                // A node owning several tokens cannot be represented as a 
single CassandraInstance. Cassandra
+                // rejects transient replicas when num_tokens > 1, so this is 
only reachable when the path is forced
+                throw new IllegalStateException(String.format(
+                "Node %s owns multiple tokens (%s and %s). Sourcing token 
ranges from Cassandra requires "
+                + "single-token nodes.", instance.nodeName(), 
previous.token(), instance.token()));
+            }
+        }
+
+        Map<Range<BigInteger>, List<CassandraInstance>> rangeReplicas = new 
HashMap<>();
+        for (TokenRangeReplicasResponse.ReplicaInfo replicaInfo : 
topology.readReplicas())
+        {
+            Range<BigInteger> range = Range.openClosed(new 
BigInteger(replicaInfo.start()),
+                                                       new 
BigInteger(replicaInfo.end()));
+            List<CassandraInstance> replicas = new ArrayList<>();
+            for (Map.Entry<String, List<String>> byDatacenter : 
replicaInfo.replicasByDatacenter().entrySet())
+            {
+                if (datacenter != null && 
!datacenter.equalsIgnoreCase(byDatacenter.getKey()))
+                {
+                    continue;
+                }
+                for (String replicaKey : byDatacenter.getValue())
+                {
+                    // Replicas are identified by "address:port", while 
instances built from the ring are keyed by
+                    // fqdn, so the replica metadata translates between them
+                    TokenRangeReplicasResponse.ReplicaMetadata metadata = 
topology.replicaMetadata().get(replicaKey);
+                    String nodeName = metadata != null ? metadata.fqdn() : 
replicaKey;
+                    CassandraInstance instance = 
instanceByNodeName.get(nodeName);
+                    if (instance == null)
+                    {
+                        // The ring response is the source of truth for which 
nodes the reader can talk to, so a
+                        // replica absent from it is skipped rather than 
failing the job. It should only happen while
+                        // a cluster operation is in progress.
+                        LOGGER.error("Skipping replica reported by Cassandra 
that is absent from the ring "
+                                    + "replica={} fqdn={} range=({}, {}]",
+                                    replicaKey, nodeName, replicaInfo.start(), 
replicaInfo.end());
+                        continue;
+                    }
+                    replicas.add(instance);
+                }
+            }
+            if (!replicas.isEmpty())
+            {
+                // Distinct, because a duplicated replica would inflate the 
per-range replica count that
+                // PartitionedDataLayer uses to decide whether the consistency 
level is satisfied
+                rangeReplicas.put(range, 
replicas.stream().distinct().collect(Collectors.toList()));
+            }
+        }
+
+        if (rangeReplicas.isEmpty())
+        {
+            throw new IllegalStateException(
+            "Cassandra reported no read replicas for keyspace " + keyspace + " 
in datacenter " + datacenter);
+        }
+
+        validateCompleteRingCoverage(partitioner, rangeReplicas.keySet());
+
+        LOGGER.info("Using token ranges reported by Cassandra keyspace={} 
numRanges={} numInstances={}",
+                    keyspace, rangeReplicas.size(), instances.size());
+        return new CassandraRing(partitioner, keyspace, replicationFactor, 
instances, rangeReplicas);
+    }
+
+    /**
+     * Fails if the supplied ranges leave any part of the token ring without a 
replica.
+     * <p>
+     * A gap is not caught downstream. {@link CassandraRing} seeds its range 
map across the whole ring, so an
+     * uncovered range is present but with an empty replica list, and the 
reader only discovers this per Spark
+     * partition as a {@code NotEnoughReplicasException} that reads like a 
cluster availability problem. Reporting it
+     * here names the actual cause.
+     *
+     * @param partitioner the partitioner, for the ring bounds
+     * @param ranges      the ranges reported by Cassandra that have at least 
one usable replica
+     */
+    private void validateCompleteRingCoverage(Partitioner partitioner, 
Set<Range<BigInteger>> ranges)
+    {
+        RangeSet<BigInteger> uncovered = TreeRangeSet.create();
+        uncovered.add(Range.openClosed(partitioner.minToken(), 
partitioner.maxToken()));
+        ranges.forEach(uncovered::remove);
+        if (!uncovered.isEmpty())
+        {
+            throw new IllegalStateException(String.format(
+            "Token ranges reported by Cassandra do not cover the whole ring 
for keyspace %s in datacenter %s. "
+            + "Uncovered: %s. This means some ranges have no replica the 
reader can use, which would otherwise "
+            + "surface later as a consistency failure.", keyspace, datacenter, 
uncovered));
+        }
+    }
+
     // Startup Validation
 
     @Override
diff --git 
a/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/data/ClientConfig.java
 
b/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/data/ClientConfig.java
index 4ca6c921..2121d79b 100644
--- 
a/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/data/ClientConfig.java
+++ 
b/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/data/ClientConfig.java
@@ -55,6 +55,8 @@ public class ClientConfig
     public static final String SNAPSHOT_NAME_KEY = "snapshotName";
     public static final String DC_KEY = "dc";
     public static final String CREATE_SNAPSHOT_KEY = "createSnapshot";
+    // Sources token ranges from Cassandra for any keyspace, not only those 
with transient replicas
+    public static final String FORCE_CASSANDRA_TOKEN_RANGES_KEY = 
"forceCassandraTokenRanges";
     public static final String CLEAR_SNAPSHOT_KEY = "clearSnapshot";
     /**
      * Format of clearSnapshotStrategy is {strategy [snapshotTTLvalue]}, 
clearSnapshotStrategy holds both the strategy
@@ -101,6 +103,7 @@ public class ClientConfig
     protected String snapshotName;
     protected String datacenter;
     protected boolean createSnapshot;
+    protected boolean forceCassandraTokenRanges;
     protected boolean clearSnapshot;
     protected ClearSnapshotStrategy clearSnapshotStrategy;
     protected int defaultParallelism;
@@ -127,6 +130,7 @@ public class ClientConfig
         this.snapshotName = MapUtils.getOrDefault(options, SNAPSHOT_NAME_KEY, 
"sbr_" + UUID.randomUUID().toString().replace("-", ""));
         this.datacenter = options.get(MapUtils.lowerCaseKey(DC_KEY));
         this.createSnapshot = MapUtils.getBoolean(options, 
CREATE_SNAPSHOT_KEY, true);
+        this.forceCassandraTokenRanges = MapUtils.getBoolean(options, 
FORCE_CASSANDRA_TOKEN_RANGES_KEY, false);
         this.clearSnapshot = MapUtils.getBoolean(options, CLEAR_SNAPSHOT_KEY, 
createSnapshot);
         String clearSnapshotStrategyOption = MapUtils.getOrDefault(options, 
CLEAR_SNAPSHOT_STRATEGY_KEY, null);
 
@@ -204,6 +208,14 @@ public class ClientConfig
         return datacenter;
     }
 
+    /**
+     * @return {@code true} to source token ranges from Cassandra regardless 
of whether the keyspace has witnesses
+     */
+    public boolean forceCassandraTokenRanges()
+    {
+        return forceCassandraTokenRanges;
+    }
+
     public boolean createSnapshot()
     {
         return createSnapshot;
diff --git 
a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/data/CassandraDataLayerTests.java
 
b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/data/CassandraDataLayerTests.java
index 088de907..a4d5721a 100644
--- 
a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/data/CassandraDataLayerTests.java
+++ 
b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/data/CassandraDataLayerTests.java
@@ -19,15 +19,31 @@
 
 package org.apache.cassandra.spark.data;
 
+import java.math.BigInteger;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
 
 import com.google.common.collect.ImmutableMap;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.CsvSource;
 
+import o.a.c.sidecar.client.shaded.common.response.RingResponse;
+import o.a.c.sidecar.client.shaded.common.response.TokenRangeReplicasResponse;
+import 
o.a.c.sidecar.client.shaded.common.response.TokenRangeReplicasResponse.ReplicaInfo;
+import 
o.a.c.sidecar.client.shaded.common.response.TokenRangeReplicasResponse.ReplicaMetadata;
+import o.a.c.sidecar.client.shaded.common.response.data.RingEntry;
+import org.apache.cassandra.clients.Sidecar;
+import org.apache.cassandra.spark.data.partitioner.CassandraInstance;
+import org.apache.cassandra.spark.data.partitioner.CassandraRing;
+import org.apache.cassandra.spark.data.partitioner.Partitioner;
+
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 class CassandraDataLayerTests
 {
@@ -64,4 +80,240 @@ class CassandraDataLayerTests
         
assertThat(clearSnapshotStrategy.hasTTL()).isEqualTo(expectedClearSnapshotStrategy.hasTTL());
         
assertThat(clearSnapshotStrategy.ttl()).isEqualTo(expectedClearSnapshotStrategy.ttl());
     }
+
+    // Sourcing token ranges from Cassandra, used for keyspaces with witness 
replicas where the locally derived
+    // ranges cannot identify which instance replicates which range
+
+    private static final String KS = "big-data";
+
+    private static CassandraDataLayer dataLayer(String datacenter)
+    {
+        Map<String, String> options = new 
HashMap<>(REQUIRED_CLIENT_CONFIG_OPTIONS);
+        if (datacenter != null)
+        {
+            options.put("dc", datacenter);
+        }
+        return new CassandraDataLayer(ClientConfig.create(options), 
Sidecar.ClientConfig.create(), null);
+    }
+
+    private static RingResponse ringOf(String... fqdnAndTokenAndDc)
+    {
+        RingResponse ring = new RingResponse();
+        for (int i = 0; i < fqdnAndTokenAndDc.length; i += 3)
+        {
+            ring.add(new RingEntry.Builder().fqdn(fqdnAndTokenAndDc[i])
+                                            .token(fqdnAndTokenAndDc[i + 1])
+                                            .datacenter(fqdnAndTokenAndDc[i + 
2])
+                                            .address(fqdnAndTokenAndDc[i])
+                                            .port(9042)
+                                            .rack("rack1")
+                                            .status("UP")
+                                            .state("NORMAL")
+                                            .load("1")
+                                            .owns("1")
+                                            .hostId("h" + i)
+                                            .build());
+        }
+        return ring;
+    }
+
+    /**
+     * Replica keys are "address:port" and are translated to fqdn through the 
replica metadata, mirroring what
+     * Sidecar returns.
+     */
+    private static TokenRangeReplicasResponse topologyOf(List<ReplicaInfo> 
readReplicas, String... addressToFqdn)
+    {
+        Map<String, ReplicaMetadata> metadata = new HashMap<>();
+        for (int i = 0; i < addressToFqdn.length; i += 2)
+        {
+            String key = addressToFqdn[i];
+            String fqdn = addressToFqdn[i + 1];
+            metadata.put(key, new ReplicaMetadata("NORMAL", "UP", fqdn, 
key.split(":")[0], 9042, "dc1"));
+        }
+        // only the read replicas are used
+        return new TokenRangeReplicasResponse(Collections.emptyList(), 
readReplicas, metadata);
+    }
+
+    @Test
+    void testRingFromTokenRangeReplicasUsesCassandraReportedRanges()
+    {
+        CassandraDataLayer layer = dataLayer(null);
+        RingResponse ring = ringOf("n1", "0", "dc1", "n2", "100", "dc1", "n3", 
"200", "dc1");
+
+        // boundary at 50 with only two replicas: a shape the local derivation 
cannot produce
+        List<ReplicaInfo> readReplicas = Arrays.asList(
+        new ReplicaInfo("-9223372036854775808", "50", ImmutableMap.of("dc1", 
Arrays.asList("1.1.1.1:9042", "1.1.1.2:9042"))),
+        new ReplicaInfo("50", "9223372036854775807", ImmutableMap.of("dc1", 
Arrays.asList("1.1.1.2:9042", "1.1.1.3:9042"))));
+        TokenRangeReplicasResponse topology = topologyOf(readReplicas,
+                                                         "1.1.1.1:9042", "n1",
+                                                         "1.1.1.2:9042", "n2",
+                                                         "1.1.1.3:9042", "n3");
+
+        CassandraRing result = layer.createCassandraRingFromTokenRangeReplicas(
+        Partitioner.Murmur3Partitioner, ReplicationFactor.simpleStrategy(3), 
ring, topology);
+
+        assertThat(result.hasReportedRanges()).isTrue();
+        assertThat(replicaNames(result, 10L)).containsExactly("n1", "n2");
+        assertThat(replicaNames(result, 1000L)).containsExactly("n2", "n3");
+    }
+
+    @Test
+    void testRingFromTokenRangeReplicasFiltersByDatacenter()
+    {
+        CassandraDataLayer layer = dataLayer("dc1");
+        RingResponse ring = ringOf("n1", "0", "dc1", "n2", "100", "dc1", "n4", 
"300", "dc2");
+
+        List<ReplicaInfo> readReplicas = Collections.singletonList(
+        new ReplicaInfo("-9223372036854775808", "9223372036854775807",
+                        ImmutableMap.of("dc1", Arrays.asList("1.1.1.1:9042", 
"1.1.1.2:9042"),
+                                        "dc2", 
Collections.singletonList("1.1.1.4:9042"))));
+        TokenRangeReplicasResponse topology = topologyOf(readReplicas,
+                                                         "1.1.1.1:9042", "n1",
+                                                         "1.1.1.2:9042", "n2",
+                                                         "1.1.1.4:9042", "n4");
+
+        CassandraRing result = layer.createCassandraRingFromTokenRangeReplicas(
+        Partitioner.Murmur3Partitioner, ReplicationFactor.simpleStrategy(2), 
ring, topology);
+
+        // the dc2 replica must not appear, even though Cassandra reported it
+        assertThat(replicaNames(result, 10L)).containsExactly("n1", "n2");
+    }
+
+    @Test
+    void testRingFromTokenRangeReplicasSkipsReplicaAbsentFromRing()
+    {
+        CassandraDataLayer layer = dataLayer(null);
+        RingResponse ring = ringOf("n1", "0", "dc1", "n2", "100", "dc1");
+
+        List<ReplicaInfo> readReplicas = Collections.singletonList(
+        new ReplicaInfo("-9223372036854775808", "9223372036854775807",
+                        ImmutableMap.of("dc1", Arrays.asList("1.1.1.1:9042", 
"1.1.1.9:9042"))));
+        TokenRangeReplicasResponse topology = topologyOf(readReplicas,
+                                                         "1.1.1.1:9042", "n1",
+                                                         "1.1.1.9:9042", 
"ghost");
+
+        CassandraRing result = layer.createCassandraRingFromTokenRangeReplicas(
+        Partitioner.Murmur3Partitioner, ReplicationFactor.simpleStrategy(2), 
ring, topology);
+
+        // the ring response is the source of truth for reachable nodes, so 
the unknown replica is dropped
+        assertThat(replicaNames(result, 10L)).containsExactly("n1");
+    }
+
+    @Test
+    void testRingFromTokenRangeReplicasRejectsMultiTokenNode()
+    {
+        CassandraDataLayer layer = dataLayer(null);
+        // same fqdn twice with different tokens, i.e. vnodes
+        RingResponse ring = ringOf("n1", "0", "dc1", "n1", "100", "dc1");
+
+        List<ReplicaInfo> readReplicas = Collections.singletonList(
+        new ReplicaInfo("-9223372036854775808", "9223372036854775807",
+                        ImmutableMap.of("dc1", 
Collections.singletonList("1.1.1.1:9042"))));
+        TokenRangeReplicasResponse topology = topologyOf(readReplicas, 
"1.1.1.1:9042", "n1");
+
+        assertThatThrownBy(() -> 
layer.createCassandraRingFromTokenRangeReplicas(
+        Partitioner.Murmur3Partitioner, ReplicationFactor.simpleStrategy(1), 
ring, topology))
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessageContaining("owns multiple tokens");
+    }
+
+    @Test
+    void testRingFromTokenRangeReplicasFailsWhenNoReadReplicas()
+    {
+        CassandraDataLayer layer = dataLayer(null);
+        RingResponse ring = ringOf("n1", "0", "dc1");
+        TokenRangeReplicasResponse topology = 
topologyOf(Collections.emptyList());
+
+        assertThatThrownBy(() -> 
layer.createCassandraRingFromTokenRangeReplicas(
+        Partitioner.Murmur3Partitioner, ReplicationFactor.simpleStrategy(1), 
ring, topology))
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessageContaining("no read replicas");
+    }
+
+    @Test
+    void testForceCassandraTokenRangesDefaultsToFalse()
+    {
+        assertThat(ClientConfig.create(new 
HashMap<>(REQUIRED_CLIENT_CONFIG_OPTIONS))
+                               .forceCassandraTokenRanges()).isFalse();
+
+        // Spark lowercases option keys, and MapUtils looks them up 
lowercased, so this is how a user sets it
+        Map<String, String> options = new 
HashMap<>(REQUIRED_CLIENT_CONFIG_OPTIONS);
+        options.put("forcecassandratokenranges", "true");
+        
assertThat(ClientConfig.create(options).forceCassandraTokenRanges()).isTrue();
+    }
+
+    private static List<String> replicaNames(CassandraRing ring, long token)
+    {
+        return ring.getReplicas(BigInteger.valueOf(token))
+                   .stream()
+                   .map(CassandraInstance::nodeName)
+                   .sorted()
+                   .collect(Collectors.toList());
+    }
+
+    @Test
+    void testRingFromTokenRangeReplicasFailsWhenRingIsNotFullyCovered()
+    {
+        CassandraDataLayer layer = dataLayer(null);
+        RingResponse ring = ringOf("n1", "0", "dc1", "n2", "100", "dc1");
+
+        // only the lower half of the ring is reported
+        List<ReplicaInfo> readReplicas = Collections.singletonList(
+        new ReplicaInfo("-9223372036854775808", "0",
+                        ImmutableMap.of("dc1", Arrays.asList("1.1.1.1:9042", 
"1.1.1.2:9042"))));
+        TokenRangeReplicasResponse topology = topologyOf(readReplicas,
+                                                        "1.1.1.1:9042", "n1",
+                                                        "1.1.1.2:9042", "n2");
+
+        assertThatThrownBy(() -> 
layer.createCassandraRingFromTokenRangeReplicas(
+        Partitioner.Murmur3Partitioner, ReplicationFactor.simpleStrategy(2), 
ring, topology))
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessageContaining("do not cover the whole ring");
+    }
+
+    @Test
+    void 
testRingFromTokenRangeReplicasFailsWhenAllReplicasForARangeAreSkipped()
+    {
+        CassandraDataLayer layer = dataLayer(null);
+        RingResponse ring = ringOf("n1", "0", "dc1", "n2", "100", "dc1");
+
+        // the upper range's only replica is absent from the ring, so that 
range ends up with no usable
+        // replica and must be reported as a coverage gap rather than silently 
dropped
+        List<ReplicaInfo> readReplicas = Arrays.asList(
+        new ReplicaInfo("-9223372036854775808", "0",
+                        ImmutableMap.of("dc1", Arrays.asList("1.1.1.1:9042", 
"1.1.1.2:9042"))),
+        new ReplicaInfo("0", "9223372036854775807",
+                        ImmutableMap.of("dc1", 
Collections.singletonList("1.1.1.9:9042"))));
+        TokenRangeReplicasResponse topology = topologyOf(readReplicas,
+                                                        "1.1.1.1:9042", "n1",
+                                                        "1.1.1.2:9042", "n2",
+                                                        "1.1.1.9:9042", 
"ghost");
+
+        assertThatThrownBy(() -> 
layer.createCassandraRingFromTokenRangeReplicas(
+        Partitioner.Murmur3Partitioner, ReplicationFactor.simpleStrategy(2), 
ring, topology))
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessageContaining("do not cover the whole ring");
+    }
+
+    @Test
+    void testRingFromTokenRangeReplicasDeduplicatesReplicas()
+    {
+        CassandraDataLayer layer = dataLayer(null);
+        RingResponse ring = ringOf("n1", "0", "dc1", "n2", "100", "dc1");
+
+        // the same replica reported twice must not be counted twice, since 
PartitionedDataLayer uses the
+        // per-range replica count to decide whether the consistency level is 
satisfied
+        List<ReplicaInfo> readReplicas = Collections.singletonList(
+        new ReplicaInfo("-9223372036854775808", "9223372036854775807",
+                        ImmutableMap.of("dc1", Arrays.asList("1.1.1.1:9042", 
"1.1.1.1:9042", "1.1.1.2:9042"))));
+        TokenRangeReplicasResponse topology = topologyOf(readReplicas,
+                                                        "1.1.1.1:9042", "n1",
+                                                        "1.1.1.2:9042", "n2");
+
+        CassandraRing result = layer.createCassandraRingFromTokenRangeReplicas(
+        Partitioner.Murmur3Partitioner, ReplicationFactor.simpleStrategy(2), 
ring, topology);
+
+        assertThat(result.getReplicas(BigInteger.valueOf(10L))).hasSize(2);
+        assertThat(replicaNames(result, 10L)).containsExactly("n1", "n2");
+    }
 }
diff --git 
a/cassandra-analytics-integration-framework/src/main/java/org/apache/cassandra/distributed/impl/CassandraCluster.java
 
b/cassandra-analytics-integration-framework/src/main/java/org/apache/cassandra/distributed/impl/CassandraCluster.java
index 69686b90..85f8e0f4 100644
--- 
a/cassandra-analytics-integration-framework/src/main/java/org/apache/cassandra/distributed/impl/CassandraCluster.java
+++ 
b/cassandra-analytics-integration-framework/src/main/java/org/apache/cassandra/distributed/impl/CassandraCluster.java
@@ -137,19 +137,18 @@ public class CassandraCluster<I extends IInstance> 
implements IClusterExtension<
         clusterBuilder.withTokenSupplier(tokenSupplier)
                       .withConfig(instanceConfigUpdater);
 
-        if (dcCount > 1)
+        // An explicit supplier is always honoured, including for a single 
datacenter, so that a test can place
+        // nodes of one datacenter in different racks. The built-in default 
only makes sense for multiple datacenters.
+        if (configuration.dcAndRackSupplier != null)
         {
-            if (configuration.dcAndRackSupplier != null)
-            {
-                
clusterBuilder.withNodeIdTopology(networkTopology(finalNodeCount, 
configuration.dcAndRackSupplier));
-            }
-            else
-            {
-                
clusterBuilder.withNodeIdTopology(networkTopology(finalNodeCount,
-                                                                  (nodeId) -> 
nodeId % 2 != 0 ?
-                                                                              
dcAndRack("datacenter1", "rack1") :
-                                                                              
dcAndRack("datacenter2", "rack2")));
-            }
+            clusterBuilder.withNodeIdTopology(networkTopology(finalNodeCount, 
configuration.dcAndRackSupplier));
+        }
+        else if (dcCount > 1)
+        {
+            clusterBuilder.withNodeIdTopology(networkTopology(finalNodeCount,
+                                                              (nodeId) -> 
nodeId % 2 != 0 ?
+                                                                          
dcAndRack("datacenter1", "rack1") :
+                                                                          
dcAndRack("datacenter2", "rack2")));
         }
 
         if (configuration.instanceInitializer != null)
diff --git 
a/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/BulkReaderCassandraTokenRangesTest.java
 
b/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/BulkReaderCassandraTokenRangesTest.java
new file mode 100644
index 00000000..8416c72d
--- /dev/null
+++ 
b/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/BulkReaderCassandraTokenRangesTest.java
@@ -0,0 +1,154 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+package org.apache.cassandra.analytics;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.cassandra.distributed.api.ConsistencyLevel;
+import org.apache.cassandra.distributed.api.IInstance;
+import org.apache.cassandra.sidecar.testing.QualifiedName;
+import org.apache.cassandra.testing.ClusterBuilderConfiguration;
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+
+import static org.apache.cassandra.testing.TestUtils.DC1_RF3;
+import static org.apache.cassandra.testing.TestUtils.TEST_KEYSPACE;
+import static org.apache.cassandra.testing.TestUtils.uniqueTestTableFullName;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests bulk reads that source token ranges from Cassandra via the 
token-range-replicas endpoint, rather than
+ * deriving them locally from tokens and the replication factor.
+ * <p>
+ * Keyspaces with transient replicas take that path automatically, because a 
range may be replicated to a witness
+ * holding no data and the local derivation cannot say which instance 
replicates which range. Witness keyspaces
+ * cannot yet be created in these tests, so they use the
+ * {@code forcecassandratokenranges} option to exercise the same path against 
an ordinary keyspace.
+ * <p>
+ * Reads must return exactly the same data either way, so each test asserts 
the full dataset rather than only a row
+ * count - an incomplete read is the failure mode this path exists to prevent.
+ */
+class BulkReaderCassandraTokenRangesTest extends 
SharedClusterSparkIntegrationTestBase
+{
+    static final List<String> DATASET = Arrays.asList("a", "b", "c", "d", "e", 
"f", "g");
+
+    QualifiedName table = uniqueTestTableFullName(TEST_KEYSPACE);
+
+    @Override
+    protected ClusterBuilderConfiguration testClusterConfiguration()
+    {
+        return super.testClusterConfiguration()
+                    .nodesPerDc(3);
+    }
+
+    @Test
+    void testReadWithCassandraSuppliedTokenRanges()
+    {
+        Dataset<Row> data = bulkReaderDataFrame(table, 
Collections.singletonMap("forcecassandratokenranges", "true"))
+                            .load();
+        assertCompleteDataset(data);
+    }
+
+    /**
+     * The derived and Cassandra-supplied paths must agree. This is the check 
that would catch the ranges being
+     * misaligned, which for a witness-enabled keyspace would show up as 
silently missing rows.
+     */
+    @Test
+    void testCassandraSuppliedRangesMatchDerivedRanges()
+    {
+        Dataset<Row> derived = bulkReaderDataFrame(table).load();
+        Dataset<Row> supplied = bulkReaderDataFrame(table, 
Collections.singletonMap("forcecassandratokenranges", "true"))
+                                .load();
+
+        assertCompleteDataset(derived);
+        assertCompleteDataset(supplied);
+        assertThat(sortedNames(supplied))
+        .describedAs("reads with Cassandra-supplied ranges must return the 
same rows as derived ranges")
+        .isEqualTo(sortedNames(derived));
+    }
+
+    @Test
+    void testReadWithCassandraSuppliedTokenRangesAtQuorum()
+    {
+        Dataset<Row> data = bulkReaderDataFrame(table, 
Collections.singletonMap("forcecassandratokenranges", "true"))
+                            .option("consistencyLevel", "LOCAL_QUORUM")
+                            .load();
+        assertCompleteDataset(data);
+    }
+
+    /**
+     * Pushdown filters are applied against the Spark partition token ranges, 
which are derived from the ring's
+     * ranges, so they are worth exercising on the new path.
+     */
+    @Test
+    void testPushDownFilterWithCassandraSuppliedTokenRanges()
+    {
+        Dataset<Row> data = bulkReaderDataFrame(table, 
Collections.singletonMap("forcecassandratokenranges", "true"))
+                            .load()
+                            .filter("id = 3");
+
+        List<Row> rows = data.collectAsList();
+        assertThat(rows).hasSize(1);
+        assertThat(rows.get(0).getInt(0)).isEqualTo(3);
+        assertThat(rows.get(0).getString(1)).isEqualTo(DATASET.get(3));
+    }
+
+    private void assertCompleteDataset(Dataset<Row> data)
+    {
+        List<Row> rows = data.collectAsList().stream()
+                             .sorted(Comparator.comparing(row -> 
row.getInt(0)))
+                             .collect(Collectors.toList());
+        assertThat(rows).hasSize(DATASET.size());
+        for (int i = 0; i < DATASET.size(); i++)
+        {
+            assertThat(rows.get(i).getInt(0)).isEqualTo(i);
+            assertThat(rows.get(i).getString(1)).isEqualTo(DATASET.get(i));
+        }
+    }
+
+    private List<String> sortedNames(Dataset<Row> data)
+    {
+        return data.collectAsList().stream()
+                   .map(row -> row.getInt(0) + "=" + row.getString(1))
+                   .sorted()
+                   .collect(Collectors.toList());
+    }
+
+    @Override
+    protected void initializeSchemaForTest()
+    {
+        createTestKeyspace(TEST_KEYSPACE, DC1_RF3);
+        createTestTable(table, "CREATE TABLE IF NOT EXISTS %s (id int PRIMARY 
KEY, name text);");
+
+        IInstance firstRunningInstance = cluster.getFirstRunningInstance();
+        for (int i = 0; i < DATASET.size(); i++)
+        {
+            firstRunningInstance.coordinator()
+                                .execute(String.format("INSERT INTO %s (id, 
name) VALUES (%d, '%s');",
+                                                       table, i, 
DATASET.get(i)),
+                                         ConsistencyLevel.ALL);
+        }
+    }
+}
diff --git 
a/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/BulkReaderMultiRackTest.java
 
b/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/BulkReaderMultiRackTest.java
new file mode 100644
index 00000000..75397277
--- /dev/null
+++ 
b/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/BulkReaderMultiRackTest.java
@@ -0,0 +1,263 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+package org.apache.cassandra.analytics;
+
+import java.math.BigInteger;
+import java.net.UnknownHostException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.UUID;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.cassandra.distributed.api.ConsistencyLevel;
+import org.apache.cassandra.distributed.api.IInstance;
+import org.apache.cassandra.distributed.api.NodeToolResult;
+import org.apache.cassandra.sidecar.testing.QualifiedName;
+import org.apache.cassandra.spark.data.CassandraDataLayer;
+import org.apache.cassandra.spark.data.CassandraDataSourceHelper;
+import org.apache.cassandra.spark.data.partitioner.CassandraInstance;
+import org.apache.cassandra.spark.data.partitioner.CassandraRing;
+import org.apache.cassandra.testing.ClusterBuilderConfiguration;
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+
+import static 
org.apache.cassandra.distributed.shared.NetworkTopology.dcAndRack;
+import static org.apache.cassandra.testing.TestUtils.DC1_RF3;
+import static org.apache.cassandra.testing.TestUtils.TEST_KEYSPACE;
+import static org.apache.cassandra.testing.TestUtils.uniqueTestTableFullName;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests bulk reads against a datacenter split across several racks.
+ * <p>
+ * This closes a coverage gap. Every other integration test places all nodes 
of a datacenter in a single rack, where
+ * Cassandra's rack-aware replica assignment reduces to "take the next RF 
nodes in ring order" - exactly what the
+ * reader derives locally. With one rack per replica, {@code 
NetworkTopologyStrategy} instead skips nodes to spread
+ * replicas across racks (see {@code acceptableRackRepeats}), so a derived 
replica set can disagree with Cassandra's.
+ * <p>
+ * {@code CassandraRing} states that it assumes racks are not in use, so a 
divergence here would previously have been
+ * invisible in CI.
+ * <p>
+ * The reads cannot tell the two topology sources apart: every replica here 
holds the data, so even a wrong replica
+ * set returns every row. {@link 
#testCassandraSuppliedReplicasMatchCassandra()} therefore compares the reader's 
rings
+ * with Cassandra's own placement directly. Reads from keyspaces where a wrong 
replica holds no data, that is with
+ * witness replicas, are covered by the witness replica tests.
+ */
+class BulkReaderMultiRackTest extends SharedClusterSparkIntegrationTestBase
+{
+    static final List<String> DATASET = Arrays.asList("a", "b", "c", "d", "e", 
"f", "g");
+    private static final Pattern DESCRIBE_RING_RANGE = 
Pattern.compile("end_token:(-?\\d+), endpoints:\\[([^\\]]*)\\]");
+
+    QualifiedName table = uniqueTestTableFullName(TEST_KEYSPACE);
+
+    @Override
+    protected ClusterBuilderConfiguration testClusterConfiguration()
+    {
+        // Four nodes, three racks, with the first two nodes sharing a rack, 
and RF 3.
+        //
+        // NetworkTopologyStrategy computes acceptableRackRepeats = RF - 
rackCount, which is 0 here, so a node whose
+        // rack has already been used is skipped. Tokens are handed out in 
node order, so for the range whose first
+        // replica is node 1 the reader derives the next three nodes in ring 
order, 1,2,3, while Cassandra must skip
+        // node 2 (rack1 again) and pick 1,3,4. The two topology sources 
therefore genuinely disagree, which is the
+        // situation no existing test creates.
+        //
+        // Four nodes is the minimum that produces a disagreement. With one 
node per rack, or with racks assigned
+        // round-robin, rack-aware selection coincides with ring order and the 
test would prove nothing.
+        return super.testClusterConfiguration()
+                    .nodesPerDc(4)
+                    .dcAndRackSupplier((nodeId) -> {
+                        switch (nodeId)
+                        {
+                            case 1:
+                            case 2:
+                                return dcAndRack("datacenter1", "rack1");
+                            case 3:
+                                return dcAndRack("datacenter1", "rack2");
+                            case 4:
+                                return dcAndRack("datacenter1", "rack3");
+                            default:
+                                return dcAndRack("", "");
+                        }
+                    });
+    }
+
+    @Test
+    void testReadWithDerivedRangesOnMultiRackCluster()
+    {
+        assertCompleteDataset(bulkReaderDataFrame(table).load());
+    }
+
+    @Test
+    void testReadWithCassandraSuppliedRangesOnMultiRackCluster()
+    {
+        Dataset<Row> data = bulkReaderDataFrame(table, 
Collections.singletonMap("forcecassandratokenranges", "true"))
+                            .load();
+        assertCompleteDataset(data);
+    }
+
+    /**
+     * Both topology sources return the same rows, as every replica holds the 
data; see
+     * {@link #testCassandraSuppliedReplicasMatchCassandra()} for the check 
that tells them apart.
+     */
+    @Test
+    void testBothTopologySourcesAgreeOnMultiRackCluster()
+    {
+        List<String> derived = sortedNames(bulkReaderDataFrame(table).load());
+        List<String> supplied = sortedNames(bulkReaderDataFrame(table,
+                                                                
Collections.singletonMap("forcecassandratokenranges", "true"))
+                                            .load());
+        assertThat(supplied)
+        .describedAs("derived and Cassandra-supplied ranges must return the 
same rows on a rack-aware cluster")
+        .isEqualTo(derived);
+        assertThat(derived).hasSize(DATASET.size());
+    }
+
+    @Test
+    void testReadAtLocalQuorumOnMultiRackCluster()
+    {
+        Dataset<Row> data = bulkReaderDataFrame(table, 
Collections.singletonMap("forcecassandratokenranges", "true"))
+                            .option("consistencyLevel", "LOCAL_QUORUM")
+                            .load();
+        assertCompleteDataset(data);
+    }
+
+    /**
+     * The point of the test class. Taking replicas from Cassandra must 
reproduce Cassandra's rack-aware placement for
+     * every range, while the local derivation, which ignores racks, must get 
at least one range wrong on this cluster.
+     * The second assertion proves the cluster really exercises the 
difference, so the first is not trivially true.
+     */
+    @Test
+    void testCassandraSuppliedReplicasMatchCassandra() throws 
UnknownHostException
+    {
+        Map<BigInteger, Set<String>> cassandraPlacement = 
cassandraReplicasByRangeEnd();
+        assertThat(cassandraPlacement).isNotEmpty();
+        CassandraRing supplied = readerRing(true);
+        CassandraRing derived = readerRing(false);
+
+        boolean derivedDiverges = false;
+        for (Map.Entry<BigInteger, Set<String>> range : 
cassandraPlacement.entrySet())
+        {
+            assertThat(replicaNames(supplied, range.getKey()))
+            .describedAs("Cassandra-supplied replicas of the range ending at 
%s", range.getKey())
+            .isEqualTo(range.getValue());
+            derivedDiverges |= !replicaNames(derived, 
range.getKey()).equals(range.getValue());
+        }
+        assertThat(derivedDiverges)
+        .describedAs("locally derived replicas should differ from Cassandra's 
for some range on a rack-aware cluster, "
+                     + "otherwise this cluster does not exercise rack-aware 
placement")
+        .isTrue();
+    }
+
+    /**
+     * Builds the ring a bulk read would use, without taking a snapshot
+     */
+    private CassandraRing readerRing(boolean forceCassandraTokenRanges)
+    {
+        getOrCreateSparkSession();
+        Map<String, String> options = new 
HashMap<>(mtlsTestHelper.mtlOptionMap());
+        options.put("sidecar_contact_points", 
SparkTestUtils.sidecarInstancesOptionStream(cluster, dnsResolver)
+                                                            
.collect(Collectors.joining(",")));
+        options.put("sidecar_port", String.valueOf(server.actualPort()));
+        options.put("keyspace", table.keyspace());
+        options.put("table", table.table());
+        options.put("DC", "datacenter1");
+        options.put("snapshotName", UUID.randomUUID().toString());
+        options.put("createSnapshot", "false");
+        options.put("clearSnapshotStrategy", "noop");
+        options.put("forcecassandratokenranges", 
String.valueOf(forceCassandraTokenRanges));
+        CassandraDataLayer dataLayer = (CassandraDataLayer) 
CassandraDataSourceHelper.getDataLayer(options, CassandraDataLayer::initialize);
+        
assertThat(dataLayer.ring().hasReportedRanges()).isEqualTo(forceCassandraTokenRanges);
+        return dataLayer.ring();
+    }
+
+    private static Set<String> replicaNames(CassandraRing ring, BigInteger 
token)
+    {
+        return 
ring.getReplicas(token).stream().map(CassandraInstance::nodeName).collect(Collectors.toCollection(TreeSet::new));
+    }
+
+    /**
+     * Cassandra's own placement, from {@code nodetool describering}, keyed by 
the inclusive end of each range
+     */
+    private Map<BigInteger, Set<String>> cassandraReplicasByRangeEnd() throws 
UnknownHostException
+    {
+        NodeToolResult result = 
cluster.getFirstRunningInstance().nodetoolResult("describering", 
table.keyspace());
+        result.asserts().success();
+        Map<BigInteger, Set<String>> placement = new LinkedHashMap<>();
+        Matcher matcher = DESCRIBE_RING_RANGE.matcher(result.getStdout());
+        while (matcher.find())
+        {
+            Set<String> names = new TreeSet<>();
+            for (String endpoint : matcher.group(2).split(","))
+            {
+                String address = endpoint.trim().replaceFirst("^/", 
"").replaceFirst(":\\d+$", "");
+                names.add(dnsResolver.reverseResolve(address));
+            }
+            placement.put(new BigInteger(matcher.group(1)), names);
+        }
+        return placement;
+    }
+
+    private void assertCompleteDataset(Dataset<Row> data)
+    {
+        List<Row> rows = data.collectAsList().stream()
+                             .sorted(Comparator.comparing(row -> 
row.getInt(0)))
+                             .collect(Collectors.toList());
+        assertThat(rows).hasSize(DATASET.size());
+        for (int i = 0; i < DATASET.size(); i++)
+        {
+            assertThat(rows.get(i).getInt(0)).isEqualTo(i);
+            assertThat(rows.get(i).getString(1)).isEqualTo(DATASET.get(i));
+        }
+    }
+
+    private List<String> sortedNames(Dataset<Row> data)
+    {
+        return data.collectAsList().stream()
+                   .map(row -> row.getInt(0) + "=" + row.getString(1))
+                   .sorted()
+                   .collect(Collectors.toList());
+    }
+
+    @Override
+    protected void initializeSchemaForTest()
+    {
+        createTestKeyspace(TEST_KEYSPACE, DC1_RF3);
+        createTestTable(table, "CREATE TABLE IF NOT EXISTS %s (id int PRIMARY 
KEY, name text);");
+
+        IInstance firstRunningInstance = cluster.getFirstRunningInstance();
+        for (int i = 0; i < DATASET.size(); i++)
+        {
+            firstRunningInstance.coordinator()
+                                .execute(String.format("INSERT INTO %s (id, 
name) VALUES (%d, '%s');",
+                                                       table, i, 
DATASET.get(i)),
+                                         ConsistencyLevel.ALL);
+        }
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to