This is an automated email from the ASF dual-hosted git repository.
sarankk 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 2cfea840 CASSSIDECAR-462: Split RestoreJobDiscoverer into a fast
status-check loop and a slow slice-discovery loop (#350)
2cfea840 is described below
commit 2cfea840910c53ca6055a0a86c954d3de40bb5b8
Author: Mansi Khara <[email protected]>
AuthorDate: Tue Jun 23 21:41:02 2026 -0700
CASSSIDECAR-462: Split RestoreJobDiscoverer into a fast status-check loop
and a slow slice-discovery loop (#350)
Patch by Mansi Khara; reviewed by Yifan Cai, Shailaja Koppu, Saranya
Krishnakumar for CASSSIDECAR-462
---
CHANGES.txt | 1 +
conf/sidecar.yaml | 1 +
.../RestoreJobDiscovererStatusCheckIntTest.java | 133 ++++++++++
.../sidecar/config/RestoreJobConfiguration.java | 7 +
.../config/yaml/RestoreJobConfigurationImpl.java | 37 +++
.../sidecar/restore/RestoreJobDiscoverer.java | 264 ++++++++++++++++---
.../sidecar/restore/RestoreJobDiscovererTest.java | 293 +++++++++++++++++++++
7 files changed, 697 insertions(+), 39 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 707df09c..4d0d10e6 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -6,6 +6,7 @@
0.4.0
-----
+ * Split RestoreJobDiscoverer into a fast status-check loop and a slow
slice-discovery loop (CASSSIDECAR-462)
* Ability to load Cassandra connection secrets from filesystem
(CASSSIDECAR-407)
* Sidecar should wake up immediately on the instance receiving a phase signal
instead of waiting for the discovery loop (CASSSIDECAR-454)
* Fix schema initialization race, CI test reporting, and build stability
(CASSSIDECAR-468)
diff --git a/conf/sidecar.yaml b/conf/sidecar.yaml
index 5c564f86..37cbf739 100644
--- a/conf/sidecar.yaml
+++ b/conf/sidecar.yaml
@@ -451,6 +451,7 @@ cassandra_input_validation:
blob_restore:
job_discovery_active_loop_delay: 5m
job_discovery_idle_loop_delay: 10m
+ job_discovery_status_check_interval: 1s
job_discovery_recency_days: 5
slice_process_max_concurrency: 20
restore_job_tables_ttl: 90d
diff --git
a/integration-tests/src/integrationTest/org/apache/cassandra/sidecar/restore/RestoreJobDiscovererStatusCheckIntTest.java
b/integration-tests/src/integrationTest/org/apache/cassandra/sidecar/restore/RestoreJobDiscovererStatusCheckIntTest.java
new file mode 100644
index 00000000..4013c994
--- /dev/null
+++
b/integration-tests/src/integrationTest/org/apache/cassandra/sidecar/restore/RestoreJobDiscovererStatusCheckIntTest.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.restore;
+
+import java.util.UUID;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+
+import org.junit.jupiter.api.Test;
+
+import com.datastax.driver.core.utils.UUIDs;
+import org.apache.cassandra.sidecar.common.data.RestoreJobSecrets;
+import org.apache.cassandra.sidecar.common.data.RestoreJobStatus;
+import org.apache.cassandra.sidecar.common.data.StorageCredentials;
+import
org.apache.cassandra.sidecar.common.request.data.CreateRestoreJobRequestPayload;
+import
org.apache.cassandra.sidecar.common.request.data.UpdateRestoreJobRequestPayload;
+import org.apache.cassandra.sidecar.common.server.data.QualifiedTableName;
+import
org.apache.cassandra.sidecar.common.server.utils.MillisecondBoundConfiguration;
+import org.apache.cassandra.sidecar.config.yaml.RestoreJobConfigurationImpl;
+import org.apache.cassandra.sidecar.config.yaml.SidecarConfigurationImpl;
+import org.apache.cassandra.sidecar.db.RestoreJobDatabaseAccessor;
+import org.apache.cassandra.sidecar.testing.SharedClusterIntegrationTestBase;
+
+import static org.apache.cassandra.testing.utils.AssertionUtils.loopAssert;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Verifies that the fast status-check loop owned by {@link
RestoreJobDiscoverer} (its non-static inner
+ * {@code StatusCheckTask}) reacts to a restore-job status transition within
the configured fast-loop
+ * interval, without waiting for the slow discovery loop.
+ *
+ * <p>The test seeds an in-flight job, runs one discovery pass to populate the
in-flight set,
+ * then flips the job's status directly in the {@code sidecar_internal}
keyspace and asserts
+ * that the discoverer sees the terminal status (via {@link
RestoreJobDiscoverer#inflightJobIds()})
+ * well within the slow-loop interval. The slow loop is configured to a much
larger interval so
+ * a positive observation can only be the work of the fast status-check loop.
+ */
+class RestoreJobDiscovererStatusCheckIntTest extends
SharedClusterIntegrationTestBase
+{
+ private static final QualifiedTableName JOB_TABLE = new
QualifiedTableName("ks", "tbl");
+
+ @Override
+ protected Function<SidecarConfigurationImpl.Builder,
SidecarConfigurationImpl.Builder> configurationOverrides()
+ {
+ return builder -> builder.restoreJobConfiguration(
+ RestoreJobConfigurationImpl.builder()
+
.jobDiscoveryStatusCheckInterval(MillisecondBoundConfiguration.parse("200ms"))
+ // Make slow loops effectively never fire
during the test so a
+ // positive observation can only be the
fast loop reacting.
+
.jobDiscoveryActiveLoopDelay(MillisecondBoundConfiguration.parse("1h"))
+
.jobDiscoveryIdleLoopDelay(MillisecondBoundConfiguration.parse("1h"))
+ .build());
+ }
+
+ @Override
+ protected void initializeSchemaForTest()
+ {
+ // Restore job rows live in sidecar_internal; no user schema setup
required.
+ }
+
+ @Override
+ protected void beforeTestStart()
+ {
+ waitForSchemaReady(30, TimeUnit.SECONDS);
+ }
+
+ @Test
+ void testFastLoopDetectsTerminalTransition()
+ {
+ RestoreJobDatabaseAccessor jobAccessor =
serverWrapper.injector.getInstance(RestoreJobDatabaseAccessor.class);
+ RestoreJobDiscoverer discoverer =
serverWrapper.injector.getInstance(RestoreJobDiscoverer.class);
+
+ UUID jobId = UUIDs.timeBased();
+ long expireAt = System.currentTimeMillis() +
TimeUnit.MINUTES.toMillis(5);
+ CreateRestoreJobRequestPayload createPayload =
CreateRestoreJobRequestPayload
+
.builder(genRestoreJobSecrets(), expireAt)
+ .jobId(jobId)
+ .jobAgent("int-test")
+ .build();
+ jobAccessor.create(createPayload, JOB_TABLE);
+
+ // Run one discovery pass synchronously so the job lands in the
discoverer's in-flight set.
+ // We do not wait for the configured slow loop to fire (it's pinned to
1h above).
+ discoverer.tryExecuteDiscovery();
+ assertThat(discoverer.inflightJobIds())
+ .describedAs("Discovery should track the newly created CREATED job as
in-flight")
+ .contains(jobId);
+
+ // Simulate another sidecar (or the storage system) marking the job
SUCCEEDED in the shared DB.
+ jobAccessor.update(UpdateRestoreJobRequestPayload.builder()
+
.withStatus(RestoreJobStatus.SUCCEEDED)
+ .build(),
+ jobId);
+
+ // The slow loop is effectively disabled, so this can only succeed via
the 200ms fast loop.
+ loopAssert(10, 200, () -> assertThat(discoverer.inflightJobIds())
+ .describedAs("Fast status-check loop should
detect the terminal transition")
+ .doesNotContain(jobId));
+ }
+
+ private static RestoreJobSecrets genRestoreJobSecrets()
+ {
+ return new RestoreJobSecrets(genStorageCredentials("read"),
genStorageCredentials("write"));
+ }
+
+ private static StorageCredentials genStorageCredentials(String permission)
+ {
+ long nonce = ThreadLocalRandom.current().nextLong();
+ return StorageCredentials.builder()
+ .accessKeyId(permission + "-accessKeyId-" +
nonce)
+ .secretAccessKey(permission +
"-secretAccessKey-" + nonce)
+ .sessionToken(permission + "-sessionToken-" +
nonce)
+ .region(permission + "-region-" + nonce)
+ .build();
+ }
+}
diff --git
a/server/src/main/java/org/apache/cassandra/sidecar/config/RestoreJobConfiguration.java
b/server/src/main/java/org/apache/cassandra/sidecar/config/RestoreJobConfiguration.java
index dbbb9f03..e80a45e7 100644
---
a/server/src/main/java/org/apache/cassandra/sidecar/config/RestoreJobConfiguration.java
+++
b/server/src/main/java/org/apache/cassandra/sidecar/config/RestoreJobConfiguration.java
@@ -37,6 +37,13 @@ public interface RestoreJobConfiguration
*/
MillisecondBoundConfiguration jobDiscoveryIdleLoopDelay();
+ /**
+ * @return the interval between cheap status-only checks of known
in-flight restore jobs.
+ * The check reacts on phase transitions (STAGE_READY, IMPORT_READY,
terminal) without
+ * waiting for the next full {@link #jobDiscoveryActiveLoopDelay()} /
{@link #jobDiscoveryIdleLoopDelay()} cycle.
+ */
+ MillisecondBoundConfiguration jobDiscoveryStatusCheckInterval();
+
/**
* @return the minimum number of days in the past to look up the restore
jobs
*/
diff --git
a/server/src/main/java/org/apache/cassandra/sidecar/config/yaml/RestoreJobConfigurationImpl.java
b/server/src/main/java/org/apache/cassandra/sidecar/config/yaml/RestoreJobConfigurationImpl.java
index 8ef95bf1..d4e97f8a 100644
---
a/server/src/main/java/org/apache/cassandra/sidecar/config/yaml/RestoreJobConfigurationImpl.java
+++
b/server/src/main/java/org/apache/cassandra/sidecar/config/yaml/RestoreJobConfigurationImpl.java
@@ -42,6 +42,11 @@ public class RestoreJobConfigurationImpl implements
RestoreJobConfiguration
MillisecondBoundConfiguration.parse("5m");
private static final MillisecondBoundConfiguration
DEFAULT_JOB_DISCOVERY_IDLE_LOOP_DELAY =
MillisecondBoundConfiguration.parse("10m");
+ // Caps the wake-up latency on peer Sidecars for a phase transition: the
Sidecar that
+ // receives the HTTP phase signal reacts immediately, peers learn via DB
point-read on
+ // this interval (vs. the 5m/10m slow loop). Costs N reads/sec where N is
in-flight jobs.
+ private static final MillisecondBoundConfiguration
DEFAULT_JOB_DISCOVERY_STATUS_CHECK_INTERVAL =
+ MillisecondBoundConfiguration.parse("1s");
private static final int DEFAULT_JOB_DISCOVERY_MINIMUM_RECENCY_DAYS = 5;
private static final int DEFAULT_PROCESS_MAX_CONCURRENCY = 20; // process
at most 20 slices concurrently
private static final SecondBoundConfiguration
DEFAULT_RESTORE_JOB_TABLES_TTL =
@@ -56,6 +61,8 @@ public class RestoreJobConfigurationImpl implements
RestoreJobConfiguration
protected MillisecondBoundConfiguration jobDiscoveryIdleLoopDelay;
+ protected MillisecondBoundConfiguration jobDiscoveryStatusCheckInterval;
+
@JsonProperty(value = "job_discovery_minimum_recency_days")
protected final int jobDiscoveryMinimumRecencyDays;
@@ -79,6 +86,7 @@ public class RestoreJobConfigurationImpl implements
RestoreJobConfiguration
{
this.jobDiscoveryActiveLoopDelay = builder.jobDiscoveryActiveLoopDelay;
this.jobDiscoveryIdleLoopDelay = builder.jobDiscoveryIdleLoopDelay;
+ this.jobDiscoveryStatusCheckInterval =
builder.jobDiscoveryStatusCheckInterval;
this.jobDiscoveryMinimumRecencyDays =
builder.jobDiscoveryMinimumRecencyDays;
this.processMaxConcurrency = builder.processMaxConcurrency;
this.restoreJobTablesTtl = builder.restoreJobTablesTtl;
@@ -163,6 +171,22 @@ public class RestoreJobConfigurationImpl implements
RestoreJobConfiguration
setJobDiscoveryIdleLoopDelay(new
MillisecondBoundConfiguration(jobDiscoveryIdleLoopDelayMillis,
TimeUnit.MILLISECONDS));
}
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ @JsonProperty(value = "job_discovery_status_check_interval")
+ public MillisecondBoundConfiguration jobDiscoveryStatusCheckInterval()
+ {
+ return jobDiscoveryStatusCheckInterval;
+ }
+
+ @JsonProperty(value = "job_discovery_status_check_interval")
+ public void
setJobDiscoveryStatusCheckInterval(MillisecondBoundConfiguration
jobDiscoveryStatusCheckInterval)
+ {
+ this.jobDiscoveryStatusCheckInterval = jobDiscoveryStatusCheckInterval;
+ }
+
/**
* {@inheritDoc}
*/
@@ -314,6 +338,7 @@ public class RestoreJobConfigurationImpl implements
RestoreJobConfiguration
private SecondBoundConfiguration slowTaskReportDelay =
DEFAULT_RESTORE_JOB_SLOW_TASK_REPORT_DELAY;
private MillisecondBoundConfiguration jobDiscoveryActiveLoopDelay =
DEFAULT_JOB_DISCOVERY_ACTIVE_LOOP_DELAY;
private MillisecondBoundConfiguration jobDiscoveryIdleLoopDelay =
DEFAULT_JOB_DISCOVERY_IDLE_LOOP_DELAY;
+ private MillisecondBoundConfiguration jobDiscoveryStatusCheckInterval
= DEFAULT_JOB_DISCOVERY_STATUS_CHECK_INTERVAL;
private int jobDiscoveryMinimumRecencyDays =
DEFAULT_JOB_DISCOVERY_MINIMUM_RECENCY_DAYS;
private int processMaxConcurrency = DEFAULT_PROCESS_MAX_CONCURRENCY;
private SecondBoundConfiguration restoreJobTablesTtl =
DEFAULT_RESTORE_JOB_TABLES_TTL;
@@ -353,6 +378,18 @@ public class RestoreJobConfigurationImpl implements
RestoreJobConfiguration
return update(b -> b.jobDiscoveryIdleLoopDelay =
jobDiscoveryIdleLoopDelay);
}
+ /**
+ * Sets the {@code jobDiscoveryStatusCheckInterval} and returns a
reference to this Builder enabling
+ * method chaining.
+ *
+ * @param jobDiscoveryStatusCheckInterval the {@code
jobDiscoveryStatusCheckInterval} to set
+ * @return a reference to this Builder
+ */
+ public Builder
jobDiscoveryStatusCheckInterval(MillisecondBoundConfiguration
jobDiscoveryStatusCheckInterval)
+ {
+ return update(b -> b.jobDiscoveryStatusCheckInterval =
jobDiscoveryStatusCheckInterval);
+ }
+
/**
* Sets the {@code jobDiscoveryMinimumRecencyDays} and returns a
reference to this Builder enabling
* method chaining.
diff --git
a/server/src/main/java/org/apache/cassandra/sidecar/restore/RestoreJobDiscoverer.java
b/server/src/main/java/org/apache/cassandra/sidecar/restore/RestoreJobDiscoverer.java
index 73c6cabe..7bfd73e5 100644
---
a/server/src/main/java/org/apache/cassandra/sidecar/restore/RestoreJobDiscoverer.java
+++
b/server/src/main/java/org/apache/cassandra/sidecar/restore/RestoreJobDiscoverer.java
@@ -27,7 +27,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
-import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
import com.google.common.annotations.VisibleForTesting;
@@ -86,9 +86,26 @@ public class RestoreJobDiscoverer implements PeriodicTask,
RingTopologyChangeLis
private final RestoreMetrics metrics;
private final JobIdsByDay jobIdsByDay;
private final RingTopologyRefresher ringTopologyRefresher;
- private final AtomicBoolean isExecuting = new AtomicBoolean(false);
- private String localDatacenter = null;
- private int inflightJobsCount = 0;
+ /**
+ * Single mutual-exclusion gate for restore-job discovery. The slow loop
({@link #tryExecuteDiscovery})
+ * acquires it via {@link ReentrantLock#lock()} — it blocks briefly if a
fast-loop tick or
+ * {@link #processJobNow} is in progress, but it never skips. The fast
{@link StatusCheckTask}
+ * and {@link #processJobNow} acquire it via {@link
ReentrantLock#tryLock()} — they skip if the
+ * slow loop is in. This encodes the priority asymmetry (slow =
correctness, fast = best-effort)
+ * directly at each call site and keeps {@link JobIdsByDay} accessed by
exactly one thread at a
+ * time without any internal locking.
+ */
+ private final ReentrantLock executionLock = new ReentrantLock();
+ private final StatusCheckTask statusCheckTask = new StatusCheckTask();
+ // Volatile because it is read by the periodic-task scheduler thread (via
scheduleDecision /
+ // hasInflightJobs / delay) without holding executionLock. Written under
the lock at the end of
+ // every slow- and fast-loop pass to the freshly recounted in-flight job
count. Reads outside
+ // the lock can be slightly stale (one pass behind) but never torn or
drifted.
+ private volatile int inflightJobsCount = 0;
+ // Volatile because initLocalDatacenterMaybe writes it without holding the
lock (the JMX/CQL
+ // call may block, so we keep it off the critical section). Idempotent —
concurrent writers
+ // would assign the same value.
+ private volatile String localDatacenter = null;
private int jobDiscoveryRecencyDays;
private PeriodicTaskExecutor periodicTaskExecutor;
@@ -149,6 +166,9 @@ public class RestoreJobDiscoverer implements PeriodicTask,
RingTopologyChangeLis
{
this.periodicTaskExecutor = executor;
PeriodicTask.super.deploy(vertx, executor);
+ // Co-deploy the fast status-check loop. It reads only job.status for
already-known
+ // in-flight jobs and reacts to phase transitions without waiting for
the slow loop.
+ executor.schedule(statusCheckTask);
}
@Override
@@ -176,43 +196,31 @@ public class RestoreJobDiscoverer implements
PeriodicTask, RingTopologyChangeLis
}
/**
- * Try to execute the job discovery task in the blocking way. It returns
immediately, if another thread is executing already.
- * The method can be invoked by external call-sites.
+ * Run the slow discovery pass synchronously, blocking until {@link
#executionLock} is free.
+ * Unlike a fast-loop tick or a {@link #processJobNow} wake-up, the slow
pass never skips —
+ * it is the correctness guarantee for restore-job discovery.
*/
public void tryExecuteDiscovery()
{
- if (!isExecuting.compareAndSet(false, true))
- {
- LOGGER.debug("Another thread is executing the restore job
discovery already. Skipping...");
- return;
- }
-
+ executionLock.lock();
try
{
executeInternal();
}
finally
{
- isExecuting.set(false);
+ executionLock.unlock();
}
}
private boolean shouldSkip()
{
- boolean shouldSkip = !sidecarSchema.isInitialized();
- if (shouldSkip)
+ if (!sidecarSchema.isInitialized())
{
LOGGER.trace("Skipping restore job discovering due to
sidecarSchema not initialized");
+ return true;
}
- boolean executing = isExecuting.get();
- shouldSkip = shouldSkip || executing;
- if (executing)
- {
- LOGGER.trace("Skipping restore job discovering due to overlapping
execution of this task");
- }
-
- // skip the task
- return shouldSkip;
+ return false;
}
private void executeInternal()
@@ -221,10 +229,8 @@ public class RestoreJobDiscoverer implements PeriodicTask,
RingTopologyChangeLis
LOGGER.debug("Discovering restore jobs. " +
"inflightJobsCount={} jobDiscoveryRecencyDays={}",
- inflightJobsCount, delay(), jobDiscoveryRecencyDays);
+ inflightJobsCount, jobDiscoveryRecencyDays);
- // reset in-flight jobs
- inflightJobsCount = 0;
RunContext context = new RunContext();
List<RestoreJob> restoreJobs =
restoreJobDatabaseAccessor.findAllRecent(context.nowMillis,
jobDiscoveryRecencyDays);
RestoreJobManagerGroup restoreJobManagers =
restoreJobManagerGroupSingleton.get();
@@ -242,6 +248,9 @@ public class RestoreJobDiscoverer implements PeriodicTask,
RingTopologyChangeLis
jobIdsByDay.cleanupMaybe();
// resize to the earliestInDays with the minimum days as defined in
jobDiscoveryMinimumRecencyDays
jobDiscoveryRecencyDays = Math.max(context.earliestInDays,
restoreJobConfig.jobDiscoveryMinimumRecencyDays());
+ // Recount and publish under the lock so unsynchronized readers
(scheduleDecision /
+ // hasInflightJobs) observe a consistent post-pass value.
+ inflightJobsCount = jobIdsByDay.inflightJobIds().size();
LOGGER.info("Exit job discovery. " +
"inflightJobsCount={} " +
"delay={} " +
@@ -252,6 +261,69 @@ public class RestoreJobDiscoverer implements PeriodicTask,
RingTopologyChangeLis
metrics.activeJobs.metric.setValue(inflightJobsCount);
}
+ /**
+ * Snapshot of restore job IDs currently considered in-flight
(non-terminal). Acquires
+ * {@link #executionLock} so external callers (tests, the fast loop) can
use it without
+ * holding the gate themselves; reentrant for callers already inside the
lock.
+ */
+ Set<UUID> inflightJobIds()
+ {
+ executionLock.lock();
+ try
+ {
+ return jobIdsByDay.inflightJobIds();
+ }
+ finally
+ {
+ executionLock.unlock();
+ }
+ }
+
+ /**
+ * Applies the same transition handling as a full discovery pass, but only
for a single freshly-read job.
+ * Called by the fast status-check loop when it detects a status change on
a known in-flight job.
+ * A no-op when the job's status matches the last-known status cached
during discovery.
+ */
+ void handleStatusTransition(RestoreJob currentJob)
+ {
+ executionLock.lock();
+ try
+ {
+ int day = currentJob.createdAt.getDaysSinceEpoch();
+ RestoreJobStatus previousStatus =
jobIdsByDay.getKnownStatus(currentJob.jobId, day);
+ if (previousStatus == null)
+ {
+ // Job isn't tracked yet; let the next full discovery pass
pick it up so that
+ // the in-flight set and derived state stay consistent.
+ return;
+ }
+ if (previousStatus == currentJob.status)
+ {
+ return;
+ }
+
+ RunContext context = new RunContext();
+ RestoreJobManagerGroup managers =
restoreJobManagerGroupSingleton.get();
+ try
+ {
+ processOneJob(currentJob, managers, context);
+ }
+ catch (Exception e)
+ {
+ LOGGER.warn("Exception on processing status transition.
jobId={} previousStatus={} newStatus={}",
+ currentJob.jobId, previousStatus,
currentJob.status, e);
+ }
+ // Recount and publish under the lock so the volatile counter and
the gauge reflect
+ // the fast-loop transition immediately, without waiting for the
next slow-loop pass.
+ inflightJobsCount = jobIdsByDay.inflightJobIds().size();
+ metrics.activeJobs.metric.setValue(inflightJobsCount);
+ }
+ finally
+ {
+ executionLock.unlock();
+ }
+ }
+
@Override
public void onRingTopologyChanged(String keyspace,
TokenRangeReplicasResponse oldTopology, TokenRangeReplicasResponse newTopology)
{
@@ -348,7 +420,6 @@ public class RestoreJobDiscoverer implements PeriodicTask,
RingTopologyChangeLis
context.earliestInDays = Math.max(context.earliestInDays,
delta(context.today, job.createdAt));
restoreJobManagers.updateRestoreJob(job);
processSidecarManagedJobMaybe(job);
- inflightJobsCount += 1;
break;
case FAILED:
case ABORTED:
@@ -550,6 +621,15 @@ public class RestoreJobDiscoverer implements PeriodicTask,
RingTopologyChangeLis
return Math.abs(date1.getDaysSinceEpoch() - date2.getDaysSinceEpoch());
}
+ /**
+ * Per-day cache of the latest known {@link RestoreJobStatus} for each
job, plus a per-day
+ * set of jobs whose slices have already been discovered.
+ *
+ * <p>Intentionally <b>not</b> internally synchronized. All callers must
hold
+ * {@link RestoreJobDiscoverer#executionLock}; that single outer gate is
the sole source of
+ * thread safety for this state. The asymmetric {@code lock()}/{@code
tryLock()} pattern at
+ * the call sites guarantees exactly one thread is inside this class at a
time.
+ */
static class JobIdsByDay
{
private final Map<Integer, Map<UUID, RestoreJobStatus>> jobsByDay =
new HashMap<>();
@@ -593,6 +673,12 @@ public class RestoreJobDiscoverer implements PeriodicTask,
RingTopologyChangeLis
.contains(job.jobId);
}
+ RestoreJobStatus getKnownStatus(UUID jobId, int day)
+ {
+ Map<UUID, RestoreJobStatus> jobs = jobsByDay.get(day);
+ return jobs == null ? null : jobs.get(jobId);
+ }
+
void unsetSlicesDiscovered(RestoreJob job)
{
int day = populateDiscoveredDay(job);
@@ -614,6 +700,26 @@ public class RestoreJobDiscoverer implements PeriodicTask,
RingTopologyChangeLis
discoveredDays.clear();
}
+ /**
+ * Snapshot of jobIds whose latest known status is non-terminal.
Caller must hold
+ * {@link RestoreJobDiscoverer#executionLock}.
+ */
+ Set<UUID> inflightJobIds()
+ {
+ Set<UUID> result = new HashSet<>();
+ for (Map<UUID, RestoreJobStatus> jobs : jobsByDay.values())
+ {
+ for (Map.Entry<UUID, RestoreJobStatus> entry : jobs.entrySet())
+ {
+ if (!entry.getValue().isFinal())
+ {
+ result.add(entry.getKey());
+ }
+ }
+ }
+ return result;
+ }
+
private int populateDiscoveredDay(RestoreJob job)
{
int day = job.createdAt.getDaysSinceEpoch();
@@ -634,23 +740,18 @@ public class RestoreJobDiscoverer implements
PeriodicTask, RingTopologyChangeLis
* This is safe to call concurrently with the discovery loop — the DB
write is the durable
* source of truth, and duplicate processing is deduplicated by existing
idempotency checks.
*
- * <p>The body is gated by the same {@link #isExecuting} CAS used by
- * {@link #tryExecuteDiscovery()} so the discovery loop and the wake-up
path are mutually
- * exclusive. The CAS provides the happens-before edge that makes the
otherwise non-thread-safe
- * {@link JobIdsByDay} state safe across both entry points. If a slow-loop
pass is already
- * running when a wake-up arrives, the wake-up is skipped — the slow loop
or the next wake-up
- * will pick the transition up.
+ * <p>Acquires {@link #executionLock} via {@link ReentrantLock#tryLock()}:
if the slow loop or
+ * the fast loop is already in, the wake-up is skipped — the slow loop
reads the DB freshly
+ * on its next pass and will pick up the same transition, so dropping the
wake-up is benign.
*
* @param restoreJob the restore job to process immediately
*/
public void processJobNow(RestoreJob restoreJob)
{
- // Resolve local DC outside the CAS gate; it can block on JMX/CQL and
the assignment itself is atomic.
initLocalDatacenterMaybe();
- if (!isExecuting.compareAndSet(false, true))
+ if (!executionLock.tryLock())
{
- LOGGER.debug("Another thread is executing the restore job
discovery already. Skipping wake-up. jobId={}",
- restoreJob.jobId);
+ LOGGER.debug("Discovery is already running. Skipping wake-up.
jobId={}", restoreJob.jobId);
return;
}
try
@@ -661,7 +762,7 @@ public class RestoreJobDiscoverer implements PeriodicTask,
RingTopologyChangeLis
}
finally
{
- isExecuting.set(false);
+ executionLock.unlock();
}
}
@@ -677,6 +778,12 @@ public class RestoreJobDiscoverer implements PeriodicTask,
RingTopologyChangeLis
return jobDiscoveryRecencyDays;
}
+ @VisibleForTesting
+ StatusCheckTask statusCheckTask()
+ {
+ return statusCheckTask;
+ }
+
static class RunContext
{
long nowMillis = System.currentTimeMillis();
@@ -685,4 +792,83 @@ public class RestoreJobDiscoverer implements PeriodicTask,
RingTopologyChangeLis
int abortedJobs = 0;
int expiredJobs = 0;
}
+
+ /**
+ * Fast status-check loop that complements the slow {@link
RestoreJobDiscoverer} discovery loop.
+ *
+ * <p>Runs on {@link
RestoreJobConfiguration#jobDiscoveryStatusCheckInterval()} (default ~1s) and
+ * performs cheap point-reads of {@code job.status} for the in-flight jobs
the discoverer already knows
+ * about. When a status transition is detected the task delegates to
+ * {@link RestoreJobDiscoverer#handleStatusTransition(RestoreJob)} so peer
Sidecar instances react to
+ * phase signals without waiting for the slow full-scan loop. The slow
loop remains the correctness
+ * and recovery guarantee (new-job discovery, expired-job aborts, missed
signals).
+ *
+ * <p>Implemented as a non-static inner class so it has direct access to
the discoverer's state and
+ * shares its lifecycle — it isn't an independent component.
+ */
+ class StatusCheckTask implements PeriodicTask
+ {
+ @Override
+ public DurationSpec delay()
+ {
+ return restoreJobConfig.jobDiscoveryStatusCheckInterval();
+ }
+
+ @Override
+ public ScheduleDecision scheduleDecision()
+ {
+ if (!sidecarSchema.isInitialized())
+ {
+ return ScheduleDecision.SKIP;
+ }
+ if (inflightJobsCount == 0)
+ {
+ return ScheduleDecision.SKIP;
+ }
+ return ScheduleDecision.EXECUTE;
+ }
+
+ @Override
+ public void execute(Promise<Void> promise)
+ {
+ // Resolve local DC once per tick before acquiring the lock — the
JMX/CQL call may
+ // block, and resolving it inside handleStatusTransition would
re-attempt the call
+ // for every in-flight job whenever Cassandra is unavailable.
+ initLocalDatacenterMaybe();
+ // tryLock — skip this tick if the slow loop or processJobNow is
in. Best-effort by
+ // design; the next tick (or the slow loop's next pass) will pick
up any transition we
+ // miss.
+ if (!executionLock.tryLock())
+ {
+ promise.tryComplete();
+ return;
+ }
+
+ try
+ {
+ for (UUID jobId : jobIdsByDay.inflightJobIds())
+ {
+ try
+ {
+ RestoreJob current =
restoreJobDatabaseAccessor.find(jobId);
+ if (current == null)
+ {
+ continue;
+ }
+ handleStatusTransition(current);
+ }
+ catch (Exception e)
+ {
+ // Do not fail the whole pass on one job; the slow
loop will retry it.
+ LOGGER.warn("Exception on status check for jobId={}",
jobId, e);
+ }
+ }
+ }
+ finally
+ {
+ executionLock.unlock();
+ promise.tryComplete();
+ }
+ }
+ }
}
diff --git
a/server/src/test/java/org/apache/cassandra/sidecar/restore/RestoreJobDiscovererTest.java
b/server/src/test/java/org/apache/cassandra/sidecar/restore/RestoreJobDiscovererTest.java
index 9eec31fc..a0e946d6 100644
---
a/server/src/test/java/org/apache/cassandra/sidecar/restore/RestoreJobDiscovererTest.java
+++
b/server/src/test/java/org/apache/cassandra/sidecar/restore/RestoreJobDiscovererTest.java
@@ -25,6 +25,7 @@ import java.util.List;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@@ -438,6 +439,298 @@ class RestoreJobDiscovererTest
assertThat(captured.jobId).isEqualTo(jobId);
}
+ @Test
+ void testInflightJobIdsReturnsNonTerminalJobsAfterDiscovery()
+ {
+ when(sidecarSchema.isInitialized()).thenReturn(true);
+ UUID activeJobId = UUIDs.timeBased();
+ UUID terminalJobId = UUIDs.timeBased();
+ when(mockJobAccessor.findAllRecent(anyLong(),
anyInt())).thenReturn(List.of(
+ RestoreJob.builder()
+ .createdAt(RestoreJob.toLocalDate(activeJobId))
+ .jobId(activeJobId)
+ .jobAgent("agent")
+ .jobStatus(RestoreJobStatus.CREATED)
+ .expireAt(new Date(System.currentTimeMillis() + 10000L))
+ .build(),
+ RestoreJob.builder()
+ .createdAt(RestoreJob.toLocalDate(terminalJobId))
+ .jobId(terminalJobId)
+ .jobAgent("agent")
+ .jobStatus(RestoreJobStatus.SUCCEEDED)
+ .expireAt(new Date(System.currentTimeMillis() + 10000L))
+ .build()));
+ executeBlocking();
+
+ assertThat(loop.inflightJobIds()).containsOnly(activeJobId);
+ }
+
+ @Test
+ void testHandleStatusTransitionIsNoOpWhenJobNotTracked()
+ {
+ when(sidecarSchema.isInitialized()).thenReturn(true);
+ UUID jobId = UUIDs.timeBased();
+ RestoreJob job = RestoreJob.builder()
+ .createdAt(RestoreJob.toLocalDate(jobId))
+ .jobId(jobId)
+ .jobAgent("agent")
+ .jobStatus(RestoreJobStatus.STAGE_READY)
+ .expireAt(new
Date(System.currentTimeMillis() + 10000L))
+ .build();
+
+ loop.handleStatusTransition(job);
+
+ verify(mockManagers, never()).updateRestoreJob(any());
+ verify(mockManagers, never()).removeJobInternal(any());
+ }
+
+ @Test
+ void testHandleStatusTransitionIsNoOpWhenStatusUnchanged()
+ {
+ when(sidecarSchema.isInitialized()).thenReturn(true);
+ UUID jobId = UUIDs.timeBased();
+ RestoreJob initial = RestoreJob.builder()
+
.createdAt(RestoreJob.toLocalDate(jobId))
+ .jobId(jobId)
+ .jobAgent("agent")
+ .jobStatus(RestoreJobStatus.CREATED)
+ .expireAt(new
Date(System.currentTimeMillis() + 10000L))
+ .build();
+ when(mockJobAccessor.findAllRecent(anyLong(),
anyInt())).thenReturn(List.of(initial));
+ executeBlocking();
+ Mockito.reset(mockManagers);
+
+ loop.handleStatusTransition(initial);
+
+ verify(mockManagers, never()).updateRestoreJob(any());
+ verify(mockManagers, never()).removeJobInternal(any());
+ }
+
+ @Test
+ void testHandleStatusTransitionFinalizesOnTerminalTransition()
+ {
+ when(sidecarSchema.isInitialized()).thenReturn(true);
+ UUID jobId = UUIDs.timeBased();
+ RestoreJob active = RestoreJob.builder()
+ .createdAt(RestoreJob.toLocalDate(jobId))
+ .jobId(jobId)
+ .jobAgent("agent")
+ .jobStatus(RestoreJobStatus.IMPORT_READY)
+ .expireAt(new
Date(System.currentTimeMillis() + 10000L))
+ .build();
+ when(mockJobAccessor.findAllRecent(anyLong(),
anyInt())).thenReturn(List.of(active));
+ executeBlocking();
+ Mockito.reset(mockManagers);
+
+ RestoreJob succeeded =
active.unbuild().jobStatus(RestoreJobStatus.SUCCEEDED).build();
+ loop.handleStatusTransition(succeeded);
+
+ verify(mockManagers).removeJobInternal(succeeded);
+ }
+
+ @Test
+ void testHandleStatusTransitionUpdatesManagersOnActiveTransition()
+ {
+ when(sidecarSchema.isInitialized()).thenReturn(true);
+ UUID jobId = UUIDs.timeBased();
+ RestoreJob created = RestoreJob.builder()
+
.createdAt(RestoreJob.toLocalDate(jobId))
+ .jobId(jobId)
+ .jobAgent("agent")
+ .jobStatus(RestoreJobStatus.CREATED)
+ .expireAt(new
Date(System.currentTimeMillis() + 10000L))
+ .build();
+ when(mockJobAccessor.findAllRecent(anyLong(),
anyInt())).thenReturn(List.of(created));
+ executeBlocking();
+ Mockito.reset(mockManagers);
+
+ RestoreJob stageReady =
created.unbuild().jobStatus(RestoreJobStatus.STAGE_READY).build();
+ loop.handleStatusTransition(stageReady);
+
+ verify(mockManagers).updateRestoreJob(stageReady);
+ }
+
+ @Test
+ void testStatusCheckTaskSkipsWhenNoInflightJobs()
+ {
+ when(sidecarSchema.isInitialized()).thenReturn(true);
+
assertThat(loop.statusCheckTask().scheduleDecision()).isEqualTo(ScheduleDecision.SKIP);
+ }
+
+ @Test
+ void testStatusCheckTaskSkipsWhenSchemaNotInitialized()
+ {
+ when(sidecarSchema.isInitialized()).thenReturn(false);
+
assertThat(loop.statusCheckTask().scheduleDecision()).isEqualTo(ScheduleDecision.SKIP);
+ }
+
+ @Test
+ void testStatusCheckTaskExecutesAgainstInflightJobs()
+ {
+ when(sidecarSchema.isInitialized()).thenReturn(true);
+ UUID jobId = UUIDs.timeBased();
+ RestoreJob created = RestoreJob.builder()
+
.createdAt(RestoreJob.toLocalDate(jobId))
+ .jobId(jobId)
+ .jobAgent("agent")
+ .jobStatus(RestoreJobStatus.CREATED)
+ .expireAt(new
Date(System.currentTimeMillis() + 10000L))
+ .build();
+ when(mockJobAccessor.findAllRecent(anyLong(),
anyInt())).thenReturn(List.of(created));
+ executeBlocking();
+
assertThat(loop.statusCheckTask().scheduleDecision()).isEqualTo(ScheduleDecision.EXECUTE);
+
+ // Flip the DB-side status; the task should point-read each in-flight
job and dispatch the transition
+ RestoreJob stageReady =
created.unbuild().jobStatus(RestoreJobStatus.STAGE_READY).build();
+ when(mockJobAccessor.find(jobId)).thenReturn(stageReady);
+ Mockito.reset(mockManagers);
+
+ Promise<Void> promise = Promise.promise();
+ loop.statusCheckTask().execute(promise);
+
+ verify(mockJobAccessor).find(jobId);
+ verify(mockManagers).updateRestoreJob(stageReady);
+ assertThat(promise.future().succeeded()).isTrue();
+ }
+
+ @Test
+ void testStatusCheckTaskTolerantOfMissingOrFailingJobs()
+ {
+ when(sidecarSchema.isInitialized()).thenReturn(true);
+ UUID missingJobId = UUIDs.timeBased();
+ UUID failingJobId = UUIDs.timeBased();
+ RestoreJob missingJob = RestoreJob.builder()
+
.createdAt(RestoreJob.toLocalDate(missingJobId))
+ .jobId(missingJobId)
+ .jobAgent("agent")
+ .jobStatus(RestoreJobStatus.CREATED)
+ .expireAt(new
Date(System.currentTimeMillis() + 10000L))
+ .build();
+ RestoreJob failingJob = RestoreJob.builder()
+
.createdAt(RestoreJob.toLocalDate(failingJobId))
+ .jobId(failingJobId)
+ .jobAgent("agent")
+ .jobStatus(RestoreJobStatus.CREATED)
+ .expireAt(new
Date(System.currentTimeMillis() + 10000L))
+ .build();
+ when(mockJobAccessor.findAllRecent(anyLong(),
anyInt())).thenReturn(List.of(missingJob, failingJob));
+ executeBlocking();
+ when(mockJobAccessor.find(missingJobId)).thenReturn(null);
// job vanished
+ when(mockJobAccessor.find(failingJobId)).thenThrow(new
RuntimeException("db down")); // transient error
+
+ Promise<Void> promise = Promise.promise();
+ loop.statusCheckTask().execute(promise);
+
+ assertThat(promise.future().succeeded())
+ .describedAs("a missing or failing job should not abort the whole
pass")
+ .isTrue();
+ }
+
+ // Hammers the slow discovery loop and the fast status-check task
concurrently against
+ // shared JobIdsByDay state, with statuses flipping under their feet, to
verify that
+ // per-method synchronization on JobIdsByDay is sufficient. Both loops
mutate the map
+ // through processOneJob via different gates (isExecuting vs
checkerExecuting), so they
+ // can interleave on every operation. The test asserts no exception leaks
out of either
+ // loop and that the in-flight view converges to the latest committed
state once both
+ // settle.
+ @Test
+ void testSlowAndFastLoopRaceDoesNotCorruptState() throws Exception
+ {
+ when(sidecarSchema.isInitialized()).thenReturn(true);
+ UUID a = UUIDs.timeBased();
+ UUID b = UUIDs.timeBased();
+ UUID c = UUIDs.timeBased();
+ AtomicReference<RestoreJobStatus> sa = new
AtomicReference<>(RestoreJobStatus.CREATED);
+ AtomicReference<RestoreJobStatus> sb = new
AtomicReference<>(RestoreJobStatus.STAGE_READY);
+ AtomicReference<RestoreJobStatus> sc = new
AtomicReference<>(RestoreJobStatus.IMPORT_READY);
+
+ when(mockJobAccessor.findAllRecent(anyLong(),
anyInt())).thenAnswer(inv ->
+ List.of(jobWith(a, sa.get()), jobWith(b, sb.get()), jobWith(c,
sc.get())));
+ when(mockJobAccessor.find(a)).thenAnswer(inv -> jobWith(a, sa.get()));
+ when(mockJobAccessor.find(b)).thenAnswer(inv -> jobWith(b, sb.get()));
+ when(mockJobAccessor.find(c)).thenAnswer(inv -> jobWith(c, sc.get()));
+
+ // Prime the in-flight set so the fast loop has work on its first run.
+ executeBlocking();
+
+ int iterations = 200;
+ AtomicReference<Throwable> err = new AtomicReference<>();
+ CountDownLatch start = new CountDownLatch(1);
+ Thread slow = new Thread(() -> {
+ try
+ {
+ start.await();
+ for (int i = 0; i < iterations && err.get() == null; i++)
+ {
+ executeBlocking();
+ }
+ }
+ catch (Throwable t)
+ {
+ err.compareAndSet(null, t);
+ }
+ }, "race-slow-loop");
+ Thread fast = new Thread(() -> {
+ try
+ {
+ start.await();
+ for (int i = 0; i < iterations && err.get() == null; i++)
+ {
+ if ((i & 1) == 0)
+ {
+ sa.set(RestoreJobStatus.STAGE_READY);
+ sb.set(RestoreJobStatus.IMPORT_READY);
+ }
+ else
+ {
+ sa.set(RestoreJobStatus.CREATED);
+ sb.set(RestoreJobStatus.STAGE_READY);
+ }
+ Promise<Void> p = Promise.promise();
+ loop.statusCheckTask().execute(p);
+ }
+ }
+ catch (Throwable t)
+ {
+ err.compareAndSet(null, t);
+ }
+ }, "race-fast-loop");
+
+ slow.start();
+ fast.start();
+ start.countDown();
+ slow.join(TimeUnit.SECONDS.toMillis(10));
+ fast.join(TimeUnit.SECONDS.toMillis(10));
+
+ assertThat(err.get())
+ .describedAs("slow and fast loops must not corrupt jobIdsByDay under
contention")
+ .isNull();
+
+ // Settle the world. After one more slow pass with all jobs terminal,
the in-flight
+ // view must converge to empty regardless of how the race played out.
+ sa.set(RestoreJobStatus.SUCCEEDED);
+ sb.set(RestoreJobStatus.SUCCEEDED);
+ sc.set(RestoreJobStatus.SUCCEEDED);
+ executeBlocking();
+ assertThat(loop.inflightJobIds())
+ .describedAs("in-flight set converges after settling all jobs to
SUCCEEDED")
+ .isEmpty();
+ assertThat(metrics.server().restore().activeJobs.metric.getValue())
+ .describedAs("active-jobs gauge converges to 0 after settling")
+ .isZero();
+ }
+
+ private static RestoreJob jobWith(UUID id, RestoreJobStatus status)
+ {
+ return RestoreJob.builder()
+ .createdAt(RestoreJob.toLocalDate(id))
+ .jobId(id)
+ .jobAgent("agent")
+ .jobStatus(status)
+ .expireAt(new Date(System.currentTimeMillis() +
10000L))
+ .build();
+ }
+
private RestoreJobConfiguration testConfig()
{
RestoreJobConfiguration restoreJobConfiguration =
mock(RestoreJobConfiguration.class);
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]