Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
* <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.
*
* <p>This class is immutable. Mutations are performed by {@link ConfigurationPatchApplier} which
* constructs a new instance with the desired changes.
*/
public class CassandraConfigurationOverlay
{
Expand All @@ -59,7 +62,7 @@ public CassandraConfigurationOverlay(@Nullable JsonObject cassandraYaml,

/**
* 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.
* returned object; use {@link ConfigurationPatchApplier} to produce a new overlay with changes applied.
*
* @return the cassandra.yaml overlay as a version-agnostic JSON object
*/
Expand Down Expand Up @@ -114,59 +117,6 @@ public static CassandraConfigurationOverlay fromJson(@NotNull JsonObject json)
return new CassandraConfigurationOverlay(cassandraYaml, 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);
}

static boolean hasConflictingBooleanOpt(Map<String, String> existing, String key)
{
String conflicting = conflictingBooleanOpt(key);
Expand All @@ -186,19 +136,6 @@ static String conflictingBooleanOpt(String key)
return null;
}

private static void validateNoConflictingBooleanOpts(Map<String, String> opts)
{
for (String key : opts.keySet())
{
if (hasConflictingBooleanOpt(opts, key))
{
String option = key.substring(5);
throw new IllegalArgumentException(
"Conflicting boolean JVM options: -XX:+" + option + " and -XX:-" + option);
}
}
}

@Override
public boolean equals(Object o)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* 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.jetbrains.annotations.NotNull;

/**
* Thrown when a configuration patch fails due to a hash mismatch,
* indicating a concurrent modification to the effective configuration.
*/
public class ConfigurationConflictException extends ConfigurationManagerException
{
@NotNull
private final String expectedHash;

@NotNull
private final String actualHash;

public ConfigurationConflictException(@NotNull String expectedHash, @NotNull String actualHash)
{
super("Configuration conflict: expected hash [" + expectedHash
+ "] but found [" + actualHash + "]", null);
this.expectedHash = expectedHash;
this.actualHash = actualHash;
}

@NotNull
public String expectedHash()
{
return expectedHash;
}

@NotNull
public String actualHash()
{
return actualHash;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
package org.apache.cassandra.sidecar.configmanagement;

import java.nio.file.Path;
import java.time.Instant;
import java.util.List;
import java.util.Objects;

import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata;
Expand Down Expand Up @@ -84,4 +86,94 @@ private ConfigurationOverlaySnapshot getBaseSnapshot()
cachedBaseSnapshot = snapshot;
return snapshot;
}

/**
* Patches the effective configuration for the given instance using RFC 6902-inspired JSON Patch
* operations. Validates the caller's expected hash against the current effective configuration,
* validates and applies the patch operations to the overlay, persists via the
* {@link ConfigurationProvider}, and returns the new effective configuration.
*
* <p>Paths reference the effective configuration structure, but mutations target the overlay.
* For nested paths within a top-level {@code cassandraYaml} key,
* the entire top-level key value is copied from the effective config into the overlay before
* applying the leaf change (copy-siblings strategy). This ensures the overlay is always
* self-contained at the top-level key granularity.
*
* <p>All operations are validated atomically: either all succeed or none are applied.
*
* @param instance the Cassandra instance metadata
* @param expectedHash the hash of the effective configuration as last seen by the caller
* @param operations the patch operations to apply
* @return a snapshot of the new effective configuration with its SHA-256 hash and last modified timestamp
* @throws ConfigurationConflictException if the expectedHash does not match the current effective hash
* @throws ConfigurationPatchException if any patch operation fails validation or application
* @throws ConfigurationManagerException if the provider fails during the operation
*/
@NotNull
public ConfigurationOverlaySnapshot patchConfiguration(@NotNull InstanceMetadata instance,
@NotNull String expectedHash,
@NotNull List<ConfigurationPatchOperation> operations)
{
Objects.requireNonNull(instance, "instance must not be null");
Objects.requireNonNull(expectedHash, "expectedHash must not be null");
Objects.requireNonNull(operations, "operations must not be null");

try
{
// 1. Validate operations structure (path format, value presence, duplicates)
List<ConfigurationPatchValidator.ParsedPatchOperation> parsedOps =
ConfigurationPatchValidator.validate(operations);

// 2. Read current state and validate the caller's hash matches
ConfigurationOverlaySnapshot baseSnapshot = getBaseSnapshot();
ConfigurationOverlaySnapshot currentOverlay = provider.getOverlay(instance);

ConfigurationOverlaySnapshot effectiveConfig = currentOverlay != null
? baseSnapshot.overlay(currentOverlay, instance.id())
: baseSnapshot;

if (!expectedHash.equals(effectiveConfig.hash()))
{
throw new ConfigurationConflictException(expectedHash, effectiveConfig.hash());
}

// 3. Apply patch operations to the overlay (precondition checks + mutations)
CassandraConfigurationOverlay currentOverlayConfig = currentOverlay != null
? currentOverlay.configuration()
: new CassandraConfigurationOverlay(null, null);
CassandraConfigurationOverlay updatedOverlay =
ConfigurationPatchApplier.apply(parsedOps, effectiveConfig.configuration(), currentOverlayConfig);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If incoming patch request has a conflicting jvm option against base yaml, this ConfigurationPatchApplier.apply only checks against overlay and doesn't detect conflict. storeOverlay call below stores it. Then baseSnapshot.overlay call below detects conflict with the base, prints warning and returns config without the conflicting option to the caller. The same thing happens in subsequent calls as well.
Also, returned config should be same as stored config, which is not true in this case.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to have test case for the same

@pauloricardomg pauloricardomg Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! Addressed in 17fb889.
apply() now checks boolean JVM opt conflicts against the effective config (base + overlay), not just the overlay, and rejects the patch before storeOverlay is called. Stored and returned configs no longer diverge, and the warn-and-drop path in baseSnapshot.overlay() is no longer hit.

Tests: ConfigurationManagerTest.testPatchConflictingBooleanJvmOptRejected (rejected, nothing stored) and testPatchReturnedConfigMatchesStoredConfig (returned == re-read).

ConfigurationOverlaySnapshot newOverlaySnapshot = new ConfigurationOverlaySnapshot(Instant.now(),
updatedOverlay);

// 4. CAS via provider — concurrent patches are resolved by the provider's own atomicity
String currentOverlayHash = currentOverlay != null ? currentOverlay.hash() : null;
boolean stored = provider.storeOverlay(instance, currentOverlayHash, newOverlaySnapshot);

// 5. Store rejected — re-read to determine if this is a true conflict or an unexpected failure
if (!stored)
{
ConfigurationOverlaySnapshot updatedCurrent = provider.getOverlay(instance);
ConfigurationOverlaySnapshot newEffective = updatedCurrent != null
? baseSnapshot.overlay(updatedCurrent, instance.id())
: baseSnapshot;
if (!expectedHash.equals(newEffective.hash()))
{
throw new ConfigurationConflictException(expectedHash, newEffective.hash());
}
throw new ConfigurationManagerException(
"Provider rejected the overlay store unexpectedly", null);
}

return baseSnapshot.overlay(newOverlaySnapshot, instance.id());
}
catch (ConfigurationManagerException e)
{
throw e;
}
catch (Exception e)
{
throw new ConfigurationManagerException("Failed to patch configuration", e);
}
}
}
Loading
Loading