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:&lt;64 hex chars&gt;"
+     */
+    @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]


Reply via email to