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

frankgh 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 951d091a CASSSIDECAR-374: Implement durable operational job tracker 
(#359)
951d091a is described below

commit 951d091ae662e04d542981dd16b11d835726180f
Author: Andrés Beck-Ruiz <[email protected]>
AuthorDate: Mon Jul 13 23:17:12 2026 -0400

    CASSSIDECAR-374: Implement durable operational job tracker (#359)
    
    Patch by Andrés Beck-Ruiz; reviewed by Paulo Motta, Shailaja Koppu, Arjun 
Ashok, Francisco Guerrero for CASSSIDECAR-374
---
 CHANGES.txt                                        |   1 +
 .../sidecar/db/ClusterOpsDatabaseAccessor.java     |  15 +-
 .../sidecar/job/DurableOperationalJobTracker.java  | 270 +++++++++++
 .../cassandra/sidecar/job/NodeDecommissionJob.java |  10 +
 .../apache/cassandra/sidecar/job/NodeDrainJob.java |  10 +
 .../apache/cassandra/sidecar/job/NodeMoveJob.java  |  10 +
 .../cassandra/sidecar/job/OperationalJob.java      |   7 +
 .../cassandra/sidecar/job/OperationalJobInfo.java  |   6 +
 .../sidecar/job/OperationalJobManager.java         |   9 +-
 .../apache/cassandra/sidecar/job/RepairJob.java    |  10 +
 .../sidecar/job/storage/OperationalJobRecord.java  | 252 ++++++++--
 .../sidecar/utils/InvocationTrackingFunction.java  |  50 ++
 .../ClusterOpsDatabaseAccessorIntegrationTest.java |  33 +-
 ...urableOperationalJobTrackerIntegrationTest.java | 108 +++++
 .../handlers/ListOperationalJobsHandlerTest.java   |   7 +
 .../job/DurableOperationalJobTrackerTest.java      | 527 +++++++++++++++++++++
 .../sidecar/job/OperationalJobInfoTest.java        |  19 +
 .../sidecar/job/OperationalJobManagerTest.java     |  41 ++
 .../cassandra/sidecar/job/OperationalJobTest.java  |  25 +
 19 files changed, 1354 insertions(+), 56 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 07239ad9..265a4a99 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,6 @@
 0.5.0
 -----
+ * Implement durable operational job tracker (CASSSIDECAR-374)
  * Remove filesystem path from Http response (CASSSIDECAR-477)
  * Add basic configuration retrieval logic to ConfigurationManager 
(CASSSIDECAR-427)
  * RPM package is created with wrong version (CASSSIDECAR-472)
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/db/ClusterOpsDatabaseAccessor.java
 
b/server/src/main/java/org/apache/cassandra/sidecar/db/ClusterOpsDatabaseAccessor.java
index eea6f769..9fb2ec4f 100644
--- 
a/server/src/main/java/org/apache/cassandra/sidecar/db/ClusterOpsDatabaseAccessor.java
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/db/ClusterOpsDatabaseAccessor.java
@@ -166,12 +166,19 @@ public class ClusterOpsDatabaseAccessor extends 
DatabaseAccessor<ClusterOpsSchem
             nodeExecutionOrder = null;
         }
         Map<String, String> operationMetadata = 
row.getMap("operation_metadata", String.class, String.class);
-        if (operationMetadata.isEmpty())
+        if (operationMetadata != null && operationMetadata.isEmpty())
         {
             operationMetadata = null;
         }
-        return new OperationalJobRecord(operationId, operationType, status,
-                                        startTime, lastUpdate, failureReason,
-                                        nodeExecutionOrder, operationMetadata);
+        return OperationalJobRecord.builder()
+                                   .jobId(operationId)
+                                   .operationType(operationType)
+                                   .status(status)
+                                   .startTime(startTime)
+                                   .lastUpdate(lastUpdate)
+                                   .failureReason(failureReason)
+                                   .nodeExecutionOrder(nodeExecutionOrder)
+                                   .operationMetadata(operationMetadata)
+                                   .build();
     }
 }
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
new file mode 100644
index 00000000..d5aa8622
--- /dev/null
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/job/DurableOperationalJobTracker.java
@@ -0,0 +1,270 @@
+/*
+ * 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.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.apache.cassandra.sidecar.common.data.OperationalJobStatus;
+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.utils.InvocationTrackingFunction;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * A durable implementation of {@link OperationalJobTracker} that persists job 
state
+ * via a {@link StorageProvider}. A local {@link ConcurrentHashMap} caches live
+ * {@link OperationalJob} references for the current process, since executing 
jobs
+ * with Vert.x promises cannot be reconstituted from storage. Once a job 
completes,
+ * it is removed from the local map, and subsequent lookups are served from 
storage.
+ *
+ * <p>Storage currently receives only two writes per job: the initial
+ * {@link OperationalJobStatus#CREATED CREATED} record on submission and the 
terminal status on
+ * completion. As a result, if the sidecar restarts mid-operation, or if the
+ * terminal-status update exhausts its retries (see {@link 
#updateTerminalStatus}), the persisted
+ * record can remain stuck at {@code CREATED} even though the operation has 
since progressed or
+ * finished. There is currently no marker distinguishing a record that is 
genuinely still
+ * {@code CREATED} from one whose true state was simply never recorded; adding 
such a marker together
+ * with a reconciliation sweep (leveraging {@link 
StorageProvider#findAllJobs(int)}) is tracked as
+ * follow-up work in
+ * <a 
href="https://issues.apache.org/jira/browse/CASSSIDECAR-482";>CASSSIDECAR-482</a>.
+ */
+@Singleton
+public class DurableOperationalJobTracker implements OperationalJobTracker
+{
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(DurableOperationalJobTracker.class);
+    private static final int MAX_STORAGE_WRITE_ATTEMPTS = 3;
+    private static final long RETRY_DELAY_MS = 100;
+    private static final long RETRY_JITTER_MS = 100;
+
+    private final ConcurrentHashMap<UUID, OperationalJob> liveJobs;
+    private final StorageProvider storageProvider;
+    private final TaskExecutorPool executor;
+
+    @Inject
+    public DurableOperationalJobTracker(ServiceConfiguration 
serviceConfiguration,
+                                        StorageProvider storageProvider,
+                                        TaskExecutorPool executor)
+    {
+        this.liveJobs = new 
ConcurrentHashMap<>(serviceConfiguration.operationalJobTrackerSize());
+        this.storageProvider = storageProvider;
+        this.executor = executor;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @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);
+        }
+
+        return job;
+    }
+
+    private void persistInitialRecord(OperationalJob job, int attempt)
+    {
+        executor.runBlocking(() -> 
storageProvider.persistJob(OperationalJobRecord.fromOperationalJob(job)))
+                .onSuccess(v -> job.asyncResult().onComplete(ar -> {
+                    updateTerminalStatus(job);
+                    liveJobs.remove(job.jobId());
+                }))
+                .onFailure(e -> {
+                    LOGGER.warn("Failed to persist job {} to storage (attempt 
{}/{}). error={}",
+                                job.jobId(), attempt, 
MAX_STORAGE_WRITE_ATTEMPTS, e.getMessage());
+                    if (attempt < MAX_STORAGE_WRITE_ATTEMPTS)
+                    {
+                        // 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));
+                    }
+                    else
+                    {
+                        // Persist exhausted its retries, but the job is 
already executing on a separate executor.
+                        // Keep it in liveJobs so in-process status queries 
and conflict detection still see it.
+                        LOGGER.error("Failed to persist job {} to storage 
after {} attempts. "
+                                     + "Job will be tracked in-memory only.", 
job.jobId(), MAX_STORAGE_WRITE_ATTEMPTS, e);
+                        job.asyncResult().onComplete(ar -> 
liveJobs.remove(job.jobId()));
+                    }
+                });
+    }
+
+    @Nullable
+    @Override
+    public OperationalJobInfo get(UUID jobId)
+    {
+        OperationalJob liveJob = liveJobs.get(jobId);
+        if (liveJob != null)
+        {
+            return liveJob;
+        }
+
+        OperationalJobRecord record = storageProvider.findJob(jobId);
+        if (record == null)
+        {
+            return null;
+        }
+        return enrichWithNodeStatuses(record);
+    }
+
+    @NotNull
+    @Override
+    public Map<UUID, OperationalJob> jobsView()
+    {
+        return Collections.unmodifiableMap(liveJobs);
+    }
+
+    @NotNull
+    @Override
+    public List<OperationalJob> inflightJobsByOperation(String operation)
+    {
+        return liveJobs.values()
+                       .stream()
+                       .filter(j -> j.name().equals(operation) &&
+                                    (j.status() == 
OperationalJobStatus.RUNNING ||
+                                     j.status() == 
OperationalJobStatus.CREATED))
+                       .collect(Collectors.toList());
+    }
+
+    /**
+     * 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
+     * that joins the data in a single query), the record is returned as is.
+     */
+    private OperationalJobRecord enrichWithNodeStatuses(OperationalJobRecord 
record)
+    {
+        if (!record.nodesPending().isEmpty()
+            || !record.nodesExecuting().isEmpty()
+            || !record.nodesSucceeded().isEmpty()
+            || !record.nodesFailed().isEmpty())
+        {
+            return record;
+        }
+
+        Map<UUID, OperationalJobStatus> nodeStatuses =
+        storageProvider.getNodeStatusesForOperation(record.jobId());
+        if (nodeStatuses.isEmpty())
+        {
+            return record;
+        }
+
+        List<UUID> pending = new ArrayList<>();
+        List<UUID> executing = new ArrayList<>();
+        List<UUID> succeeded = new ArrayList<>();
+        List<UUID> failed = new ArrayList<>();
+
+        for (Map.Entry<UUID, OperationalJobStatus> entry : 
nodeStatuses.entrySet())
+        {
+            switch (entry.getValue())
+            {
+                case CREATED:
+                    pending.add(entry.getKey());
+                    break;
+                case RUNNING:
+                    executing.add(entry.getKey());
+                    break;
+                case SUCCEEDED:
+                    succeeded.add(entry.getKey());
+                    break;
+                case FAILED:
+                    failed.add(entry.getKey());
+                    break;
+                default:
+                    throw new IllegalStateException("Invalid state = " + 
entry.getValue());
+            }
+        }
+
+        return OperationalJobRecord.builder()
+                                   .jobId(record.jobId())
+                                   .operationType(record.operationType())
+                                   .status(record.status())
+                                   .startTime(record.startTime())
+                                   .lastUpdate(record.lastUpdate())
+                                   .failureReason(record.failureReason())
+                                   
.nodeExecutionOrder(record.nodeExecutionOrder())
+                                   
.operationMetadata(record.operationMetadata())
+                                   
.nodesPending(Collections.unmodifiableList(pending))
+                                   
.nodesExecuting(Collections.unmodifiableList(executing))
+                                   
.nodesSucceeded(Collections.unmodifiableList(succeeded))
+                                   
.nodesFailed(Collections.unmodifiableList(failed))
+                                   .build();
+    }
+
+    /**
+     * Attempts to update the terminal status in storage with retry.
+     * If all attempts fail, logs a warning and continues.
+     */
+    private void updateTerminalStatus(OperationalJob job)
+    {
+        updateTerminalStatus(job, 1);
+    }
+
+    private void updateTerminalStatus(OperationalJob job, int attempt)
+    {
+        try
+        {
+            storageProvider.updateJobStatus(job.jobId(), job.operationType(), 
job.status(), job.failureReason());
+        }
+        catch (RuntimeException e)
+        {
+            LOGGER.warn("Failed to update terminal status for job {} (attempt 
{}/{}). error={}",
+                        job.jobId(), attempt, MAX_STORAGE_WRITE_ATTEMPTS, 
e.getMessage());
+            if (attempt < MAX_STORAGE_WRITE_ATTEMPTS)
+            {
+                // 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 -> updateTerminalStatus(job, 
attempt + 1));
+            }
+            else
+            {
+                LOGGER.error("Exhausted retries when updating terminal status 
for job {}. " +
+                             "Manual intervention may be required to correct 
job metadata.", job.jobId(), e);
+            }
+        }
+    }
+}
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/job/NodeDecommissionJob.java
 
