This is an automated email from the ASF dual-hosted git repository.
yifan-c pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/cassandra-analytics.git
The following commit(s) were added to refs/heads/trunk by this push:
new 20116e26 CASSANALYTICS-180: TokenPartitioner fails to detect range gap
in reader (#234)
20116e26 is described below
commit 20116e26a360084e23fe127661b3adca9e9f082d
Author: Mansi Khara <[email protected]>
AuthorDate: Tue Sep 1 17:23:53 2026 -0400
CASSANALYTICS-180: TokenPartitioner fails to detect range gap in reader
(#234)
Patch by Mansi Khara; Reviewed by Jyothsna Konisa, Yifan Cai for
CASSANALYTICS-180
---
CHANGES.txt | 1 +
.../spark/data/partitioner/TokenPartitioner.java | 12 ++-
.../TokenPartitionerValidationTest.java | 111 +++++++++++++++++++++
.../spark/bulkwriter/TokenPartitioner.java | 13 +--
.../spark/bulkwriter/TokenPartitionerTest.java | 100 +++++++++++++++++++
5 files changed, 226 insertions(+), 11 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index c3cda0a5..0cc8fa70 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,6 @@
0.5.0
-----
+ * TokenPartitioner fails to detect range gap in reader (CASSANALYTICS-180)
* CDC reader stats silently dropped in SidecarCdcBuilder (CASSANALYTICS-191)
* Add CapturePublishedSchema metric to SidecarCdcStats (CASSANALYTICS-189)
* Expand list of architecture that supports unaligned access in
FastByteOperations (CASSANALYTICS-188)
diff --git
a/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/data/partitioner/TokenPartitioner.java
b/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/data/partitioner/TokenPartitioner.java
index 6f63c357..3fe4ce42 100644
---
a/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/data/partitioner/TokenPartitioner.java
+++
b/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/data/partitioner/TokenPartitioner.java
@@ -31,6 +31,7 @@ import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.stream.Collectors;
import com.google.common.base.Preconditions;
@@ -206,14 +207,15 @@ public class TokenPartitioner implements Serializable
private void validateCompleteRangeCoverage()
{
RangeSet<BigInteger> missingRangeSet = TreeRangeSet.create();
- missingRangeSet.add(Range.closed(ring.partitioner().minToken(),
- ring.partitioner().maxToken()));
+ // The ring must be open-closed, matching the sub-ranges it is
compared against; a closed lower bound would
+ // report minToken as a spurious gap, because open-closed sub-ranges
never cover their own lower endpoint
+ missingRangeSet.add(Range.openClosed(ring.partitioner().minToken(),
+ ring.partitioner().maxToken()));
partitionMap.asMapOfRanges().keySet().forEach(missingRangeSet::remove);
- List<Range<BigInteger>> missingRanges =
missingRangeSet.asRanges().stream()
-
.filter(Range::isEmpty)
-
.collect(Collectors.toList());
+ // Whatever is left is a real gap: TreeRangeSet never retains empty
ranges
+ Set<Range<BigInteger>> missingRanges = missingRangeSet.asRanges();
Preconditions.checkState(missingRanges.isEmpty(),
"There should be no missing ranges, but found
" + missingRanges.toString());
}
diff --git
a/cassandra-analytics-common/src/test/java/org/apache/cassandra/spark/data/partitioner/TokenPartitionerValidationTest.java
b/cassandra-analytics-common/src/test/java/org/apache/cassandra/spark/data/partitioner/TokenPartitionerValidationTest.java
new file mode 100644
index 00000000..54afc589
--- /dev/null
+++
b/cassandra-analytics-common/src/test/java/org/apache/cassandra/spark/data/partitioner/TokenPartitionerValidationTest.java
@@ -0,0 +1,111 @@
+/*
+ * 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.spark.data.partitioner;
+
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Range;
+import org.junit.jupiter.api.Test;
+
+import org.apache.cassandra.spark.data.ReplicationFactor;
+import org.apache.cassandra.spark.utils.RangeUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class TokenPartitionerValidationTest
+{
+ private static final Partitioner PARTITIONER =
Partitioner.Murmur3Partitioner;
+
+ @Test
+ public void testValidationDetectsRangeGap()
+ {
+ List<Range<BigInteger>> subRanges = RangeUtils.split(wholeRing(), 4);
+
+ assertThatThrownBy(() -> new TokenPartitioner(withGapAt(subRanges, 2),
ring()))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("There should be no missing ranges")
+ .hasMessageContaining(gapPunchedInto(subRanges.get(2)).toString());
+ }
+
+ @Test
+ public void testValidationDetectsRangeGapAtRingLowerEdge()
+ {
+ // Guards the bound type at minToken from both sides: minToken itself
is owned by no sub-range and must not
+ // be reported, yet a gap starting immediately above it must still be
caught. The gap is punched into the
+ // first sub-range rather than dropping it, so that the partition
count stays put and validateMapSizes
+ // cannot fail first with an unrelated message.
+ List<Range<BigInteger>> subRanges = RangeUtils.split(wholeRing(), 4);
+
+ assertThatThrownBy(() -> new TokenPartitioner(withGapAt(subRanges, 0),
ring()))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("There should be no missing ranges")
+ .hasMessageContaining(gapPunchedInto(subRanges.get(0)).toString());
+ }
+
+ @Test
+ public void testValidationAcceptsCompleteRangeCoverage()
+ {
+ // minToken is deliberately left uncovered: the sub-ranges are
open-closed, so it belongs to none of them.
+ // Validation must not report it as a gap, otherwise every job fails
on a healthy ring.
+ TokenPartitioner partitioner = new
TokenPartitioner(RangeUtils.split(wholeRing(), 4), ring());
+ assertThat(partitioner.numPartitions()).isEqualTo(4);
+ }
+
+ private static Range<BigInteger> wholeRing()
+ {
+ return Range.openClosed(PARTITIONER.minToken(),
PARTITIONER.maxToken());
+ }
+
+ /**
+ * Punches a real, non-empty gap into the sub-range at {@code gapIndex} by
moving its lower endpoint up, so that
+ * the returned ranges leave exactly {@link #gapPunchedInto} uncovered.
+ */
+ private static List<Range<BigInteger>> withGapAt(List<Range<BigInteger>>
gapFreeRanges, int gapIndex)
+ {
+ List<Range<BigInteger>> ranges = new ArrayList<>(gapFreeRanges);
+ Range<BigInteger> covered = ranges.get(gapIndex);
+ ranges.set(gapIndex,
Range.openClosed(gapPunchedInto(covered).upperEndpoint(),
covered.upperEndpoint()));
+ return ranges;
+ }
+
+ /**
+ * @return the sub-range that {@link #withGapAt} leaves uncovered when it
punches a gap into {@code range}
+ */
+ private static Range<BigInteger> gapPunchedInto(Range<BigInteger> range)
+ {
+ return Range.openClosed(range.lowerEndpoint(),
range.lowerEndpoint().add(BigInteger.TEN));
+ }
+
+ private static CassandraRing ring()
+ {
+ List<CassandraInstance> instances = Arrays.asList(new
CassandraInstance("0", "local0-i1", "DEV"),
+ new
CassandraInstance("100", "local0-i2", "DEV"),
+ new
CassandraInstance("200", "local0-i3", "DEV"));
+ return new CassandraRing(PARTITIONER,
+ "test",
+ new
ReplicationFactor(ImmutableMap.of("class", "NetworkTopologyStrategy", "DEV",
"3")),
+ instances);
+ }
+}
diff --git
a/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/bulkwriter/TokenPartitioner.java
b/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/bulkwriter/TokenPartitioner.java
index 479ddcae..d4efb1bf 100644
---
a/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/bulkwriter/TokenPartitioner.java
+++
b/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/bulkwriter/TokenPartitioner.java
@@ -25,6 +25,7 @@ import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
@@ -230,15 +231,15 @@ public class TokenPartitioner extends Partitioner
private void validateCompleteRangeCoverage()
{
RangeSet<BigInteger> missingRangeSet = TreeRangeSet.create();
-
missingRangeSet.add(Range.closed(tokenRangeMapping.partitioner().minToken(),
-
tokenRangeMapping.partitioner().maxToken()));
+ // The ring must be open-closed, matching the sub-ranges it is
compared against; a closed lower bound would
+ // report minToken as a spurious gap, because open-closed sub-ranges
never cover their own lower endpoint
+
missingRangeSet.add(Range.openClosed(tokenRangeMapping.partitioner().minToken(),
+
tokenRangeMapping.partitioner().maxToken()));
partitionMap.asMapOfRanges().keySet().forEach(missingRangeSet::remove);
- List<Range<BigInteger>> missingRanges =
missingRangeSet.asRanges().stream()
-
.filter(Range::isEmpty)
-
.collect(Collectors.toList());
- // noinspection unchecked
+ // Whatever is left is a real gap: TreeRangeSet never retains empty
ranges
+ Set<Range<BigInteger>> missingRanges = missingRangeSet.asRanges();
Preconditions.checkState(missingRanges.isEmpty(),
"There should be no missing ranges, but found
" + missingRanges.toString());
}
diff --git
a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/TokenPartitionerTest.java
b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/TokenPartitionerTest.java
index e2657d5e..dd1164d0 100644
---
a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/TokenPartitionerTest.java
+++
b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/TokenPartitionerTest.java
@@ -21,17 +21,31 @@ package org.apache.cassandra.spark.bulkwriter;
import java.math.BigInteger;
import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Range;
+import com.google.common.collect.RangeMap;
+import com.google.common.collect.TreeRangeMap;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.apache.cassandra.spark.bulkwriter.token.TokenRangeMapping;
+import org.apache.cassandra.spark.data.partitioner.Partitioner;
+import org.apache.cassandra.spark.utils.RangeUtils;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
public class TokenPartitionerTest
{
+ private static final Partitioner RING_PARTITIONER =
Partitioner.Murmur3Partitioner;
+
private TokenPartitioner partitioner;
@BeforeEach
@@ -172,6 +186,92 @@ public class TokenPartitionerTest
assertThat(partitioner.numPartitions()).isGreaterThanOrEqualTo(200);
}
+ // Range coverage validation must reject a partition map that leaves a
token uncovered.
+ @Test
+ public void testValidationDetectsRangeGap()
+ {
+ List<Range<BigInteger>> subRanges = RangeUtils.split(wholeRing(), 4);
+
+ // numberSplits of 1 leaves the ranges untouched, so they reach the
partition map as-is
+ assertThatThrownBy(() -> new
TokenPartitioner(mappingCovering(withGapAt(subRanges, 2)), 1, 2, 1, false))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("There should be no missing ranges")
+ .hasMessageContaining(gapPunchedInto(subRanges.get(2)).toString());
+ }
+
+ @Test
+ public void testValidationDetectsRangeGapAtRingLowerEdge()
+ {
+ // Guards the bound type at minToken from both sides: minToken itself
is owned by no sub-range and must not
+ // be reported, yet a gap starting immediately above it must still be
caught. The gap is punched into the
+ // first sub-range rather than dropping it, so that the partition
count stays put and validateMapSizes
+ // cannot fail first with an unrelated message.
+ List<Range<BigInteger>> subRanges = RangeUtils.split(wholeRing(), 4);
+
+ assertThatThrownBy(() -> new
TokenPartitioner(mappingCovering(withGapAt(subRanges, 0)), 1, 2, 1, false))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("There should be no missing ranges")
+ .hasMessageContaining(gapPunchedInto(subRanges.get(0)).toString());
+ }
+
+ // Guards against over-correcting the fix for the above: the partition map
is built from open-closed sub-ranges,
+ // so minToken belongs to none of them. Validation that expected
[minToken, maxToken] to be covered would report
+ // a spurious [minToken, minToken] gap and fail every bulk write on a
perfectly healthy ring.
+ @Test
+ public void testValidationAcceptsRingNotCoveringMinToken()
+ {
+ TokenRangeMapping<RingInstance> tokenRangeMapping =
TokenRangeMappingUtils.buildTokenRangeMapping(0, ImmutableMap.of("DC1", 3), 3);
+ // Validation runs in the driver as part of construction, so not
throwing here is the assertion
+ TokenPartitioner tokenPartitioner = new
TokenPartitioner(tokenRangeMapping, 2, 2, 1, false);
+ // ... and the premise of the test holds: no partition owns minToken,
as the sub-ranges are open-closed
+
assertThat(tokenPartitioner.getTokenRange(0).contains(RING_PARTITIONER.minToken())).isFalse();
+ }
+
+ private static Range<BigInteger> wholeRing()
+ {
+ return Range.openClosed(RING_PARTITIONER.minToken(),
RING_PARTITIONER.maxToken());
+ }
+
+ /**
+ * Mocking is the only way to feed a gapped range map to the partitioner:
{@link TokenRangeMapping} seeds its
+ * range map with the whole ring, so a mapping built the normal way is
always gap-free and cannot exercise the
+ * coverage check.
+ *
+ * @return a mapping whose range map covers exactly {@code ranges}
+ */
+ private static TokenRangeMapping<RingInstance>
mappingCovering(List<Range<BigInteger>> ranges)
+ {
+ RangeMap<BigInteger, List<RingInstance>> rangeMap =
TreeRangeMap.create();
+ ranges.forEach(range -> rangeMap.put(range, Collections.emptyList()));
+
+ @SuppressWarnings("unchecked")
+ TokenRangeMapping<RingInstance> tokenRangeMapping =
mock(TokenRangeMapping.class);
+ when(tokenRangeMapping.partitioner()).thenReturn(RING_PARTITIONER);
+ when(tokenRangeMapping.getRangeMap()).thenReturn(rangeMap);
+
when(tokenRangeMapping.getTokenRanges()).thenReturn(ArrayListMultimap.create());
+ return tokenRangeMapping;
+ }
+
+ /**
+ * Punches a real, non-empty gap into the sub-range at {@code gapIndex} by
moving its lower endpoint up, so that
+ * the returned ranges leave exactly {@link #gapPunchedInto} uncovered.
+ */
+ private static List<Range<BigInteger>> withGapAt(List<Range<BigInteger>>
gapFreeRanges, int gapIndex)
+ {
+ List<Range<BigInteger>> ranges = new ArrayList<>(gapFreeRanges);
+ Range<BigInteger> covered = ranges.get(gapIndex);
+ ranges.set(gapIndex,
Range.openClosed(gapPunchedInto(covered).upperEndpoint(),
covered.upperEndpoint()));
+ return ranges;
+ }
+
+ /**
+ * @return the sub-range that {@link #withGapAt} leaves uncovered when it
punches a gap into {@code range}
+ */
+ private static Range<BigInteger> gapPunchedInto(Range<BigInteger> range)
+ {
+ return Range.openClosed(range.lowerEndpoint(),
range.lowerEndpoint().add(BigInteger.TEN));
+ }
+
private int partitionForToken(int token)
{
return partitionForToken(BigInteger.valueOf(token));
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]