diff --git a/gcloud-java-examples/src/main/java/com/google/gcloud/examples/StorageExample.java b/gcloud-java-examples/src/main/java/com/google/gcloud/examples/StorageExample.java index 274710f10e93..b71a51fe6ac6 100644 --- a/gcloud-java-examples/src/main/java/com/google/gcloud/examples/StorageExample.java +++ b/gcloud-java-examples/src/main/java/com/google/gcloud/examples/StorageExample.java @@ -24,6 +24,7 @@ import com.google.gcloud.storage.BlobId; import com.google.gcloud.storage.BlobInfo; import com.google.gcloud.storage.BlobReadChannel; +import com.google.gcloud.storage.CopyWriter; import com.google.gcloud.storage.BlobWriteChannel; import com.google.gcloud.storage.Bucket; import com.google.gcloud.storage.BucketInfo; @@ -366,8 +367,8 @@ public String params() { private static class CopyAction extends StorageAction { @Override public void run(Storage storage, CopyRequest request) { - BlobInfo copiedBlobInfo = storage.copy(request); - System.out.println("Copied " + copiedBlobInfo); + CopyWriter copyWriter = storage.copy(request); + System.out.println("Copied " + copyWriter.result()); } @Override diff --git a/gcloud-java-storage/src/main/java/com/google/gcloud/spi/DefaultStorageRpc.java b/gcloud-java-storage/src/main/java/com/google/gcloud/spi/DefaultStorageRpc.java index 90f2c98ad4b8..957fbc90095d 100644 --- a/gcloud-java-storage/src/main/java/com/google/gcloud/spi/DefaultStorageRpc.java +++ b/gcloud-java-storage/src/main/java/com/google/gcloud/spi/DefaultStorageRpc.java @@ -78,6 +78,7 @@ public class DefaultStorageRpc implements StorageRpc { // see: https://cloud.google.com/storage/docs/concepts-techniques#practices private static final Set RETRYABLE_CODES = ImmutableSet.of(504, 503, 502, 500, 429, 408); + private static final long MEGABYTE = 1024L * 1024L; public DefaultStorageRpc(StorageOptions options) { HttpTransport transport = options.httpTransportFactory().create(); @@ -320,30 +321,6 @@ public StorageObject compose(Iterable sources, StorageObject targ } } - @Override - public StorageObject copy(StorageObject source, Map sourceOptions, - StorageObject target, Map targetOptions) throws StorageException { - try { - return storage - .objects() - .copy(source.getBucket(), source.getName(), target.getBucket(), target.getName(), - target.getContentType() != null ? target : null) - .setProjection(DEFAULT_PROJECTION) - .setIfSourceMetagenerationMatch(IF_SOURCE_METAGENERATION_MATCH.getLong(sourceOptions)) - .setIfSourceMetagenerationNotMatch( - IF_SOURCE_METAGENERATION_NOT_MATCH.getLong(sourceOptions)) - .setIfSourceGenerationMatch(IF_SOURCE_GENERATION_MATCH.getLong(sourceOptions)) - .setIfSourceGenerationNotMatch(IF_SOURCE_GENERATION_NOT_MATCH.getLong(sourceOptions)) - .setIfMetagenerationMatch(IF_METAGENERATION_MATCH.getLong(targetOptions)) - .setIfMetagenerationNotMatch(IF_METAGENERATION_NOT_MATCH.getLong(targetOptions)) - .setIfGenerationMatch(IF_GENERATION_MATCH.getLong(targetOptions)) - .setIfGenerationNotMatch(IF_GENERATION_NOT_MATCH.getLong(targetOptions)) - .execute(); - } catch (IOException ex) { - throw translate(ex); - } - } - @Override public byte[] load(StorageObject from, Map options) throws StorageException { @@ -521,4 +498,46 @@ public String open(StorageObject object, Map options) throw translate(ex); } } + + @Override + public RewriteResponse openRewrite(RewriteRequest rewriteRequest) throws StorageException { + return rewrite(rewriteRequest, null); + } + + @Override + public RewriteResponse continueRewrite(RewriteResponse previousResponse) throws StorageException { + return rewrite(previousResponse.rewriteRequest, previousResponse.rewriteToken); + } + + private RewriteResponse rewrite(RewriteRequest req, String token) throws StorageException { + try { + Long maxBytesRewrittenPerCall = req.megabytesRewrittenPerCall != null + ? req.megabytesRewrittenPerCall * MEGABYTE : null; + com.google.api.services.storage.model.RewriteResponse rewriteReponse = storage.objects() + .rewrite(req.source.getBucket(), req.source.getName(), req.target.getBucket(), + req.target.getName(), req.target.getContentType() != null ? req.target : null) + .setRewriteToken(token) + .setMaxBytesRewrittenPerCall(maxBytesRewrittenPerCall) + .setProjection(DEFAULT_PROJECTION) + .setIfSourceMetagenerationMatch(IF_SOURCE_METAGENERATION_MATCH.getLong(req.sourceOptions)) + .setIfSourceMetagenerationNotMatch( + IF_SOURCE_METAGENERATION_NOT_MATCH.getLong(req.sourceOptions)) + .setIfSourceGenerationMatch(IF_SOURCE_GENERATION_MATCH.getLong(req.sourceOptions)) + .setIfSourceGenerationNotMatch(IF_SOURCE_GENERATION_NOT_MATCH.getLong(req.sourceOptions)) + .setIfMetagenerationMatch(IF_METAGENERATION_MATCH.getLong(req.targetOptions)) + .setIfMetagenerationNotMatch(IF_METAGENERATION_NOT_MATCH.getLong(req.targetOptions)) + .setIfGenerationMatch(IF_GENERATION_MATCH.getLong(req.targetOptions)) + .setIfGenerationNotMatch(IF_GENERATION_NOT_MATCH.getLong(req.targetOptions)) + .execute(); + return new RewriteResponse( + req, + rewriteReponse.getResource(), + rewriteReponse.getObjectSize().longValue(), + rewriteReponse.getDone(), + rewriteReponse.getRewriteToken(), + rewriteReponse.getTotalBytesRewritten().longValue()); + } catch (IOException ex) { + throw translate(ex); + } + } } diff --git a/gcloud-java-storage/src/main/java/com/google/gcloud/spi/StorageRpc.java b/gcloud-java-storage/src/main/java/com/google/gcloud/spi/StorageRpc.java index b7ac99bf909e..40382a857fca 100644 --- a/gcloud-java-storage/src/main/java/com/google/gcloud/spi/StorageRpc.java +++ b/gcloud-java-storage/src/main/java/com/google/gcloud/spi/StorageRpc.java @@ -27,6 +27,7 @@ import java.io.InputStream; import java.util.List; import java.util.Map; +import java.util.Objects; public interface StorageRpc { @@ -132,6 +133,89 @@ public BatchResponse(Map> delete } } + class RewriteRequest { + + public final StorageObject source; + public final Map sourceOptions; + public final StorageObject target; + public final Map targetOptions; + public final Long megabytesRewrittenPerCall; + + public RewriteRequest(StorageObject source, Map sourceOptions, + StorageObject target, Map targetOptions, + Long megabytesRewrittenPerCall) { + this.source = source; + this.sourceOptions = sourceOptions; + this.target = target; + this.targetOptions = targetOptions; + this.megabytesRewrittenPerCall = megabytesRewrittenPerCall; + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (!(obj instanceof RewriteRequest)) { + return false; + } + final RewriteRequest other = (RewriteRequest) obj; + return Objects.equals(this.source, other.source) + && Objects.equals(this.sourceOptions, other.sourceOptions) + && Objects.equals(this.target, other.target) + && Objects.equals(this.targetOptions, other.targetOptions) + && Objects.equals(this.megabytesRewrittenPerCall, other.megabytesRewrittenPerCall); + } + + @Override + public int hashCode() { + return Objects.hash(source, sourceOptions, target, targetOptions, megabytesRewrittenPerCall); + } + } + + class RewriteResponse { + + public final RewriteRequest rewriteRequest; + public final StorageObject result; + public final long blobSize; + public final boolean isDone; + public final String rewriteToken; + public final long totalBytesRewritten; + + public RewriteResponse(RewriteRequest rewriteRequest, StorageObject result, long blobSize, + boolean isDone, String rewriteToken, long totalBytesRewritten) { + this.rewriteRequest = rewriteRequest; + this.result = result; + this.blobSize = blobSize; + this.isDone = isDone; + this.rewriteToken = rewriteToken; + this.totalBytesRewritten = totalBytesRewritten; + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (!(obj instanceof RewriteResponse)) { + return false; + } + final RewriteResponse other = (RewriteResponse) obj; + return Objects.equals(this.rewriteRequest, other.rewriteRequest) + && Objects.equals(this.result, other.result) + && Objects.equals(this.rewriteToken, other.rewriteToken) + && this.blobSize == other.blobSize + && Objects.equals(this.isDone, other.isDone) + && this.totalBytesRewritten == other.totalBytesRewritten; + } + + @Override + public int hashCode() { + return Objects.hash(rewriteRequest, result, blobSize, isDone, rewriteToken, + totalBytesRewritten); + } + } + Bucket create(Bucket bucket, Map options) throws StorageException; StorageObject create(StorageObject object, InputStream content, Map options) @@ -161,9 +245,6 @@ StorageObject patch(StorageObject storageObject, Map options) StorageObject compose(Iterable sources, StorageObject target, Map targetOptions) throws StorageException; - StorageObject copy(StorageObject source, Map sourceOptions, - StorageObject target, Map targetOptions) throws StorageException; - byte[] load(StorageObject storageObject, Map options) throws StorageException; @@ -174,4 +255,8 @@ byte[] read(StorageObject from, Map options, long position, int bytes void write(String uploadId, byte[] toWrite, int toWriteOffset, StorageObject dest, long destOffset, int length, boolean last) throws StorageException; + + RewriteResponse openRewrite(RewriteRequest rewriteRequest) throws StorageException; + + RewriteResponse continueRewrite(RewriteResponse previousResponse) throws StorageException; } diff --git a/gcloud-java-storage/src/main/java/com/google/gcloud/storage/Blob.java b/gcloud-java-storage/src/main/java/com/google/gcloud/storage/Blob.java index bc75408997f4..284a7818457c 100644 --- a/gcloud-java-storage/src/main/java/com/google/gcloud/storage/Blob.java +++ b/gcloud-java-storage/src/main/java/com/google/gcloud/storage/Blob.java @@ -213,19 +213,20 @@ public Blob update(BlobInfo blobInfo, BlobTargetOption... options) { } /** - * Copies this blob to the specified target. Possibly copying also some of the metadata - * (e.g. content-type). + * Sends a copy request for the current blob to the target blob. Possibly also some of the + * metadata are copied (e.g. content-type). * * @param targetBlob target blob's id * @param options source blob options - * @return the copied blob + * @return a {@link CopyWriter} object that can be used to get information on the newly created + * blob or to complete the copy if more than one RPC request is needed * @throws StorageException upon failure */ - public Blob copyTo(BlobId targetBlob, BlobSourceOption... options) { - BlobInfo updatedInfo = info.toBuilder().blobId(targetBlob).build(); + public CopyWriter copyTo(BlobId targetBlob, BlobSourceOption... options) { + BlobInfo updatedInfo = BlobInfo.builder(targetBlob).build(); CopyRequest copyRequest = CopyRequest.builder().source(info.bucket(), info.name()) .sourceOptions(convert(info, options)).target(updatedInfo).build(); - return new Blob(storage, storage.copy(copyRequest)); + return storage.copy(copyRequest); } /** @@ -240,33 +241,35 @@ public boolean delete(BlobSourceOption... options) { } /** - * Copies this blob to the target bucket, preserving its name. Possibly copying also some of the - * metadata (e.g. content-type). + * Sends a copy request for the current blob to the target bucket, preserving its name. Possibly + * copying also some of the metadata (e.g. content-type). * * @param targetBucket target bucket's name * @param options source blob options - * @return the copied blob + * @return a {@link CopyWriter} object that can be used to get information on the newly created + * blob or to complete the copy if more than one RPC request is needed * @throws StorageException upon failure */ - public Blob copyTo(String targetBucket, BlobSourceOption... options) { + public CopyWriter copyTo(String targetBucket, BlobSourceOption... options) { return copyTo(targetBucket, info.name(), options); } /** - * Copies this blob to the target bucket with a new name. Possibly copying also some of the - * metadata (e.g. content-type). + * Sends a copy request for the current blob to the target blob. Possibly also some of the + * metadata are copied (e.g. content-type). * * @param targetBucket target bucket's name * @param targetBlob target blob's name * @param options source blob options - * @return the copied blob + * @return a {@link CopyWriter} object that can be used to get information on the newly created + * blob or to complete the copy if more than one RPC request is needed * @throws StorageException upon failure */ - public Blob copyTo(String targetBucket, String targetBlob, BlobSourceOption... options) { - BlobInfo updatedInfo = info.toBuilder().blobId(BlobId.of(targetBucket, targetBlob)).build(); + public CopyWriter copyTo(String targetBucket, String targetBlob, BlobSourceOption... options) { + BlobInfo updatedInfo = BlobInfo.builder(targetBucket, targetBlob).build(); CopyRequest copyRequest = CopyRequest.builder().source(info.bucket(), info.name()) .sourceOptions(convert(info, options)).target(updatedInfo).build(); - return new Blob(storage, storage.copy(copyRequest)); + return storage.copy(copyRequest); } /** diff --git a/gcloud-java-storage/src/main/java/com/google/gcloud/storage/CopyWriter.java b/gcloud-java-storage/src/main/java/com/google/gcloud/storage/CopyWriter.java new file mode 100644 index 000000000000..142f8d4b6de7 --- /dev/null +++ b/gcloud-java-storage/src/main/java/com/google/gcloud/storage/CopyWriter.java @@ -0,0 +1,276 @@ +/* + * Copyright 2015 Google Inc. All Rights Reserved. + * + * 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.gcloud.storage; + +import static com.google.gcloud.RetryHelper.runWithRetries; + +import com.google.common.base.MoreObjects; +import com.google.gcloud.Restorable; +import com.google.gcloud.RestorableState; +import com.google.gcloud.RetryHelper; +import com.google.gcloud.spi.StorageRpc; +import com.google.gcloud.spi.StorageRpc.RewriteRequest; +import com.google.gcloud.spi.StorageRpc.RewriteResponse; + +import java.io.Serializable; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.Callable; + +/** + * Google Storage blob copy writer. This class holds the result of a copy request. If source and + * destination blobs share the same location and storage class the copy is completed in one RPC call + * otherwise one or more {@link #copyChunk} calls are necessary to complete the copy. In addition, + * {@link CopyWriter#result()} can be used to automatically complete the copy and return information + * on the newly created blob. + * + * @see Rewrite + */ +public class CopyWriter implements Restorable { + + private final StorageOptions serviceOptions; + private final StorageRpc storageRpc; + private RewriteResponse rewriteResponse; + + CopyWriter(StorageOptions serviceOptions, RewriteResponse rewriteResponse) { + this.serviceOptions = serviceOptions; + this.rewriteResponse = rewriteResponse; + this.storageRpc = serviceOptions.rpc(); + } + + /** + * Returns the updated information for the written blob. Calling this method when {@code isDone()} + * is {@code false} will block until all pending chunks are copied. + *

+ * This method has the same effect of doing: + *

    {@code while (!copyWriter.isDone()) {
+   *        copyWriter.copyChunk();
+   *    }}
+   * 
+ * + * @throws StorageException upon failure + */ + public BlobInfo result() { + while (!isDone()) { + copyChunk(); + } + return BlobInfo.fromPb(rewriteResponse.result); + } + + /** + * Returns the size of the blob being copied. + */ + public long blobSize() { + return rewriteResponse.blobSize; + } + + /** + * Returns {@code true} of blob rewrite finished, {@code false} otherwise. + */ + public boolean isDone() { + return rewriteResponse.isDone; + } + + /** + * Returns the number of bytes copied. + */ + public long totalBytesCopied() { + return rewriteResponse.totalBytesRewritten; + } + + /** + * Copies the next chunk of the blob. An RPC is issued only if copy has not finished yet + * ({@link #isDone} returns {@code false}). + * + * @throws StorageException upon failure + */ + public void copyChunk() { + if (!isDone()) { + try { + this.rewriteResponse = runWithRetries(new Callable() { + @Override + public RewriteResponse call() { + return storageRpc.continueRewrite(rewriteResponse); + } + }, serviceOptions.retryParams(), StorageImpl.EXCEPTION_HANDLER); + } catch (RetryHelper.RetryHelperException e) { + throw StorageException.translateAndThrow(e); + } + } + } + + @Override + public RestorableState capture() { + return StateImpl.builder( + serviceOptions, + BlobId.fromPb(rewriteResponse.rewriteRequest.source), + rewriteResponse.rewriteRequest.sourceOptions, + BlobInfo.fromPb(rewriteResponse.rewriteRequest.target), + rewriteResponse.rewriteRequest.targetOptions) + .blobSize(blobSize()) + .isDone(isDone()) + .megabytesCopiedPerChunk(rewriteResponse.rewriteRequest.megabytesRewrittenPerCall) + .rewriteToken(rewriteResponse.rewriteToken) + .totalBytesRewritten(totalBytesCopied()) + .build(); + } + + static class StateImpl implements RestorableState, Serializable { + + private static final long serialVersionUID = 8279287678903181701L; + + private final StorageOptions serviceOptions; + private final BlobId source; + private final Map sourceOptions; + private final BlobInfo target; + private final Map targetOptions; + private final BlobInfo result; + private final long blobSize; + private final boolean isDone; + private final String rewriteToken; + private final long totalBytesCopied; + private final Long megabytesCopiedPerChunk; + + StateImpl(Builder builder) { + this.serviceOptions = builder.serviceOptions; + this.source = builder.source; + this.sourceOptions = builder.sourceOptions; + this.target = builder.target; + this.targetOptions = builder.targetOptions; + this.result = builder.result; + this.blobSize = builder.blobSize; + this.isDone = builder.isDone; + this.rewriteToken = builder.rewriteToken; + this.totalBytesCopied = builder.totalBytesCopied; + this.megabytesCopiedPerChunk = builder.megabytesCopiedPerChunk; + } + + static class Builder { + + private final StorageOptions serviceOptions; + private final BlobId source; + private final Map sourceOptions; + private final BlobInfo target; + private final Map targetOptions; + private BlobInfo result; + private long blobSize; + private boolean isDone; + private String rewriteToken; + private long totalBytesCopied; + private Long megabytesCopiedPerChunk; + + private Builder(StorageOptions options, BlobId source, + Map sourceOptions, + BlobInfo target, Map targetOptions) { + this.serviceOptions = options; + this.source = source; + this.sourceOptions = sourceOptions; + this.target = target; + this.targetOptions = targetOptions; + } + + Builder result(BlobInfo result) { + this.result = result; + return this; + } + + Builder blobSize(long blobSize) { + this.blobSize = blobSize; + return this; + } + + Builder isDone(boolean isDone) { + this.isDone = isDone; + return this; + } + + Builder rewriteToken(String rewriteToken) { + this.rewriteToken = rewriteToken; + return this; + } + + Builder totalBytesRewritten(long totalBytesRewritten) { + this.totalBytesCopied = totalBytesRewritten; + return this; + } + + Builder megabytesCopiedPerChunk(Long megabytesCopiedPerChunk) { + this.megabytesCopiedPerChunk = megabytesCopiedPerChunk; + return this; + } + + RestorableState build() { + return new StateImpl(this); + } + } + + static Builder builder(StorageOptions options, BlobId source, + Map sourceOptions, BlobInfo target, + Map targetOptions) { + return new Builder(options, source, sourceOptions, target, targetOptions); + } + + @Override + public CopyWriter restore() { + RewriteRequest rewriteRequest = new RewriteRequest( + source.toPb(), sourceOptions, target.toPb(), targetOptions, megabytesCopiedPerChunk); + RewriteResponse rewriteResponse = new RewriteResponse(rewriteRequest, + result != null ? result.toPb() : null, blobSize, isDone, rewriteToken, + totalBytesCopied); + return new CopyWriter(serviceOptions, rewriteResponse); + } + + @Override + public int hashCode() { + return Objects.hash(serviceOptions, source, sourceOptions, target, targetOptions, result, + blobSize, isDone, megabytesCopiedPerChunk, rewriteToken, totalBytesCopied); + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (!(obj instanceof StateImpl)) { + return false; + } + final StateImpl other = (StateImpl) obj; + return Objects.equals(this.serviceOptions, other.serviceOptions) + && Objects.equals(this.source, other.source) + && Objects.equals(this.sourceOptions, other.sourceOptions) + && Objects.equals(this.target, other.target) + && Objects.equals(this.targetOptions, other.targetOptions) + && Objects.equals(this.result, other.result) + && Objects.equals(this.rewriteToken, other.rewriteToken) + && Objects.equals(this.megabytesCopiedPerChunk, other.megabytesCopiedPerChunk) + && this.blobSize == other.blobSize + && this.isDone == other.isDone + && this.totalBytesCopied == other.totalBytesCopied; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("source", source) + .add("target", target) + .add("isDone", isDone) + .add("totalBytesRewritten", totalBytesCopied) + .add("blobSize", blobSize) + .toString(); + } + } +} diff --git a/gcloud-java-storage/src/main/java/com/google/gcloud/storage/Storage.java b/gcloud-java-storage/src/main/java/com/google/gcloud/storage/Storage.java index 7f96ba90fc1f..98698107a205 100644 --- a/gcloud-java-storage/src/main/java/com/google/gcloud/storage/Storage.java +++ b/gcloud-java-storage/src/main/java/com/google/gcloud/storage/Storage.java @@ -494,12 +494,13 @@ public static Builder builder() { class CopyRequest implements Serializable { - private static final long serialVersionUID = -2606508373751748775L; + private static final long serialVersionUID = -4498650529476219937L; private final BlobId source; private final List sourceOptions; private final BlobInfo target; private final List targetOptions; + private final Long megabytesCopiedPerChunk; public static class Builder { @@ -507,42 +508,93 @@ public static class Builder { private final Set targetOptions = new LinkedHashSet<>(); private BlobId source; private BlobInfo target; + private Long megabytesCopiedPerChunk; + /** + * Sets the blob to copy given bucket and blob name. + * + * @return the builder. + */ public Builder source(String bucket, String blob) { this.source = BlobId.of(bucket, blob); return this; } + /** + * Sets the blob to copy given a {@link BlobId}. + * + * @return the builder. + */ public Builder source(BlobId source) { this.source = source; return this; } + /** + * Sets blob's source options. + * + * @return the builder. + */ public Builder sourceOptions(BlobSourceOption... options) { Collections.addAll(sourceOptions, options); return this; } + /** + * Sets blob's source options. + * + * @return the builder. + */ public Builder sourceOptions(Iterable options) { Iterables.addAll(sourceOptions, options); return this; } + /** + * Sets the copy target. + * + * @return the builder. + */ public Builder target(BlobInfo target) { this.target = target; return this; } + /** + * Sets blob's target options. + * + * @return the builder. + */ public Builder targetOptions(BlobTargetOption... options) { Collections.addAll(targetOptions, options); return this; } + /** + * Sets blob's target options. + * + * @return the builder. + */ public Builder targetOptions(Iterable options) { Iterables.addAll(targetOptions, options); return this; } + /** + * Sets the maximum number of megabytes to copy for each RPC call. This parameter is ignored + * if source and target blob share the same location and storage class as copy is made with + * one single RPC. + * + * @return the builder. + */ + public Builder megabytesCopiedPerChunk(Long megabytesCopiedPerChunk) { + this.megabytesCopiedPerChunk = megabytesCopiedPerChunk; + return this; + } + + /** + * Creates a {@code CopyRequest}. + */ public CopyRequest build() { checkNotNull(source); checkNotNull(target); @@ -555,24 +607,46 @@ private CopyRequest(Builder builder) { sourceOptions = ImmutableList.copyOf(builder.sourceOptions); target = checkNotNull(builder.target); targetOptions = ImmutableList.copyOf(builder.targetOptions); + megabytesCopiedPerChunk = builder.megabytesCopiedPerChunk; } + /** + * Returns the blob to rewrite, as a {@link BlobId}. + */ public BlobId source() { return source; } + /** + * Returns blob's source options. + */ public List sourceOptions() { return sourceOptions; } + /** + * Returns the rewrite target. + */ public BlobInfo target() { return target; } + /** + * Returns blob's target options. + */ public List targetOptions() { return targetOptions; } + /** + * Returns the maximum number of megabytes to copy for each RPC call. This parameter is ignored + * if source and target blob share the same location and storage class as copy is made with + * one single RPC. + */ + public Long megabytesCopiedPerChunk() { + return megabytesCopiedPerChunk; + } + public static CopyRequest of(String sourceBucket, String sourceBlob, BlobInfo target) { return builder().source(sourceBucket, sourceBlob).target(target).build(); } @@ -755,12 +829,31 @@ public static Builder builder() { BlobInfo compose(ComposeRequest composeRequest); /** - * Send a copy request. + * Sends a copy request. Returns a {@link CopyWriter} object for the provided + * {@code CopyRequest}. If source and destination objects share the same location and storage + * class the source blob is copied with one request and {@link CopyWriter#result()} immediately + * returns, regardless of the {@link CopyRequest#megabytesCopiedPerChunk} parameter. + * If source and destination have different location or storage class {@link CopyWriter#result()} + * might issue multiple RPC calls depending on blob's size. + *

+ * Example usage of copy: + *

    {@code BlobInfo blob = service.copy(copyRequest).result();}
+   * 
+ * To explicitly issue chunk copy requests use {@link CopyWriter#copyChunk()} instead: + *
    {@code CopyWriter copyWriter = service.copy(copyRequest);
+   *    while (!copyWriter.isDone()) {
+   *        copyWriter.copyChunk();
+   *    }
+   *    BlobInfo blob = copyWriter.result();
+   * }
+   * 
* - * @return the copied blob. + * @return a {@link CopyWriter} object that can be used to get information on the newly created + * blob or to complete the copy if more than one RPC request is needed * @throws StorageException upon failure + * @see Rewrite */ - BlobInfo copy(CopyRequest copyRequest); + CopyWriter copy(CopyRequest copyRequest); /** * Reads all the bytes from a blob. diff --git a/gcloud-java-storage/src/main/java/com/google/gcloud/storage/StorageImpl.java b/gcloud-java-storage/src/main/java/com/google/gcloud/storage/StorageImpl.java index 21cd8b726753..ab85dc8b4609 100644 --- a/gcloud-java-storage/src/main/java/com/google/gcloud/storage/StorageImpl.java +++ b/gcloud-java-storage/src/main/java/com/google/gcloud/storage/StorageImpl.java @@ -49,6 +49,7 @@ import com.google.gcloud.ExceptionHandler.Interceptor; import com.google.gcloud.RetryHelper.RetryHelperException; import com.google.gcloud.spi.StorageRpc; +import com.google.gcloud.spi.StorageRpc.RewriteResponse; import com.google.gcloud.spi.StorageRpc.Tuple; import java.io.ByteArrayInputStream; @@ -444,21 +445,22 @@ public StorageObject call() { } @Override - public BlobInfo copy(CopyRequest copyRequest) { + public CopyWriter copy(final CopyRequest copyRequest) { final StorageObject source = copyRequest.source().toPb(); - copyRequest.sourceOptions(); final Map sourceOptions = optionMap(null, null, copyRequest.sourceOptions(), true); final StorageObject target = copyRequest.target().toPb(); final Map targetOptions = optionMap(copyRequest.target().generation(), copyRequest.target().metageneration(), copyRequest.targetOptions()); try { - return BlobInfo.fromPb(runWithRetries(new Callable() { + RewriteResponse rewriteResponse = runWithRetries(new Callable() { @Override - public StorageObject call() { - return storageRpc.copy(source, sourceOptions, target, targetOptions); + public RewriteResponse call() { + return storageRpc.openRewrite(new StorageRpc.RewriteRequest(source, sourceOptions, target, + targetOptions, copyRequest.megabytesCopiedPerChunk())); } - }, options().retryParams(), EXCEPTION_HANDLER)); + }, options().retryParams(), EXCEPTION_HANDLER); + return new CopyWriter(options(), rewriteResponse); } catch (RetryHelperException e) { throw StorageException.translateAndThrow(e); } diff --git a/gcloud-java-storage/src/test/java/com/google/gcloud/storage/BlobTest.java b/gcloud-java-storage/src/test/java/com/google/gcloud/storage/BlobTest.java index dddbb763f04c..defb1d35e3f4 100644 --- a/gcloud-java-storage/src/test/java/com/google/gcloud/storage/BlobTest.java +++ b/gcloud-java-storage/src/test/java/com/google/gcloud/storage/BlobTest.java @@ -31,10 +31,12 @@ import com.google.api.client.util.Lists; import com.google.gcloud.storage.Storage.CopyRequest; + import org.easymock.Capture; import org.junit.After; import org.junit.Before; import org.junit.Test; + import java.net.URL; import java.util.Arrays; import java.util.List; @@ -119,42 +121,48 @@ public void testDelete() throws Exception { @Test public void testCopyToBucket() throws Exception { - BlobInfo target = BLOB_INFO.toBuilder().blobId(BlobId.of("bt", "n")).build(); + BlobInfo target = BlobInfo.builder(BlobId.of("bt", "n")).build(); + CopyWriter copyWriter = createMock(CopyWriter.class); Capture capturedCopyRequest = Capture.newInstance(); - expect(storage.copy(capture(capturedCopyRequest))).andReturn(target); + expect(storage.copy(capture(capturedCopyRequest))).andReturn(copyWriter); replay(storage); - Blob targetBlob = blob.copyTo("bt"); - assertEquals(target, targetBlob.info()); + CopyWriter returnedCopyWriter = blob.copyTo("bt"); + assertEquals(copyWriter, returnedCopyWriter); assertEquals(capturedCopyRequest.getValue().source(), blob.id()); assertEquals(capturedCopyRequest.getValue().target(), target); - assertSame(storage, targetBlob.storage()); + assertTrue(capturedCopyRequest.getValue().sourceOptions().isEmpty()); + assertTrue(capturedCopyRequest.getValue().targetOptions().isEmpty()); } @Test public void testCopyTo() throws Exception { - BlobInfo target = BLOB_INFO.toBuilder().blobId(BlobId.of("bt", "nt")).build(); + BlobInfo target = BlobInfo.builder(BlobId.of("bt", "nt")).build(); + CopyWriter copyWriter = createMock(CopyWriter.class); Capture capturedCopyRequest = Capture.newInstance(); - expect(storage.copy(capture(capturedCopyRequest))).andReturn(target); + expect(storage.copy(capture(capturedCopyRequest))).andReturn(copyWriter); replay(storage); - Blob targetBlob = blob.copyTo("bt", "nt"); - assertEquals(target, targetBlob.info()); + CopyWriter returnedCopyWriter = blob.copyTo("bt", "nt"); + assertEquals(copyWriter, returnedCopyWriter); assertEquals(capturedCopyRequest.getValue().source(), blob.id()); assertEquals(capturedCopyRequest.getValue().target(), target); - assertSame(storage, targetBlob.storage()); + assertTrue(capturedCopyRequest.getValue().sourceOptions().isEmpty()); + assertTrue(capturedCopyRequest.getValue().targetOptions().isEmpty()); } @Test public void testCopyToBlobId() throws Exception { BlobId targetId = BlobId.of("bt", "nt"); - BlobInfo target = BLOB_INFO.toBuilder().blobId(targetId).build(); + CopyWriter copyWriter = createMock(CopyWriter.class); + BlobInfo target = BLOB_INFO.builder(targetId).build(); Capture capturedCopyRequest = Capture.newInstance(); - expect(storage.copy(capture(capturedCopyRequest))).andReturn(target); + expect(storage.copy(capture(capturedCopyRequest))).andReturn(copyWriter); replay(storage); - Blob targetBlob = blob.copyTo(targetId); - assertEquals(target, targetBlob.info()); + CopyWriter returnedCopyWriter = blob.copyTo(targetId); + assertEquals(copyWriter, returnedCopyWriter); assertEquals(capturedCopyRequest.getValue().source(), blob.id()); assertEquals(capturedCopyRequest.getValue().target(), target); - assertSame(storage, targetBlob.storage()); + assertTrue(capturedCopyRequest.getValue().sourceOptions().isEmpty()); + assertTrue(capturedCopyRequest.getValue().targetOptions().isEmpty()); } @Test diff --git a/gcloud-java-storage/src/test/java/com/google/gcloud/storage/CopyWriterTest.java b/gcloud-java-storage/src/test/java/com/google/gcloud/storage/CopyWriterTest.java new file mode 100644 index 000000000000..0fcdb744c244 --- /dev/null +++ b/gcloud-java-storage/src/test/java/com/google/gcloud/storage/CopyWriterTest.java @@ -0,0 +1,124 @@ +/* + * Copyright 2015 Google Inc. All Rights Reserved. + * + * 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.gcloud.storage; + +import static org.easymock.EasyMock.anyObject; +import static org.easymock.EasyMock.createMock; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.replay; +import static org.easymock.EasyMock.verify; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertEquals; + +import com.google.common.collect.ImmutableMap; +import com.google.gcloud.RestorableState; +import com.google.gcloud.spi.StorageRpc; +import com.google.gcloud.spi.StorageRpc.RewriteRequest; +import com.google.gcloud.spi.StorageRpc.RewriteResponse; +import com.google.gcloud.spi.StorageRpcFactory; + +import org.easymock.EasyMock; +import org.junit.After; +import org.junit.Test; +import org.junit.Before; + +import java.io.IOException; +import java.util.Map; + +public class CopyWriterTest { + + private static final String SOURCE_BUCKET_NAME = "b"; + private static final String SOURCE_BLOB_NAME = "n"; + private static final String DESTINATION_BUCKET_NAME = "b1"; + private static final String DESTINATION_BLOB_NAME = "n1"; + private static final BlobId BLOB_ID = BlobId.of(SOURCE_BUCKET_NAME, SOURCE_BLOB_NAME); + private static final BlobInfo BLOB_INFO = + BlobInfo.builder(DESTINATION_BUCKET_NAME, DESTINATION_BLOB_NAME).build(); + private static final BlobInfo RESULT = + BlobInfo.builder(DESTINATION_BUCKET_NAME, DESTINATION_BLOB_NAME).contentType("type").build(); + private static final Map EMPTY_OPTIONS = ImmutableMap.of(); + private static final RewriteRequest REQUEST = new StorageRpc.RewriteRequest(BLOB_ID.toPb(), + EMPTY_OPTIONS, BLOB_INFO.toPb(), EMPTY_OPTIONS, null); + private static final RewriteResponse RESPONSE = new StorageRpc.RewriteResponse(REQUEST, + null, 42L, false, "token", 21L); + private static final RewriteResponse RESPONSE_DONE = new StorageRpc.RewriteResponse(REQUEST, + RESULT.toPb(), 42L, true, "token", 42L); + + private StorageOptions options; + private StorageRpcFactory rpcFactoryMock; + private StorageRpc storageRpcMock; + private CopyWriter copyWriter; + + @Before + public void setUp() throws IOException, InterruptedException { + rpcFactoryMock = createMock(StorageRpcFactory.class); + storageRpcMock = createMock(StorageRpc.class); + expect(rpcFactoryMock.create(anyObject(StorageOptions.class))) + .andReturn(storageRpcMock); + replay(rpcFactoryMock); + options = StorageOptions.builder() + .projectId("projectid") + .serviceRpcFactory(rpcFactoryMock) + .build(); + } + + @After + public void tearDown() throws Exception { + verify(rpcFactoryMock, storageRpcMock); + } + + @Test + public void testRewrite() { + EasyMock.expect(storageRpcMock.continueRewrite(RESPONSE)).andReturn(RESPONSE_DONE); + EasyMock.replay(storageRpcMock); + copyWriter = new CopyWriter(options, RESPONSE); + assertEquals(RESULT, copyWriter.result()); + assertTrue(copyWriter.isDone()); + assertEquals(42L, copyWriter.totalBytesCopied()); + assertEquals(42L, copyWriter.blobSize()); + } + + @Test + public void testRewriteMultipleRequests() { + EasyMock.expect(storageRpcMock.continueRewrite(RESPONSE)).andReturn(RESPONSE); + EasyMock.expect(storageRpcMock.continueRewrite(RESPONSE)).andReturn(RESPONSE_DONE); + EasyMock.replay(storageRpcMock); + copyWriter = new CopyWriter(options, RESPONSE); + assertEquals(RESULT, copyWriter.result()); + assertTrue(copyWriter.isDone()); + assertEquals(42L, copyWriter.totalBytesCopied()); + assertEquals(42L, copyWriter.blobSize()); + } + + @Test + public void testSaveAndRestore() throws IOException { + EasyMock.expect(storageRpcMock.continueRewrite(RESPONSE)).andReturn(RESPONSE); + EasyMock.expect(storageRpcMock.continueRewrite(RESPONSE)).andReturn(RESPONSE_DONE); + EasyMock.replay(storageRpcMock); + copyWriter = new CopyWriter(options, RESPONSE); + copyWriter.copyChunk(); + assertTrue(!copyWriter.isDone()); + assertEquals(21L, copyWriter.totalBytesCopied()); + assertEquals(42L, copyWriter.blobSize()); + RestorableState rewriterState = copyWriter.capture(); + CopyWriter restoredRewriter = rewriterState.restore(); + assertEquals(RESULT, restoredRewriter.result()); + assertTrue(restoredRewriter.isDone()); + assertEquals(42L, restoredRewriter.totalBytesCopied()); + assertEquals(42L, restoredRewriter.blobSize()); + } +} diff --git a/gcloud-java-storage/src/test/java/com/google/gcloud/storage/ITStorageTest.java b/gcloud-java-storage/src/test/java/com/google/gcloud/storage/ITStorageTest.java index 7b29086ecbe5..8957ed2b8364 100644 --- a/gcloud-java-storage/src/test/java/com/google/gcloud/storage/ITStorageTest.java +++ b/gcloud-java-storage/src/test/java/com/google/gcloud/storage/ITStorageTest.java @@ -317,16 +317,22 @@ public void testComposeBlobFail() { @Test public void testCopyBlob() { String sourceBlobName = "test-copy-blob-source"; - BlobInfo blob = BlobInfo.builder(BUCKET, sourceBlobName).build(); + BlobId source = BlobId.of(BUCKET, sourceBlobName); + ImmutableMap metadata = ImmutableMap.of("k", "v"); + BlobInfo blob = BlobInfo.builder(source) + .contentType(CONTENT_TYPE) + .metadata(metadata) + .build(); assertNotNull(storage.create(blob, BLOB_BYTE_CONTENT)); String targetBlobName = "test-copy-blob-target"; - Storage.CopyRequest req = Storage.CopyRequest.of(blob.blobId(), targetBlobName); - BlobInfo remoteBlob = storage.copy(req); - assertNotNull(remoteBlob); - assertEquals(BUCKET, remoteBlob.bucket()); - assertEquals(targetBlobName, remoteBlob.name()); - byte[] readBytes = storage.readAllBytes(BUCKET, targetBlobName); - assertArrayEquals(BLOB_BYTE_CONTENT, readBytes); + BlobInfo target = BlobInfo.builder(BUCKET, targetBlobName).build(); + Storage.CopyRequest req = Storage.CopyRequest.of(source, target); + CopyWriter copyWriter = storage.copy(req); + assertEquals(BUCKET, copyWriter.result().bucket()); + assertEquals(targetBlobName, copyWriter.result().name()); + assertEquals(CONTENT_TYPE, copyWriter.result().contentType()); + assertEquals(metadata, copyWriter.result().metadata()); + assertTrue(copyWriter.isDone()); assertTrue(storage.delete(BUCKET, sourceBlobName)); assertTrue(storage.delete(BUCKET, targetBlobName)); } @@ -334,30 +340,36 @@ public void testCopyBlob() { @Test public void testCopyBlobUpdateMetadata() { String sourceBlobName = "test-copy-blob-update-metadata-source"; - BlobInfo sourceBlob = BlobInfo.builder(BUCKET, sourceBlobName).build(); - assertNotNull(storage.create(sourceBlob)); + BlobId source = BlobId.of(BUCKET, sourceBlobName); + assertNotNull(storage.create(BlobInfo.builder(source).build(), BLOB_BYTE_CONTENT)); String targetBlobName = "test-copy-blob-update-metadata-target"; - BlobInfo targetBlob = - BlobInfo.builder(BUCKET, targetBlobName).contentType(CONTENT_TYPE).build(); - Storage.CopyRequest req = Storage.CopyRequest.of(BUCKET, sourceBlobName, targetBlob); - BlobInfo remoteBlob = storage.copy(req); - assertNotNull(remoteBlob); - assertEquals(targetBlob.blobId(), remoteBlob.blobId()); - assertEquals(CONTENT_TYPE, remoteBlob.contentType()); + ImmutableMap metadata = ImmutableMap.of("k", "v"); + BlobInfo target = BlobInfo.builder(BUCKET, targetBlobName) + .contentType(CONTENT_TYPE) + .metadata(metadata) + .build(); + Storage.CopyRequest req = Storage.CopyRequest.of(source, target); + CopyWriter copyWriter = storage.copy(req); + assertEquals(BUCKET, copyWriter.result().bucket()); + assertEquals(targetBlobName, copyWriter.result().name()); + assertEquals(CONTENT_TYPE, copyWriter.result().contentType()); + assertEquals(metadata, copyWriter.result().metadata()); + assertTrue(copyWriter.isDone()); assertTrue(storage.delete(BUCKET, sourceBlobName)); assertTrue(storage.delete(BUCKET, targetBlobName)); } @Test public void testCopyBlobFail() { - String sourceBlobName = "test-copy-blob-fail-source"; - BlobInfo blob = BlobInfo.builder(BUCKET, sourceBlobName).build(); - assertNotNull(storage.create(blob)); - String targetBlobName = "test-copy-blob-fail-target"; - Storage.CopyRequest req = new Storage.CopyRequest.Builder() - .source(BUCKET, sourceBlobName) - .target(BlobInfo.builder(BUCKET, targetBlobName).build()) - .sourceOptions(Storage.BlobSourceOption.metagenerationMatch(-1L)) + String sourceBlobName = "test-copy-blob-source-fail"; + BlobId source = BlobId.of(BUCKET, sourceBlobName); + assertNotNull(storage.create(BlobInfo.builder(source).build(), BLOB_BYTE_CONTENT)); + String targetBlobName = "test-copy-blob-target-fail"; + BlobInfo target = BlobInfo.builder(BUCKET, targetBlobName).contentType(CONTENT_TYPE).build(); + Storage.CopyRequest req = Storage.CopyRequest.builder() + .source(source) + .sourceOptions(Storage.BlobSourceOption.generationMatch(-1L)) + .target(target) .build(); try { storage.copy(req); diff --git a/gcloud-java-storage/src/test/java/com/google/gcloud/storage/StorageImplTest.java b/gcloud-java-storage/src/test/java/com/google/gcloud/storage/StorageImplTest.java index 9c80d43396c0..910d614c64d7 100644 --- a/gcloud-java-storage/src/test/java/com/google/gcloud/storage/StorageImplTest.java +++ b/gcloud-java-storage/src/test/java/com/google/gcloud/storage/StorageImplTest.java @@ -38,6 +38,7 @@ import com.google.gcloud.spi.StorageRpc; import com.google.gcloud.spi.StorageRpc.Tuple; import com.google.gcloud.spi.StorageRpcFactory; +import com.google.gcloud.storage.Storage.CopyRequest; import org.easymock.Capture; import org.easymock.EasyMock; @@ -590,33 +591,62 @@ public void testComposeWithOptions() { @Test public void testCopy() { - Storage.CopyRequest req = Storage.CopyRequest.builder() - .source(BUCKET_NAME1, BLOB_NAME2) - .target(BLOB_INFO1) - .build(); - EasyMock.expect(storageRpcMock.copy(BLOB_INFO2.toPb(), EMPTY_RPC_OPTIONS, BLOB_INFO1.toPb(), - EMPTY_RPC_OPTIONS)).andReturn(BLOB_INFO1.toPb()); + CopyRequest request = Storage.CopyRequest.of(BLOB_INFO1.blobId(), BLOB_INFO2); + StorageRpc.RewriteRequest rpcRequest = new StorageRpc.RewriteRequest(request.source().toPb(), + EMPTY_RPC_OPTIONS, request.target().toPb(), EMPTY_RPC_OPTIONS, null); + StorageRpc.RewriteResponse rpcResponse = new StorageRpc.RewriteResponse(rpcRequest, null, 42L, + false, "token", 21L); + EasyMock.expect(storageRpcMock.openRewrite(rpcRequest)).andReturn(rpcResponse); EasyMock.replay(storageRpcMock); storage = options.service(); - BlobInfo blob = storage.copy(req); - assertEquals(BLOB_INFO1, blob); + CopyWriter writer = storage.copy(request); + assertEquals(42L, writer.blobSize()); + assertEquals(21L, writer.totalBytesCopied()); + assertTrue(!writer.isDone()); } @Test public void testCopyWithOptions() { - Storage.CopyRequest req = Storage.CopyRequest.builder() - .source(BUCKET_NAME1, BLOB_NAME2) + CopyRequest request = Storage.CopyRequest.builder() + .source(BLOB_INFO2.blobId()) .sourceOptions(BLOB_SOURCE_GENERATION, BLOB_SOURCE_METAGENERATION) .target(BLOB_INFO1) .targetOptions(BLOB_TARGET_GENERATION, BLOB_TARGET_METAGENERATION) .build(); - EasyMock.expect( - storageRpcMock.copy(BLOB_INFO2.toPb(), BLOB_SOURCE_OPTIONS_COPY, BLOB_INFO1.toPb(), - BLOB_TARGET_OPTIONS_COMPOSE)).andReturn(BLOB_INFO1.toPb()); + StorageRpc.RewriteRequest rpcRequest = new StorageRpc.RewriteRequest(request.source().toPb(), + BLOB_SOURCE_OPTIONS_COPY, request.target().toPb(), BLOB_TARGET_OPTIONS_COMPOSE, null); + StorageRpc.RewriteResponse rpcResponse = new StorageRpc.RewriteResponse(rpcRequest, null, 42L, + false, "token", 21L); + EasyMock.expect(storageRpcMock.openRewrite(rpcRequest)).andReturn(rpcResponse); EasyMock.replay(storageRpcMock); storage = options.service(); - BlobInfo blob = storage.copy(req); - assertEquals(BLOB_INFO1, blob); + CopyWriter writer = storage.copy(request); + assertEquals(42L, writer.blobSize()); + assertEquals(21L, writer.totalBytesCopied()); + assertTrue(!writer.isDone()); + } + + @Test + public void testCopyMultipleRequests() { + CopyRequest request = Storage.CopyRequest.of(BLOB_INFO1.blobId(), BLOB_INFO2); + StorageRpc.RewriteRequest rpcRequest = new StorageRpc.RewriteRequest(request.source().toPb(), + EMPTY_RPC_OPTIONS, request.target().toPb(), EMPTY_RPC_OPTIONS, null); + StorageRpc.RewriteResponse rpcResponse1 = new StorageRpc.RewriteResponse(rpcRequest, null, 42L, + false, "token", 21L); + StorageRpc.RewriteResponse rpcResponse2 = new StorageRpc.RewriteResponse(rpcRequest, + BLOB_INFO1.toPb(), 42L, true, "token", 42L); + EasyMock.expect(storageRpcMock.openRewrite(rpcRequest)).andReturn(rpcResponse1); + EasyMock.expect(storageRpcMock.continueRewrite(rpcResponse1)).andReturn(rpcResponse2); + EasyMock.replay(storageRpcMock); + storage = options.service(); + CopyWriter writer = storage.copy(request); + assertEquals(42L, writer.blobSize()); + assertEquals(21L, writer.totalBytesCopied()); + assertTrue(!writer.isDone()); + assertEquals(BLOB_INFO1, writer.result()); + assertTrue(writer.isDone()); + assertEquals(42L, writer.totalBytesCopied()); + assertEquals(42L, writer.blobSize()); } @Test