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 3c88f576 CASSSIDECAR-377: Implement job coordination for cluster-wide 
operations (#360)
3c88f576 is described below

commit 3c88f57643990333097d313c984b90015bd84394
Author: AndrĂ©s Beck-Ruiz <[email protected]>
AuthorDate: Thu Aug 6 16:46:32 2026 -0400

    CASSSIDECAR-377: Implement job coordination for cluster-wide operations 
(#360)
    
    Patch by Andres Beck-Ruiz; Reviewed by Arjun Ashok, Francisco Guerrero, 
Paulo Motta, Shailaja Koppu for CASSSIDECAR-377
---
 CHANGES.txt                                        |   1 +
 .../job/DisabledOperationalJobCoordinator.java     |  68 ++++++++
 .../cassandra/sidecar/job/OperationalJob.java      |  31 ++++
 .../sidecar/job/OperationalJobCoordinator.java     |  79 +++++++++
 .../sidecar/job/OperationalJobManager.java         | 122 +++++++++++--
 .../StorageBackedOperationalJobCoordinator.java    |  71 ++++++++
 .../sidecar/modules/CassandraOperationsModule.java |   3 +
 .../job/DisabledOperationalJobCoordinatorTest.java |  68 ++++++++
 .../sidecar/job/OperationalJobManagerTest.java     | 194 ++++++++++++++++++++-
 .../cassandra/sidecar/job/RepairJobTest.java       |   2 +-
 10 files changed, 622 insertions(+), 17 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 71c6953d..6c43ac33 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,6 @@
 0.5.0
 -----
+ * Implement job coordination for cluster-wide operations (CASSSIDECAR-377)
  * Fix dead/dropped CDC lifecycle metrics and duplicate SidecarCdcStats 
interface (CASSSIDECAR-489)
  * Wire CDC configs in configs table to 
SidecarCdcOptions/SidecarStatePersister (CASSSIDECAR-483)
  * Implement durable operational job tracker (CASSSIDECAR-374)
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/job/DisabledOperationalJobCoordinator.java
 
b/server/src/main/java/org/apache/cassandra/sidecar/job/DisabledOperationalJobCoordinator.java
new file mode 100644
index 00000000..994e5f6e
--- /dev/null
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/job/DisabledOperationalJobCoordinator.java
@@ -0,0 +1,68 @@
+/*
+ * 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.job;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.UUID;
+
+import org.apache.cassandra.sidecar.common.data.OperationType;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * An {@link OperationalJobCoordinator} used when a Sidecar instance is not 
configured to support
+ * coordinated cluster-wide operations.
+ * <p>
+ * Jobs that do not require coordination ({@link 
OperationalJob#requiresCoordination()} returns
+ * {@code false}) never reach this coordinator, so uncoordinated operations 
(e.g. decommission) are
+ * unaffected. 
+ */
+public class DisabledOperationalJobCoordinator implements 
OperationalJobCoordinator
+{
+    private static final String NOT_SUPPORTED_MESSAGE =
+    "Operational job coordination is not supported by this Sidecar instance. "
+    + "Configure a coordinator to enable coordinated cluster-wide operations.";
+
+    @Override
+    public boolean trySetActive(OperationType operationType, UUID operationId)
+    {
+        throw new UnsupportedOperationException(NOT_SUPPORTED_MESSAGE);
+    }
+
+    @Override
+    public boolean clearActive(OperationType operationType, UUID operationId)
+    {
+        throw new UnsupportedOperationException(NOT_SUPPORTED_MESSAGE);
+    }
+
+    @Override
+    @Nullable
+    public UUID getActiveOperation(OperationType operationType)
+    {
+        return null;
+    }
+
+    @Override
+    @NotNull
+    public Map<OperationType, UUID> getActiveOperations()
+    {
+        return Collections.emptyMap();
+    }
+}
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJob.java 
b/server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJob.java
index f3c15c6a..bdfa377c 100644
--- a/server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJob.java
+++ b/server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJob.java
@@ -183,6 +183,24 @@ public abstract class OperationalJob implements 
Task<Void>, OperationalJobInfo
         return false;
     }
 
+    /**
+     * Whether the manager should release the active operation lock (via
+     * {@link OperationalJobCoordinator#clearActive}) when this job completes 
locally. Only consulted for jobs that
+     * {@link #requiresCoordination() require coordination}.
+     * <p>
+     * Single-node coordinated jobs return {@code true} (the default): the 
Sidecar that acquires the lock also
+     * finishes the work, so releasing on local completion is correct. 
Distributed cluster-wide jobs whose work
+     * finishes on other nodes return {@code false} and rely on the 
orchestration layer to call
+     * {@link OperationalJobCoordinator#clearActive} once all nodes reach a 
terminal state.
+     *
+     * @return {@code true} if the manager should release the lock on local 
completion; {@code false} to defer
+     *         release to the orchestration layer
+     */
+    public boolean releasesOnCompletion()
+    {
+        return true;
+    }
+
     @Override
     public final Void result()
     {
@@ -343,6 +361,19 @@ public abstract class OperationalJob implements 
Task<Void>, OperationalJobInfo
                      });
     }
 