b/server/src/main/java/org/apache/cassandra/sidecar/job/NodeDecommissionJob.java
index e042200f..c67026ac 100644
--- 
a/server/src/main/java/org/apache/cassandra/sidecar/job/NodeDecommissionJob.java
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/job/NodeDecommissionJob.java
@@ -25,6 +25,7 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import io.vertx.core.Future;
+import org.apache.cassandra.sidecar.common.data.OperationType;
 import org.apache.cassandra.sidecar.common.data.OperationalJobStatus;
 import org.apache.cassandra.sidecar.common.server.StorageOperations;
 import org.jetbrains.annotations.NotNull;
@@ -103,6 +104,15 @@ public class NodeDecommissionJob extends OperationalJob
         return Future.succeededFuture();
     }
 
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public OperationType operationType()
+    {
+        return OperationType.DECOMMISSION;
+    }
+
     /**
      * {@inheritDoc}
      */
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/job/NodeDrainJob.java 
b/server/src/main/java/org/apache/cassandra/sidecar/job/NodeDrainJob.java
index b5b1db92..52d44bfa 100644
--- a/server/src/main/java/org/apache/cassandra/sidecar/job/NodeDrainJob.java
+++ b/server/src/main/java/org/apache/cassandra/sidecar/job/NodeDrainJob.java
@@ -27,6 +27,7 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import io.vertx.core.Future;
+import org.apache.cassandra.sidecar.common.data.OperationType;
 import org.apache.cassandra.sidecar.common.data.OperationalJobStatus;
 import org.apache.cassandra.sidecar.common.server.StorageOperations;
 import 
org.apache.cassandra.sidecar.common.server.exceptions.OperationalJobException;
@@ -136,6 +137,15 @@ public class NodeDrainJob extends OperationalJob
         return Future.succeededFuture();
     }
 
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public OperationType operationType()
+    {
+        return OperationType.DRAIN;
+    }
+
     /**
      * {@inheritDoc}
      */
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/job/NodeMoveJob.java 
b/server/src/main/java/org/apache/cassandra/sidecar/job/NodeMoveJob.java
index c35f13ef..5fa04f30 100644
--- a/server/src/main/java/org/apache/cassandra/sidecar/job/NodeMoveJob.java
+++ b/server/src/main/java/org/apache/cassandra/sidecar/job/NodeMoveJob.java
@@ -26,6 +26,7 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import io.vertx.core.Future;
+import org.apache.cassandra.sidecar.common.data.OperationType;
 import org.apache.cassandra.sidecar.common.server.StorageOperations;
 import 
org.apache.cassandra.sidecar.common.server.exceptions.OperationalJobException;
 import org.jetbrains.annotations.NotNull;
@@ -83,6 +84,15 @@ public class NodeMoveJob extends OperationalJob
         return Future.succeededFuture();
     }
 
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public OperationType operationType()
+    {
+        return OperationType.MOVE;
+    }
+
     /**
      * {@inheritDoc}
      */
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 58320e4e..f3c15c6a 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
@@ -30,6 +30,7 @@ import org.slf4j.LoggerFactory;
 import com.datastax.driver.core.utils.UUIDs;
 import io.vertx.core.Future;
 import io.vertx.core.Promise;
+import org.apache.cassandra.sidecar.common.data.OperationType;
 import org.apache.cassandra.sidecar.common.data.OperationalJobStatus;
 import 
org.apache.cassandra.sidecar.common.server.exceptions.OperationalJobException;
 import org.apache.cassandra.sidecar.common.server.utils.DurationSpec;
