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 4121ee8fea3c122242f0eba3886139ddc8f79628
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:&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 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]


Reply via email to