Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 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);
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