@@ -239,6 +240,12 @@ public abstract class OperationalJob implements 
Task<Void>, OperationalJobInfo
         return simpleName.isEmpty() ? this.getClass().getName() : simpleName;
     }
 
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public abstract OperationType operationType();
+
     /**
      * Determines the status of the job. OperationalJob subclasses could 
choose to override the method.
      * <p>
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJobInfo.java 
b/server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJobInfo.java
index 1748e3db..a6fc8a95 100644
--- 
a/server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJobInfo.java
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJobInfo.java
@@ -22,6 +22,7 @@ import java.time.Instant;
 import java.util.List;
 import java.util.UUID;
 
+import org.apache.cassandra.sidecar.common.data.OperationType;
 import org.apache.cassandra.sidecar.common.data.OperationalJobStatus;
 import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
@@ -48,6 +49,11 @@ public interface OperationalJobInfo
      */
     String name();
 
+    /**
+     * @return the {@link OperationType} of this job
+     */
+    OperationType operationType();
+
     /**
      * @return the current status of the job
      */
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 ccaed374..a1e4d406 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
@@ -100,11 +100,12 @@ public class OperationalJobManager
         {
             checkConflict(job);
 
-            // New job is submitted for all cases when we do not have a 
corresponding downstream job
-            jobTracker.computeIfAbsent(job.jobId(), jobId -> {
+            // Track the job first, then start execution separately
+            OperationalJob tracked = jobTracker.computeIfAbsent(job.jobId(), 
jobId -> job);
+            if (tracked == job)
+            {
                 internalExecutorPool.executeBlocking(job::execute);
-                return job;
-            });
+            }
         }
         catch (OperationalJobConflictException oje)
         {
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/job/RepairJob.java 
b/server/src/main/java/org/apache/cassandra/sidecar/job/RepairJob.java
index a9fe4ccb..156fa781 100644
--- a/server/src/main/java/org/apache/cassandra/sidecar/job/RepairJob.java
+++ b/server/src/main/java/org/apache/cassandra/sidecar/job/RepairJob.java
@@ -31,6 +31,7 @@ import org.slf4j.LoggerFactory;
 import io.vertx.core.Future;
 import io.vertx.core.Promise;
 import org.apache.cassandra.sidecar.adapters.base.RepairOptions;
+import org.apache.cassandra.sidecar.common.data.OperationType;
 import org.apache.cassandra.sidecar.common.data.OperationalJobStatus;
 import org.apache.cassandra.sidecar.common.request.data.RepairPayload;
 import org.apache.cassandra.sidecar.common.server.StorageOperations;
@@ -184,6 +185,15 @@ public class RepairJob extends OperationalJob
         return currentStatus != null ? currentStatus : super.status();
     }
 
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public OperationType operationType()
+    {
+        return OperationType.REPAIR;
+    }
+
     /**
      * {@inheritDoc}
      */
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 7e3d508a..42761634 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
@@ -19,21 +19,29 @@
 package org.apache.cassandra.sidecar.job.storage;
 
 import java.time.Instant;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 
+import java.util.Objects;
 import java.util.UUID;
 
 import com.datastax.driver.core.utils.UUIDs;
+import org.apache.cassandra.sidecar.common.DataObjectBuilder;
 import org.apache.cassandra.sidecar.common.data.OperationType;
 import org.apache.cassandra.sidecar.common.data.OperationalJobStatus;
 import org.apache.cassandra.sidecar.common.utils.Preconditions;
+import org.apache.cassandra.sidecar.job.OperationalJob;
+import org.apache.cassandra.sidecar.job.OperationalJobInfo;
+import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 /**
  * A data transfer object representing the persisted state of an operational 
job.
+ * Implements {@link OperationalJobInfo} so that completed jobs stored in a 
persistent storage
+ * can be returned directly through the tracker without requiring a live 
{@link OperationalJob}.
  */