+    /**
+     * Marks the job as failed before it begins executing, for example when it 
cannot be started due to a
+     * coordination conflict. The execution result is completed exceptionally 
so the job reports
+     * {@link OperationalJobStatus#FAILED} with the supplied reason, and 
{@link #executeInternal()} is never invoked.
+     *
+     * @param cause the reason the job could not be started
+     */
+    public void failToStart(Throwable cause)
+    {
+        lastUpdate = Instant.now();
+        executionPromise.tryFail(cause);
+    }
+
     /**
      * OperationalJob body. The implementation returns a Future representing 
the job execution.
      */
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJobCoordinator.java
 
b/server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJobCoordinator.java
new file mode 100644
index 00000000..90be1a3f
--- /dev/null
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJobCoordinator.java
@@ -0,0 +1,79 @@
+/*
+ * 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.job;
+
+import java.util.Map;
+import java.util.UUID;
+
+import org.apache.cassandra.sidecar.common.data.OperationType;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Coordinates cluster-wide operational jobs by managing active operation 
state.
+ * Ensures mutual exclusion so that only one operation of a given type is 
active
+ * at a time within a datacenter.
+ */
+public interface OperationalJobCoordinator
+{
+    /**
+     * Attempt to set an operation as active. Implementations must ensure 
mutual exclusion
+     * so that only one operation of the given type is active at a time.
+     *
+     * @param operationType the type of operation
+     * @param operationId   the unique identifier for this operation
+     * @return {@code true} if the operation was successfully set as active,
+     *         {@code false} if an operation of the same type is already active
+     */
+    boolean trySetActive(OperationType operationType, UUID operationId);
+
+    /**
+     * Clear the active operation lock, but only if the provided operation ID 
matches
+     * the currently active one.
+     * <p>
+     * This method is not called by {@code OperationalJobManager} during job 
submission.
+     * For cluster-wide operations, the Sidecar that creates the job is not 
necessarily
+     * the one that finishes it. Clearing is the responsibility of the 
orchestration layer
+     * once all participating nodes have completed their work.
+     *
+     * @param operationType the type of operation
+     * @param operationId   the operation ID to clear
+     * @return {@code true} if the active operation was cleared,
+     *         {@code false} if the provided operation ID did not match the 
active one
+     */
+    boolean clearActive(OperationType operationType, UUID operationId);
+
+    /**
+     * Get the active operation ID for a given operation type.
+     *
+     * @param operationType the type of operation
+     * @return the active operation ID, or {@code null} if no operation of 
this type is active
+     */
+    @Nullable
+    UUID getActiveOperation(OperationType operationType);
+
+    /**
+     * Get all active operations.
+     *
+     * @return a map of operation type to operation ID for all currently 
active operations,
+     *         or an empty map if no operations are active
+     */
+    @NotNull
+    Map<OperationType, UUID> getActiveOperations();
+}
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJobManager.java
 
b/server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJobManager.java
index a1e4d406..c348c86a 100644
--- 
a/server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJobManager.java
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJobManager.java
@@ -28,10 +28,12 @@ import org.slf4j.LoggerFactory;
 
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import io.vertx.core.Future;
 import org.apache.cassandra.sidecar.common.server.utils.DurationSpec;
 import org.apache.cassandra.sidecar.concurrent.ExecutorPools;
 import org.apache.cassandra.sidecar.concurrent.TaskExecutorPool;
 import org.apache.cassandra.sidecar.exceptions.OperationalJobConflictException;
