This is an automated email from the ASF dual-hosted git repository.

bbotella 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 e6b3ec9b3 CASSSIDECAR-508: Add local job execution and storage support 
for coordinated cluster-wide operations (#392)
e6b3ec9b3 is described below

commit e6b3ec9b3945e4dc4702e227b7c941a4cc994bab
Author: Andrés Beck-Ruiz <[email protected]>
AuthorDate: Tue Oct 27 10:23:19 2026 +0000

    CASSSIDECAR-508: Add local job execution and storage support for 
coordinated cluster-wide operations (#392)
    
    Updates operational job storage and tracking so that cluster-wide 
operational jobs can be coordinated in CASSSIDECAR-378. Also adds LocalJob and 
LocalJobManager, which the local coordinator uses to run individual node jobs 
that are part of a larger cluster-wide operation.
    
    The local job coordinator (StorageBackedLocalJobCoordinator) that uses 
these changes is in this CASSSIDECAR-378 stacked PR.
    
    Patch by Andrés Beck-Ruiz; Reviewed by Bernardo Botella, , Paulo Motta, 
Arjun Ashok for CASSSIDECAR-508
---
 CHANGES.txt                                        |   1 +
 .../handlers/ListOperationalJobsHandler.java       |  39 ++--
 .../sidecar/job/DurableOperationalJobTracker.java  |  97 +++++++-
 .../sidecar/job/InMemoryOperationalJobTracker.java |  14 ++
 .../org/apache/cassandra/sidecar/job/LocalJob.java | 133 +++++++++++
 .../cassandra/sidecar/job/LocalJobManager.java     | 153 +++++++++++++
 .../cassandra/sidecar/job/OperationalJob.java      |  30 +++
 .../sidecar/job/OperationalJobCoordinator.java     |   4 +-
 .../sidecar/job/OperationalJobManager.java         |   6 +-
 .../sidecar/job/OperationalJobTracker.java         |   8 +
 .../job/storage/CassandraStorageProvider.java      |  40 ++--
 .../sidecar/job/storage/OperationalJobRecord.java  |  13 +-
 .../sidecar/job/storage/StorageProvider.java       |   7 +-
 .../sidecar/modules/OperationalJobsModule.java     |   8 +-
 ...urableOperationalJobTrackerIntegrationTest.java |   1 -
 .../job/DurableOperationalJobTrackerTest.java      | 247 ++++++++++++++++++++-
 .../job/InMemoryOperationalJobTrackerTest.java     |  17 ++
 .../cassandra/sidecar/job/LocalJobManagerTest.java | 176 +++++++++++++++
 .../apache/cassandra/sidecar/job/LocalJobTest.java |  94 ++++++++
 .../sidecar/job/OperationalJobManagerTest.java     |  12 +
 .../cassandra/sidecar/job/OperationalJobTest.java  |  98 ++++++++
 .../job/storage/CassandraStorageProviderTest.java  | 117 ++++++++++
 22 files changed, 1254 insertions(+), 61 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 9987d8b5f..509e6e422 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,6 @@
 0.5.0
 -----
+ * Add local job execution and storage support for coordinated cluster-wide 
operations (CASSSIDECAR-508)
  * Use describe statement to fetch schema instead of Java driver 
(CASSSIDECAR-505)
  * Make the CDC CassandraBridge injectable (CASSSIDECAR-511)
  * Introduce server-side configuration to enforce allowed credential modes for 
restore jobs (CASSSIDECAR-501)
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/handlers/ListOperationalJobsHandler.java
 
b/server/src/main/java/org/apache/cassandra/sidecar/handlers/ListOperationalJobsHandler.java
index ca8407031..57e655501 100644
--- 
a/server/src/main/java/org/apache/cassandra/sidecar/handlers/ListOperationalJobsHandler.java
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/handlers/ListOperationalJobsHandler.java
@@ -68,22 +68,27 @@ public class ListOperationalJobsHandler extends 
AbstractHandler<Void> implements
     @Override
     protected void handleInternal(RoutingContext context, HttpServerRequest 
httpRequest, @NotNull String host, SocketAddress remoteAddress, Void request)
     {
-        ListOperationalJobsResponse listResponse = new 
ListOperationalJobsResponse();
-        jobManager.allInflightJobs()
-                  .stream()
-                  .map(job -> OperationalJobResponse.builder()
-                                                    .jobId(job.jobId())
-                                                    .status(job.status())
-                                                    .operation(job.name())
-                                                    .startTime(job.startTime())
-                                                    
.nodesPending(job.nodesPending())
-                                                    
.nodesExecuting(job.nodesExecuting())
-                                                    
.nodesSucceeded(job.nodesSucceeded())
-                                                    
.nodesFailed(job.nodesFailed())
-                                                    
.lastUpdate(job.lastUpdate())
-                                                    .build()
-                  )
-                  .forEach(listResponse::addJob);
-        context.json(listResponse);
+        // Durable tracker fetches active operations from storage, run off 
event loop
+        executorPools.service()
+                     .executeBlocking(jobManager::allInflightJobs)
+                     .onFailure(cause -> processFailure(cause, context, host, 
remoteAddress, request))
+                     .onSuccess(jobs -> {
+                         ListOperationalJobsResponse listResponse = new 
ListOperationalJobsResponse();
+                         jobs.stream()
+                             .map(job -> OperationalJobResponse.builder()
+                                                               
.jobId(job.jobId())
+                                                               
.status(job.status())
+                                                               
.operation(job.name())
+                                                               
.startTime(job.startTime())
+                                                               
.nodesPending(job.nodesPending())
+                                                               
.nodesExecuting(job.nodesExecuting())
+                                                               
.nodesSucceeded(job.nodesSucceeded())
+                                                               
.nodesFailed(job.nodesFailed())
+                                                               
.lastUpdate(job.lastUpdate())
+                                                               .build()
+                             )
+                             .forEach(listResponse::addJob);
+                         context.json(listResponse);
+                     });
     }
 }
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/job/DurableOperationalJobTracker.java
 
b/server/src/main/java/org/apache/cassandra/sidecar/job/DurableOperationalJobTracker.java
index d5aa8622b..9a4a9902e 100644
--- 
a/server/src/main/java/org/apache/cassandra/sidecar/job/DurableOperationalJobTracker.java
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/job/DurableOperationalJobTracker.java
@@ -18,8 +18,10 @@
 
 package org.apache.cassandra.sidecar.job;
 
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.UUID;
@@ -38,6 +40,7 @@ import 
org.apache.cassandra.sidecar.concurrent.TaskExecutorPool;
 import org.apache.cassandra.sidecar.config.ServiceConfiguration;
 import org.apache.cassandra.sidecar.job.storage.OperationalJobRecord;
 import org.apache.cassandra.sidecar.job.storage.StorageProvider;
+import org.apache.cassandra.sidecar.job.storage.StorageProviderException;
 import org.apache.cassandra.sidecar.utils.InvocationTrackingFunction;
 import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
@@ -88,28 +91,40 @@ public class DurableOperationalJobTracker implements 
OperationalJobTracker
     @Override
     public OperationalJob computeIfAbsent(UUID jobId, Function<UUID, 
OperationalJob> mappingFunction)
     {
-        if (!storageProvider.isAvailable())
-        {
-            throw new IllegalStateException("Storage provider is not 
available");
-        }
-
         InvocationTrackingFunction<UUID, OperationalJob> 
mappingFunctionTracker =
         new InvocationTrackingFunction<>(mappingFunction);
         OperationalJob job = liveJobs.computeIfAbsent(jobId, 
mappingFunctionTracker);
 
         if (mappingFunctionTracker.wasInvoked())
         {
-            persistInitialRecord(job, 1);
+            persistInitialRecord(job, job.shouldActivate(), 1);
         }
 
         return job;
     }
 
-    private void persistInitialRecord(OperationalJob job, int attempt)
+    private void persistInitialRecord(OperationalJob job, boolean 
activatingCoordinatedJob, int attempt)
     {
-        executor.runBlocking(() -> 
storageProvider.persistJob(OperationalJobRecord.fromOperationalJob(job)))
+        executor.runBlocking(() -> {
+                    if (activatingCoordinatedJob)
+                    {
+                        seedNodeStatuses(job);
+                        // Currently only support job submission that executes 
immediately
+                        
storageProvider.persistJob(OperationalJobRecord.fromOperationalJob(job, 
OperationalJobStatus.RUNNING,
+                                                                               
            Instant.now()));
+                    }
+                    else
+                    {
+                        
storageProvider.persistJob(OperationalJobRecord.fromOperationalJob(job));
+                    }
+                })
                 .onSuccess(v -> job.asyncResult().onComplete(ar -> {
-                    updateTerminalStatus(job);
+                    // The coordinator owns updating the terminal status of 
jobs that do not release
+                    // the active operation lock on completion
+                    if (job.releasesOnCompletion())
+                    {
+                        updateTerminalStatus(job);
+                    }
                     liveJobs.remove(job.jobId());
                 }))
                 .onFailure(e -> {
@@ -120,7 +135,8 @@ public class DurableOperationalJobTracker implements 
OperationalJobTracker
                         // Add jitter so that concurrent sidecar processes 
retrying against the same transient
                         // Cassandra blip do not all retry in lockstep
                         long delay = RETRY_DELAY_MS * attempt + 
ThreadLocalRandom.current().nextLong(RETRY_JITTER_MS);
-                        executor.setTimer(delay, id -> 
persistInitialRecord(job, attempt + 1));
+                        executor.setTimer(delay,
+                                          id -> persistInitialRecord(job, 
activatingCoordinatedJob, attempt + 1));
                     }
                     else
                     {
@@ -170,6 +186,67 @@ public class DurableOperationalJobTracker implements 
OperationalJobTracker
                        .collect(Collectors.toList());
     }
 
+    @NotNull
+    @Override
+    public List<OperationalJobInfo> inflightJobs()
+    {
+        Map<UUID, OperationalJobInfo> merged = new LinkedHashMap<>(liveJobs);
+        if (storageProvider.isAvailable())
+        {
+            mergeActiveOperationsFromStorage(merged);
+        }
+
+        return merged.values()
+                     .stream()
+                     .filter(job -> !job.status().isCompleted())
+                     .collect(Collectors.toList());
+    }
+
+    private void mergeActiveOperationsFromStorage(Map<UUID, 
OperationalJobInfo> merged)
+    {
+        try
+        {
+            // Coordinated jobs are removed from liveJobs almost immediately 
after handoff but might be still
+            // running. Therefore, we fetch those active operations from 
storage
+            for (UUID activeJobId : 
storageProvider.getActiveOperations().values())
+            {
+                if (merged.containsKey(activeJobId))
+                {
+                    continue;
+                }
+                OperationalJobRecord record = 
storageProvider.findJob(activeJobId);
+                if (record != null)
+                {
+                    merged.put(activeJobId, enrichWithNodeStatuses(record));
+                }
+            }
+        }
+        catch (StorageProviderException e)
+        {
+            LOGGER.warn("Failed to read active operations from storage. 
Returning in-memory jobs only", e);
+        }
+    }
+
+    /**
+     * Adds a {@link OperationalJobStatus#CREATED} for every node in the job's 
execution order.
+     */
+    private void seedNodeStatuses(OperationalJob job)
+    {
+        List<List<UUID>> executionOrder = job.nodeExecutionOrder();
+        if (executionOrder == null)
+        {
+            return;
+        }
+        List<UUID> nodeIds = executionOrder.stream()
+                                           .flatMap(List::stream)
+                                           .collect(Collectors.toList());
+        if (nodeIds.isEmpty())
+        {
+            return;
+        }
+        storageProvider.updateNodeStatuses(job.jobId(), nodeIds, 
OperationalJobStatus.CREATED);
+    }
+
     /**
      * Enriches an {@link OperationalJobRecord} with per-node status data from 
storage.
      * If the record already has non-empty node lists (e.g. populated by a 
storage provider
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/job/InMemoryOperationalJobTracker.java
 
b/server/src/main/java/org/apache/cassandra/sidecar/job/InMemoryOperationalJobTracker.java
index bbdd4777b..866cde23a 100644
--- 
a/server/src/main/java/org/apache/cassandra/sidecar/job/InMemoryOperationalJobTracker.java
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/job/InMemoryOperationalJobTracker.java
@@ -129,6 +129,20 @@ public class InMemoryOperationalJobTracker implements 
OperationalJobTracker
                          .collect(Collectors.toList());
     }
 
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    @NotNull
+    public List<OperationalJobInfo> inflightJobs()
+    {
+        return jobsView().values()
+                         .stream()
+                         .filter(j -> !j.status().isCompleted())
+                         .map(j -> (OperationalJobInfo) j)
+                         .collect(Collectors.toList());
+    }
+
     @VisibleForTesting
     OperationalJob put(OperationalJob job)
     {
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/job/LocalJob.java 
b/server/src/main/java/org/apache/cassandra/sidecar/job/LocalJob.java
new file mode 100644
index 000000000..608e9aa7f
--- /dev/null
+++ b/server/src/main/java/org/apache/cassandra/sidecar/job/LocalJob.java
@@ -0,0 +1,133 @@
+/*
+ * 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 io.vertx.core.Future;
+import org.apache.cassandra.sidecar.common.data.OperationType;
+import org.apache.cassandra.sidecar.common.data.OperationalJobStatus;
+
+/**
+ * Represents a job executed on a local Cassandra node as part of a 
cluster-wide operation.
+ */
+public abstract class LocalJob
+{
+    private final UUID operationId;
+    private final UUID nodeId;
+    private final String instanceHost;
+    private final OperationType operationType;
+    private volatile OperationalJobStatus status;
+    private volatile boolean cancelled;
+
+    /**
+     * @param operationId   the cluster-wide operation this job belongs to
+     * @param nodeId        the host ID of the Cassandra node
+     * @param instanceHost  the hostname or IP address of the Cassandra 
instance
+     * @param operationType the type of operation being performed
+     */
+    protected LocalJob(UUID operationId, UUID nodeId, String instanceHost, 
OperationType operationType)
+    {
+        this.operationId = operationId;
+        this.nodeId = nodeId;
+        this.instanceHost = instanceHost;
+        this.operationType = operationType;
+        this.status = OperationalJobStatus.CREATED;
+    }
+
+    /**
+     * Performs the job's work. Called on a worker thread, so implementations 
may block.
+     */
+    protected abstract void executeInternal();
+
+    /**
+     * Requests cancellation of this job. The running task is not interrupted. 
Instead, a long-running
+     * {@link #executeInternal()} is expected to check {@link #isCancelled()} 
at safe points and stop
+     * and recover when cancelled.
+     */
+    public void cancel()
+    {
+        cancelled = true;
+    }
+
+    /**
+     * @return whether cancellation has been requested for this job
+     */
+    protected boolean isCancelled()
+    {
+        return cancelled;
+    }
+
+    /**
+     * Performs reconciliation when the overall cluster-wide job terminates 
with FAILED
+     * status.
+     *
+     * @return a future that completes when reconciliation is done
+     */
+    public Future<Void> onJobFailed()
+    {
+        return Future.succeededFuture();
+    }
+
+    /**
+     * Execute the job, tracking status transitions
+     *
+     * @return null (for compatibility with {@code Callable} in {@code 
executeBlocking})
+     */
+    public Void execute()
+    {
+        status = OperationalJobStatus.RUNNING;
+        try
+        {
+            executeInternal();
+            status = OperationalJobStatus.SUCCEEDED;
+        }
+        catch (Exception e)
+        {
+            status = OperationalJobStatus.FAILED;
+            throw e;
+        }
+        return null;
+    }
+
+    public UUID operationId()
+    {
+        return operationId;
+    }
+
+    public UUID nodeId()
+    {
+        return nodeId;
+    }
+
+    public String instanceHost()
+    {
+        return instanceHost;
+    }
+
+    public OperationType operationType()
+    {
+        return operationType;
+    }
+
+    public OperationalJobStatus status()
+    {
+        return status;
+    }
+}
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/job/LocalJobManager.java 
b/server/src/main/java/org/apache/cassandra/sidecar/job/LocalJobManager.java
new file mode 100644
index 000000000..bc91be543
--- /dev/null
+++ b/server/src/main/java/org/apache/cassandra/sidecar/job/LocalJobManager.java
@@ -0,0 +1,153 @@
+/*
+ * 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.Collection;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import io.vertx.core.Future;
+import org.apache.cassandra.sidecar.concurrent.ExecutorPools;
+import org.apache.cassandra.sidecar.concurrent.TaskExecutorPool;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Manages the lifecycle of {@link LocalJob} instances for this Sidecar.
+ */
+@Singleton
+public class LocalJobManager
+{
+    private final Map<OperationNodeKey, LocalJob> activeJobs = new 
ConcurrentHashMap<>();
+    private final TaskExecutorPool internalExecutorPool;
+
+    @Inject
+    public LocalJobManager(ExecutorPools executorPools)
+    {
+        this.internalExecutorPool = executorPools.internal();
+    }
+
+    /**
+     * Submit a {@link LocalJob} for execution
+     *
+     * @param job the job to submit
+     * @return a future that completes when the job finishes, or succeeds 
immediately if already tracked
+     */
+    public Future<Void> submitJob(LocalJob job)
+    {
+        LocalJob existing = activeJobs.putIfAbsent(new 
OperationNodeKey(job.operationId(), job.nodeId()), job);
+        if (existing != null)
+        {
+            return Future.succeededFuture();
+        }
+        return internalExecutorPool.executeBlocking(job::execute).mapEmpty();
+    }
+
+    /**
+     * Get the active job for a node within a specific operation, or {@code 
null} if none.
+     *
+     * @param operationId the cluster-wide operation identifier
+     * @param nodeId      the node identifier
+     * @return the active job, or {@code null}
+     */
+    @Nullable
+    public LocalJob getJob(UUID operationId, UUID nodeId)
+    {
+        return activeJobs.get(new OperationNodeKey(operationId, nodeId));
+    }
+
+    /**
+     * Get all active local jobs.
+     *
+     * @return an unmodifiable view of active jobs
+     */
+    public Collection<LocalJob> activeJobs()
+    {
+        return Collections.unmodifiableCollection(activeJobs.values());
+    }
+
+    /**
+     * Remove a job from tracking, only if it is still the tracked one
+     *
+     * @param operationId the cluster-wide operation identifier
+     * @param nodeId      the node whose job should be removed
+     * @param expected    the job the caller is finished with
+     * @return whether the job was removed
+     */
+    public boolean removeJob(UUID operationId, UUID nodeId, LocalJob expected)
+    {
+        return activeJobs.remove(new OperationNodeKey(operationId, nodeId), 
expected);
+    }
+
+    /**
+     * Composite key identifying per-node state within a specific cluster-wide 
operation
+     */
+    static final class OperationNodeKey
+    {
+        private final UUID operationId;
+        private final UUID nodeId;
+
+        OperationNodeKey(UUID operationId, UUID nodeId)
+        {
+            this.operationId = operationId;
+            this.nodeId = nodeId;
+        }
+
+        UUID operationId()
+        {
+            return operationId;
+        }
+
+        UUID nodeId()
+        {
+            return nodeId;
+        }
+
+        @Override
+        public boolean equals(Object o)
+        {
+            if (this == o)
+            {
+                return true;
+            }
+            if (!(o instanceof OperationNodeKey))
+            {
+                return false;
+            }
+            OperationNodeKey that = (OperationNodeKey) o;
+            return operationId.equals(that.operationId) && 
nodeId.equals(that.nodeId);
+        }
+
+        @Override
+        public int hashCode()
+        {
+            return Objects.hash(operationId, nodeId);
+        }
+
+        @Override
+        public String toString()
+        {
+            return "OperationNodeKey{operationId=" + operationId + ", nodeId=" 
+ nodeId + '}';
+        }
+    }
+}
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 bdfa377c8..d086450f7 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
@@ -22,6 +22,7 @@ import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.UUID;
 
 import org.slf4j.Logger;
@@ -201,6 +202,35 @@ public abstract class OperationalJob implements 
Task<Void>, OperationalJobInfo
         return true;
     }
 
+    /**
+     * @return the ordered list of parallel node groups for execution, or null 
if not applicable
+     */
+    @Nullable
+    public List<List<UUID>> nodeExecutionOrder()
+    {
+        return null;
+    }
+
+    /**
+     * Seed node statuses and mark the job as RUNNING, only for a coordinated 
job that successfully started.
+     * A job that failed to start (e.g. it did not acquire the active 
operation lock) is already complete.
+     */
+    final boolean shouldActivate()
+    {
+        return requiresCoordination()
+               && nodeExecutionOrder() != null
+               && !asyncResult().isComplete();
+    }
+
+    /**
+     * @return metadata associated with this operation, or null if not 
applicable
+     */
+    @Nullable
+    public Map<String, String> operationMetadata()
+    {
+        return null;
+    }
+
     @Override
     public final Void result()
     {
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
index 90be1a3f8..3e52943c8 100644
--- 
a/server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJobCoordinator.java
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJobCoordinator.java
@@ -34,12 +34,12 @@ 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.
+     * so that only one operation of the given type is active at a time within 
the local datacenter.
      *
      * @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
+     *         {@code false} if an operation of the same type is already 
active in the local datacenter
      */
     boolean trySetActive(OperationType operationType, UUID operationId);
 
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 c348c86ae..4d1c1206a 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
@@ -21,7 +21,6 @@ package org.apache.cassandra.sidecar.job;
 import java.util.List;
 import java.util.UUID;
 import java.util.function.BiConsumer;
-import java.util.stream.Collectors;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -71,10 +70,7 @@ public class OperationalJobManager
      */
     public List<OperationalJobInfo> allInflightJobs()
     {
-        return jobTracker.jobsView().values()
-                         .stream()
-                         .filter(j -> !j.status().isCompleted())
-                         .collect(Collectors.toList());
+        return jobTracker.inflightJobs();
     }
 
     /**
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJobTracker.java
 
b/server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJobTracker.java
index 5dcb4283c..bed0f10f1 100644
--- 
a/server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJobTracker.java
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJobTracker.java
@@ -70,4 +70,12 @@ public interface OperationalJobTracker
      */
     @NotNull
     List<OperationalJob> inflightJobsByOperation(String operation);
+
+    /**
+     * Returns all inflight (CREATED or RUNNING) jobs tracked by this Sidecar.
+     *
+     * @return list of inflight job info
+     */
+    @NotNull
+    List<OperationalJobInfo> inflightJobs();
 }
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/job/storage/CassandraStorageProvider.java
 
b/server/src/main/java/org/apache/cassandra/sidecar/job/storage/CassandraStorageProvider.java
index 47781845b..e9667154a 100644
--- 
a/server/src/main/java/org/apache/cassandra/sidecar/job/storage/CassandraStorageProvider.java
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/job/storage/CassandraStorageProvider.java
@@ -18,22 +18,21 @@
 
 package org.apache.cassandra.sidecar.job.storage;
 
-import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import java.util.UUID;
 import java.util.function.Supplier;
 
-import com.datastax.driver.core.Host;
-import com.datastax.driver.core.Session;
 import com.datastax.driver.core.exceptions.DriverException;
 import org.apache.cassandra.sidecar.common.data.OperationType;
 import org.apache.cassandra.sidecar.common.data.OperationalJobStatus;
+import org.apache.cassandra.sidecar.common.response.NodeSettings;
 import org.apache.cassandra.sidecar.common.server.CQLSessionProvider;
 import org.apache.cassandra.sidecar.db.ActiveClusterOpsDatabaseAccessor;
 import org.apache.cassandra.sidecar.db.ClusterOpsDatabaseAccessor;
 import org.apache.cassandra.sidecar.db.ClusterOpsNodeStateDatabaseAccessor;
 import org.apache.cassandra.sidecar.exceptions.CassandraUnavailableException;
+import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher;
 import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
@@ -48,6 +47,7 @@ public class CassandraStorageProvider implements 
StorageProvider
     private final ClusterOpsDatabaseAccessor clusterOpsAccessor;
     private final ClusterOpsNodeStateDatabaseAccessor nodeStateAccessor;
     private final ActiveClusterOpsDatabaseAccessor activeOpsAccessor;
+    private final InstanceMetadataFetcher instanceMetadataFetcher;
     private volatile String clusterName;
     private volatile String datacenter;
 
@@ -61,12 +61,14 @@ public class CassandraStorageProvider implements 
StorageProvider
                                     ClusterOpsDatabaseAccessor 
clusterOpsAccessor,
                                     ClusterOpsNodeStateDatabaseAccessor 
nodeStateAccessor,
                                     ActiveClusterOpsDatabaseAccessor 
activeOpsAccessor,
+                                    InstanceMetadataFetcher 
instanceMetadataFetcher,
                                     @Nullable String clusterName)
     {
         this.sessionProvider = sessionProvider;
         this.clusterOpsAccessor = clusterOpsAccessor;
         this.nodeStateAccessor = nodeStateAccessor;
         this.activeOpsAccessor = activeOpsAccessor;
+        this.instanceMetadataFetcher = instanceMetadataFetcher;
         this.clusterName = clusterName;
     }
 
@@ -176,20 +178,13 @@ public class CassandraStorageProvider implements 
StorageProvider
         // TTL on all tables handles record pruning.
         try
         {
-            Session session = sessionProvider.get();
             if (clusterName == null)
             {
-                clusterName = 
session.getCluster().getMetadata().getClusterName();
+                clusterName = 
sessionProvider.get().getCluster().getMetadata().getClusterName();
             }
             if (datacenter == null)
             {
-                Collection<Host> connectedHosts = 
session.getState().getConnectedHosts();
-                if (connectedHosts.isEmpty())
-                {
-                    throw new StorageProviderException(
-                    "Failed to resolve local datacenter: no connected hosts 
available");
-                }
-                datacenter = connectedHosts.iterator().next().getDatacenter();
+                datacenter = resolveLocalDatacenter();
             }
         }
         catch (CassandraUnavailableException e)
@@ -198,6 +193,23 @@ public class CassandraStorageProvider implements 
StorageProvider
         }
     }
 
+    /**
+     * Resolves this Sidecar's local datacenter from the managed instance's 
node settings
+     */
+    @NotNull
+    private String resolveLocalDatacenter()
+    {
+        NodeSettings nodeSettings = 
instanceMetadataFetcher.callOnFirstAvailableInstance(
+        instance -> instance.delegate().nodeSettings());
+        String nodeSettingsDatacenter = nodeSettings.datacenter();
+        if (nodeSettingsDatacenter == null || nodeSettingsDatacenter.isEmpty())
+        {
+            throw new StorageProviderException("Failed to resolve local 
datacenter for operational job coordination: "
+                                               + "node settings did not 
provide a datacenter");
+        }
+        return nodeSettingsDatacenter;
+    }
+
     @Override
     public boolean isAvailable()
     {
@@ -216,13 +228,13 @@ public class CassandraStorageProvider implements 
StorageProvider
     {
         if (clusterName == null || datacenter == null)
         {
-            throw new StorageProviderException("StorageProvider has not been 
initialized. Call initialize() first.");
+            initialize();
         }
         try
         {
             return action.get();
         }
-        catch (DriverException e)
+        catch (DriverException | CassandraUnavailableException e)
         {
             throw new StorageProviderException("Failed to execute " + 
operation, e);
         }
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/job/storage/OperationalJobRecord.java
 
b/server/src/main/java/org/apache/cassandra/sidecar/job/storage/OperationalJobRecord.java
index 42761634b..f66f8ca04 100644
--- 
a/server/src/main/java/org/apache/cassandra/sidecar/job/storage/OperationalJobRecord.java
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/job/storage/OperationalJobRecord.java
@@ -229,11 +229,22 @@ public class OperationalJobRecord implements 
OperationalJobInfo
      * @return a new record capturing the job's current state
      */
     public static OperationalJobRecord fromOperationalJob(OperationalJob job)
+    {
+        return fromOperationalJob(job, job.status(), job.startTime());
+    }
+
+    public static OperationalJobRecord fromOperationalJob(OperationalJob job,
+                                                          OperationalJobStatus 
status,
+                                                          @Nullable Instant 
startTime)
     {
         return builder().jobId(job.jobId())
                         .operationType(job.operationType())
-                        .status(job.status())
+                        .status(status)
+                        .startTime(startTime)
                         .lastUpdate(Instant.now())
+                        .failureReason(job.failureReason())
+                        .nodeExecutionOrder(job.nodeExecutionOrder())
+                        .operationMetadata(job.operationMetadata())
                         .build();
     }
 
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/job/storage/StorageProvider.java
 
b/server/src/main/java/org/apache/cassandra/sidecar/job/storage/StorageProvider.java
index 0f931bc5e..3309ef2d0 100644
--- 
a/server/src/main/java/org/apache/cassandra/sidecar/job/storage/StorageProvider.java
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/job/storage/StorageProvider.java
@@ -89,17 +89,18 @@ public interface StorageProvider extends Closeable
     // --- Active Operation Coordination ---
 
     /**
-     * Set an operation as active if no other operation of the same type is 
currently active.
+     * Set an operation as active if no other operation of the same type is 
currently active
+     * in the local datacenter.
      * <p>
      * Implementations must provide compare-and-set (CAS) semantics to ensure 
only one active
-     * operation of a given type runs at a time across the cluster. This lock 
is per operation
+     * operation of a given type runs at a time within the local datacenter. 
This lock is per operation
      * plan, not per node; within a single operation, multiple nodes may be 
operated on
      * concurrently as defined by the node execution order.
      *
      * @param operationType the operation type
      * @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 (including the 
same operation ID)
+     *         an operation of the same type is already active in the local 
datacenter
      */
     boolean trySetActiveOperation(OperationType operationType, UUID 
operationId);
 
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/modules/OperationalJobsModule.java
 
b/server/src/main/java/org/apache/cassandra/sidecar/modules/OperationalJobsModule.java
index 820e7c388..cfaa1d488 100644
--- 
a/server/src/main/java/org/apache/cassandra/sidecar/modules/OperationalJobsModule.java
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/modules/OperationalJobsModule.java
@@ -35,6 +35,7 @@ import 
org.apache.cassandra.sidecar.job.storage.CassandraStorageProvider;
 import org.apache.cassandra.sidecar.job.storage.StorageProvider;
 import org.apache.cassandra.sidecar.modules.multibindings.KeyClassMapKey;
 import org.apache.cassandra.sidecar.modules.multibindings.TableSchemaMapKeys;
+import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher;
 
 /**
  * Guice module for operational job storage, providing schema registrations
@@ -71,9 +72,10 @@ public class OperationalJobsModule extends AbstractModule
     StorageProvider cassandraStorageProvider(CQLSessionProvider 
sessionProvider,
                                             ClusterOpsDatabaseAccessor 
clusterOpsAccessor,
                                             
ClusterOpsNodeStateDatabaseAccessor nodeStateAccessor,
-                                            ActiveClusterOpsDatabaseAccessor 
activeOpsAccessor)
+                                            ActiveClusterOpsDatabaseAccessor 
activeOpsAccessor,
+                                            InstanceMetadataFetcher 
instanceMetadataFetcher)
     {
-        return new CassandraStorageProvider(sessionProvider,
-                                            clusterOpsAccessor, 
nodeStateAccessor, activeOpsAccessor, null);
+        return new CassandraStorageProvider(sessionProvider, 
clusterOpsAccessor, nodeStateAccessor,
+                                            activeOpsAccessor, 
instanceMetadataFetcher, null);
     }
 }
diff --git 
a/server/src/test/integration/org/apache/cassandra/sidecar/job/DurableOperationalJobTrackerIntegrationTest.java
 
b/server/src/test/integration/org/apache/cassandra/sidecar/job/DurableOperationalJobTrackerIntegrationTest.java
index 155161d9c..5b9a5c69c 100644
--- 
a/server/src/test/integration/org/apache/cassandra/sidecar/job/DurableOperationalJobTrackerIntegrationTest.java
+++ 
b/server/src/test/integration/org/apache/cassandra/sidecar/job/DurableOperationalJobTrackerIntegrationTest.java
@@ -47,7 +47,6 @@ class DurableOperationalJobTrackerIntegrationTest extends 
IntegrationTestBase
         waitForSchemaReady(10, TimeUnit.SECONDS);
 
         StorageProvider storageProvider = 
injector.getInstance(StorageProvider.class);
-        storageProvider.initialize();
 
         ExecutorPools executorPools = 
injector.getInstance(ExecutorPools.class);
         TaskExecutorPool executorPool = executorPools.internal();
diff --git 
a/server/src/test/java/org/apache/cassandra/sidecar/job/DurableOperationalJobTrackerTest.java
 
b/server/src/test/java/org/apache/cassandra/sidecar/job/DurableOperationalJobTrackerTest.java
index 58158a78b..f8acd3c3e 100644
--- 
a/server/src/test/java/org/apache/cassandra/sidecar/job/DurableOperationalJobTrackerTest.java
+++ 
b/server/src/test/java/org/apache/cassandra/sidecar/job/DurableOperationalJobTrackerTest.java
@@ -42,11 +42,13 @@ import 
org.apache.cassandra.sidecar.config.yaml.ServiceConfigurationImpl;
 import org.apache.cassandra.sidecar.job.storage.OperationalJobRecord;
 import org.apache.cassandra.sidecar.job.storage.StorageProvider;
 import org.apache.cassandra.sidecar.job.storage.StorageProviderException;
+import org.mockito.InOrder;
 
 import static 
org.apache.cassandra.sidecar.common.data.OperationalJobStatus.CREATED;
 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.sidecar.job.OperationalJobTest.createCoordinatedOperationalJob;
 import static 
org.apache.cassandra.sidecar.job.OperationalJobTest.createOperationalJob;
 import static org.apache.cassandra.testing.utils.AssertionUtils.loopAssert;
 import static org.assertj.core.api.Assertions.assertThat;
@@ -57,6 +59,7 @@ import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.isNull;
 import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
@@ -324,14 +327,31 @@ class DurableOperationalJobTrackerTest
     }
 
     @Test
-    void testComputeIfAbsentFailsWhenStorageUnavailable()
+    void 
testComputeIfAbsentDegradesForNonCoordinatedJobWhenStorageUnavailable()
     {
-        when(storageProvider.isAvailable()).thenReturn(false);
+        doThrow(new StorageProviderException("Storage 
unavailable")).when(storageProvider).persistJob(any());
         OperationalJob job = createOperationalJob(CREATED);
 
-        assertThatThrownBy(() -> tracker.computeIfAbsent(job.jobId(), id -> 
job))
-            .isInstanceOf(IllegalStateException.class)
-            .hasMessage("Storage provider is not available");
+        OperationalJob result = tracker.computeIfAbsent(job.jobId(), id -> 
job);
+
+        assertThat(result).isSameAs(job);
+        loopAssert(2, () -> verify(storageProvider, 
times(3)).persistJob(any()));
+        assertThat(tracker.jobsView()).containsKey(job.jobId());
+    }
+
+    @Test
+    void testFailedToStartCoordinatedJobIsNotSeededOrMarkedRunning()
+    {
+        UUID jobId = UUIDs.timeBased();
+        UUID node = UUIDs.timeBased();
+        OperationalJob job = createCoordinatedOperationalJob(jobId, 
List.of(List.of(node)));
+        job.failToStart(new RuntimeException("An active operation already 
exists"));
+
+        tracker.computeIfAbsent(jobId, id -> job);
+
+        loopAssert(2, () -> verify(storageProvider).persistJob(any()));
+        verify(storageProvider, never()).updateNodeStatuses(any(), any(), 
any());
+        verify(storageProvider, never()).updateJobStatus(any(), any(), 
eq(RUNNING), any());
     }
 
     @Test
@@ -439,6 +459,96 @@ class DurableOperationalJobTrackerTest
             .hasMessage("Node state unavailable");
     }
 
+    @Test
+    void 
testCoordinatedJobThatDoesNotReleaseOnCompletionSkipsTerminalStatusUpdate() 
throws InterruptedException
+    {
+        UUID jobId = UUIDs.timeBased();
+        OperationalJob job = createCoordinatedOperationalJob(jobId, 
List.of(List.of(UUIDs.timeBased())));
+        CountDownLatch latch = new CountDownLatch(1);
+
+        tracker.computeIfAbsent(jobId, id -> job);
+        executorPool.executeBlocking(job::execute);
+
+        job.asyncResult().onComplete(ar -> latch.countDown());
+        assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue();
+
+        loopAssert(2, () -> {
+            assertThat(tracker.jobsView()).doesNotContainKey(jobId);
+            verify(storageProvider, never()).updateJobStatus(eq(jobId), any(), 
eq(SUCCEEDED), any());
+        });
+    }
+
+    @Test
+    void testCoordinatedJobThatReleasesOnCompletionUpdatesTerminalStatus() 
throws InterruptedException
+    {
+        UUID jobId = UUIDs.timeBased();
+        OperationalJob job = createCoordinatedOperationalJob(jobId, 
MillisecondBoundConfiguration.parse("50ms"));
+        CountDownLatch latch = new CountDownLatch(1);
+
+        tracker.computeIfAbsent(jobId, id -> job);
+        executorPool.executeBlocking(job::execute);
+
+        job.asyncResult().onComplete(ar -> latch.countDown());
+        assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue();
+
+        loopAssert(2, () -> {
+            verify(storageProvider).updateJobStatus(eq(jobId), 
eq(OperationType.DRAIN), eq(SUCCEEDED), isNull());
+        });
+    }
+
+    @Test
+    void testNonCoordinatedJobStillUpdatesTerminalStatus() throws 
InterruptedException
+    {
+        UUID jobId = UUIDs.timeBased();
+        OperationalJob job = OperationalJobTest.createOperationalJob(jobId, 
MillisecondBoundConfiguration.parse("50ms"));
+        CountDownLatch latch = new CountDownLatch(1);
+
+        tracker.computeIfAbsent(jobId, id -> job);
+        executorPool.executeBlocking(job::execute);
+
+        job.asyncResult().onComplete(ar -> latch.countDown());
+        assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue();
+
+        loopAssert(2, () -> {
+            verify(storageProvider).updateJobStatus(eq(jobId), 
eq(OperationType.DRAIN), eq(SUCCEEDED), isNull());
+        });
+    }
+
+    @Test
+    void testCoordinatedJobSeedsNodeStatusesBeforePersistingRunningRecord()
+    {
+        UUID jobId = UUIDs.timeBased();
+        UUID node1 = UUIDs.timeBased();
+        UUID node2 = UUIDs.timeBased();
+        UUID node3 = UUIDs.timeBased();
+        OperationalJob job = createCoordinatedOperationalJob(jobId,
+                                                            
List.of(List.of(node1, node2), List.of(node3)));
+
+        tracker.computeIfAbsent(jobId, id -> job);
+
+        loopAssert(2, () -> verify(storageProvider).persistJob(any()));
+        InOrder inOrder = inOrder(storageProvider);
+        inOrder.verify(storageProvider).updateNodeStatuses(jobId, 
List.of(node1, node2, node3), CREATED);
+        inOrder.verify(storageProvider).persistJob(argThat(record -> 
record.status() == RUNNING
+                                                                     && 
record.startTime() != null));
+        verify(storageProvider, never()).updateJobStatus(any(), any(), any(), 
any());
+    }
+
+    @Test
+    void testNonCoordinatedJobDoesNotSeedNodeStatuses()
+    {
+        UUID jobId = UUIDs.timeBased();
+        OperationalJob job = createOperationalJob(jobId, 
MillisecondBoundConfiguration.parse("50ms"));
+
+        tracker.computeIfAbsent(jobId, id -> job);
+
+        loopAssert(2, () -> {
+            verify(storageProvider).persistJob(any());
+            verify(storageProvider, never()).updateNodeStatuses(any(), any(), 
any());
+            verify(storageProvider, never()).updateJobStatus(any(), any(), 
eq(RUNNING), any());
+        });
+    }
+
     @Test
     void testOnCompleteRetrySucceedsAfterTransientFailure() throws 
InterruptedException
     {
@@ -524,4 +634,131 @@ class DurableOperationalJobTrackerTest
             assertThat(tracker.jobsView()).doesNotContainKey(jobId);
         });
     }
+
+    @Test
+    void testInflightJobsMergesLiveJobsWithActiveStorageRecords()
+    {
+        UUID coordinatedJobId = UUIDs.timeBased();
+        OperationalJobRecord record = OperationalJobRecord.builder()
+                                                          
.jobId(coordinatedJobId)
+                                                          
.operationType(OperationType.DRAIN)
+                                                          .status(RUNNING)
+                                                          .build();
+        
when(storageProvider.getActiveOperations()).thenReturn(Map.of(OperationType.DRAIN,
 coordinatedJobId));
+        when(storageProvider.findJob(coordinatedJobId)).thenReturn(record);
+        
when(storageProvider.getNodeStatusesForOperation(coordinatedJobId)).thenReturn(Collections.emptyMap());
+
+        OperationalJob liveJob = createOperationalJob(CREATED);
+        tracker.computeIfAbsent(liveJob.jobId(), id -> liveJob);
+
+        List<OperationalJobInfo> inflight = tracker.inflightJobs();
+
+        assertThat(inflight).hasSize(2);
+        assertThat(inflight).extracting(OperationalJobInfo::jobId)
+                            .containsExactlyInAnyOrder(liveJob.jobId(), 
coordinatedJobId);
+    }
+
+    @Test
+    void testInflightJobsPrefersLiveJobAndSkipsStorageLookupForSameId()
+    {
+        UUID jobId = UUIDs.timeBased();
+        UUID node = UUIDs.timeBased();
+        
when(storageProvider.getActiveOperations()).thenReturn(Map.of(OperationType.DRAIN,
 jobId));
+        OperationalJob liveJob = createCoordinatedOperationalJob(jobId, 
List.of(List.of(node)));
+        tracker.computeIfAbsent(jobId, id -> liveJob);
+
+        List<OperationalJobInfo> inflight = tracker.inflightJobs();
+
+        assertThat(inflight).hasSize(1);
+        assertThat(inflight.get(0)).isSameAs(liveJob);
+        verify(storageProvider, never()).findJob(jobId);
+    }
+
+    @Test
+    void testInflightJobsEnrichesStorageRecordsWithNodeStatuses()
+    {
+        UUID coordinatedJobId = UUIDs.timeBased();
+        UUID pendingNode = UUIDs.timeBased();
+        UUID runningNode = UUIDs.timeBased();
+        OperationalJobRecord record = OperationalJobRecord.builder()
+                                                          
.jobId(coordinatedJobId)
+                                                          
.operationType(OperationType.DRAIN)
+                                                          .status(RUNNING)
+                                                          .build();
+        
when(storageProvider.getActiveOperations()).thenReturn(Map.of(OperationType.DRAIN,
 coordinatedJobId));
+        when(storageProvider.findJob(coordinatedJobId)).thenReturn(record);
+        Map<UUID, OperationalJobStatus> nodeStatuses = new HashMap<>();
+        nodeStatuses.put(pendingNode, CREATED);
+        nodeStatuses.put(runningNode, RUNNING);
+        
when(storageProvider.getNodeStatusesForOperation(coordinatedJobId)).thenReturn(nodeStatuses);
+
+        List<OperationalJobInfo> inflight = tracker.inflightJobs();
+
+        assertThat(inflight).hasSize(1);
+        
assertThat(inflight.get(0).nodesPending()).containsExactly(pendingNode);
+        
assertThat(inflight.get(0).nodesExecuting()).containsExactly(runningNode);
+    }
+
+    @Test
+    void testInflightJobsExcludesCompletedLiveJobs()
+    {
+        OperationalJob completedJob = createOperationalJob(SUCCEEDED);
+        tracker.computeIfAbsent(completedJob.jobId(), id -> completedJob);
+
+        List<OperationalJobInfo> inflight = tracker.inflightJobs();
+
+        assertThat(inflight).isEmpty();
+    }
+
+    @Test
+    void testInflightJobsSkipsActiveOperationMissingFromStorage()
+    {
+        UUID coordinatedJobId = UUIDs.timeBased();
+        
when(storageProvider.getActiveOperations()).thenReturn(Map.of(OperationType.DRAIN,
 coordinatedJobId));
+        when(storageProvider.findJob(coordinatedJobId)).thenReturn(null);
+
+        List<OperationalJobInfo> inflight = tracker.inflightJobs();
+
+        assertThat(inflight).isEmpty();
+        verify(storageProvider).findJob(coordinatedJobId);
+    }
+
+    @Test
+    void testInflightJobsReturnsLiveJobsWhenStorageUnavailable()
+    {
+        when(storageProvider.isAvailable()).thenReturn(false);
+        OperationalJob liveJob = createOperationalJob(CREATED);
+        tracker.computeIfAbsent(liveJob.jobId(), id -> liveJob);
+
+        List<OperationalJobInfo> inflight = tracker.inflightJobs();
+
+        assertThat(inflight).containsExactly(liveJob);
+        verify(storageProvider, never()).getActiveOperations();
+    }
+
+    @Test
+    void testInflightJobsReturnsLiveJobsWhenGetActiveOperationsFails()
+    {
+        when(storageProvider.getActiveOperations()).thenThrow(new 
StorageProviderException("read failed"));
+        OperationalJob liveJob = createOperationalJob(CREATED);
+        tracker.computeIfAbsent(liveJob.jobId(), id -> liveJob);
+
+        List<OperationalJobInfo> inflight = tracker.inflightJobs();
+
+        assertThat(inflight).containsExactly(liveJob);
+    }
+
+    @Test
+    void testInflightJobsReturnsLiveJobsWhenFindJobFails()
+    {
+        UUID coordinatedJobId = UUIDs.timeBased();
+        
when(storageProvider.getActiveOperations()).thenReturn(Map.of(OperationType.DRAIN,
 coordinatedJobId));
+        when(storageProvider.findJob(coordinatedJobId)).thenThrow(new 
StorageProviderException("read failed"));
+        OperationalJob liveJob = createOperationalJob(CREATED);
+        tracker.computeIfAbsent(liveJob.jobId(), id -> liveJob);
+
+        List<OperationalJobInfo> inflight = tracker.inflightJobs();
+
+        assertThat(inflight).containsExactly(liveJob);
+    }
 }
diff --git 
a/server/src/test/java/org/apache/cassandra/sidecar/job/InMemoryOperationalJobTrackerTest.java
 
b/server/src/test/java/org/apache/cassandra/sidecar/job/InMemoryOperationalJobTrackerTest.java
index cf5e5f590..59316302d 100644
--- 
a/server/src/test/java/org/apache/cassandra/sidecar/job/InMemoryOperationalJobTrackerTest.java
+++ 
b/server/src/test/java/org/apache/cassandra/sidecar/job/InMemoryOperationalJobTrackerTest.java
@@ -229,4 +229,21 @@ class InMemoryOperationalJobTrackerTest
 
         assertThat(inflightJobs).isEmpty();
     }
+
+    @Test
+    void testInflightJobsReturnsInflightJobsFromMap()
+    {
+        OperationalJob createdJob = createOperationalJob(CREATED);
+        OperationalJob runningJob = createOperationalJob(RUNNING);
+        jobTracker.put(createdJob);
+        jobTracker.put(runningJob);
+        jobTracker.put(job1);
+
+        List<OperationalJobInfo> inflightJobs = jobTracker.inflightJobs();
+
+        assertThat(inflightJobs)
+            .describedAs("Only CREATED and RUNNING jobs are inflight")
+            .extracting(OperationalJobInfo::jobId)
+            .containsExactlyInAnyOrder(createdJob.jobId(), runningJob.jobId());
+    }
 }
diff --git 
a/server/src/test/java/org/apache/cassandra/sidecar/job/LocalJobManagerTest.java
 
b/server/src/test/java/org/apache/cassandra/sidecar/job/LocalJobManagerTest.java
new file mode 100644
index 000000000..cb166dfa9
--- /dev/null
+++ 
b/server/src/test/java/org/apache/cassandra/sidecar/job/LocalJobManagerTest.java
@@ -0,0 +1,176 @@
+/*
+ * 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.Collection;
+import java.util.UUID;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.vertx.core.Future;
+import io.vertx.core.Vertx;
+import org.apache.cassandra.sidecar.TestResourceReaper;
+import org.apache.cassandra.sidecar.common.data.OperationType;
+import org.apache.cassandra.sidecar.common.data.OperationalJobStatus;
+import org.apache.cassandra.sidecar.concurrent.ExecutorPools;
+import org.apache.cassandra.sidecar.config.yaml.ServiceConfigurationImpl;
+
+import static org.apache.cassandra.testing.utils.AssertionUtils.loopAssert;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link LocalJobManager}
+ */
+class LocalJobManagerTest
+{
+    private static final UUID OPERATION_ID = UUID.randomUUID();
+    private static final UUID NODE_ID_1 = UUID.randomUUID();
+    private static final UUID NODE_ID_2 = UUID.randomUUID();
+
+    private Vertx vertx;
+    private ExecutorPools executorPools;
+    private LocalJobManager manager;
+
+    @BeforeEach
+    void setup()
+    {
+        vertx = Vertx.vertx();
+        executorPools = new ExecutorPools(vertx, new 
ServiceConfigurationImpl());
+        manager = new LocalJobManager(executorPools);
+    }
+
+    @AfterEach
+    void cleanup()
+    {
+        TestResourceReaper.create().with(vertx).with(executorPools).close();
+    }
+
+    @Test
+    void testSubmitJobExecutesAsync()
+    {
+        LocalJob job = createTestJob(NODE_ID_1);
+        Future<Void> result = manager.submitJob(job);
+        loopAssert(2, () -> {
+            assertThat(job.status()).isEqualTo(OperationalJobStatus.SUCCEEDED);
+            assertThat(result.succeeded()).isTrue();
+        });
+    }
+
+    @Test
+    void testSubmitDuplicateDoesNotReplaceExisting()
+    {
+        LocalJob first = createTestJob(NODE_ID_1);
+        LocalJob second = createTestJob(NODE_ID_1);
+
+        manager.submitJob(first);
+        Future<Void> duplicate = manager.submitJob(second);
+
+        assertThat(manager.getJob(OPERATION_ID, NODE_ID_1)).isSameAs(first);
+        assertThat(duplicate.succeeded()).isTrue();
+        assertThat(second.status()).isEqualTo(OperationalJobStatus.CREATED);
+    }
+
+    @Test
+    void testGetJobReturnsActiveJob()
+    {
+        LocalJob job = createTestJob(NODE_ID_1);
+        manager.submitJob(job);
+        assertThat(manager.getJob(OPERATION_ID, NODE_ID_1)).isSameAs(job);
+    }
+
+    @Test
+    void testGetJobReturnsNullWhenNone()
+    {
+        assertThat(manager.getJob(OPERATION_ID, NODE_ID_1)).isNull();
+    }
+
+    @Test
+    void testCompletedJobIsStillTrackedUntilRemoveJob()
+    {
+        LocalJob job = createTestJob(NODE_ID_1);
+        manager.submitJob(job);
+        loopAssert(2, () -> 
assertThat(job.status()).isEqualTo(OperationalJobStatus.SUCCEEDED));
+
+        assertThat(manager.getJob(OPERATION_ID, NODE_ID_1)).isSameAs(job);
+
+        assertThat(manager.removeJob(OPERATION_ID, NODE_ID_1, job)).isTrue();
+        assertThat(manager.getJob(OPERATION_ID, NODE_ID_1)).isNull();
+    }
+
+    @Test
+    void testRemoveJobWithDuplicateHandleKeepsTrackedJob()
+    {
+        LocalJob tracked = createTestJob(NODE_ID_1);
+        LocalJob duplicate = createTestJob(NODE_ID_1);
+        manager.submitJob(tracked);
+        manager.submitJob(duplicate);
+
+        assertThat(manager.removeJob(OPERATION_ID, NODE_ID_1, 
duplicate)).isFalse();
+        assertThat(manager.getJob(OPERATION_ID, NODE_ID_1)).isSameAs(tracked);
+    }
+
+    @Test
+    void testActiveJobsReturnsAllTracked()
+    {
+        LocalJob job1 = createTestJob(NODE_ID_1);
+        LocalJob job2 = createTestJob(NODE_ID_2);
+        manager.submitJob(job1);
+        manager.submitJob(job2);
+
+        Collection<LocalJob> active = manager.activeJobs();
+        assertThat(active).containsExactlyInAnyOrder(job1, job2);
+    }
+
+    @Test
+    void testSameNodeAcrossOperationsTrackedIndependently()
+    {
+        UUID otherOperationId = UUID.randomUUID();
+        LocalJob job1 = createTestJob(OPERATION_ID, NODE_ID_1);
+        LocalJob job2 = createTestJob(otherOperationId, NODE_ID_1);
+
+        manager.submitJob(job1);
+        manager.submitJob(job2);
+
+        assertThat(manager.getJob(OPERATION_ID, NODE_ID_1)).isSameAs(job1);
+        assertThat(manager.getJob(otherOperationId, NODE_ID_1)).isSameAs(job2);
+        assertThat(manager.activeJobs()).containsExactlyInAnyOrder(job1, job2);
+
+        assertThat(manager.removeJob(OPERATION_ID, NODE_ID_1, job1)).isTrue();
+        assertThat(manager.getJob(OPERATION_ID, NODE_ID_1)).isNull();
+        assertThat(manager.getJob(otherOperationId, NODE_ID_1)).isSameAs(job2);
+    }
+
+    private static LocalJob createTestJob(UUID nodeId)
+    {
+        return createTestJob(OPERATION_ID, nodeId);
+    }
+
+    private static LocalJob createTestJob(UUID operationId, UUID nodeId)
+    {
+        return new LocalJob(operationId, nodeId, "127.0.0.1", 
OperationType.DRAIN)
+        {
+            @Override
+            protected void executeInternal()
+            {
+            }
+        };
+    }
+}
diff --git 
a/server/src/test/java/org/apache/cassandra/sidecar/job/LocalJobTest.java 
b/server/src/test/java/org/apache/cassandra/sidecar/job/LocalJobTest.java
new file mode 100644
index 000000000..6e0457cf7
--- /dev/null
+++ b/server/src/test/java/org/apache/cassandra/sidecar/job/LocalJobTest.java
@@ -0,0 +1,94 @@
+/*
+ * 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 org.apache.cassandra.sidecar.common.data.OperationType;
+import org.apache.cassandra.sidecar.common.data.OperationalJobStatus;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link LocalJob}
+ */
+class LocalJobTest
+{
+    private static final UUID OPERATION_ID = UUID.randomUUID();
+    private static final UUID NODE_ID = UUID.randomUUID();
+    private static final String INSTANCE_HOST = "127.0.0.1";
+    private static final OperationType OPERATION_TYPE = OperationType.DRAIN;
+
+    @Test
+    void testStatusTransitionsOnSuccess()
+    {
+        LocalJob job = createSucceedingJob();
+        assertThat(job.status()).isEqualTo(OperationalJobStatus.CREATED);
+
+        job.execute();
+        assertThat(job.status()).isEqualTo(OperationalJobStatus.SUCCEEDED);
+    }
+
+    @Test
+    void testStatusTransitionsOnFailure()
+    {
+        LocalJob job = createFailingJob();
+        assertThat(job.status()).isEqualTo(OperationalJobStatus.CREATED);
+
+        assertThatThrownBy(job::execute).isInstanceOf(RuntimeException.class);
+        assertThat(job.status()).isEqualTo(OperationalJobStatus.FAILED);
+    }
+
+    @Test
+    void testCancelDoesNotChangeStatus()
+    {
+        LocalJob job = createSucceedingJob();
+
+        job.cancel();
+
+        assertThat(job.isCancelled()).isTrue();
+        assertThat(job.status()).isEqualTo(OperationalJobStatus.CREATED);
+    }
+
+    private static LocalJob createSucceedingJob()
+    {
+        return new LocalJob(OPERATION_ID, NODE_ID, INSTANCE_HOST, 
OPERATION_TYPE)
+        {
+            @Override
+            protected void executeInternal()
+            {
+            }
+        };
+    }
+
+    private static LocalJob createFailingJob()
+    {
+        return new LocalJob(OPERATION_ID, NODE_ID, INSTANCE_HOST, 
OPERATION_TYPE)
+        {
+            @Override
+            protected void executeInternal()
+            {
+                throw new RuntimeException("test failure");
+            }
+        };
+    }
+}
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 797743927..f652db475 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
@@ -326,6 +326,18 @@ class OperationalJobManagerTest
         verify(coordinator, never()).trySetActive(any(), any());
     }
 
+    @Test
+    void testAllInflightJobsDelegatesToTracker()
+    {
+        OperationalJobTracker tracker = mock(OperationalJobTracker.class);
+        List<OperationalJobInfo> inflight = 
List.of(mock(OperationalJobInfo.class));
+        when(tracker.inflightJobs()).thenReturn(inflight);
+        OperationalJobManager manager = new OperationalJobManager(tracker, new 
DisabledOperationalJobCoordinator(), executorPool);
+
+        assertThat(manager.allInflightJobs()).isEqualTo(inflight);
+        verify(tracker).inflightJobs();
+    }
+
     private static OperationalJob createCoordinatedJob(UUID jobId)
     {
         return new OperationalJob(jobId)
diff --git 
a/server/src/test/java/org/apache/cassandra/sidecar/job/OperationalJobTest.java 
b/server/src/test/java/org/apache/cassandra/sidecar/job/OperationalJobTest.java
index 485a64819..31f603c3d 100644
--- 
a/server/src/test/java/org/apache/cassandra/sidecar/job/OperationalJobTest.java
+++ 
b/server/src/test/java/org/apache/cassandra/sidecar/job/OperationalJobTest.java
@@ -129,6 +129,104 @@ class OperationalJobTest
         };
     }
 
+    public static OperationalJob createCoordinatedOperationalJob(UUID jobId, 
DurationSpec jobDuration)
+    {
+        return createCoordinatedOperationalJob(jobId, jobDuration, null);
+    }
+
+    public static OperationalJob createCoordinatedOperationalJob(UUID jobId, 
DurationSpec jobDuration, OperationalJobException jobFailure)
+    {
+        return new OperationalJob(jobId)
+        {
+            @Override
+            public boolean hasConflict(List<OperationalJob> jobs)
+            {
+                return false;
+            }
+
+            @Override
+            public OperationType operationType()
+            {
+                return OperationType.DRAIN;
+            }
+
+            @Override
+            public boolean requiresCoordination()
+            {
+                return true;
+            }
+
+            @Override
+            protected Future<Void> executeInternal() throws 
OperationalJobException
+            {
+                if (jobDuration != null)
+                {
+                    
Uninterruptibles.sleepUninterruptibly(jobDuration.quantity(), 
jobDuration.unit());
+                }
+
+                if (jobFailure != null)
+                {
+                    throw jobFailure;
+                }
+                return Future.succeededFuture();
+            }
+
+            @Override
+            public String name()
+            {
+                return "Coordinated Operation";
+            }
+        };
+    }
+
+    public static OperationalJob createCoordinatedOperationalJob(UUID jobId, 
List<List<UUID>> nodeExecutionOrder)
+    {
+        return new OperationalJob(jobId)
+        {
+            @Override
+            public boolean hasConflict(List<OperationalJob> jobs)
+            {
+                return false;
+            }
+
+            @Override
+            public OperationType operationType()
+            {
+                return OperationType.DRAIN;
+            }
+
+            @Override
+            public boolean requiresCoordination()
+            {
+                return true;
+            }
+
+            @Override
+            public boolean releasesOnCompletion()
+            {
+                return false;
+            }
+
+            @Override
+            public List<List<UUID>> nodeExecutionOrder()
+            {
+                return nodeExecutionOrder;
+            }
+
+            @Override
+            protected Future<Void> executeInternal()
+            {
+                return Future.succeededFuture();
+            }
+
+            @Override
+            public String name()
+            {
+                return "Coordinated Operation";
+            }
+        };
+    }
+
     public static OperationalJob createOperationalJob(UUID jobId, DurationSpec 
jobDuration)
     {
         return createOperationalJob(jobId, jobDuration, null);
diff --git 
a/server/src/test/java/org/apache/cassandra/sidecar/job/storage/CassandraStorageProviderTest.java
 
b/server/src/test/java/org/apache/cassandra/sidecar/job/storage/CassandraStorageProviderTest.java
new file mode 100644
index 000000000..96002efaa
--- /dev/null
+++ 
b/server/src/test/java/org/apache/cassandra/sidecar/job/storage/CassandraStorageProviderTest.java
@@ -0,0 +1,117 @@
+/*
+ * 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.storage;
+
+import java.util.UUID;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import com.datastax.driver.core.utils.UUIDs;
+import org.apache.cassandra.sidecar.common.data.OperationType;
+import org.apache.cassandra.sidecar.common.response.NodeSettings;
+import org.apache.cassandra.sidecar.common.server.CQLSessionProvider;
+import org.apache.cassandra.sidecar.db.ActiveClusterOpsDatabaseAccessor;
+import org.apache.cassandra.sidecar.db.ClusterOpsDatabaseAccessor;
+import org.apache.cassandra.sidecar.db.ClusterOpsNodeStateDatabaseAccessor;
+import org.apache.cassandra.sidecar.exceptions.CassandraUnavailableException;
+import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher;
+
+import static 
org.apache.cassandra.sidecar.exceptions.CassandraUnavailableException.Service.CQL;
+import static 
org.apache.cassandra.sidecar.exceptions.CassandraUnavailableException.Service.CQL_AND_JMX;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests for {@link CassandraStorageProvider}
+ */
+class CassandraStorageProviderTest
+{
+    private static final String CLUSTER_NAME = "test-cluster";
+    private static final UUID OPERATION_ID = UUIDs.timeBased();
+
+    private ActiveClusterOpsDatabaseAccessor activeOpsAccessor;
+    private InstanceMetadataFetcher instanceMetadataFetcher;
+    private CassandraStorageProvider provider;
+
+    @BeforeEach
+    void setup()
+    {
+        activeOpsAccessor = mock(ActiveClusterOpsDatabaseAccessor.class);
+        instanceMetadataFetcher = mock(InstanceMetadataFetcher.class);
+        provider = new CassandraStorageProvider(mock(CQLSessionProvider.class),
+                                                
mock(ClusterOpsDatabaseAccessor.class),
+                                                
mock(ClusterOpsNodeStateDatabaseAccessor.class),
+                                                activeOpsAccessor,
+                                                instanceMetadataFetcher,
+                                                CLUSTER_NAME);
+    }
+
+    @Test
+    void testResolvesDatacenterFromNodeSettings()
+    {
+        
when(instanceMetadataFetcher.<NodeSettings>callOnFirstAvailableInstance(any()))
+        .thenReturn(NodeSettings.builder().datacenter("dc-from-node").build());
+
+        provider.trySetActiveOperation(OperationType.DRAIN, OPERATION_ID);
+
+        verify(activeOpsAccessor).trySetActiveOperation(eq(CLUSTER_NAME), 
eq("dc-from-node"),
+                                                        
eq(OperationType.DRAIN), eq(OPERATION_ID));
+    }
+
+    @Test
+    void testThrowsWhenNodeSettingsUnavailable()
+    {
+        
when(instanceMetadataFetcher.<NodeSettings>callOnFirstAvailableInstance(any()))
+        .thenThrow(new CassandraUnavailableException(CQL_AND_JMX, "node 
settings unavailable"));
+
+        assertThatThrownBy(() -> 
provider.trySetActiveOperation(OperationType.DRAIN, OPERATION_ID))
+        .isInstanceOf(StorageProviderException.class)
+        .hasMessageContaining("Failed to initialize storage provider");
+    }
+
+    @Test
+    void testThrowsWhenNodeSettingsDatacenterMissing()
+    {
+        
when(instanceMetadataFetcher.<NodeSettings>callOnFirstAvailableInstance(any()))
+        .thenReturn(NodeSettings.builder().build());
+
+        assertThatThrownBy(() -> 
provider.trySetActiveOperation(OperationType.DRAIN, OPERATION_ID))
+        .isInstanceOf(StorageProviderException.class)
+        .hasMessageContaining("Failed to resolve local datacenter");
+    }
+
+    @Test
+    void testWrapsCassandraUnavailableFromAccessor()
+    {
+        
when(instanceMetadataFetcher.<NodeSettings>callOnFirstAvailableInstance(any()))
+        .thenReturn(NodeSettings.builder().datacenter("dc-from-node").build());
+        when(activeOpsAccessor.getActiveOperations(CLUSTER_NAME, 
"dc-from-node"))
+        .thenThrow(new CassandraUnavailableException(CQL, "session 
unavailable"));
+
+        assertThatThrownBy(() -> provider.getActiveOperations())
+        .isInstanceOf(StorageProviderException.class)
+        .hasMessageContaining("Failed to execute getActiveOperations")
+        .hasCauseInstanceOf(CassandraUnavailableException.class);
+    }
+}


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

Reply via email to