diff --git a/CHANGES.txt b/CHANGES.txt index c3cda0a5a..0cc8fa70a 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 6f63c357a..fd890a87e 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 @@ -37,9 +37,7 @@ import com.google.common.collect.BoundType; import com.google.common.collect.Range; import com.google.common.collect.RangeMap; -import com.google.common.collect.RangeSet; import com.google.common.collect.TreeRangeMap; -import com.google.common.collect.TreeRangeSet; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -205,17 +203,10 @@ private void validateRangesDoNotOverlap() private void validateCompleteRangeCoverage() { - RangeSet missingRangeSet = TreeRangeSet.create(); - missingRangeSet.add(Range.closed(ring.partitioner().minToken(), - ring.partitioner().maxToken())); - - partitionMap.asMapOfRanges().keySet().forEach(missingRangeSet::remove); - - List> missingRanges = missingRangeSet.asRanges().stream() - .filter(Range::isEmpty) - .collect(Collectors.toList()); + List> missingRanges = RangeUtils.findUncoveredRingRanges(ring.partitioner(), + partitionMap.asMapOfRanges().keySet()); Preconditions.checkState(missingRanges.isEmpty(), - "There should be no missing ranges, but found " + missingRanges.toString()); + "There should be no missing ranges, but found %s", missingRanges); } private void validateMapSizes() diff --git a/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/utils/RangeUtils.java b/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/utils/RangeUtils.java index 67912d36c..d100f9064 100644 --- a/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/utils/RangeUtils.java +++ b/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/utils/RangeUtils.java @@ -21,14 +21,18 @@ import java.math.BigInteger; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.List; +import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.BoundType; import com.google.common.collect.Multimap; import com.google.common.collect.Range; +import com.google.common.collect.RangeSet; +import com.google.common.collect.TreeRangeSet; import org.apache.cassandra.bridge.TokenRange; import org.apache.cassandra.spark.data.model.TokenOwner; @@ -70,6 +74,56 @@ public static boolean isOpenClosedRange(Range range) return range.lowerBoundType() == BoundType.OPEN && range.upperBoundType() == BoundType.CLOSED; } + /** + * Finds the sub-ranges of the whole token ring that none of the {@code coveringRanges} covers, i.e. the gaps in + * the ring coverage. An empty result means the ring is covered in its entirety. + *

+ * The ring is expressed as {@code (minToken, maxToken]}, matching the open-closed notation used for token + * ranges everywhere else. Expressing it as {@code [minToken, maxToken]} instead would report minToken as a + * spurious single-token gap, because open-closed ranges never cover their own lower endpoint. + * + * @param partitioner the partitioner whose ring bounds are expected to be covered + * @param coveringRanges the ranges expected to cover the ring; they may overlap and need not be sorted + * @return the uncovered sub-ranges of the ring, in ascending order + */ + public static List> findUncoveredRingRanges(Partitioner partitioner, + Collection> coveringRanges) + { + return findUncoveredRanges(Range.openClosed(partitioner.minToken(), partitioner.maxToken()), coveringRanges); + } + + /** + * Finds the sub-ranges of {@code fullRange} that none of the {@code coveringRanges} covers, i.e. the gaps in + * the coverage. An empty result means {@code coveringRanges} covers {@code fullRange} in its entirety. + *

+ * Both the input and the output honor Guava's bound types, so {@code fullRange} must be expressed using the + * same notation as the covering ranges. That makes the method easy to misuse, which is why it is not exposed + * beyond this class: callers go through {@link #findUncoveredRingRanges}, which owns the notation so that the + * bound types are not decided at each call site. Empty ranges are never reported, as a range that contains no + * token cannot be a gap. + * + * @param fullRange the non-empty range expected to be fully covered + * @param coveringRanges the ranges expected to cover {@code fullRange}; they may overlap and need not be sorted + * @return the uncovered sub-ranges of {@code fullRange}, in ascending order + */ + @VisibleForTesting + static List> findUncoveredRanges(Range fullRange, + Collection> coveringRanges) + { + // An empty fullRange has nothing to cover, so every input would trivially look fully covered. + // Reject it rather than report a false "no gaps". + Preconditions.checkArgument(!fullRange.isEmpty(), "fullRange must not be empty"); + + RangeSet uncovered = TreeRangeSet.create(); + uncovered.add(fullRange); + // Not removeAll: on Guava 16.0.1 it only accepts a RangeSet, not a Collection, and it is itself just this + // loop of remove() calls, so there is nothing to gain by wrapping the input in a second range set + coveringRanges.forEach(uncovered::remove); + // TreeRangeSet coalesces connected ranges and never retains empty ones, + // so everything left is a real, non-empty gap + return new ArrayList<>(uncovered.asRanges()); + } + /** * Splits the given range into equal-sized small ranges. Number of splits can be controlled by * nrSplits. If nrSplits are smaller than size of the range, split size would be set to 1, which is 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 000000000..54afc589c --- /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> 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> 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 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> withGapAt(List> gapFreeRanges, int gapIndex) + { + List> ranges = new ArrayList<>(gapFreeRanges); + Range 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 gapPunchedInto(Range range) + { + return Range.openClosed(range.lowerEndpoint(), range.lowerEndpoint().add(BigInteger.TEN)); + } + + private static CassandraRing ring() + { + List 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-common/src/test/java/org/apache/cassandra/spark/utils/RangeUtilsTest.java b/cassandra-analytics-common/src/test/java/org/apache/cassandra/spark/utils/RangeUtilsTest.java index 772eef521..cbce2c63e 100644 --- a/cassandra-analytics-common/src/test/java/org/apache/cassandra/spark/utils/RangeUtilsTest.java +++ b/cassandra-analytics-common/src/test/java/org/apache/cassandra/spark/utils/RangeUtilsTest.java @@ -230,6 +230,169 @@ void testSplitNotSatisfyNrSplits() assertThat(RangeUtils.split(range, nrSplits)).isEqualTo(expectedResult); } + @Test + void testFindUncoveredRangesWithCompleteCoverage() + { + Range fullRange = Range.openClosed(BigInteger.ZERO, BigInteger.valueOf(30)); + List> covering = Arrays.asList( + Range.openClosed(BigInteger.ZERO, BigInteger.TEN), + Range.openClosed(BigInteger.TEN, BigInteger.valueOf(20)), + Range.openClosed(BigInteger.valueOf(20), BigInteger.valueOf(30)) + ); + assertThat(RangeUtils.findUncoveredRanges(fullRange, covering)).isEmpty(); + } + + @Test + void testFindUncoveredRangesDetectsGap() + { + // leaves (10, 20] uncovered -- a real, non-empty gap + Range fullRange = Range.openClosed(BigInteger.ZERO, BigInteger.valueOf(30)); + List> covering = Arrays.asList( + Range.openClosed(BigInteger.ZERO, BigInteger.TEN), + Range.openClosed(BigInteger.valueOf(20), BigInteger.valueOf(30)) + ); + assertThat(RangeUtils.findUncoveredRanges(fullRange, covering)) + .containsExactly(Range.openClosed(BigInteger.TEN, BigInteger.valueOf(20))); + } + + @Test + void testFindUncoveredRangesDetectsMultipleGapsInAscendingOrder() + { + Range fullRange = Range.openClosed(BigInteger.ZERO, BigInteger.valueOf(40)); + List> covering = Arrays.asList( + Range.openClosed(BigInteger.valueOf(20), BigInteger.valueOf(30)), + Range.openClosed(BigInteger.ZERO, BigInteger.TEN) + ); + assertThat(RangeUtils.findUncoveredRanges(fullRange, covering)) + .containsExactly(Range.openClosed(BigInteger.TEN, BigInteger.valueOf(20)), + Range.openClosed(BigInteger.valueOf(30), BigInteger.valueOf(40))); + } + + @Test + void testFindUncoveredRangesToleratesOverlappingAndUnsortedInput() + { + Range fullRange = Range.openClosed(BigInteger.ZERO, BigInteger.valueOf(30)); + List> covering = Arrays.asList( + Range.openClosed(BigInteger.valueOf(15), BigInteger.valueOf(30)), + Range.openClosed(BigInteger.ZERO, BigInteger.valueOf(20)) + ); + assertThat(RangeUtils.findUncoveredRanges(fullRange, covering)).isEmpty(); + } + + @Test + void testFindUncoveredRangesToleratesEmptyCoveringRanges() + { + // (5, 5] is degenerate: it covers no token, so removing it is a no-op and the ranges either side still cover + Range fullRange = Range.openClosed(BigInteger.ZERO, BigInteger.TEN); + List> covering = Arrays.asList( + Range.openClosed(BigInteger.ZERO, BigInteger.valueOf(5)), + Range.openClosed(BigInteger.valueOf(5), BigInteger.valueOf(5)), + Range.openClosed(BigInteger.valueOf(5), BigInteger.TEN) + ); + assertThat(RangeUtils.findUncoveredRanges(fullRange, covering)).isEmpty(); + } + + @Test + void testFindUncoveredRangesRejectsEmptyFullRange() + { + // Nothing can cover an empty range, so reporting it as fully covered would be a false negative + Range emptyRange = Range.openClosed(BigInteger.TEN, BigInteger.TEN); + assertThatThrownBy(() -> RangeUtils.findUncoveredRanges(emptyRange, Collections.emptyList())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("fullRange must not be empty"); + } + + @Test + void testFindUncoveredRingRangesDetectsGap() + { + for (Partitioner partitioner : Partitioner.values()) + { + List> ring = RangeUtils.split(wholeRing(partitioner), 4); + assertThat(RangeUtils.findUncoveredRingRanges(partitioner, withGapAt(ring, 2))) + .as("A gap in the %s ring should be reported", partitioner) + .containsExactly(gapPunchedInto(ring.get(2))); + } + } + + @Test + void testFindUncoveredRingRangesDetectsGapAtRingLowerEdge() + { + // The bound type at minToken is the whole subtlety of findUncoveredRingRanges: it must not report minToken + // itself as a gap, yet it must still report a gap that starts immediately above minToken + for (Partitioner partitioner : Partitioner.values()) + { + List> ring = RangeUtils.split(wholeRing(partitioner), 4); + assertThat(RangeUtils.findUncoveredRingRanges(partitioner, withGapAt(ring, 0))) + .as("A gap at the lower edge of the %s ring should be reported", partitioner) + .containsExactly(gapPunchedInto(ring.get(0))); + } + } + + @Test + void testFindUncoveredRingRangesDetectsMissingFirstSubRange() + { + // Dropping the first sub-range leaves everything above minToken up to its upper endpoint uncovered + for (Partitioner partitioner : Partitioner.values()) + { + List> ring = RangeUtils.split(wholeRing(partitioner), 4); + assertThat(RangeUtils.findUncoveredRingRanges(partitioner, ring.subList(1, ring.size()))) + .as("A missing first sub-range of the %s ring should be reported", partitioner) + .containsExactly(Range.openClosed(partitioner.minToken(), ring.get(0).upperEndpoint())); + } + } + + @Test + void testFindUncoveredRingRangesDetectsMissingLastSubRange() + { + // The counterpart of the above at the other end of the ring, where maxToken is inclusive + for (Partitioner partitioner : Partitioner.values()) + { + List> ring = RangeUtils.split(wholeRing(partitioner), 4); + assertThat(RangeUtils.findUncoveredRingRanges(partitioner, ring.subList(0, ring.size() - 1))) + .as("A missing last sub-range of the %s ring should be reported", partitioner) + .containsExactly(ring.get(ring.size() - 1)); + } + } + + @Test + void testFindUncoveredRingRangesOverWholeRingIsGapFree() + { + // A gap-free split of the whole ring leaves minToken uncovered, because the sub-ranges are open-closed. + // findUncoveredRingRanges owns that bound-type decision, so no caller can get it wrong: it must not + // report [minToken, minToken] as a gap. + for (Partitioner partitioner : Partitioner.values()) + { + assertThat(RangeUtils.findUncoveredRingRanges(partitioner, RangeUtils.split(wholeRing(partitioner), 16))) + .as("Splitting the whole %s ring should leave no gap", partitioner) + .isEmpty(); + } + } + + private static Range wholeRing(Partitioner partitioner) + { + 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> withGapAt(List> gapFreeRanges, int gapIndex) + { + List> ranges = new ArrayList<>(gapFreeRanges); + Range 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 gapPunchedInto(Range range) + { + return Range.openClosed(range.lowerEndpoint(), range.lowerEndpoint().add(BigInteger.TEN)); + } + private static void assertTokenRanges(int nodes, int replicationFactor, String[]... ranges) { assertThat(nodes).isEqualTo(ranges.length); 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 479ddcae0..ea51c385b 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 @@ -32,9 +32,7 @@ import com.google.common.base.Preconditions; import com.google.common.collect.Range; import com.google.common.collect.RangeMap; -import com.google.common.collect.RangeSet; import com.google.common.collect.TreeRangeMap; -import com.google.common.collect.TreeRangeSet; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -229,18 +227,10 @@ private void validateRangesDoNotOverlap() private void validateCompleteRangeCoverage() { - RangeSet missingRangeSet = TreeRangeSet.create(); - missingRangeSet.add(Range.closed(tokenRangeMapping.partitioner().minToken(), - tokenRangeMapping.partitioner().maxToken())); - - partitionMap.asMapOfRanges().keySet().forEach(missingRangeSet::remove); - - List> missingRanges = missingRangeSet.asRanges().stream() - .filter(Range::isEmpty) - .collect(Collectors.toList()); - // noinspection unchecked + List> missingRanges = RangeUtils.findUncoveredRingRanges(tokenRangeMapping.partitioner(), + partitionMap.asMapOfRanges().keySet()); Preconditions.checkState(missingRanges.isEmpty(), - "There should be no missing ranges, but found " + missingRanges.toString()); + "There should be no missing ranges, but found %s", missingRanges); } private void validateMapSizes() 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 e2657d5e9..dd1164d0a 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 @@ 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 void testSplitCalculationWithMultipleDcs() assertThat(partitioner.numPartitions()).isGreaterThanOrEqualTo(200); } + // Range coverage validation must reject a partition map that leaves a token uncovered. + @Test + public void testValidationDetectsRangeGap() + { + List> 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> 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 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 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 mappingCovering(List> ranges) + { + RangeMap> rangeMap = TreeRangeMap.create(); + ranges.forEach(range -> rangeMap.put(range, Collections.emptyList())); + + @SuppressWarnings("unchecked") + TokenRangeMapping 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> withGapAt(List> gapFreeRanges, int gapIndex) + { + List> ranges = new ArrayList<>(gapFreeRanges); + Range 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 gapPunchedInto(Range range) + { + return Range.openClosed(range.lowerEndpoint(), range.lowerEndpoint().add(BigInteger.TEN)); + } + private int partitionForToken(int token) { return partitionForToken(BigInteger.valueOf(token));