+import org.jetbrains.annotations.Nullable;
 
 /**
  * An abstraction of the management and tracking of long-running jobs running 
on the sidecar.
@@ -41,18 +43,24 @@ public class OperationalJobManager
 {
     protected final Logger logger = LoggerFactory.getLogger(this.getClass());
     private final OperationalJobTracker jobTracker;
-
+    private final OperationalJobCoordinator coordinator;
     private final TaskExecutorPool internalExecutorPool;
 
     /**
-     * Creates a manager instance with a default sized job-tracker.
+     * Creates a manager instance with a coordinator for cluster-wide 
operation mutual exclusion. Instances that
+     * do not support coordination bind a {@link 
DisabledOperationalJobCoordinator}, which fails coordination
+     * requests.
      *
-     * @param jobTracker the tracker for the operational jobs
+     * @param jobTracker  the tracker for the operational jobs
+     * @param coordinator the coordinator for cluster-wide operations
      */
     @Inject
-    public OperationalJobManager(OperationalJobTracker jobTracker, 
ExecutorPools executorPools)
+    public OperationalJobManager(OperationalJobTracker jobTracker,
+                                 OperationalJobCoordinator coordinator,
+                                 ExecutorPools executorPools)
     {
         this.jobTracker = jobTracker;
+        this.coordinator = coordinator;
         this.internalExecutorPool = executorPools.internal();
     }
 
@@ -99,13 +107,6 @@ public class OperationalJobManager
         try
         {
             checkConflict(job);
-
-            // Track the job first, then start execution separately
-            OperationalJob tracked = jobTracker.computeIfAbsent(job.jobId(), 
jobId -> job);
-            if (tracked == job)
-            {
-                internalExecutorPool.executeBlocking(job::execute);
-            }
         }
         catch (OperationalJobConflictException oje)
         {
@@ -113,6 +114,58 @@ public class OperationalJobManager
             return;
         }
 
+        if (!job.requiresCoordination())
+        {
+            trackAndExecute(job, onComplete, serviceExecutorPool, waitTime);
+            return;
+        }
+
+        // Acquiring the active operation lock might perform blocking storage 
I/O so it must
+        // run off the event loop
+        acquireActiveOperationLock(job)
+        .onComplete(ar ->
+        {
+            if (ar.succeeded() && Boolean.TRUE.equals(ar.result()))
+            {
+                // Distributed jobs that finish on other nodes opt out of 
auto-release; the orchestration
+                // layer clears the lock once all nodes reach a terminal state.
+                if (job.releasesOnCompletion())
+                {
+                    job.asyncResult().onComplete(result -> 
releaseActiveOperationLock(job));
+                }
+                trackAndExecute(job, onComplete, serviceExecutorPool, 
waitTime);
+            }
+            else
+            {
+                OperationalJobConflictException conflict = 
coordinationConflict(job, ar.cause());
+                job.failToStart(conflict);
+                jobTracker.computeIfAbsent(job.jobId(), jobId -> job);
+                onComplete.accept(job, conflict);
+            }
+        });
+    }
+
+    /**
+     * Tracks the job and submits it for asynchronous execution on the 
internal executor pool, then arranges for
+     * {@code onComplete} to be invoked with the result (or after {@code 
waitTime} elapses).
+     *
+     * @param job                 the job to track and execute
+     * @param onComplete          callback to invoke when the job completes
+     * @param serviceExecutorPool the executor pool to use for waiting on job 
completion
+     * @param waitTime            the maximum time to wait for job completion 
before returning
+     */
+    private void trackAndExecute(OperationalJob job,
+                                 BiConsumer<OperationalJob, 
OperationalJobConflictException> onComplete,
+                                 TaskExecutorPool serviceExecutorPool,
+                                 DurationSpec waitTime)
+    {
+        // New job is submitted for all cases when we do not have a 
corresponding downstream job
+        OperationalJob tracked = jobTracker.computeIfAbsent(job.jobId(), jobId 
-> job);
+        if (tracked == job)
+        {
+            internalExecutorPool.executeBlocking(job::execute);
+        }
+
         // Get the result, waiting for the specified wait time for result
         job.asyncResult(serviceExecutorPool, waitTime)
            .onComplete(v -> onComplete.accept(job, null));
@@ -132,4 +185,51 @@ public class OperationalJobManager
             throw new OperationalJobConflictException("The same operational 
job is already running on Cassandra. operationName='" + job.name() + '\'');
         }
     }
