diff --git a/CHANGES.txt b/CHANGES.txt index e62958285..112bf73da 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,6 @@ 0.5.0 ----- + * Parse / replication factor for witness-enabled keyspaces (CASSANALYTICS-194) * Determine whether mutation tracking is enabled for keyspace for bulk writes (CASSANALYTICS-160) * Upgrade sidecar version to 0.4.0 * CDC logs NPE for deleted column values (CASSANALYTICS-178) diff --git a/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/data/ReplicationFactor.java b/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/data/ReplicationFactor.java index 0b9b6821d..229c06972 100644 --- a/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/data/ReplicationFactor.java +++ b/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/data/ReplicationFactor.java @@ -19,7 +19,9 @@ package org.apache.cassandra.spark.data; +import java.io.InvalidObjectException; import java.io.Serializable; +import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; @@ -27,8 +29,6 @@ import java.util.Objects; import com.google.common.collect.ImmutableMap; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import com.esotericsoftware.kryo.Kryo; import com.esotericsoftware.kryo.io.Input; @@ -50,12 +50,16 @@ * "replication_factor" : 1 * } * } + *

+ * Replica counts may also use the {@code /} form, e.g. {@code "DC1" : "3/1"}, meaning three + * replicas of which one is transient. Witness replicas (CEP-46) reuse this form, so {@code "3/1"} describes two full + * replicas and one witness. {@link #getTotalReplicationFactor()} continues to report all three; use + * {@link #getFullReplicationFactor()} for the count that holds the full data set. */ public class ReplicationFactor implements Serializable { public static final Serializer SERIALIZER = new Serializer(); private static final long serialVersionUID = -2017022813595983257L; - private static final Logger LOGGER = LoggerFactory.getLogger(ReplicationFactor.class); public enum ReplicationStrategy { @@ -106,64 +110,232 @@ public static ReplicationFactor simpleStrategy(int rf) @NotNull private final ReplicationStrategy replicationStrategy; + /** + * Per-datacenter replica counts. Holding the total and the transient count together, rather than in two + * parallel maps, means the two cannot drift apart. + */ @NotNull - private final Map options; + private final Map replication; + // Derived from replication, computed once because this class is immutable. Marked transient so they are absent + // from the serialized form: readResolve rebuilds through the canonical constructor, and the Kryo serializer + // already goes through it, so neither path can leave these out of step with replication. + private final transient int totalReplicationFactor; + private final transient int fullReplicationFactor; + private final transient int transientReplicationFactor; + private final transient Map options; + private final transient Map transientOptions; + + /** + * Parses a raw replication map. A value that cannot be parsed, or that Cassandra itself would reject, raises + * {@link IllegalArgumentException} naming the offending datacenter: a partial replication factor is not usable + * and reporting it here avoids a misleading failure later, such as "DC not found in replication factor". + * + * @param options the raw replication map, including the {@code class} entry + * @throws IllegalArgumentException when any replication value cannot be parsed + */ public ReplicationFactor(@NotNull Map options) { - this.replicationStrategy = ReplicationFactor.ReplicationStrategy.getEnum(options.get("class")); - this.options = new LinkedHashMap<>(options.size()); - for (Map.Entry entry : options.entrySet()) + this(ReplicationStrategy.getEnum(options.get("class")), parse(options), options); + } + + public ReplicationFactor(@NotNull ReplicationStrategy replicationStrategy, @NotNull Map options) + { + this(replicationStrategy, options, Collections.emptyMap()); + } + + public ReplicationFactor(@NotNull ReplicationStrategy replicationStrategy, + @NotNull Map options, + @NotNull Map transientOptions) + { + this(replicationStrategy, merge(options, transientOptions), options); + } + + /** + * Canonical constructor. Every other constructor resolves its input to per-datacenter counts and delegates here. + * + * @param replicationStrategy the replication strategy + * @param replication per-datacenter replica counts, already validated individually + * @param raw the caller's original input, used only in the error message + */ + private ReplicationFactor(@NotNull ReplicationStrategy replicationStrategy, + @NotNull Map replication, + @NotNull Object raw) + { + // A strategy other than LocalStrategy with no datacenter entries is not usable + if (replicationStrategy != ReplicationStrategy.LocalStrategy && replication.isEmpty()) + { + throw new IllegalArgumentException("Could not find replication info in schema map: " + raw); + } + this.replicationStrategy = replicationStrategy; + this.replication = Collections.unmodifiableMap(new LinkedHashMap<>(replication)); + + int total = 0; + int transientCount = 0; + Map allByDatacenter = new LinkedHashMap<>(this.replication.size()); + Map transientByDatacenter = new LinkedHashMap<>(); + for (Map.Entry entry : this.replication.entrySet()) { - if ("class".equals(entry.getKey())) + ReplicaCounts counts = entry.getValue(); + total += counts.allReplicas(); + transientCount += counts.transientReplicas(); + allByDatacenter.put(entry.getKey(), counts.allReplicas()); + if (counts.transientReplicas() > 0) { - continue; + transientByDatacenter.put(entry.getKey(), counts.transientReplicas()); } + } + this.totalReplicationFactor = total; + this.transientReplicationFactor = transientCount; + this.fullReplicationFactor = total - transientCount; + this.options = Collections.unmodifiableMap(allByDatacenter); + this.transientOptions = Collections.unmodifiableMap(transientByDatacenter); + } + /** + * Resolves raw string values, e.g. {@code "3"} or {@code "3/1"}, to per-datacenter counts. + */ + private static Map parse(@NotNull Map options) + { + Map parsed = new LinkedHashMap<>(options.size()); + options.forEach((datacenter, value) -> { + if ("class".equals(datacenter)) + { + return; + } try { - this.options.put(entry.getKey(), Integer.parseInt(entry.getValue())); + parsed.put(datacenter, ReplicaCounts.parse(value)); } - catch (NumberFormatException exception) + catch (IllegalArgumentException exception) { - LOGGER.warn("Could not parse replication option: {} = {}", entry.getKey(), entry.getValue()); + throw new IllegalArgumentException(String.format("Could not parse replication option: %s = %s", + datacenter, value), exception); } - } + }); + return parsed; } - public ReplicationFactor(@NotNull ReplicationStrategy replicationStrategy, @NotNull Map options) + /** + * Resolves separate total and transient maps to per-datacenter counts, rejecting a transient entry for a + * datacenter that has no replication factor. + */ + private static Map merge(@NotNull Map options, + @NotNull Map transientOptions) { - this.replicationStrategy = replicationStrategy; - this.options = new LinkedHashMap<>(options.size()); + Map merged = new LinkedHashMap<>(options.size()); + options.forEach((datacenter, allReplicas) -> { + if (!"class".equals(datacenter)) + { + merged.put(datacenter, + ReplicaCounts.of(datacenter, allReplicas, transientOptions.getOrDefault(datacenter, 0))); + } + }); + transientOptions.forEach((datacenter, transientReplicas) -> { + if (!"class".equals(datacenter) && transientReplicas != null && transientReplicas != 0 + && !merged.containsKey(datacenter)) + { + throw new IllegalArgumentException(String.format( + "Transient replicas specified for %s but it has no replication factor", datacenter)); + } + }); + return merged; + } - if (!replicationStrategy.equals(ReplicationStrategy.LocalStrategy) && options.isEmpty()) - { - throw new RuntimeException(String.format("Could not find replication info in schema map: %s.", options)); - } + /** + * @return the total number of replicas across all datacenters, including transient (witness) replicas. + * Semantics are unchanged from before transient replica support was added. + */ + public Integer getTotalReplicationFactor() + { + return totalReplicationFactor; + } - for (Map.Entry entry : options.entrySet()) + /** + * @return the number of replicas across all datacenters that hold the full data set, i.e. the total + * replication factor minus transient (witness) replicas + */ + public Integer getFullReplicationFactor() + { + return fullReplicationFactor; + } + + /** + * @return the number of transient (witness) replicas across all datacenters, {@code 0} when none are configured + */ + public Integer getTransientReplicationFactor() + { + return transientReplicationFactor; + } + + /** + * @return {@code true} if any datacenter is configured with transient (witness) replicas + */ + public boolean hasTransientReplicas() + { + return !transientOptions.isEmpty(); + } + + /** + * @param datacenter the datacenter to look up + * @return the number of transient (witness) replicas in {@code datacenter}, {@code 0} when it has none + * @throws IllegalArgumentException when {@code datacenter} has no replication factor + */ + public int getTransientReplicas(@NotNull String datacenter) + { + return counts(datacenter).transientReplicas(); + } + + /** + * @param datacenter the datacenter to look up + * @return the number of replicas in {@code datacenter} holding the full data set + * @throws IllegalArgumentException when {@code datacenter} has no replication factor + */ + public int getFullReplicas(@NotNull String datacenter) + { + return counts(datacenter).fullReplicas(); + } + + private ReplicaCounts counts(@NotNull String datacenter) + { + ReplicaCounts counts = replication.get(datacenter); + if (counts == null) { - if ("class".equals(entry.getKey())) - { - continue; - } - this.options.put(entry.getKey(), entry.getValue()); + throw new IllegalArgumentException(String.format("Datacenter %s not found in replication factor %s", + datacenter, replication.keySet())); } + return counts; } - public Integer getTotalReplicationFactor() + /** + * @return per-datacenter replica counts, unmodifiable + */ + @NotNull + public Map getReplication() { - return options.values().stream() - .mapToInt(Integer::intValue) - .sum(); + return replication; } + /** + * @return per-datacenter total replica counts, including transient (witness) replicas. Unmodifiable, and derived + * from {@link #getReplication()}. + */ @NotNull public Map getOptions() { return options; } + /** + * @return per-datacenter transient (witness) replica counts, unmodifiable. Datacenters without transient + * replicas are absent. + */ + @NotNull + public Map getTransientOptions() + { + return transientOptions; + } + @NotNull public ReplicationStrategy getReplicationStrategy() { @@ -188,13 +360,173 @@ public boolean equals(Object other) ReplicationFactor that = (ReplicationFactor) other; return this.replicationStrategy == that.replicationStrategy - && java.util.Objects.equals(this.options, that.options); + && Objects.equals(this.replication, that.replication); } @Override public int hashCode() { - return Objects.hash(replicationStrategy, options); + return Objects.hash(replicationStrategy, replication); + } + + /** + * {@link #serialVersionUID} is pinned, so an instance serialized before the per-datacenter counts were combined + * deserializes with a {@code null} {@code replication}. Only reachable under driver/executor version skew, which + * is not a supported configuration, so this reports the mismatch rather than silently losing the counts. + * + * @return this instance + * @throws InvalidObjectException when deserialized from an incompatible older form + */ + private Object readResolve() throws InvalidObjectException + { + if (replication == null) + { + throw new InvalidObjectException( + "ReplicationFactor was serialized by an incompatible version: per-datacenter replica counts are absent. " + + "Driver and executors must run the same version."); + } + // Rebuild through the canonical constructor so the derived fields, which are transient, are populated + return new ReplicationFactor(replicationStrategy, replication, replication); + } + + /** + * Replica counts for a single datacenter. Cassandra accepts either {@code } or + * {@code /}; transient replication predates mutation tracking, and witness replicas + * (CEP-46) reuse the same form. See {@code org.apache.cassandra.locator.ReplicationFactor} in Cassandra. + */ + public static final class ReplicaCounts implements Serializable + { + private static final long serialVersionUID = 2026091500000000001L; + private static final String TRANSIENT_SEPARATOR = "/"; + + private final int allReplicas; + private final int transientReplicas; + + private ReplicaCounts(int allReplicas, int transientReplicas) + { + this.allReplicas = allReplicas; + this.transientReplicas = transientReplicas; + } + + /** + * @param datacenter datacenter name, used only in error messages, may be {@code null} + * @param allReplicas total replicas + * @param transientReplicas transient (witness) replicas + * @return validated counts + * @throws IllegalArgumentException when the counts are inconsistent + */ + public static ReplicaCounts of(String datacenter, int allReplicas, int transientReplicas) + { + validate(datacenter, allReplicas, transientReplicas); + return new ReplicaCounts(allReplicas, transientReplicas); + } + + /** + * @return total replicas, including transient (witness) replicas + */ + public int allReplicas() + { + return allReplicas; + } + + /** + * @return replicas holding the full data set + */ + public int fullReplicas() + { + return allReplicas - transientReplicas; + } + + /** + * @return transient (witness) replicas, {@code 0} when none are configured + */ + public int transientReplicas() + { + return transientReplicas; + } + + /** + * @param value the raw replication value, e.g. {@code "3"} or {@code "3/1"} + * @return the parsed replica counts + * @throws NumberFormatException when either component is not an integer + * @throws IllegalArgumentException when the value is malformed or the counts are inconsistent + */ + static ReplicaCounts parse(@NotNull String value) + { + String trimmed = value.trim(); + int separator = trimmed.indexOf(TRANSIENT_SEPARATOR); + if (separator < 0) + { + return of(null, Integer.parseInt(trimmed), 0); + } + + if (trimmed.indexOf(TRANSIENT_SEPARATOR, separator + 1) >= 0) + { + throw new IllegalArgumentException(String.format( + "Replication factor format is or /, found '%s'", value)); + } + + return of(null, + Integer.parseInt(trimmed.substring(0, separator).trim()), + Integer.parseInt(trimmed.substring(separator + 1).trim())); + } + + /** + * Mirrors the constraints Cassandra enforces in {@code ReplicationFactor.validate}: transient replicas must + * be non-negative and strictly fewer than the total, so at least one full replica always exists. + * + * @param datacenter datacenter name for the error message, may be {@code null} + * @param allReplicas total replicas + * @param transientReplicas transient (witness) replicas + */ + static void validate(String datacenter, int allReplicas, int transientReplicas) + { + String where = datacenter == null ? "" : String.format(" for datacenter %s", datacenter); + if (allReplicas < 0) + { + throw new IllegalArgumentException(String.format( + "Replication factor must be non-negative, found %d%s", allReplicas, where)); + } + if (transientReplicas < 0) + { + throw new IllegalArgumentException(String.format( + "Transient replicas must be non-negative, found %d%s", transientReplicas, where)); + } + if (transientReplicas > 0 && transientReplicas >= allReplicas) + { + throw new IllegalArgumentException(String.format( + "Transient replicas must be zero, or less than the total replication factor. For %d/%d%s", + allReplicas, transientReplicas, where)); + } + } + + @Override + public boolean equals(Object other) + { + if (this == other) + { + return true; + } + if (other == null || getClass() != other.getClass()) + { + return false; + } + ReplicaCounts that = (ReplicaCounts) other; + return allReplicas == that.allReplicas && transientReplicas == that.transientReplicas; + } + + @Override + public int hashCode() + { + return Objects.hash(allReplicas, transientReplicas); + } + + @Override + public String toString() + { + return transientReplicas > 0 ? allReplicas + TRANSIENT_SEPARATOR + transientReplicas + : String.valueOf(allReplicas); + } } public static class Serializer extends com.esotericsoftware.kryo.Serializer @@ -203,11 +535,12 @@ public static class Serializer extends com.esotericsoftware.kryo.Serializer entry : replicationFactor.options.entrySet()) + out.writeByte(replicationFactor.replication.size()); + for (Map.Entry entry : replicationFactor.replication.entrySet()) { out.writeString(entry.getKey()); - out.writeByte(entry.getValue()); + out.writeByte(entry.getValue().allReplicas()); + out.writeByte(entry.getValue().transientReplicas()); } } @@ -215,13 +548,16 @@ public void write(Kryo kryo, Output out, ReplicationFactor replicationFactor) public ReplicationFactor read(Kryo kryo, Input in, Class type) { ReplicationStrategy strategy = ReplicationStrategy.valueOf(in.readByte()); - int numOptions = in.readByte(); - Map options = new HashMap<>(numOptions); - for (int option = 0; option < numOptions; option++) + int numDatacenters = in.readByte(); + Map replication = new LinkedHashMap<>(numDatacenters); + for (int datacenter = 0; datacenter < numDatacenters; datacenter++) { - options.put(in.readString(), (int) in.readByte()); + String name = in.readString(); + int allReplicas = in.readByte(); + int transientReplicas = in.readByte(); + replication.put(name, ReplicaCounts.of(name, allReplicas, transientReplicas)); } - return new ReplicationFactor(strategy, options); + return new ReplicationFactor(strategy, replication, replication); } } } 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 3d142f8df..096a5bf14 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 @@ -69,6 +69,20 @@ public class CassandraRing implements Serializable private static final Logger LOGGER = LoggerFactory.getLogger(CassandraRing.class); public static final Serializer SERIALIZER = new Serializer(); + /** + * Pinned so that the JDK serialization format change made when transient (witness) replica counts were added is + * detected. Without it the UID is computed from the class signature, which did not change - only the + * {@link #readObject}/{@link #writeObject} bodies did - so an older stream would be silently misread rather than + * rejected. + */ + private static final long serialVersionUID = 2026082800000000001L; + + /** + * Incremented whenever the hand-rolled JDK serialization format below changes. Version 1 writes the + * ReplicationFactor as an object rather than destructuring it. + */ + private static final byte SERIALIZATION_FORMAT_VERSION = 1; + private Partitioner partitioner; private String keyspace; private ReplicationFactor replicationFactor; @@ -260,17 +274,18 @@ public int hashCode() private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { LOGGER.debug("Falling back to JDK deserialization"); + byte formatVersion = in.readByte(); + if (formatVersion != SERIALIZATION_FORMAT_VERSION) + { + throw new IOException(String.format("Unsupported CassandraRing serialization format version %d, expected %d", + formatVersion, SERIALIZATION_FORMAT_VERSION)); + } this.partitioner = in.readByte() == 0 ? Partitioner.RandomPartitioner : Partitioner.Murmur3Partitioner; this.keyspace = in.readUTF(); - ReplicationFactor.ReplicationStrategy strategy = ReplicationFactor.ReplicationStrategy.valueOf(in.readByte()); - int optionCount = in.readByte(); - Map options = new HashMap<>(optionCount); - for (int option = 0; option < optionCount; option++) - { - options.put(in.readUTF(), (int) in.readByte()); - } - this.replicationFactor = new ReplicationFactor(strategy, options); + // ReplicationFactor is Serializable, so it is written whole rather than destructured. That keeps this + // method independent of how ReplicationFactor represents its per-datacenter counts. + this.replicationFactor = (ReplicationFactor) in.readObject(); int numInstances = in.readShort(); this.instances = new ArrayList<>(numInstances); @@ -284,17 +299,11 @@ private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundE private void writeObject(ObjectOutputStream out) throws IOException, ClassNotFoundException { LOGGER.debug("Falling back to JDK serialization"); + out.writeByte(SERIALIZATION_FORMAT_VERSION); out.writeByte(this.partitioner == Partitioner.RandomPartitioner ? 0 : 1); out.writeUTF(this.keyspace); - out.writeByte(this.replicationFactor.getReplicationStrategy().value); - Map options = this.replicationFactor.getOptions(); - out.writeByte(options.size()); - for (Map.Entry option : options.entrySet()) - { - out.writeUTF(option.getKey()); - out.writeByte(option.getValue()); - } + out.writeObject(this.replicationFactor); out.writeShort(this.instances.size()); for (CassandraInstance instance : this.instances) diff --git a/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/utils/CqlUtils.java b/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/utils/CqlUtils.java index 80d69118c..59dc919b8 100644 --- a/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/utils/CqlUtils.java +++ b/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/utils/CqlUtils.java @@ -176,9 +176,18 @@ public static ReplicationFactor extractReplicationFactor(@NotNull String schemaS throw new RuntimeException(String.format("Unable to parse replication factor for keyspace: %s", keyspace), exception); } - String className = map.remove("class"); - ReplicationFactor.ReplicationStrategy strategy = ReplicationFactor.ReplicationStrategy.getEnum(className); - return new ReplicationFactor(strategy, map.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, v -> Integer.parseInt(v.getValue())))); + // Values may use the / form for witness replicas, so delegate parsing to + // ReplicationFactor, which reports an unparseable value rather than dropping the datacenter. Dropping it + // would surface later as a confusing "DC not found in replication factor" error. + try + { + return new ReplicationFactor(map); + } + catch (IllegalArgumentException exception) + { + throw new RuntimeException(String.format("Unable to parse replication factor for keyspace: %s", keyspace), + exception); + } } public static String extractTableSchema(@NotNull String schemaStr, @NotNull String keyspace, @NotNull String table) diff --git a/cassandra-analytics-common/src/test/java/org/apache/cassandra/spark/data/ReplicationFactorTests.java b/cassandra-analytics-common/src/test/java/org/apache/cassandra/spark/data/ReplicationFactorTests.java index ef2eb8eeb..aa0f5f384 100644 --- a/cassandra-analytics-common/src/test/java/org/apache/cassandra/spark/data/ReplicationFactorTests.java +++ b/cassandra-analytics-common/src/test/java/org/apache/cassandra/spark/data/ReplicationFactorTests.java @@ -19,8 +19,16 @@ package org.apache.cassandra.spark.data; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.lang.reflect.Field; import java.util.ArrayList; +import com.esotericsoftware.kryo.Kryo; +import com.esotericsoftware.kryo.io.Input; +import com.esotericsoftware.kryo.io.Output; import com.google.common.collect.ImmutableMap; import org.junit.jupiter.api.Test; @@ -111,4 +119,356 @@ public void testEquality() assertThat(replicationFactor1).isEqualTo(replicationFactor2); assertThat(replicationFactor1.hashCode()).isEqualTo(replicationFactor2.hashCode()); } + + // Transient / witness replicas: the / form, reused by witness replicas under + // mutation tracking (CEP-45/CEP-46) + + @Test + public void testNoTransientReplicasByDefault() + { + ReplicationFactor replicationFactor = new ReplicationFactor(ImmutableMap.of( + "class", "NetworkTopologyStrategy", + "datacenter1", "3")); + assertThat(replicationFactor.hasTransientReplicas()).isFalse(); + assertThat(replicationFactor.getTransientOptions()).isEmpty(); + assertThat(replicationFactor.getTotalReplicationFactor()).isEqualTo(3); + assertThat(replicationFactor.getFullReplicationFactor()).isEqualTo(3); + assertThat(replicationFactor.getTransientReplicationFactor()).isEqualTo(0); + assertThat(replicationFactor.getFullReplicas("datacenter1")).isEqualTo(3); + assertThat(replicationFactor.getTransientReplicas("datacenter1")).isEqualTo(0); + } + + @Test + public void testTransientReplicasSingleDatacenter() + { + ReplicationFactor replicationFactor = new ReplicationFactor(ImmutableMap.of( + "class", "NetworkTopologyStrategy", + "datacenter1", "3/1")); + assertThat(replicationFactor.hasTransientReplicas()).isTrue(); + // total keeps its original meaning: all replicas, witnesses included + assertThat(replicationFactor.getOptions().get("datacenter1")).isEqualTo(Integer.valueOf(3)); + assertThat(replicationFactor.getTotalReplicationFactor()).isEqualTo(3); + assertThat(replicationFactor.getFullReplicationFactor()).isEqualTo(2); + assertThat(replicationFactor.getTransientReplicationFactor()).isEqualTo(1); + assertThat(replicationFactor.getFullReplicas("datacenter1")).isEqualTo(2); + assertThat(replicationFactor.getTransientReplicas("datacenter1")).isEqualTo(1); + } + + @Test + public void testTransientReplicasMultipleDatacenters() + { + ReplicationFactor replicationFactor = new ReplicationFactor(ImmutableMap.of( + "class", "NetworkTopologyStrategy", + "datacenter1", "3/1", + "datacenter2", "3/1")); + assertThat(replicationFactor.getTotalReplicationFactor()).isEqualTo(6); + assertThat(replicationFactor.getFullReplicationFactor()).isEqualTo(4); + assertThat(replicationFactor.getTransientReplicationFactor()).isEqualTo(2); + } + + @Test + public void testMixedTransientAndFullDatacenters() + { + ReplicationFactor replicationFactor = new ReplicationFactor(ImmutableMap.of( + "class", "NetworkTopologyStrategy", + "datacenter1", "3/1", + "datacenter2", "3")); + assertThat(replicationFactor.getTotalReplicationFactor()).isEqualTo(6); + assertThat(replicationFactor.getFullReplicationFactor()).isEqualTo(5); + assertThat(replicationFactor.getTransientReplicas("datacenter1")).isEqualTo(1); + assertThat(replicationFactor.getTransientReplicas("datacenter2")).isEqualTo(0); + assertThat(replicationFactor.getFullReplicas("datacenter2")).isEqualTo(3); + // datacenter2 has no transient replicas, so it must be absent rather than mapped to zero + assertThat(replicationFactor.getTransientOptions()).containsOnlyKeys("datacenter1"); + } + + @Test + public void testTransientReplicasSimpleStrategy() + { + ReplicationFactor replicationFactor = new ReplicationFactor(ImmutableMap.of( + "class", "SimpleStrategy", + "replication_factor", "3/1")); + assertThat(replicationFactor.getReplicationStrategy()) + .isEqualTo(ReplicationFactor.ReplicationStrategy.SimpleStrategy); + assertThat(replicationFactor.getTotalReplicationFactor()).isEqualTo(3); + assertThat(replicationFactor.getFullReplicationFactor()).isEqualTo(2); + } + + @Test + public void testTransientReplicasWithWhitespace() + { + ReplicationFactor replicationFactor = new ReplicationFactor(ImmutableMap.of( + "class", "NetworkTopologyStrategy", + "datacenter1", " 3 / 1 ")); + assertThat(replicationFactor.getTotalReplicationFactor()).isEqualTo(3); + assertThat(replicationFactor.getFullReplicationFactor()).isEqualTo(2); + } + + @Test + public void testTransientEqualToTotalIsRejected() + { + // Cassandra requires at least one full replica, so 3/3 is invalid and must not be accepted silently + assertThatThrownBy(() -> new ReplicationFactor(ImmutableMap.of( + "class", "NetworkTopologyStrategy", + "datacenter1", "3/3"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("datacenter1"); + } + + @Test + public void testMalformedReplicationValuesAreRejected() + { + for (String malformed : new String[]{"3/", "/1", "3/1/1", "3/x", "x/1", "3/-1", ""}) + { + assertThatThrownBy(() -> new ReplicationFactor(ImmutableMap.of( + "class", "NetworkTopologyStrategy", + "datacenter1", malformed))) + .as("malformed replication value '%s' should be rejected", malformed) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Test + public void testGetFullReplicasUnknownDatacenter() + { + ReplicationFactor replicationFactor = new ReplicationFactor(ImmutableMap.of( + "class", "NetworkTopologyStrategy", + "datacenter1", "3/1")); + assertThatThrownBy(() -> replicationFactor.getFullReplicas("nosuchdc")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void testTransientOptionsForUnknownDatacenterRejected() + { + assertThatThrownBy(() -> new ReplicationFactor( + ReplicationFactor.ReplicationStrategy.NetworkTopologyStrategy, + ImmutableMap.of("datacenter1", 3), + ImmutableMap.of("datacenter2", 1))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void testEqualityConsidersTransientReplicas() + { + ReplicationFactor full = new ReplicationFactor(ImmutableMap.of( + "class", "NetworkTopologyStrategy", + "datacenter1", "3")); + ReplicationFactor withTransient = new ReplicationFactor(ImmutableMap.of( + "class", "NetworkTopologyStrategy", + "datacenter1", "3/1")); + assertThat(full).isNotEqualTo(withTransient); + assertThat(full.hashCode()).isNotEqualTo(withTransient.hashCode()); + } + + @Test + public void testNegativeReplicationFactorIsRejected() + { + assertThatThrownBy(() -> new ReplicationFactor(ImmutableMap.of( + "class", "NetworkTopologyStrategy", + "datacenter1", "-3"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("datacenter1"); + } + + @Test + public void testZeroReplicationFactorIsAllowed() + { + // RF 0 is legitimate for NetworkTopologyStrategy: the keyspace is simply not replicated to that datacenter + ReplicationFactor replicationFactor = new ReplicationFactor(ImmutableMap.of( + "class", "NetworkTopologyStrategy", + "datacenter1", "3", + "datacenter2", "0")); + assertThat(replicationFactor.getOptions().get("datacenter2")).isEqualTo(Integer.valueOf(0)); + assertThat(replicationFactor.getTotalReplicationFactor()).isEqualTo(3); + } + + @Test + public void testKryoSerializationRoundTripWithTransientReplicas() throws Exception + { + ReplicationFactor original = new ReplicationFactor(ImmutableMap.of( + "class", "NetworkTopologyStrategy", + "datacenter1", "3/1", + "datacenter2", "3")); + + Kryo kryo = new Kryo(); + kryo.register(ReplicationFactor.class, new ReplicationFactor.Serializer()); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (Output out = new Output(bytes)) + { + kryo.writeObject(out, original); + } + ReplicationFactor deserialized; + try (Input in = new Input(new ByteArrayInputStream(bytes.toByteArray()))) + { + deserialized = kryo.readObject(in, ReplicationFactor.class); + } + + assertThat(deserialized).isEqualTo(original); + assertThat(deserialized.getTotalReplicationFactor()).isEqualTo(6); + assertThat(deserialized.getFullReplicationFactor()).isEqualTo(5); + assertThat(deserialized.getTransientReplicas("datacenter1")).isEqualTo(1); + assertThat(deserialized.getTransientReplicas("datacenter2")).isEqualTo(0); + } + + @Test + public void testJdkSerializationRoundTripWithTransientReplicas() throws Exception { + ReplicationFactor original = new ReplicationFactor(ImmutableMap.of( + "class", "NetworkTopologyStrategy", + "datacenter1", "3/1", + "datacenter2", "3")); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream out = new ObjectOutputStream(bytes)) + { + out.writeObject(original); + } + ReplicationFactor deserialized; + try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) + { + deserialized = (ReplicationFactor) in.readObject(); + } + + assertThat(deserialized).isEqualTo(original); + assertThat(deserialized.getFullReplicationFactor()).isEqualTo(5); + assertThat(deserialized.getTransientReplicas("datacenter1")).isEqualTo(1); + } + + @Test + public void testUnparseableValueIsRejected() + { + assertThatThrownBy(() -> new ReplicationFactor(ImmutableMap.of( + "class", "NetworkTopologyStrategy", + "datacenter1", "xyz"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("datacenter1"); + } + + @Test + public void testNoDatacenterEntriesIsRejected() + { + assertThatThrownBy(() -> new ReplicationFactor(ImmutableMap.of( + "class", "NetworkTopologyStrategy"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Could not find replication info"); + } + + @Test + public void testLocalStrategyWithNoEntriesIsAllowed() + { + // LocalStrategy legitimately has no datacenter entries, e.g. the system_schema keyspace + ReplicationFactor replicationFactor = new ReplicationFactor(ImmutableMap.of( + "class", "org.apache.cassandra.locator.LocalStrategy")); + assertThat(replicationFactor.getReplicationStrategy()) + .isEqualTo(ReplicationFactor.ReplicationStrategy.LocalStrategy); + assertThat(replicationFactor.getOptions()).isEmpty(); + assertThat(replicationFactor.getTotalReplicationFactor()).isEqualTo(0); + } + + // The per-datacenter counts are held together rather than in two parallel maps, so the total and the + // transient count cannot drift apart + + @Test + public void testReplicationExposesCombinedCounts() + { + ReplicationFactor replicationFactor = new ReplicationFactor(ImmutableMap.of( + "class", "NetworkTopologyStrategy", + "datacenter1", "3/1", + "datacenter2", "3")); + + assertThat(replicationFactor.getReplication()).containsOnlyKeys("datacenter1", "datacenter2"); + ReplicationFactor.ReplicaCounts dc1 = replicationFactor.getReplication().get("datacenter1"); + assertThat(dc1.allReplicas()).isEqualTo(3); + assertThat(dc1.fullReplicas()).isEqualTo(2); + assertThat(dc1.transientReplicas()).isEqualTo(1); + + ReplicationFactor.ReplicaCounts dc2 = replicationFactor.getReplication().get("datacenter2"); + assertThat(dc2.allReplicas()).isEqualTo(3); + assertThat(dc2.fullReplicas()).isEqualTo(3); + assertThat(dc2.transientReplicas()).isEqualTo(0); + } + + @Test + public void testDerivedOptionMapsAreUnmodifiable() + { + ReplicationFactor replicationFactor = new ReplicationFactor(ImmutableMap.of( + "class", "NetworkTopologyStrategy", + "datacenter1", "3/1")); + assertThatThrownBy(() -> replicationFactor.getOptions().put("datacenter2", 3)) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> replicationFactor.getTransientOptions().put("datacenter2", 1)) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> replicationFactor.getReplication().remove("datacenter1")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + public void testGetTransientReplicasUnknownDatacenter() + { + // Consistent with getFullReplicas: an unknown datacenter is a programming error, not zero transients + ReplicationFactor replicationFactor = new ReplicationFactor(ImmutableMap.of( + "class", "NetworkTopologyStrategy", + "datacenter1", "3/1")); + assertThatThrownBy(() -> replicationFactor.getTransientReplicas("nosuchdc")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("nosuchdc"); + } + + @Test + public void testPrecomputedValuesSurviveJdkSerialization() throws Exception + { + // The aggregate counts and derived maps are precomputed and transient, so they are absent from the + // serialized form and must be rebuilt on the way back in + ReplicationFactor original = new ReplicationFactor(ImmutableMap.of( + "class", "NetworkTopologyStrategy", + "datacenter1", "3/1", + "datacenter2", "3")); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream out = new ObjectOutputStream(bytes)) + { + out.writeObject(original); + } + ReplicationFactor deserialized; + try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) + { + deserialized = (ReplicationFactor) in.readObject(); + } + + assertThat(deserialized.getTotalReplicationFactor()).isEqualTo(6); + assertThat(deserialized.getFullReplicationFactor()).isEqualTo(5); + assertThat(deserialized.getTransientReplicationFactor()).isEqualTo(1); + assertThat(deserialized.hasTransientReplicas()).isTrue(); + assertThat(deserialized.getOptions()).containsOnlyKeys("datacenter1", "datacenter2"); + assertThat(deserialized.getTransientOptions()).containsOnlyKeys("datacenter1"); + assertThat(deserialized).isEqualTo(original); + } + + @Test + public void testPrecomputedValuesSurviveKryoSerialization() + { + ReplicationFactor original = new ReplicationFactor(ImmutableMap.of( + "class", "NetworkTopologyStrategy", + "datacenter1", "3/1", + "datacenter2", "3")); + + Kryo kryo = new Kryo(); + kryo.register(ReplicationFactor.class, new ReplicationFactor.Serializer()); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (Output out = new Output(bytes)) + { + kryo.writeObject(out, original); + } + ReplicationFactor deserialized; + try (Input in = new Input(new ByteArrayInputStream(bytes.toByteArray()))) + { + deserialized = kryo.readObject(in, ReplicationFactor.class); + } + + assertThat(deserialized.getTotalReplicationFactor()).isEqualTo(6); + assertThat(deserialized.getFullReplicationFactor()).isEqualTo(5); + assertThat(deserialized.getOptions()).containsOnlyKeys("datacenter1", "datacenter2"); + assertThat(deserialized.getTransientOptions()).containsOnlyKeys("datacenter1"); + } } 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 c66290f0f..89cb14be3 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 @@ -19,6 +19,11 @@ package org.apache.cassandra.spark.data.partitioner; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; import java.math.BigInteger; import java.util.Arrays; import java.util.Collection; @@ -34,6 +39,7 @@ import org.apache.cassandra.spark.data.ReplicationFactor; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; @SuppressWarnings("UnstableApiUsage") public class CassandraRingTests @@ -447,4 +453,102 @@ public void testNetworkStrategyRF22() Partitioner.Murmur3Partitioner.minToken(), Partitioner.Murmur3Partitioner.maxToken())); } + + @Test + public void testJdkSerializationPreservesTransientReplicas() throws Exception + { + // CassandraRing hand-rolls readObject/writeObject and rebuilds ReplicationFactor from strategy plus + // options, so a transient (witness) count could silently vanish on the way to a Spark executor + CassandraRing ring = new CassandraRing( + Partitioner.Murmur3Partitioner, + "test", + new ReplicationFactor(ImmutableMap.of("class", "org.apache.cassandra.locator.NetworkTopologyStrategy", + "DC1", "3/1", + "DC2", "3")), + Arrays.asList(new CassandraInstance("0", "local0-i1", "DC1"), + new CassandraInstance("100", "local0-i2", "DC1"), + new CassandraInstance("200", "local0-i3", "DC1"), + new CassandraInstance("1", "local1-i1", "DC2"), + new CassandraInstance("101", "local1-i2", "DC2"), + new CassandraInstance("201", "local1-i3", "DC2"))); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream out = new ObjectOutputStream(bytes)) + { + out.writeObject(ring); + } + CassandraRing deserialized; + try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) + { + deserialized = (CassandraRing) in.readObject(); + } + + ReplicationFactor rf = deserialized.replicationFactor(); + assertThat(rf.getTransientReplicas("DC1")).isEqualTo(1); + assertThat(rf.getTransientReplicas("DC2")).isEqualTo(0); + assertThat(rf.getTotalReplicationFactor()).isEqualTo(6); + assertThat(rf.getFullReplicationFactor()).isEqualTo(5); + assertThat(rf).isEqualTo(ring.replicationFactor()); + // Deliberately not asserting deserialized.equals(ring): CassandraRing#equals compares the derived + // replicas and tokenRangeMap fields, and does not hold across a JDK round trip even without transient + // replicas. Pre-existing behaviour, unrelated to replica types. + } + + @Test + public void testJdkDeserializationRejectsUnknownFormatVersion() throws Exception + { + // The hand-rolled format gained a transient-replica section, but the class signature did not change, so the + // computed serialVersionUID would still match an older stream. A leading format-version byte makes a stale + // stream fail loudly instead of being misread. + CassandraRing ring = new CassandraRing( + Partitioner.Murmur3Partitioner, + "test", + new ReplicationFactor(ImmutableMap.of("class", "org.apache.cassandra.locator.NetworkTopologyStrategy", + "DC1", "3")), + Arrays.asList(new CassandraInstance("0", "local0-i1", "DC1"), + new CassandraInstance("100", "local0-i2", "DC1"), + new CassandraInstance("200", "local0-i3", "DC1"))); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream out = new ObjectOutputStream(bytes)) + { + out.writeObject(ring); + } + + // Locate the format-version byte deterministically: the format is + // [version][partitioner] then writeUTF(keyspace), so it sits two bytes before the "test" UTF header + byte[] raw = bytes.toByteArray(); + byte[] keyspaceUtf = new byte[]{ 0, 4, 't', 'e', 's', 't' }; + 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); + + // Simulate a stream written before the transient-replica section existed + raw[versionIndex] = 0; + + try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(raw))) + { + assertThatThrownBy(in::readObject) + .isInstanceOf(IOException.class) + .hasMessageContaining("Unsupported CassandraRing serialization format version"); + } + } + + private static int indexOf(byte[] haystack, byte[] needle) + { + outer: + for (int i = 0; i <= haystack.length - needle.length; i++) + { + for (int j = 0; j < needle.length; j++) + { + if (haystack[i + j] != needle[j]) + { + continue outer; + } + } + return i; + } + return -1; + } } diff --git a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/utils/CqlUtilsTest.java b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/utils/CqlUtilsTest.java index 58fb5c960..c2cf777a8 100644 --- a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/utils/CqlUtilsTest.java +++ b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/utils/CqlUtilsTest.java @@ -142,6 +142,79 @@ public void testExtractReplicationFactor(CassandraBridge bridge) assertThat(systemSchemaRf.getOptions()).isEqualTo(ImmutableMap.of()); } + @Test + public void testExtractReplicationFactorWithWitnessReplicas() + { + String schema = "CREATE KEYSPACE witnessks WITH REPLICATION = {'class': 'NetworkTopologyStrategy', " + + "'datacenter1': '3/1', 'datacenter2': '3/1'} AND durable_writes = true;\n"; + + ReplicationFactor rf = CqlUtils.extractReplicationFactor(schema, "witnessks"); + assertThat(rf).isNotNull(); + assertThat(rf.getReplicationStrategy()).isEqualTo(ReplicationFactor.ReplicationStrategy.NetworkTopologyStrategy); + // total keeps its original meaning: all replicas, witnesses included + assertThat(rf.getOptions()).isEqualTo(ImmutableMap.of("datacenter1", 3, "datacenter2", 3)); + assertThat(rf.getTotalReplicationFactor()).isEqualTo(6); + assertThat(rf.getFullReplicationFactor()).isEqualTo(4); + assertThat(rf.getTransientReplicationFactor()).isEqualTo(2); + assertThat(rf.hasTransientReplicas()).isTrue(); + assertThat(rf.getFullReplicas("datacenter1")).isEqualTo(2); + assertThat(rf.getTransientReplicas("datacenter1")).isEqualTo(1); + } + + @Test + public void testExtractReplicationFactorMixedWitnessAndFullDatacenters() + { + String schema = "CREATE KEYSPACE mixedks WITH REPLICATION = {'class': 'NetworkTopologyStrategy', " + + "'datacenter1': '3/1', 'datacenter2': '3'} AND durable_writes = true;\n"; + + ReplicationFactor rf = CqlUtils.extractReplicationFactor(schema, "mixedks"); + assertThat(rf.getTotalReplicationFactor()).isEqualTo(6); + assertThat(rf.getFullReplicationFactor()).isEqualTo(5); + assertThat(rf.getTransientReplicas("datacenter1")).isEqualTo(1); + assertThat(rf.getTransientReplicas("datacenter2")).isEqualTo(0); + } + + @Test + public void testExtractReplicationFactorUntrackedKeyspaceUnaffected() + { + String schema = "CREATE KEYSPACE plainks WITH REPLICATION = {'class': 'NetworkTopologyStrategy', " + + "'datacenter1': '3'} AND durable_writes = true;\n"; + + ReplicationFactor rf = CqlUtils.extractReplicationFactor(schema, "plainks"); + assertThat(rf.getTotalReplicationFactor()).isEqualTo(3); + assertThat(rf.getFullReplicationFactor()).isEqualTo(3); + assertThat(rf.hasTransientReplicas()).isFalse(); + assertThat(rf.getTransientOptions()).isEmpty(); + } + + @Test + public void testExtractReplicationFactorFailsLoudlyOnUnparseableValue() + { + // An unparseable value must be reported here rather than silently dropping the datacenter, which + // would surface later as a confusing "DC not found in replication factor" error + for (String malformed : new String[]{"xyz", "3/", "3/1/1", "3/x", "3/3"}) + { + String schema = "CREATE KEYSPACE badks WITH REPLICATION = {'class': 'NetworkTopologyStrategy', " + + "'datacenter1': '" + malformed + "'} AND durable_writes = true;\n"; + assertThatThrownBy(() -> CqlUtils.extractReplicationFactor(schema, "badks")) + .as("malformed replication value '%s' should raise", malformed) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Unable to parse replication factor for keyspace: badks"); + } + } + + @Test + public void testExtractReplicationFactorFailsLoudlyOnMissingDatacenterEntries() + { + // A NetworkTopologyStrategy keyspace with no datacenter entries is not usable. It must fail here + // rather than yielding an empty replication factor that fails later with "DC not found" + String schema = "CREATE KEYSPACE emptyks WITH REPLICATION = {'class': 'NetworkTopologyStrategy'} " + + "AND durable_writes = true;\n"; + assertThatThrownBy(() -> CqlUtils.extractReplicationFactor(schema, "emptyks")) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Unable to parse replication factor for keyspace: emptyks"); + } + @ParameterizedTest @MethodSource("org.apache.cassandra.spark.data.VersionRunner#bridges") public void testEscapedColumnNames(CassandraBridge bridge) diff --git a/cassandra-five-zero-bridge/src/test/java/org/apache/cassandra/spark/reader/SchemaBuilderTests.java b/cassandra-five-zero-bridge/src/test/java/org/apache/cassandra/spark/reader/SchemaBuilderTests.java index ce51359a6..4175ad457 100644 --- a/cassandra-five-zero-bridge/src/test/java/org/apache/cassandra/spark/reader/SchemaBuilderTests.java +++ b/cassandra-five-zero-bridge/src/test/java/org/apache/cassandra/spark/reader/SchemaBuilderTests.java @@ -115,4 +115,20 @@ public void testSchemaBuilderWithPartiallyInitializedMetadata() new SchemaBuilder(createTableStatement, keyspaceName, replicationFactor); } + + @Test + public void testRfToMapOmitsTransientReplicas() + { + // rfToMap must emit only the total, never the / form. The embedded Cassandra runs with + // transient_replication_enabled=false, so a transient value makes Keyspace.openWithoutSSTables throw + // "Transient replication is not enabled on this node" for exactly the witness-enabled keyspaces the bulk + // reader needs to read. Dropping it is safe because replica placement comes from CassandraRing. + ReplicationFactor withTransient = new ReplicationFactor(ImmutableMap.of( + "class", "org.apache.cassandra.locator.NetworkTopologyStrategy", + "datacenter1", "3/1", + "datacenter2", "3")); + assertThat(rfToMap(withTransient)) + .containsEntry("datacenter1", "3") + .containsEntry("datacenter2", "3"); + } } diff --git a/cassandra-four-zero-bridge/src/test/java/org/apache/cassandra/spark/reader/SchemaBuilderTests.java b/cassandra-four-zero-bridge/src/test/java/org/apache/cassandra/spark/reader/SchemaBuilderTests.java index d73e1976b..92031dd0f 100644 --- a/cassandra-four-zero-bridge/src/test/java/org/apache/cassandra/spark/reader/SchemaBuilderTests.java +++ b/cassandra-four-zero-bridge/src/test/java/org/apache/cassandra/spark/reader/SchemaBuilderTests.java @@ -22,6 +22,7 @@ import java.util.Collections; import java.util.HashMap; +import com.google.common.collect.ImmutableMap; import org.junit.jupiter.api.Test; import org.apache.cassandra.bridge.CassandraBridgeImplementation; @@ -164,4 +165,20 @@ public void testBuildPreservesCdcFlag() .as("build() must preserve enableCdc=false through to the returned CqlTable") .isFalse(); } + + @Test + public void testRfToMapOmitsTransientReplicas() + { + // rfToMap must emit only the total, never the / form. The embedded Cassandra runs with + // transient_replication_enabled=false, so a transient value makes Keyspace.openWithoutSSTables throw + // "Transient replication is not enabled on this node" for exactly the witness-enabled keyspaces the bulk + // reader needs to read. Dropping it is safe because replica placement comes from CassandraRing. + ReplicationFactor withTransient = new ReplicationFactor(ImmutableMap.of( + "class", "org.apache.cassandra.locator.NetworkTopologyStrategy", + "datacenter1", "3/1", + "datacenter2", "3")); + assertThat(rfToMap(withTransient)) + .containsEntry("datacenter1", "3") + .containsEntry("datacenter2", "3"); + } } diff --git a/cassandra-four-zero-types/src/main/java/org/apache/cassandra/spark/reader/AbstractSchemaBuilder.java b/cassandra-four-zero-types/src/main/java/org/apache/cassandra/spark/reader/AbstractSchemaBuilder.java index 52ea0fd82..723c1533a 100644 --- a/cassandra-four-zero-types/src/main/java/org/apache/cassandra/spark/reader/AbstractSchemaBuilder.java +++ b/cassandra-four-zero-types/src/main/java/org/apache/cassandra/spark/reader/AbstractSchemaBuilder.java @@ -589,6 +589,10 @@ static Map rfToMap(ReplicationFactor replicationFactor) result.put("class", "org.apache.cassandra.locator." + replicationFactor.getReplicationStrategy().name()); for (Map.Entry entry : replicationFactor.getOptions().entrySet()) { + // Deliberately emits only the total, never the / form. The embedded Cassandra runs + // with transient_replication_enabled=false, so a transient value makes Keyspace.openWithoutSSTables throw + // "Transient replication is not enabled on this node". Safe to drop because replica placement comes from + // CassandraRing, not from these KeyspaceParams. result.put(entry.getKey(), Integer.toString(entry.getValue())); } return result; diff --git a/cassandra-six-zero-bridge/src/test/java/org/apache/cassandra/spark/reader/SchemaBuilderTests.java b/cassandra-six-zero-bridge/src/test/java/org/apache/cassandra/spark/reader/SchemaBuilderTests.java index f378982c7..ba7896712 100644 --- a/cassandra-six-zero-bridge/src/test/java/org/apache/cassandra/spark/reader/SchemaBuilderTests.java +++ b/cassandra-six-zero-bridge/src/test/java/org/apache/cassandra/spark/reader/SchemaBuilderTests.java @@ -138,4 +138,20 @@ public boolean compatibleWith(ClusterMetadata metadata) new SchemaBuilder(createTableStatement, keyspaceName, replicationFactor); } + + @Test + public void testRfToMapOmitsTransientReplicas() + { + // rfToMap must emit only the total, never the / form. The embedded Cassandra runs with + // transient_replication_enabled=false, so a transient value makes Keyspace.openWithoutSSTables throw + // "Transient replication is not enabled on this node" for exactly the witness-enabled keyspaces the bulk + // reader needs to read. Dropping it is safe because replica placement comes from CassandraRing. + ReplicationFactor withTransient = new ReplicationFactor(ImmutableMap.of( + "class", "org.apache.cassandra.locator.NetworkTopologyStrategy", + "datacenter1", "3/1", + "datacenter2", "3")); + assertThat(rfToMap(withTransient)) + .containsEntry("datacenter1", "3") + .containsEntry("datacenter2", "3"); + } } diff --git a/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/reader/AbstractSchemaBuilder.java b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/reader/AbstractSchemaBuilder.java index aa1c15e8d..a105ebb4b 100644 --- a/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/reader/AbstractSchemaBuilder.java +++ b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/reader/AbstractSchemaBuilder.java @@ -589,6 +589,10 @@ static Map rfToMap(ReplicationFactor replicationFactor) result.put("class", "org.apache.cassandra.locator." + replicationFactor.getReplicationStrategy().name()); for (Map.Entry entry : replicationFactor.getOptions().entrySet()) { + // Deliberately emits only the total, never the / form. The embedded Cassandra runs + // with transient_replication_enabled=false, so a transient value makes Keyspace.openWithoutSSTables throw + // "Transient replication is not enabled on this node". Safe to drop because replica placement comes from + // CassandraRing, not from these KeyspaceParams. result.put(entry.getKey(), Integer.toString(entry.getValue())); } return result;