diff --git a/google-cloud-spanner/clirr-ignored-differences.xml b/google-cloud-spanner/clirr-ignored-differences.xml index bdef45796c..a8afa4b642 100644 --- a/google-cloud-spanner/clirr-ignored-differences.xml +++ b/google-cloud-spanner/clirr-ignored-differences.xml @@ -11,4 +11,140 @@ com/google/cloud/spanner/spi/v1/SpannerRpc * asyncDeleteSession(*) + + + + 7012 + com/google/cloud/spanner/DatabaseAdminClient + void cancelOperation(java.lang.String) + + + 7012 + com/google/cloud/spanner/DatabaseAdminClient + com.google.api.gax.longrunning.OperationFuture createBackup(java.lang.String, java.lang.String, java.lang.String, com.google.cloud.Timestamp) + + + 7012 + com/google/cloud/spanner/DatabaseAdminClient + void deleteBackup(java.lang.String, java.lang.String) + + + 7012 + com/google/cloud/spanner/DatabaseAdminClient + com.google.cloud.spanner.Backup getBackup(java.lang.String, java.lang.String) + + + 7012 + com/google/cloud/spanner/DatabaseAdminClient + com.google.cloud.spanner.Backup getBackup(java.lang.String, java.lang.String) + + + 7012 + com/google/cloud/spanner/DatabaseAdminClient + com.google.cloud.Policy getBackupIAMPolicy(java.lang.String, java.lang.String) + + + 7012 + com/google/cloud/spanner/DatabaseAdminClient + com.google.longrunning.Operation getOperation(java.lang.String) + + + 7012 + com/google/cloud/spanner/DatabaseAdminClient + com.google.api.gax.paging.Page listBackupOperations(java.lang.String, com.google.cloud.spanner.Options$ListOption[]) + + + 7012 + com/google/cloud/spanner/DatabaseAdminClient + com.google.api.gax.paging.Page listBackups(java.lang.String, com.google.cloud.spanner.Options$ListOption[]) + + + 7012 + com/google/cloud/spanner/DatabaseAdminClient + com.google.api.gax.paging.Page listDatabaseOperations(java.lang.String, com.google.cloud.spanner.Options$ListOption[]) + + + 7012 + com/google/cloud/spanner/DatabaseAdminClient + com.google.cloud.spanner.Backup$Builder newBackupBuilder(com.google.cloud.spanner.BackupId) + + + 7012 + com/google/cloud/spanner/DatabaseAdminClient + com.google.cloud.spanner.Backup$Builder newBackupBuilder(com.google.cloud.spanner.BackupId) + + + 7012 + com/google/cloud/spanner/DatabaseAdminClient + com.google.api.gax.longrunning.OperationFuture restoreDatabase(java.lang.String, java.lang.String, java.lang.String, java.lang.String) + + + 7012 + com/google/cloud/spanner/DatabaseAdminClient + com.google.cloud.Policy setBackupIAMPolicy(java.lang.String, java.lang.String, com.google.cloud.Policy) + + + 7012 + com/google/cloud/spanner/DatabaseAdminClient + java.lang.Iterable testBackupIAMPermissions(java.lang.String, java.lang.String, java.lang.Iterable) + + + 7012 + com/google/cloud/spanner/DatabaseAdminClient + com.google.cloud.spanner.Backup updateBackup(java.lang.String, java.lang.String, com.google.cloud.Timestamp) + + + + 7012 + com/google/cloud/spanner/spi/v1/SpannerRpc + void cancelOperation(java.lang.String) + + + 7012 + com/google/cloud/spanner/spi/v1/SpannerRpc + com.google.api.gax.longrunning.OperationFuture createBackup(java.lang.String, java.lang.String, com.google.spanner.admin.database.v1.Backup) + + + 7012 + com/google/cloud/spanner/spi/v1/SpannerRpc + void deleteBackup(java.lang.String) + + + 7012 + com/google/cloud/spanner/spi/v1/SpannerRpc + com.google.spanner.admin.database.v1.Backup getBackup(java.lang.String) + + + 7012 + com/google/cloud/spanner/spi/v1/SpannerRpc + com.google.cloud.spanner.spi.v1.SpannerRpc$Paginated listBackupOperations(java.lang.String, int, java.lang.String, java.lang.String) + + + 7012 + com/google/cloud/spanner/spi/v1/SpannerRpc + com.google.cloud.spanner.spi.v1.SpannerRpc$Paginated listBackups(java.lang.String, int, java.lang.String, java.lang.String) + + + 7012 + com/google/cloud/spanner/spi/v1/SpannerRpc + com.google.cloud.spanner.spi.v1.SpannerRpc$Paginated listDatabaseOperations(java.lang.String, int, java.lang.String, java.lang.String) + + + 7012 + com/google/cloud/spanner/spi/v1/SpannerRpc + com.google.api.gax.longrunning.OperationFuture restoreDatabase(java.lang.String, java.lang.String, java.lang.String) + + + 7012 + com/google/cloud/spanner/spi/v1/SpannerRpc + com.google.spanner.admin.database.v1.Backup updateBackup(com.google.spanner.admin.database.v1.Backup, com.google.protobuf.FieldMask) + + + + 7004 + com/google/cloud/spanner/Instance + + com.google.api.gax.paging.Page listDatabases() + + diff --git a/google-cloud-spanner/pom.xml b/google-cloud-spanner/pom.xml index 546fa250d5..fb9a1f22c0 100644 --- a/google-cloud-spanner/pom.xml +++ b/google-cloud-spanner/pom.xml @@ -62,6 +62,38 @@ + + org.apache.maven.plugins + maven-failsafe-plugin + + + com.google.cloud.spanner.GceTestEnvConfig + projects/gcloud-devel/instances/spanner-testing + + 3000 + + + + default + + com.google.cloud.spanner.IntegrationTest + com.google.cloud.spanner.FlakyTest,com.google.cloud.spanner.TracerTest,com.google.cloud.spanner.ParallelIntegrationTest + + + + parallel-integration-test + + integration-test + + + com.google.cloud.spanner.ParallelIntegrationTest + com.google.cloud.spanner.FlakyTest,com.google.cloud.spanner.TracerTest,com.google.cloud.spanner.IntegrationTest + 8 + true + + + + @@ -74,15 +106,6 @@ org.apache.maven.plugins maven-failsafe-plugin 3.0.0-M4 - - - com.google.cloud.spanner.GceTestEnvConfig - projects/gcloud-devel/instances/spanner-testing - - com.google.cloud.spanner.IntegrationTest - com.google.cloud.spanner.FlakyTest,com.google.cloud.spanner.TracerTest - 2400 - org.apache.maven.plugins diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Backup.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Backup.java new file mode 100644 index 0000000000..d49e9f7f50 --- /dev/null +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Backup.java @@ -0,0 +1,203 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed 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 com.google.cloud.spanner; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.api.client.util.Preconditions; +import com.google.api.gax.longrunning.OperationFuture; +import com.google.api.gax.paging.Page; +import com.google.cloud.Policy; +import com.google.cloud.Timestamp; +import com.google.longrunning.Operation; +import com.google.spanner.admin.database.v1.CreateBackupMetadata; +import com.google.spanner.admin.database.v1.RestoreDatabaseMetadata; + +/** + * Represents a Cloud Spanner database backup. {@code Backup} adds a layer of service related + * functionality over {@code BackupInfo}. + */ +public class Backup extends BackupInfo { + public static class Builder extends BackupInfo.BuilderImpl { + private final DatabaseAdminClient dbClient; + + Builder(DatabaseAdminClient dbClient, BackupId backupId) { + super(backupId); + this.dbClient = Preconditions.checkNotNull(dbClient); + } + + private Builder(Backup backup) { + super(backup); + this.dbClient = backup.dbClient; + } + + @Override + public Backup build() { + return new Backup(this); + } + } + + private static final String FILTER_BACKUP_OPERATIONS_TEMPLATE = "name:backups/%s"; + private final DatabaseAdminClient dbClient; + + Backup(Builder builder) { + super(builder); + this.dbClient = Preconditions.checkNotNull(builder.dbClient); + } + + /** Creates a backup on the server based on the source of this {@link Backup} instance. */ + public OperationFuture create() { + Preconditions.checkState( + getExpireTime() != null, "Cannot create a backup without an expire time"); + Preconditions.checkState( + getDatabase() != null, "Cannot create a backup without a source database"); + return dbClient.createBackup(instance(), backup(), sourceDatabase(), getExpireTime()); + } + + /** + * Returns true if a backup with the id of this {@link Backup} exists on Cloud + * Spanner. + */ + public boolean exists() { + try { + dbClient.getBackup(instance(), backup()); + } catch (SpannerException e) { + if (e.getErrorCode() == ErrorCode.NOT_FOUND) { + return false; + } + throw e; + } + return true; + } + + /** + * Returns true if this backup is ready to use. The value returned by this method + * could be out-of-sync with the value returned by {@link #getState()}, as this method will make a + * round-trip to the server and return a value based on the response from the server. + */ + public boolean isReady() { + return reload().getState() == State.READY; + } + + /** + * Fetches the backup's current information and returns a new {@link Backup} instance. It does not + * update this instance. + */ + public Backup reload() throws SpannerException { + return dbClient.getBackup(instance(), backup()); + } + + /** Deletes this backup on Cloud Spanner. */ + public void delete() throws SpannerException { + dbClient.deleteBackup(instance(), backup()); + } + + /** + * Updates the expire time of this backup on Cloud Spanner. If this {@link Backup} does not have + * an expire time, the method will throw an {@link IllegalStateException}. + */ + public void updateExpireTime() { + Preconditions.checkState(getExpireTime() != null, "This backup has no expire time"); + dbClient.updateBackup(instance(), backup(), getExpireTime()); + } + + /** + * Restores this backup to the specified database. The database must not already exist and will be + * created by this call. The database may be created in a different instance than where the backup + * is stored. + */ + public OperationFuture restore(DatabaseId database) { + Preconditions.checkNotNull(database); + return dbClient.restoreDatabase( + instance(), backup(), database.getInstanceId().getInstance(), database.getDatabase()); + } + + /** Returns all long-running backup operations for this {@link Backup}. */ + public Page listBackupOperations() { + return dbClient.listBackupOperations( + instance(), Options.filter(String.format(FILTER_BACKUP_OPERATIONS_TEMPLATE, backup()))); + } + + /** Returns the IAM {@link Policy} for this backup. */ + public Policy getIAMPolicy() { + return dbClient.getBackupIAMPolicy(instance(), backup()); + } + + /** + * Updates the IAM policy for this backup and returns the resulting policy. It is highly + * recommended to first get the current policy and base the updated policy on the returned policy. + * See {@link Policy.Builder#setEtag(String)} for information on the recommended read-modify-write + * cycle. + */ + public Policy setIAMPolicy(Policy policy) { + return dbClient.setBackupIAMPolicy(instance(), backup(), policy); + } + + /** + * Tests for the given permissions on this backup for the caller. + * + * @param permissions the permissions to test for. Permissions with wildcards (such as '*', + * 'spanner.*', 'spanner.instances.*') are not allowed. + * @return the subset of the tested permissions that the caller is allowed. + */ + public Iterable testIAMPermissions(Iterable permissions) { + return dbClient.testBackupIAMPermissions(instance(), backup(), permissions); + } + + public Builder toBuilder() { + return new Builder(this); + } + + private String instance() { + return getInstanceId().getInstance(); + } + + private String backup() { + return getId().getBackup(); + } + + private String sourceDatabase() { + return getDatabase().getDatabase(); + } + + static Backup fromProto( + com.google.spanner.admin.database.v1.Backup proto, DatabaseAdminClient client) { + checkArgument(!proto.getName().isEmpty(), "Missing expected 'name' field"); + checkArgument(!proto.getDatabase().isEmpty(), "Missing expected 'database' field"); + return new Backup.Builder(client, BackupId.of(proto.getName())) + .setState(fromProtoState(proto.getState())) + .setSize(proto.getSizeBytes()) + .setExpireTime(Timestamp.fromProto(proto.getExpireTime())) + .setDatabase(DatabaseId.of(proto.getDatabase())) + .setProto(proto) + .build(); + } + + static BackupInfo.State fromProtoState( + com.google.spanner.admin.database.v1.Backup.State protoState) { + switch (protoState) { + case STATE_UNSPECIFIED: + return BackupInfo.State.UNSPECIFIED; + case CREATING: + return BackupInfo.State.CREATING; + case READY: + return BackupInfo.State.READY; + default: + throw new IllegalArgumentException("Unrecognized state " + protoState); + } + } +} diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/BackupId.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/BackupId.java new file mode 100644 index 0000000000..f89a242fc7 --- /dev/null +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/BackupId.java @@ -0,0 +1,107 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed 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 com.google.cloud.spanner; + +import com.google.api.pathtemplate.PathTemplate; +import com.google.common.base.Preconditions; +import java.util.Map; +import java.util.Objects; + +/** Represents an id of a Cloud Spanner backup resource. */ +public final class BackupId { + private static final PathTemplate NAME_TEMPLATE = + PathTemplate.create("projects/{project}/instances/{instance}/backups/{backup}"); + + private final InstanceId instanceId; + private final String backup; + + BackupId(InstanceId instanceId, String backup) { + this.instanceId = instanceId; + this.backup = backup; + } + + /** Returns the instance id for this backup. */ + public InstanceId getInstanceId() { + return instanceId; + } + + /** Returns the backup id. */ + public String getBackup() { + return backup; + } + + /** Returns the name of this backup. */ + public String getName() { + return String.format( + "projects/%s/instances/%s/backups/%s", + instanceId.getProject(), instanceId.getInstance(), backup); + } + + @Override + public int hashCode() { + return Objects.hash(instanceId, backup); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + BackupId that = (BackupId) o; + return that.instanceId.equals(instanceId) && that.backup.equals(backup); + } + + @Override + public String toString() { + return getName(); + } + + /** + * Creates a {@code BackupId} from the name of the backup. + * + * @param name the backup name of the form {@code + * projects/PROJECT_ID/instances/INSTANCE_ID/backups/BACKUP_ID} + * @throws IllegalArgumentException if {@code name} does not conform to the expected pattern + */ + static BackupId of(String name) { + Map parts = NAME_TEMPLATE.match(name); + Preconditions.checkArgument( + parts != null, "Name should conform to pattern %s: %s", NAME_TEMPLATE, name); + return of(parts.get("project"), parts.get("instance"), parts.get("backup")); + } + + /** + * Creates a {@code BackupId} given project, instance and backup IDs. The backup id must conform + * to the regular expression [a-z][a-z0-9_\-]*[a-z0-9] and be between 2 and 60 characters in + * length. + */ + public static BackupId of(String project, String instance, String backup) { + return new BackupId(new InstanceId(project, instance), backup); + } + + /** + * Creates a {@code BackupId} given the instance identity and backup id. The backup id must + * conform to the regular expression [a-z][a-z0-9_\-]*[a-z0-9] and be between 2 and 60 characters + * in length. + */ + public static BackupId of(InstanceId instanceId, String backup) { + return new BackupId(instanceId, backup); + } +} diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/BackupInfo.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/BackupInfo.java new file mode 100644 index 0000000000..5968bcdc21 --- /dev/null +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/BackupInfo.java @@ -0,0 +1,196 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed 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 com.google.cloud.spanner; + +import com.google.api.client.util.Preconditions; +import com.google.cloud.Timestamp; +import java.util.Objects; +import javax.annotation.Nullable; + +/** Represents a Cloud Spanner database backup. */ +public class BackupInfo { + public abstract static class Builder { + abstract Builder setState(State state); + + abstract Builder setSize(long size); + + abstract Builder setProto(com.google.spanner.admin.database.v1.Backup proto); + + /** + * Required for creating a new backup. + * + *

Sets the expiration time of the backup. The expiration time of the backup, with + * microseconds granularity that must be at least 6 hours and at most 366 days from the time the + * request is received. Once the expireTime has passed, Cloud Spanner will delete the backup and + * free the resources used by the backup. + */ + public abstract Builder setExpireTime(Timestamp expireTime); + + /** + * Required for creating a new backup. + * + *

Sets the source database to use for creating the backup. + */ + public abstract Builder setDatabase(DatabaseId database); + + /** Builds the backup from this builder. */ + public abstract Backup build(); + } + + abstract static class BuilderImpl extends Builder { + protected final BackupId id; + private State state = State.UNSPECIFIED; + private Timestamp expireTime; + private DatabaseId database; + private long size; + private com.google.spanner.admin.database.v1.Backup proto; + + BuilderImpl(BackupId id) { + this.id = Preconditions.checkNotNull(id); + } + + BuilderImpl(BackupInfo other) { + this.id = other.id; + this.state = other.state; + this.expireTime = other.expireTime; + this.database = other.database; + this.size = other.size; + this.proto = other.proto; + } + + @Override + Builder setState(State state) { + this.state = Preconditions.checkNotNull(state); + return this; + } + + @Override + public Builder setExpireTime(Timestamp expireTime) { + this.expireTime = Preconditions.checkNotNull(expireTime); + return this; + } + + @Override + public Builder setDatabase(DatabaseId database) { + Preconditions.checkArgument( + database.getInstanceId().equals(id.getInstanceId()), + "The instance of the source database must be equal to the instance of the backup."); + this.database = Preconditions.checkNotNull(database); + return this; + } + + @Override + Builder setSize(long size) { + this.size = size; + return this; + } + + @Override + Builder setProto(@Nullable com.google.spanner.admin.database.v1.Backup proto) { + this.proto = proto; + return this; + } + } + + /** State of the backup. */ + public enum State { + // Not specified. + UNSPECIFIED, + // The backup is still being created and is not ready to use. + CREATING, + // The backup is fully created and ready to use. + READY, + } + + private final BackupId id; + private final State state; + private final Timestamp expireTime; + private final DatabaseId database; + private final long size; + private final com.google.spanner.admin.database.v1.Backup proto; + + BackupInfo(BuilderImpl builder) { + this.id = builder.id; + this.state = builder.state; + this.size = builder.size; + this.expireTime = builder.expireTime; + this.database = builder.database; + this.proto = builder.proto; + } + + /** Returns the backup id. */ + public BackupId getId() { + return id; + } + + /** Returns the id of the instance that the backup belongs to. */ + public InstanceId getInstanceId() { + return id.getInstanceId(); + } + + /** Returns the state of the backup. */ + public State getState() { + return state; + } + + /** Returns the size of the backup in bytes. */ + public long getSize() { + return size; + } + + /** Returns the expire time of the backup. */ + public Timestamp getExpireTime() { + return expireTime; + } + + /** Returns the id of the database that was used to create the backup. */ + public DatabaseId getDatabase() { + return database; + } + + /** Returns the raw proto instance that was used to construct this {@link Backup}. */ + public @Nullable com.google.spanner.admin.database.v1.Backup getProto() { + return proto; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + BackupInfo that = (BackupInfo) o; + return id.equals(that.id) + && state == that.state + && size == that.size + && Objects.equals(expireTime, that.expireTime) + && Objects.equals(database, that.database); + } + + @Override + public int hashCode() { + return Objects.hash(id, state, size, expireTime, database); + } + + @Override + public String toString() { + return String.format( + "Backup[%s, %s, %d, %s, %s]", id.getName(), state, size, expireTime, database); + } +} diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Database.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Database.java index 307cad1ed7..89ee6e875a 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Database.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Database.java @@ -19,7 +19,12 @@ import static com.google.common.base.Preconditions.checkArgument; import com.google.api.gax.longrunning.OperationFuture; +import com.google.api.gax.paging.Page; import com.google.cloud.Policy; +import com.google.cloud.Timestamp; +import com.google.common.base.Preconditions; +import com.google.longrunning.Operation; +import com.google.spanner.admin.database.v1.CreateBackupMetadata; import com.google.spanner.admin.database.v1.UpdateDatabaseDdlMetadata; /** @@ -27,7 +32,26 @@ * functionality over {@code DatabaseInfo}. */ public class Database extends DatabaseInfo { + public static class Builder extends DatabaseInfo.BuilderImpl { + private final DatabaseAdminClient dbClient; + Builder(DatabaseAdminClient dbClient, DatabaseId databaseId) { + super(databaseId); + this.dbClient = Preconditions.checkNotNull(dbClient); + } + + private Builder(Database database) { + super(database); + this.dbClient = database.dbClient; + } + + @Override + public Database build() { + return new Database(this); + } + } + + private static final String FILTER_DB_OPERATIONS_TEMPLATE = "name:databases/%s"; private final DatabaseAdminClient dbClient; public Database(DatabaseId id, State state, DatabaseAdminClient dbClient) { @@ -35,6 +59,11 @@ public Database(DatabaseId id, State state, DatabaseAdminClient dbClient) { this.dbClient = dbClient; } + Database(Builder builder) { + super(builder); + this.dbClient = Preconditions.checkNotNull(builder.dbClient); + } + /** Fetches the database's current information. */ public Database reload() throws SpannerException { return dbClient.getDatabase(instance(), database()); @@ -63,6 +92,36 @@ public void drop() throws SpannerException { dbClient.dropDatabase(instance(), database()); } + /** + * Returns true if a database with the id of this {@link Database} exists on Cloud + * Spanner. + */ + public boolean exists() { + try { + dbClient.getDatabase(instance(), database()); + } catch (SpannerException e) { + if (e.getErrorCode() == ErrorCode.NOT_FOUND) { + return false; + } + throw e; + } + return true; + } + + /** + * Backs up this database to the location specified by the {@link Backup}. The given {@link + * Backup} must have an expire time. The backup must belong to the same instance as this database. + */ + public OperationFuture backup(Backup backup) { + Preconditions.checkArgument( + backup.getExpireTime() != null, "The backup does not have an expire time."); + Preconditions.checkArgument( + backup.getInstanceId().equals(getId().getInstanceId()), + "The instance of the backup must be equal to the instance of this database."); + return dbClient.createBackup( + instance(), backup.getId().getBackup(), database(), backup.getExpireTime()); + } + /** * Returns the schema of a Cloud Spanner database as a list of formatted DDL statements. This * method does not show pending schema updates. @@ -71,6 +130,12 @@ public Iterable getDdl() throws SpannerException { return dbClient.getDatabaseDdl(instance(), database()); } + /** Returns the long-running operations for this database. */ + public Page listDatabaseOperations() { + return dbClient.listDatabaseOperations( + instance(), Options.filter(String.format(FILTER_DB_OPERATIONS_TEMPLATE, database()))); + } + /** Returns the IAM {@link Policy} for this database. */ public Policy getIAMPolicy() { return dbClient.getDatabaseIAMPolicy(instance(), database()); @@ -108,7 +173,12 @@ private String database() { static Database fromProto( com.google.spanner.admin.database.v1.Database proto, DatabaseAdminClient client) { checkArgument(!proto.getName().isEmpty(), "Missing expected 'name' field"); - return new Database(DatabaseId.of(proto.getName()), fromProtoState(proto.getState()), client); + return new Database.Builder(client, DatabaseId.of(proto.getName())) + .setState(fromProtoState(proto.getState())) + .setCreateTime(Timestamp.fromProto(proto.getCreateTime())) + .setRestoreInfo(RestoreInfo.fromProtoOrNullIfDefaultInstance(proto.getRestoreInfo())) + .setProto(proto) + .build(); } static DatabaseInfo.State fromProtoState( @@ -120,6 +190,8 @@ static DatabaseInfo.State fromProtoState( return DatabaseInfo.State.CREATING; case READY: return DatabaseInfo.State.READY; + case READY_OPTIMIZING: + return DatabaseInfo.State.READY_OPTIMIZING; default: throw new IllegalArgumentException("Unrecognized state " + protoState); } diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseAdminClient.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseAdminClient.java index a4f81ce11d..ed736b92ad 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseAdminClient.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseAdminClient.java @@ -19,8 +19,12 @@ import com.google.api.gax.longrunning.OperationFuture; import com.google.api.gax.paging.Page; import com.google.cloud.Policy; +import com.google.cloud.Timestamp; import com.google.cloud.spanner.Options.ListOption; +import com.google.longrunning.Operation; +import com.google.spanner.admin.database.v1.CreateBackupMetadata; import com.google.spanner.admin.database.v1.CreateDatabaseMetadata; +import com.google.spanner.admin.database.v1.RestoreDatabaseMetadata; import com.google.spanner.admin.database.v1.UpdateDatabaseDdlMetadata; import java.util.List; import javax.annotation.Nullable; @@ -64,6 +68,75 @@ public interface DatabaseAdminClient { OperationFuture createDatabase( String instanceId, String databaseId, Iterable statements) throws SpannerException; + /** Returns a builder for a {@code Backup} object with the given id. */ + Backup.Builder newBackupBuilder(BackupId id); + + /** + * Creates a new backup from a database in a Cloud Spanner instance. + * + *

Example to create a backup. + * + *

{@code
+   * String instance       = my_instance_id;
+   * String backupId       = my_backup_id;
+   * String databaseId     = my_database_id;
+   * Timestamp expireTime  = Timestamp.ofTimeMicroseconds(micros);
+   * OperationFuture op = dbAdminClient
+   *     .createBackup(
+   *         instanceId,
+   *         backupId,
+   *         databaseId,
+   *         expireTime);
+   * Backup backup = op.get();
+   * }
+ * + * @param instanceId the id of the instance where the database to backup is located and where the + * backup will be created. + * @param backupId the id of the backup which will be created. It must conform to the regular + * expression [a-z][a-z0-9_\-]*[a-z0-9] and be between 2 and 60 characters in length. + * @param databaseId the id of the database to backup. + * @param expireTime the time that the backup will automatically expire. + */ + OperationFuture createBackup( + String sourceInstanceId, String backupId, String databaseId, Timestamp expireTime) + throws SpannerException; + + /** + * Restore a database from a backup. The database that is restored will be created and may not + * already exist. + * + *

Example to restore a database. + * + *

{@code
+   * String backupInstanceId   = my_instance_id;
+   * String backupId           = my_backup_id;
+   * String restoreInstanceId  = my_db_instance_id;
+   * String restoreDatabaseId  = my_database_id;
+   * OperationFuture op = dbAdminClient
+   *     .restoreDatabase(
+   *         backupInstanceId,
+   *         backupId,
+   *         restoreInstanceId,
+   *         restoreDatabaseId);
+   * Database database = op.get();
+   * }
+ * + * @param backupInstanceId the id of the instance where the backup is located. + * @param backupId the id of the backup to restore. + * @param restoreInstanceId the id of the instance where the database should be created. This may + * be a different instance than where the backup is stored. + * @param restoreDatabaseId the id of the database to restore to. + */ + public OperationFuture restoreDatabase( + String backupInstanceId, String backupId, String restoreInstanceId, String restoreDatabaseId) + throws SpannerException; + + /** Lists long-running database operations on the specified instance. */ + Page listDatabaseOperations(String instanceId, ListOption... options); + + /** Lists long-running backup operations on the specified instance. */ + Page listBackupOperations(String instanceId, ListOption... options); + /** * Gets the current state of a Cloud Spanner database. * @@ -77,6 +150,19 @@ OperationFuture createDatabase( */ Database getDatabase(String instanceId, String databaseId) throws SpannerException; + /** + * Gets the current state of a Cloud Spanner database backup. + * + *

Example to get a backup. + * + *

{@code
+   * String instanceId = my_instance_id;
+   * String backupId   = my_backup_id;
+   * Backup backup = dbAdminClient.getBackup(instanceId, backupId);
+   * }
+ */ + Backup getBackup(String instanceId, String backupId) throws SpannerException; + /** * Enqueues the given DDL statements to be applied, in order but not necessarily all at once, to * the database schema at some point (or points) in the future. The server checks that the @@ -153,6 +239,48 @@ OperationFuture updateDatabaseDdl( */ Page listDatabases(String instanceId, ListOption... options); + /** + * Returns the list of Cloud Spanner backups in the given instance. + * + *

Example to get the list of Cloud Spanner backups in the given instance. + * + *

{@code
+   * String instanceId = my_instance_id;
+   * Page page = dbAdminClient.listBackups(instanceId, Options.pageSize(1));
+   * List backups = new ArrayList<>();
+   * while (page != null) {
+   *   Backup backup = Iterables.getOnlyElement(page.getValues());
+   *   dbs.add(backup);
+   *   page = page.getNextPage();
+   * }
+   * }
+ */ + Page listBackups(String instanceId, ListOption... options); + + /** + * Updates the expire time of a backup. + * + * @param instanceId Required. The instance of the backup to update. + * @param backupId Required. The backup id of the backup to update. + * @param expireTime Required. The new expire time of the backup to set to. + * @return the updated Backup object. + */ + Backup updateBackup(String instanceId, String backupId, Timestamp expireTime); + + /** + * Deletes a pending or completed backup. + * + * @param instanceId Required. The instance where the backup exists. + * @param backupId Required. The id of the backup to delete. + */ + void deleteBackup(String instanceId, String backupId); + + /** Cancels the specified long-running operation. */ + void cancelOperation(String name); + + /** Gets the specified long-running operation. */ + Operation getOperation(String name); + /** Returns the IAM policy for the given database. */ Policy getDatabaseIAMPolicy(String instanceId, String databaseId); @@ -175,4 +303,27 @@ OperationFuture updateDatabaseDdl( */ Iterable testDatabaseIAMPermissions( String instanceId, String databaseId, Iterable permissions); + + /** Returns the IAM policy for the given backup. */ + Policy getBackupIAMPolicy(String instanceId, String backupId); + + /** + * Updates the IAM policy for the given backup and returns the resulting policy. It is highly + * recommended to first get the current policy and base the updated policy on the returned policy. + * See {@link Policy.Builder#setEtag(String)} for information on the recommended read-modify-write + * cycle. + */ + Policy setBackupIAMPolicy(String instanceId, String backupId, Policy policy); + + /** + * Tests for the given permissions on the specified backup for the caller. + * + * @param instanceId the id of the instance where the backup to test is located. + * @param backupId the id of the backup to test. + * @param permissions the permissions to test for. Permissions with wildcards (such as '*', + * 'spanner.*', 'spanner.instances.*') are not allowed. + * @return the subset of the tested permissions that the caller is allowed. + */ + Iterable testBackupIAMPermissions( + String instanceId, String backupId, Iterable permissions); } diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseAdminClientImpl.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseAdminClientImpl.java index 3211c22b96..28de150cae 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseAdminClientImpl.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseAdminClientImpl.java @@ -24,14 +24,19 @@ import com.google.api.gax.paging.Page; import com.google.cloud.Policy; import com.google.cloud.Policy.DefaultMarshaller; +import com.google.cloud.Timestamp; import com.google.cloud.spanner.Options.ListOption; import com.google.cloud.spanner.SpannerImpl.PageFetcher; import com.google.cloud.spanner.spi.v1.SpannerRpc; import com.google.cloud.spanner.spi.v1.SpannerRpc.Paginated; import com.google.common.base.MoreObjects; import com.google.common.base.Preconditions; +import com.google.longrunning.Operation; import com.google.protobuf.Empty; +import com.google.protobuf.FieldMask; +import com.google.spanner.admin.database.v1.CreateBackupMetadata; import com.google.spanner.admin.database.v1.CreateDatabaseMetadata; +import com.google.spanner.admin.database.v1.RestoreDatabaseMetadata; import com.google.spanner.admin.database.v1.UpdateDatabaseDdlMetadata; import java.util.List; import java.util.UUID; @@ -54,6 +59,7 @@ protected com.google.iam.v1.Policy toPb(Policy policy) { private final String projectId; private final SpannerRpc rpc; private final PolicyMarshaller policyMarshaller = new PolicyMarshaller(); + private static final String EXPIRE_TIME_MASK = "expire_time"; DatabaseAdminClientImpl(String projectId, SpannerRpc rpc) { this.projectId = projectId; @@ -66,6 +72,196 @@ private static String randomOperationId() { return ("r" + uuid.toString()).replace("-", "_"); } + @Override + public Backup.Builder newBackupBuilder(BackupId backupId) { + return new Backup.Builder(this, backupId); + } + + @Override + public OperationFuture restoreDatabase( + String backupInstanceId, String backupId, String restoreInstanceId, String restoreDatabaseId) + throws SpannerException { + String databaseInstanceName = getInstanceName(restoreInstanceId); + String backupName = getBackupName(backupInstanceId, backupId); + + OperationFuture + rawOperationFuture = + rpc.restoreDatabase(databaseInstanceName, restoreDatabaseId, backupName); + + return new OperationFutureImpl( + rawOperationFuture.getPollingFuture(), + rawOperationFuture.getInitialFuture(), + new ApiFunction() { + @Override + public Database apply(OperationSnapshot snapshot) { + return Database.fromProto( + ProtoOperationTransformers.ResponseTransformer.create( + com.google.spanner.admin.database.v1.Database.class) + .apply(snapshot), + DatabaseAdminClientImpl.this); + } + }, + ProtoOperationTransformers.MetadataTransformer.create(RestoreDatabaseMetadata.class), + new ApiFunction() { + @Override + public Database apply(Exception e) { + throw SpannerExceptionFactory.newSpannerException(e); + } + }); + } + + @Override + public OperationFuture createBackup( + String instanceId, String backupId, String databaseId, Timestamp expireTime) + throws SpannerException { + com.google.spanner.admin.database.v1.Backup backup = + com.google.spanner.admin.database.v1.Backup.newBuilder() + .setDatabase(getDatabaseName(instanceId, databaseId)) + .setExpireTime(expireTime.toProto()) + .build(); + String instanceName = getInstanceName(instanceId); + OperationFuture + rawOperationFuture = rpc.createBackup(instanceName, backupId, backup); + + return new OperationFutureImpl( + rawOperationFuture.getPollingFuture(), + rawOperationFuture.getInitialFuture(), + new ApiFunction() { + @Override + public Backup apply(OperationSnapshot snapshot) { + com.google.spanner.admin.database.v1.Backup proto = + ProtoOperationTransformers.ResponseTransformer.create( + com.google.spanner.admin.database.v1.Backup.class) + .apply(snapshot); + return Backup.fromProto( + com.google.spanner.admin.database.v1.Backup.newBuilder(proto) + .setName(proto.getName()) + .setExpireTime(proto.getExpireTime()) + .setState(proto.getState()) + .build(), + DatabaseAdminClientImpl.this); + } + }, + ProtoOperationTransformers.MetadataTransformer.create(CreateBackupMetadata.class), + new ApiFunction() { + @Override + public Backup apply(Exception e) { + throw SpannerExceptionFactory.newSpannerException(e); + } + }); + } + + @Override + public Backup updateBackup(String instanceId, String backupId, Timestamp expireTime) { + String backupName = getBackupName(instanceId, backupId); + final com.google.spanner.admin.database.v1.Backup backup = + com.google.spanner.admin.database.v1.Backup.newBuilder() + .setName(backupName) + .setExpireTime(expireTime.toProto()) + .build(); + // Only update the expire time of the backup. + final FieldMask updateMask = FieldMask.newBuilder().addPaths(EXPIRE_TIME_MASK).build(); + try { + return Backup.fromProto(rpc.updateBackup(backup, updateMask), DatabaseAdminClientImpl.this); + } catch (Exception e) { + throw SpannerExceptionFactory.newSpannerException(e); + } + } + + @Override + public void deleteBackup(String instanceId, String backupId) { + final String backupName = getBackupName(instanceId, backupId); + try { + rpc.deleteBackup(backupName); + } catch (Exception e) { + throw SpannerExceptionFactory.newSpannerException(e); + } + } + + @Override + public Backup getBackup(String instanceId, String backupId) throws SpannerException { + final String backupName = getBackupName(instanceId, backupId); + return Backup.fromProto(rpc.getBackup(backupName), DatabaseAdminClientImpl.this); + } + + @Override + public final Page listBackupOperations(String instanceId, ListOption... options) { + final String instanceName = getInstanceName(instanceId); + final Options listOptions = Options.fromListOptions(options); + final int pageSize = listOptions.hasPageSize() ? listOptions.pageSize() : 0; + final String filter = listOptions.hasFilter() ? listOptions.filter() : null; + final String pageToken = listOptions.hasPageToken() ? listOptions.pageToken() : null; + + PageFetcher pageFetcher = + new PageFetcher() { + @Override + public Paginated getNextPage(String nextPageToken) { + return rpc.listBackupOperations(instanceName, pageSize, filter, pageToken); + } + + @Override + public Operation fromProto(Operation proto) { + return proto; + } + }; + if (listOptions.hasPageToken()) { + pageFetcher.setNextPageToken(listOptions.pageToken()); + } + return pageFetcher.getNextPage(); + } + + @Override + public final Page listDatabaseOperations(String instanceId, ListOption... options) { + final String instanceName = getInstanceName(instanceId); + final Options listOptions = Options.fromListOptions(options); + final int pageSize = listOptions.hasPageSize() ? listOptions.pageSize() : 0; + final String filter = listOptions.hasFilter() ? listOptions.filter() : null; + final String pageToken = listOptions.hasPageToken() ? listOptions.pageToken() : null; + + PageFetcher pageFetcher = + new PageFetcher() { + @Override + public Paginated getNextPage(String nextPageToken) { + return rpc.listDatabaseOperations(instanceName, pageSize, filter, pageToken); + } + + @Override + public Operation fromProto(Operation proto) { + return proto; + } + }; + if (listOptions.hasPageToken()) { + pageFetcher.setNextPageToken(listOptions.pageToken()); + } + return pageFetcher.getNextPage(); + } + + @Override + public Page listBackups(String instanceId, ListOption... options) { + final String instanceName = getInstanceName(instanceId); + final Options listOptions = Options.fromListOptions(options); + final String filter = listOptions.hasFilter() ? listOptions.filter() : null; + final int pageSize = listOptions.hasPageSize() ? listOptions.pageSize() : 0; + + PageFetcher pageFetcher = + new PageFetcher() { + @Override + public Paginated getNextPage( + String nextPageToken) { + return rpc.listBackups(instanceName, pageSize, filter, nextPageToken); + } + + @Override + public Backup fromProto(com.google.spanner.admin.database.v1.Backup proto) { + return Backup.fromProto(proto, DatabaseAdminClientImpl.this); + } + }; + if (listOptions.hasPageToken()) { + pageFetcher.setNextPageToken(listOptions.pageToken()); + } + return pageFetcher.getNextPage(); + } + @Override public OperationFuture createDatabase( String instanceId, String databaseId, Iterable statements) throws SpannerException { @@ -149,7 +345,7 @@ public Page listDatabases(String instanceId, ListOption... options) { final String instanceName = getInstanceName(instanceId); final Options listOptions = Options.fromListOptions(options); Preconditions.checkArgument( - !listOptions.hasFilter(), "Filter option is not support by" + "listDatabases"); + !listOptions.hasFilter(), "Filter option is not supported by listDatabases"); final int pageSize = listOptions.hasPageSize() ? listOptions.pageSize() : 0; PageFetcher pageFetcher = new PageFetcher() { @@ -181,6 +377,18 @@ public Database fromProto(com.google.spanner.admin.database.v1.Database proto) { return pageFetcher.getNextPage(); } + @Override + public void cancelOperation(String name) { + Preconditions.checkNotNull(name); + rpc.cancelOperation(name); + } + + @Override + public Operation getOperation(String name) { + Preconditions.checkNotNull(name); + return rpc.getOperation(name); + } + @Override public Policy getDatabaseIAMPolicy(String instanceId, String databaseId) { final String databaseName = DatabaseId.of(projectId, instanceId, databaseId).getName(); @@ -203,6 +411,28 @@ public Iterable testDatabaseIAMPermissions( return rpc.testDatabaseAdminIAMPermissions(databaseName, permissions).getPermissionsList(); } + @Override + public Policy getBackupIAMPolicy(String instanceId, String backupId) { + final String databaseName = BackupId.of(projectId, instanceId, backupId).getName(); + return policyMarshaller.fromPb(rpc.getDatabaseAdminIAMPolicy(databaseName)); + } + + @Override + public Policy setBackupIAMPolicy(String instanceId, String backupId, final Policy policy) { + Preconditions.checkNotNull(policy); + final String databaseName = BackupId.of(projectId, instanceId, backupId).getName(); + return policyMarshaller.fromPb( + rpc.setDatabaseAdminIAMPolicy(databaseName, policyMarshaller.toPb(policy))); + } + + @Override + public Iterable testBackupIAMPermissions( + String instanceId, String backupId, final Iterable permissions) { + Preconditions.checkNotNull(permissions); + final String databaseName = BackupId.of(projectId, instanceId, backupId).getName(); + return rpc.testDatabaseAdminIAMPermissions(databaseName, permissions).getPermissionsList(); + } + private String getInstanceName(String instanceId) { return new InstanceId(projectId, instanceId).getName(); } @@ -210,4 +440,9 @@ private String getInstanceName(String instanceId) { private String getDatabaseName(String instanceId, String databaseId) { return new DatabaseId(new InstanceId(projectId, instanceId), databaseId).getName(); } + + private String getBackupName(String instanceId, String backupId) { + InstanceId instance = new InstanceId(projectId, instanceId); + return new BackupId(instance, backupId).getName(); + } } diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseInfo.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseInfo.java index a45e180071..5b7faa324e 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseInfo.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseInfo.java @@ -16,10 +16,69 @@ package com.google.cloud.spanner; +import com.google.cloud.Timestamp; +import com.google.common.base.Preconditions; import java.util.Objects; +import javax.annotation.Nullable; /** Represents a Cloud Spanner database. */ public class DatabaseInfo { + public abstract static class Builder { + abstract Builder setState(State state); + + abstract Builder setCreateTime(Timestamp createTime); + + abstract Builder setRestoreInfo(RestoreInfo restoreInfo); + + abstract Builder setProto(com.google.spanner.admin.database.v1.Database proto); + + /** Builds the database from this builder. */ + public abstract Database build(); + } + + abstract static class BuilderImpl extends Builder { + protected final DatabaseId id; + private State state = State.UNSPECIFIED; + private Timestamp createTime; + private RestoreInfo restoreInfo; + private com.google.spanner.admin.database.v1.Database proto; + + BuilderImpl(DatabaseId id) { + this.id = Preconditions.checkNotNull(id); + } + + BuilderImpl(DatabaseInfo other) { + this.id = other.id; + this.state = other.state; + this.createTime = other.createTime; + this.restoreInfo = other.restoreInfo; + this.proto = other.proto; + } + + @Override + Builder setState(State state) { + this.state = Preconditions.checkNotNull(state); + return this; + } + + @Override + Builder setCreateTime(Timestamp createTime) { + this.createTime = Preconditions.checkNotNull(createTime); + return this; + } + + @Override + Builder setRestoreInfo(@Nullable RestoreInfo restoreInfo) { + this.restoreInfo = restoreInfo; + return this; + } + + @Override + Builder setProto(@Nullable com.google.spanner.admin.database.v1.Database proto) { + this.proto = proto; + return this; + } + } /** State of the database. */ public enum State { @@ -28,15 +87,31 @@ public enum State { // The database is still being created and is not ready to use. CREATING, // The database is fully created and ready to use. - READY; + READY, + // The database has restored and is being optimized for use. + READY_OPTIMIZING } private final DatabaseId id; private final State state; + private final Timestamp createTime; + private final RestoreInfo restoreInfo; + private final com.google.spanner.admin.database.v1.Database proto; public DatabaseInfo(DatabaseId id, State state) { this.id = id; this.state = state; + this.createTime = null; + this.restoreInfo = null; + this.proto = null; + } + + DatabaseInfo(BuilderImpl builder) { + this.id = builder.id; + this.state = builder.state; + this.createTime = builder.createTime; + this.restoreInfo = builder.restoreInfo; + this.proto = builder.proto; } /** Returns the database id. */ @@ -49,6 +124,24 @@ public State getState() { return state; } + /** Returns the creation time of the database. */ + public Timestamp getCreateTime() { + return createTime; + } + + /** + * Returns the {@link RestoreInfo} of the database if any is available, or null if no + * {@link RestoreInfo} is available for this database. + */ + public @Nullable RestoreInfo getRestoreInfo() { + return restoreInfo; + } + + /** Returns the raw proto instance that was used to construct this {@link Database}. */ + public @Nullable com.google.spanner.admin.database.v1.Database getProto() { + return proto; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -58,16 +151,19 @@ public boolean equals(Object o) { return false; } DatabaseInfo that = (DatabaseInfo) o; - return id.equals(that.id) && state == that.state; + return id.equals(that.id) + && state == that.state + && Objects.equals(createTime, that.createTime) + && Objects.equals(restoreInfo, that.restoreInfo); } @Override public int hashCode() { - return Objects.hash(id, state); + return Objects.hash(id, state, createTime, restoreInfo); } @Override public String toString() { - return String.format("Database[%s, %s]", id.getName(), state); + return String.format("Database[%s, %s, %s, %s]", id.getName(), state, createTime, restoreInfo); } } diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Instance.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Instance.java index 5565c67de8..9484bd0590 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Instance.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Instance.java @@ -19,6 +19,8 @@ import com.google.api.gax.longrunning.OperationFuture; import com.google.api.gax.paging.Page; import com.google.cloud.Policy; +import com.google.cloud.spanner.Options.ListOption; +import com.google.longrunning.Operation; import com.google.spanner.admin.database.v1.CreateDatabaseMetadata; import com.google.spanner.admin.instance.v1.UpdateInstanceMetadata; import java.util.Map; @@ -111,8 +113,8 @@ public OperationFuture update( return instanceClient.updateInstance(this, fieldsToUpdate); } - public Page listDatabases() { - return dbClient.listDatabases(instanceId()); + public Page listDatabases(ListOption... options) { + return dbClient.listDatabases(instanceId(), options); } public Database getDatabase(String databaseId) { @@ -132,6 +134,26 @@ public OperationFuture createDatabase( return dbClient.createDatabase(instanceId(), databaseId, statements); } + /** Returns the backups belonging to this instance. */ + public Page listBackups(ListOption... options) { + return dbClient.listBackups(instanceId(), options); + } + + /** Returns the backup with the given id on this instance. */ + public Backup getBackup(String backupId) { + return dbClient.getBackup(instanceId(), backupId); + } + + /** Returns the long-running database operations on this instance. */ + public Page listDatabaseOperations(ListOption... options) { + return dbClient.listDatabaseOperations(instanceId(), options); + } + + /** Returns the long-running backup operations on this instance. */ + public Page listBackupOperations(ListOption... options) { + return dbClient.listBackupOperations(instanceId(), options); + } + /** Returns the IAM {@link Policy} for this instance. */ public Policy getIAMPolicy() { return instanceClient.getInstanceIAMPolicy(instanceId()); diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Options.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Options.java index e5d1729fef..d193ad1c75 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Options.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/Options.java @@ -314,5 +314,12 @@ static class FilterOption extends InternalOption implements ListOption { void appendToOptions(Options options) { options.filter = filter; } + + @Override + public boolean equals(Object o) { + if (o == this) return true; + if (!(o instanceof FilterOption)) return false; + return Objects.equals(filter, ((FilterOption) o).filter); + } } } diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/RestoreInfo.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/RestoreInfo.java new file mode 100644 index 0000000000..f00504e698 --- /dev/null +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/RestoreInfo.java @@ -0,0 +1,134 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed 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 com.google.cloud.spanner; + +import com.google.cloud.Timestamp; +import javax.annotation.Nullable; + +/** Represents the restore information of a Cloud Spanner database. */ +public class RestoreInfo { + private static class Builder { + private final BackupId backup; + private RestoreSourceType sourceType; + private Timestamp backupCreateTime; + private DatabaseId sourceDatabase; + private com.google.spanner.admin.database.v1.RestoreInfo proto; + + private Builder(BackupId backup) { + this.backup = backup; + } + + private Builder setSourceType(RestoreSourceType sourceType) { + this.sourceType = sourceType; + return this; + } + + private Builder setBackupCreateTime(Timestamp backupCreateTime) { + this.backupCreateTime = backupCreateTime; + return this; + } + + private Builder setSourceDatabase(DatabaseId sourceDatabase) { + this.sourceDatabase = sourceDatabase; + return this; + } + + private Builder setProto(com.google.spanner.admin.database.v1.RestoreInfo proto) { + this.proto = proto; + return this; + } + + private RestoreInfo build() { + return new RestoreInfo(this); + } + } + + /** Source of the restore information. */ + public enum RestoreSourceType { + // Not specified. + UNSPECIFIED, + // The database was restored from a Backup. + BACKUP + } + + private final BackupId backup; + private final RestoreSourceType sourceType; + private final Timestamp backupCreateTime; + private final DatabaseId sourceDatabase; + private final com.google.spanner.admin.database.v1.RestoreInfo proto; + + private RestoreInfo(Builder builder) { + this.backup = builder.backup; + this.sourceType = builder.sourceType; + this.backupCreateTime = builder.backupCreateTime; + this.sourceDatabase = builder.sourceDatabase; + this.proto = builder.proto; + } + + /** The backup source of the restored database. The backup may no longer exist. */ + public BackupId getBackup() { + return backup; + } + + /** The source type of the restore. */ + public RestoreSourceType getSourceType() { + return sourceType; + } + + /** The create time of the backup for the restore. */ + public Timestamp getBackupCreateTime() { + return backupCreateTime; + } + + /** The source database that was used to create the backup. The database may no longer exist. */ + public DatabaseId getSourceDatabase() { + return sourceDatabase; + } + + /** Returns the raw proto instance that was used to construct this {@link RestoreInfo}. */ + public @Nullable com.google.spanner.admin.database.v1.RestoreInfo getProto() { + return proto; + } + + /** + * Returns a {@link RestoreInfo} instance from the given proto, or null if the given + * proto is the default proto instance (i.e. there is no restore info). + */ + static RestoreInfo fromProtoOrNullIfDefaultInstance( + com.google.spanner.admin.database.v1.RestoreInfo proto) { + return proto.equals(com.google.spanner.admin.database.v1.RestoreInfo.getDefaultInstance()) + ? null + : new Builder(BackupId.of(proto.getBackupInfo().getBackup())) + .setSourceType(fromProtoSourceType(proto.getSourceType())) + .setBackupCreateTime(Timestamp.fromProto(proto.getBackupInfo().getCreateTime())) + .setSourceDatabase(DatabaseId.of(proto.getBackupInfo().getSourceDatabase())) + .setProto(proto) + .build(); + } + + static RestoreSourceType fromProtoSourceType( + com.google.spanner.admin.database.v1.RestoreSourceType protoSourceType) { + switch (protoSourceType) { + case BACKUP: + return RestoreSourceType.BACKUP; + case TYPE_UNSPECIFIED: + return RestoreSourceType.UNSPECIFIED; + default: + throw new IllegalArgumentException("Unrecognized source type " + protoSourceType); + } + } +} diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java index f898b8f88f..32dc3b7157 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java @@ -18,8 +18,11 @@ import com.google.api.core.ApiFunction; import com.google.api.gax.grpc.GrpcInterceptorProvider; +import com.google.api.gax.longrunning.OperationSnapshot; +import com.google.api.gax.longrunning.OperationTimedPollAlgorithm; import com.google.api.gax.retrying.RetrySettings; import com.google.api.gax.rpc.TransportChannelProvider; +import com.google.api.gax.rpc.UnaryCallSettings; import com.google.cloud.NoCredentials; import com.google.cloud.ServiceDefaults; import com.google.cloud.ServiceOptions; @@ -40,6 +43,9 @@ import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; +import com.google.spanner.admin.database.v1.CreateBackupRequest; +import com.google.spanner.admin.database.v1.CreateDatabaseRequest; +import com.google.spanner.admin.database.v1.RestoreDatabaseRequest; import com.google.spanner.v1.ExecuteSqlRequest.QueryOptions; import io.grpc.CallCredentials; import io.grpc.ManagedChannelBuilder; @@ -81,6 +87,7 @@ public class SpannerOptions extends ServiceOptions { private final InstanceAdminStubSettings instanceAdminStubSettings; private final DatabaseAdminStubSettings databaseAdminStubSettings; private final Duration partitionedDmlTimeout; + private final boolean autoThrottleAdministrativeRequests; /** * These are the default {@link QueryOptions} defined by the user on this {@link SpannerOptions}. */ @@ -152,6 +159,7 @@ private SpannerOptions(Builder builder) { throw SpannerExceptionFactory.newSpannerException(e); } partitionedDmlTimeout = builder.partitionedDmlTimeout; + autoThrottleAdministrativeRequests = builder.autoThrottleAdministrativeRequests; defaultQueryOptions = builder.defaultQueryOptions; envQueryOptions = builder.getEnvironmentQueryOptions(); if (envQueryOptions.equals(QueryOptions.getDefaultInstance())) { @@ -226,11 +234,65 @@ public static class Builder private DatabaseAdminStubSettings.Builder databaseAdminStubSettingsBuilder = DatabaseAdminStubSettings.newBuilder(); private Duration partitionedDmlTimeout = Duration.ofHours(2L); + private boolean autoThrottleAdministrativeRequests = false; private Map defaultQueryOptions = new HashMap<>(); private CallCredentialsProvider callCredentialsProvider; private String emulatorHost = System.getenv("SPANNER_EMULATOR_HOST"); - private Builder() {} + private Builder() { + // Manually set retry and polling settings that work. + OperationTimedPollAlgorithm longRunningPollingAlgorithm = + OperationTimedPollAlgorithm.create( + RetrySettings.newBuilder() + .setInitialRpcTimeout(Duration.ofSeconds(60L)) + .setMaxRpcTimeout(Duration.ofSeconds(600L)) + .setInitialRetryDelay(Duration.ofSeconds(20L)) + .setMaxRetryDelay(Duration.ofSeconds(45L)) + .setRetryDelayMultiplier(1.5) + .setRpcTimeoutMultiplier(1.5) + .setTotalTimeout(Duration.ofHours(48L)) + .build()); + RetrySettings longRunningRetrySettings = + RetrySettings.newBuilder() + .setInitialRpcTimeout(Duration.ofSeconds(60L)) + .setMaxRpcTimeout(Duration.ofSeconds(600L)) + .setInitialRetryDelay(Duration.ofSeconds(20L)) + .setMaxRetryDelay(Duration.ofSeconds(45L)) + .setRetryDelayMultiplier(1.5) + .setRpcTimeoutMultiplier(1.5) + .setTotalTimeout(Duration.ofHours(48L)) + .build(); + databaseAdminStubSettingsBuilder + .createDatabaseOperationSettings() + .setPollingAlgorithm(longRunningPollingAlgorithm) + .setInitialCallSettings( + UnaryCallSettings + .newUnaryCallSettingsBuilder() + .setRetrySettings(longRunningRetrySettings) + .build()); + databaseAdminStubSettingsBuilder + .createBackupOperationSettings() + .setPollingAlgorithm(longRunningPollingAlgorithm) + .setInitialCallSettings( + UnaryCallSettings + .newUnaryCallSettingsBuilder() + .setRetrySettings(longRunningRetrySettings) + .build()); + databaseAdminStubSettingsBuilder + .restoreDatabaseOperationSettings() + .setPollingAlgorithm(longRunningPollingAlgorithm) + .setInitialCallSettings( + UnaryCallSettings + .newUnaryCallSettingsBuilder() + .setRetrySettings(longRunningRetrySettings) + .build()); + databaseAdminStubSettingsBuilder + .deleteBackupSettings() + .setRetrySettings(longRunningRetrySettings); + databaseAdminStubSettingsBuilder + .updateBackupSettings() + .setRetrySettings(longRunningRetrySettings); + } Builder(SpannerOptions options) { super(options); @@ -242,6 +304,7 @@ private Builder() {} this.instanceAdminStubSettingsBuilder = options.instanceAdminStubSettings.toBuilder(); this.databaseAdminStubSettingsBuilder = options.databaseAdminStubSettings.toBuilder(); this.partitionedDmlTimeout = options.partitionedDmlTimeout; + this.autoThrottleAdministrativeRequests = options.autoThrottleAdministrativeRequests; this.defaultQueryOptions = options.defaultQueryOptions; this.callCredentialsProvider = options.callCredentialsProvider; this.channelProvider = options.channelProvider; @@ -435,6 +498,22 @@ public Builder setPartitionedDmlTimeout(Duration timeout) { return this; } + /** + * Instructs the client library to automatically throttle the number of administrative requests + * if the rate of administrative requests generated by this {@link Spanner} instance will exceed + * the administrative limits Cloud Spanner. The default behavior is to not throttle any + * requests. If the limit is exceeded, Cloud Spanner will return a RESOURCE_EXHAUSTED error. + * More information on the administrative limits can be found here: + * https://cloud.google.com/spanner/quotas#administrative_limits. Setting this option is not a + * guarantee that the rate will never be exceeded, as this option will only throttle requests + * coming from this client. Additional requests from other clients could still cause the limit + * to be exceeded. + */ + public Builder setAutoThrottleAdministrativeRequests() { + this.autoThrottleAdministrativeRequests = true; + return this; + } + /** * Sets the default {@link QueryOptions} that will be used for all queries on the specified * database. Query options can also be specified on a per-query basis and as environment @@ -592,6 +671,10 @@ public Duration getPartitionedDmlTimeout() { return partitionedDmlTimeout; } + public boolean isAutoThrottleAdministrativeRequests() { + return autoThrottleAdministrativeRequests; + } + public CallCredentialsProvider getCallCredentialsProvider() { return callCredentialsProvider; } diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GapicSpannerRpc.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GapicSpannerRpc.java index b3a6c9b92b..de1a09158c 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GapicSpannerRpc.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GapicSpannerRpc.java @@ -51,24 +51,40 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.base.MoreObjects; import com.google.common.base.Preconditions; +import com.google.common.util.concurrent.RateLimiter; import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.google.iam.v1.GetIamPolicyRequest; import com.google.iam.v1.Policy; import com.google.iam.v1.SetIamPolicyRequest; import com.google.iam.v1.TestIamPermissionsRequest; import com.google.iam.v1.TestIamPermissionsResponse; +import com.google.longrunning.CancelOperationRequest; import com.google.longrunning.GetOperationRequest; import com.google.longrunning.Operation; import com.google.protobuf.Empty; import com.google.protobuf.FieldMask; +import com.google.spanner.admin.database.v1.Backup; +import com.google.spanner.admin.database.v1.CreateBackupMetadata; +import com.google.spanner.admin.database.v1.CreateBackupRequest; import com.google.spanner.admin.database.v1.CreateDatabaseMetadata; import com.google.spanner.admin.database.v1.CreateDatabaseRequest; import com.google.spanner.admin.database.v1.Database; +import com.google.spanner.admin.database.v1.DeleteBackupRequest; import com.google.spanner.admin.database.v1.DropDatabaseRequest; +import com.google.spanner.admin.database.v1.GetBackupRequest; import com.google.spanner.admin.database.v1.GetDatabaseDdlRequest; import com.google.spanner.admin.database.v1.GetDatabaseRequest; +import com.google.spanner.admin.database.v1.ListBackupOperationsRequest; +import com.google.spanner.admin.database.v1.ListBackupOperationsResponse; +import com.google.spanner.admin.database.v1.ListBackupsRequest; +import com.google.spanner.admin.database.v1.ListBackupsResponse; +import com.google.spanner.admin.database.v1.ListDatabaseOperationsRequest; +import com.google.spanner.admin.database.v1.ListDatabaseOperationsResponse; import com.google.spanner.admin.database.v1.ListDatabasesRequest; import com.google.spanner.admin.database.v1.ListDatabasesResponse; +import com.google.spanner.admin.database.v1.RestoreDatabaseMetadata; +import com.google.spanner.admin.database.v1.RestoreDatabaseRequest; +import com.google.spanner.admin.database.v1.UpdateBackupRequest; import com.google.spanner.admin.database.v1.UpdateDatabaseDdlMetadata; import com.google.spanner.admin.database.v1.UpdateDatabaseDdlRequest; import com.google.spanner.admin.instance.v1.CreateInstanceMetadata; @@ -110,6 +126,8 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; import java.util.concurrent.Future; @@ -189,6 +207,11 @@ private synchronized void shutdown() { private final ScheduledExecutorService spannerWatchdog; + private final boolean throttleAdministrativeRequests; + private static final double ADMINISTRATIVE_REQUESTS_RATE_LIMIT = 1.0D; + private static final ConcurrentMap ADMINISTRATIVE_REQUESTS_RATE_LIMITERS = + new ConcurrentHashMap(); + public static GapicSpannerRpc create(SpannerOptions options) { return new GapicSpannerRpc(options); } @@ -203,6 +226,11 @@ public GapicSpannerRpc(final SpannerOptions options) { } catch (UnsupportedEncodingException e) { // Ignored. } this.projectName = projectNameStr; + this.throttleAdministrativeRequests = options.isAutoThrottleAdministrativeRequests(); + if (throttleAdministrativeRequests) { + ADMINISTRATIVE_REQUESTS_RATE_LIMITERS.putIfAbsent( + projectNameStr, RateLimiter.create(ADMINISTRATIVE_REQUESTS_RATE_LIMIT)); + } // create a metadataProvider which combines both internal headers and // per-method-call extra headers for channelProvider to inject the headers @@ -307,6 +335,15 @@ public GapicSpannerRpc(final SpannerOptions options) { } } + private void acquireAdministrativeRequestsRateLimiter() { + if (throttleAdministrativeRequests) { + RateLimiter limiter = ADMINISTRATIVE_REQUESTS_RATE_LIMITERS.get(this.projectName); + if (limiter != null) { + limiter.acquire(); + } + } + } + @Override public Paginated listInstanceConfigs(int pageSize, @Nullable String pageToken) throws SpannerException { @@ -390,9 +427,72 @@ public void deleteInstance(String instanceName) throws SpannerException { get(instanceAdminStub.deleteInstanceCallable().futureCall(request, context)); } + @Override + public Paginated listBackupOperations( + String instanceName, int pageSize, @Nullable String filter, @Nullable String pageToken) { + acquireAdministrativeRequestsRateLimiter(); + ListBackupOperationsRequest.Builder requestBuilder = + ListBackupOperationsRequest.newBuilder().setParent(instanceName).setPageSize(pageSize); + if (filter != null) { + requestBuilder.setFilter(filter); + } + if (pageToken != null) { + requestBuilder.setPageToken(pageToken); + } + ListBackupOperationsRequest request = requestBuilder.build(); + + GrpcCallContext context = newCallContext(null, instanceName); + ListBackupOperationsResponse response = + get(databaseAdminStub.listBackupOperationsCallable().futureCall(request, context)); + return new Paginated<>(response.getOperationsList(), response.getNextPageToken()); + } + + @Override + public Paginated listDatabaseOperations( + String instanceName, int pageSize, @Nullable String filter, @Nullable String pageToken) { + acquireAdministrativeRequestsRateLimiter(); + ListDatabaseOperationsRequest.Builder requestBuilder = + ListDatabaseOperationsRequest.newBuilder().setParent(instanceName).setPageSize(pageSize); + + if (filter != null) { + requestBuilder.setFilter(filter); + } + if (pageToken != null) { + requestBuilder.setPageToken(pageToken); + } + ListDatabaseOperationsRequest request = requestBuilder.build(); + + GrpcCallContext context = newCallContext(null, instanceName); + ListDatabaseOperationsResponse response = + get(databaseAdminStub.listDatabaseOperationsCallable().futureCall(request, context)); + return new Paginated<>(response.getOperationsList(), response.getNextPageToken()); + } + + @Override + public Paginated listBackups( + String instanceName, int pageSize, @Nullable String filter, @Nullable String pageToken) + throws SpannerException { + acquireAdministrativeRequestsRateLimiter(); + ListBackupsRequest.Builder requestBuilder = + ListBackupsRequest.newBuilder().setParent(instanceName).setPageSize(pageSize); + if (filter != null) { + requestBuilder.setFilter(filter); + } + if (pageToken != null) { + requestBuilder.setPageToken(pageToken); + } + ListBackupsRequest request = requestBuilder.build(); + + GrpcCallContext context = newCallContext(null, instanceName); + ListBackupsResponse response = + get(databaseAdminStub.listBackupsCallable().futureCall(request, context)); + return new Paginated<>(response.getBackupsList(), response.getNextPageToken()); + } + @Override public Paginated listDatabases( String instanceName, int pageSize, @Nullable String pageToken) throws SpannerException { + acquireAdministrativeRequestsRateLimiter(); ListDatabasesRequest.Builder requestBuilder = ListDatabasesRequest.newBuilder().setParent(instanceName).setPageSize(pageSize); if (pageToken != null) { @@ -410,6 +510,7 @@ public Paginated listDatabases( public OperationFuture createDatabase( String instanceName, String createDatabaseStatement, Iterable additionalStatements) throws SpannerException { + acquireAdministrativeRequestsRateLimiter(); CreateDatabaseRequest request = CreateDatabaseRequest.newBuilder() .setParent(instanceName) @@ -424,6 +525,7 @@ public OperationFuture createDatabase( public OperationFuture updateDatabaseDdl( String databaseName, Iterable updateDatabaseStatements, @Nullable String updateId) throws SpannerException { + acquireAdministrativeRequestsRateLimiter(); UpdateDatabaseDdlRequest request = UpdateDatabaseDdlRequest.newBuilder() .setDatabase(databaseName) @@ -452,6 +554,7 @@ public OperationFuture updateDatabaseDdl( @Override public void dropDatabase(String databaseName) throws SpannerException { + acquireAdministrativeRequestsRateLimiter(); DropDatabaseRequest request = DropDatabaseRequest.newBuilder().setDatabase(databaseName).build(); @@ -461,6 +564,7 @@ public void dropDatabase(String databaseName) throws SpannerException { @Override public Database getDatabase(String databaseName) throws SpannerException { + acquireAdministrativeRequestsRateLimiter(); GetDatabaseRequest request = GetDatabaseRequest.newBuilder().setName(databaseName).build(); GrpcCallContext context = newCallContext(null, databaseName); @@ -469,6 +573,7 @@ public Database getDatabase(String databaseName) throws SpannerException { @Override public List getDatabaseDdl(String databaseName) throws SpannerException { + acquireAdministrativeRequestsRateLimiter(); GetDatabaseDdlRequest request = GetDatabaseDdlRequest.newBuilder().setDatabase(databaseName).build(); @@ -477,14 +582,80 @@ public List getDatabaseDdl(String databaseName) throws SpannerException .getStatementsList(); } + @Override + public OperationFuture createBackup( + String instanceName, String backupId, Backup backup) throws SpannerException { + acquireAdministrativeRequestsRateLimiter(); + CreateBackupRequest request = + CreateBackupRequest.newBuilder() + .setParent(instanceName) + .setBackupId(backupId) + .setBackup(backup) + .build(); + GrpcCallContext context = newCallContext(null, instanceName); + return databaseAdminStub.createBackupOperationCallable().futureCall(request, context); + } + + @Override + public final OperationFuture restoreDatabase( + String databaseInstanceName, String databaseId, String backupName) { + acquireAdministrativeRequestsRateLimiter(); + RestoreDatabaseRequest request = + RestoreDatabaseRequest.newBuilder() + .setParent(databaseInstanceName) + .setDatabaseId(databaseId) + .setBackup(backupName) + .build(); + GrpcCallContext context = newCallContext(null, databaseInstanceName); + return databaseAdminStub.restoreDatabaseOperationCallable().futureCall(request, context); + } + + @Override + public final Backup updateBackup(Backup backup, FieldMask updateMask) { + acquireAdministrativeRequestsRateLimiter(); + UpdateBackupRequest request = + UpdateBackupRequest.newBuilder().setBackup(backup).setUpdateMask(updateMask).build(); + GrpcCallContext context = newCallContext(null, backup.getName()); + return databaseAdminStub.updateBackupCallable().call(request, context); + } + + @Override + public final void deleteBackup(String backupName) { + acquireAdministrativeRequestsRateLimiter(); + DeleteBackupRequest request = DeleteBackupRequest.newBuilder().setName(backupName).build(); + GrpcCallContext context = newCallContext(null, backupName); + databaseAdminStub.deleteBackupCallable().call(request, context); + } + + @Override + public Backup getBackup(String backupName) throws SpannerException { + acquireAdministrativeRequestsRateLimiter(); + GetBackupRequest request = GetBackupRequest.newBuilder().setName(backupName).build(); + GrpcCallContext context = newCallContext(null, backupName); + return get(databaseAdminStub.getBackupCallable().futureCall(request, context)); + } + @Override public Operation getOperation(String name) throws SpannerException { + acquireAdministrativeRequestsRateLimiter(); GetOperationRequest request = GetOperationRequest.newBuilder().setName(name).build(); GrpcCallContext context = newCallContext(null, name); return get( databaseAdminStub.getOperationsStub().getOperationCallable().futureCall(request, context)); } + @Override + public void cancelOperation(String name) throws SpannerException { + acquireAdministrativeRequestsRateLimiter(); + CancelOperationRequest request = CancelOperationRequest.newBuilder().setName(name).build(); + GrpcCallContext context = newCallContext(null, name); + get( + databaseAdminStub + .getOperationsStub() + .cancelOperationCallable() + .futureCall(request, context)); + } + @Override public List batchCreateSessions( String databaseName, @@ -636,6 +807,7 @@ public PartitionResponse partitionRead( @Override public Policy getDatabaseAdminIAMPolicy(String resource) { + acquireAdministrativeRequestsRateLimiter(); GrpcCallContext context = newCallContext(null, resource); return get( databaseAdminStub @@ -645,6 +817,7 @@ public Policy getDatabaseAdminIAMPolicy(String resource) { @Override public Policy setDatabaseAdminIAMPolicy(String resource, Policy policy) { + acquireAdministrativeRequestsRateLimiter(); GrpcCallContext context = newCallContext(null, resource); return get( databaseAdminStub @@ -657,6 +830,7 @@ public Policy setDatabaseAdminIAMPolicy(String resource, Policy policy) { @Override public TestIamPermissionsResponse testDatabaseAdminIAMPermissions( String resource, Iterable permissions) { + acquireAdministrativeRequestsRateLimiter(); GrpcCallContext context = newCallContext(null, resource); return get( databaseAdminStub @@ -671,6 +845,7 @@ public TestIamPermissionsResponse testDatabaseAdminIAMPermissions( @Override public Policy getInstanceAdminIAMPolicy(String resource) { + acquireAdministrativeRequestsRateLimiter(); GrpcCallContext context = newCallContext(null, resource); return get( instanceAdminStub @@ -680,6 +855,7 @@ public Policy getInstanceAdminIAMPolicy(String resource) { @Override public Policy setInstanceAdminIAMPolicy(String resource, Policy policy) { + acquireAdministrativeRequestsRateLimiter(); GrpcCallContext context = newCallContext(null, resource); return get( instanceAdminStub @@ -692,6 +868,7 @@ public Policy setInstanceAdminIAMPolicy(String resource, Policy policy) { @Override public TestIamPermissionsResponse testInstanceAdminIAMPermissions( String resource, Iterable permissions) { + acquireAdministrativeRequestsRateLimiter(); GrpcCallContext context = newCallContext(null, resource); return get( instanceAdminStub diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/SpannerRpc.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/SpannerRpc.java index 8a30cae194..497be948cc 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/SpannerRpc.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/SpannerRpc.java @@ -29,8 +29,11 @@ import com.google.longrunning.Operation; import com.google.protobuf.Empty; import com.google.protobuf.FieldMask; +import com.google.spanner.admin.database.v1.Backup; +import com.google.spanner.admin.database.v1.CreateBackupMetadata; import com.google.spanner.admin.database.v1.CreateDatabaseMetadata; import com.google.spanner.admin.database.v1.Database; +import com.google.spanner.admin.database.v1.RestoreDatabaseMetadata; import com.google.spanner.admin.database.v1.UpdateDatabaseDdlMetadata; import com.google.spanner.admin.instance.v1.CreateInstanceMetadata; import com.google.spanner.admin.instance.v1.Instance; @@ -205,10 +208,59 @@ OperationFuture updateDatabaseDdl( Database getDatabase(String databaseName) throws SpannerException; List getDatabaseDdl(String databaseName) throws SpannerException; + /** Lists the backups in the specified instance. */ + Paginated listBackups( + String instanceName, int pageSize, @Nullable String filter, @Nullable String pageToken) + throws SpannerException; + + /** + * Creates a new backup from the source database specified in the {@link Backup} instance. + * + * @param instanceName the name of the instance where the backup should be created. + * @param backupId the id of the backup to create. + * @param backup the backup to create. The database and expireTime fields of the backup must be + * filled. + * @return the operation that monitors the backup creation. + */ + OperationFuture createBackup( + String instanceName, String backupId, Backup backup) throws SpannerException; + + /** + * Restore a backup into the given database. + * + * @param instanceName Fully qualified name of instance where to restore the database + * @param databaseId DatabaseId to restore into + * @param backupName Fully qualified name of backup to restore from + */ + OperationFuture restoreDatabase( + String instanceName, String databaseId, String backupName); + + /** Gets the backup with the specified name. */ + Backup getBackup(String backupName) throws SpannerException; + + /** Updates the specified backup. The only supported field for updates is expireTime. */ + Backup updateBackup(Backup backup, FieldMask updateMask); + + /** List all long-running backup operations on the given instance. */ + Paginated listBackupOperations( + String instanceName, int pageSize, @Nullable String filter, @Nullable String pageToken); + + /** + * Deletes a pending or completed backup. + * + * @param backupName Required. The fully qualified name of the backup to delete. + */ + void deleteBackup(String backupName); + + Paginated listDatabaseOperations( + String instanceName, int pageSize, @Nullable String filter, @Nullable String pageToken); /** Retrieves a long running operation. */ Operation getOperation(String name) throws SpannerException; + /** Cancels the specified long-running operation. */ + void cancelOperation(String name) throws SpannerException; + List batchCreateSessions( String databaseName, int sessionCount, diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/testing/RemoteSpannerHelper.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/testing/RemoteSpannerHelper.java index 9ab9e19435..a9fc8faf5f 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/testing/RemoteSpannerHelper.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/testing/RemoteSpannerHelper.java @@ -133,7 +133,10 @@ public void cleanUp() { */ public static RemoteSpannerHelper create(InstanceId instanceId) throws Throwable { SpannerOptions options = - SpannerOptions.newBuilder().setProjectId(instanceId.getProject()).build(); + SpannerOptions.newBuilder() + .setProjectId(instanceId.getProject()) + .setAutoThrottleAdministrativeRequests() + .build(); Spanner client = options.getService(); return new RemoteSpannerHelper(options, instanceId, client); } diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/BackupIdTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/BackupIdTest.java new file mode 100644 index 0000000000..c3405a05a0 --- /dev/null +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/BackupIdTest.java @@ -0,0 +1,55 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed 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 com.google.cloud.spanner; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link com.google.cloud.spanner.BackupId}. */ +@RunWith(JUnit4.class) +public class BackupIdTest { + @Rule public ExpectedException expectedException = ExpectedException.none(); + + @Test + public void basics() { + String name = "projects/test-project/instances/test-instance/backups/backup-1"; + BackupId bid = BackupId.of(name); + assertThat(bid.getName()).isEqualTo(name); + assertThat(bid.getInstanceId().getInstance()).isEqualTo("test-instance"); + assertThat(bid.getBackup()).isEqualTo("backup-1"); + assertThat(BackupId.of("test-project", "test-instance", "backup-1")).isEqualTo(bid); + assertThat(BackupId.of(name)).isEqualTo(bid); + assertThat(BackupId.of(name).hashCode()).isEqualTo(bid.hashCode()); + assertThat(bid.toString()).isEqualTo(name); + } + + @Test + public void badName() { + try { + BackupId.of("bad name"); + fail("Expected exception"); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage().contains("projects")); + } + } +} diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/BackupTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/BackupTest.java new file mode 100644 index 0000000000..0e5e1afd39 --- /dev/null +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/BackupTest.java @@ -0,0 +1,298 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed 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 com.google.cloud.spanner; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; + +import com.google.cloud.Identity; +import com.google.cloud.Policy; +import com.google.cloud.Role; +import com.google.cloud.Timestamp; +import com.google.cloud.spanner.Backup.Builder; +import com.google.cloud.spanner.BackupInfo.State; +import java.util.Arrays; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +@RunWith(JUnit4.class) +public class BackupTest { + private static final String NAME = + "projects/test-project/instances/test-instance/backups/backup-1"; + private static final String DB = "projects/test-project/instances/test-instance/databases/db-1"; + private static final Timestamp EXP_TIME = Timestamp.ofTimeSecondsAndNanos(1000L, 1000); + + @Rule public ExpectedException expectedException = ExpectedException.none(); + @Mock DatabaseAdminClient dbClient; + + @Before + public void setUp() { + initMocks(this); + when(dbClient.newBackupBuilder(Mockito.any(BackupId.class))) + .thenAnswer( + new Answer() { + @Override + public Builder answer(InvocationOnMock invocation) throws Throwable { + return new Backup.Builder(dbClient, (BackupId) invocation.getArguments()[0]); + } + }); + } + + @Test + public void build() { + Timestamp expireTime = Timestamp.now(); + Backup backup = + dbClient + .newBackupBuilder(BackupId.of("test-project", "instance-id", "backup-id")) + .setDatabase(DatabaseId.of("test-project", "instance-id", "src-database")) + .setExpireTime(expireTime) + .setSize(100L) + .setState(State.CREATING) + .build(); + Backup copy = backup.toBuilder().build(); + assertThat(copy.getId()).isEqualTo(backup.getId()); + assertThat(copy.getDatabase()).isEqualTo(backup.getDatabase()); + assertThat(copy.getExpireTime()).isEqualTo(backup.getExpireTime()); + assertThat(copy.getSize()).isEqualTo(backup.getSize()); + assertThat(copy.getState()).isEqualTo(backup.getState()); + } + + @Test + public void create() { + Timestamp expireTime = Timestamp.now(); + Backup backup = + dbClient + .newBackupBuilder(BackupId.of("test-project", "instance-id", "backup-id")) + .setDatabase(DatabaseId.of("test-project", "instance-id", "src-database")) + .setExpireTime(expireTime) + .build(); + backup.create(); + verify(dbClient).createBackup("instance-id", "backup-id", "src-database", expireTime); + } + + @Test + public void createWithoutSource() { + Timestamp expireTime = Timestamp.now(); + Backup backup = + dbClient + .newBackupBuilder(BackupId.of("test-project", "dest-instance", "backup-id")) + .setExpireTime(expireTime) + .build(); + expectedException.expect(IllegalStateException.class); + backup.create(); + } + + @Test + public void createWithoutExpireTime() { + Backup backup = + dbClient + .newBackupBuilder(BackupId.of("test-project", "instance-id", "backup-id")) + .setDatabase(DatabaseId.of("test-project", "instance-id", "src-database")) + .build(); + expectedException.expect(IllegalStateException.class); + backup.create(); + } + + @Test + public void exists() { + when(dbClient.getBackup("test-instance", "test-backup")) + .thenReturn( + new Backup.Builder( + dbClient, BackupId.of("test-project", "test-instance", "test-backup")) + .build()); + when(dbClient.getBackup("other-instance", "other-backup")) + .thenThrow( + SpannerExceptionFactory.newSpannerException(ErrorCode.NOT_FOUND, "backup not found")); + Backup backup = + dbClient + .newBackupBuilder(BackupId.of("test-project", "test-instance", "test-backup")) + .build(); + assertThat(backup.exists()).isTrue(); + Backup otherBackup = + dbClient + .newBackupBuilder(BackupId.of("test-project", "other-instance", "other-backup")) + .build(); + assertThat(otherBackup.exists()).isFalse(); + } + + @Test + public void isReady() { + when(dbClient.getBackup("test-instance", "test-backup")) + .thenReturn( + new Backup.Builder( + dbClient, BackupId.of("test-project", "test-instance", "test-backup")) + .setState(State.READY) + .build()); + when(dbClient.getBackup("other-instance", "other-backup")) + .thenReturn( + new Backup.Builder( + dbClient, BackupId.of("test-project", "other-instance", "other-backup")) + .setState(State.CREATING) + .build()); + Backup backup = + dbClient + .newBackupBuilder(BackupId.of("test-project", "test-instance", "test-backup")) + .setState(State.UNSPECIFIED) + .build(); + assertThat(backup.isReady()).isTrue(); + assertThat(backup.getState()).isEqualTo(State.UNSPECIFIED); + Backup otherBackup = + dbClient + .newBackupBuilder(BackupId.of("test-project", "other-instance", "other-backup")) + .setState(State.READY) + .build(); + assertThat(otherBackup.isReady()).isFalse(); + assertThat(otherBackup.getState()).isEqualTo(State.READY); + } + + @Test + public void reload() { + Backup backup = + dbClient + .newBackupBuilder(BackupId.of("test-project", "test-instance", "test-backup")) + .build(); + backup.reload(); + verify(dbClient).getBackup("test-instance", "test-backup"); + } + + @Test + public void delete() { + Backup backup = + dbClient + .newBackupBuilder(BackupId.of("test-project", "test-instance", "test-backup")) + .build(); + backup.delete(); + verify(dbClient).deleteBackup("test-instance", "test-backup"); + } + + @Test + public void updateExpireTime() { + Timestamp expireTime = Timestamp.now(); + Backup backup = + dbClient + .newBackupBuilder(BackupId.of("test-project", "test-instance", "test-backup")) + .setExpireTime(expireTime) + .build(); + backup.updateExpireTime(); + verify(dbClient).updateBackup("test-instance", "test-backup", expireTime); + } + + @Test + public void updateExpireTimeWithoutExpireTime() { + Backup backup = + dbClient + .newBackupBuilder(BackupId.of("test-project", "test-instance", "test-backup")) + .build(); + expectedException.expect(IllegalStateException.class); + backup.updateExpireTime(); + } + + @Test + public void restore() { + Backup backup = + dbClient + .newBackupBuilder(BackupId.of("test-project", "backup-instance", "test-backup")) + .build(); + backup.restore(DatabaseId.of("test-project", "db-instance", "test-database")); + verify(dbClient) + .restoreDatabase("backup-instance", "test-backup", "db-instance", "test-database"); + } + + @Test + public void restoreWithoutDestination() { + Backup backup = + dbClient + .newBackupBuilder(BackupId.of("test-project", "test-instance", "test-backup")) + .build(); + expectedException.expect(NullPointerException.class); + backup.restore(null); + } + + @Test + public void listBackupOperations() { + Backup backup = + dbClient + .newBackupBuilder(BackupId.of("test-project", "test-instance", "backup-id")) + .build(); + backup.listBackupOperations(); + verify(dbClient) + .listBackupOperations("test-instance", Options.filter("name:backups/backup-id")); + } + + @Test + public void getIAMPolicy() { + Backup backup = + dbClient + .newBackupBuilder(BackupId.of("test-project", "test-instance", "test-backup")) + .build(); + backup.getIAMPolicy(); + verify(dbClient).getBackupIAMPolicy("test-instance", "test-backup"); + } + + @Test + public void setIAMPolicy() { + Backup backup = + dbClient + .newBackupBuilder(BackupId.of("test-project", "test-instance", "test-backup")) + .build(); + Policy policy = + Policy.newBuilder().addIdentity(Role.editor(), Identity.user("joe@example.com")).build(); + backup.setIAMPolicy(policy); + verify(dbClient).setBackupIAMPolicy("test-instance", "test-backup", policy); + } + + @Test + public void testIAMPermissions() { + Backup backup = + dbClient + .newBackupBuilder(BackupId.of("test-project", "test-instance", "test-backup")) + .build(); + Iterable permissions = Arrays.asList("read"); + backup.testIAMPermissions(permissions); + verify(dbClient).testBackupIAMPermissions("test-instance", "test-backup", permissions); + } + + @Test + public void fromProto() { + Backup backup = createBackup(); + assertThat(backup.getId().getName()).isEqualTo(NAME); + assertThat(backup.getState()).isEqualTo(BackupInfo.State.CREATING); + assertThat(backup.getExpireTime()).isEqualTo(EXP_TIME); + } + + private Backup createBackup() { + com.google.spanner.admin.database.v1.Backup proto = + com.google.spanner.admin.database.v1.Backup.newBuilder() + .setName(NAME) + .setDatabase(DB) + .setExpireTime( + com.google.protobuf.Timestamp.newBuilder().setSeconds(1000L).setNanos(1000).build()) + .setState(com.google.spanner.admin.database.v1.Backup.State.CREATING) + .build(); + return Backup.fromProto(proto, dbClient); + } +} diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseAdminClientImplTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseAdminClientImplTest.java index 260c2f77ca..e77fb38439 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseAdminClientImplTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseAdminClientImplTest.java @@ -24,6 +24,7 @@ import com.google.api.gax.longrunning.OperationFuture; import com.google.cloud.Identity; import com.google.cloud.Role; +import com.google.cloud.Timestamp; import com.google.cloud.spanner.spi.v1.SpannerRpc; import com.google.cloud.spanner.spi.v1.SpannerRpc.Paginated; import com.google.common.collect.ImmutableList; @@ -36,13 +37,18 @@ import com.google.protobuf.Any; import com.google.protobuf.ByteString; import com.google.protobuf.Empty; +import com.google.protobuf.FieldMask; import com.google.protobuf.Message; +import com.google.spanner.admin.database.v1.Backup; +import com.google.spanner.admin.database.v1.CreateBackupMetadata; import com.google.spanner.admin.database.v1.CreateDatabaseMetadata; import com.google.spanner.admin.database.v1.Database; +import com.google.spanner.admin.database.v1.RestoreDatabaseMetadata; import com.google.spanner.admin.database.v1.UpdateDatabaseDdlMetadata; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.concurrent.TimeUnit; import org.junit.Assert; import org.junit.Before; import org.junit.Test; @@ -60,6 +66,9 @@ public class DatabaseAdminClientImplTest { private static final String DB_NAME = "projects/my-project/instances/my-instance/databases/my-db"; private static final String DB_NAME2 = "projects/my-project/instances/my-instance/databases/my-db2"; + private static final String BK_ID = "my-bk"; + private static final String BK_NAME = "projects/my-project/instances/my-instance/backups/my-bk"; + private static final String BK_NAME2 = "projects/my-project/instances/my-instance/backups/my-bk2"; @Mock SpannerRpc rpc; DatabaseAdminClientImpl client; @@ -85,6 +94,22 @@ static Any toAny(Message message) { .build(); } + private Backup getBackupProto() { + return Backup.newBuilder() + .setName(BK_NAME) + .setDatabase(DB_NAME) + .setState(Backup.State.READY) + .build(); + } + + private Backup getAnotherBackupProto() { + return Backup.newBuilder() + .setName(BK_NAME2) + .setDatabase(DB_NAME2) + .setState(Backup.State.READY) + .build(); + } + @Test public void getDatabase() { when(rpc.getDatabase(DB_NAME)).thenReturn(getDatabaseProto()); @@ -132,12 +157,7 @@ public void updateDatabaseDdlOpAlreadyExists() throws Exception { Empty.getDefaultInstance(), UpdateDatabaseDdlMetadata.getDefaultInstance()); - String newOpName = DB_NAME + "/operations/newop"; String newOpId = "newop"; - OperationFuture newop = - OperationFutureUtil.immediateOperationFuture( - newOpName, Empty.getDefaultInstance(), UpdateDatabaseDdlMetadata.getDefaultInstance()); - when(rpc.updateDatabaseDdl(DB_NAME, ddl, newOpId)).thenReturn(originalOp); OperationFuture op = client.updateDatabaseDdl(INSTANCE_ID, DB_ID, ddl, newOpId); @@ -270,4 +290,80 @@ public void testDatabaseIAMPermissions() { Iterable allowed = client.testDatabaseIAMPermissions(INSTANCE_ID, DB_ID, permissions); assertThat(allowed).containsExactly("spanner.databases.select"); } + + @Test + public void createBackup() throws Exception { + OperationFuture rawOperationFuture = + OperationFutureUtil.immediateOperationFuture( + "createBackup", getBackupProto(), CreateBackupMetadata.getDefaultInstance()); + Timestamp t = + Timestamp.ofTimeMicroseconds( + TimeUnit.MILLISECONDS.toMicros(System.currentTimeMillis()) + + TimeUnit.HOURS.toMicros(28)); + Backup backup = Backup.newBuilder().setDatabase(DB_NAME).setExpireTime(t.toProto()).build(); + when(rpc.createBackup(INSTANCE_NAME, BK_ID, backup)).thenReturn(rawOperationFuture); + OperationFuture op = + client.createBackup(INSTANCE_ID, BK_ID, DB_ID, t); + assertThat(op.isDone()).isTrue(); + assertThat(op.get().getId().getName()).isEqualTo(BK_NAME); + } + + @Test + public void deleteBackup() { + client.deleteBackup(INSTANCE_ID, BK_ID); + verify(rpc).deleteBackup(BK_NAME); + } + + @Test + public void getBackup() { + when(rpc.getBackup(BK_NAME)).thenReturn(getBackupProto()); + com.google.cloud.spanner.Backup bk = client.getBackup(INSTANCE_ID, BK_ID); + BackupId bid = BackupId.of(bk.getId().getName()); + assertThat(bid.getName()).isEqualTo(BK_NAME); + assertThat(bk.getState()).isEqualTo(com.google.cloud.spanner.Backup.State.READY); + } + + @Test + public void listBackups() { + String pageToken = "token"; + when(rpc.listBackups(INSTANCE_NAME, 1, null, null)) + .thenReturn(new Paginated<>(ImmutableList.of(getBackupProto()), pageToken)); + when(rpc.listBackups(INSTANCE_NAME, 1, null, pageToken)) + .thenReturn(new Paginated<>(ImmutableList.of(getAnotherBackupProto()), "")); + List backups = + Lists.newArrayList(client.listBackups(INSTANCE_ID, Options.pageSize(1)).iterateAll()); + assertThat(backups.get(0).getId().getName()).isEqualTo(BK_NAME); + assertThat(backups.get(1).getId().getName()).isEqualTo(BK_NAME2); + assertThat(backups.size()).isEqualTo(2); + } + + @Test + public void updateBackup() throws Exception { + Timestamp t = + Timestamp.ofTimeMicroseconds( + TimeUnit.MILLISECONDS.toMicros(System.currentTimeMillis()) + + TimeUnit.HOURS.toMicros(28)); + Backup backup = Backup.newBuilder().setName(BK_NAME).setExpireTime(t.toProto()).build(); + when(rpc.updateBackup(backup, FieldMask.newBuilder().addPaths("expire_time").build())) + .thenReturn( + Backup.newBuilder() + .setName(BK_NAME) + .setDatabase(DB_NAME) + .setExpireTime(t.toProto()) + .build()); + com.google.cloud.spanner.Backup updatedBackup = client.updateBackup(INSTANCE_ID, BK_ID, t); + assertThat(updatedBackup.getExpireTime()).isEqualTo(t); + } + + @Test + public void restoreDatabase() throws Exception { + OperationFuture rawOperationFuture = + OperationFutureUtil.immediateOperationFuture( + "restoreDatabase", getDatabaseProto(), RestoreDatabaseMetadata.getDefaultInstance()); + when(rpc.restoreDatabase(INSTANCE_NAME, DB_ID, BK_NAME)).thenReturn(rawOperationFuture); + OperationFuture op = + client.restoreDatabase(INSTANCE_ID, BK_ID, INSTANCE_ID, DB_ID); + assertThat(op.isDone()).isTrue(); + assertThat(op.get().getId().getName()).isEqualTo(DB_NAME); + } } diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseAdminClientTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseAdminClientTest.java index 1204908864..38bcd8437d 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseAdminClientTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseAdminClientTest.java @@ -16,21 +16,40 @@ package com.google.cloud.spanner; +import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.truth.Truth.assertThat; import com.google.api.gax.grpc.testing.LocalChannelProvider; import com.google.api.gax.grpc.testing.MockGrpcService; import com.google.api.gax.grpc.testing.MockServiceHelper; +import com.google.api.gax.longrunning.OperationFuture; +import com.google.api.gax.longrunning.OperationSnapshot; import com.google.api.gax.longrunning.OperationTimedPollAlgorithm; +import com.google.api.gax.paging.Page; import com.google.api.gax.retrying.RetrySettings; +import com.google.api.gax.retrying.RetryingFuture; import com.google.cloud.Identity; import com.google.cloud.NoCredentials; import com.google.cloud.Policy; import com.google.cloud.Role; +import com.google.cloud.Timestamp; +import com.google.cloud.spanner.DatabaseInfo.State; +import com.google.longrunning.Operation; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.spanner.admin.database.v1.CreateBackupMetadata; +import com.google.spanner.admin.database.v1.CreateDatabaseMetadata; +import com.google.spanner.admin.database.v1.OptimizeRestoredDatabaseMetadata; +import com.google.spanner.admin.database.v1.RestoreDatabaseMetadata; +import com.google.spanner.admin.database.v1.UpdateDatabaseDdlMetadata; import java.io.IOException; import java.util.Arrays; +import java.util.Collections; import java.util.List; +import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import org.hamcrest.BaseMatcher; +import org.hamcrest.Description; import org.junit.After; import org.junit.AfterClass; import org.junit.Before; @@ -44,9 +63,42 @@ @RunWith(JUnit4.class) public class DatabaseAdminClientTest { + private static class SpannerExecutionExceptionMatcher extends BaseMatcher { + private final ErrorCode expectedCode; + + private static SpannerExecutionExceptionMatcher forCode(ErrorCode code) { + return new SpannerExecutionExceptionMatcher(code); + } + + private SpannerExecutionExceptionMatcher(ErrorCode code) { + this.expectedCode = checkNotNull(code); + } + + @Override + public boolean matches(Object item) { + if (item instanceof ExecutionException) { + ExecutionException e = (ExecutionException) item; + if (e.getCause() instanceof SpannerException) { + SpannerException se = (SpannerException) e.getCause(); + return se.getErrorCode() == expectedCode; + } + } + return false; + } + + @Override + public void describeTo(Description description) { + description.appendText("SpannerException[" + expectedCode + "]"); + } + } + private static final String PROJECT_ID = "my-project"; private static final String INSTANCE_ID = "my-instance"; private static final String DB_ID = "test-db"; + private static final String BCK_ID = "test-bck"; + private static final String RESTORED_ID = "restored-test-db"; + private static final String TEST_PARENT = "projects/my-project/instances/my-instance"; + private static final String TEST_BCK_NAME = String.format("%s/backups/test-bck", TEST_PARENT); private static final List INITIAL_STATEMENTS = Arrays.asList("CREATE TABLE FOO", "CREATE TABLE BAR"); @@ -57,6 +109,9 @@ public class DatabaseAdminClientTest { private Spanner spanner; private DatabaseAdminClient client; @Rule public ExpectedException exception = ExpectedException.none(); + private OperationFuture createDatabaseOperation; + private OperationFuture createBackupOperation; + private OperationFuture restoreDatabaseOperation; @BeforeClass public static void startStaticServer() { @@ -78,6 +133,21 @@ public void setUp() throws IOException { serviceHelper.reset(); channelProvider = serviceHelper.createChannelProvider(); SpannerOptions.Builder builder = SpannerOptions.newBuilder(); + builder + .getDatabaseAdminStubSettingsBuilder() + .createBackupOperationSettings() + .setPollingAlgorithm( + OperationTimedPollAlgorithm.create( + RetrySettings.newBuilder() + .setInitialRpcTimeout(Duration.ofMillis(20L)) + .setInitialRetryDelay(Duration.ofMillis(10L)) + .setMaxRetryDelay(Duration.ofMillis(150L)) + .setMaxRpcTimeout(Duration.ofMillis(150L)) + .setMaxAttempts(10) + .setTotalTimeout(Duration.ofMillis(5000L)) + .setRetryDelayMultiplier(1.3) + .setRpcTimeoutMultiplier(1.3) + .build())); builder .getDatabaseAdminStubSettingsBuilder() .createDatabaseOperationSettings() @@ -93,6 +163,21 @@ public void setUp() throws IOException { .setRetryDelayMultiplier(1.3) .setRpcTimeoutMultiplier(1.3) .build())); + builder + .getDatabaseAdminStubSettingsBuilder() + .restoreDatabaseOperationSettings() + .setPollingAlgorithm( + OperationTimedPollAlgorithm.create( + RetrySettings.newBuilder() + .setInitialRpcTimeout(Duration.ofMillis(20L)) + .setInitialRetryDelay(Duration.ofMillis(10L)) + .setMaxRetryDelay(Duration.ofMillis(150L)) + .setMaxRpcTimeout(Duration.ofMillis(150L)) + .setMaxAttempts(10) + .setTotalTimeout(Duration.ofMillis(5000L)) + .setRetryDelayMultiplier(1.3) + .setRpcTimeoutMultiplier(1.3) + .build())); spanner = builder .setChannelProvider(channelProvider) @@ -102,13 +187,523 @@ public void setUp() throws IOException { .getService(); client = spanner.getDatabaseAdminClient(); createTestDatabase(); + createTestBackup(); + restoreTestBackup(); } @After public void tearDown() throws Exception { + serviceHelper.reset(); spanner.close(); } + @Test + public void dbAdminCreateBackup() throws InterruptedException, ExecutionException { + final String backupId = "other-backup-id"; + OperationFuture op = + client.createBackup(INSTANCE_ID, backupId, DB_ID, after7Days()); + Backup backup = op.get(); + assertThat(backup.getId().getName()) + .isEqualTo( + String.format( + "projects/%s/instances/%s/backups/%s", PROJECT_ID, INSTANCE_ID, backupId)); + assertThat(client.getBackup(INSTANCE_ID, backupId)).isEqualTo(backup); + } + + @Test + public void backupCreate() throws InterruptedException, ExecutionException { + final String backupId = "other-backup-id"; + Backup backup = + client + .newBackupBuilder(BackupId.of(PROJECT_ID, INSTANCE_ID, backupId)) + .setDatabase(DatabaseId.of(PROJECT_ID, INSTANCE_ID, DB_ID)) + .setExpireTime(after7Days()) + .build(); + OperationFuture op = backup.create(); + backup = op.get(); + assertThat(backup.getId().getName()) + .isEqualTo( + String.format( + "projects/%s/instances/%s/backups/%s", PROJECT_ID, INSTANCE_ID, backupId)); + assertThat(client.getBackup(INSTANCE_ID, backupId)).isEqualTo(backup); + } + + @Test + public void backupCreateCancel() { + final String backupId = "other-backup-id"; + // Set expire time to 14 days from now. + long currentTimeInMicroSeconds = + TimeUnit.MICROSECONDS.convert(System.currentTimeMillis(), TimeUnit.MILLISECONDS); + long deltaTimeInMicroseconds = TimeUnit.MICROSECONDS.convert(14L, TimeUnit.DAYS); + Timestamp expireTime = + Timestamp.ofTimeMicroseconds(currentTimeInMicroSeconds + deltaTimeInMicroseconds); + Backup backup = + client + .newBackupBuilder(BackupId.of(PROJECT_ID, INSTANCE_ID, backupId)) + .setDatabase(DatabaseId.of(PROJECT_ID, INSTANCE_ID, DB_ID)) + .setExpireTime(expireTime) + .build(); + // Start a creation of a backup. + OperationFuture op = backup.create(); + try { + // Try to cancel the backup operation. + client.cancelOperation(op.getName()); + // Get a polling future for the running operation. This future will regularly poll the server + // for the current status of the backup operation. + RetryingFuture pollingFuture = op.getPollingFuture(); + // Wait for the operation to finish. + // isDone will return true if the operation has finished successfully or if it was cancelled + // or any other error occurred. + while (!pollingFuture.get().isDone()) { + Thread.sleep(TimeUnit.MILLISECONDS.convert(5, TimeUnit.SECONDS)); + } + } catch (CancellationException e) { + // ignore, this exception may also occur if the polling future has been cancelled. + } catch (ExecutionException e) { + throw (RuntimeException) e.getCause(); + } catch (InterruptedException e) { + throw SpannerExceptionFactory.propagateInterrupt(e); + } finally { + backup.delete(); + } + } + + @Test + public void databaseBackup() throws InterruptedException, ExecutionException { + final String backupId = "other-backup-id"; + Database db = client.getDatabase(INSTANCE_ID, DB_ID); + Backup backup = + db.backup( + client + .newBackupBuilder(BackupId.of(PROJECT_ID, INSTANCE_ID, backupId)) + .setExpireTime(after7Days()) + .build()) + .get(); + assertThat(backup.getId().getName()) + .isEqualTo( + String.format( + "projects/%s/instances/%s/backups/%s", PROJECT_ID, INSTANCE_ID, backupId)); + assertThat(client.getBackup(INSTANCE_ID, backupId)).isEqualTo(backup); + } + + @Test + public void dbAdminCreateBackupAlreadyExists() throws InterruptedException, ExecutionException { + OperationFuture op = + client.createBackup(INSTANCE_ID, BCK_ID, DB_ID, after7Days()); + exception.expect(SpannerExecutionExceptionMatcher.forCode(ErrorCode.ALREADY_EXISTS)); + op.get(); + } + + @Test + public void backupCreateAlreadyExists() throws InterruptedException, ExecutionException { + Backup backup = + client + .newBackupBuilder(BackupId.of(PROJECT_ID, INSTANCE_ID, BCK_ID)) + .setDatabase(DatabaseId.of(PROJECT_ID, INSTANCE_ID, DB_ID)) + .setExpireTime(after7Days()) + .build(); + OperationFuture op = backup.create(); + exception.expect(SpannerExecutionExceptionMatcher.forCode(ErrorCode.ALREADY_EXISTS)); + op.get(); + } + + @Test + public void databaseBackupAlreadyExists() throws InterruptedException, ExecutionException { + Database db = client.getDatabase(INSTANCE_ID, DB_ID); + OperationFuture op = + db.backup( + client + .newBackupBuilder(BackupId.of(PROJECT_ID, INSTANCE_ID, BCK_ID)) + .setExpireTime(after7Days()) + .build()); + exception.expect(SpannerExecutionExceptionMatcher.forCode(ErrorCode.ALREADY_EXISTS)); + op.get(); + } + + @Test + public void dbAdminCreateBackupDbNotFound() throws InterruptedException, ExecutionException { + final String backupId = "other-backup-id"; + OperationFuture op = + client.createBackup(INSTANCE_ID, backupId, "does-not-exist", after7Days()); + exception.expect(SpannerExecutionExceptionMatcher.forCode(ErrorCode.NOT_FOUND)); + op.get(); + } + + @Test + public void backupCreateDbNotFound() throws InterruptedException, ExecutionException { + final String backupId = "other-backup-id"; + Backup backup = + client + .newBackupBuilder(BackupId.of(PROJECT_ID, INSTANCE_ID, backupId)) + .setDatabase(DatabaseId.of(PROJECT_ID, INSTANCE_ID, "does-not-exist")) + .setExpireTime(after7Days()) + .build(); + OperationFuture op = backup.create(); + exception.expect(SpannerExecutionExceptionMatcher.forCode(ErrorCode.NOT_FOUND)); + op.get(); + } + + @Test + public void databaseBackupDbNotFound() throws InterruptedException, ExecutionException { + final String backupId = "other-backup-id"; + Database db = + new Database( + DatabaseId.of(PROJECT_ID, INSTANCE_ID, "does-not-exist"), State.UNSPECIFIED, client); + OperationFuture op = + db.backup( + client + .newBackupBuilder(BackupId.of(PROJECT_ID, INSTANCE_ID, backupId)) + .setExpireTime(after7Days()) + .build()); + exception.expect(SpannerExecutionExceptionMatcher.forCode(ErrorCode.NOT_FOUND)); + op.get(); + } + + @Test + public void dbAdminDeleteBackup() { + Backup backup = client.newBackupBuilder(BackupId.of(PROJECT_ID, INSTANCE_ID, BCK_ID)).build(); + assertThat(backup.exists()).isTrue(); + client.deleteBackup(INSTANCE_ID, BCK_ID); + assertThat(backup.exists()).isFalse(); + } + + @Test + public void backupDelete() { + Backup backup = client.newBackupBuilder(BackupId.of(PROJECT_ID, INSTANCE_ID, BCK_ID)).build(); + assertThat(backup.exists()).isTrue(); + backup.delete(); + assertThat(backup.exists()).isFalse(); + } + + @Test + public void dbAdminDeleteBackupNotFound() { + exception.expect(SpannerMatchers.isSpannerException(ErrorCode.NOT_FOUND)); + client.deleteBackup(INSTANCE_ID, "does-not-exist"); + } + + @Test + public void backupDeleteNotFound() { + Backup backup = + client.newBackupBuilder(BackupId.of(PROJECT_ID, INSTANCE_ID, "does-not-exist")).build(); + exception.expect(SpannerMatchers.isSpannerException(ErrorCode.NOT_FOUND)); + backup.delete(); + } + + @Test + public void dbAdminGetBackup() { + Backup backup = client.getBackup(INSTANCE_ID, BCK_ID); + assertThat(backup.getId().getName()).isEqualTo(TEST_BCK_NAME); + } + + @Test + public void backupReload() { + Backup backup = client.newBackupBuilder(BackupId.of(PROJECT_ID, INSTANCE_ID, BCK_ID)).build(); + assertThat(backup.getState()).isEqualTo(com.google.cloud.spanner.BackupInfo.State.UNSPECIFIED); + backup.reload(); + assertThat(backup.getId().getName()).isEqualTo(TEST_BCK_NAME); + } + + @Test + public void dbAdminGetBackupNotFound() { + exception.expect(SpannerMatchers.isSpannerException(ErrorCode.NOT_FOUND)); + client.getBackup(INSTANCE_ID, "does-not-exist"); + } + + @Test + public void backupReloadNotFound() { + Backup backup = + client.newBackupBuilder(BackupId.of(PROJECT_ID, INSTANCE_ID, "does-not-exist")).build(); + exception.expect(SpannerMatchers.isSpannerException(ErrorCode.NOT_FOUND)); + backup.reload(); + } + + @Test + public void backupExists() { + Backup backup = + client.newBackupBuilder(BackupId.of(PROJECT_ID, INSTANCE_ID, "does-not-exist")).build(); + assertThat(backup.exists()).isFalse(); + backup = client.newBackupBuilder(BackupId.of(PROJECT_ID, INSTANCE_ID, BCK_ID)).build(); + assertThat(backup.exists()).isTrue(); + } + + @Test + public void dbClientListBackups() + throws SpannerException, InterruptedException, ExecutionException { + Backup backup = client.getBackup(INSTANCE_ID, BCK_ID); + assertThat(client.listBackups(INSTANCE_ID).iterateAll()).containsExactly(backup); + Backup backup2 = client.createBackup(INSTANCE_ID, "backup2", DB_ID, after7Days()).get(); + assertThat(client.listBackups(INSTANCE_ID).iterateAll()).containsExactly(backup, backup2); + backup2.delete(); + assertThat(client.listBackups(INSTANCE_ID).iterateAll()).containsExactly(backup); + } + + @Test + public void instanceListBackups() + throws SpannerException, InterruptedException, ExecutionException { + Instance instance = + spanner + .getInstanceAdminClient() + .newInstanceBuilder(InstanceId.of(PROJECT_ID, INSTANCE_ID)) + .build(); + Backup backup = client.getBackup(INSTANCE_ID, BCK_ID); + assertThat(instance.listBackups().iterateAll()).containsExactly(backup); + Backup backup2 = client.createBackup(INSTANCE_ID, "backup2", DB_ID, after7Days()).get(); + assertThat(instance.listBackups().iterateAll()).containsExactly(backup, backup2); + backup2.delete(); + assertThat(instance.listBackups().iterateAll()).containsExactly(backup); + } + + @Test + public void instanceListBackupsWithFilter() + throws SpannerException, InterruptedException, ExecutionException { + Instance instance = + spanner + .getInstanceAdminClient() + .newInstanceBuilder(InstanceId.of(PROJECT_ID, INSTANCE_ID)) + .build(); + + Backup backup = client.getBackup(INSTANCE_ID, BCK_ID); + assertThat(instance.listBackups().iterateAll()).containsExactly(backup); + Backup backup2 = client.createBackup(INSTANCE_ID, "backup2", DB_ID, after7Days()).get(); + + // All backups. + assertThat(instance.listBackups().iterateAll()).containsExactly(backup, backup2); + + // All backups with name containing 'backup2'. + String filter = "name:backup2"; + mockDatabaseAdmin.addFilterMatches(filter, backup2.getId().getName()); + assertThat(instance.listBackups(Options.filter(filter)).iterateAll()).containsExactly(backup2); + + // All backups for a database with the name db2. + filter = String.format("database:%s", DB_ID); + mockDatabaseAdmin.addFilterMatches(filter, backup.getId().getName(), backup2.getId().getName()); + assertThat(instance.listBackups(Options.filter(filter)).iterateAll()) + .containsExactly(backup, backup2); + + // All backups that expire before a certain time. + String ts = after14Days().toString(); + filter = String.format("expire_time < \"%s\"", ts); + mockDatabaseAdmin.addFilterMatches(filter, backup.getId().getName(), backup2.getId().getName()); + assertThat(instance.listBackups(Options.filter(filter)).iterateAll()) + .containsExactly(backup, backup2); + // All backups with size greater than a certain number of bytes. + long minBytes = Math.min(backup.getSize(), backup2.getSize()); + filter = String.format("size_bytes > %d", minBytes); + Backup backupWithLargestSize; + if (backup.getSize() == minBytes) { + backupWithLargestSize = backup2; + } else { + backupWithLargestSize = backup; + } + mockDatabaseAdmin.addFilterMatches(filter, backupWithLargestSize.getId().getName()); + assertThat(instance.listBackups(Options.filter(filter)).iterateAll()) + .containsExactly(backupWithLargestSize); + // All backups with a create time after a certain timestamp and that are also ready. + ts = backup2.getProto().getCreateTime().toString(); + filter = String.format("create_time >= \"%s\" AND state:READY", ts.toString()); + mockDatabaseAdmin.addFilterMatches(filter, backup2.getId().getName()); + assertThat(instance.listBackups(Options.filter(filter)).iterateAll()).containsExactly(backup2); + } + + @Test + public void dbClientUpdateBackup() { + Timestamp oldExpireTime = client.getBackup(INSTANCE_ID, BCK_ID).getExpireTime(); + Timestamp newExpireTime = + Timestamp.ofTimeSecondsAndNanos( + Timestamp.now().getSeconds() + TimeUnit.SECONDS.convert(1, TimeUnit.DAYS), 0); + assertThat(oldExpireTime).isNotEqualTo(newExpireTime); + Backup backup = client.updateBackup(INSTANCE_ID, BCK_ID, newExpireTime); + assertThat(backup.getExpireTime()).isEqualTo(newExpireTime); + assertThat(client.getBackup(INSTANCE_ID, BCK_ID)).isEqualTo(backup); + } + + @Test + public void backupUpdate() { + Timestamp newExpireTime = + Timestamp.ofTimeSecondsAndNanos( + Timestamp.now().getSeconds() + TimeUnit.SECONDS.convert(1, TimeUnit.DAYS), 0); + Backup backup = client.getBackup(INSTANCE_ID, BCK_ID); + assertThat(backup.getExpireTime()).isNotEqualTo(newExpireTime); + backup.toBuilder().setExpireTime(newExpireTime).build().updateExpireTime(); + Backup updated = client.getBackup(INSTANCE_ID, BCK_ID); + assertThat(updated.getExpireTime()).isEqualTo(newExpireTime); + assertThat(updated).isNotEqualTo(backup); + assertThat(backup.reload()).isEqualTo(updated); + } + + @Test + public void dbClientRestoreDatabase() throws InterruptedException, ExecutionException { + OperationFuture op = + client.restoreDatabase(INSTANCE_ID, BCK_ID, "other-instance-id", "restored-db"); + Database restored = op.get(); + assertThat(restored.getId().getDatabase()).isEqualTo("restored-db"); + assertThat(restored.getId().getInstanceId().getInstance()).isEqualTo("other-instance-id"); + } + + @Test + public void backupRestoreDatabase() throws InterruptedException, ExecutionException { + Backup backup = client.newBackupBuilder(BackupId.of(PROJECT_ID, INSTANCE_ID, BCK_ID)).build(); + Database restored = + backup.restore(DatabaseId.of(PROJECT_ID, "other-instance-id", "restored-db")).get(); + assertThat(restored.getId().getDatabase()).isEqualTo("restored-db"); + assertThat(restored.getId().getInstanceId().getInstance()).isEqualTo("other-instance-id"); + } + + @Test + public void dbClientListDatabaseOperations() + throws SpannerException, InterruptedException, ExecutionException { + // Note: The mock server keeps all operations until the server is reset, including operations + // that have already finished. + // The setup method creates a test database --> 1 operation. + // + restores a database --> 2 operations. + assertThat(client.listDatabaseOperations(INSTANCE_ID).iterateAll()).hasSize(3); + // Create another database which should also create another operation. + client.createDatabase(INSTANCE_ID, "other-database", Collections.emptyList()).get(); + assertThat(client.listDatabaseOperations(INSTANCE_ID).iterateAll()).hasSize(4); + // Restore a backup. This should create 2 database operations: One to restore the database and + // one to optimize it. + client.restoreDatabase(INSTANCE_ID, BCK_ID, INSTANCE_ID, "restored-db").get(); + assertThat(client.listDatabaseOperations(INSTANCE_ID).iterateAll()).hasSize(6); + } + + @Test + public void instanceListDatabaseOperations() + throws SpannerException, InterruptedException, ExecutionException { + Instance instance = + spanner + .getInstanceAdminClient() + .newInstanceBuilder(InstanceId.of(PROJECT_ID, INSTANCE_ID)) + .build(); + assertThat(instance.listDatabaseOperations().iterateAll()).hasSize(3); + instance.createDatabase("other-database", Collections.emptyList()).get(); + assertThat(instance.listDatabaseOperations().iterateAll()).hasSize(4); + client + .newBackupBuilder(BackupId.of(PROJECT_ID, INSTANCE_ID, BCK_ID)) + .build() + .restore(DatabaseId.of(PROJECT_ID, INSTANCE_ID, "restored-db")) + .get(); + assertThat(instance.listDatabaseOperations().iterateAll()).hasSize(6); + } + + @Test + public void instanceListDatabaseOperationsWithMetadata() throws Exception { + Instance instance = + spanner + .getInstanceAdminClient() + .newInstanceBuilder(InstanceId.of(PROJECT_ID, INSTANCE_ID)) + .build(); + String filter = + "(metadata.@type:type.googleapis.com/" + + "google.spanner.admin.database.v1.OptimizeRestoredDatabaseMetadata)"; + mockDatabaseAdmin.addFilterMatches( + filter, restoreDatabaseOperation.getMetadata().get().getOptimizeDatabaseOperationName()); + Iterable operations = + instance.listDatabaseOperations(Options.filter(filter)).iterateAll(); + assertThat(operations).hasSize(1); + for (Operation op : operations) { + OptimizeRestoredDatabaseMetadata metadata = + op.getMetadata().unpack(OptimizeRestoredDatabaseMetadata.class); + String progress = + String.format( + "Restored database %s is optimized", + metadata.getName(), metadata.getProgress().getProgressPercent()); + assertThat(progress.contains("100%")); + } + } + + @Test + public void databaseListDatabaseOperations() + throws SpannerException, InterruptedException, ExecutionException { + Database database = client.getDatabase(INSTANCE_ID, DB_ID); + mockDatabaseAdmin.addFilterMatches( + "name:databases/" + DB_ID, createDatabaseOperation.getName()); + assertThat(database.listDatabaseOperations().iterateAll()).hasSize(1); + // Create another database which should also create another operation, but for a different + // database. + client.createDatabase(INSTANCE_ID, "other-database", Collections.emptyList()).get(); + assertThat(database.listDatabaseOperations().iterateAll()).hasSize(1); + // Update the database DDL. This should create an operation for this database. + OperationFuture op = + database.updateDdl(Arrays.asList("DROP TABLE FOO"), null); + mockDatabaseAdmin.addFilterMatches("name:databases/" + DB_ID, op.getName()); + assertThat(database.listDatabaseOperations().iterateAll()).hasSize(2); + } + + @Test + public void dbClientListBackupOperations() + throws SpannerException, InterruptedException, ExecutionException { + assertThat(client.listBackupOperations(INSTANCE_ID).iterateAll()).hasSize(1); + client.createBackup(INSTANCE_ID, "other-backup", DB_ID, after7Days()).get(); + assertThat(client.listBackupOperations(INSTANCE_ID).iterateAll()).hasSize(2); + // Restore a backup. This creates 2 DATABASE operations: One to restore the database and + // one to optimize it. + client.restoreDatabase(INSTANCE_ID, BCK_ID, INSTANCE_ID, "restored-db").get(); + assertThat(client.listBackupOperations(INSTANCE_ID).iterateAll()).hasSize(2); + } + + @Test + public void instanceListBackupOperations() + throws SpannerException, InterruptedException, ExecutionException { + Instance instance = + spanner + .getInstanceAdminClient() + .newInstanceBuilder(InstanceId.of(PROJECT_ID, INSTANCE_ID)) + .build(); + assertThat(instance.listBackupOperations().iterateAll()).hasSize(1); + instance + .getDatabase(DB_ID) + .backup( + client + .newBackupBuilder(BackupId.of(PROJECT_ID, INSTANCE_ID, "other-backup")) + .setExpireTime(after7Days()) + .build()) + .get(); + assertThat(instance.listBackupOperations().iterateAll()).hasSize(2); + client + .newBackupBuilder(BackupId.of(PROJECT_ID, INSTANCE_ID, BCK_ID)) + .build() + .restore(DatabaseId.of(PROJECT_ID, INSTANCE_ID, "restored-db")) + .get(); + assertThat(instance.listBackupOperations().iterateAll()).hasSize(2); + } + + @Test + public void instanceListBackupOperationsWithProgress() throws InvalidProtocolBufferException { + Instance instance = + spanner + .getInstanceAdminClient() + .newInstanceBuilder(InstanceId.of(PROJECT_ID, INSTANCE_ID)) + .build(); + String database = String.format("%s/databases/%s", TEST_PARENT, DB_ID); + String filter = + String.format( + "(metadata.database:%s) AND " + + "(metadata.@type:type.googleapis.com/" + + "google.spanner.admin.database.v1.CreateBackupMetadata)", + database); + Page operations = instance.listBackupOperations(Options.filter(filter)); + for (Operation op : operations.iterateAll()) { + CreateBackupMetadata metadata = op.getMetadata().unpack(CreateBackupMetadata.class); + String progress = + String.format( + "Backup %s on database %s pending: %d%% complete", + metadata.getName(), + metadata.getDatabase(), + metadata.getProgress().getProgressPercent()); + assertThat(progress.contains("100%")); + } + } + + @Test + public void backupListBackupOperations() + throws SpannerException, InterruptedException, ExecutionException { + Backup backup = client.newBackupBuilder(BackupId.of(PROJECT_ID, INSTANCE_ID, BCK_ID)).build(); + mockDatabaseAdmin.addFilterMatches("name:backups/" + BCK_ID, createBackupOperation.getName()); + assertThat(backup.listBackupOperations().iterateAll()).hasSize(1); + client.createBackup(INSTANCE_ID, "other-backup", DB_ID, after7Days()).get(); + assertThat(backup.listBackupOperations().iterateAll()).hasSize(1); + } + @Test public void getAndSetIAMPolicy() { Policy policy = client.getDatabaseIAMPolicy(INSTANCE_ID, DB_ID); @@ -128,9 +723,41 @@ public void testDatabaseIAMPermissions() { assertThat(permissions).containsExactly("spanner.databases.select"); } + private Timestamp after7Days() { + return Timestamp.ofTimeMicroseconds( + TimeUnit.MICROSECONDS.convert(System.currentTimeMillis(), TimeUnit.MILLISECONDS) + + TimeUnit.MICROSECONDS.convert(7, TimeUnit.DAYS)); + } + + private Timestamp after14Days() { + return Timestamp.ofTimeMicroseconds( + TimeUnit.MICROSECONDS.convert(System.currentTimeMillis(), TimeUnit.MILLISECONDS) + + TimeUnit.MICROSECONDS.convert(14, TimeUnit.DAYS)); + } + private void createTestDatabase() { try { - client.createDatabase(INSTANCE_ID, DB_ID, INITIAL_STATEMENTS).get(); + createDatabaseOperation = client.createDatabase(INSTANCE_ID, DB_ID, INITIAL_STATEMENTS); + createDatabaseOperation.get(); + } catch (InterruptedException | ExecutionException e) { + throw SpannerExceptionFactory.newSpannerException(e); + } + } + + private void createTestBackup() { + try { + createBackupOperation = client.createBackup(INSTANCE_ID, BCK_ID, DB_ID, after7Days()); + createBackupOperation.get(); + } catch (InterruptedException | ExecutionException e) { + throw SpannerExceptionFactory.newSpannerException(e); + } + } + + private void restoreTestBackup() { + try { + restoreDatabaseOperation = + client.restoreDatabase(INSTANCE_ID, BCK_ID, INSTANCE_ID, RESTORED_ID); + restoreDatabaseOperation.get(); } catch (InterruptedException | ExecutionException e) { throw SpannerExceptionFactory.newSpannerException(e); } diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseTest.java index c4e2e5316c..14b93d7413 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseTest.java @@ -18,11 +18,13 @@ import static com.google.common.truth.Truth.assertThat; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import static org.mockito.MockitoAnnotations.initMocks; import com.google.cloud.Identity; import com.google.cloud.Policy; import com.google.cloud.Role; +import com.google.cloud.Timestamp; import com.google.cloud.spanner.DatabaseInfo.State; import java.util.Arrays; import org.junit.Before; @@ -32,6 +34,9 @@ import org.junit.runner.RunWith; import org.junit.runners.JUnit4; import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; /** Unit tests for {@link com.google.cloud.spanner.Database}. */ @RunWith(JUnit4.class) @@ -45,6 +50,34 @@ public class DatabaseTest { @Before public void setUp() { initMocks(this); + when(dbClient.newBackupBuilder(Mockito.any(BackupId.class))) + .thenAnswer( + new Answer() { + @Override + public Backup.Builder answer(InvocationOnMock invocation) throws Throwable { + return new Backup.Builder(dbClient, (BackupId) invocation.getArguments()[0]); + } + }); + } + + @Test + public void backup() { + Timestamp expireTime = Timestamp.now(); + Database db = createDatabase(); + db.backup( + dbClient + .newBackupBuilder(BackupId.of("test-project", "test-instance", "test-backup")) + .setExpireTime(expireTime) + .build()); + verify(dbClient).createBackup("test-instance", "test-backup", "database-1", expireTime); + } + + @Test + public void listDatabaseOperations() { + Database db = createDatabase(); + db.listDatabaseOperations(); + verify(dbClient) + .listDatabaseOperations("test-instance", Options.filter("name:databases/database-1")); } @Test diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/GceTestEnvConfig.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/GceTestEnvConfig.java index eaea833491..e5c0959b58 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/GceTestEnvConfig.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/GceTestEnvConfig.java @@ -51,7 +51,8 @@ public GceTestEnvConfig() { double errorProbability = Double.parseDouble(System.getProperty(GCE_STREAM_BROKEN_PROBABILITY, "0.0")); checkState(errorProbability <= 1.0); - SpannerOptions.Builder builder = SpannerOptions.newBuilder(); + SpannerOptions.Builder builder = + SpannerOptions.newBuilder().setAutoThrottleAdministrativeRequests(); if (!projectId.isEmpty()) { builder.setProjectId(projectId); } diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/InstanceTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/InstanceTest.java index de0a3a564e..90f0b7630f 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/InstanceTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/InstanceTest.java @@ -109,6 +109,38 @@ public void equality() { tester.testEquals(); } + @Test + public void listDatabases() { + InstanceId id = new InstanceId("test-project", "test-instance"); + Instance instance = new Instance.Builder(instanceClient, dbClient, id).build(); + instance.listDatabases(); + verify(dbClient).listDatabases("test-instance"); + } + + @Test + public void listBackups() { + InstanceId id = new InstanceId("test-project", "test-instance"); + Instance instance = new Instance.Builder(instanceClient, dbClient, id).build(); + instance.listBackups(); + verify(dbClient).listBackups("test-instance"); + } + + @Test + public void listDatabaseOperations() { + InstanceId id = new InstanceId("test-project", "test-instance"); + Instance instance = new Instance.Builder(instanceClient, dbClient, id).build(); + instance.listDatabaseOperations(); + verify(dbClient).listDatabaseOperations("test-instance"); + } + + @Test + public void listBackupOperations() { + InstanceId id = new InstanceId("test-project", "test-instance"); + Instance instance = new Instance.Builder(instanceClient, dbClient, id).build(); + instance.listBackupOperations(); + verify(dbClient).listBackupOperations("test-instance"); + } + @Test public void getIAMPolicy() { InstanceId id = new InstanceId("test-project", "test-instance"); diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/MockDatabaseAdminServiceImpl.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/MockDatabaseAdminServiceImpl.java index de4681efa0..c198bc4f56 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/MockDatabaseAdminServiceImpl.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/MockDatabaseAdminServiceImpl.java @@ -17,6 +17,7 @@ package com.google.cloud.spanner; import com.google.api.gax.grpc.testing.MockGrpcService; +import com.google.common.base.Strings; import com.google.iam.v1.GetIamPolicyRequest; import com.google.iam.v1.Policy; import com.google.iam.v1.SetIamPolicyRequest; @@ -27,27 +28,51 @@ import com.google.protobuf.Any; import com.google.protobuf.Empty; import com.google.protobuf.Timestamp; +import com.google.rpc.Code; +import com.google.spanner.admin.database.v1.Backup; +import com.google.spanner.admin.database.v1.BackupInfo; +import com.google.spanner.admin.database.v1.CreateBackupMetadata; +import com.google.spanner.admin.database.v1.CreateBackupRequest; import com.google.spanner.admin.database.v1.CreateDatabaseMetadata; import com.google.spanner.admin.database.v1.CreateDatabaseRequest; import com.google.spanner.admin.database.v1.Database; import com.google.spanner.admin.database.v1.Database.State; import com.google.spanner.admin.database.v1.DatabaseAdminGrpc.DatabaseAdminImplBase; +import com.google.spanner.admin.database.v1.DeleteBackupRequest; import com.google.spanner.admin.database.v1.DropDatabaseRequest; +import com.google.spanner.admin.database.v1.GetBackupRequest; import com.google.spanner.admin.database.v1.GetDatabaseDdlRequest; import com.google.spanner.admin.database.v1.GetDatabaseDdlResponse; import com.google.spanner.admin.database.v1.GetDatabaseRequest; +import com.google.spanner.admin.database.v1.ListBackupOperationsRequest; +import com.google.spanner.admin.database.v1.ListBackupOperationsResponse; +import com.google.spanner.admin.database.v1.ListBackupsRequest; +import com.google.spanner.admin.database.v1.ListBackupsResponse; +import com.google.spanner.admin.database.v1.ListDatabaseOperationsRequest; +import com.google.spanner.admin.database.v1.ListDatabaseOperationsResponse; import com.google.spanner.admin.database.v1.ListDatabasesRequest; import com.google.spanner.admin.database.v1.ListDatabasesResponse; +import com.google.spanner.admin.database.v1.OperationProgress; +import com.google.spanner.admin.database.v1.OptimizeRestoredDatabaseMetadata; +import com.google.spanner.admin.database.v1.RestoreDatabaseMetadata; +import com.google.spanner.admin.database.v1.RestoreDatabaseRequest; +import com.google.spanner.admin.database.v1.RestoreInfo; +import com.google.spanner.admin.database.v1.RestoreSourceType; +import com.google.spanner.admin.database.v1.UpdateBackupRequest; import com.google.spanner.admin.database.v1.UpdateDatabaseDdlMetadata; import com.google.spanner.admin.database.v1.UpdateDatabaseDdlRequest; import io.grpc.ServerServiceDefinition; import io.grpc.Status; import io.grpc.stub.StreamObserver; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map.Entry; import java.util.Queue; +import java.util.Random; +import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; @@ -57,16 +82,93 @@ public class MockDatabaseAdminServiceImpl extends DatabaseAdminImplBase implemen private static final class MockDatabase { private final String name; private State state; + private final Timestamp createTime; private final List ddl = new ArrayList<>(); + private final RestoreInfo restoreInfo; - private MockDatabase(String name, List ddl) { + private MockDatabase(String name, List ddl, RestoreInfo restoreInfo) { this.name = name; this.state = State.CREATING; + this.createTime = + Timestamp.newBuilder().setSeconds(System.currentTimeMillis() / 1000L).build(); this.ddl.addAll(ddl); + this.restoreInfo = restoreInfo; } private Database toProto() { - return Database.newBuilder().setName(name).setState(state).build(); + return Database.newBuilder() + .setCreateTime(createTime) + .setName(name) + .setRestoreInfo(restoreInfo == null ? RestoreInfo.getDefaultInstance() : restoreInfo) + .setState(state) + .build(); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof MockDatabase)) { + return false; + } + return ((MockDatabase) o).name.equals(this.name); + } + + @Override + public int hashCode() { + return name.hashCode(); + } + } + + static final class MockBackup { + private final String name; + private Backup.State state; + private final Timestamp createTime; + private final String database; + private final List ddl = new ArrayList<>(); + private final List referencingDatabases = new ArrayList<>(); + private final long size; + private Timestamp expireTime; + + private MockBackup(String name, Backup backup, MockDatabase database) { + this.name = name; + this.state = Backup.State.CREATING; + this.createTime = + Timestamp.newBuilder().setSeconds(System.currentTimeMillis() / 1000L).build(); + this.database = database.name; + this.ddl.addAll(database.ddl); + this.size = RND.nextInt(Integer.MAX_VALUE); + this.expireTime = backup.getExpireTime(); + } + + private Backup toProto() { + return Backup.newBuilder() + .setCreateTime(createTime) + .setDatabase(database) + .setExpireTime(expireTime) + .setName(name) + .setSizeBytes(size) + .setState(state) + .addAllReferencingDatabases(referencingDatabases) + .build(); + } + + private BackupInfo toBackupInfo() { + return BackupInfo.newBuilder() + .setBackup(name) + .setCreateTime(createTime) + .setSourceDatabase(database) + .build(); + } + + public String getName() { + return name; + } + + public String getDatabase() { + return database; + } + + public Timestamp getExpireTime() { + return expireTime; } @Override @@ -135,11 +237,205 @@ public Empty call() throws Exception { } } + private final class CreateBackupCallable implements Callable { + private final String operationName; + private final String name; + + private CreateBackupCallable(String operationName, String name) { + this.operationName = operationName; + this.name = name; + } + + @Override + public Backup call() throws Exception { + MockBackup backup = backups.get(name); + Backup proto = backup.toProto(); + Operation operation = operations.get(operationName); + for (int progress = 1; progress <= 100; progress++) { + operation = operations.get(operationName); + long sleep = createBackupExecutionTime / 100; + if (progress == 100) { + sleep += createBackupExecutionTime % 100; + } + Thread.sleep(sleep); + if (operation != null) { + CreateBackupMetadata metadata = + operation.getMetadata().unpack(CreateBackupMetadata.class); + metadata = + metadata + .toBuilder() + .setProgress( + metadata.getProgress().toBuilder().setProgressPercent(progress).build()) + .build(); + operations.update( + operation + .toBuilder() + .setMetadata(Any.pack(metadata)) + .setResponse(Any.pack(proto)) + .build()); + } + } + backup.state = Backup.State.READY; + proto = backup.toProto(); + if (operation != null) { + CreateBackupMetadata metadata = operation.getMetadata().unpack(CreateBackupMetadata.class); + metadata = + metadata + .toBuilder() + .setProgress( + metadata + .getProgress() + .toBuilder() + .setProgressPercent(100) + .setEndTime(currentTime()) + .build()) + .build(); + operations.update( + operation + .toBuilder() + .setDone(true) + .setMetadata(Any.pack(metadata)) + .setResponse(Any.pack(proto)) + .build()); + } + return proto; + } + } + + private final class RestoreDatabaseCallable implements Callable { + private final String operationName; + private final String name; + + private RestoreDatabaseCallable(String operationName, String name) { + this.operationName = operationName; + this.name = name; + } + + @Override + public Database call() throws Exception { + MockDatabase db = databases.get(name); + db.state = State.READY_OPTIMIZING; + Database proto = db.toProto(); + Operation operation = operations.get(operationName); + for (int progress = 1; progress <= 100; progress++) { + long sleep = restoreDatabaseExecutionTime / 100; + if (progress == 100) { + sleep += restoreDatabaseExecutionTime % 100; + } + Thread.sleep(sleep); + if (operation != null) { + RestoreDatabaseMetadata metadata = + operation.getMetadata().unpack(RestoreDatabaseMetadata.class); + metadata = + metadata + .toBuilder() + .setProgress( + metadata.getProgress().toBuilder().setProgressPercent(progress).build()) + .build(); + operations.update( + operation + .toBuilder() + .setMetadata(Any.pack(metadata)) + .setResponse(Any.pack(proto)) + .build()); + } + } + db.state = State.READY_OPTIMIZING; + proto = db.toProto(); + if (operation != null) { + RestoreDatabaseMetadata metadata = + operation.getMetadata().unpack(RestoreDatabaseMetadata.class); + metadata = + metadata + .toBuilder() + .setProgress( + metadata + .getProgress() + .toBuilder() + .setEndTime(currentTime()) + .setProgressPercent(100) + .build()) + .build(); + operations.update( + operation + .toBuilder() + .setDone(true) + .setMetadata(Any.pack(metadata)) + .setResponse(Any.pack(proto)) + .build()); + } + return proto; + } + } + + private final class OptimizeDatabaseCallable implements Callable { + private final String operationName; + private final String restoreOperationName; + private final String name; + + private OptimizeDatabaseCallable( + String operationName, String restoreOperationName, String name) { + this.operationName = operationName; + this.restoreOperationName = restoreOperationName; + this.name = name; + } + + @Override + public Database call() throws Exception { + MockDatabase db = databases.get(name); + Operation operation = operations.get(operationName); + try { + // Wait until the restore operation has finished. + Operation restoreOperation = operations.get(restoreOperationName); + while (!restoreOperation.getDone()) { + Thread.sleep(10L); + restoreOperation = operations.get(restoreOperationName); + } + Thread.sleep(optimizeDatabaseExecutionTime); + db.state = State.READY; + Database proto = db.toProto(); + if (operation != null) { + operations.update( + operation.toBuilder().setDone(true).setResponse(Any.pack(proto)).build()); + } + return proto; + } catch (Exception e) { + if (operation != null) { + Database proto = db.toProto(); + operations.update( + operation + .toBuilder() + .setDone(true) + .setError(fromException(e)) + .setResponse(Any.pack(proto)) + .build()); + } + throw e; + } + } + } + + private com.google.rpc.Status fromException(Exception e) { + int code = Code.UNKNOWN_VALUE; + if (e instanceof InterruptedException) { + code = Code.CANCELLED_VALUE; + } + return com.google.rpc.Status.newBuilder().setCode(code).setMessage(e.getMessage()).build(); + } + private ConcurrentMap policies = new ConcurrentHashMap<>(); + private static final String EXPIRE_TIME_MASK = "expire_time"; + private static final Random RND = new Random(); private final Queue exceptions = new ConcurrentLinkedQueue<>(); private final ConcurrentMap databases = new ConcurrentHashMap<>(); + private final ConcurrentMap backups = new ConcurrentHashMap<>(); + private final ConcurrentMap> filterMatches = new ConcurrentHashMap<>(); private final MockOperationsServiceImpl operations; + private long createBackupExecutionTime; + private long restoreDatabaseExecutionTime; + private long optimizeDatabaseExecutionTime; + public MockDatabaseAdminServiceImpl(MockOperationsServiceImpl operations) { this.operations = operations; } @@ -152,7 +448,7 @@ public void createDatabase( id = id.substring(1, id.length() - 1); } String name = String.format("%s/databases/%s", request.getParent(), id); - MockDatabase db = new MockDatabase(name, request.getExtraStatementsList()); + MockDatabase db = new MockDatabase(name, request.getExtraStatementsList(), null); if (databases.putIfAbsent(name, db) == null) { CreateDatabaseMetadata metadata = CreateDatabaseMetadata.newBuilder().setDatabase(name).build(); @@ -191,7 +487,11 @@ public void getDatabase(GetDatabaseRequest request, StreamObserver res MockDatabase db = databases.get(request.getName()); if (db != null) { responseObserver.onNext( - Database.newBuilder().setName(request.getName()).setState(State.READY).build()); + Database.newBuilder() + .setName(request.getName()) + .setCreateTime(db.createTime) + .setState(State.READY) + .build()); responseObserver.onCompleted(); } else { responseObserver.onError(Status.NOT_FOUND.asRuntimeException()); @@ -215,12 +515,50 @@ public void listDatabases( ListDatabasesRequest request, StreamObserver responseObserver) { List dbs = new ArrayList<>(databases.size()); for (Entry entry : databases.entrySet()) { - dbs.add(Database.newBuilder().setName(entry.getKey()).setState(State.READY).build()); + dbs.add( + Database.newBuilder() + .setName(entry.getKey()) + .setCreateTime(entry.getValue().createTime) + .setState(State.READY) + .build()); } responseObserver.onNext(ListDatabasesResponse.newBuilder().addAllDatabases(dbs).build()); responseObserver.onCompleted(); } + @Override + public void listDatabaseOperations( + ListDatabaseOperationsRequest request, + StreamObserver responseObserver) { + ListDatabaseOperationsResponse.Builder builder = ListDatabaseOperationsResponse.newBuilder(); + try { + for (Operation op : operations.iterable()) { + if (op.getName().matches(".*?/databases\\/.*?/operations/.*?") + && op.getName().startsWith(request.getParent())) { + if (matchesFilter(op, request.getFilter())) { + builder.addOperations(op); + } + } + } + responseObserver.onNext(builder.build()); + responseObserver.onCompleted(); + } catch (Exception e) { + responseObserver.onError(e); + } + } + + private boolean matchesFilter(Object obj, String filter) throws Exception { + if (!Strings.isNullOrEmpty(filter)) { + Set matches = filterMatches.get(filter); + if (matches != null) { + String name = (String) obj.getClass().getMethod("getName").invoke(obj); + return matches.contains(name); + } + return false; + } + return true; + } + @Override public void updateDatabaseDdl( UpdateDatabaseDdlRequest request, StreamObserver responseObserver) { @@ -247,6 +585,214 @@ public void updateDatabaseDdl( } } + @Override + public void createBackup( + CreateBackupRequest request, StreamObserver responseObserver) { + String name = String.format("%s/backups/%s", request.getParent(), request.getBackupId()); + MockDatabase db = databases.get(request.getBackup().getDatabase()); + if (db == null) { + responseObserver.onError( + Status.NOT_FOUND + .withDescription( + String.format( + "Database with name %s not found", request.getBackup().getDatabase())) + .asRuntimeException()); + return; + } + MockBackup bck = new MockBackup(name, request.getBackup(), db); + if (backups.putIfAbsent(name, bck) == null) { + CreateBackupMetadata metadata = + CreateBackupMetadata.newBuilder() + .setName(name) + .setDatabase(bck.database) + .setProgress( + OperationProgress.newBuilder() + .setStartTime( + Timestamp.newBuilder() + .setSeconds(System.currentTimeMillis() / 1000L) + .build()) + .setProgressPercent(0)) + .build(); + Operation operation = + Operation.newBuilder() + .setMetadata(Any.pack(metadata)) + .setResponse(Any.pack(bck.toProto())) + .setName(operations.generateOperationName(name)) + .build(); + operations.addOperation(operation, new CreateBackupCallable(operation.getName(), name)); + responseObserver.onNext(operation); + responseObserver.onCompleted(); + } else { + responseObserver.onError( + Status.ALREADY_EXISTS + .withDescription(String.format("Backup with name %s already exists", name)) + .asRuntimeException()); + } + } + + @Override + public void deleteBackup(DeleteBackupRequest request, StreamObserver responseObserver) { + MockBackup bck = backups.get(request.getName()); + if (backups.remove(request.getName(), bck)) { + responseObserver.onNext(Empty.getDefaultInstance()); + responseObserver.onCompleted(); + } else { + responseObserver.onError(Status.NOT_FOUND.asRuntimeException()); + } + } + + @Override + public void getBackup(GetBackupRequest request, StreamObserver responseObserver) { + MockBackup bck = backups.get(request.getName()); + if (bck != null) { + responseObserver.onNext( + Backup.newBuilder() + .setName(request.getName()) + .setCreateTime(bck.createTime) + .setDatabase(bck.database) + .setExpireTime(bck.expireTime) + .setSizeBytes(bck.size) + .setState(Backup.State.READY) + .build()); + responseObserver.onCompleted(); + } else { + responseObserver.onError(Status.NOT_FOUND.asRuntimeException()); + } + } + + @Override + public void listBackups( + ListBackupsRequest request, StreamObserver responseObserver) { + List bcks = new ArrayList<>(backups.size()); + try { + for (Entry entry : backups.entrySet()) { + if (matchesFilter(entry.getValue(), request.getFilter())) { + bcks.add( + Backup.newBuilder() + .setName(entry.getKey()) + .setCreateTime(entry.getValue().createTime) + .setDatabase(entry.getValue().database) + .setExpireTime(entry.getValue().expireTime) + .setSizeBytes(entry.getValue().size) + .setState(Backup.State.READY) + .build()); + } + } + responseObserver.onNext(ListBackupsResponse.newBuilder().addAllBackups(bcks).build()); + responseObserver.onCompleted(); + } catch (Exception e) { + responseObserver.onError(e); + } + } + + @Override + public void listBackupOperations( + ListBackupOperationsRequest request, + StreamObserver responseObserver) { + ListBackupOperationsResponse.Builder builder = ListBackupOperationsResponse.newBuilder(); + try { + for (Operation op : operations.iterable()) { + if (op.getName().matches(".*?/backups/.*?/operations/.*?") + && op.getName().startsWith(request.getParent())) { + if (matchesFilter(op, request.getFilter())) { + builder.addOperations(op); + } + } + } + responseObserver.onNext(builder.build()); + responseObserver.onCompleted(); + } catch (Exception e) { + responseObserver.onError(e); + } + } + + @Override + public void updateBackup(UpdateBackupRequest request, StreamObserver responseObserver) { + MockBackup bck = backups.get(request.getBackup().getName()); + if (bck != null) { + if (request.getUpdateMask().getPathsList().contains(EXPIRE_TIME_MASK)) { + bck.expireTime = request.getBackup().getExpireTime(); + } + responseObserver.onNext( + Backup.newBuilder() + .setName(bck.name) + .setCreateTime(bck.createTime) + .setDatabase(bck.database) + .setExpireTime(bck.expireTime) + .setSizeBytes(bck.size) + .setState(Backup.State.READY) + .build()); + responseObserver.onCompleted(); + } else { + responseObserver.onError(Status.NOT_FOUND.asRuntimeException()); + } + } + + @Override + public void restoreDatabase( + RestoreDatabaseRequest request, StreamObserver responseObserver) { + MockBackup bck = backups.get(request.getBackup()); + if (bck != null) { + String name = String.format("%s/databases/%s", request.getParent(), request.getDatabaseId()); + MockDatabase db = + new MockDatabase( + name, + bck.ddl, + RestoreInfo.newBuilder() + .setBackupInfo(bck.toBackupInfo()) + .setSourceType(RestoreSourceType.BACKUP) + .build()); + if (databases.putIfAbsent(name, db) == null) { + bck.referencingDatabases.add(db.name); + Operation optimizeOperation = + Operation.newBuilder() + .setDone(false) + .setName(operations.generateOperationName(name)) + .setMetadata( + Any.pack( + OptimizeRestoredDatabaseMetadata.newBuilder() + .setName(name) + .setProgress( + OperationProgress.newBuilder() + .setStartTime(currentTime()) + .setProgressPercent(0) + .build()) + .build())) + .setResponse(Any.pack(db.toProto())) + .build(); + RestoreDatabaseMetadata metadata = + RestoreDatabaseMetadata.newBuilder() + .setBackupInfo(bck.toBackupInfo()) + .setName(name) + .setProgress( + OperationProgress.newBuilder() + .setStartTime(currentTime()) + .setProgressPercent(0) + .build()) + .setOptimizeDatabaseOperationName(optimizeOperation.getName()) + .setSourceType(RestoreSourceType.BACKUP) + .build(); + Operation operation = + Operation.newBuilder() + .setMetadata(Any.pack(metadata)) + .setResponse(Any.pack(db.toProto())) + .setDone(false) + .setName(operations.generateOperationName(name)) + .build(); + operations.addOperation(operation, new RestoreDatabaseCallable(operation.getName(), name)); + operations.addOperation( + optimizeOperation, + new OptimizeDatabaseCallable(optimizeOperation.getName(), operation.getName(), name)); + responseObserver.onNext(operation); + responseObserver.onCompleted(); + } else { + responseObserver.onError(Status.ALREADY_EXISTS.asRuntimeException()); + } + } else { + responseObserver.onError(Status.NOT_FOUND.asRuntimeException()); + } + } + @Override public void getIamPolicy(GetIamPolicyRequest request, StreamObserver responseObserver) { Policy policy = policies.get(request.getResource()); @@ -292,6 +838,19 @@ public void addException(Exception exception) { exceptions.add(exception); } + public void addFilterMatches(String filter, String... names) { + Set matches = filterMatches.get(filter); + if (matches == null) { + matches = new HashSet<>(); + filterMatches.put(filter, matches); + } + matches.addAll(Arrays.asList(names)); + } + + public void clearFilterMatches() { + filterMatches.clear(); + } + @Override public ServerServiceDefinition getServiceDefinition() { return bindService(); @@ -302,6 +861,8 @@ public void reset() { exceptions.clear(); policies.clear(); databases.clear(); + backups.clear(); + filterMatches.clear(); } private Timestamp currentTime() { diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/MockDatabaseAdminServiceImplTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/MockDatabaseAdminServiceImplTest.java index b143418859..4364bfe26a 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/MockDatabaseAdminServiceImplTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/MockDatabaseAdminServiceImplTest.java @@ -32,15 +32,30 @@ import com.google.api.gax.rpc.StatusCode.Code; import com.google.cloud.spanner.OperationFutureUtil.FakeStatusCode; import com.google.cloud.spanner.admin.database.v1.DatabaseAdminClient; +import com.google.cloud.spanner.admin.database.v1.DatabaseAdminClient.ListBackupOperationsPagedResponse; +import com.google.cloud.spanner.admin.database.v1.DatabaseAdminClient.ListBackupsPagedResponse; +import com.google.cloud.spanner.admin.database.v1.DatabaseAdminClient.ListDatabaseOperationsPagedResponse; import com.google.cloud.spanner.admin.database.v1.DatabaseAdminClient.ListDatabasesPagedResponse; import com.google.cloud.spanner.admin.database.v1.DatabaseAdminSettings; +import com.google.iam.v1.Binding; +import com.google.iam.v1.GetIamPolicyRequest; +import com.google.iam.v1.Policy; +import com.google.iam.v1.SetIamPolicyRequest; import com.google.iam.v1.TestIamPermissionsRequest; import com.google.iam.v1.TestIamPermissionsResponse; +import com.google.longrunning.Operation; import com.google.protobuf.Empty; +import com.google.protobuf.FieldMask; +import com.google.protobuf.Timestamp; +import com.google.spanner.admin.database.v1.Backup; +import com.google.spanner.admin.database.v1.CreateBackupMetadata; +import com.google.spanner.admin.database.v1.CreateBackupRequest; import com.google.spanner.admin.database.v1.CreateDatabaseMetadata; import com.google.spanner.admin.database.v1.CreateDatabaseRequest; import com.google.spanner.admin.database.v1.Database; import com.google.spanner.admin.database.v1.GetDatabaseDdlResponse; +import com.google.spanner.admin.database.v1.RestoreDatabaseMetadata; +import com.google.spanner.admin.database.v1.RestoreDatabaseRequest; import com.google.spanner.admin.database.v1.UpdateDatabaseDdlMetadata; import com.google.spanner.admin.database.v1.UpdateDatabaseDdlRequest; import java.io.IOException; @@ -48,6 +63,7 @@ import java.util.Arrays; import java.util.List; import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; import org.hamcrest.BaseMatcher; import org.hamcrest.Description; import org.junit.After; @@ -101,6 +117,7 @@ public void describeTo(Description description) { private static final String TEST_PARENT = "projects/my-project/instances/my-instance"; private static final String TEST_DB_NAME = String.format("%s/databases/test-db", TEST_PARENT); + private static final String TEST_BCK_NAME = String.format("%s/backups/test-bck", TEST_PARENT); private static MockOperationsServiceImpl mockOperations; private static MockDatabaseAdminServiceImpl mockDatabaseAdmin; private static MockServiceHelper serviceHelper; @@ -130,6 +147,20 @@ public void setUp() throws IOException { DatabaseAdminSettings.newBuilder() .setTransportChannelProvider(channelProvider) .setCredentialsProvider(NoCredentialsProvider.create()); + settingsBuilder + .createBackupOperationSettings() + .setPollingAlgorithm( + OperationTimedPollAlgorithm.create( + RetrySettings.newBuilder() + .setInitialRpcTimeout(Duration.ofMillis(20L)) + .setInitialRetryDelay(Duration.ofMillis(10L)) + .setMaxRetryDelay(Duration.ofMillis(150L)) + .setMaxRpcTimeout(Duration.ofMillis(150L)) + .setMaxAttempts(10) + .setTotalTimeout(Duration.ofMillis(5000L)) + .setRetryDelayMultiplier(1.3) + .setRpcTimeoutMultiplier(1.3) + .build())); settingsBuilder .createDatabaseOperationSettings() .setPollingAlgorithm( @@ -144,6 +175,20 @@ public void setUp() throws IOException { .setRetryDelayMultiplier(1.3) .setRpcTimeoutMultiplier(1.3) .build())); + settingsBuilder + .restoreDatabaseOperationSettings() + .setPollingAlgorithm( + OperationTimedPollAlgorithm.create( + RetrySettings.newBuilder() + .setInitialRpcTimeout(Duration.ofMillis(20L)) + .setInitialRetryDelay(Duration.ofMillis(10L)) + .setMaxRetryDelay(Duration.ofMillis(150L)) + .setMaxRpcTimeout(Duration.ofMillis(150L)) + .setMaxAttempts(10) + .setTotalTimeout(Duration.ofMillis(5000L)) + .setRetryDelayMultiplier(1.3) + .setRpcTimeoutMultiplier(1.3) + .build())); client = DatabaseAdminClient.create(settingsBuilder.build()); } @@ -239,6 +284,17 @@ public void listDatabases() { assertThat(databases).containsExactly(TEST_DB_NAME); } + @Test + public void listDatabaseOperations() { + createTestDb(); + ListDatabaseOperationsPagedResponse response = client.listDatabaseOperations(TEST_DB_NAME); + List operations = new ArrayList<>(); + for (Operation op : response.iterateAll()) { + operations.add(op); + } + assertThat(operations).hasSize(1); + } + @Test public void updateDatabaseDdl() throws InterruptedException, ExecutionException { createTestDb(); @@ -256,6 +312,174 @@ public void updateDatabaseDdl() throws InterruptedException, ExecutionException "CREATE TABLE FOO", "CREATE TABLE BAR", "CREATE TABLE BAZ", "DROP TABLE FOO"); } + private Backup createTestBackup() { + CreateBackupRequest request = + CreateBackupRequest.newBuilder() + .setBackupId("test-bck") + .setBackup( + Backup.newBuilder() + .setDatabase(TEST_DB_NAME) + .setExpireTime( + Timestamp.newBuilder() + .setSeconds( + System.currentTimeMillis() * 1000L + + TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS))) + .build()) + .setParent(TEST_PARENT) + .build(); + OperationFuture op = + client.createBackupOperationCallable().futureCall(request); + try { + return op.get(); + } catch (ExecutionException e) { + if (e.getCause() != null && e.getCause() instanceof RuntimeException) { + throw (RuntimeException) e.getCause(); + } + throw new RuntimeException(e); + } catch (InterruptedException e) { + throw new CancelledException(e, FakeStatusCode.of(Code.CANCELLED), false); + } + } + + @Test + public void createBackup() { + createTestDb(); + Backup bck = createTestBackup(); + assertThat(bck.getName()).isEqualTo(TEST_BCK_NAME); + } + + @Test + public void createBackupAlreadyExists() { + createTestDb(); + createTestBackup(); + exception.expect(ApiExceptionMatcher.forCode(StatusCode.Code.ALREADY_EXISTS)); + createTestBackup(); + } + + @Test + public void createBackupDatabaseDoesNotExist() { + exception.expect(ApiExceptionMatcher.forCode(StatusCode.Code.NOT_FOUND)); + createTestBackup(); + } + + @Test + public void deleteBackup() { + createTestDb(); + createTestBackup(); + Backup bck = client.getBackup(TEST_BCK_NAME); + assertThat(bck.getName()).isEqualTo(TEST_BCK_NAME); + client.deleteBackup(TEST_BCK_NAME); + exception.expect(ApiExceptionMatcher.forCode(StatusCode.Code.NOT_FOUND)); + client.getBackup(TEST_BCK_NAME); + } + + @Test + public void deleteBackupNotFound() { + exception.expect(ApiExceptionMatcher.forCode(StatusCode.Code.NOT_FOUND)); + client.deleteBackup(TEST_BCK_NAME); + } + + @Test + public void getBackup() { + createTestDb(); + createTestBackup(); + Backup bck = client.getBackup(TEST_BCK_NAME); + assertThat(bck.getName()).isEqualTo(TEST_BCK_NAME); + } + + @Test + public void getBackupNotFound() { + exception.expect(ApiExceptionMatcher.forCode(StatusCode.Code.NOT_FOUND)); + client.getBackup(TEST_BCK_NAME); + } + + @Test + public void listBackups() { + createTestDb(); + createTestBackup(); + ListBackupsPagedResponse response = client.listBackups(TEST_PARENT); + List backups = new ArrayList<>(); + for (Backup bck : response.iterateAll()) { + backups.add(bck.getName()); + } + assertThat(backups).containsExactly(TEST_BCK_NAME); + } + + @Test + public void listBackupOperations() { + createTestDb(); + createTestBackup(); + ListBackupOperationsPagedResponse response = client.listBackupOperations(TEST_BCK_NAME); + List operations = new ArrayList<>(); + for (Operation op : response.iterateAll()) { + operations.add(op); + } + assertThat(operations).hasSize(1); + } + + @Test + public void updateBackup() { + createTestDb(); + Backup backup = createTestBackup(); + Backup toBeUpdated = + backup.toBuilder().setExpireTime(Timestamp.newBuilder().setSeconds(1000L).build()).build(); + Backup updated = + client.updateBackup(toBeUpdated, FieldMask.newBuilder().addPaths("expire_time").build()); + assertThat(updated.getExpireTime()).isEqualTo(toBeUpdated.getExpireTime()); + assertThat(backup.getExpireTime()).isNotEqualTo(updated.getExpireTime()); + } + + @Test + public void restoreDatabase() throws InterruptedException, ExecutionException { + createTestDb(); + createTestBackup(); + RestoreDatabaseRequest request = + RestoreDatabaseRequest.newBuilder() + .setBackup(TEST_BCK_NAME) + .setDatabaseId("restored-db") + .setParent(TEST_PARENT) + .build(); + OperationFuture op = + client.restoreDatabaseOperationCallable().futureCall(request); + Database restoredDb = op.get(); + assertThat(restoredDb.getName()) + .isEqualTo(String.format("%s/databases/%s", TEST_PARENT, "restored-db")); + assertThat(restoredDb.getRestoreInfo().getBackupInfo().getBackup()).isEqualTo(TEST_BCK_NAME); + assertThat(restoredDb.getRestoreInfo().getBackupInfo().getSourceDatabase()) + .isEqualTo(TEST_DB_NAME); + } + + @Test + public void restoreDatabaseNotFound() throws InterruptedException, ExecutionException { + createTestDb(); + RestoreDatabaseRequest request = + RestoreDatabaseRequest.newBuilder() + .setBackup(TEST_BCK_NAME) + .setDatabaseId("restored-db") + .setParent(TEST_PARENT) + .build(); + OperationFuture op = + client.restoreDatabaseOperationCallable().futureCall(request); + exception.expect(ApiExceptionMatcher.forCode(StatusCode.Code.NOT_FOUND)); + op.get(); + } + + @Test + public void restoreDatabaseAlreadyExists() throws InterruptedException, ExecutionException { + createTestDb(); + createTestBackup(); + RestoreDatabaseRequest request = + RestoreDatabaseRequest.newBuilder() + .setBackup(TEST_BCK_NAME) + .setDatabaseId("test-db") + .setParent(TEST_PARENT) + .build(); + OperationFuture op = + client.restoreDatabaseOperationCallable().futureCall(request); + exception.expect(ApiExceptionMatcher.forCode(StatusCode.Code.ALREADY_EXISTS)); + op.get(); + } + @Test public void testIAMPolicy() { TestIamPermissionsResponse response = @@ -267,5 +491,30 @@ public void testIAMPolicy() { .build()); assertThat(response.getPermissionsList()) .containsExactly("spanner.databases.select", "spanner.databases.write"); + + GetIamPolicyRequest request = GetIamPolicyRequest.newBuilder().setResource(TEST_PARENT).build(); + Policy policy = client.getIamPolicy(request); + assertThat(policy).isNotNull(); + + Policy newPolicy = + Policy.newBuilder() + .addBindings( + Binding.newBuilder().setRole("roles/admin").addMembers("user:joe@example.com")) + .setEtag(policy.getEtag()) + .build(); + client.setIamPolicy( + SetIamPolicyRequest.newBuilder().setResource(TEST_PARENT).setPolicy(newPolicy).build()); + policy = client.getIamPolicy(TEST_PARENT); + assertThat(policy).isEqualTo(newPolicy); + + response = + client.testIamPermissions( + TestIamPermissionsRequest.newBuilder() + .setResource(TEST_PARENT) + .addPermissions("spanner.databases.select") + .addPermissions("spanner.databases.update") + .build()); + assertThat(response.getPermissionsList()) + .containsExactly("spanner.databases.select", "spanner.databases.update"); } } diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/MockOperationsServiceImpl.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/MockOperationsServiceImpl.java index b809358740..35d6bcdce3 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/MockOperationsServiceImpl.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/MockOperationsServiceImpl.java @@ -70,7 +70,10 @@ Operation get(String name) { } void update(Operation operation) { - operations.put(operation.getName(), operation); + Operation existing = operations.get(operation.getName()); + if (!existing.getDone()) { + operations.put(operation.getName(), operation); + } } Iterable iterable() { @@ -133,8 +136,21 @@ public void cancelOperation( Future fut = futures.get(request.getName()); if (op != null && fut != null) { if (!op.getDone()) { + operations.put( + request.getName(), + op.toBuilder() + .clearResponse() + .setDone(true) + .setError( + com.google.rpc.Status.newBuilder() + .setCode(Status.CANCELLED.getCode().value()) + .setMessage("Operation was cancelled") + .build()) + .build()); fut.cancel(true); } + responseObserver.onNext(Empty.getDefaultInstance()); + responseObserver.onCompleted(); } else { responseObserver.onError(Status.NOT_FOUND.asRuntimeException()); } diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/ParallelIntegrationTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/ParallelIntegrationTest.java new file mode 100644 index 0000000000..382f826c6a --- /dev/null +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/ParallelIntegrationTest.java @@ -0,0 +1,20 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed 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 com.google.cloud.spanner; + +/** Parallel Integration Test interface. */ +public interface ParallelIntegrationTest {} diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITBackupTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITBackupTest.java new file mode 100644 index 0000000000..f0f8434ed6 --- /dev/null +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITBackupTest.java @@ -0,0 +1,559 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed 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 com.google.cloud.spanner.it; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import com.google.api.gax.longrunning.OperationFuture; +import com.google.api.gax.paging.Page; +import com.google.cloud.Timestamp; +import com.google.cloud.spanner.Backup; +import com.google.cloud.spanner.BackupId; +import com.google.cloud.spanner.Database; +import com.google.cloud.spanner.DatabaseAdminClient; +import com.google.cloud.spanner.DatabaseClient; +import com.google.cloud.spanner.DatabaseId; +import com.google.cloud.spanner.ErrorCode; +import com.google.cloud.spanner.Instance; +import com.google.cloud.spanner.InstanceAdminClient; +import com.google.cloud.spanner.IntegrationTestEnv; +import com.google.cloud.spanner.Mutation; +import com.google.cloud.spanner.Options; +import com.google.cloud.spanner.ParallelIntegrationTest; +import com.google.cloud.spanner.SpannerException; +import com.google.cloud.spanner.testing.RemoteSpannerHelper; +import com.google.common.base.Predicate; +import com.google.common.collect.Iterables; +import com.google.longrunning.Operation; +import com.google.spanner.admin.database.v1.CreateBackupMetadata; +import com.google.spanner.admin.database.v1.CreateDatabaseMetadata; +import com.google.spanner.admin.database.v1.RestoreDatabaseMetadata; +import com.google.spanner.admin.database.v1.RestoreSourceType; +import io.grpc.Status; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Logger; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Integration tests creating, reading, updating and deleting backups. This test class combines + * several tests into one long test to reduce the total execution time. + */ +@Category(ParallelIntegrationTest.class) +@RunWith(JUnit4.class) +public class ITBackupTest { + private static final Logger logger = Logger.getLogger(ITBackupTest.class.getName()); + private static final String EXPECTED_OP_NAME_FORMAT = "%s/backups/%s/operations/"; + @ClassRule public static IntegrationTestEnv env = new IntegrationTestEnv(); + + @Rule public ExpectedException expectedException = ExpectedException.none(); + private DatabaseAdminClient dbAdminClient; + private InstanceAdminClient instanceAdminClient; + private Instance instance; + private RemoteSpannerHelper testHelper; + private final AtomicInteger backupSeq = new AtomicInteger(); + private List databases = new ArrayList<>(); + private List backups = new ArrayList<>(); + private final Random random = new Random(); + + @Before + public void setUp() throws Exception { + logger.info("Setting up tests"); + testHelper = env.getTestHelper(); + dbAdminClient = testHelper.getClient().getDatabaseAdminClient(); + instanceAdminClient = testHelper.getClient().getInstanceAdminClient(); + instance = instanceAdminClient.getInstance(testHelper.getInstanceId().getInstance()); + logger.info("Finished setup"); + } + + @After + public void tearDown() throws Exception { + for (String backup : backups) { + waitForDbOperations(backup); + dbAdminClient.deleteBackup(testHelper.getInstanceId().getInstance(), backup); + } + backups.clear(); + for (String db : databases) { + dbAdminClient.dropDatabase(testHelper.getInstanceId().getInstance(), db); + } + } + + private void waitForDbOperations(String backupId) throws InterruptedException { + try { + Backup backupMetadata = + dbAdminClient.getBackup(testHelper.getInstanceId().getInstance(), backupId); + boolean allDbOpsDone = false; + while (!allDbOpsDone) { + allDbOpsDone = true; + for (String referencingDb : backupMetadata.getProto().getReferencingDatabasesList()) { + String filter = + String.format( + "name:%s/operations/ AND " + + "(metadata.@type:type.googleapis.com/" + + "google.spanner.admin.database.v1.OptimizeRestoredDatabaseMetadata)", + referencingDb); + for (Operation op : + dbAdminClient + .listDatabaseOperations( + testHelper.getInstanceId().getInstance(), Options.filter(filter)) + .iterateAll()) { + if (!op.getDone()) { + Thread.sleep(5000L); + allDbOpsDone = false; + break; + } + } + } + } + } catch (SpannerException e) { + if (e.getErrorCode() == ErrorCode.NOT_FOUND) { + return; + } + throw e; + } + } + + private String getUniqueBackupId() { + return String.format("testbck_%06d_%04d", random.nextInt(1000000), backupSeq.incrementAndGet()); + } + + private static Timestamp after7Days() { + return Timestamp.ofTimeMicroseconds( + TimeUnit.MICROSECONDS.convert(System.currentTimeMillis(), TimeUnit.MILLISECONDS) + + TimeUnit.MICROSECONDS.convert(7L, TimeUnit.DAYS)); + } + + private Timestamp after5Minutes() { + return Timestamp.ofTimeMicroseconds( + TimeUnit.MICROSECONDS.convert(System.currentTimeMillis(), TimeUnit.MILLISECONDS) + + TimeUnit.MICROSECONDS.convert(5L, TimeUnit.MINUTES)); + } + + private Timestamp tomorrow() { + return Timestamp.ofTimeMicroseconds( + TimeUnit.MICROSECONDS.convert(System.currentTimeMillis(), TimeUnit.MILLISECONDS) + + TimeUnit.MICROSECONDS.convert(1L, TimeUnit.DAYS)); + } + + private Timestamp yesterday() { + return Timestamp.ofTimeMicroseconds( + TimeUnit.MICROSECONDS.convert(System.currentTimeMillis(), TimeUnit.MILLISECONDS) + - TimeUnit.MICROSECONDS.convert(1L, TimeUnit.DAYS)); + } + + @Test + public void testBackups() throws InterruptedException, ExecutionException { + // Create two test databases in parallel. + String db1Id = testHelper.getUniqueDatabaseId() + "_db1"; + logger.info(String.format("Creating test database %s", db1Id)); + OperationFuture dbOp1 = + dbAdminClient.createDatabase( + testHelper.getInstanceId().getInstance(), + db1Id, + Arrays.asList("CREATE TABLE FOO (ID INT64, NAME STRING(100)) PRIMARY KEY (ID)")); + String db2Id = testHelper.getUniqueDatabaseId() + "_db2"; + logger.info(String.format("Creating test database %s", db2Id)); + OperationFuture dbOp2 = + dbAdminClient.createDatabase( + testHelper.getInstanceId().getInstance(), + testHelper.getUniqueDatabaseId() + "_db2", + Arrays.asList("CREATE TABLE BAR (ID INT64, NAME STRING(100)) PRIMARY KEY (ID)")); + // Make sure all databases are created before we try to create any backups. + Database db1 = dbOp1.get(); + Database db2 = dbOp2.get(); + databases.add(db1.getId().getDatabase()); + databases.add(db2.getId().getDatabase()); + // Insert some data into db2 to make sure the backup will have a size>0. + DatabaseClient client = testHelper.getDatabaseClient(db2); + client.writeAtLeastOnce( + Arrays.asList( + Mutation.newInsertOrUpdateBuilder("BAR") + .set("ID") + .to(1L) + .set("NAME") + .to("TEST") + .build())); + + // Create two backups in parallel. + String backupId1 = getUniqueBackupId() + "_bck1"; + String backupId2 = getUniqueBackupId() + "_bck2"; + Timestamp expireTime = after7Days(); + logger.info(String.format("Creating backups %s and %s in parallel", backupId1, backupId2)); + OperationFuture op1 = + dbAdminClient.createBackup( + testHelper.getInstanceId().getInstance(), + backupId1, + db1.getId().getDatabase(), + expireTime); + OperationFuture op2 = + dbAdminClient.createBackup( + testHelper.getInstanceId().getInstance(), + backupId2, + db2.getId().getDatabase(), + expireTime); + backups.add(backupId1); + backups.add(backupId2); + + // Execute metadata tests as part of this integration test to reduce total execution time. + testMetadata(op1, op2, backupId1, backupId2, db1, db2); + + // Ensure both backups have been created before we proceed. + Backup backup1 = op1.get(); + Backup backup2 = op2.get(); + // Insert some more data into db2 to get a timestamp from the server. + Timestamp commitTs = + client.writeAtLeastOnce( + Arrays.asList( + Mutation.newInsertOrUpdateBuilder("BAR") + .set("ID") + .to(2L) + .set("NAME") + .to("TEST2") + .build())); + + // Test listing operations. + // List all backups. + logger.info("Listing all backups"); + assertThat(instance.listBackups().iterateAll()).containsAtLeast(backup1, backup2); + // List all backups whose names contain 'bck1'. + logger.info("Listing backups with name bck1"); + assertThat( + dbAdminClient + .listBackups( + testHelper.getInstanceId().getInstance(), + Options.filter(String.format("name:%s", backup1.getId().getName()))) + .iterateAll()) + .containsExactly(backup1); + logger.info("Listing ready backups"); + Iterable readyBackups = + dbAdminClient + .listBackups(testHelper.getInstanceId().getInstance(), Options.filter("state:READY")) + .iterateAll(); + assertThat(readyBackups).containsAtLeast(backup1, backup2); + // List all backups for databases whose names contain 'db1'. + logger.info("Listing backups for database db1"); + assertThat( + dbAdminClient + .listBackups( + testHelper.getInstanceId().getInstance(), + Options.filter(String.format("database:%s", db1.getId().getName()))) + .iterateAll()) + .containsExactly(backup1); + // List all backups that were created before a certain time. + Timestamp ts = Timestamp.ofTimeSecondsAndNanos(commitTs.getSeconds(), 0); + logger.info(String.format("Listing backups created before %s", ts)); + assertThat( + dbAdminClient + .listBackups( + testHelper.getInstanceId().getInstance(), + Options.filter(String.format("create_time<\"%s\"", ts))) + .iterateAll()) + .containsAtLeast(backup1, backup2); + // List all backups with a size > 0. + logger.info("Listing backups with size>0"); + assertThat( + dbAdminClient + .listBackups( + testHelper.getInstanceId().getInstance(), Options.filter("size_bytes>0")) + .iterateAll()) + .contains(backup2); + assertThat( + dbAdminClient + .listBackups( + testHelper.getInstanceId().getInstance(), Options.filter("size_bytes>0")) + .iterateAll()) + .doesNotContain(backup1); + + // Test pagination. + testPagination(2); + logger.info("Finished listBackup tests"); + + // Execute other tests as part of this integration test to reduce total execution time. + testGetBackup(db2, backupId2, expireTime); + testUpdateBackup(backup1); + testCreateInvalidExpirationDate(db1); + testRestore(backup1, op1); + + testDelete(backupId2); + testCancelBackupOperation(db1); + // Finished all tests. + logger.info("Finished all backup tests"); + } + + private void testMetadata( + OperationFuture op1, + OperationFuture op2, + String backupId1, + String backupId2, + Database db1, + Database db2) + throws InterruptedException, ExecutionException { + + logger.info("Getting operation metadata 1"); + CreateBackupMetadata metadata1 = op1.getMetadata().get(); + logger.info("Getting operation metadata 2"); + CreateBackupMetadata metadata2 = op2.getMetadata().get(); + String expectedOperationName1 = + String.format(EXPECTED_OP_NAME_FORMAT, testHelper.getInstanceId().getName(), backupId1); + String expectedOperationName2 = + String.format(EXPECTED_OP_NAME_FORMAT, testHelper.getInstanceId().getName(), backupId2); + assertThat(op1.getName()).startsWith(expectedOperationName1); + assertThat(op2.getName()).startsWith(expectedOperationName2); + assertThat(metadata1.getDatabase()).isEqualTo(db1.getId().getName()); + assertThat(metadata2.getDatabase()).isEqualTo(db2.getId().getName()); + assertThat(metadata1.getName()) + .isEqualTo(BackupId.of(testHelper.getInstanceId(), backupId1).getName()); + assertThat(metadata2.getName()) + .isEqualTo(BackupId.of(testHelper.getInstanceId(), backupId2).getName()); + } + + private void testCreateInvalidExpirationDate(Database db) throws InterruptedException { + // This is not allowed, the expiration date must be at least 6 hours in the future. + Timestamp expireTime = yesterday(); + String backupId = getUniqueBackupId(); + logger.info(String.format("Creating backup %s with invalid expiration date", backupId)); + OperationFuture op = + dbAdminClient.createBackup( + testHelper.getInstanceId().getInstance(), + backupId, + db.getId().getDatabase(), + expireTime); + backups.add(backupId); + try { + op.get(); + fail("missing expected exception"); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + assertThat(cause).isInstanceOf(SpannerException.class); + SpannerException se = (SpannerException) cause; + assertThat(se.getErrorCode()).isEqualTo(ErrorCode.INVALID_ARGUMENT); + } + } + + private void testCancelBackupOperation(Database db) + throws InterruptedException, ExecutionException { + Timestamp expireTime = after7Days(); + String backupId = getUniqueBackupId(); + logger.info(String.format("Starting to create backup %s", backupId)); + OperationFuture op = + dbAdminClient.createBackup( + testHelper.getInstanceId().getInstance(), + backupId, + db.getId().getDatabase(), + expireTime); + backups.add(backupId); + // Cancel the backup operation. + logger.info(String.format("Cancelling the creation of backup %s", backupId)); + dbAdminClient.cancelOperation(op.getName()); + logger.info("Fetching backup operations"); + boolean operationFound = false; + for (Operation operation : + dbAdminClient + .listBackupOperations( + testHelper.getInstanceId().getInstance(), + Options.filter(String.format("name:%s", op.getName()))) + .iterateAll()) { + assertThat(operation.getError().getCode()).isEqualTo(Status.Code.CANCELLED.value()); + operationFound = true; + } + assertThat(operationFound).isTrue(); + logger.info("Finished cancel test"); + } + + private void testGetBackup(Database db, String backupId, Timestamp expireTime) { + // Get the most recent version of the backup. + logger.info(String.format("Getting backup %s", backupId)); + Backup backup = instance.getBackup(backupId); + assertThat(backup.getState()).isEqualTo(Backup.State.READY); + assertThat(backup.getSize()).isGreaterThan(0L); + assertThat(backup.getExpireTime()).isEqualTo(expireTime); + assertThat(backup.getDatabase()).isEqualTo(db.getId()); + } + + private void testUpdateBackup(Backup backup) throws InterruptedException, ExecutionException { + // Update the expire time. + Timestamp tomorrow = tomorrow(); + backup = backup.toBuilder().setExpireTime(tomorrow).build(); + logger.info( + String.format("Updating expire time of backup %s to 1 week", backup.getId().getBackup())); + backup.updateExpireTime(); + // Re-get the backup and ensure the expire time was updated. + logger.info(String.format("Reloading backup %s", backup.getId().getBackup())); + backup = backup.reload(); + assertThat(backup.getExpireTime()).isEqualTo(tomorrow); + + // Try to set the expire time to 5 minutes in the future. + Timestamp in5Minutes = after5Minutes(); + backup = backup.toBuilder().setExpireTime(in5Minutes).build(); + try { + logger.info( + String.format( + "Updating expire time of backup %s to 5 minutes", backup.getId().getBackup())); + backup.updateExpireTime(); + fail("Missing expected exception"); + } catch (SpannerException e) { + assertThat(e.getErrorCode()).isEqualTo(ErrorCode.INVALID_ARGUMENT); + } + // Re-get the backup and ensure the expire time is still in one week. + backup = backup.reload(); + assertThat(backup.getExpireTime()).isEqualTo(tomorrow); + } + + private void testPagination(int expectedMinimumTotalBackups) { + logger.info("Listing backups using pagination"); + int numBackups = 0; + logger.info("Fetching first page"); + Page page = + dbAdminClient.listBackups(testHelper.getInstanceId().getInstance(), Options.pageSize(1)); + assertThat(page.getValues()).hasSize(1); + numBackups++; + assertThat(page.hasNextPage()).isTrue(); + while (page.hasNextPage()) { + logger.info(String.format("Fetching page %d", numBackups + 1)); + page = + dbAdminClient.listBackups( + testHelper.getInstanceId().getInstance(), + Options.pageToken(page.getNextPageToken()), + Options.pageSize(1)); + assertThat(page.getValues()).hasSize(1); + numBackups++; + } + assertThat(numBackups).isAtLeast(expectedMinimumTotalBackups); + } + + private void testDelete(String backupId) throws InterruptedException, ExecutionException { + waitForDbOperations(backupId); + // Get the backup. + logger.info(String.format("Fetching backup %s", backupId)); + Backup backup = instance.getBackup(backupId); + // Delete it. + logger.info(String.format("Deleting backup %s", backupId)); + backup.delete(); + // Try to get it again. This should cause a NOT_FOUND error. + try { + logger.info(String.format("Fetching non-existent backup %s", backupId)); + instance.getBackup(backupId); + fail("Missing expected exception"); + } catch (SpannerException e) { + assertThat(e.getErrorCode()).isEqualTo(ErrorCode.NOT_FOUND); + } + // Try to delete the non-existent backup. This should be a no-op. + logger.info(String.format("Deleting non-existent backup %s", backupId)); + backup.delete(); + logger.info("Finished delete tests"); + } + + private void testRestore(Backup backup, OperationFuture backupOp) + throws InterruptedException, ExecutionException { + // Restore the backup to a new database. + String restoredDb = testHelper.getUniqueDatabaseId(); + logger.info( + String.format( + "Restoring backup %s to database %s", backup.getId().getBackup(), restoredDb)); + OperationFuture restoreOp = + backup.restore(DatabaseId.of(testHelper.getInstanceId(), restoredDb)); + databases.add(restoredDb); + final String restoreOperationName = restoreOp.getName(); + logger.info(String.format("Restore operation %s running", restoreOperationName)); + RestoreDatabaseMetadata metadata = restoreOp.getMetadata().get(); + assertThat(metadata.getBackupInfo().getBackup()).isEqualTo(backup.getId().getName()); + assertThat(metadata.getSourceType()).isEqualTo(RestoreSourceType.BACKUP); + assertThat(metadata.getName()) + .isEqualTo(DatabaseId.of(testHelper.getInstanceId(), restoredDb).getName()); + + // Ensure the operations show up in the right collections. + // TODO: Re-enable when it is clear why this fails on the CI environment. + // verifyRestoreOperations(backupOp.getName(), restoreOperationName); + + // Wait until the restore operation has finished successfully. + Database database = restoreOp.get(); + assertThat(database.getId().getDatabase()).isEqualTo(restoredDb); + // Restoring the backup to an existing database should fail. + try { + logger.info( + String.format( + "Restoring backup %s to existing database %s", + backup.getId().getBackup(), restoredDb)); + backup.restore(DatabaseId.of(testHelper.getInstanceId(), restoredDb)).get(); + fail("Missing expected exception"); + } catch (ExecutionException ee) { + assertThat(ee.getCause()).isInstanceOf(SpannerException.class); + SpannerException e = (SpannerException) ee.getCause(); + assertThat(e.getErrorCode()).isEqualTo(ErrorCode.ALREADY_EXISTS); + } + } + + // TODO: Remove when this verification can be re-enabled. + @SuppressWarnings("unused") + private void verifyRestoreOperations( + final String backupOperationName, final String restoreOperationName) { + assertThat( + Iterables.any( + instance.listBackupOperations().iterateAll(), + new Predicate() { + @Override + public boolean apply(Operation input) { + return input.getName().equals(backupOperationName); + } + })) + .isTrue(); + assertThat( + Iterables.any( + instance.listBackupOperations().iterateAll(), + new Predicate() { + @Override + public boolean apply(Operation input) { + return input.getName().equals(restoreOperationName); + } + })) + .isFalse(); + assertThat( + Iterables.any( + instance.listDatabaseOperations().iterateAll(), + new Predicate() { + @Override + public boolean apply(Operation input) { + return input.getName().equals(backupOperationName); + } + })) + .isFalse(); + assertThat( + Iterables.any( + instance.listDatabaseOperations().iterateAll(), + new Predicate() { + @Override + public boolean apply(Operation input) { + return input.getName().equals(restoreOperationName); + } + })) + .isTrue(); + } +} diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITBatchDmlTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITBatchDmlTest.java index c5090d10e4..b7fc530799 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITBatchDmlTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITBatchDmlTest.java @@ -22,8 +22,8 @@ import com.google.cloud.spanner.Database; import com.google.cloud.spanner.DatabaseClient; import com.google.cloud.spanner.ErrorCode; -import com.google.cloud.spanner.IntegrationTest; import com.google.cloud.spanner.IntegrationTestEnv; +import com.google.cloud.spanner.ParallelIntegrationTest; import com.google.cloud.spanner.SpannerBatchUpdateException; import com.google.cloud.spanner.SpannerException; import com.google.cloud.spanner.Statement; @@ -45,7 +45,7 @@ import org.junit.runners.JUnit4; /** Integration tests for DML. */ -@Category(IntegrationTest.class) +@Category(ParallelIntegrationTest.class) @RunWith(JUnit4.class) public final class ITBatchDmlTest { diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITBatchReadTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITBatchReadTest.java index 33f9b68680..9c3f11d3ee 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITBatchReadTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITBatchReadTest.java @@ -25,10 +25,10 @@ import com.google.cloud.spanner.BatchTransactionId; import com.google.cloud.spanner.Database; import com.google.cloud.spanner.DatabaseClient; -import com.google.cloud.spanner.IntegrationTest; import com.google.cloud.spanner.IntegrationTestEnv; import com.google.cloud.spanner.KeySet; import com.google.cloud.spanner.Mutation; +import com.google.cloud.spanner.ParallelIntegrationTest; import com.google.cloud.spanner.Partition; import com.google.cloud.spanner.PartitionOptions; import com.google.cloud.spanner.ResultSet; @@ -56,7 +56,7 @@ * Integration test reading large amounts of data using the Batch APIs. The size of data ensures * that multiple paritions are returned by the server. */ -@Category(IntegrationTest.class) +@Category(ParallelIntegrationTest.class) @RunWith(JUnit4.class) public class ITBatchReadTest { private static int numRows; diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITClosedSessionTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITClosedSessionTest.java index 043430a2cc..c3be062bfa 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITClosedSessionTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITClosedSessionTest.java @@ -20,9 +20,9 @@ import com.google.cloud.spanner.AbortedException; import com.google.cloud.spanner.Database; -import com.google.cloud.spanner.IntegrationTest; import com.google.cloud.spanner.IntegrationTestWithClosedSessionsEnv; import com.google.cloud.spanner.IntegrationTestWithClosedSessionsEnv.DatabaseClientWithClosedSessionImpl; +import com.google.cloud.spanner.ParallelIntegrationTest; import com.google.cloud.spanner.ReadOnlyTransaction; import com.google.cloud.spanner.ResultSet; import com.google.cloud.spanner.SessionNotFoundException; @@ -44,7 +44,7 @@ import org.junit.runners.JUnit4; /** Test the automatic re-creation of sessions that have been invalidated by the server. */ -@Category(IntegrationTest.class) +@Category(ParallelIntegrationTest.class) @RunWith(JUnit4.class) public class ITClosedSessionTest { // Run each test case twice to ensure that a retried session does not affect subsequent diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITCommitTimestampTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITCommitTimestampTest.java index 0dec8ae58b..d9da0f3ef0 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITCommitTimestampTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITCommitTimestampTest.java @@ -24,10 +24,10 @@ import com.google.cloud.spanner.DatabaseAdminClient; import com.google.cloud.spanner.DatabaseClient; import com.google.cloud.spanner.ErrorCode; -import com.google.cloud.spanner.IntegrationTest; import com.google.cloud.spanner.IntegrationTestEnv; import com.google.cloud.spanner.Key; import com.google.cloud.spanner.Mutation; +import com.google.cloud.spanner.ParallelIntegrationTest; import com.google.cloud.spanner.SpannerExceptionFactory; import com.google.cloud.spanner.Struct; import com.google.cloud.spanner.TimestampBound; @@ -48,7 +48,7 @@ import org.threeten.bp.Instant; /** Integration test for commit timestamp of Cloud Spanner. */ -@Category(IntegrationTest.class) +@Category(ParallelIntegrationTest.class) @RunWith(JUnit4.class) public class ITCommitTimestampTest { @ClassRule public static IntegrationTestEnv env = new IntegrationTestEnv(); diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITDMLTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITDMLTest.java index aff4dace54..5610cf336a 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITDMLTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITDMLTest.java @@ -23,12 +23,12 @@ import com.google.cloud.spanner.Database; import com.google.cloud.spanner.DatabaseClient; import com.google.cloud.spanner.ErrorCode; -import com.google.cloud.spanner.IntegrationTest; import com.google.cloud.spanner.IntegrationTestEnv; import com.google.cloud.spanner.Key; import com.google.cloud.spanner.KeyRange; import com.google.cloud.spanner.KeySet; import com.google.cloud.spanner.Mutation; +import com.google.cloud.spanner.ParallelIntegrationTest; import com.google.cloud.spanner.ResultSet; import com.google.cloud.spanner.SpannerException; import com.google.cloud.spanner.SpannerExceptionFactory; @@ -46,7 +46,7 @@ import org.junit.runners.JUnit4; /** Integration tests for DML. */ -@Category(IntegrationTest.class) +@Category(ParallelIntegrationTest.class) @RunWith(JUnit4.class) public final class ITDMLTest { @ClassRule public static IntegrationTestEnv env = new IntegrationTestEnv(); diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITDatabaseAdminTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITDatabaseAdminTest.java index bbeed5aef0..574d9c9b15 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITDatabaseAdminTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITDatabaseAdminTest.java @@ -24,9 +24,9 @@ import com.google.cloud.spanner.Database; import com.google.cloud.spanner.DatabaseAdminClient; import com.google.cloud.spanner.ErrorCode; -import com.google.cloud.spanner.IntegrationTest; import com.google.cloud.spanner.IntegrationTestEnv; import com.google.cloud.spanner.Options; +import com.google.cloud.spanner.ParallelIntegrationTest; import com.google.cloud.spanner.testing.RemoteSpannerHelper; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; @@ -46,7 +46,7 @@ import org.junit.runners.JUnit4; /** Integration tests for {@link com.google.cloud.spanner.DatabaseAdminClient}. */ -@Category(IntegrationTest.class) +@Category(ParallelIntegrationTest.class) @RunWith(JUnit4.class) public class ITDatabaseAdminTest { @ClassRule public static IntegrationTestEnv env = new IntegrationTestEnv(); diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITDatabaseTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITDatabaseTest.java index d24c56661f..3a7125312b 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITDatabaseTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITDatabaseTest.java @@ -29,8 +29,8 @@ import com.google.cloud.spanner.ErrorCode; import com.google.cloud.spanner.InstanceId; import com.google.cloud.spanner.InstanceNotFoundException; -import com.google.cloud.spanner.IntegrationTest; import com.google.cloud.spanner.IntegrationTestEnv; +import com.google.cloud.spanner.ParallelIntegrationTest; import com.google.cloud.spanner.ResultSet; import com.google.cloud.spanner.Statement; import com.google.spanner.admin.database.v1.CreateDatabaseMetadata; @@ -44,7 +44,7 @@ import org.junit.runners.JUnit4; /** Integration tests for database admin functionality: DDL etc. */ -@Category(IntegrationTest.class) +@Category(ParallelIntegrationTest.class) @RunWith(JUnit4.class) public class ITDatabaseTest { @ClassRule public static IntegrationTestEnv env = new IntegrationTestEnv(); diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITLargeReadTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITLargeReadTest.java index 8fa2e05ce8..3fa99c225f 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITLargeReadTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITLargeReadTest.java @@ -21,11 +21,11 @@ import com.google.cloud.ByteArray; import com.google.cloud.spanner.Database; import com.google.cloud.spanner.DatabaseClient; -import com.google.cloud.spanner.IntegrationTest; import com.google.cloud.spanner.IntegrationTestEnv; import com.google.cloud.spanner.KeySet; import com.google.cloud.spanner.Mutation; import com.google.cloud.spanner.Options; +import com.google.cloud.spanner.ParallelIntegrationTest; import com.google.cloud.spanner.ResultSet; import com.google.cloud.spanner.Statement; import com.google.common.hash.HashFunction; @@ -46,7 +46,7 @@ * Integration test reading large amounts of data. The size of data ensures that multiple chunks are * returned by the server. */ -@Category(IntegrationTest.class) +@Category(ParallelIntegrationTest.class) @RunWith(JUnit4.class) public class ITLargeReadTest { private static int numRows; diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITQueryOptionsTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITQueryOptionsTest.java index 89d789e6f0..9bc221c553 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITQueryOptionsTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITQueryOptionsTest.java @@ -22,8 +22,8 @@ import com.google.cloud.spanner.Database; import com.google.cloud.spanner.DatabaseClient; import com.google.cloud.spanner.ErrorCode; -import com.google.cloud.spanner.IntegrationTest; import com.google.cloud.spanner.IntegrationTestEnv; +import com.google.cloud.spanner.ParallelIntegrationTest; import com.google.cloud.spanner.ResultSet; import com.google.cloud.spanner.Spanner; import com.google.cloud.spanner.SpannerException; @@ -40,7 +40,7 @@ import org.junit.runner.RunWith; import org.junit.runners.JUnit4; -@Category(IntegrationTest.class) +@Category(ParallelIntegrationTest.class) @RunWith(JUnit4.class) public class ITQueryOptionsTest { @ClassRule public static IntegrationTestEnv env = new IntegrationTestEnv(); diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITQueryTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITQueryTest.java index 2cc92867b6..79e9a5f4ea 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITQueryTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITQueryTest.java @@ -27,9 +27,9 @@ import com.google.cloud.spanner.Database; import com.google.cloud.spanner.DatabaseClient; import com.google.cloud.spanner.ErrorCode; -import com.google.cloud.spanner.IntegrationTest; import com.google.cloud.spanner.IntegrationTestEnv; import com.google.cloud.spanner.Mutation; +import com.google.cloud.spanner.ParallelIntegrationTest; import com.google.cloud.spanner.ReadContext.QueryAnalyzeMode; import com.google.cloud.spanner.ResultSet; import com.google.cloud.spanner.Statement; @@ -54,7 +54,7 @@ import org.junit.runners.JUnit4; /** Integration tests for query execution. */ -@Category(IntegrationTest.class) +@Category(ParallelIntegrationTest.class) @RunWith(JUnit4.class) public class ITQueryTest { @ClassRule public static IntegrationTestEnv env = new IntegrationTestEnv(); diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITReadOnlyTxnTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITReadOnlyTxnTest.java index 352219208d..e6e473779d 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITReadOnlyTxnTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITReadOnlyTxnTest.java @@ -21,10 +21,10 @@ import com.google.cloud.Timestamp; import com.google.cloud.spanner.Database; import com.google.cloud.spanner.DatabaseClient; -import com.google.cloud.spanner.IntegrationTest; import com.google.cloud.spanner.IntegrationTestEnv; import com.google.cloud.spanner.Key; import com.google.cloud.spanner.Mutation; +import com.google.cloud.spanner.ParallelIntegrationTest; import com.google.cloud.spanner.ReadContext; import com.google.cloud.spanner.ReadOnlyTransaction; import com.google.cloud.spanner.ResultSet; @@ -55,7 +55,7 @@ * distinguished. Hence, these integration tests only minimally verify that read-only transactions * work at all, and unit tests are relied on for validating that modes are encoded correctly. */ -@Category(IntegrationTest.class) +@Category(ParallelIntegrationTest.class) @RunWith(JUnit4.class) public class ITReadOnlyTxnTest { @ClassRule public static IntegrationTestEnv env = new IntegrationTestEnv(); diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITReadTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITReadTest.java index b673d05e1e..b06d3ae152 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITReadTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITReadTest.java @@ -25,13 +25,13 @@ import com.google.cloud.spanner.DatabaseClient; import com.google.cloud.spanner.DatabaseId; import com.google.cloud.spanner.ErrorCode; -import com.google.cloud.spanner.IntegrationTest; import com.google.cloud.spanner.IntegrationTestEnv; import com.google.cloud.spanner.Key; import com.google.cloud.spanner.KeyRange; import com.google.cloud.spanner.KeySet; import com.google.cloud.spanner.Mutation; import com.google.cloud.spanner.Options; +import com.google.cloud.spanner.ParallelIntegrationTest; import com.google.cloud.spanner.ResultSet; import com.google.cloud.spanner.SpannerException; import com.google.cloud.spanner.Struct; @@ -63,7 +63,7 @@ *

See also {@link ITWriteTest}, which provides coverage of writing and reading back all Cloud * Spanner types. */ -@Category(IntegrationTest.class) +@Category(ParallelIntegrationTest.class) @RunWith(JUnit4.class) public class ITReadTest { @ClassRule public static IntegrationTestEnv env = new IntegrationTestEnv(); diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITSpannerOptionsTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITSpannerOptionsTest.java index 08cdabd1f3..ed7442a237 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITSpannerOptionsTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITSpannerOptionsTest.java @@ -24,8 +24,8 @@ import com.google.cloud.spanner.DatabaseAdminClient; import com.google.cloud.spanner.DatabaseClient; import com.google.cloud.spanner.InstanceAdminClient; -import com.google.cloud.spanner.IntegrationTest; import com.google.cloud.spanner.IntegrationTestEnv; +import com.google.cloud.spanner.ParallelIntegrationTest; import com.google.cloud.spanner.ResultSet; import com.google.cloud.spanner.Spanner; import com.google.cloud.spanner.SpannerOptions; @@ -45,7 +45,7 @@ import org.junit.runner.RunWith; import org.junit.runners.JUnit4; -@Category(IntegrationTest.class) +@Category(ParallelIntegrationTest.class) @RunWith(JUnit4.class) public class ITSpannerOptionsTest { @ClassRule public static IntegrationTestEnv env = new IntegrationTestEnv(); diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITTransactionManagerTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITTransactionManagerTest.java index abda78dd3e..12d7ec2747 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITTransactionManagerTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITTransactionManagerTest.java @@ -22,10 +22,10 @@ import com.google.cloud.spanner.AbortedException; import com.google.cloud.spanner.Database; import com.google.cloud.spanner.DatabaseClient; -import com.google.cloud.spanner.IntegrationTest; import com.google.cloud.spanner.IntegrationTestEnv; import com.google.cloud.spanner.Key; import com.google.cloud.spanner.Mutation; +import com.google.cloud.spanner.ParallelIntegrationTest; import com.google.cloud.spanner.SpannerException; import com.google.cloud.spanner.Struct; import com.google.cloud.spanner.TransactionContext; @@ -41,7 +41,7 @@ import org.junit.runner.RunWith; import org.junit.runners.JUnit4; -@Category(IntegrationTest.class) +@Category(ParallelIntegrationTest.class) @RunWith(JUnit4.class) public class ITTransactionManagerTest { diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITTransactionTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITTransactionTest.java index a442e4d2ec..4e95f8efe8 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITTransactionTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITTransactionTest.java @@ -28,11 +28,11 @@ import com.google.cloud.spanner.Database; import com.google.cloud.spanner.DatabaseClient; import com.google.cloud.spanner.ErrorCode; -import com.google.cloud.spanner.IntegrationTest; import com.google.cloud.spanner.IntegrationTestEnv; import com.google.cloud.spanner.Key; import com.google.cloud.spanner.KeySet; import com.google.cloud.spanner.Mutation; +import com.google.cloud.spanner.ParallelIntegrationTest; import com.google.cloud.spanner.PartitionOptions; import com.google.cloud.spanner.ReadContext; import com.google.cloud.spanner.ResultSet; @@ -58,7 +58,7 @@ import org.junit.runners.JUnit4; /** Integration tests for read-write transactions. */ -@Category(IntegrationTest.class) +@Category(ParallelIntegrationTest.class) @RunWith(JUnit4.class) public class ITTransactionTest { @ClassRule public static IntegrationTestEnv env = new IntegrationTestEnv(); diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITWriteTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITWriteTest.java index fed755c588..0a4dd79d5b 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITWriteTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITWriteTest.java @@ -26,11 +26,11 @@ import com.google.cloud.spanner.Database; import com.google.cloud.spanner.DatabaseClient; import com.google.cloud.spanner.ErrorCode; -import com.google.cloud.spanner.IntegrationTest; import com.google.cloud.spanner.IntegrationTestEnv; import com.google.cloud.spanner.Key; import com.google.cloud.spanner.KeySet; import com.google.cloud.spanner.Mutation; +import com.google.cloud.spanner.ParallelIntegrationTest; import com.google.cloud.spanner.ResultSet; import com.google.cloud.spanner.SpannerException; import com.google.cloud.spanner.Struct; @@ -57,7 +57,7 @@ import org.junit.runners.JUnit4; /** Integration test for writing data to Cloud Spanner. */ -@Category(IntegrationTest.class) +@Category(ParallelIntegrationTest.class) @RunWith(JUnit4.class) public class ITWriteTest { @ClassRule public static IntegrationTestEnv env = new IntegrationTestEnv();