-public class OperationalJobRecord
+public class OperationalJobRecord implements OperationalJobInfo
 {
     private final UUID jobId;
     private final OperationType operationType;
@@ -49,51 +57,39 @@ public class OperationalJobRecord
     private final List<List<UUID>> nodeExecutionOrder;
     @Nullable
     private final Map<String, String> operationMetadata;
+    @NotNull
+    private final List<UUID> nodesPending;
+    @NotNull
+    private final List<UUID> nodesExecuting;
+    @NotNull
+    private final List<UUID> nodesSucceeded;
+    @NotNull
+    private final List<UUID> nodesFailed;
 
     /**
-     * Constructs an OperationalJobRecord with the given fields.
+     * Constructs an OperationalJobRecord from its {@link Builder}.
      *
-     * @param jobId         time-based v1 UUID identifying the job
-     * @param operationType the operation type
-     * @param status        the current status of the job
+     * @param builder the builder holding the field values
      */
-    public OperationalJobRecord(UUID jobId, OperationType operationType, 
OperationalJobStatus status)
+    private OperationalJobRecord(Builder builder)
     {
-        this(jobId, operationType, status, null, Instant.now(), null, null, 
null);
-    }
-
-    /**
-     * Constructs an OperationalJobRecord with all fields.
-     *
-     * @param jobId             time-based v1 UUID identifying the job
-     * @param operationType     the operation type
-     * @param status            the current status of the job
-     * @param startTime         the timestamp when execution started, or null 
if not yet started
-     * @param lastUpdate        the timestamp of the last status update, or 
null for pre-existing rows
-     * @param failureReason     the failure reason if the job failed, or null
-     * @param nodeExecutionOrder        the ordered list of parallel node 
groups for execution, or null
-     * @param operationMetadata the operation parameters, or null
-     */
-    public OperationalJobRecord(UUID jobId, OperationType operationType, 
OperationalJobStatus status,
-                                @Nullable Instant startTime,
-                                @Nullable Instant lastUpdate,
-                                @Nullable String failureReason,
-                                @Nullable List<List<UUID>> nodeExecutionOrder,
-                                @Nullable Map<String, String> 
operationMetadata)
-    {
-        Preconditions.checkArgument(jobId != null, "jobId must not be null");
-        Preconditions.checkArgument(jobId.version() == 1, "jobId must be a 
time-based (v1) UUID");
-        Preconditions.checkArgument(operationType != null, "operationType must 
not be null");
-        Preconditions.checkArgument(status != null, "status must not be null");
-        this.jobId = jobId;
-        this.operationType = operationType;
-        this.status = status;
-        this.creationTimeMillis = UUIDs.unixTimestamp(jobId);
-        this.startTime = startTime;
-        this.lastUpdate = lastUpdate;
-        this.failureReason = failureReason;
-        this.nodeExecutionOrder = nodeExecutionOrder;
-        this.operationMetadata = operationMetadata;
+        Preconditions.checkArgument(builder.jobId != null, "jobId must not be 
null");
+        Preconditions.checkArgument(builder.jobId.version() == 1, "jobId must 
be a time-based (v1) UUID");
+        Preconditions.checkArgument(builder.operationType != null, 
"operationType must not be null");
+        Preconditions.checkArgument(builder.status != null, "status must not 
be null");
+        jobId = builder.jobId;
+        operationType = builder.operationType;
+        status = builder.status;
+        creationTimeMillis = UUIDs.unixTimestamp(builder.jobId);
+        startTime = builder.startTime;
+        lastUpdate = builder.lastUpdate;
+        failureReason = builder.failureReason;
+        nodeExecutionOrder = builder.nodeExecutionOrder;
+        operationMetadata = builder.operationMetadata;
+        nodesPending = builder.nodesPending;
+        nodesExecuting = builder.nodesExecuting;
+        nodesSucceeded = builder.nodesSucceeded;
+        nodesFailed = builder.nodesFailed;
     }
 
     /**
@@ -173,6 +169,74 @@ public class OperationalJobRecord
         return operationMetadata;
     }
 
+    @Override
+    @Nullable
+    public UUID nodeId()
+    {
+        return null;
+    }
+
+    @Override
+    public String name()
+    {
+        return operationType.name().toLowerCase();
+    }
+
+    @Override
+    public long creationTime()
+    {
+        return creationTimeMillis;
+    }
+
+    @Override
+    @NotNull
+    public List<UUID> nodesPending()
+    {
+        return nodesPending;
+    }
+
+    @Override
+    @NotNull
+    public List<UUID> nodesExecuting()
+    {
+        return nodesExecuting;
+    }
+
+    @Override
+    @NotNull
+    public List<UUID> nodesSucceeded()
+    {
+        return nodesSucceeded;
+    }
+
+    @Override
+    @NotNull
+    public List<UUID> nodesFailed()
+    {
+        return nodesFailed;
+    }
+
+    @Override
+    public boolean isExecuting()
+    {
+        return status == OperationalJobStatus.RUNNING;
+    }
+
+    /**
+     * Creates an {@link OperationalJobRecord} from a live {@link 
OperationalJob}.
+     *
+     * @param job the operational job to convert
+     * @return a new record capturing the job's current state
+     */
+    public static OperationalJobRecord fromOperationalJob(OperationalJob job)
+    {
+        return builder().jobId(job.jobId())
+                        .operationType(job.operationType())
+                        .status(job.status())
+                        .lastUpdate(Instant.now())
+                        .build();
+    }
+
     @Override
     public String toString()
     {
@@ -186,6 +250,112 @@ public class OperationalJobRecord
                ", failureReason=" + failureReason +
                ", nodeExecutionOrder=" + nodeExecutionOrder +
                ", operationMetadata=" + operationMetadata +
+               ", nodesPending=" + nodesPending +
+               ", nodesExecuting=" + nodesExecuting +
+               ", nodesSucceeded=" + nodesSucceeded +
+               ", nodesFailed=" + nodesFailed +
                '}';
     }
+
+    /**
+     * @return a new {@link Builder} for constructing an {@link 
OperationalJobRecord}
+     */
+    public static Builder builder()
+    {
+        return new Builder();
+    }
+
+    /**
+     * {@link DataObjectBuilder} for {@link OperationalJobRecord}.
+     */
+    public static final class Builder implements DataObjectBuilder<Builder, 
OperationalJobRecord>
+    {
+        private UUID jobId;
+        private OperationType operationType;
+        private OperationalJobStatus status;
+        private @Nullable Instant startTime;
+        private @Nullable Instant lastUpdate;
+        private @Nullable String failureReason;
+        private @Nullable List<List<UUID>> nodeExecutionOrder;
+        private @Nullable Map<String, String> operationMetadata;
+        private @NotNull List<UUID> nodesPending = Collections.emptyList();
+        private @NotNull List<UUID> nodesExecuting = Collections.emptyList();
+        private @NotNull List<UUID> nodesSucceeded = Collections.emptyList();
+        private @NotNull List<UUID> nodesFailed = Collections.emptyList();
+
+        private Builder()
+        {
+        }
+
+        @Override
+        public Builder self()
+        {
+            return this;
+        }
+
+        public Builder jobId(UUID jobId)
+        {
+            return update(b -> b.jobId = jobId);
+        }
+
+        public Builder operationType(OperationType operationType)
+        {
+            return update(b -> b.operationType = operationType);
+        }
+
+        public Builder status(OperationalJobStatus status)
+        {
+            return update(b -> b.status = status);
+        }
+
+        public Builder startTime(@Nullable Instant startTime)
+        {
+            return update(b -> b.startTime = startTime);
+        }
+
+        public Builder lastUpdate(@Nullable Instant lastUpdate)
+        {
+            return update(b -> b.lastUpdate = lastUpdate);
+        }
+
+        public Builder failureReason(@Nullable String failureReason)
+        {
+            return update(b -> b.failureReason = failureReason);
+        }
+
+        public Builder nodeExecutionOrder(@Nullable List<List<UUID>> 
nodeExecutionOrder)
+        {
+            return update(b -> b.nodeExecutionOrder = nodeExecutionOrder);
+        }
+
+        public Builder operationMetadata(@Nullable Map<String, String> 
operationMetadata)
+        {
+            return update(b -> b.operationMetadata = operationMetadata);
+        }
+
+        public Builder nodesPending(@NotNull List<UUID> nodesPending)
+        {
+            return update(b -> b.nodesPending = 
Objects.requireNonNull(nodesPending, "nodesPending cannot be null"));
+        }
+
+        public Builder nodesExecuting(@NotNull List<UUID> nodesExecuting)
+        {
+            return update(b -> b.nodesExecuting = 
Objects.requireNonNull(nodesExecuting, "nodesExecuting cannot be null"));
+        }
+
+        public Builder nodesSucceeded(@NotNull List<UUID> nodesSucceeded)
+        {
+            return update(b -> b.nodesSucceeded = 
Objects.requireNonNull(nodesSucceeded, "nodesSucceeded cannot be null"));
+        }
+
+        public Builder nodesFailed(@NotNull List<UUID> nodesFailed)
+        {
+            return update(b -> b.nodesFailed = 
Objects.requireNonNull(nodesFailed, "nodesFailed cannot be null"));
+        }
+
+        public OperationalJobRecord build()
+        {
+            return new OperationalJobRecord(this);
+        }
+    }
 }
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/utils/InvocationTrackingFunction.java
 
b/server/src/main/java/org/apache/cassandra/sidecar/utils/InvocationTrackingFunction.java
new file mode 100644
index 00000000..5d2feef9
--- /dev/null
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/utils/InvocationTrackingFunction.java
@@ -0,0 +1,50 @@
+/*
+ * 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.utils;
+
+import java.util.function.Function;
+
+/**
+ * A {@link Function} decorator that records whether the wrapped function was 
ever invoked.
+ *
+ * @param <T> the function input type
+ * @param <R> the function result type
+ */
+public class InvocationTrackingFunction<T, R> implements Function<T, R>
+{
+    private final Function<T, R> delegate;
+    private boolean invoked;
+
+    public InvocationTrackingFunction(Function<T, R> delegate)
+    {
+        this.delegate = delegate;
+    }
+
+    @Override
+    public R apply(T input)
+    {
+        invoked = true;
+        return delegate.apply(input);
+    }
+
+    public boolean wasInvoked()
+    {
+        return invoked;
+    }
+}
diff --git 
a/server/src/test/integration/org/apache/cassandra/sidecar/db/ClusterOpsDatabaseAccessorIntegrationTest.java
 
b/server/src/test/integration/org/apache/cassandra/sidecar/db/ClusterOpsDatabaseAccessorIntegrationTest.java
index 84720cc0..0942cd92 100644
--- 
a/server/src/test/integration/org/apache/cassandra/sidecar/db/ClusterOpsDatabaseAccessorIntegrationTest.java
+++ 
b/server/src/test/integration/org/apache/cassandra/sidecar/db/ClusterOpsDatabaseAccessorIntegrationTest.java
@@ -61,8 +61,14 @@ class ClusterOpsDatabaseAccessorIntegrationTest extends 
IntegrationTestBase
                 Arrays.asList(UUID.randomUUID())
         );
         Map<String, String> metadata = Map.of("key1", "value1", "key2", 
"value2");
-        OperationalJobRecord job1 = new OperationalJobRecord(jobId1, 
OperationType.REPAIR, OperationalJobStatus.CREATED,
-                                                             null, 
Instant.now(), null, nodeOrder, metadata);
+        OperationalJobRecord job1 = OperationalJobRecord.builder()
+                                                        .jobId(jobId1)
+                                                        
.operationType(OperationType.REPAIR)
+                                                        
.status(OperationalJobStatus.CREATED)
+                                                        
.lastUpdate(Instant.now())
+                                                        
.nodeExecutionOrder(nodeOrder)
+                                                        
.operationMetadata(metadata)
+                                                        .build();
         accessor.persistJob(clusterName, job1);
 
         OperationalJobRecord found = accessor.findJob(clusterName, jobId1);
@@ -81,7 +87,11 @@ class ClusterOpsDatabaseAccessorIntegrationTest extends 
IntegrationTestBase
                 });
 
         UUID jobId2 = UUIDs.timeBased();
-        OperationalJobRecord job2 = new OperationalJobRecord(jobId2, 
OperationType.DECOMMISSION, OperationalJobStatus.CREATED);
+        OperationalJobRecord job2 = OperationalJobRecord.builder()
+                                                        .jobId(jobId2)
+                                                        
.operationType(OperationType.DECOMMISSION)
+                                                        
.status(OperationalJobStatus.CREATED)
+                                                        .build();
         accessor.persistJob(clusterName, job2);
 
         OperationalJobRecord found2 = accessor.findJob(clusterName, jobId2);
@@ -133,7 +143,11 @@ class ClusterOpsDatabaseAccessorIntegrationTest extends 
IntegrationTestBase
                 });
 
         UUID jobId3 = UUIDs.timeBased();
