Skip to content

Commit a2bd0e1

Browse files
Mark OperationalJob as FAILED if coordination fails
1 parent 817bf51 commit a2bd0e1

3 files changed

Lines changed: 117 additions & 23 deletions

File tree

server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJob.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,19 @@ public Future<Void> asyncResult(TaskExecutorPool executorPool, DurationSpec wait
342342
});
343343
}
344344

345+
/**
346+
* Marks the job as failed before it begins executing, for example when it cannot be started due to a
347+
* coordination conflict. The execution result is completed exceptionally so the job reports
348+
* {@link OperationalJobStatus#FAILED} with the supplied reason, and {@link #executeInternal()} is never invoked.
349+
*
350+
* @param cause the reason the job could not be started
351+
*/
352+
public void failToStart(Throwable cause)
353+
{
354+
lastUpdate = Instant.now();
355+
executionPromise.tryFail(cause);
356+
}
357+
345358
/**
346359
* OperationalJob body. The implementation returns a Future representing the job execution.
347360
*/

server/src/main/java/org/apache/cassandra/sidecar/job/OperationalJobManager.java

Lines changed: 76 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@
2828

2929
import com.google.inject.Inject;
3030
import com.google.inject.Singleton;
31+
import io.vertx.core.Future;
3132
import org.apache.cassandra.sidecar.common.server.utils.DurationSpec;
32-
import org.apache.cassandra.sidecar.common.utils.Preconditions;
3333
import org.apache.cassandra.sidecar.concurrent.ExecutorPools;
3434
import org.apache.cassandra.sidecar.concurrent.TaskExecutorPool;
3535
import org.apache.cassandra.sidecar.exceptions.OperationalJobConflictException;
@@ -116,21 +116,59 @@ public void trySubmitJob(OperationalJob job,
116116
try
117117
{
118118
checkConflict(job);
119-
120-
// New job is submitted for all cases when we do not have a corresponding downstream job
121-
OperationalJob tracked = jobTracker.computeIfAbsent(job.jobId(), jobId -> job);
122-
if (tracked == job)
123-
{
124-
tryCoordination(job);
125-
internalExecutorPool.executeBlocking(job::execute);
126-
}
127119
}
128120
catch (OperationalJobConflictException oje)
129121
{
130122
onComplete.accept(job, oje);
131123
return;
132124
}
133125

126+
if (!job.requiresCoordination())
127+
{
128+
trackAndExecute(job, onComplete, serviceExecutorPool, waitTime);
129+
return;
130+
}
131+
132+
// Acquiring the active operation lock might perform blocking storage I/O so it must
133+
// run off the event loop
134+
acquireActiveOperationLock(job)
135+
.onComplete(ar ->
136+
{
137+
if (ar.succeeded() && Boolean.TRUE.equals(ar.result()))
138+
{
139+
trackAndExecute(job, onComplete, serviceExecutorPool, waitTime);
140+
}
141+
else
142+
{
143+
OperationalJobConflictException conflict = coordinationConflict(job, ar.cause());
144+
job.failToStart(conflict);
145+
jobTracker.computeIfAbsent(job.jobId(), jobId -> job);
146+
onComplete.accept(job, conflict);
147+
}
148+
});
149+
}
150+
151+
/**
152+
* Tracks the job and submits it for asynchronous execution on the internal executor pool, then arranges for
153+
* {@code onComplete} to be invoked with the result (or after {@code waitTime} elapses).
154+
*
155+
* @param job the job to track and execute
156+
* @param onComplete callback to invoke when the job completes
157+
* @param serviceExecutorPool the executor pool to use for waiting on job completion
158+
* @param waitTime the maximum time to wait for job completion before returning
159+
*/
160+
private void trackAndExecute(OperationalJob job,
161+
BiConsumer<OperationalJob, OperationalJobConflictException> onComplete,
162+
TaskExecutorPool serviceExecutorPool,
163+
DurationSpec waitTime)
164+
{
165+
// New job is submitted for all cases when we do not have a corresponding downstream job
166+
OperationalJob tracked = jobTracker.computeIfAbsent(job.jobId(), jobId -> job);
167+
if (tracked == job)
168+
{
169+
internalExecutorPool.executeBlocking(job::execute);
170+
}
171+
134172
// Get the result, waiting for the specified wait time for result
135173
job.asyncResult(serviceExecutorPool, waitTime)
136174
.onComplete(v -> onComplete.accept(job, null));
@@ -152,24 +190,39 @@ private void checkConflict(OperationalJob job) throws OperationalJobConflictExce
152190
}
153191

