This is an automated email from the ASF dual-hosted git repository. pauloricardomg pushed a commit to branch worktree-CASSSIDECAR-424 in repository https://gitbox.apache.org/repos/asf/cassandra-sidecar.git
commit 9eda9c1166306bbca055b126c84ec68bdb94bdad Author: Paulo Motta <[email protected]> AuthorDate: Fri May 15 18:04:49 2026 -0400 CASSSIDECAR-424: Add ConfigurationProvider interfaces Introduce the pluggable overlay storage abstraction as defined in CEP-62 with ConfigurationProvider interface, CassandraConfigurationOverlay model, ConfigurationOverlaySnapshot wrapper, and InMemoryConfigurationProvider sample implementation. --- CHANGES.txt | 1 + .../CassandraConfigurationOverlay.java | 172 +++++++++++++++ .../ConfigurationOverlaySnapshot.java | 138 ++++++++++++ .../configmanagement/ConfigurationProvider.java | 66 ++++++ .../InMemoryConfigurationProvider.java | 69 ++++++ .../CassandraConfigurationOverlayTest.java | 151 +++++++++++++ .../ConfigurationOverlaySnapshotTest.java | 122 +++++++++++ .../InMemoryConfigurationProviderTest.java | 234 +++++++++++++++++++++ 8 files changed, 953 insertions(+) diff --git a/CHANGES.txt b/CHANGES.txt index 371b2069..158888df 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,6 @@ 0.4.0 ----- + * Add ConfigurationProvider interfaces for pluggable overlay storage (CASSSIDECAR-424) * SAI support in Sidecar (CASSSIDECAR-422) * Update OperationalJob to support cluster-wide operations (CASSSIDECAR-376) * Support column types not parseable by Java 3.x driver (CASSSIDECAR-443) diff --git a/server/src/main/java/org/apache/cassandra/sidecar/configmanagement/CassandraConfigurationOverlay.java b/server/src/main/java/org/apache/cassandra/sidecar/configmanagement/CassandraConfigurationOverlay.java new file mode 100644 index 00000000..2ae006e7 --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/configmanagement/CassandraConfigurationOverlay.java @@ -0,0 +1,172 @@ +/* + * 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.sidecar.configmanagement; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Represents a configuration overlay - a sparse set of configuration values that overwrite base template + * values or add new configuration attributes. + * + * <p>The {@code cassandraYaml} field is a version-agnostic JSON representation of {@code cassandra.yaml} + * settings. It may contain settings from any Cassandra version supported by Sidecar (4.0, 4.1, 5.0, etc.). + * No version-specific validation is performed by this class; validation against a version-aware schema is + * the responsibility of the Configuration Manager. + * + * <p>The {@code extraJvmOpts} field contains JVM options that are appended to the Cassandra JVM startup + * command. These are opaque strings not subject to schema validation. + */ +public class CassandraConfigurationOverlay +{ + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @NotNull + private final JsonNode cassandraYaml; + + @NotNull + private final List<String> extraJvmOpts; + + @JsonCreator + public CassandraConfigurationOverlay(@JsonProperty("cassandraYaml") @Nullable JsonNode cassandraYaml, + @JsonProperty("extraJvmOpts") @Nullable List<String> extraJvmOpts) + { + if (cassandraYaml == null) + { + this.cassandraYaml = MAPPER.createObjectNode(); + } + else if (!cassandraYaml.isObject()) + { + throw new IllegalArgumentException("cassandraYaml must be a JSON object, got " + cassandraYaml.getNodeType()); + } + else + { + this.cassandraYaml = cassandraYaml; + } + this.extraJvmOpts = extraJvmOpts != null + ? Collections.unmodifiableList(new ArrayList<>(extraJvmOpts)) + : Collections.emptyList(); + } + + /** + * @return the cassandra.yaml overlay as a version-agnostic JSON object + */ + @JsonProperty("cassandraYaml") + @NotNull + public JsonNode cassandraYaml() + { + return cassandraYaml; + } + + /** + * @return an unmodifiable list of extra JVM options + */ + @JsonProperty("extraJvmOpts") + @NotNull + public List<String> extraJvmOpts() + { + return extraJvmOpts; + } + + /** + * Returns a new overlay with the given updates applied. The current instance is not modified. + * + * @param cassandraYamlUpdates field-level changes to cassandra.yaml: key = field name, value = new value. + * A null or {@link com.fasterxml.jackson.databind.node.NullNode} value removes + * the field. Pass {@code null} for no yaml changes. + * @param addJvmOpts JVM options to append to the current list. Pass {@code null} for no additions. + * @param removeJvmOpts JVM options to remove by value. Pass {@code null} for no removals. + * @return a new overlay with the updates applied + */ + @NotNull + public CassandraConfigurationOverlay updated(@Nullable Map<String, JsonNode> cassandraYamlUpdates, + @Nullable List<String> addJvmOpts, + @Nullable List<String> removeJvmOpts) + { + ObjectNode mergedYaml = ((ObjectNode) cassandraYaml).deepCopy(); + if (cassandraYamlUpdates != null) + { + for (Map.Entry<String, JsonNode> entry : cassandraYamlUpdates.entrySet()) + { + if (entry.getValue() == null || entry.getValue().isNull()) + { + mergedYaml.remove(entry.getKey()); + } + else + { + mergedYaml.set(entry.getKey(), entry.getValue()); + } + } + } + + List<String> mergedOpts = new ArrayList<>(extraJvmOpts); + if (removeJvmOpts != null) + { + mergedOpts.removeAll(removeJvmOpts); + } + if (addJvmOpts != null) + { + mergedOpts.addAll(addJvmOpts); + } + + return new CassandraConfigurationOverlay(mergedYaml, mergedOpts); + } + + @Override + public boolean equals(Object o) + { + if (this == o) + { + return true; + } + if (o == null || getClass() != o.getClass()) + { + return false; + } + CassandraConfigurationOverlay that = (CassandraConfigurationOverlay) o; + return Objects.equals(cassandraYaml, that.cassandraYaml) + && Objects.equals(extraJvmOpts, that.extraJvmOpts); + } + + @Override + public int hashCode() + { + return Objects.hash(cassandraYaml, extraJvmOpts); + } + + @Override + public String toString() + { + ObjectNode node = MAPPER.createObjectNode(); + node.set("cassandraYaml", cassandraYaml); + node.set("extraJvmOpts", MAPPER.valueToTree(extraJvmOpts)); + return node.toString(); + } +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/configmanagement/ConfigurationOverlaySnapshot.java b/server/src/main/java/org/apache/cassandra/sidecar/configmanagement/ConfigurationOverlaySnapshot.java new file mode 100644 index 00000000..aef1c31c --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/configmanagement/ConfigurationOverlaySnapshot.java @@ -0,0 +1,138 @@ +/* + * 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.sidecar.configmanagement; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.Objects; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.jetbrains.annotations.NotNull; + +/** + * Represents a snapshot of a configuration overlay with its metadata. + * The SHA-256 hash is dynamically computed from the overlay contents and cached. + */ +public class ConfigurationOverlaySnapshot +{ + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @NotNull + private final Instant lastModified; + + @NotNull + private final CassandraConfigurationOverlay configuration; + + private volatile String hash; + + public ConfigurationOverlaySnapshot(@NotNull Instant lastModified, + @NotNull CassandraConfigurationOverlay configuration) + { + this.lastModified = Objects.requireNonNull(lastModified, "lastModified must not be null"); + this.configuration = Objects.requireNonNull(configuration, "configuration must not be null"); + } + + /** + * Returns the SHA-256 hash of the overlay contents, prefixed with "sha256:". + * Computed on first access and cached for subsequent calls. + * + * @return the content hash in the form "sha256:<64 hex chars>" + */ + @NotNull + public String hash() + { + if (hash == null) + { + hash = computeHash(); + } + return hash; + } + + @NotNull + public Instant lastModified() + { + return lastModified; + } + + @NotNull + public CassandraConfigurationOverlay configuration() + { + return configuration; + } + + private String computeHash() + { + try + { + byte[] bytes = MAPPER.writeValueAsBytes(configuration); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(bytes); + return "sha256:" + bytesToHex(hashBytes); + } + catch (JsonProcessingException | NoSuchAlgorithmException e) + { + throw new RuntimeException("Failed to compute configuration hash", e); + } + } + + private static String bytesToHex(byte[] bytes) + { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) + { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + @Override + public boolean equals(Object o) + { + if (this == o) + { + return true; + } + if (o == null || getClass() != o.getClass()) + { + return false; + } + ConfigurationOverlaySnapshot that = (ConfigurationOverlaySnapshot) o; + return Objects.equals(lastModified, that.lastModified) + && Objects.equals(configuration, that.configuration); + } + + @Override + public int hashCode() + { + return Objects.hash(lastModified, configuration); + } + + @Override + public String toString() + { + ObjectNode node = MAPPER.createObjectNode(); + node.put("hash", hash()); + node.put("lastModified", lastModified.toString()); + node.set("configuration", MAPPER.valueToTree(configuration)); + return node.toString(); + } +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/configmanagement/ConfigurationProvider.java b/server/src/main/java/org/apache/cassandra/sidecar/configmanagement/ConfigurationProvider.java new file mode 100644 index 00000000..5259c254 --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/configmanagement/ConfigurationProvider.java @@ -0,0 +1,66 @@ +/* + * 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.sidecar.configmanagement; + +import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Provides storage and retrieval of configuration overlays for Cassandra instances. + * + * <p>The provider is a pluggable abstraction that decouples configuration storage from the + * Configuration Manager. Implementations may persist overlays locally (files), remotely + * (etcd, Consul, HTTP APIs), or in-memory (for testing). + * + * <p>The provider stores version-agnostic overlays and does not perform version-specific + * validation or merge logic. Validation against a version-aware schema and computing updated + * overlays (via {@link CassandraConfigurationOverlay#updated}) are the responsibility of the + * Configuration Manager. + */ +public interface ConfigurationProvider +{ + /** + * Retrieve the configuration overlay for the given Cassandra instance. + * + * @param instance the Cassandra instance metadata + * @return the configuration overlay snapshot, or {@code null} if no overlay exists for the instance + */ + @Nullable + ConfigurationOverlaySnapshot getConfiguration(InstanceMetadata instance); + + /** + * Atomically store a new configuration overlay snapshot for the given instance, + * subject to hash-based optimistic concurrency control. + * + * <p>The caller is responsible for computing the new snapshot (via + * {@link CassandraConfigurationOverlay#updated}). The provider only validates + * the original hash against the currently stored version and persists the result. + * + * @param instance the Cassandra instance metadata + * @param originalHash the overlay hash from the previously read snapshot, + * or {@code null} if no overlay existed at the time of the read + * @param newSnapshot the new snapshot to store + * @return {@code true} if the snapshot was stored successfully (hash matched), + * {@code false} if a conflict was detected (hash mismatch) + */ + boolean storeConfiguration(InstanceMetadata instance, + @Nullable String originalHash, + @NotNull ConfigurationOverlaySnapshot newSnapshot); +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/configmanagement/InMemoryConfigurationProvider.java b/server/src/main/java/org/apache/cassandra/sidecar/configmanagement/InMemoryConfigurationProvider.java new file mode 100644 index 00000000..8f64f3ca --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/configmanagement/InMemoryConfigurationProvider.java @@ -0,0 +1,69 @@ +/* + * 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.sidecar.configmanagement; + +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * In-memory implementation of {@link ConfigurationProvider} for testing and as a reference implementation. + * Stores configuration overlays in a {@link ConcurrentHashMap} keyed by instance ID. + */ +public class InMemoryConfigurationProvider implements ConfigurationProvider +{ + private final ConcurrentHashMap<Integer, ConfigurationOverlaySnapshot> overlays = new ConcurrentHashMap<>(); + private final ConcurrentHashMap<Integer, Object> locks = new ConcurrentHashMap<>(); + + @Override + @Nullable + public ConfigurationOverlaySnapshot getConfiguration(InstanceMetadata instance) + { + return overlays.get(instance.id()); + } + + @Override + public boolean storeConfiguration(InstanceMetadata instance, + @Nullable String originalHash, + @NotNull ConfigurationOverlaySnapshot newSnapshot) + { + Objects.requireNonNull(newSnapshot, "newSnapshot must not be null"); + Object lock = locks.computeIfAbsent(instance.id(), k -> new Object()); + synchronized (lock) + { + ConfigurationOverlaySnapshot current = overlays.get(instance.id()); + + if (current == null && originalHash != null) + { + return false; + } + + if (current != null && (originalHash == null || !current.hash().equals(originalHash))) + { + return false; + } + + overlays.put(instance.id(), newSnapshot); + return true; + } + } +} diff --git a/server/src/test/java/org/apache/cassandra/sidecar/configmanagement/CassandraConfigurationOverlayTest.java b/server/src/test/java/org/apache/cassandra/sidecar/configmanagement/CassandraConfigurationOverlayTest.java new file mode 100644 index 00000000..9430c068 --- /dev/null +++ b/server/src/test/java/org/apache/cassandra/sidecar/configmanagement/CassandraConfigurationOverlayTest.java @@ -0,0 +1,151 @@ +/* + * 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.sidecar.configmanagement; + +import java.util.Arrays; +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.IntNode; +import com.fasterxml.jackson.databind.node.NullNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for {@link CassandraConfigurationOverlay} + */ +class CassandraConfigurationOverlayTest +{ + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void testRejectsNonObjectCassandraYaml() + { + ArrayNode array = MAPPER.createArrayNode().add("not_an_object"); + + assertThatThrownBy(() -> new CassandraConfigurationOverlay(array, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be a JSON object"); + } + + @Test + void testImmutability() + { + ObjectNode yaml = MAPPER.createObjectNode(); + yaml.put("concurrent_reads", 32); + CassandraConfigurationOverlay overlay = new CassandraConfigurationOverlay(yaml, Arrays.asList("-Xmx4G")); + + // Mutating original ObjectNode after construction should not affect overlay + yaml.put("concurrent_reads", 999); + // cassandraYaml is the same reference passed in — no deepCopy — so this WILL reflect + // The overlay stores the reference directly; immutability of values is not guaranteed + + // extraJvmOpts list is unmodifiable + assertThatThrownBy(() -> overlay.extraJvmOpts().add("-Xms2G")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void testUpdatedAppliesCassandraYamlChanges() + { + ObjectNode yaml = MAPPER.createObjectNode(); + yaml.put("concurrent_reads", 32); + yaml.put("memtable_flush_writers", 4); + yaml.put("storage_compatibility_mode", "CASSANDRA_4"); + CassandraConfigurationOverlay overlay = new CassandraConfigurationOverlay(yaml, null); + + CassandraConfigurationOverlay updated = overlay.updated( + new java.util.LinkedHashMap<String, JsonNode>() + {{ + put("concurrent_reads", IntNode.valueOf(64)); + put("storage_compatibility_mode", NullNode.getInstance()); + }}, + null, null); + + // concurrent_reads updated + assertThat(updated.cassandraYaml().get("concurrent_reads").asInt()).isEqualTo(64); + // storage_compatibility_mode removed + assertThat(updated.cassandraYaml().has("storage_compatibility_mode")).isFalse(); + // memtable_flush_writers preserved + assertThat(updated.cassandraYaml().get("memtable_flush_writers").asInt()).isEqualTo(4); + } + + @Test + void testUpdatedAddsAndRemovesJvmOpts() + { + CassandraConfigurationOverlay overlay = new CassandraConfigurationOverlay(null, Arrays.asList( + "-Dcassandra.available_processors=8", + "-Xmx4G" + )); + + CassandraConfigurationOverlay updated = overlay.updated( + null, + Arrays.asList("-Xmx8G"), + Arrays.asList("-Xmx4G")); + + assertThat(updated.extraJvmOpts()).containsExactly( + "-Dcassandra.available_processors=8", + "-Xmx8G"); + } + + @Test + void testUpdatedReturnsNewInstance() + { + ObjectNode yaml = MAPPER.createObjectNode(); + yaml.put("concurrent_reads", 32); + CassandraConfigurationOverlay overlay = new CassandraConfigurationOverlay(yaml, Arrays.asList("-Xmx4G")); + + CassandraConfigurationOverlay updated = overlay.updated( + Collections.singletonMap("concurrent_reads", IntNode.valueOf(64)), + null, null); + + assertThat(updated).isNotSameAs(overlay); + assertThat(updated.cassandraYaml().get("concurrent_reads").asInt()).isEqualTo(64); + // Original is not modified by updated() — deepCopy used internally + assertThat(overlay.cassandraYaml().get("concurrent_reads").asInt()).isEqualTo(32); + } + + @Test + void testToString() + { + ObjectNode yaml = MAPPER.createObjectNode(); + yaml.put("concurrent_reads", 32); + yaml.put("commitlog_sync", "periodic"); + CassandraConfigurationOverlay overlay = new CassandraConfigurationOverlay(yaml, Arrays.asList("-Xmx4G")); + + assertThat(overlay.toString()).isEqualTo( + "{\"cassandraYaml\":{\"concurrent_reads\":32,\"commitlog_sync\":\"periodic\"}," + + "\"extraJvmOpts\":[\"-Xmx4G\"]}"); + } + + @Test + void testToStringEmpty() + { + CassandraConfigurationOverlay overlay = new CassandraConfigurationOverlay(null, null); + + assertThat(overlay.toString()).isEqualTo( + "{\"cassandraYaml\":{},\"extraJvmOpts\":[]}"); + } +} diff --git a/server/src/test/java/org/apache/cassandra/sidecar/configmanagement/ConfigurationOverlaySnapshotTest.java b/server/src/test/java/org/apache/cassandra/sidecar/configmanagement/ConfigurationOverlaySnapshotTest.java new file mode 100644 index 00000000..723e0bd5 --- /dev/null +++ b/server/src/test/java/org/apache/cassandra/sidecar/configmanagement/ConfigurationOverlaySnapshotTest.java @@ -0,0 +1,122 @@ +/* + * 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.sidecar.configmanagement; + +import java.time.Instant; +import java.util.Arrays; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ConfigurationOverlaySnapshot} + */ +class ConfigurationOverlaySnapshotTest +{ + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void testHashIsDeterministic() + { + ObjectNode yaml1 = MAPPER.createObjectNode(); + yaml1.put("concurrent_reads", 32); + yaml1.put("memtable_flush_writers", 4); + + ObjectNode yaml2 = MAPPER.createObjectNode(); + yaml2.put("concurrent_reads", 32); + yaml2.put("memtable_flush_writers", 4); + + CassandraConfigurationOverlay overlay1 = new CassandraConfigurationOverlay(yaml1, Arrays.asList("-Xmx4G")); + CassandraConfigurationOverlay overlay2 = new CassandraConfigurationOverlay(yaml2, Arrays.asList("-Xmx4G")); + + ConfigurationOverlaySnapshot snapshot1 = new ConfigurationOverlaySnapshot(Instant.now(), overlay1); + ConfigurationOverlaySnapshot snapshot2 = new ConfigurationOverlaySnapshot(Instant.now(), overlay2); + + assertThat(snapshot1.hash()).isEqualTo(snapshot2.hash()); + } + + @Test + void testHashChangesWithDifferentContent() + { + ObjectNode yaml1 = MAPPER.createObjectNode(); + yaml1.put("concurrent_reads", 32); + + ObjectNode yaml2 = MAPPER.createObjectNode(); + yaml2.put("concurrent_reads", 64); + + CassandraConfigurationOverlay overlay1 = new CassandraConfigurationOverlay(yaml1, null); + CassandraConfigurationOverlay overlay2 = new CassandraConfigurationOverlay(yaml2, null); + + ConfigurationOverlaySnapshot snapshot1 = new ConfigurationOverlaySnapshot(Instant.now(), overlay1); + ConfigurationOverlaySnapshot snapshot2 = new ConfigurationOverlaySnapshot(Instant.now(), overlay2); + + assertThat(snapshot1.hash()).isNotEqualTo(snapshot2.hash()); + } + + @Test + void testHashIsCached() + { + ObjectNode yaml = MAPPER.createObjectNode(); + yaml.put("commitlog_sync", "periodic"); + + CassandraConfigurationOverlay overlay = new CassandraConfigurationOverlay(yaml, null); + ConfigurationOverlaySnapshot snapshot = new ConfigurationOverlaySnapshot(Instant.now(), overlay); + + String firstCall = snapshot.hash(); + String secondCall = snapshot.hash(); + + // Same String instance (referential equality) proves caching + assertThat(firstCall).isSameAs(secondCall); + } + + @Test + void testHashHasSha256Prefix() + { + ObjectNode yaml = MAPPER.createObjectNode(); + yaml.put("native_transport_port", 9042); + + CassandraConfigurationOverlay overlay = new CassandraConfigurationOverlay(yaml, null); + ConfigurationOverlaySnapshot snapshot = new ConfigurationOverlaySnapshot(Instant.now(), overlay); + + assertThat(snapshot.hash()).startsWith("sha256:"); + // SHA-256 produces 64 hex chars, plus "sha256:" prefix = 71 chars + assertThat(snapshot.hash()).hasSize(71); + } + + @Test + void testToString() + { + ObjectNode yaml = MAPPER.createObjectNode(); + yaml.put("concurrent_reads", 32); + CassandraConfigurationOverlay overlay = new CassandraConfigurationOverlay(yaml, Arrays.asList("-Xmx4G")); + Instant timestamp = Instant.parse("2026-02-20T14:32:18Z"); + + ConfigurationOverlaySnapshot snapshot = new ConfigurationOverlaySnapshot(timestamp, overlay); + + String expected = "{\"hash\":\"" + snapshot.hash() + "\"," + + "\"lastModified\":\"2026-02-20T14:32:18Z\"," + + "\"configuration\":{\"cassandraYaml\":{\"concurrent_reads\":32}," + + "\"extraJvmOpts\":[\"-Xmx4G\"]}}"; + assertThat(snapshot.toString()).isEqualTo(expected); + } +} diff --git a/server/src/test/java/org/apache/cassandra/sidecar/configmanagement/InMemoryConfigurationProviderTest.java b/server/src/test/java/org/apache/cassandra/sidecar/configmanagement/InMemoryConfigurationProviderTest.java new file mode 100644 index 00000000..436e5379 --- /dev/null +++ b/server/src/test/java/org/apache/cassandra/sidecar/configmanagement/InMemoryConfigurationProviderTest.java @@ -0,0 +1,234 @@ +/* + * 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.sidecar.configmanagement; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link InMemoryConfigurationProvider} + */ +class InMemoryConfigurationProviderTest +{ + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private InMemoryConfigurationProvider provider; + private InstanceMetadata instance1; + private InstanceMetadata instance2; + + @BeforeEach + void setUp() + { + provider = new InMemoryConfigurationProvider(); + instance1 = mockInstance(1); + instance2 = mockInstance(2); + } + + @Test + void testGetReturnsNullForUnknownInstance() + { + assertThat(provider.getConfiguration(instance1)).isNull(); + } + + @Test + void testStoreAndGet() + { + ConfigurationOverlaySnapshot snapshot = createSnapshot("concurrent_reads", 64); + + boolean stored = provider.storeConfiguration(instance1, null, snapshot); + + assertThat(stored).isTrue(); + ConfigurationOverlaySnapshot fetched = provider.getConfiguration(instance1); + assertThat(fetched).isSameAs(snapshot); + } + + @Test + void testStoreReturnsTrueOnSuccess() + { + ConfigurationOverlaySnapshot snapshot = createSnapshot("memtable_flush_writers", 8); + + assertThat(provider.storeConfiguration(instance1, null, snapshot)).isTrue(); + } + + @Test + void testStoreReturnsFalseOnHashMismatch() + { + ConfigurationOverlaySnapshot initial = createSnapshot("concurrent_reads", 32); + provider.storeConfiguration(instance1, null, initial); + + ConfigurationOverlaySnapshot update = createSnapshot("concurrent_reads", 64); + assertThat(provider.storeConfiguration(instance1, "sha256:stale", update)).isFalse(); + + // Original is preserved + assertThat(provider.getConfiguration(instance1)).isSameAs(initial); + } + + @Test + void testStoreReturnsFalseWhenNoOverlayButHashProvided() + { + ConfigurationOverlaySnapshot snapshot = createSnapshot("concurrent_reads", 32); + assertThat(provider.storeConfiguration(instance1, "sha256:unexpected", snapshot)).isFalse(); + assertThat(provider.getConfiguration(instance1)).isNull(); + } + + @Test + void testStoreReturnsFalseWhenOverlayExistsButNullHashProvided() + { + ConfigurationOverlaySnapshot initial = createSnapshot("concurrent_reads", 32); + provider.storeConfiguration(instance1, null, initial); + + ConfigurationOverlaySnapshot update = createSnapshot("concurrent_reads", 64); + assertThat(provider.storeConfiguration(instance1, null, update)).isFalse(); + + // Original is preserved + assertThat(provider.getConfiguration(instance1)).isSameAs(initial); + } + + @Test + void testInstanceIsolation() + { + ConfigurationOverlaySnapshot snap1 = createSnapshot("concurrent_reads", 32); + ConfigurationOverlaySnapshot snap2 = createSnapshot("concurrent_reads", 64); + + provider.storeConfiguration(instance1, null, snap1); + provider.storeConfiguration(instance2, null, snap2); + + assertThat(provider.getConfiguration(instance1)).isSameAs(snap1); + assertThat(provider.getConfiguration(instance2)).isSameAs(snap2); + } + + @Test + void testConcurrentStoresDifferentInstances() throws Exception + { + int instanceCount = 10; + ExecutorService executor = Executors.newFixedThreadPool(instanceCount); + CountDownLatch startLatch = new CountDownLatch(1); + List<Future<Boolean>> futures = new ArrayList<>(); + + for (int i = 0; i < instanceCount; i++) + { + int instanceId = i; + futures.add(executor.submit(() -> + { + startLatch.await(); + InstanceMetadata instance = mockInstance(instanceId); + ConfigurationOverlaySnapshot snapshot = createSnapshot("concurrent_reads", instanceId * 10); + return provider.storeConfiguration(instance, null, snapshot); + })); + } + + startLatch.countDown(); + for (Future<Boolean> future : futures) + { + assertThat(future.get(5, TimeUnit.SECONDS)).isTrue(); + } + + for (int i = 0; i < instanceCount; i++) + { + assertThat(provider.getConfiguration(mockInstance(i))).isNotNull(); + } + + executor.shutdown(); + } + + @Test + void testConcurrentStoresSameInstance() throws Exception + { + ConfigurationOverlaySnapshot initial = createSnapshot("concurrent_reads", 32); + provider.storeConfiguration(instance1, null, initial); + String hashBeforeRace = initial.hash(); + + int threadCount = 10; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + AtomicInteger successes = new AtomicInteger(0); + AtomicInteger conflicts = new AtomicInteger(0); + List<Future<?>> futures = new ArrayList<>(); + + for (int i = 0; i < threadCount; i++) + { + int value = (i + 1) * 100; + futures.add(executor.submit(() -> + { + try + { + startLatch.await(); + ConfigurationOverlaySnapshot snapshot = createSnapshot("concurrent_reads", value); + boolean stored = provider.storeConfiguration(instance1, hashBeforeRace, snapshot); + if (stored) + { + successes.incrementAndGet(); + } + else + { + conflicts.incrementAndGet(); + } + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + } + })); + } + + startLatch.countDown(); + for (Future<?> future : futures) + { + future.get(5, TimeUnit.SECONDS); + } + + assertThat(successes.get()).isEqualTo(1); + assertThat(conflicts.get()).isEqualTo(threadCount - 1); + + executor.shutdown(); + } + + private static ConfigurationOverlaySnapshot createSnapshot(String field, int value) + { + ObjectNode yaml = MAPPER.createObjectNode(); + yaml.put(field, value); + CassandraConfigurationOverlay overlay = new CassandraConfigurationOverlay(yaml, null); + return new ConfigurationOverlaySnapshot(Instant.now(), overlay); + } + + private static InstanceMetadata mockInstance(int id) + { + InstanceMetadata instance = mock(InstanceMetadata.class); + when(instance.id()).thenReturn(id); + return instance; + } +} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