-        accessor.persistJob(clusterName, new OperationalJobRecord(jobId3, 
OperationType.REPAIR, OperationalJobStatus.CREATED));
+        accessor.persistJob(clusterName, OperationalJobRecord.builder()
+                                                             .jobId(jobId3)
+                                                             
.operationType(OperationType.REPAIR)
+                                                             
.status(OperationalJobStatus.CREATED)
+                                                             .build());
         List<OperationalJobRecord> allJobs = accessor.findAllJobs(clusterName, 
10);
         assertThat(allJobs)
                 .withFailMessage("findAllJobs should return every persisted 
job when the limit exceeds total count")
@@ -148,9 +162,14 @@ class ClusterOpsDatabaseAccessorIntegrationTest extends 
IntegrationTestBase
                 .withFailMessage("findAllJobs should return the most recently 
created job first")
                 .isEqualTo(jobId3);
 
-        OperationalJobRecord job1Updated = new OperationalJobRecord(jobId1, 
OperationType.REPAIR,
-                                                                     
OperationalJobStatus.SUCCEEDED,
-                                                                     null, 
Instant.now(), null, nodeOrder, metadata);
+        OperationalJobRecord job1Updated = OperationalJobRecord.builder()
+                                                               .jobId(jobId1)
+                                                               
.operationType(OperationType.REPAIR)
+                                                               
.status(OperationalJobStatus.SUCCEEDED)
+                                                               
.lastUpdate(Instant.now())
+                                                               
.nodeExecutionOrder(nodeOrder)
+                                                               
.operationMetadata(metadata)
+                                                               .build();
         accessor.persistJob(clusterName, job1Updated);
         OperationalJobRecord afterUpsert = accessor.findJob(clusterName, 
jobId1);
         assertThat(afterUpsert)
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
new file mode 100644
index 00000000..155161d9
--- /dev/null
+++ 
b/server/src/test/integration/org/apache/cassandra/sidecar/job/DurableOperationalJobTrackerIntegrationTest.java
@@ -0,0 +1,108 @@
+/*
+ * 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 java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import com.datastax.driver.core.utils.UUIDs;
+import org.apache.cassandra.sidecar.common.data.OperationalJobStatus;
+import 
org.apache.cassandra.sidecar.common.server.utils.MillisecondBoundConfiguration;
+import org.apache.cassandra.sidecar.concurrent.ExecutorPools;
+import org.apache.cassandra.sidecar.concurrent.TaskExecutorPool;
+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.testing.IntegrationTestBase;
+import org.apache.cassandra.testing.CassandraIntegrationTest;
+
+import static org.apache.cassandra.testing.utils.AssertionUtils.loopAssert;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests for {@link DurableOperationalJobTracker} with a 
Cassandra-backed {@link StorageProvider}.
+ */
+class DurableOperationalJobTrackerIntegrationTest extends IntegrationTestBase
+{
+    @CassandraIntegrationTest
+    void testDurableOperationalJobTrackerOperations() throws 
InterruptedException
+    {
+        waitForSchemaReady(10, TimeUnit.SECONDS);
+
+        StorageProvider storageProvider = 
injector.getInstance(StorageProvider.class);
+        storageProvider.initialize();
+
+        ExecutorPools executorPools = 
injector.getInstance(ExecutorPools.class);
+        TaskExecutorPool executorPool = executorPools.internal();
+        DurableOperationalJobTracker tracker = new 
DurableOperationalJobTracker(new ServiceConfigurationImpl(),
+                                                                               
 storageProvider,
+                                                                               
 executorPool);
+
+        UUID jobId1 = UUIDs.timeBased();
+        OperationalJob job1 = OperationalJobTest.createOperationalJob(jobId1, 
OperationalJobStatus.CREATED);
+
+        tracker.computeIfAbsent(jobId1, id -> job1);
+
+        loopAssert(2, () -> {
+            OperationalJobRecord record1 = storageProvider.findJob(jobId1);
+            assertThat(record1)
+                .withFailMessage("Job should be persisted to Cassandra on 
creation")
+                .isNotNull()
+                .satisfies(r -> {
+                    assertThat(r.jobId()).isEqualTo(jobId1);
+                    
assertThat(r.operationType()).isEqualTo(job1.operationType());
+                    
assertThat(r.status()).isEqualTo(OperationalJobStatus.CREATED);
+                });
+        });
+
+        UUID jobId2 = UUIDs.timeBased();
+        OperationalJob job2 = OperationalJobTest.createOperationalJob(jobId2, 
MillisecondBoundConfiguration.parse("50ms"));
+        CountDownLatch latch = new CountDownLatch(1);
+
+        tracker.computeIfAbsent(jobId2, id -> job2);
+        executorPool.executeBlocking(job2::execute);
+
+        job2.asyncResult().onComplete(ar -> latch.countDown());
+        assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue();
+
+        loopAssert(2, () -> {
+            OperationalJobRecord record2 = storageProvider.findJob(jobId2);
+            assertThat(record2)
+                .withFailMessage("Job status should be updated to SUCCEEDED in 
Cassandra")
+                .isNotNull()
+                .satisfies(r -> 
assertThat(r.status()).isEqualTo(OperationalJobStatus.SUCCEEDED));
+        });
+
+        loopAssert(2, () -> {
+            assertThat(tracker.jobsView())
+                .withFailMessage("Job should be removed from local map after 
completion")
+                .doesNotContainKey(jobId2);
+        });
+
+        OperationalJobInfo retrieved = tracker.get(jobId2);
+        assertThat(retrieved)
+            .withFailMessage("get() should return an OperationalJobRecord from 
storage after completion")
+            .isNotNull()
+            .isInstanceOf(OperationalJobRecord.class);
+        assertThat(retrieved.jobId()).isEqualTo(jobId2);
+        
assertThat(retrieved.status()).isEqualTo(OperationalJobStatus.SUCCEEDED);
+        assertThat(retrieved.operationType()).isEqualTo(job2.operationType());
+    }
+}
diff --git 
a/server/src/test/java/org/apache/cassandra/sidecar/handlers/ListOperationalJobsHandlerTest.java
 
b/server/src/test/java/org/apache/cassandra/sidecar/handlers/ListOperationalJobsHandlerTest.java
index 91159bd0..8edd2d82 100644
--- 
a/server/src/test/java/org/apache/cassandra/sidecar/handlers/ListOperationalJobsHandlerTest.java
+++ 
b/server/src/test/java/org/apache/cassandra/sidecar/handlers/ListOperationalJobsHandlerTest.java
@@ -47,6 +47,7 @@ import io.vertx.ext.web.client.predicate.ResponsePredicate;
 import io.vertx.junit5.VertxExtension;
 import io.vertx.junit5.VertxTestContext;
 import org.apache.cassandra.sidecar.TestModule;
+import org.apache.cassandra.sidecar.common.data.OperationType;
 import 
org.apache.cassandra.sidecar.common.response.ListOperationalJobsResponse;
 import 
org.apache.cassandra.sidecar.common.server.exceptions.OperationalJobException;
 import org.apache.cassandra.sidecar.job.OperationalJob;
@@ -157,6 +158,12 @@ class ListOperationalJobsHandlerTest
             return false;
         }
 
+        @Override
+        public OperationType operationType()
+        {
+            return OperationType.DRAIN;
+        }
+
         @Override
         protected Future<Void> executeInternal() throws OperationalJobException
         {
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
new file mode 100644
index 00000000..58158a78
--- /dev/null
+++ 
b/server/src/test/java/org/apache/cassandra/sidecar/job/DurableOperationalJobTrackerTest.java
@@ -0,0 +1,527 @@
+/*
+ * 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.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import com.datastax.driver.core.utils.UUIDs;
+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.common.server.utils.MillisecondBoundConfiguration;
+import org.apache.cassandra.sidecar.concurrent.ExecutorPools;
+import org.apache.cassandra.sidecar.concurrent.TaskExecutorPool;
+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 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.createOperationalJob;
+import static org.apache.cassandra.testing.utils.AssertionUtils.loopAssert;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+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.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests for {@link DurableOperationalJobTracker}
+ */
+class DurableOperationalJobTrackerTest
+{
+    private StorageProvider storageProvider;
+    private DurableOperationalJobTracker tracker;
+    private Vertx vertx;
+    private ExecutorPools executorPools;
+    private TaskExecutorPool executorPool;
+
+    @BeforeEach
+    void setUp()
+    {
+        storageProvider = mock(StorageProvider.class);
+        when(storageProvider.isAvailable()).thenReturn(true);
+        vertx = Vertx.vertx();
+        executorPools = new ExecutorPools(vertx, new 
ServiceConfigurationImpl());
+        executorPool = executorPools.internal();
+        tracker = new DurableOperationalJobTracker(new 
ServiceConfigurationImpl(), storageProvider, executorPool);
+    }
+
+    @AfterEach
+    void cleanup()
+    {
+        TestResourceReaper.create().with(vertx).with(executorPools).close();
+    }
+
+    @Test
+    void testComputeIfAbsentPersistsNewJob()
+    {
+        OperationalJob job = createOperationalJob(CREATED);
+
+        OperationalJob result = tracker.computeIfAbsent(job.jobId(), id -> 
job);
+
+        assertThat(result).isSameAs(job);
+        loopAssert(2, () -> {
+            verify(storageProvider).persistJob(argThat(record ->
+                record.jobId().equals(job.jobId()) &&
+                record.operationType() == job.operationType() &&
+                record.status() == CREATED
+            ));
+        });
+    }
+
+    @Test
+    void testComputeIfAbsentDoesNotPersistExistingJob()
+    {
+        OperationalJob job = createOperationalJob(CREATED);
+
+        tracker.computeIfAbsent(job.jobId(), id -> job);
+        OperationalJob secondCall = tracker.computeIfAbsent(job.jobId(), id -> 
createOperationalJob(CREATED));
+
+        assertThat(secondCall).isSameAs(job);
+        loopAssert(2, () -> {
+            verify(storageProvider, times(1)).persistJob(any());
+        });
+    }
+
+    @Test
+    void testComputeIfAbsentUpdatesStatusOnCompletion() 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 testTerminalStatusNotUpdatedWhenPersistFails() throws 
InterruptedException
+    {
+        UUID jobId = UUIDs.timeBased();
+        OperationalJob job = OperationalJobTest.createOperationalJob(jobId, 
MillisecondBoundConfiguration.parse("50ms"));
+
+        doThrow(new StorageProviderException("persist 
failed")).when(storageProvider).persistJob(any());
+
+        CountDownLatch completed = new CountDownLatch(1);
+        tracker.computeIfAbsent(jobId, id -> job);
+        executorPool.executeBlocking(job::execute);
+        job.asyncResult().onComplete(ar -> completed.countDown());
+
+        assertThat(completed.await(5, TimeUnit.SECONDS)).isTrue();
+        // The tracker only registers its completion handler after a 
successful persist. Since persist
+        // failed here, the handler is never registered, so updateJobStatus 
should not run.
+        verify(storageProvider, never()).updateJobStatus(any(), any(), any(), 
any());
+    }
+
+    @Test
+    void testComputeIfAbsentUpdatesStatusOnFailure() throws 
InterruptedException
+    {
+        UUID jobId = UUIDs.timeBased();
+        OperationalJob job = OperationalJobTest.createOperationalJob(jobId,
+            MillisecondBoundConfiguration.parse("50ms"),
+            new 
org.apache.cassandra.sidecar.common.server.exceptions.OperationalJobException("test
 failure"));
+        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(FAILED), eq("test failure"));
+        });
+    }
+
+    @Test
+    void testComputeIfAbsentRemovesJobFromLocalMapOnCompletion() 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);
+
+        assertThat(tracker.jobsView()).containsKey(jobId);
+
+        job.asyncResult().onComplete(ar -> latch.countDown());
+        assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue();
+
+        loopAssert(2, () -> {
+            assertThat(tracker.jobsView()).doesNotContainKey(jobId);
+        });
+    }
+
+    @Test
+    void testGetReturnsLiveJobFromLocalMap()
+    {
+        OperationalJob job = createOperationalJob(CREATED);
+        tracker.computeIfAbsent(job.jobId(), id -> job);
+
+        OperationalJobInfo result = tracker.get(job.jobId());
+
+        assertThat(result).isSameAs(job);
+        verify(storageProvider, never()).findJob(any());
+    }
+
+    @Test
+    void testGetFallsBackToStorage()
+    {
+        UUID jobId = UUIDs.timeBased();
+        OperationalJobRecord record = OperationalJobRecord.builder()
+                                                          .jobId(jobId)
+                                                          
.operationType(OperationType.DECOMMISSION)
+                                                          .status(SUCCEEDED)
+                                                          .build();
+        when(storageProvider.findJob(jobId)).thenReturn(record);
+        
when(storageProvider.getNodeStatusesForOperation(jobId)).thenReturn(Collections.emptyMap());
+
+        OperationalJobInfo result = tracker.get(jobId);
+
+        assertThat(result).isInstanceOf(OperationalJobRecord.class);
+        assertThat(result.jobId()).isEqualTo(jobId);
+        assertThat(result.status()).isEqualTo(SUCCEEDED);
+        
assertThat(result.name()).isEqualTo(OperationType.DECOMMISSION.name().toLowerCase());
+        
assertThat(result.operationType()).isEqualTo(OperationType.DECOMMISSION);
+        verify(storageProvider).findJob(jobId);
+    }
+
+    @Test
+    void testGetReturnsNullWhenNotFoundAnywhere()
+    {
+        UUID jobId = UUIDs.timeBased();
+        when(storageProvider.findJob(jobId)).thenReturn(null);
+
+        OperationalJobInfo result = tracker.get(jobId);
+
+        assertThat(result).isNull();
+        verify(storageProvider).findJob(jobId);
+    }
+
+    @Test
+    void testJobsViewReturnsOnlyLocalMapContents()
+    {
+        OperationalJob job1 = createOperationalJob(CREATED);
+        OperationalJob job2 = createOperationalJob(CREATED);
+        tracker.computeIfAbsent(job1.jobId(), id -> job1);
+        tracker.computeIfAbsent(job2.jobId(), id -> job2);
+
+        Map<UUID, OperationalJob> view = tracker.jobsView();
+
+        assertThat(view).hasSize(2);
+        assertThat(view).containsEntry(job1.jobId(), job1);
+        assertThat(view).containsEntry(job2.jobId(), job2);
+        verify(storageProvider, never()).findJob(any());
+        verify(storageProvider, never()).findAllJobs(any(int.class));
+    }
+
+    @Test
+    void testJobsViewIsImmutable()
+    {
+        OperationalJob job = createOperationalJob(CREATED);
+        tracker.computeIfAbsent(job.jobId(), id -> job);
+
+        Map<UUID, OperationalJob> view = tracker.jobsView();
+
+        assertThatThrownBy(() -> view.put(UUIDs.timeBased(), job))
+            .isInstanceOf(UnsupportedOperationException.class);
+    }
+
+    @Test
+    void testInflightJobsByOperationFiltersLocalMap()
+    {
+        OperationalJob createdJob = createOperationalJob("decommission", 
CREATED);
+        OperationalJob runningJob = createOperationalJob("decommission", 
RUNNING);
+        OperationalJob succeededJob = createOperationalJob("decommission", 
SUCCEEDED);
+        OperationalJob drainJob = createOperationalJob("drain", RUNNING);
+
+        tracker.computeIfAbsent(createdJob.jobId(), id -> createdJob);
+        tracker.computeIfAbsent(runningJob.jobId(), id -> runningJob);
+        tracker.computeIfAbsent(succeededJob.jobId(), id -> succeededJob);
+        tracker.computeIfAbsent(drainJob.jobId(), id -> drainJob);
+
+        List<OperationalJob> inflightDecommission = 
tracker.inflightJobsByOperation("decommission");
+
+        assertThat(inflightDecommission)
+            .hasSize(2)
+            .containsExactlyInAnyOrder(createdJob, runningJob);
+        verify(storageProvider, never()).findJob(any());
+    }
+
+    @Test
+    void testComputeIfAbsentKeepsJobInMapOnPersistenceFailureUntilCompletion() 
throws InterruptedException
+    {
+        UUID jobId = UUIDs.timeBased();
+        OperationalJob job = OperationalJobTest.createOperationalJob(jobId, 
MillisecondBoundConfiguration.parse("200ms"));
+
+        doThrow(new StorageProviderException("Storage unavailable"))
+            .when(storageProvider).persistJob(any());
+
+        CountDownLatch completed = new CountDownLatch(1);
+        tracker.computeIfAbsent(jobId, id -> job);
+        executorPool.executeBlocking(job::execute);
+        job.asyncResult().onComplete(ar -> completed.countDown());
+
+        // The persist failed, but the job is already executing, so it must 
remain in the live map
+        // (durability-degraded) rather than being dropped mid-flight.
+        loopAssert(2, () -> {
+            verify(storageProvider, atLeastOnce()).persistJob(any());
+            assertThat(tracker.jobsView()).containsKey(jobId);
+        });
+
+        // Once the job completes, the completion handler removes it from the 
live map to avoid leaking.
+        assertThat(completed.await(5, TimeUnit.SECONDS)).isTrue();
+        loopAssert(2, () -> {
+            assertThat(tracker.jobsView()).doesNotContainKey(jobId);
+        });
+    }
+
+    @Test
+    void testComputeIfAbsentFailsWhenStorageUnavailable()
+    {
+        when(storageProvider.isAvailable()).thenReturn(false);
+        OperationalJob job = createOperationalJob(CREATED);
+
+        assertThatThrownBy(() -> tracker.computeIfAbsent(job.jobId(), id -> 
job))
+            .isInstanceOf(IllegalStateException.class)
+            .hasMessage("Storage provider is not available");
+    }
+
+    @Test
+    void testGetPropagatesStorageFallbackFailure()
+    {
+        UUID jobId = UUIDs.timeBased();
+        when(storageProvider.findJob(jobId)).thenThrow(new 
StorageProviderException("Storage unavailable"));
+
+        assertThatThrownBy(() -> tracker.get(jobId))
+            .isInstanceOf(StorageProviderException.class)
+            .hasMessage("Storage unavailable");
+    }
+
+    @Test
+    void testGetEnrichesRecordWithNodeStatuses()
+    {
+        UUID jobId = UUIDs.timeBased();
+        UUID pendingNode = UUIDs.timeBased();
+        UUID runningNode = UUIDs.timeBased();
+        UUID succeededNode = UUIDs.timeBased();
+        UUID failedNode = UUIDs.timeBased();
+
+        OperationalJobRecord record = OperationalJobRecord.builder()
+                                                          .jobId(jobId)
+                                                          
.operationType(OperationType.DECOMMISSION)
+                                                          .status(RUNNING)
+                                                          .build();
+        when(storageProvider.findJob(jobId)).thenReturn(record);
+
+        Map<UUID, OperationalJobStatus> nodeStatuses = new HashMap<>();
+        nodeStatuses.put(pendingNode, CREATED);
+        nodeStatuses.put(runningNode, RUNNING);
+        nodeStatuses.put(succeededNode, SUCCEEDED);
+        nodeStatuses.put(failedNode, FAILED);
+        
when(storageProvider.getNodeStatusesForOperation(jobId)).thenReturn(nodeStatuses);
+
+        OperationalJobInfo result = tracker.get(jobId);
+
+        assertThat(result).isInstanceOf(OperationalJobRecord.class);
+        assertThat(result.nodesPending()).containsExactly(pendingNode);
+        assertThat(result.nodesExecuting()).containsExactly(runningNode);
+        assertThat(result.nodesSucceeded()).containsExactly(succeededNode);
+        assertThat(result.nodesFailed()).containsExactly(failedNode);
+        verify(storageProvider).getNodeStatusesForOperation(jobId);
+    }
+
+    @Test
+    void testGetSkipsEnrichmentWhenNodeListsAlreadyPopulated()
+    {
+        UUID jobId = UUIDs.timeBased();
+        UUID succeededNode = UUIDs.timeBased();
+
+        OperationalJobRecord record = OperationalJobRecord.builder()
+                                                          .jobId(jobId)
+                                                          
.operationType(OperationType.DECOMMISSION)
+                                                          .status(SUCCEEDED)
+                                                          
.nodesSucceeded(Collections.singletonList(succeededNode))
+                                                          .build();
+        when(storageProvider.findJob(jobId)).thenReturn(record);
+
+        OperationalJobInfo result = tracker.get(jobId);
+
+        assertThat(result).isSameAs(record);
+        assertThat(result.nodesSucceeded()).containsExactly(succeededNode);
+        verify(storageProvider, never()).getNodeStatusesForOperation(any());
+    }
+
+    @Test
+    void testGetReturnsUnenrichedRecordWhenNoNodeStatuses()
+    {
+        UUID jobId = UUIDs.timeBased();
+        OperationalJobRecord record = OperationalJobRecord.builder()
+                                                          .jobId(jobId)
+                                                          
.operationType(OperationType.DECOMMISSION)
+                                                          .status(SUCCEEDED)
+                                                          .build();
+        when(storageProvider.findJob(jobId)).thenReturn(record);
+        
when(storageProvider.getNodeStatusesForOperation(jobId)).thenReturn(Collections.emptyMap());
+
+        OperationalJobInfo result = tracker.get(jobId);
+
+        assertThat(result).isSameAs(record);
+        assertThat(result.nodesPending()).isEmpty();
+        assertThat(result.nodesExecuting()).isEmpty();
+        assertThat(result.nodesSucceeded()).isEmpty();
+        assertThat(result.nodesFailed()).isEmpty();
+        verify(storageProvider).getNodeStatusesForOperation(jobId);
+    }
+
+    @Test
+    void testGetPropagatesNodeStatusQueryFailure()
+    {
+        UUID jobId = UUIDs.timeBased();
+        OperationalJobRecord record = OperationalJobRecord.builder()
+                                                          .jobId(jobId)
+                                                          
.operationType(OperationType.DECOMMISSION)
+                                                          .status(SUCCEEDED)
+                                                          .build();
+        when(storageProvider.findJob(jobId)).thenReturn(record);
+        when(storageProvider.getNodeStatusesForOperation(jobId))
+            .thenThrow(new StorageProviderException("Node state unavailable"));
+
+        assertThatThrownBy(() -> tracker.get(jobId))
+            .isInstanceOf(StorageProviderException.class)
+            .hasMessage("Node state unavailable");
+    }
+
+    @Test
+    void testOnCompleteRetrySucceedsAfterTransientFailure() throws 
InterruptedException
+    {
+        UUID jobId = UUIDs.timeBased();
+        OperationalJob job = OperationalJobTest.createOperationalJob(jobId, 
MillisecondBoundConfiguration.parse("50ms"));
+
+        doThrow(new StorageProviderException("Transient failure"))
+            .doNothing()
+            .when(storageProvider).updateJobStatus(eq(jobId), 
eq(OperationType.DRAIN), eq(SUCCEEDED), isNull());
+
+        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, times(2)).updateJobStatus(eq(jobId), 
eq(OperationType.DRAIN), eq(SUCCEEDED), isNull());
+        });
+    }
+
+    @Test
+    void testOnCompleteRemovesJobFromLocalMapWhenAllRetriesFail() throws 
InterruptedException
+    {
+        UUID jobId = UUIDs.timeBased();
+        OperationalJob job = OperationalJobTest.createOperationalJob(jobId, 
MillisecondBoundConfiguration.parse("50ms"));
+
+        doThrow(new StorageProviderException("Persistent failure"))
+            .when(storageProvider).updateJobStatus(eq(jobId), 
eq(OperationType.DRAIN), eq(SUCCEEDED), isNull());
+
+        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, times(3)).updateJobStatus(eq(jobId), 
eq(OperationType.DRAIN), eq(SUCCEEDED), isNull());
+            assertThat(tracker.jobsView()).doesNotContainKey(jobId);
+        });
+    }
+
+    @Test
+    void testInitialPersistRetrySucceedsAfterTransientFailure() throws 
InterruptedException
+    {
+        UUID jobId = UUIDs.timeBased();
+        OperationalJob job = OperationalJobTest.createOperationalJob(jobId, 
MillisecondBoundConfiguration.parse("50ms"));
+
+        doThrow(new StorageProviderException("Transient failure"))
+            .doNothing()
+            .when(storageProvider).persistJob(any());
+
+        tracker.computeIfAbsent(jobId, id -> job);
+        executorPool.executeBlocking(job::execute);
+
+        loopAssert(2, () -> {
+            verify(storageProvider, times(2)).persistJob(any());
+            verify(storageProvider).updateJobStatus(eq(jobId), 
eq(OperationType.DRAIN), eq(SUCCEEDED), isNull());
+        });
+    }
+
+    @Test
+    void testInitialPersistExhaustsRetriesAndTracksInMemoryOnly() throws 
InterruptedException
+    {
+        UUID jobId = UUIDs.timeBased();
+        OperationalJob job = OperationalJobTest.createOperationalJob(jobId, 
MillisecondBoundConfiguration.parse("50ms"));
+
+        doThrow(new StorageProviderException("Persistent 
failure")).when(storageProvider).persistJob(any());
+
+        CountDownLatch completed = new CountDownLatch(1);
+        tracker.computeIfAbsent(jobId, id -> job);
+        executorPool.executeBlocking(job::execute);
+        job.asyncResult().onComplete(ar -> completed.countDown());
+
+        assertThat(completed.await(5, TimeUnit.SECONDS)).isTrue();
+
+        loopAssert(2, () -> {
+            verify(storageProvider, times(3)).persistJob(any());
+            verify(storageProvider, never()).updateJobStatus(any(), any(), 
any(), any());
+            assertThat(tracker.jobsView()).doesNotContainKey(jobId);
+        });
+    }
+}
diff --git 
a/server/src/test/java/org/apache/cassandra/sidecar/job/OperationalJobInfoTest.java
 
b/server/src/test/java/org/apache/cassandra/sidecar/job/OperationalJobInfoTest.java
index 19b3a9eb..717b6201 100644
--- 
a/server/src/test/java/org/apache/cassandra/sidecar/job/OperationalJobInfoTest.java
+++ 
b/server/src/test/java/org/apache/cassandra/sidecar/job/OperationalJobInfoTest.java
@@ -26,6 +26,7 @@ import org.junit.jupiter.api.Test;
 import com.datastax.driver.core.utils.UUIDs;
 import io.vertx.core.Future;
 import io.vertx.core.Promise;
+import org.apache.cassandra.sidecar.common.data.OperationType;
 import 
org.apache.cassandra.sidecar.common.server.exceptions.OperationalJobException;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -96,6 +97,12 @@ class OperationalJobInfoTest
             return false;
         }
 