+
+    /**
+     * For jobs that require cluster-wide coordination, attempts to acquire 
the active operation lock via the
+     * coordinator. The acquisition runs on the internal executor pool because 
it might performs blocking storage I/O
+     *
+     * @param job the job requiring coordination
+     * @return a future resolving to {@code true} if the lock was acquired, 
{@code false} if another operation
+     *         already holds it, or a failed future if coordination could not 
be attempted (e.g. coordination is
+     *         disabled on this instance or the storage call failed)
+     */
+    private Future<Boolean> acquireActiveOperationLock(OperationalJob job)
+    {
+        return internalExecutorPool.executeBlocking(() -> 
coordinator.trySetActive(job.operationType(), job.jobId()), false);
+    }
+
+    /**
+     * Releases the active operation lock previously acquired for the given 
job. The release performs
+     * blocking storage I/O, so it runs on the internal executor pool off the 
event loop. A failure to clear is logged
+     * rather than surfaced, since the job has already completed by this point.
+     *
+     * @param job the job whose active operation lock should be released
+     */
+    private void releaseActiveOperationLock(OperationalJob job)
+    {
+        internalExecutorPool.executeBlocking(() -> 
coordinator.clearActive(job.operationType(), job.jobId()), false)
+                            .onFailure(e -> logger.error("Failed to clear 
active operation lock. jobId={} operationType={}",
+                                                         job.jobId(), 
job.operationType(), e));
+    }
+
+    /**
+     * Builds the conflict exception describing why the active operation lock 
could not be acquired.
+     *
+     * @param job   the job that could not be coordinated
+     * @param cause the failure cause when coordination could not be 
attempted, or {@code null} when the lock is
+     *              simply held by another active operation
+     * @return the conflict exception to report to the caller
+     */
+    private OperationalJobConflictException 
coordinationConflict(OperationalJob job, @Nullable Throwable cause)
+    {
+        if (cause != null)
+        {
+            return new OperationalJobConflictException("Unable to coordinate 
operation. operationType='"
+                                                       + job.operationType() + 
"', reason='" + cause.getMessage() + '\'');
+        }
+        return new OperationalJobConflictException("An active operation 
already exists. operationType='"
+                                                   + job.operationType() + 
'\'');
+    }
 }
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/job/StorageBackedOperationalJobCoordinator.java
 
b/server/src/main/java/org/apache/cassandra/sidecar/job/StorageBackedOperationalJobCoordinator.java
new file mode 100644
index 00000000..152ba9b6
--- /dev/null
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/job/StorageBackedOperationalJobCoordinator.java
@@ -0,0 +1,71 @@
+/*
+ * 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.job;
+
+import java.util.Map;
+import java.util.UUID;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.apache.cassandra.sidecar.common.data.OperationType;
+import org.apache.cassandra.sidecar.job.storage.StorageProvider;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * An {@link OperationalJobCoordinator} implementation that delegates to a 
{@link StorageProvider}
+ * for coordination of active operations.
+ */
+@Singleton
+public class StorageBackedOperationalJobCoordinator implements 
OperationalJobCoordinator
+{
+    private final StorageProvider storageProvider;
+
+    @Inject
+    public StorageBackedOperationalJobCoordinator(StorageProvider 
storageProvider)
+    {
+        this.storageProvider = storageProvider;
+    }
+
+    @Override
+    public boolean trySetActive(OperationType operationType, UUID operationId)
+    {
+        return storageProvider.trySetActiveOperation(operationType, 
operationId);
+    }
+
+    @Override
+    public boolean clearActive(OperationType operationType, UUID operationId)
+    {
+        return storageProvider.clearActiveOperation(operationType, 
operationId);
+    }
+
+    @Override
+    @Nullable
+    public UUID getActiveOperation(OperationType operationType)
+    {
+        return storageProvider.getActiveOperation(operationType);
+    }
+
+    @Override
+    @NotNull
+    public Map<OperationType, UUID> getActiveOperations()
+    {
+        return storageProvider.getActiveOperations();
+    }
+}
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/modules/CassandraOperationsModule.java
 