154192
/**
155-
* For jobs that require cluster-wide coordination, attempts to acquire the active operation lock
156-
* via the coordinator. Throws a conflict if another operation is already active.
193+
* For jobs that require cluster-wide coordination, attempts to acquire the active operation lock via the
194+
* coordinator. The acquisition runs on the internal executor pool because it might performs blocking storage I/O
157195
*
158-
* @param job instance of the job to coordinate
159-
* @throws OperationalJobConflictException when the coordinator cannot activate the operation
196+
* @param job the job requiring coordination
197+
* @return a future resolving to {@code true} if the lock was acquired, {@code false} if another operation
198+
* already holds it, or a failed future if coordination could not be attempted (e.g. no coordinator
199+
* is configured or the storage call failed)
160200
*/
161-
private void tryCoordination(OperationalJob job) throws OperationalJobConflictException
201+
private Future<Boolean> acquireActiveOperationLock(OperationalJob job)
162202
{
163-
if (job.requiresCoordination())
203+
if (coordinator == null)
164204
{
165-
Preconditions.checkState(coordinator != null,
166-
"Job requires coordination but no OperationalJobCoordinator is configured");
167-
boolean activated = coordinator.trySetActive(job.operationType(), job.jobId());
168-
if (!activated)
169-
{
170-
throw new OperationalJobConflictException("An active operation already exists. operationType='"
171-
+ job.operationType() + '\'');
172-
}
205+
return Future.failedFuture("Job requires coordination but no OperationalJobCoordinator is configured");
206+
}
207+
return internalExecutorPool.executeBlocking(() -> coordinator.trySetActive(job.operationType(), job.jobId()), false);
208+
}
209+
210+
/**
211+
* Builds the conflict exception describing why the active operation lock could not be acquired.
212+
*
213+
* @param job the job that could not be coordinated
214+
* @param cause the failure cause when coordination could not be attempted, or {@code null} when the lock is
215+
* simply held by another active operation
216+
* @return the conflict exception to report to the caller
217+
*/
218+
private OperationalJobConflictException coordinationConflict(OperationalJob job, @Nullable Throwable cause)
219+
{
220+
if (cause != null)
221+
{
222+
return new OperationalJobConflictException("Unable to coordinate operation. operationType='"
223+
+ job.operationType() + "', reason='" + cause.getMessage() + '\'');
173224
}
225+
return new OperationalJobConflictException("An active operation already exists. operationType='"
226+
+ job.operationType() + '\'');
174227
}
175228
}

server/src/test/java/org/apache/cassandra/sidecar/job/OperationalJobManagerTest.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
import org.apache.cassandra.sidecar.exceptions.OperationalJobConflictException;
4141
import org.jetbrains.annotations.NotNull;
4242

43+
import static org.apache.cassandra.sidecar.common.data.OperationalJobStatus.FAILED;
4344
import static org.apache.cassandra.sidecar.common.data.OperationalJobStatus.RUNNING;
4445
import static org.apache.cassandra.sidecar.common.data.OperationalJobStatus.SUCCEEDED;
4546
import static org.assertj.core.api.Assertions.assertThat;
@@ -218,6 +219,33 @@ void testConflictWhenCoordinatorReturnsFalse() throws InterruptedException
218219

219220
manager.trySubmitJob(job, onComplete, executorPool.service(), SecondBoundConfiguration.parse("5s"));
220221
assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue();
222+
OperationalJobInfo tracked = tracker.get(job.jobId());
223+
assertThat(tracked).isNotNull();
224+
assertThat(tracked.status()).isEqualTo(FAILED);
225+
assertThat(tracked.failureReason()).contains("An active operation already exists");
226+
}
227+
228+
@Test
229+
void testCoordinationFailsWhenNoCoordinatorConfigured() throws InterruptedException
230+
{
231+
OperationalJobTracker tracker = new InMemoryOperationalJobTracker(4);
232+
// No coordinator is wired, yet the job requires coordination.
233+
OperationalJobManager manager = new OperationalJobManager(tracker, executorPool);
234+
CountDownLatch latch = new CountDownLatch(1);
235+
236+
OperationalJob job = createCoordinatedJob(UUIDs.timeBased());
237+
BiConsumer<OperationalJob, OperationalJobConflictException> onComplete = (j, ex) -> {
238+
assertThat(ex).isInstanceOf(OperationalJobConflictException.class);
239+
assertThat(ex.getMessage()).contains("no OperationalJobCoordinator is configured");
240+
latch.countDown();
241+
};
242+
243+
manager.trySubmitJob(job, onComplete, executorPool.service(), SecondBoundConfiguration.parse("5s"));
244+
assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue();
245+
OperationalJobInfo tracked = tracker.get(job.jobId());
246+
assertThat(tracked).isNotNull();
247+
assertThat(tracked.status()).isEqualTo(FAILED);
248+
assertThat(tracked.failureReason()).contains("no OperationalJobCoordinator is configured");
221249
}
222250

223251
@Test

0 commit comments

Comments
 (0)