+        @Override
+        public OperationType operationType()
+        {
+            return OperationType.DRAIN;
+        }
+
         @Override
         protected Future<Void> executeInternal()
         {
@@ -113,6 +120,12 @@ class OperationalJobInfoTest
                 return false;
             }
 
+            @Override
+            public OperationType operationType()
+            {
+                return OperationType.DRAIN;
+            }
+
             @Override
             protected Future<Void> executeInternal() throws 
OperationalJobException
             {
@@ -132,6 +145,12 @@ class OperationalJobInfoTest
                 return false;
             }
 
+            @Override
+            public OperationType operationType()
+            {
+                return OperationType.DRAIN;
+            }
+
             @Override
             protected Future<Void> executeInternal()
             {
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 4007b661..cf7492c2 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
@@ -32,15 +32,24 @@ import com.datastax.driver.core.utils.UUIDs;
 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.server.exceptions.OperationalJobException;
+import 
org.apache.cassandra.sidecar.common.server.utils.MillisecondBoundConfiguration;
 import 
org.apache.cassandra.sidecar.common.server.utils.SecondBoundConfiguration;
 import org.apache.cassandra.sidecar.concurrent.ExecutorPools;
 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 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.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
 /**
  * Tests to validate the Job submission behavior for scenarios which are a 
combination of values for
@@ -148,6 +157,12 @@ class OperationalJobManagerTest
                 return false;
             }
 
+            @Override
+            public OperationType operationType()
+            {
+                return OperationType.DRAIN;
+            }
+
             @Override
             protected Future<Void> executeInternal() throws 
OperationalJobException
             {
@@ -166,4 +181,30 @@ class OperationalJobManagerTest
         assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue();
         assertThat(tracker.get(jobId)).isNotNull();
     }
+
+    @Test
+    void testJobRemovedFromTrackerWhenPersistenceFails()
+    {
+        StorageProvider storageProvider = mock(StorageProvider.class);
+        when(storageProvider.isAvailable()).thenReturn(true);
+        doThrow(new StorageProviderException("Storage unavailable"))
+            .when(storageProvider).persistJob(any());
+
+        DurableOperationalJobTracker durableTracker = new 
DurableOperationalJobTracker(new ServiceConfigurationImpl(),
+                                                                               
        storageProvider,
+                                                                               
        executorPool.service());
+        OperationalJobManager manager = new 
OperationalJobManager(durableTracker, executorPool);
+
+        UUID jobId = UUIDs.timeBased();
+        OperationalJob job = OperationalJobTest.createOperationalJob(jobId, 
MillisecondBoundConfiguration.parse("50ms"));
+
+        manager.trySubmitJob(job,
+                             (j, ex) -> {},
+                             executorPool.service(),
+                             SecondBoundConfiguration.parse("5s"));
+
+        loopAssert(2, () -> {
+            assertThat(durableTracker.jobsView()).doesNotContainKey(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 c047d9db..485a6481 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
@@ -30,6 +30,7 @@ import io.vertx.core.Future;
 import io.vertx.core.Promise;
 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.common.server.exceptions.OperationalJobException;
 import org.apache.cassandra.sidecar.common.server.utils.DurationSpec;
@@ -78,6 +79,12 @@ class OperationalJobTest
                 return jobStatus;
             }
 
+            @Override
+            public OperationType operationType()
+            {
+                return OperationType.DRAIN;
+            }
+
             @Override
             public String name()
             {
@@ -108,6 +115,12 @@ class OperationalJobTest
                 return jobStatus;
             }
 
+            @Override
+            public OperationType operationType()
+            {
+                return OperationType.DRAIN;
+            }
+
             @Override
             public String name()
             {
@@ -146,6 +159,12 @@ class OperationalJobTest
                 return Future.succeededFuture();
             }
 
+            @Override
+            public OperationType operationType()
+            {
+                return OperationType.DRAIN;
+            }
+
             @Override
             public String name()
             {
@@ -185,6 +204,12 @@ class OperationalJobTest
                 return false;
             }
 
+            @Override
+            public OperationType operationType()
+            {
+                return OperationType.DRAIN;
+            }
+
             @Override
             protected Future<Void> executeInternal() throws 
OperationalJobException
             {


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

Reply via email to