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 965af15e CASSSIDECAR-463: Add fast_forward_enabled flag to enable
eager pipelining of staging and importing in coordinated writes (#376)
965af15e is described below
commit 965af15e29d157bd1e1e7d3de706b4e3c62cf039
Author: Mansi Khara <[email protected]>
AuthorDate: Tue Aug 25 13:52:58 2026 -0400
CASSSIDECAR-463: Add fast_forward_enabled flag to enable eager pipelining
of staging and importing in coordinated writes (#376)
Patch by Mansi Khara; reviewed by Yifan Cai, Saranya Krishnakumar for
CASSSIDECAR-463
---
CHANGES.txt | 3 +-
NEWS.txt | 11 +++
.../sidecar/common/data/RestoreJobConstants.java | 14 ++++
.../data/CreateRestoreJobRequestPayload.java | 45 ++++++++++-
.../CreateRestoreJobRequestPayloadTest.java | 60 ++++++++++++--
.../apache/cassandra/sidecar/db/RestoreJob.java | 59 +++++++++++++-
.../sidecar/db/RestoreJobDatabaseAccessor.java | 2 +
.../sidecar/db/schema/RestoreJobsSchema.java | 8 +-
.../handlers/restore/UpdateRestoreJobHandler.java | 4 +-
.../restore/RestoreJobConsistencyChecker.java | 17 ++--
.../sidecar/restore/RestoreJobDiscoverer.java | 28 +++++--
.../sidecar/restore/RestoreProcessor.java | 5 +-
.../sidecar/restore/RestoreRangeTask.java | 10 +--
.../db/RestoreJobDatabaseAccessorIntTest.java | 29 +++++++
.../cassandra/sidecar/db/RestoreJobTest.java | 64 +++++++++++++++
.../cassandra/sidecar/db/SidecarSchemaTest.java | 29 +++----
.../restore/UpdateRestoreJobHandlerTest.java | 39 +++++++++
.../sidecar/restore/RestoreJobDiscovererTest.java | 92 +++++++++++++++++++---
.../sidecar/restore/RestoreProcessorTest.java | 42 ++++++++++
.../sidecar/restore/RestoreRangeTaskTest.java | 63 +++++++++++++++
20 files changed, 560 insertions(+), 64 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 3772e216..588af8c4 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,6 +1,7 @@
0.5.0
-----
- * CachingSchemaStore should be reloaded when Schemastore kafka configs are
changed (CASSSIDECAR-493)
+ * Add fast_forward_enabled flag to enable eager pipelining of staging and
importing in coordinated writes (CASSSIDECAR-463)
+ * CachingSchemaStore should be reloaded when Schemastore kafka configs are
changed (CASSSIDECAR-493)
* Support retrying on HTTP 429 responses and fix duplicate immediate
execution in RequestExecutor retry scheduling (CASSSIDECAR-465)
* Add RFC 6902-inspired JSON Patch support to ConfigurationManager
(CASSSIDECAR-429)
* Implement job coordination for cluster-wide operations (CASSSIDECAR-377)
diff --git a/NEWS.txt b/NEWS.txt
index 788b4fd8..a304e695 100644
--- a/NEWS.txt
+++ b/NEWS.txt
@@ -3,6 +3,9 @@
Upgrading
---------
+ - In CASSSIDECAR-463, a new internal table (restore_job_v7) is created to
track the fastForwardEnabled
+ option of restore jobs. It replaces restore_job_v6. Spark jobs
performing S3 restores should be paused
+ during sidecar upgrade to ensure in-flight restore job state is not lost.
- In CASSSIDECAR-461, the list snapshot endpoint now validates the
snapshot name, aligned with
the validation introduced by CASSANDRA-21389. A new
cassandra_input_validation.allowed_chars_for_snapshot_name
setting in sidecar.yaml controls the accepted pattern; the default is
"[a-zA-Z0-9_.+-]{1,255}".
@@ -13,6 +16,14 @@ Upgrading
New features
------------
+ - Eager pipelining of the staging and the importing phases of coordinated
restore jobs
+ (CASSSIDECAR-463). Set fastForwardEnabled to true when creating a
restore job so that Sidecar stages
+ slices while the job is still in CREATED status and imports the staged
slices once the job enters
+ STAGED status, instead of waiting for the STAGE_READY and IMPORT_READY
signals. Only enable it when
+ the caller can accept that the imported data becomes immediately visible
and that importing cannot
+ be rolled back. Slices uploaded while the job is in CREATED status are
picked up by the periodic job
+ discovery, and, until all the declared slices are found, by every job
progress poll; set sliceCount
+ when creating the job and poll the progress to keep staging close to the
uploads.
- Snapshot name validation on the list snapshot endpoint
(CASSSIDECAR-461), with a configurable
allowed_chars_for_snapshot_name pattern under cassandra_input_validation
in sidecar.yaml.
diff --git
a/client-common/src/main/java/org/apache/cassandra/sidecar/common/data/RestoreJobConstants.java
b/client-common/src/main/java/org/apache/cassandra/sidecar/common/data/RestoreJobConstants.java
index 21991cf7..4609ffc4 100644
---
a/client-common/src/main/java/org/apache/cassandra/sidecar/common/data/RestoreJobConstants.java
+++
b/client-common/src/main/java/org/apache/cassandra/sidecar/common/data/RestoreJobConstants.java
@@ -48,6 +48,20 @@ public class RestoreJobConstants
* In this case, there is an external coordinator that manges the restore
job in each datacenter.
*/
public static final String JOB_RESTORE_TO_LOCAL_DATA_CENTER_ONLY =
"restoreToLocalDatacenterOnly";
+ /**
+ * A boolean field; when fastForwardEnabled is set to true, Sidecar
eagerly pipelines the staging and the importing
+ * phases of the restore job, instead of waiting for the phase signals
from the external controller. Concretely,
+ * slices are staged while the job is still in {@code CREATED} status, and
staged slices are imported once the job
+ * enters {@code STAGED} status. The {@code STAGE_READY} and {@code
IMPORT_READY} signals become a fence and a
+ * confirmation, respectively, rather than triggers.
+ * The option should only be enabled by callers that can accept that the
imported data becomes immediately visible
+ * and that importing cannot be rolled back.
+ * Slices that are uploaded while the job is in {@code CREATED} status are
picked up by the periodic restore job
+ * discovery of Sidecar, and, until all the declared slices are found, by
every poll of the job progress endpoint.
+ * A caller that wants the staging to keep up with its uploads should
therefore set {@code sliceCount} when
+ * creating the job and poll the job progress, rather than relying on the
discovery loop alone.
+ */
+ public static final String JOB_FAST_FORWARD_ENABLED = "fastForwardEnabled";
public static final String SLICE_ID = "sliceId";
public static final String BUCKET_ID = "bucketId";
public static final String SLICE_START_TOKEN = "startToken";
diff --git
a/client-common/src/main/java/org/apache/cassandra/sidecar/common/request/data/CreateRestoreJobRequestPayload.java
b/client-common/src/main/java/org/apache/cassandra/sidecar/common/request/data/CreateRestoreJobRequestPayload.java
index f6dbd49c..550a25d2 100644
---
a/client-common/src/main/java/org/apache/cassandra/sidecar/common/request/data/CreateRestoreJobRequestPayload.java
+++
b/client-common/src/main/java/org/apache/cassandra/sidecar/common/request/data/CreateRestoreJobRequestPayload.java
@@ -24,6 +24,7 @@ import java.util.UUID;
import java.util.function.Consumer;
import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.apache.cassandra.sidecar.common.DataObjectBuilder;
@@ -42,6 +43,7 @@ import static
org.apache.cassandra.sidecar.common.data.RestoreJobConstants.JOB_A
import static
org.apache.cassandra.sidecar.common.data.RestoreJobConstants.JOB_CONSISTENCY_LEVEL;
import static
org.apache.cassandra.sidecar.common.data.RestoreJobConstants.JOB_CREDENTIAL_TYPE;
import static
org.apache.cassandra.sidecar.common.data.RestoreJobConstants.JOB_EXPIRE_AT;
+import static
org.apache.cassandra.sidecar.common.data.RestoreJobConstants.JOB_FAST_FORWARD_ENABLED;
import static
org.apache.cassandra.sidecar.common.data.RestoreJobConstants.JOB_ID;
import static
org.apache.cassandra.sidecar.common.data.RestoreJobConstants.JOB_IMPORT_OPTIONS;
import static
org.apache.cassandra.sidecar.common.data.RestoreJobConstants.JOB_LOCAL_DATA_CENTER;
@@ -65,6 +67,8 @@ import static
org.apache.cassandra.sidecar.common.data.RestoreJobConstants.JOB_S
* type is rejected at construction time to surface misconfigurations before
they reach the AWS SDK.
*/
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
+// Ignore the unknown fields, so that a newer client can talk to an older
server that does not know all the fields yet
+@JsonIgnoreProperties(ignoreUnknown = true)
public class CreateRestoreJobRequestPayload
{
private final UUID jobId;
@@ -75,6 +79,7 @@ public class CreateRestoreJobRequestPayload
private final long expireAtInMillis;
private final ConsistencyConfig consistencyConfig;
private final boolean localDatacenterOnly;
+ private final boolean fastForwardEnabled;
/**
* Builder to build a {@link CreateRestoreJobRequestPayload}.
@@ -109,6 +114,9 @@ public class CreateRestoreJobRequestPayload
* @param consistencyLevel consistency level a job should satisfy
* @param localDatacenter the local datacenter name; required if using
local consistency level and localDatacenterOnly is specified
* @param localDatacenterOnly whether the job should restore to the
specified local datacenter only
+ * @param fastForwardEnabled whether Sidecar should eagerly pipeline the
staging and the importing phases,
+ * i.e. stage while the job is in {@link
RestoreJobStatus#CREATED} status and import
+ * once the job is in {@link
RestoreJobStatus#STAGED} status
*/
@JsonCreator
public CreateRestoreJobRequestPayload(@JsonProperty(JOB_ID) UUID jobId,
@@ -119,7 +127,8 @@ public class CreateRestoreJobRequestPayload
@JsonProperty(JOB_EXPIRE_AT) long
expireAtInMillis,
@JsonProperty(JOB_CONSISTENCY_LEVEL)
String consistencyLevel,
@JsonProperty(JOB_LOCAL_DATA_CENTER)
String localDatacenter,
-
@JsonProperty(JOB_RESTORE_TO_LOCAL_DATA_CENTER_ONLY) boolean
localDatacenterOnly)
+
@JsonProperty(JOB_RESTORE_TO_LOCAL_DATA_CENTER_ONLY) boolean
localDatacenterOnly,
+
@JsonProperty(JOB_FAST_FORWARD_ENABLED) boolean fastForwardEnabled)
{
Preconditions.checkArgument(jobId == null || jobId.version() == 1,
"Only time based UUIDs allowed for jobId");
@@ -142,6 +151,11 @@ public class CreateRestoreJobRequestPayload
Preconditions.checkArgument(!localDatacenterOnly ||
StringUtils.isNotEmpty(localDatacenter),
"Must specify a localDatacenter when
restoreToLocalDatacenterOnly is true");
this.localDatacenterOnly = localDatacenterOnly;
+ // Fast forward only alters the phase transitions of Sidecar-managed
jobs. A job is Sidecar-managed if and only
+ // if it declares a consistency level. Reject the combination instead
of silently ignoring the flag.
+ Preconditions.checkArgument(!fastForwardEnabled ||
this.consistencyConfig.consistencyLevel != null,
+ "Must specify a " + JOB_CONSISTENCY_LEVEL
+ " when fastForwardEnabled is true");
+ this.fastForwardEnabled = fastForwardEnabled;
}
/**
@@ -242,6 +256,15 @@ public class CreateRestoreJobRequestPayload
return consistencyConfig;
}
+ /**
+ * @return whether Sidecar should eagerly pipeline the staging and the
importing phases of the job
+ */
+ @JsonProperty(JOB_FAST_FORWARD_ENABLED)
+ public boolean fastForwardEnabled()
+ {
+ return fastForwardEnabled;
+ }
+
/**
* @return the AWS region of the S3 bucket, derived from {@code
secrets.readCredentials().region()}
*/
@@ -261,6 +284,7 @@ public class CreateRestoreJobRequestPayload
JOB_CONSISTENCY_LEVEL + "='" + consistencyLevel() + "', " +
JOB_LOCAL_DATA_CENTER + "='" + localDatacenter() + "', " +
JOB_RESTORE_TO_LOCAL_DATA_CENTER_ONLY + "='" +
shouldRestoreToLocalDatacenterOnly() + "', " +
+ JOB_FAST_FORWARD_ENABLED + "='" + fastForwardEnabled + "', " +
JOB_IMPORT_OPTIONS + "='" + importOptions + "'}";
}
@@ -279,6 +303,7 @@ public class CreateRestoreJobRequestPayload
private ConsistencyLevel consistencyLevel = null;
private String localDc = null;
private boolean localDatacenterOnly = false;
+ private boolean fastForwardEnabled = false;
Builder(RestoreJobSecrets secrets, long expireAtInMillis)
{
@@ -324,6 +349,21 @@ public class CreateRestoreJobRequestPayload
return update(b -> b.localDatacenterOnly = localDatacenterOnly);
}
+ /**
+ * Enable eager pipelining of the staging and the importing phases. It
requires a consistency level, i.e. the
+ * restore job must be Sidecar-managed.
+ *
+ * <p>Only enable it when the caller can accept that the imported data
becomes immediately visible and that
+ * importing cannot be rolled back.
+ *
+ * @param fastForwardEnabled whether to enable fast forward
+ * @return builder
+ */
+ public Builder fastForwardEnabled(boolean fastForwardEnabled)
+ {
+ return update(b -> b.fastForwardEnabled = fastForwardEnabled);
+ }
+
@Override
public Builder self()
{
@@ -350,7 +390,8 @@ public class CreateRestoreJobRequestPayload
builder.expireAtInMillis,
nameOrNull(builder.consistencyLevel),
builder.localDc,
- builder.localDatacenterOnly);
+ builder.localDatacenterOnly,
+ builder.fastForwardEnabled);
}
private static String nameOrNull(ConsistencyLevel cl)
diff --git
a/client-common/src/test/java/org/apache/cassandra/sidecar/common/request/CreateRestoreJobRequestPayloadTest.java
b/client-common/src/test/java/org/apache/cassandra/sidecar/common/request/CreateRestoreJobRequestPayloadTest.java
index dc102c94..b0aedb62 100644
---
a/client-common/src/test/java/org/apache/cassandra/sidecar/common/request/CreateRestoreJobRequestPayloadTest.java
+++
b/client-common/src/test/java/org/apache/cassandra/sidecar/common/request/CreateRestoreJobRequestPayloadTest.java
@@ -28,7 +28,6 @@ import org.junit.jupiter.api.Test;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException;
import com.fasterxml.jackson.databind.exc.ValueInstantiationException;
import org.apache.cassandra.sidecar.common.data.ConsistencyLevel;
import org.apache.cassandra.sidecar.common.data.CredentialType;
@@ -39,6 +38,7 @@ import
org.apache.cassandra.sidecar.common.request.data.CreateRestoreJobRequestP
import org.apache.cassandra.sidecar.foundation.RestoreJobSecretsGen;
import static
org.apache.cassandra.sidecar.common.data.RestoreJobConstants.JOB_CONSISTENCY_LEVEL;
+import static
org.apache.cassandra.sidecar.common.data.RestoreJobConstants.JOB_FAST_FORWARD_ENABLED;
import static
org.apache.cassandra.sidecar.common.data.RestoreJobConstants.JOB_RESTORE_TO_LOCAL_DATA_CENTER_ONLY;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -94,17 +94,21 @@ class CreateRestoreJobRequestPayloadTest
}
@Test
- void testReadFromJsonFailsWithUnknownFields() throws
JsonProcessingException
+ void testReadFromJsonIgnoresUnknownFields() throws JsonProcessingException
{
String uuid = "e870e5dc-d25e-11ed-afa1-0242ac120002";
+ RestoreJobSecrets secrets =
RestoreJobSecretsGen.genRestoreJobSecrets();
+ // "status" is not a field of the create payload; it is ignored, so
that a newer client that sends fields
+ // unknown to this version can still be served. Note that the status
of a created job is always CREATED.
String json = "{\"jobId\":\"" + uuid + "\"," +
"\"jobAgent\":\"Spark Bulk Analytics\"," +
"\"status\":\"Completed\"," +
"\"expireAt\":" + (System.currentTimeMillis() + 1000) +
- ",\"secrets\":" +
MAPPER.writeValueAsString(RestoreJobSecretsGen.genRestoreJobSecrets()) + "}";
- assertThatThrownBy(() -> MAPPER.readValue(json,
CreateRestoreJobRequestPayload.class))
- .isInstanceOf(UnrecognizedPropertyException.class)
- .hasMessageContaining("Unrecognized field \"status\"");
+ ",\"secrets\":" + MAPPER.writeValueAsString(secrets) +
"}";
+ CreateRestoreJobRequestPayload payload = MAPPER.readValue(json,
CreateRestoreJobRequestPayload.class);
+ assertThat(payload.jobId()).hasToString(uuid);
+ assertThat(payload.jobAgent()).isEqualTo("Spark Bulk Analytics");
+ assertThat(payload.secrets()).isEqualTo(secrets);
}
@Test
@@ -398,4 +402,48 @@ class CreateRestoreJobRequestPayloadTest
.hasCauseInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Static credentials must have all key fields
present for writeCredentials");
}
+
+ @Test
+ void testFastForwardEnabledSerDeser() throws JsonProcessingException
+ {
+ RestoreJobSecrets secrets =
RestoreJobSecretsGen.genRestoreJobSecrets();
+ CreateRestoreJobRequestPayload req = CreateRestoreJobRequestPayload
+ .builder(secrets,
System.currentTimeMillis() + 10000)
+ .jobAgent("agent")
+
.consistencyLevel(ConsistencyLevel.QUORUM)
+ .fastForwardEnabled(true)
+ .build();
+ assertThat(req.fastForwardEnabled()).isTrue();
+
+ String json = MAPPER.writeValueAsString(req);
+ assertThat(json).contains("\"" + JOB_FAST_FORWARD_ENABLED + "\":true");
+ assertThat(MAPPER.readValue(json,
CreateRestoreJobRequestPayload.class).fastForwardEnabled()).isTrue();
+ }
+
+ @Test
+ void testFastForwardDisabledByDefault() throws JsonProcessingException
+ {
+ RestoreJobSecrets secrets =
RestoreJobSecretsGen.genRestoreJobSecrets();
+ CreateRestoreJobRequestPayload req = CreateRestoreJobRequestPayload
+ .builder(secrets,
System.currentTimeMillis() + 10000)
+
.consistencyLevel(ConsistencyLevel.QUORUM)
+ .build();
+ assertThat(req.fastForwardEnabled()).isFalse();
+ assertThat(MAPPER.writeValueAsString(req))
+ .describedAs("Default value fields should be excluded")
+ .doesNotContain(JOB_FAST_FORWARD_ENABLED);
+ }
+
+ @Test
+ void testFastForwardEnabledWithoutConsistencyLevelFails()
+ {
+ RestoreJobSecrets secrets =
RestoreJobSecretsGen.genRestoreJobSecrets();
+ // fast forward only applies to Sidecar-managed jobs, i.e. the jobs
that declare a consistency level
+ assertThatThrownBy(() -> CreateRestoreJobRequestPayload
+ .builder(secrets, System.currentTimeMillis()
+ 10000)
+ .fastForwardEnabled(true)
+ .build())
+ .isExactlyInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Must specify a " + JOB_CONSISTENCY_LEVEL + " when
fastForwardEnabled is true");
+ }
}
diff --git
a/server/src/main/java/org/apache/cassandra/sidecar/db/RestoreJob.java
b/server/src/main/java/org/apache/cassandra/sidecar/db/RestoreJob.java
index f2df7917..52f0e68c 100644
--- a/server/src/main/java/org/apache/cassandra/sidecar/db/RestoreJob.java
+++ b/server/src/main/java/org/apache/cassandra/sidecar/db/RestoreJob.java
@@ -67,6 +67,9 @@ public class RestoreJob
public final @Nullable String localDatacenter;
// whether a restore job should restore to the local Cassandra nodes only;
default is false
public final boolean shouldRestoreToLocalDatacenterOnly;
+ // whether the staging and the importing phases of the job are eagerly
pipelined, i.e. slices are staged while the
+ // job is in CREATED status and staged slices are imported once the job is
in STAGED status; default is false
+ public final boolean fastForwardEnabled;
public final Manager restoreJobManager;
public final Long sliceCount;
@@ -100,6 +103,7 @@ public class RestoreJob
.consistencyLevel(consistencyConfig.consistencyLevel)
.localDatacenter(consistencyConfig.localDatacenter)
.shouldRestoreToLocalDatacenterOnly(row.getBool("local_datacenter_only"))
+ .fastForwardEnabled(row.getBool("fast_forward_enabled"))
.sliceCount(row.get("slice_count", Long.class));
return builder.build();
@@ -189,6 +193,7 @@ public class RestoreJob
this.localDatacenter = builder.localDatacenter;
this.restoreJobManager = builder.manager;
this.sliceCount = builder.sliceCount;
+ this.fastForwardEnabled = builder.fastForwardEnabled;
}
public Builder unbuild()
@@ -216,16 +221,54 @@ public class RestoreJob
return expireAt != null && referenceTimestampMillis >=
expireAt.getTime();
}
+ /**
+ * Determine whether the restore ranges of the job should be staged now.
+ *
+ * <p>Staging is normally triggered by the {@link
RestoreJobStatus#STAGE_READY} signal from the external
+ * controller. When fast forward is enabled, staging starts as soon as the
job is created; the individual slices
+ * are already available on the storage cloud as they are uploaded
progressively. {@code STAGE_READY} then becomes
+ * a fence, which guarantees that all slices of the job have been
uploaded, rather than the trigger of the phase.
+ *
+ * @return true if the ranges of this job can be staged in the current job
status
+ */
+ public boolean shouldStageNow()
+ {
+ return status == RestoreJobStatus.STAGE_READY
+ || (fastForwardEnabled && status == RestoreJobStatus.CREATED);
+ }
+
+ /**
+ * Determine whether the staged restore ranges of the job should be
imported now.
+ *
+ * <p>Importing is normally triggered by the {@link
RestoreJobStatus#IMPORT_READY} signal from the external
+ * controller. When fast forward is enabled, importing starts once the job
enters {@link RestoreJobStatus#STAGED},
+ * i.e. all clusters have staged their data and satisfied the consistency
requirement of the job.
+ * {@code IMPORT_READY} then becomes a confirmation rather than the
trigger of the phase.
+ *
+ * @return true if the staged ranges of this job can be imported in the
current job status
+ */
+ public boolean shouldImportNow()
+ {
+ return status == RestoreJobStatus.IMPORT_READY
+ || (fastForwardEnabled && status == RestoreJobStatus.STAGED);
+ }
+
/**
* Determine the expected range status based on the job status
* @return the expected next range status in order to succeed
*/
public RestoreRangeStatus expectedNextRangeStatus()
{
- Preconditions.checkArgument(status != RestoreJobStatus.CREATED,
+ // Ranges of a fast forward job are already staging while the job is
in CREATED status, so their progress can
+ // be examined. For all other jobs, no range exists yet in CREATED
status.
+ Preconditions.checkArgument(fastForwardEnabled || status !=
RestoreJobStatus.CREATED,
"Cannot check progress for restore job in
CREATED status. jobId: " + jobId);
- return status == RestoreJobStatus.STAGE_READY || status ==
RestoreJobStatus.STAGED
+ // The job is still staging its ranges when shouldStageNow() holds,
i.e. in CREATED status for a fast forward
+ // job and in STAGE_READY status for any job.
+ // The STAGED status maps to STAGED as well, but only in the normal
flow, where importing waits for the
+ // IMPORT_READY signal. With fast forward, importing has already
started in STAGED status, hence SUCCEEDED.
+ return shouldStageNow() || (status == RestoreJobStatus.STAGED &&
!fastForwardEnabled)
? RestoreRangeStatus.STAGED
: RestoreRangeStatus.SUCCEEDED;
}
@@ -245,13 +288,14 @@ public class RestoreJob
"createdAt='%s', jobId='%s', keyspaceName='%s', "
+
"tableName='%s', status='%s', secrets='%s',
importOptions='%s', " +
"expireAt='%s', bucketCount='%s',
consistencyLevel='%s', localDatacenter='%s', " +
- "shouldRestoreToLocalDatacenterOnly='%s'}",
+ "shouldRestoreToLocalDatacenterOnly='%s',
fastForwardEnabled='%s'}",
createdAt.toString(), jobId.toString(),
keyspaceName, tableName,
statusText, secrets, importOptions,
expireAt, bucketCount,
consistencyLevel, localDatacenter,
- shouldRestoreToLocalDatacenterOnly);
+ shouldRestoreToLocalDatacenterOnly,
+ fastForwardEnabled);
}
public static LocalDate toLocalDate(UUID jobId)
@@ -291,6 +335,7 @@ public class RestoreJob
private ConsistencyLevel consistencyLevel;
private String localDatacenter;
private boolean shouldRestoreToLocalDatacenterOnly = false;
+ private boolean fastForwardEnabled = false;
private Manager manager;
private Long sliceCount;
@@ -317,6 +362,7 @@ public class RestoreJob
this.localDatacenter = restoreJob.localDatacenter;
this.manager = restoreJob.restoreJobManager;
this.sliceCount = restoreJob.sliceCount;
+ this.fastForwardEnabled = restoreJob.fastForwardEnabled;
}
public Builder createdAt(LocalDate createdAt)
@@ -413,6 +459,11 @@ public class RestoreJob
return update(b -> b.shouldRestoreToLocalDatacenterOnly =
localDatacenterOnly);
}
+ public Builder fastForwardEnabled(boolean fastForwardEnabled)
+ {
+ return update(b -> b.fastForwardEnabled = fastForwardEnabled);
+ }
+
@Override
public Builder self()
{
diff --git
a/server/src/main/java/org/apache/cassandra/sidecar/db/RestoreJobDatabaseAccessor.java
b/server/src/main/java/org/apache/cassandra/sidecar/db/RestoreJobDatabaseAccessor.java
index 98454185..1d62a2d1 100644
---
a/server/src/main/java/org/apache/cassandra/sidecar/db/RestoreJobDatabaseAccessor.java
+++
b/server/src/main/java/org/apache/cassandra/sidecar/db/RestoreJobDatabaseAccessor.java
@@ -87,6 +87,7 @@ public class RestoreJobDatabaseAccessor extends
DatabaseAccessor<RestoreJobsSche
.consistencyLevel(payload.consistencyConfig().consistencyLevel)
.localDatacenter(payload.consistencyConfig().localDatacenter)
.shouldRestoreToLocalDatacenterOnly(payload.shouldRestoreToLocalDatacenterOnly())
+
.fastForwardEnabled(payload.fastForwardEnabled())
.build();
ByteBuffer secrets = serializeValue(job.secrets, "secrets");
ByteBuffer importOptions = serializeValue(job.importOptions, "sstable
import options");
@@ -103,6 +104,7 @@ public class RestoreJobDatabaseAccessor extends
DatabaseAccessor<RestoreJobsSche
job.localDatacenter,
job.shouldRestoreToLocalDatacenterOnly,
job.credentialType.name(),
+ job.fastForwardEnabled,
job.expireAt);
execute(statement);
diff --git
a/server/src/main/java/org/apache/cassandra/sidecar/db/schema/RestoreJobsSchema.java
b/server/src/main/java/org/apache/cassandra/sidecar/db/schema/RestoreJobsSchema.java
index 42c50418..ea81eefb 100644
---
a/server/src/main/java/org/apache/cassandra/sidecar/db/schema/RestoreJobsSchema.java
+++
b/server/src/main/java/org/apache/cassandra/sidecar/db/schema/RestoreJobsSchema.java
@@ -32,7 +32,7 @@ import org.jetbrains.annotations.NotNull;
*/
public class RestoreJobsSchema extends TableSchema implements
ExecuteOnClusterLeaseholderOnly
{
- private static final String RESTORE_JOB_TABLE_NAME = "restore_job_v6";
+ private static final String RESTORE_JOB_TABLE_NAME = "restore_job_v7";
private final SchemaKeyspaceConfiguration keyspaceConfig;
private final SecondBoundConfiguration tableTtl;
@@ -97,6 +97,7 @@ public class RestoreJobsSchema extends TableSchema implements
ExecuteOnClusterLe
" local_datacenter text," +
" local_datacenter_only boolean," +
" credential_type text," +
+ " fast_forward_enabled boolean," +
" PRIMARY KEY (created_at, job_id)" +
") WITH default_time_to_live = %s",
keyspaceConfig.keyspace(),
RESTORE_JOB_TABLE_NAME, tableTtl.toSeconds());
@@ -159,8 +160,9 @@ public class RestoreJobsSchema extends TableSchema
implements ExecuteOnClusterLe
" local_datacenter," +
" local_datacenter_only," +
" credential_type," +
+ " fast_forward_enabled," +
" expire_at" +
- ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?)", config);
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?)", config);
}
static String updateBlobSecrets(SchemaKeyspaceConfiguration config)
@@ -223,6 +225,7 @@ public class RestoreJobsSchema extends TableSchema
implements ExecuteOnClusterLe
"local_datacenter, " +
"local_datacenter_only, " +
"credential_type, " +
+ "fast_forward_enabled, " +
"expire_at, " +
"slice_count " +
"FROM %s.%s " +
@@ -243,6 +246,7 @@ public class RestoreJobsSchema extends TableSchema
implements ExecuteOnClusterLe
"local_datacenter, " +
"local_datacenter_only, " +
"credential_type, " +
+ "fast_forward_enabled, " +
"expire_at, " +
"slice_count " +
"FROM %s.%s " +
diff --git
a/server/src/main/java/org/apache/cassandra/sidecar/handlers/restore/UpdateRestoreJobHandler.java
b/server/src/main/java/org/apache/cassandra/sidecar/handlers/restore/UpdateRestoreJobHandler.java
index c217512c..aae9973b 100644
---
a/server/src/main/java/org/apache/cassandra/sidecar/handlers/restore/UpdateRestoreJobHandler.java
+++
b/server/src/main/java/org/apache/cassandra/sidecar/handlers/restore/UpdateRestoreJobHandler.java
@@ -168,7 +168,9 @@ public class UpdateRestoreJobHandler extends
AbstractHandler<UpdateRestoreJobReq
private void notifyPhaseSignalMaybe(RestoreJob updatedJob)
{
- if (updatedJob.status != RestoreJobStatus.IMPORT_READY &&
updatedJob.status != RestoreJobStatus.STAGE_READY)
+ // A phase signal is any status that lets Sidecar start staging or
importing. For a fast forward job, that
+ // includes STAGED, which starts the importing phase.
+ if (!updatedJob.shouldStageNow() && !updatedJob.shouldImportNow())
{
return;
}
diff --git
a/server/src/main/java/org/apache/cassandra/sidecar/restore/RestoreJobConsistencyChecker.java
b/server/src/main/java/org/apache/cassandra/sidecar/restore/RestoreJobConsistencyChecker.java
index ae73dd73..f260d90a 100644
---
a/server/src/main/java/org/apache/cassandra/sidecar/restore/RestoreJobConsistencyChecker.java
+++
b/server/src/main/java/org/apache/cassandra/sidecar/restore/RestoreJobConsistencyChecker.java
@@ -40,7 +40,6 @@ import
org.apache.cassandra.sidecar.cluster.ConsistencyVerifiers;
import org.apache.cassandra.sidecar.cluster.locator.InstanceSetByDc;
import org.apache.cassandra.sidecar.common.data.ConsistencyVerificationResult;
import org.apache.cassandra.sidecar.common.data.RestoreJobProgressFetchPolicy;
-import org.apache.cassandra.sidecar.common.data.RestoreJobStatus;
import org.apache.cassandra.sidecar.common.response.TokenRangeReplicasResponse;
import org.apache.cassandra.sidecar.common.server.cluster.locator.Token;
import org.apache.cassandra.sidecar.common.server.cluster.locator.TokenRange;
@@ -75,7 +74,9 @@ public class RestoreJobConsistencyChecker
private final RestoreRangeDatabaseAccessor rangeDatabaseAccessor;
private final TaskExecutorPool taskExecutorPool;
private final RestoreMetrics restoreMetrics;
- private volatile boolean firstTimeSinceImportReady = true;
+ // Set to false after the first consistency check of the importing phase,
which starts at IMPORT_READY normally
+ // and at STAGED when the job has fast forward enabled
+ private volatile boolean firstCheckOfImportPhase = true;
@Inject
public RestoreJobConsistencyChecker(RingTopologyRefresher
ringTopologyRefresher,
@@ -140,11 +141,12 @@ public class RestoreJobConsistencyChecker
return true;
}
- if (restoreJob.status == RestoreJobStatus.IMPORT_READY &&
firstTimeSinceImportReady)
+ if (restoreJob.shouldImportNow() && firstCheckOfImportPhase)
{
- firstTimeSinceImportReady = false;
- LOGGER.info("First time checking consistency of the restore job
after import_ready. " +
- "Mark the progress as pending and force restore job
discover run. jobId={}", restoreJob.jobId);
+ firstCheckOfImportPhase = false;
+ LOGGER.info("First time checking consistency of the restore job
after the importing phase has started. " +
+ "Mark the progress as pending and force restore job
discover run. jobId={} status={}",
+ restoreJob.jobId, restoreJob.status);
return true;
}
return false;
@@ -313,7 +315,8 @@ public class RestoreJobConsistencyChecker
RestoreRangeStatus successCriteria,
RestoreRange
range)
{
- return concludeOneRange(populateReplicas(topology), verifier,
successCriteria, range.tokenRange().range, range.statusByReplica());
+ return concludeOneRange(populateReplicas(topology), verifier,
successCriteria,
+ range.tokenRange().range,
range.statusByReplica());
}
@VisibleForTesting
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 7bfd73e5..c49c2fc3 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
@@ -398,18 +398,27 @@ public class RestoreJobDiscoverer implements
PeriodicTask, RingTopologyChangeLis
private void processOneJob(RestoreJob job, RestoreJobManagerGroup
restoreJobManagers, RunContext context)
{
+ // read the status seen from the prior run before shouldLogJob records
the current one
+ boolean statusChanged = jobIdsByDay.getKnownStatus(job.jobId,
job.createdAt.getDaysSinceEpoch()) != job.status;
if (jobIdsByDay.shouldLogJob(job))
{
LOGGER.info("Found job. jobId={} job={}", job.jobId, job);
}
+ if (statusChanged)
+ {
+ // Unset the flag, so that the slices are re-discovered once in
the phase the job just entered.
+ // The flag is deliberately not unset on the subsequent runs of
the same status; a fast forward job finds
+ // and submits its slices while in STAGED status, and repeating
the discovery (which is expensive) on
+ // every run of the same phase would be wasteful.
+ jobIdsByDay.unsetSlicesDiscovered(job);
+ }
+
switch (job.status)
{
- case STAGED:
- // unset the flag, so that it can re-discover the slices when
the job status changes to a ready status
- jobIdsByDay.unsetSlicesDiscovered(job); // no break by design
case CREATED:
case STAGE_READY:
+ case STAGED:
case IMPORT_READY:
if (job.hasExpired(context.nowMillis))
{
@@ -465,8 +474,13 @@ public class RestoreJobDiscoverer implements PeriodicTask,
RingTopologyChangeLis
if (shouldFindSlicesAndSubmit(job))
{
findSlicesAndSubmit(job);
- // Mark the flag. It prevents finding slices (which is expensive)
until the flag is unset.
- jobIdsByDay.markSlicesDiscovered(job);
+ if (job.status != RestoreJobStatus.CREATED)
+ {
+ // Mark the flag. It prevents finding slices (which is
expensive) until the flag is unset.
+ // The flag is not marked while a fast forward job is still in
CREATED status, since its slices are
+ // uploaded progressively and each run should look for the
newly uploaded ones.
+ jobIdsByDay.markSlicesDiscovered(job);
+ }
}
}
@@ -507,7 +521,9 @@ public class RestoreJobDiscoverer implements PeriodicTask,
RingTopologyChangeLis
private boolean shouldFindSlicesAndSubmit(RestoreJob job)
{
- return (job.status == RestoreJobStatus.STAGE_READY || job.status ==
RestoreJobStatus.IMPORT_READY)
+ // Note that the discovered flag is never marked while a fast forward
job is in CREATED status, since its
+ // slices are uploaded progressively. The discovery therefore repeats
on every run of that phase.
+ return (job.shouldStageNow() || job.shouldImportNow())
&& !jobIdsByDay.isSliceDiscovered(job);
}
diff --git
a/server/src/main/java/org/apache/cassandra/sidecar/restore/RestoreProcessor.java
b/server/src/main/java/org/apache/cassandra/sidecar/restore/RestoreProcessor.java
index 0befaa2f..a837bdb8 100644
---
a/server/src/main/java/org/apache/cassandra/sidecar/restore/RestoreProcessor.java
+++
b/server/src/main/java/org/apache/cassandra/sidecar/restore/RestoreProcessor.java
@@ -36,7 +36,6 @@ import com.google.inject.Singleton;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import org.apache.cassandra.sidecar.cluster.locator.LocalTokenRangesProvider;
-import org.apache.cassandra.sidecar.common.data.RestoreJobStatus;
import org.apache.cassandra.sidecar.common.server.utils.DurationSpec;
import
org.apache.cassandra.sidecar.common.server.utils.SecondBoundConfiguration;
import org.apache.cassandra.sidecar.concurrent.ConcurrencyLimiter;
@@ -168,9 +167,9 @@ public class RestoreProcessor implements PeriodicTask
break; // break in order to complete promise
}
- // only create task when the restore job status is import ready
for the staged ranges
+ // only create task when the restore job is ready to import the
staged ranges
// otherwise, putting the range back to the staged queue and exit
early.
- if (range.hasStaged() && range.job().status !=
RestoreJobStatus.IMPORT_READY)
+ if (range.hasStaged() && !range.job().shouldImportNow())
{
workQueue.offerStaged(range);
processMaxConcurrency.releasePermit();
diff --git
a/server/src/main/java/org/apache/cassandra/sidecar/restore/RestoreRangeTask.java
b/server/src/main/java/org/apache/cassandra/sidecar/restore/RestoreRangeTask.java
index 02f8cc52..ba3a1bea 100644
---
a/server/src/main/java/org/apache/cassandra/sidecar/restore/RestoreRangeTask.java
+++
b/server/src/main/java/org/apache/cassandra/sidecar/restore/RestoreRangeTask.java
@@ -35,7 +35,6 @@ import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.ext.web.handler.HttpException;
import org.apache.cassandra.sidecar.cluster.locator.LocalTokenRangesProvider;
-import org.apache.cassandra.sidecar.common.data.RestoreJobStatus;
import org.apache.cassandra.sidecar.common.data.SSTableImportOptions;
import org.apache.cassandra.sidecar.common.server.cluster.locator.TokenRange;
import org.apache.cassandra.sidecar.common.server.utils.ThrowableUtils;
@@ -145,7 +144,7 @@ public class RestoreRangeTask implements RestoreRangeHandler
RestoreJob job = range.job();
if (job.isManagedBySidecar())
{
- if (job.status == RestoreJobStatus.STAGE_READY)
+ if (job.shouldStageNow())
{
if (Files.exists(range.stagedObjectPath()))
{
@@ -169,7 +168,7 @@ public class RestoreRangeTask implements RestoreRangeHandler
return Future.succeededFuture();
});
}
- else if (job.status == RestoreJobStatus.IMPORT_READY)
+ else if (job.shouldImportNow())
{
if (range.hasStaged())
{
@@ -193,8 +192,9 @@ public class RestoreRangeTask implements RestoreRangeHandler
}
else
{
- String msg = "Unexpected restore job status. Expected only
STAGE_READY or IMPORT_READY when " +
- "processing active slices. Found status: " +
job.statusWithOptionalDescription();
+ String msg = "Unexpected restore job status. Expected the
job to be ready to stage or to import " +
+ "when processing active slices. Found status:
" + job.statusWithOptionalDescription() +
+ " and fast forward: " +
(job.fastForwardEnabled ? "enabled" : "disabled");
Exception unexpectedState = new IllegalStateException(msg);
return
Future.failedFuture(RestoreJobExceptions.ofFatal("Unexpected restore job
status",
range, unexpectedState));
diff --git
a/server/src/test/integration/org/apache/cassandra/sidecar/db/RestoreJobDatabaseAccessorIntTest.java
b/server/src/test/integration/org/apache/cassandra/sidecar/db/RestoreJobDatabaseAccessorIntTest.java
index 40dda516..1024f11d 100644
---
a/server/src/test/integration/org/apache/cassandra/sidecar/db/RestoreJobDatabaseAccessorIntTest.java
+++
b/server/src/test/integration/org/apache/cassandra/sidecar/db/RestoreJobDatabaseAccessorIntTest.java
@@ -120,6 +120,35 @@ class RestoreJobDatabaseAccessorIntTest extends
IntegrationTestBase
assertThat(found.secrets.readCredentials().sessionToken()).isNull();
}
+ @CassandraIntegrationTest
+ void testFastForwardEnabledRoundTrips()
+ {
+ waitForSchemaReady(30, TimeUnit.SECONDS);
+
+ RestoreJobDatabaseAccessor accessor =
injector.getInstance(RestoreJobDatabaseAccessor.class);
+ UUID jobId = UUIDs.timeBased();
+ CreateRestoreJobRequestPayload payload =
CreateRestoreJobRequestPayload.builder(secrets, expiresAtMillis)
+
.jobId(jobId)
+
.jobAgent("agent")
+
.consistencyLevel(ConsistencyLevel.QUORUM)
+
.fastForwardEnabled(true)
+
.build();
+ accessor.create(payload, qualifiedTableName);
+
+ RestoreJob found = accessor.find(jobId);
+ assertThat(found).isNotNull();
+ assertThat(found.fastForwardEnabled).isTrue();
+
+ // the flag is preserved when other fields of the job are updated
+ UpdateRestoreJobRequestPayload markStaged
+ = new UpdateRestoreJobRequestPayload(null, null,
RestoreJobStatus.STAGED, null, null);
+ accessor.update(markStaged, found);
+ assertThat(accessor.find(jobId).fastForwardEnabled).isTrue();
+
+ // and it is disabled by default
+
assertThat(accessor.find(createJob(accessor)).fastForwardEnabled).isFalse();
+ }
+
@CassandraIntegrationTest
void testStaticCredentialTypeRoundTrips()
{
diff --git
a/server/src/test/java/org/apache/cassandra/sidecar/db/RestoreJobTest.java
b/server/src/test/java/org/apache/cassandra/sidecar/db/RestoreJobTest.java
index d94c5788..74c88581 100644
--- a/server/src/test/java/org/apache/cassandra/sidecar/db/RestoreJobTest.java
+++ b/server/src/test/java/org/apache/cassandra/sidecar/db/RestoreJobTest.java
@@ -20,6 +20,8 @@ package org.apache.cassandra.sidecar.db;
import java.util.Arrays;
import java.util.Date;
+import java.util.EnumMap;
+import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@@ -86,6 +88,12 @@ public class RestoreJobTest
return builder.build();
}
+ public static RestoreJob createFastForwardTestingJob(UUID jobId,
RestoreJobStatus status)
+ throws DataObjectMappingException
+ {
+ return createTestingJob(jobId,
status).unbuild().fastForwardEnabled(true).build();
+ }
+
public static RestoreJob createUpdatedJob(UUID jobId, String jobAgent,
RestoreJobStatus status,
RestoreJobSecrets secrets,
@@ -141,6 +149,62 @@ public class RestoreJobTest
}
}
+ @Test
+ void testExpectedNextRangeStatusOfFastForwardJob()
+ {
+ UUID jobId = UUIDs.timeBased();
+ // The expected range status of a fast forward job per job status. It
differs from the normal flow in the
+ // CREATED status, where the ranges are staged ahead of the
STAGE_READY signal, and in the STAGED status,
+ // where importing has already started instead of waiting for the
IMPORT_READY signal.
+ Map<RestoreJobStatus, RestoreRangeStatus> expectedByJobStatus = new
EnumMap<>(RestoreJobStatus.class);
+ expectedByJobStatus.put(RestoreJobStatus.CREATED,
RestoreRangeStatus.STAGED);
+ expectedByJobStatus.put(RestoreJobStatus.STAGE_READY,
RestoreRangeStatus.STAGED);
+ expectedByJobStatus.put(RestoreJobStatus.STAGED,
RestoreRangeStatus.SUCCEEDED);
+ expectedByJobStatus.put(RestoreJobStatus.IMPORT_READY,
RestoreRangeStatus.SUCCEEDED);
+
+ expectedByJobStatus.forEach((jobStatus, expectedRangeStatus) ->
+
assertThat(createFastForwardTestingJob(jobId,
jobStatus).expectedNextRangeStatus())
+ .describedAs("Expecting the ranges of a
fast forward job in " + jobStatus +
+ " status to enter " +
expectedRangeStatus)
+ .isEqualTo(expectedRangeStatus));
+ }
+
+ @Test
+ void testPhaseGatesOfRegularJob()
+ {
+ UUID jobId = UUIDs.timeBased();
+ for (RestoreJobStatus status : RestoreJobStatus.values())
+ {
+ RestoreJob job = createTestingJob(jobId, status);
+ assertThat(job.shouldStageNow()).isEqualTo(status ==
RestoreJobStatus.STAGE_READY);
+ assertThat(job.shouldImportNow()).isEqualTo(status ==
RestoreJobStatus.IMPORT_READY);
+ }
+ }
+
+ @Test
+ void testPhaseGatesOfFastForwardJob()
+ {
+ UUID jobId = UUIDs.timeBased();
+ for (RestoreJobStatus status : RestoreJobStatus.values())
+ {
+ RestoreJob job = createFastForwardTestingJob(jobId, status);
+ assertThat(job.shouldStageNow())
+ .describedAs("Expecting a fast forward job to stage in both
CREATED and STAGE_READY status. status=" + status)
+ .isEqualTo(status == RestoreJobStatus.CREATED || status ==
RestoreJobStatus.STAGE_READY);
+ assertThat(job.shouldImportNow())
+ .describedAs("Expecting a fast forward job to import in both
STAGED and IMPORT_READY status. status=" + status)
+ .isEqualTo(status == RestoreJobStatus.STAGED || status ==
RestoreJobStatus.IMPORT_READY);
+ }
+ }
+
+ @Test
+ void testFastForwardEnabledPreservedThroughUnbuild()
+ {
+ RestoreJob job = createFastForwardTestingJob(UUIDs.timeBased(),
RestoreJobStatus.CREATED);
+ assertThat(job.fastForwardEnabled).isTrue();
+ assertThat(job.unbuild().build().fastForwardEnabled).isTrue();
+ }
+
@Test
void testCreateLocalConsistencyLevelJobWithoutLocalDcFails()
{
diff --git
a/server/src/test/java/org/apache/cassandra/sidecar/db/SidecarSchemaTest.java
b/server/src/test/java/org/apache/cassandra/sidecar/db/SidecarSchemaTest.java
index 1cece57b..aa728510 100644
---
a/server/src/test/java/org/apache/cassandra/sidecar/db/SidecarSchemaTest.java
+++
b/server/src/test/java/org/apache/cassandra/sidecar/db/SidecarSchemaTest.java
@@ -140,7 +140,7 @@ public class SidecarSchemaTest
assertThat(interceptedExecStmts.get(0)).as("Create keyspace should
be executed the first")
.contains("CREATE KEYSPACE
IF NOT EXISTS sidecar_internal");
assertThat(interceptedExecStmts).as("Create table should be
executed for job table")
- .anyMatch(stmt ->
stmt.contains("CREATE TABLE IF NOT EXISTS sidecar_internal.restore_job_v6"));
+ .anyMatch(stmt ->
stmt.contains("CREATE TABLE IF NOT EXISTS sidecar_internal.restore_job_v7"));
assertThat(interceptedExecStmts).as("Create table should be
executed for slice table")
.anyMatch(stmt ->
stmt.contains("CREATE TABLE IF NOT EXISTS sidecar_internal.restore_slice_v3"));
assertThat(interceptedExecStmts).as("Create table should be
executed for range table")
@@ -163,27 +163,28 @@ public class SidecarSchemaTest
.anyMatch(stmt ->
stmt.contains("CREATE TABLE IF NOT EXISTS
sidecar_internal.cluster_ops_node_state"));
List<String> expectedPrepStatements = Arrays.asList(
- "INSERT INTO sidecar_internal.restore_job_v6 ( created_at,
job_id, keyspace_name, table_name, " +
- "job_agent, status, blob_secrets, import_options,
consistency_level, local_datacenter, local_datacenter_only, credential_type,
expire_at) " +
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO sidecar_internal.restore_job_v7 ( created_at,
job_id, keyspace_name, table_name, " +
+ "job_agent, status, blob_secrets, import_options,
consistency_level, local_datacenter, " +
+ "local_datacenter_only, credential_type, fast_forward_enabled,
expire_at) " +
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
- "INSERT INTO sidecar_internal.restore_job_v6 ( created_at,
job_id, blob_secrets) VALUES (?, ? ,?)",
+ "INSERT INTO sidecar_internal.restore_job_v7 ( created_at,
job_id, blob_secrets) VALUES (?, ? ,?)",
- "INSERT INTO sidecar_internal.restore_job_v6 ( created_at,
job_id, status) VALUES (?, ?, ?)",
+ "INSERT INTO sidecar_internal.restore_job_v7 ( created_at,
job_id, status) VALUES (?, ?, ?)",
- "INSERT INTO sidecar_internal.restore_job_v6 ( created_at,
job_id, job_agent) VALUES (?, ?, ?)",
+ "INSERT INTO sidecar_internal.restore_job_v7 ( created_at,
job_id, job_agent) VALUES (?, ?, ?)",
- "INSERT INTO sidecar_internal.restore_job_v6 ( created_at,
job_id, expire_at) VALUES (?, ?, ?)",
+ "INSERT INTO sidecar_internal.restore_job_v7 ( created_at,
job_id, expire_at) VALUES (?, ?, ?)",
- "INSERT INTO sidecar_internal.restore_job_v6 ( created_at,
job_id, slice_count) VALUES (?, ?, ?)",
+ "INSERT INTO sidecar_internal.restore_job_v7 ( created_at,
job_id, slice_count) VALUES (?, ?, ?)",
"SELECT created_at, job_id, keyspace_name, table_name, job_agent,
status, blob_secrets, import_options, " +
- "consistency_level, local_datacenter, local_datacenter_only,
credential_type, expire_at, slice_count " +
- "FROM sidecar_internal.restore_job_v6 WHERE created_at = ? AND
job_id = ?",
+ "consistency_level, local_datacenter, local_datacenter_only,
credential_type, fast_forward_enabled, expire_at, slice_count " +
+ "FROM sidecar_internal.restore_job_v7 WHERE created_at = ? AND
job_id = ?",
"SELECT created_at, job_id, keyspace_name, table_name, job_agent,
status, blob_secrets, import_options, " +
- "consistency_level, local_datacenter, local_datacenter_only,
credential_type, expire_at, slice_count " +
- "FROM sidecar_internal.restore_job_v6 WHERE created_at = ?",
+ "consistency_level, local_datacenter, local_datacenter_only,
credential_type, fast_forward_enabled, expire_at, slice_count " +
+ "FROM sidecar_internal.restore_job_v7 WHERE created_at = ?",
"INSERT INTO sidecar_internal.restore_slice_v3 ( job_id,
bucket_id, slice_id, bucket, key, " +
"checksum, start_token, end_token, compressed_size,
uncompressed_size) " +
@@ -257,7 +258,7 @@ public class SidecarSchemaTest
.containsExactlyInAnyOrderElementsOf(expectedPrepStatements);
assertThat(sidecarSchema.isInitialized()).as("Schema is
successfully initialized").isTrue();
-
assertTableSchema(sidecarSchema.tableSchema(RestoreJobsSchema.class),
"sidecar_internal.restore_job_v6");
+
assertTableSchema(sidecarSchema.tableSchema(RestoreJobsSchema.class),
"sidecar_internal.restore_job_v7");
assertTableSchema(sidecarSchema.tableSchema(RestoreRangesSchema.class),
"sidecar_internal.restore_range_v1");
assertTableSchema(sidecarSchema.tableSchema(RestoreSlicesSchema.class),
"sidecar_internal.restore_slice_v3");
assertTableSchema(sidecarSchema.tableSchema(SidecarLeaseSchema.class),
"sidecar_internal.sidecar_lease_v1");
diff --git
a/server/src/test/java/org/apache/cassandra/sidecar/handlers/restore/UpdateRestoreJobHandlerTest.java
b/server/src/test/java/org/apache/cassandra/sidecar/handlers/restore/UpdateRestoreJobHandlerTest.java
index 6ace186b..55ea8232 100644
---
a/server/src/test/java/org/apache/cassandra/sidecar/handlers/restore/UpdateRestoreJobHandlerTest.java
+++
b/server/src/test/java/org/apache/cassandra/sidecar/handlers/restore/UpdateRestoreJobHandlerTest.java
@@ -232,6 +232,40 @@ class UpdateRestoreJobHandlerTest extends
BaseRestoreJobTests
assertThat(Uninterruptibles.awaitUninterruptibly(latch, 5,
TimeUnit.SECONDS)).isTrue();
}
+ @Test
+ void
testUpdateToStagedNotifiesDiscovererForFastForwardJob(VertxTestContext context)
throws Throwable
+ {
+ String jobId = "8e5799a4-d277-11ed-8d85-6916bb9b8056";
+ CountDownLatch latch = new CountDownLatch(1);
+ testRestoreJobDiscoverer.processJobNowCallback = job ->
latch.countDown();
+ mockLookupRestoreJob(id -> createTestFastForwardJobWithStatus(jobId,
RestoreJobStatus.STAGE_READY));
+ mockUpdateRestoreJob(payload ->
createTestFastForwardJobWithStatus(jobId, RestoreJobStatus.STAGED));
+ JsonObject payload = new JsonObject();
+ payload.put("status", "STAGED");
+ sendUpdateRestoreJobRequestAndVerify("ks", "table", jobId,
+ payload, context,
HttpResponseStatus.OK.code());
+ assertThat(Uninterruptibles.awaitUninterruptibly(latch, 5,
TimeUnit.SECONDS))
+ .describedAs("STAGED starts the importing phase of a fast forward job,
hence it is a phase signal")
+ .isTrue();
+ }
+
+ @Test
+ void
testUpdateToStagedDoesNotNotifyDiscovererForRegularJob(VertxTestContext
context) throws Throwable
+ {
+ String jobId = "8e5799a4-d277-11ed-8d85-6916bb9b8056";
+ CountDownLatch latch = new CountDownLatch(1);
+ testRestoreJobDiscoverer.processJobNowCallback = job ->
latch.countDown();
+ mockLookupRestoreJob(id -> createTestJobWithStatus(jobId,
RestoreJobStatus.STAGE_READY));
+ mockUpdateRestoreJob(payload -> createTestJobWithStatus(jobId,
RestoreJobStatus.STAGED));
+ JsonObject payload = new JsonObject();
+ payload.put("status", "STAGED");
+ sendUpdateRestoreJobRequestAndVerify("ks", "table", jobId,
+ payload, context,
HttpResponseStatus.OK.code());
+ assertThat(Uninterruptibles.awaitUninterruptibly(latch, 1,
TimeUnit.SECONDS))
+ .describedAs("A regular job still waits for the IMPORT_READY signal,
so STAGED is not a phase signal")
+ .isFalse();
+ }
+
private RestoreJob createTestNewJob(String jobId)
{
return RestoreJob.builder()
@@ -256,6 +290,11 @@ class UpdateRestoreJobHandlerTest extends
BaseRestoreJobTests
.build();
}
+ private RestoreJob createTestFastForwardJobWithStatus(String jobId,
RestoreJobStatus status)
+ {
+ return createTestJobWithStatus(jobId,
status).unbuild().fastForwardEnabled(true).build();
+ }
+
private JsonObject getRequestPayload()
{
JsonObject payload = new JsonObject();
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 a0e946d6..4404f05a 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
@@ -79,6 +79,7 @@ import static org.mockito.Mockito.doNothing;
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;
@@ -293,11 +294,87 @@ class RestoreJobDiscovererTest
verify(mockRangeAccessor, never()).create(any());
}
+ @Test
+ void testDiscoverFastForwardJobInCreatedStatus() throws Exception
+ {
+ UUID jobId = UUIDs.timeBased();
+ RestoreJob job = createTestingJob(jobId, RestoreJobStatus.CREATED,
ConsistencyLevel.QUORUM)
+ .unbuild()
+ .fastForwardEnabled(true)
+ .build();
+ mockSidecarManagedJobDiscovery(job);
+
+ executeBlocking();
+ verify(mockSliceAccessor,
times(1)).selectByJobByBucketByTokenRange(any(), anyShort(), any());
+ ArgumentCaptor<RestoreRange> restoreRangeCaptor =
ArgumentCaptor.forClass(RestoreRange.class);
+ verify(mockRangeAccessor).create(restoreRangeCaptor.capture());
+ assertThat(restoreRangeCaptor.getValue().jobId()).isEqualTo(jobId);
+
+ executeBlocking();
+ // slices of a fast forward job are uploaded progressively, hence the
discovery repeats in CREATED status
+ verify(mockSliceAccessor,
times(2)).selectByJobByBucketByTokenRange(any(), anyShort(), any());
+ }
+
+ @Test
+ void testSkipDiscoveringSlicesOfRegularJobInCreatedStatus() throws
Exception
+ {
+ mockSidecarManagedJobDiscovery(createTestingJob(UUIDs.timeBased(),
RestoreJobStatus.CREATED, ConsistencyLevel.QUORUM));
+
+ executeBlocking();
+
+ verify(mockSliceAccessor,
never()).selectByJobByBucketByTokenRange(any(), anyShort(), any());
+ verify(mockRangeAccessor, never()).create(any());
+ }
+
+ @Test
+ void testDiscoveringSlicesOfStageReadyJobIsNotRepeated() throws Exception
+ {
+ mockSidecarManagedJobDiscovery(createTestingJob(UUIDs.timeBased(),
RestoreJobStatus.STAGE_READY, ConsistencyLevel.QUORUM));
+
+ executeBlocking();
+ executeBlocking();
+
+ // STAGE_READY guarantees that all slices are uploaded, so discovering
them once is sufficient
+ verify(mockSliceAccessor,
times(1)).selectByJobByBucketByTokenRange(any(), anyShort(), any());
+ }
+
+ @Test
+ void testDiscoveringSlicesOfFastForwardJobInStagedStatusIsNotRepeated()
throws Exception
+ {
+ UUID jobId = UUIDs.timeBased();
+ RestoreJob job = createTestingJob(jobId, RestoreJobStatus.STAGED,
ConsistencyLevel.QUORUM)
+ .unbuild()
+ .fastForwardEnabled(true)
+ .build();
+ mockSidecarManagedJobDiscovery(job);
+
+ executeBlocking();
+ // on entering STAGED, the ranges are discovered and submitted, so
that the staged ones can be imported
+ verify(mockSliceAccessor,
times(1)).selectByJobByBucketByTokenRange(any(), anyShort(), any());
+
+ executeBlocking();
+ // all slices are uploaded by the time the job is STAGED, so the
expensive discovery is not repeated
+ verify(mockSliceAccessor,
times(1)).selectByJobByBucketByTokenRange(any(), anyShort(), any());
+ }
+
private UUID discoverSidecarManagedJob(boolean isJobFailed) throws
Exception
{
- when(sidecarSchema.isInitialized()).thenReturn(true);
UUID jobId = UUIDs.timeBased();
RestoreJob sidecarManagedJob = createTestingJob(jobId,
RestoreJobStatus.STAGE_READY, ConsistencyLevel.QUORUM);
+ mockSidecarManagedJobDiscovery(sidecarManagedJob);
+ if (isJobFailed)
+ {
+ doThrow(RestoreJobExceptions.ofFatal("Job failed",
mock(RestoreRange.class), null))
+ .when(mockManagers).trySubmit(any(), any(), any());
+ }
+
+ executeBlocking();
+ return jobId;
+ }
+
+ private void mockSidecarManagedJobDiscovery(RestoreJob sidecarManagedJob)
throws Exception
+ {
+ when(sidecarSchema.isInitialized()).thenReturn(true);
assertThat(sidecarManagedJob.isManagedBySidecar()).isTrue();
when(mockJobAccessor.findAllRecent(anyLong(), anyInt()))
@@ -309,18 +386,7 @@ class RestoreJobDiscovererTest
when(instanceMetadataFetcher.instance(anyInt())).thenReturn(instance);
when(mockSliceAccessor.selectByJobByBucketByTokenRange(any(),
anyShort(), any()))
.thenReturn(Collections.singletonList(RestoreSliceTest.createTestingSlice(sidecarManagedJob,
"sliceId", 0, 10)));
- if (isJobFailed)
- {
- doThrow(RestoreJobExceptions.ofFatal("Job failed",
mock(RestoreRange.class), null))
- .when(mockManagers).trySubmit(any(), any(), any());
- }
- else
- {
- when(mockManagers.trySubmit(any(), any(),
any())).thenReturn(RestoreJobProgressTracker.Status.CREATED);
- }
-
- executeBlocking();
- return jobId;
+ when(mockManagers.trySubmit(any(), any(),
any())).thenReturn(RestoreJobProgressTracker.Status.CREATED);
}
@Test
diff --git
a/server/src/test/java/org/apache/cassandra/sidecar/restore/RestoreProcessorTest.java
b/server/src/test/java/org/apache/cassandra/sidecar/restore/RestoreProcessorTest.java
index 2c1b65ea..6b347354 100644
---
a/server/src/test/java/org/apache/cassandra/sidecar/restore/RestoreProcessorTest.java
+++
b/server/src/test/java/org/apache/cassandra/sidecar/restore/RestoreProcessorTest.java
@@ -30,12 +30,14 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.codahale.metrics.SharedMetricRegistries;
+import com.datastax.driver.core.utils.UUIDs;
import io.vertx.core.Promise;
import io.vertx.core.Vertx;
import org.apache.cassandra.sidecar.ExecutorPoolsHelper;
import org.apache.cassandra.sidecar.TestModule;
import org.apache.cassandra.sidecar.TestResourceReaper;
import org.apache.cassandra.sidecar.cluster.locator.LocalTokenRangesProvider;
+import org.apache.cassandra.sidecar.common.data.RestoreJobStatus;
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;
@@ -43,6 +45,8 @@ import
org.apache.cassandra.sidecar.config.SidecarConfiguration;
import org.apache.cassandra.sidecar.config.yaml.RestoreJobConfigurationImpl;
import org.apache.cassandra.sidecar.config.yaml.SidecarConfigurationImpl;
import org.apache.cassandra.sidecar.coordination.ClusterLease;
+import org.apache.cassandra.sidecar.db.RestoreJob;
+import org.apache.cassandra.sidecar.db.RestoreJobTest;
import org.apache.cassandra.sidecar.db.RestoreRange;
import org.apache.cassandra.sidecar.db.RestoreRangeDatabaseAccessor;
import org.apache.cassandra.sidecar.db.schema.SidecarSchema;
@@ -63,6 +67,7 @@ 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.anyDouble;
+import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
@@ -300,6 +305,34 @@ class RestoreProcessorTest
});
}
+ @Test
+ void testStagedRangeWaitsForImportSignal()
+ {
+ CountDownLatch readyToFinish = new CountDownLatch(1);
+ RestoreRange range = stagedRange(readyToFinish,
RestoreJobTest.createTestingJob(UUIDs.timeBased(), RestoreJobStatus.STAGED));
+ processor.submit(range);
+ processor.execute(Promise.promise());
+ assertThat(processor.activeTasks())
+ .describedAs("A staged range of a regular job should wait for the
IMPORT_READY signal, " +
+ "even when the job has entered STAGED status")
+ .isZero();
+ readyToFinish.countDown();
+ }
+
+ @Test
+ void testStagedRangeOfFastForwardJobImportsOnceStaged()
+ {
+ CountDownLatch readyToFinish = new CountDownLatch(1);
+ RestoreRange range = stagedRange(readyToFinish,
RestoreJobTest.createFastForwardTestingJob(UUIDs.timeBased(),
RestoreJobStatus.STAGED));
+ processor.submit(range);
+ processor.execute(Promise.promise());
+ assertThat(processor.activeTasks())
+ .describedAs("A staged range of a fast forward job should import as
soon as the job enters STAGED status")
+ .isOne();
+ readyToFinish.countDown();
+ loopAssert(3, () -> assertThat(processor.activeTasks()).isZero());
+ }
+
private InstanceMetrics instanceMetrics()
{
return new InstanceMetricsImpl(registry(1));
@@ -310,6 +343,15 @@ class RestoreProcessorTest
return mockSlowRestoreRange(latch, System::nanoTime);
}
+ // a range that has completed its staging phase and belongs to the given
restore job
+ private RestoreRange stagedRange(CountDownLatch latch, RestoreJob job)
+ {
+ RestoreRange range = mockSlowRestoreRange(latch);
+ doReturn(true).when(range).hasStaged();
+ doReturn(job).when(range).job();
+ return range;
+ }
+
private RestoreRange mockSlowRestoreRange(CountDownLatch latch,
RestoreRange mockRange)
{
return mockSlowRestoreRange(latch, System::nanoTime, mockRange);
diff --git
a/server/src/test/java/org/apache/cassandra/sidecar/restore/RestoreRangeTaskTest.java
b/server/src/test/java/org/apache/cassandra/sidecar/restore/RestoreRangeTaskTest.java
index 4a8a170c..4e8d4e85 100644
---
a/server/src/test/java/org/apache/cassandra/sidecar/restore/RestoreRangeTaskTest.java
+++
b/server/src/test/java/org/apache/cassandra/sidecar/restore/RestoreRangeTaskTest.java
@@ -342,6 +342,69 @@ class RestoreRangeTaskTest
assertThat(mockRange.hasImported()).isTrue();
}
+ @Test
+ void testStagingOfFastForwardJobInCreatedStatus()
+ {
+ // A fast forward job stages its slices ahead of the STAGE_READY
signal, i.e. while it is still CREATED
+ RestoreJob job =
spy(RestoreJobTest.createFastForwardTestingJob(UUIDs.timeBased(),
RestoreJobStatus.CREATED));
+ doReturn(true).when(job).isManagedBySidecar();
+ doReturn(job).when(mockRange).job();
+ doReturn(Paths.get("nonexist")).when(mockRange).stagedObjectPath();
+ HeadObjectResponse headObjectResponse = mock(HeadObjectResponse.class);
+ when(headObjectResponse.contentLength()).thenReturn(1L);
+
when(mockStorageClient.objectExists(mockRange)).thenReturn(CompletableFuture.completedFuture(headObjectResponse));
+ when(mockStorageClient.downloadObjectIfAbsent(eq(mockRange),
any(TaskExecutorPool.class)))
+ .thenReturn(Future.succeededFuture(new File(".")));
+ RestoreRangeTask task = createTask(mockRange, job);
+
+ Promise<RestoreRange> promise = Promise.promise();
+ task.handle(promise);
+ getBlocking(promise.future()); // no error is thrown
+
+ verify(mockRange, times(1)).completeStagePhase();
+ verify(mockRange, times(0)).completeImportPhase(); // should not be
called in this phase
+ verify(mockRangeAccessor, times(1)).updateStatus(mockRange);
+ assertThat(mockRange.hasStaged()).isTrue();
+ assertThat(mockRange.hasImported()).isFalse();
+ }
+
+ @Test
+ void testImportPhaseOfFastForwardJobInStagedStatus()
+ {
+ // A fast forward job imports its staged slices ahead of the
IMPORT_READY signal, i.e. once it is STAGED
+ RestoreJob job =
spy(RestoreJobTest.createFastForwardTestingJob(UUIDs.timeBased(),
RestoreJobStatus.STAGED));
+ doReturn(true).when(job).isManagedBySidecar();
+ doReturn(job).when(mockRange).job();
+ doReturn(true).when(mockRange).hasStaged();
+ RestoreRangeTask task = createTask(mockRange, job);
+
+ Promise<RestoreRange> promise = Promise.promise();
+ task.handle(promise);
+ getBlocking(promise.future()); // no error is thrown
+
+ verify(mockRange, times(0)).completeStagePhase(); // should not be
called in the phase
+ verify(mockRange, times(1)).completeImportPhase();
+ verify(mockRangeAccessor, times(1)).updateStatus(mockRange);
+ assertThat(mockRange.hasImported()).isTrue();
+ }
+
+ @Test
+ void testFailsWhenJobIsNotReadyToStageOrImport()
+ {
+ // Without fast forward, no phase can start while the job is in
CREATED status
+ RestoreJob job =
spy(RestoreJobTest.createTestingJob(UUIDs.timeBased(),
RestoreJobStatus.CREATED));
+ doReturn(true).when(job).isManagedBySidecar();
+ doReturn(job).when(mockRange).job();
+ RestoreRangeTask task = createTask(mockRange, job);
+
+ Promise<RestoreRange> promise = Promise.promise();
+ task.handle(promise);
+ assertThatThrownBy(() -> getBlocking(promise.future()))
+ .hasMessageContaining("Unexpected restore job status");
+ verify(mockRange, times(0)).completeStagePhase();
+ verify(mockRange, times(0)).completeImportPhase();
+ }
+
@Test
void testImportSSTables(@TempDir Path testFolder)
{
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]