b/server/src/main/java/org/apache/cassandra/sidecar/modules/CassandraOperationsModule.java
index a2224e5c..49c1af46 100644
--- 
a/server/src/main/java/org/apache/cassandra/sidecar/modules/CassandraOperationsModule.java
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/modules/CassandraOperationsModule.java
@@ -61,7 +61,9 @@ import 
org.apache.cassandra.sidecar.handlers.TokenRangeReplicaMapHandler;
 import org.apache.cassandra.sidecar.handlers.cassandra.NodeSettingsHandler;
 import 
org.apache.cassandra.sidecar.handlers.v2.cassandra.V2NodeSettingsHandler;
 import 
org.apache.cassandra.sidecar.handlers.validations.ValidateTableExistenceHandler;
+import org.apache.cassandra.sidecar.job.DisabledOperationalJobCoordinator;
 import org.apache.cassandra.sidecar.job.InMemoryOperationalJobTracker;
+import org.apache.cassandra.sidecar.job.OperationalJobCoordinator;
 import org.apache.cassandra.sidecar.job.OperationalJobTracker;
 import org.apache.cassandra.sidecar.modules.multibindings.KeyClassMapKey;
 import org.apache.cassandra.sidecar.modules.multibindings.TableSchemaMapKeys;
@@ -83,6 +85,7 @@ public class CassandraOperationsModule extends AbstractModule
     protected void configure()
     {
         
bind(OperationalJobTracker.class).to(InMemoryOperationalJobTracker.class);
+        
bind(OperationalJobCoordinator.class).to(DisabledOperationalJobCoordinator.class);
     }
 
     @ProvidesIntoMap
diff --git 
a/server/src/test/java/org/apache/cassandra/sidecar/job/DisabledOperationalJobCoordinatorTest.java
 
