This is an automated email from the ASF dual-hosted git repository.
pauloricardomg pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/cassandra-sidecar.git
The following commit(s) were added to refs/heads/trunk by this push:
new f6632fc0 CASSSIDECAR-424: Add ConfigurationProvider interfaces (#351)
f6632fc0 is described below
commit f6632fc071ddd772d80d40989e3381377ec94d4a
Author: Paulo Motta <[email protected]>
AuthorDate: Tue May 26 09:36:15 2026 -0400
CASSSIDECAR-424: Add ConfigurationProvider interfaces (#351)
Introduce the pluggable overlay storage abstraction as defined in CEP-62
with ConfigurationProvider interface, CassandraConfigurationOverlay model,
ConfigurationOverlaySnapshot wrapper, and InMemoryConfigurationProvider
sample implementation.
Patch by Paulo Motta; Reviewed by Francisco Guerrero for CASSSIDECAR-424
---
CHANGES.txt | 1 +
.../CassandraConfigurationOverlay.java | 200 ++++++++++++++++++
.../ConfigurationOverlaySnapshot.java | 134 ++++++++++++
.../configmanagement/ConfigurationProvider.java | 66 ++++++
.../InMemoryConfigurationProvider.java | 62 ++++++
.../CassandraConfigurationOverlayTest.java | 182 ++++++++++++++++
.../ConfigurationOverlaySnapshotTest.java | 122 +++++++++++
.../InMemoryConfigurationProviderTest.java | 230 +++++++++++++++++++++
8 files changed, 997 insertions(+)
diff --git a/CHANGES.txt b/CHANGES.txt
index c6fb4c35..7e00877c 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,6 @@
0.4.0
-----
+ * Add ConfigurationProvider interfaces for pluggable overlay storage
(CASSSIDECAR-424)
* Refactor OperationalJob to have data separate from execution logic
(CASSSIDECAR-460)
* Sidecar’s CassandraBridgeFactory FQCN colliding with the Cassandra
analytics class (CASSSIDECAR-467)
* Fix ON_CDC_CACHE_WARMED_UP not fired when schema publisher fails
(CASSSIDECAR-459)
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..577bc8b0
--- /dev/null
+++
b/server/src/main/java/org/apache/cassandra/sidecar/configmanagement/CassandraConfigurationOverlay.java
@@ -0,0 +1,200 @@
+/*
+ * 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.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+import io.vertx.core.json.JsonObject;
+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. Each entry maps the full option flag (e.g. {@code
-Dcassandra.jmx.local.port}) to its value
+ * (e.g. {@code 7199}). These are opaque strings not subject to schema
validation.
+ */
+public class CassandraConfigurationOverlay
+{
+ @NotNull
+ private final JsonObject cassandraYaml;
+
+ @NotNull
+ private final Map<String, String> extraJvmOpts;
+
+ public CassandraConfigurationOverlay(@Nullable JsonObject cassandraYaml,
+ @Nullable Map<String, String>
extraJvmOpts)
+ {
+ this.cassandraYaml = cassandraYaml != null ? cassandraYaml.copy() :
new JsonObject();
+ this.extraJvmOpts = extraJvmOpts != null
+ ? Collections.unmodifiableMap(new
LinkedHashMap<>(extraJvmOpts))
+ : Collections.emptyMap();
+ }
+
+ /**
+ * Returns the cassandra.yaml overlay as a version-agnostic JSON object.
Callers must not mutate the
+ * returned object; use {@link #updated} to produce a new overlay with
changes applied.
+ *
+ * @return the cassandra.yaml overlay as a version-agnostic JSON object
+ */
+ @NotNull
+ public JsonObject cassandraYaml()
+ {
+ return cassandraYaml;
+ }
+
+ /**
+ * @return an unmodifiable map of extra JVM options (option name to value)
+ */
+ @NotNull
+ public Map<String, String> extraJvmOpts()
+ {
+ return extraJvmOpts;
+ }
+
+ /**
+ * Returns a JSON representation of this overlay.
+ *
+ * @return a new {@link JsonObject} containing {@code cassandraYaml} and
{@code extraJvmOpts}
+ */
+ @NotNull
+ public JsonObject toJson()
+ {
+ return new JsonObject()
+ .put("cassandraYaml", cassandraYaml.copy())
+ .put("extraJvmOpts", new JsonObject(new
LinkedHashMap<>(extraJvmOpts)));
+ }
+
+ /**
+ * Returns a new overlay with the given updates applied. The current
instance is not modified.
+ *
+ * <p>Both parameters follow the same semantics: a {@code null} value for
a key removes that entry,
+ * a non-null value upserts it.
+ *
+ * @param cassandraYamlUpdates field-level changes to cassandra.yaml: key
= field name, value = new value.
+ * A {@code null} value removes the field.
Pass {@code null} for no yaml changes.
+ * @param extraJvmOptsUpdates JVM option changes: key = option name,
value = new option value.
+ * A {@code null} value removes the option.
Pass {@code null} for no changes.
+ * @return a new overlay with the updates applied
+ */
+ @NotNull
+ public CassandraConfigurationOverlay updated(@Nullable Map<String, Object>
cassandraYamlUpdates,
+ @Nullable Map<String, String>
extraJvmOptsUpdates)
+ {
+ JsonObject mergedYaml = cassandraYaml.copy();
+ if (cassandraYamlUpdates != null)
+ {
+ for (Map.Entry<String, Object> entry :
cassandraYamlUpdates.entrySet())
+ {
+ if (entry.getValue() == null)
+ {
+ mergedYaml.remove(entry.getKey());
+ }
+ else
+ {
+ mergedYaml.put(entry.getKey(), entry.getValue());
+ }
+ }
+ }
+
+ LinkedHashMap<String, String> mergedOpts = new
LinkedHashMap<>(extraJvmOpts);
+ if (extraJvmOptsUpdates != null)
+ {
+ for (Map.Entry<String, String> entry :
extraJvmOptsUpdates.entrySet())
+ {
+ if (entry.getValue() == null)
+ {
+ mergedOpts.remove(entry.getKey());
+ }
+ else
+ {
+ mergedOpts.put(entry.getKey(), entry.getValue());
+ }
+ }
+ }
+
+ validateNoConflictingBooleanOpts(mergedOpts);
+
+ return new CassandraConfigurationOverlay(mergedYaml, mergedOpts);
+ }
+
+ private static void validateNoConflictingBooleanOpts(Map<String, String>
opts)
+ {
+ Set<String> enabled = new HashSet<>();
+ Set<String> disabled = new HashSet<>();
+ for (String key : opts.keySet())
+ {
+ if (key.startsWith("-XX:+"))
+ {
+ enabled.add(key.substring(5));
+ }
+ else if (key.startsWith("-XX:-"))
+ {
+ disabled.add(key.substring(5));
+ }
+ }
+ enabled.retainAll(disabled);
+ if (!enabled.isEmpty())
+ {
+ String option = enabled.iterator().next();
+ throw new IllegalArgumentException(
+ "Conflicting boolean JVM options: -XX:+" + option + " and
-XX:-" + option);
+ }
+ }
+
+ @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()
+ {
+ return toJson().encodePrettily();
+ }
+}
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..984cac07
--- /dev/null
+++
b/server/src/main/java/org/apache/cassandra/sidecar/configmanagement/ConfigurationOverlaySnapshot.java
@@ -0,0 +1,134 @@
+/*
+ * 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 io.vertx.core.json.JsonObject;
+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
+{
+ @NotNull
+ private final Instant lastModified;
+
+ @NotNull
+ private final CassandraConfigurationOverlay overlay;
+
+ private volatile String hash;
+
+ public ConfigurationOverlaySnapshot(@NotNull Instant lastModified,
+ @NotNull CassandraConfigurationOverlay
overlay)
+ {
+ this.lastModified = Objects.requireNonNull(lastModified, "lastModified
must not be null");
+ this.overlay = Objects.requireNonNull(overlay, "overlay 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 overlay()
+ {
+ return overlay;
+ }
+
+ private String computeHash()
+ {
+ try
+ {
+ byte[] bytes = overlay.toJson().toBuffer().getBytes();
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hashBytes = digest.digest(bytes);
+ return "sha256:" + bytesToHex(hashBytes);
+ }
+ catch (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(overlay, that.overlay);
+ }
+
+ @Override
+ public int hashCode()
+ {
+ return Objects.hash(lastModified, overlay);
+ }
+
+ @Override
+ public String toString()
+ {
+ return new JsonObject()
+ .put("hash", hash())
+ .put("lastModified", lastModified.toString())
+ .put("overlay", overlay.toJson())
+ .encodePrettily();
+ }
+}
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..86fcb3b8
--- /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 getOverlay(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 storeOverlay(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..1f823180
--- /dev/null
+++
b/server/src/main/java/org/apache/cassandra/sidecar/configmanagement/InMemoryConfigurationProvider.java
@@ -0,0 +1,62 @@
+/*
+ * 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<>();
+
+ @Override
+ @Nullable
+ public ConfigurationOverlaySnapshot getOverlay(InstanceMetadata instance)
+ {
+ return overlays.get(instance.id());
+ }
+
+ @Override
+ public boolean storeOverlay(InstanceMetadata instance,
+ @Nullable String originalHash,
+ @NotNull ConfigurationOverlaySnapshot
newSnapshot)
+ {
+ Objects.requireNonNull(newSnapshot, "newSnapshot must not be null");
+ return overlays.compute(instance.id(), (k, current) -> {
+ if (current == null && originalHash != null)
+ {
+ return null;
+ }
+
+ if (current != null && (originalHash == null ||
!current.hash().equals(originalHash)))
+ {
+ return current;
+ }
+ return newSnapshot;
+ }) == newSnapshot;
+ }
+}
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..26f9f886
--- /dev/null
+++
b/server/src/test/java/org/apache/cassandra/sidecar/configmanagement/CassandraConfigurationOverlayTest.java
@@ -0,0 +1,182 @@
+/*
+ * 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.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import io.vertx.core.json.JsonObject;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link CassandraConfigurationOverlay}
+ */
+class CassandraConfigurationOverlayTest
+{
+ @Test
+ void testUpdatedAppliesCassandraYamlChanges()
+ {
+ JsonObject yaml = new JsonObject()
+ .put("concurrent_reads", 32)
+ .put("memtable_flush_writers", 4)
+ .put("storage_compatibility_mode", "CASSANDRA_4");
+ CassandraConfigurationOverlay overlay = new
CassandraConfigurationOverlay(yaml, null);
+
+ Map<String, Object> updates = new LinkedHashMap<>();
+ updates.put("concurrent_reads", 64);
+ updates.put("storage_compatibility_mode", null);
+
+ CassandraConfigurationOverlay updated = overlay.updated(updates, null);
+
+ // concurrent_reads updated
+
assertThat(updated.cassandraYaml().getInteger("concurrent_reads")).isEqualTo(64);
+ // storage_compatibility_mode removed
+
assertThat(updated.cassandraYaml().containsKey("storage_compatibility_mode")).isFalse();
+ // memtable_flush_writers preserved
+
assertThat(updated.cassandraYaml().getInteger("memtable_flush_writers")).isEqualTo(4);
+ }
+
+ @Test
+ void testUpdatedUpsertsAndRemovesJvmOpts()
+ {
+ Map<String, String> jvmOpts = new LinkedHashMap<>();
+ jvmOpts.put("-Dcassandra.jmx.local.port", "7199");
+ jvmOpts.put("-Xmx", "4G");
+ CassandraConfigurationOverlay overlay = new
CassandraConfigurationOverlay(null, jvmOpts);
+
+ Map<String, String> updates = new LinkedHashMap<>();
+ updates.put("-Xmx", "8G");
+
+ CassandraConfigurationOverlay updated = overlay.updated(null, updates);
+
+ Map<String, String> expected = new LinkedHashMap<>();
+ expected.put("-Dcassandra.jmx.local.port", "7199");
+ expected.put("-Xmx", "8G");
+ assertThat(updated.extraJvmOpts()).containsExactlyEntriesOf(expected);
+ }
+
+ @Test
+ void testUpdatedRemovesJvmOptWithNullValue()
+ {
+ Map<String, String> jvmOpts = new LinkedHashMap<>();
+ jvmOpts.put("-Dcassandra.jmx.local.port", "7199");
+ jvmOpts.put("-Xmx", "4G");
+ CassandraConfigurationOverlay overlay = new
CassandraConfigurationOverlay(null, jvmOpts);
+
+ Map<String, String> updates = new LinkedHashMap<>();
+ updates.put("-Xmx", null);
+
+ CassandraConfigurationOverlay updated = overlay.updated(null, updates);
+
+ assertThat(updated.extraJvmOpts()).containsExactlyEntriesOf(Map.of(
+ "-Dcassandra.jmx.local.port", "7199"));
+ }
+
+ @Test
+ void testUpdatedRejectsConflictingBooleanOpts()
+ {
+ CassandraConfigurationOverlay overlay = new
CassandraConfigurationOverlay(null, Map.of("-XX:+UseG1GC", ""));
+
+ Map<String, String> updates = new LinkedHashMap<>();
+ updates.put("-XX:-UseG1GC", "");
+
+ assertThatThrownBy(() -> overlay.updated(null, updates))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("-XX:+UseG1GC")
+ .hasMessageContaining("-XX:-UseG1GC");
+ }
+
+ @Test
+ void testUpdatedAllowsReplacingBooleanOpt()
+ {
+ CassandraConfigurationOverlay overlay = new
CassandraConfigurationOverlay(null, Map.of("-XX:+UseG1GC", ""));
+
+ Map<String, String> updates = new LinkedHashMap<>();
+ updates.put("-XX:+UseG1GC", null);
+ updates.put("-XX:-UseG1GC", "");
+
+ CassandraConfigurationOverlay updated = overlay.updated(null, updates);
+
+
assertThat(updated.extraJvmOpts()).containsExactlyEntriesOf(Map.of("-XX:-UseG1GC",
""));
+ }
+
+ @Test
+ void testConstructorDeepCopiesCassandraYaml()
+ {
+ JsonObject yaml = new JsonObject().put("concurrent_reads", 32);
+ CassandraConfigurationOverlay overlay = new
CassandraConfigurationOverlay(yaml, null);
+
+ yaml.put("concurrent_reads", 64);
+
+
assertThat(overlay.cassandraYaml().getInteger("concurrent_reads")).isEqualTo(32);
+ }
+
+ @Test
+ void testUpdatedReturnsNewInstance()
+ {
+ JsonObject yaml = new JsonObject().put("concurrent_reads", 32);
+ CassandraConfigurationOverlay overlay = new
CassandraConfigurationOverlay(yaml, Map.of("-Xmx", "4G"));
+
+ CassandraConfigurationOverlay updated = overlay.updated(
+ Collections.singletonMap("concurrent_reads", 64),
+ null);
+
+ assertThat(updated).isNotSameAs(overlay);
+
assertThat(updated.cassandraYaml().getInteger("concurrent_reads")).isEqualTo(64);
+ // Original is not modified by updated() — deep copy used internally
+
assertThat(overlay.cassandraYaml().getInteger("concurrent_reads")).isEqualTo(32);
+ }
+
+ @Test
+ void testToString()
+ {
+ JsonObject yaml = new JsonObject()
+ .put("concurrent_reads", 32)
+ .put("commitlog_sync", "periodic");
+ CassandraConfigurationOverlay overlay = new
CassandraConfigurationOverlay(yaml, Map.of("-Xmx", "4G"));
+
+ assertThat(overlay.toString()).isEqualTo(String.join("\n",
+ "{",
+ " \"cassandraYaml\" : {",
+ " \"concurrent_reads\" : 32,",
+ " \"commitlog_sync\" : \"periodic\"",
+ " },",
+ " \"extraJvmOpts\" : {",
+ " \"-Xmx\" : \"4G\"",
+ " }",
+ "}"));
+ }
+
+ @Test
+ void testToStringEmpty()
+ {
+ CassandraConfigurationOverlay overlay = new
CassandraConfigurationOverlay(null, null);
+
+ assertThat(overlay.toString()).isEqualTo(String.join("\n",
+ "{",
+ " \"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..2eb095e8
--- /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.Map;
+
+import org.junit.jupiter.api.Test;
+
+import io.vertx.core.json.JsonObject;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ConfigurationOverlaySnapshot}
+ */
+class ConfigurationOverlaySnapshotTest
+{
+ @Test
+ void testHashIsDeterministic()
+ {
+ JsonObject yaml1 = new JsonObject()
+ .put("concurrent_reads", 32)
+ .put("memtable_flush_writers", 4);
+
+ JsonObject yaml2 = new JsonObject()
+ .put("concurrent_reads", 32)
+ .put("memtable_flush_writers", 4);
+
+ CassandraConfigurationOverlay overlay1 = new
CassandraConfigurationOverlay(yaml1, Map.of("-Xmx", "4G"));
+ CassandraConfigurationOverlay overlay2 = new
CassandraConfigurationOverlay(yaml2, Map.of("-Xmx", "4G"));
+
+ ConfigurationOverlaySnapshot snapshot1 = new
ConfigurationOverlaySnapshot(Instant.now(), overlay1);
+ ConfigurationOverlaySnapshot snapshot2 = new
ConfigurationOverlaySnapshot(Instant.now(), overlay2);
+
+ assertThat(snapshot1.hash()).isEqualTo(snapshot2.hash());
+ }
+
+ @Test
+ void testHashChangesWithDifferentContent()
+ {
+ JsonObject yaml1 = new JsonObject().put("concurrent_reads", 32);
+ JsonObject yaml2 = new JsonObject().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()
+ {
+ JsonObject yaml = new JsonObject().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()
+ {
+ JsonObject yaml = new JsonObject().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()
+ {
+ JsonObject yaml = new JsonObject().put("concurrent_reads", 32);
+ CassandraConfigurationOverlay overlay = new
CassandraConfigurationOverlay(yaml, Map.of("-Xmx", "4G"));
+ Instant timestamp = Instant.parse("2026-02-20T14:32:18Z");
+
+ ConfigurationOverlaySnapshot snapshot = new
ConfigurationOverlaySnapshot(timestamp, overlay);
+
+ String expected = String.join("\n",
+ "{",
+ " \"hash\" : \"" + snapshot.hash() + "\",",
+ " \"lastModified\" : \"2026-02-20T14:32:18Z\",",
+ " \"overlay\" : {",
+ " \"cassandraYaml\" : {",
+ " \"concurrent_reads\" : 32",
+ " },",
+ " \"extraJvmOpts\" : {",
+ " \"-Xmx\" : \"4G\"",
+ " }",
+ " }",
+ "}");
+ 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..c58f88af
--- /dev/null
+++
b/server/src/test/java/org/apache/cassandra/sidecar/configmanagement/InMemoryConfigurationProviderTest.java
@@ -0,0 +1,230 @@
+/*
+ * 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 io.vertx.core.json.JsonObject;
+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 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.getOverlay(instance1)).isNull();
+ }
+
+ @Test
+ void testStoreAndGet()
+ {
+ ConfigurationOverlaySnapshot snapshot =
createSnapshot("concurrent_reads", 64);
+
+ boolean stored = provider.storeOverlay(instance1, null, snapshot);
+
+ assertThat(stored).isTrue();
+ ConfigurationOverlaySnapshot fetched = provider.getOverlay(instance1);
+ assertThat(fetched).isSameAs(snapshot);
+ }
+
+ @Test
+ void testStoreReturnsTrueOnSuccess()
+ {
+ ConfigurationOverlaySnapshot snapshot =
createSnapshot("memtable_flush_writers", 8);
+
+ assertThat(provider.storeOverlay(instance1, null, snapshot)).isTrue();
+ }
+
+ @Test
+ void testStoreReturnsFalseOnHashMismatch()
+ {
+ ConfigurationOverlaySnapshot initial =
createSnapshot("concurrent_reads", 32);
+ provider.storeOverlay(instance1, null, initial);
+
+ ConfigurationOverlaySnapshot update =
createSnapshot("concurrent_reads", 64);
+ assertThat(provider.storeOverlay(instance1, "sha256:stale",
update)).isFalse();
+
+ // Original is preserved
+ assertThat(provider.getOverlay(instance1)).isSameAs(initial);
+ }
+
+ @Test
+ void testStoreReturnsFalseWhenNoOverlayButHashProvided()
+ {
+ ConfigurationOverlaySnapshot snapshot =
createSnapshot("concurrent_reads", 32);
+ assertThat(provider.storeOverlay(instance1, "sha256:unexpected",
snapshot)).isFalse();
+ assertThat(provider.getOverlay(instance1)).isNull();
+ }
+
+ @Test
+ void testStoreReturnsFalseWhenOverlayExistsButNullHashProvided()
+ {
+ ConfigurationOverlaySnapshot initial =
createSnapshot("concurrent_reads", 32);
+ provider.storeOverlay(instance1, null, initial);
+
+ ConfigurationOverlaySnapshot update =
createSnapshot("concurrent_reads", 64);
+ assertThat(provider.storeOverlay(instance1, null, update)).isFalse();
+
+ // Original is preserved
+ assertThat(provider.getOverlay(instance1)).isSameAs(initial);
+ }
+
+ @Test
+ void testInstanceIsolation()
+ {
+ ConfigurationOverlaySnapshot snap1 =
createSnapshot("concurrent_reads", 32);
+ ConfigurationOverlaySnapshot snap2 =
createSnapshot("concurrent_reads", 64);
+
+ provider.storeOverlay(instance1, null, snap1);
+ provider.storeOverlay(instance2, null, snap2);
+
+ assertThat(provider.getOverlay(instance1)).isSameAs(snap1);
+ assertThat(provider.getOverlay(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.storeOverlay(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.getOverlay(mockInstance(i))).isNotNull();
+ }
+
+ executor.shutdown();
+ }
+
+ @Test
+ void testConcurrentStoresSameInstance() throws Exception
+ {
+ ConfigurationOverlaySnapshot initial =
createSnapshot("concurrent_reads", 32);
+ provider.storeOverlay(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.storeOverlay(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)
+ {
+ JsonObject yaml = new JsonObject().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]