b/server/src/test/java/org/apache/cassandra/sidecar/job/DisabledOperationalJobCoordinatorTest.java
new file mode 100644
index 00000000..efebc6cf
--- /dev/null
+++ 
b/server/src/test/java/org/apache/cassandra/sidecar/job/DisabledOperationalJobCoordinatorTest.java
@@ -0,0 +1,68 @@
+/*
+ * 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.job;
+
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+import com.datastax.driver.core.utils.UUIDs;
+import org.apache.cassandra.sidecar.common.data.OperationType;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link DisabledOperationalJobCoordinator}, the coordinator used 
when a Sidecar instance
+ * does not support coordinated cluster-wide operations.
+ */
+class DisabledOperationalJobCoordinatorTest
+{
+    private final OperationalJobCoordinator coordinator = new 
DisabledOperationalJobCoordinator();
+
+    @Test
+    void testTrySetActiveThrows()
+    {
+        UUID operationId = UUIDs.timeBased();
+        assertThatThrownBy(() -> coordinator.trySetActive(OperationType.MOVE, 
operationId))
+        .isInstanceOf(UnsupportedOperationException.class)
+        .hasMessageContaining("coordination is not supported by this Sidecar 
instance");
+    }
+
+    @Test
+    void testClearActiveThrows()
+    {
+        UUID operationId = UUIDs.timeBased();
+        assertThatThrownBy(() -> coordinator.clearActive(OperationType.MOVE, 
operationId))
+        .isInstanceOf(UnsupportedOperationException.class)
+        .hasMessageContaining("coordination is not supported by this Sidecar 
instance");
+    }
+
+    @Test
+    void testGetActiveOperationReturnsNull()
+    {
+        
assertThat(coordinator.getActiveOperation(OperationType.MOVE)).isNull();
+    }
+
+    @Test
+    void testGetActiveOperationsReturnsEmptyMap()
+    {
+        assertThat(coordinator.getActiveOperations()).isEmpty();
+    }
+}
diff --git 
a/server/src/test/java/org/apache/cassandra/sidecar/job/OperationalJobManagerTest.java
 
b/server/src/test/java/org/apache/cassandra/sidecar/job/OperationalJobManagerTest.java
index cf7492c2..79774392 100644
--- 
a/server/src/test/java/org/apache/cassandra/sidecar/job/OperationalJobManagerTest.java
+++ 
b/server/src/test/java/org/apache/cassandra/sidecar/job/OperationalJobManagerTest.java
@@ -41,14 +41,20 @@ import 
org.apache.cassandra.sidecar.config.yaml.ServiceConfigurationImpl;
 import org.apache.cassandra.sidecar.exceptions.OperationalJobConflictException;
 import org.apache.cassandra.sidecar.job.storage.StorageProvider;
 import org.apache.cassandra.sidecar.job.storage.StorageProviderException;
+import org.jetbrains.annotations.NotNull;
 
+import static 
org.apache.cassandra.sidecar.common.data.OperationalJobStatus.FAILED;
 import static 
org.apache.cassandra.sidecar.common.data.OperationalJobStatus.RUNNING;
 import static 
org.apache.cassandra.sidecar.common.data.OperationalJobStatus.SUCCEEDED;
 import static org.apache.cassandra.testing.utils.AssertionUtils.loopAssert;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.after;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 /**
@@ -82,7 +88,7 @@ class OperationalJobManagerTest
     void testWithNoDownstreamJob() throws InterruptedException
     {
         OperationalJobTracker tracker = new InMemoryOperationalJobTracker(4);
-        OperationalJobManager manager = new OperationalJobManager(tracker, 
executorPool);
+        OperationalJobManager manager = new OperationalJobManager(tracker, new 
DisabledOperationalJobCoordinator(), executorPool);
         CountDownLatch latch = new CountDownLatch(1);
 
         OperationalJob testJob = 
OperationalJobTest.createOperationalJob(SUCCEEDED);
@@ -103,7 +109,7 @@ class OperationalJobManagerTest
     {
         OperationalJob runningJob = 
OperationalJobTest.createOperationalJob(RUNNING);
         OperationalJobTracker tracker = new InMemoryOperationalJobTracker(4);
-        OperationalJobManager manager = new OperationalJobManager(tracker, 
executorPool);
+        OperationalJobManager manager = new OperationalJobManager(tracker, new 
DisabledOperationalJobCoordinator(), executorPool);
         CountDownLatch latch = new CountDownLatch(1);
 
         BiConsumer<OperationalJob, OperationalJobConflictException> onComplete 
= (job, exception) -> {
@@ -121,7 +127,7 @@ class OperationalJobManagerTest
     {
         UUID jobId = UUIDs.timeBased();
         OperationalJobTracker tracker = new InMemoryOperationalJobTracker(4);
-        OperationalJobManager manager = new OperationalJobManager(tracker, 
executorPool);
+        OperationalJobManager manager = new OperationalJobManager(tracker, new 
DisabledOperationalJobCoordinator(), executorPool);
         CountDownLatch latch = new CountDownLatch(1);
 
         OperationalJob testJob = 
OperationalJobTest.createOperationalJob(jobId, 
SecondBoundConfiguration.parse("2s"));
@@ -145,7 +151,7 @@ class OperationalJobManagerTest
     {
         UUID jobId = UUIDs.timeBased();
         OperationalJobTracker tracker = new InMemoryOperationalJobTracker(4);
-        OperationalJobManager manager = new OperationalJobManager(tracker, 
executorPool);
+        OperationalJobManager manager = new OperationalJobManager(tracker, new 
DisabledOperationalJobCoordinator(), executorPool);
         CountDownLatch latch = new CountDownLatch(1);
 
         String msg = "Test Job failed";
@@ -193,7 +199,7 @@ class OperationalJobManagerTest
         DurableOperationalJobTracker durableTracker = new 
DurableOperationalJobTracker(new ServiceConfigurationImpl(),
                                                                                
        storageProvider,
                                                                                
        executorPool.service());
-        OperationalJobManager manager = new 
OperationalJobManager(durableTracker, executorPool);
+        OperationalJobManager manager = new 
OperationalJobManager(durableTracker, new DisabledOperationalJobCoordinator(), 
executorPool);
 
         UUID jobId = UUIDs.timeBased();
         OperationalJob job = OperationalJobTest.createOperationalJob(jobId, 
MillisecondBoundConfiguration.parse("50ms"));
@@ -207,4 +213,182 @@ class OperationalJobManagerTest
             assertThat(durableTracker.jobsView()).doesNotContainKey(jobId);
         });
     }
+
+    void testCoordinatorCalledWhenJobRequiresCoordination() throws 
InterruptedException
+    {
+        OperationalJobTracker tracker = new InMemoryOperationalJobTracker(4);
+        OperationalJobCoordinator coordinator = 
mock(OperationalJobCoordinator.class);
+        when(coordinator.trySetActive(any(), any())).thenReturn(true);
+        OperationalJobManager manager = new OperationalJobManager(tracker, 
coordinator, executorPool);
+        CountDownLatch latch = new CountDownLatch(1);
+
+        OperationalJob job = createCoordinatedJob(UUIDs.timeBased());
+        BiConsumer<OperationalJob, OperationalJobConflictException> onComplete 
= (j, ex) -> {
+            assertThat(ex).isNull();
+            latch.countDown();
+        };
+
+        manager.trySubmitJob(job, onComplete, executorPool.service(), 
SecondBoundConfiguration.parse("5s"));
+        assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue();
+        verify(coordinator).trySetActive(OperationType.MOVE, job.jobId());
+        verify(coordinator, timeout(5000)).clearActive(OperationType.MOVE, 
job.jobId());
+    }
+
+    @Test
+    void testConflictWhenCoordinatorReturnsFalse() throws InterruptedException
+    {
+        OperationalJobTracker tracker = new InMemoryOperationalJobTracker(4);
+        OperationalJobCoordinator coordinator = 
mock(OperationalJobCoordinator.class);
+        when(coordinator.trySetActive(any(), any())).thenReturn(false);
+        OperationalJobManager manager = new OperationalJobManager(tracker, 
coordinator, executorPool);
+        CountDownLatch latch = new CountDownLatch(1);
+
+        OperationalJob job = createCoordinatedJob(UUIDs.timeBased());
+        BiConsumer<OperationalJob, OperationalJobConflictException> onComplete 
= (j, ex) -> {
+            assertThat(ex).isInstanceOf(OperationalJobConflictException.class);
+            assertThat(ex.getMessage()).contains("An active operation already 
exists");
+            latch.countDown();
+        };
+
+        manager.trySubmitJob(job, onComplete, executorPool.service(), 
SecondBoundConfiguration.parse("5s"));
+        assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue();
+        OperationalJobInfo tracked = tracker.get(job.jobId());
+        assertThat(tracked).isNotNull();
+        assertThat(tracked.status()).isEqualTo(FAILED);
+        assertThat(tracked.failureReason()).contains("An active operation 
already exists");
+        
assertThat(tracker.inflightJobsByOperation(job.name())).doesNotContain(job);
+        verify(coordinator, never()).clearActive(any(), any());
+    }
+
+    @Test
+    void testCoordinationFailsWhenCoordinationDisabled() throws 
InterruptedException
+    {
+        OperationalJobTracker tracker = new InMemoryOperationalJobTracker(4);
+        // Coordination is disabled on this instance, yet the job requires 
coordination.
+        OperationalJobManager manager = new OperationalJobManager(tracker, new 
DisabledOperationalJobCoordinator(), executorPool);
+        CountDownLatch latch = new CountDownLatch(1);
+
+        OperationalJob job = createCoordinatedJob(UUIDs.timeBased());
+        BiConsumer<OperationalJob, OperationalJobConflictException> onComplete 
= (j, ex) -> {
+            assertThat(ex).isInstanceOf(OperationalJobConflictException.class);
+            assertThat(ex.getMessage()).contains("coordination is not 
supported by this Sidecar instance");
+            latch.countDown();
+        };
+
+        manager.trySubmitJob(job, onComplete, executorPool.service(), 
SecondBoundConfiguration.parse("5s"));
+        assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue();
+        OperationalJobInfo tracked = tracker.get(job.jobId());
+        assertThat(tracked).isNotNull();
+        assertThat(tracked.status()).isEqualTo(FAILED);
+        assertThat(tracked.failureReason()).contains("coordination is not 
supported by this Sidecar instance");
+        
assertThat(tracker.inflightJobsByOperation(job.name())).doesNotContain(job);
+    }
+
+    @Test
+    void testLockNotReleasedWhenJobOptsOut() throws InterruptedException
+    {
+        OperationalJobTracker tracker = new InMemoryOperationalJobTracker(4);
+        OperationalJobCoordinator coordinator = 
mock(OperationalJobCoordinator.class);
+        when(coordinator.trySetActive(any(), any())).thenReturn(true);
+        OperationalJobManager manager = new OperationalJobManager(tracker, 
coordinator, executorPool);
+        CountDownLatch latch = new CountDownLatch(1);
+
+        // A distributed cluster-wide job that acquires the lock locally but 
relies on the orchestration
+        // layer to clear it once all nodes finish, so the manager must not 
auto-release on local completion.
+        OperationalJob job = 
createNonReleasingCoordinatedJob(UUIDs.timeBased());
+        BiConsumer<OperationalJob, OperationalJobConflictException> onComplete 
= (j, ex) -> {
+            assertThat(ex).isNull();
+            latch.countDown();
+        };
+
+        manager.trySubmitJob(job, onComplete, executorPool.service(), 
SecondBoundConfiguration.parse("5s"));
+        assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue();
+        verify(coordinator).trySetActive(OperationType.MOVE, job.jobId());
+        verify(coordinator, after(1000).never()).clearActive(any(), any());
+    }
+
+    @Test
+    void testCoordinatorNotCalledWhenJobDoesNotRequireCoordination() throws 
InterruptedException
+    {
+        OperationalJobTracker tracker = new InMemoryOperationalJobTracker(4);
+        OperationalJobCoordinator coordinator = 
mock(OperationalJobCoordinator.class);
+        OperationalJobManager manager = new OperationalJobManager(tracker, 
coordinator, executorPool);
+        CountDownLatch latch = new CountDownLatch(1);
+
+        OperationalJob job = 
OperationalJobTest.createOperationalJob(SUCCEEDED);
+        BiConsumer<OperationalJob, OperationalJobConflictException> onComplete 
= (j, ex) -> {
+            assertThat(ex).isNull();
+            latch.countDown();
+        };
+
+        manager.trySubmitJob(job, onComplete, executorPool.service(), 
SecondBoundConfiguration.parse("5s"));
+        assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue();
+        verify(coordinator, never()).trySetActive(any(), any());
+    }
+
+    private static OperationalJob createCoordinatedJob(UUID jobId)
+    {
+        return new OperationalJob(jobId)
+        {
+            @Override
+            public boolean hasConflict(@NotNull List<OperationalJob> 
sameOperationJobs)
+            {
+                return false;
+            }
+
+            @Override
+            public OperationType operationType()
+            {
+                return OperationType.MOVE;
+            }
+
+            @Override
+            public boolean requiresCoordination()
+            {
+                return true;
+            }
+
+            @Override
+            protected Future<Void> executeInternal()
+            {
+                return Future.succeededFuture();
+            }
+        };
+    }
+
+    private static OperationalJob createNonReleasingCoordinatedJob(UUID jobId)
+    {
+        return new OperationalJob(jobId)
+        {
+            @Override
+            public boolean hasConflict(@NotNull List<OperationalJob> 
sameOperationJobs)
+            {
+                return false;
+            }
+
+            @Override
+            public OperationType operationType()
+            {
+                return OperationType.MOVE;
+            }
+
+            @Override
+            public boolean requiresCoordination()
+            {
+                return true;
+            }
+
+            @Override
+            public boolean releasesOnCompletion()
+            {
+                return false;
+            }
+
+            @Override
+            protected Future<Void> executeInternal()
+            {
+                return Future.succeededFuture();
+            }
+        };
+    }
 }
diff --git 
a/server/src/test/java/org/apache/cassandra/sidecar/job/RepairJobTest.java 
b/server/src/test/java/org/apache/cassandra/sidecar/job/RepairJobTest.java
index 1eb46601..34f772af 100644
--- a/server/src/test/java/org/apache/cassandra/sidecar/job/RepairJobTest.java
+++ b/server/src/test/java/org/apache/cassandra/sidecar/job/RepairJobTest.java
@@ -234,7 +234,7 @@ class RepairJobTest
     {
         // Create a job tracker and manager
         OperationalJobTracker tracker = new InMemoryOperationalJobTracker(10);
-        OperationalJobManager manager = new OperationalJobManager(tracker, 
executorPool);
+        OperationalJobManager manager = new OperationalJobManager(tracker, new 
DisabledOperationalJobCoordinator(), executorPool);
 
         // Mock the storage operations
         StorageOperations storageOperations = mock(StorageOperations.class);


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to