|
82 | 82 | import com.google.cloud.storage.StorageException;
|
83 | 83 | import com.google.cloud.storage.StorageOptions;
|
84 | 84 | import com.google.cloud.storage.StorageRoles;
|
| 85 | +import com.google.cloud.storage.spi.StorageRpcFactory; |
| 86 | +import com.google.cloud.storage.spi.v1.StorageRpc; |
| 87 | +import com.google.cloud.storage.spi.v1.StorageRpc.Option; |
85 | 88 | import com.google.cloud.storage.testing.RemoteStorageHelper;
|
86 | 89 | import com.google.common.collect.ImmutableList;
|
87 | 90 | import com.google.common.collect.ImmutableMap;
|
|
90 | 93 | import com.google.common.collect.Lists;
|
91 | 94 | import com.google.common.io.BaseEncoding;
|
92 | 95 | import com.google.common.io.ByteStreams;
|
| 96 | +import com.google.common.reflect.AbstractInvocationHandler; |
| 97 | +import com.google.common.reflect.Reflection; |
93 | 98 | import com.google.iam.v1.Binding;
|
94 | 99 | import com.google.iam.v1.IAMPolicyGrpc;
|
95 | 100 | import com.google.iam.v1.SetIamPolicyRequest;
|
|
107 | 112 | import java.io.FileOutputStream;
|
108 | 113 | import java.io.IOException;
|
109 | 114 | import java.io.InputStream;
|
| 115 | +import java.lang.reflect.Method; |
110 | 116 | import java.net.URL;
|
111 | 117 | import java.net.URLConnection;
|
112 | 118 | import java.nio.ByteBuffer;
|
| 119 | +import java.nio.charset.StandardCharsets; |
113 | 120 | import java.nio.file.Files;
|
114 | 121 | import java.nio.file.Path;
|
115 | 122 | import java.security.Key;
|
|
125 | 132 | import java.util.Set;
|
126 | 133 | import java.util.concurrent.ExecutionException;
|
127 | 134 | import java.util.concurrent.TimeUnit;
|
| 135 | +import java.util.concurrent.atomic.AtomicBoolean; |
128 | 136 | import java.util.logging.Level;
|
129 | 137 | import java.util.logging.Logger;
|
130 | 138 | import java.util.zip.GZIPInputStream;
|
|
139 | 147 | import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
|
140 | 148 | import org.junit.AfterClass;
|
141 | 149 | import org.junit.BeforeClass;
|
| 150 | +import org.junit.Rule; |
142 | 151 | import org.junit.Test;
|
| 152 | +import org.junit.rules.TestName; |
| 153 | +import org.threeten.bp.Clock; |
| 154 | +import org.threeten.bp.Instant; |
| 155 | +import org.threeten.bp.ZoneId; |
| 156 | +import org.threeten.bp.ZoneOffset; |
| 157 | +import org.threeten.bp.format.DateTimeFormatter; |
143 | 158 |
|
144 | 159 | public class ITStorageTest {
|
145 | 160 |
|
@@ -200,6 +215,8 @@ public class ITStorageTest {
|
200 | 215 | private static final ImmutableList<LifecycleRule> LIFECYCLE_RULES =
|
201 | 216 | ImmutableList.of(LIFECYCLE_RULE_1, LIFECYCLE_RULE_2);
|
202 | 217 |
|
| 218 | + @Rule public final TestName testName = new TestName(); |
| 219 | + |
203 | 220 | @BeforeClass
|
204 | 221 | public static void beforeClass() throws IOException {
|
205 | 222 | remoteStorageHelper = RemoteStorageHelper.create();
|
@@ -3813,4 +3830,125 @@ public void testWriterWithKmsKeyName() throws IOException {
|
3813 | 3830 | assertThat(blob.getKmsKeyName()).isNotNull();
|
3814 | 3831 | assertThat(storage.delete(BUCKET, blobName)).isTrue();
|
3815 | 3832 | }
|
| 3833 | + |
| 3834 | + @Test |
| 3835 | + public void blobWriteChannel_handlesRecoveryOnLastChunkWhenGenerationIsPresent_multipleChunks() |
| 3836 | + throws IOException { |
| 3837 | + int _2MiB = 256 * 1024; |
| 3838 | + int contentSize = 292_617; |
| 3839 | + |
| 3840 | + blobWriteChannel_handlesRecoveryOnLastChunkWhenGenerationIsPresent(_2MiB, contentSize); |
| 3841 | + } |
| 3842 | + |
| 3843 | + @Test |
| 3844 | + public void blobWriteChannel_handlesRecoveryOnLastChunkWhenGenerationIsPresent_singleChunk() |
| 3845 | + throws IOException { |
| 3846 | + int _4MiB = 256 * 1024 * 2; |
| 3847 | + int contentSize = 292_617; |
| 3848 | + |
| 3849 | + blobWriteChannel_handlesRecoveryOnLastChunkWhenGenerationIsPresent(_4MiB, contentSize); |
| 3850 | + } |
| 3851 | + |
| 3852 | + private void blobWriteChannel_handlesRecoveryOnLastChunkWhenGenerationIsPresent( |
| 3853 | + int chunkSize, int contentSize) throws IOException { |
| 3854 | + Instant now = Clock.systemUTC().instant(); |
| 3855 | + DateTimeFormatter formatter = |
| 3856 | + DateTimeFormatter.ISO_LOCAL_DATE_TIME.withZone(ZoneId.from(ZoneOffset.UTC)); |
| 3857 | + String nowString = formatter.format(now); |
| 3858 | + |
| 3859 | + String blobPath = String.format("%s/%s/blob", testName.getMethodName(), nowString); |
| 3860 | + BlobId blobId = BlobId.of(BUCKET, blobPath); |
| 3861 | + BlobInfo blobInfo = BlobInfo.newBuilder(blobId).build(); |
| 3862 | + |
| 3863 | + Random rand = new Random(1234567890); |
| 3864 | + String randString = randString(rand, contentSize); |
| 3865 | + final byte[] randStringBytes = randString.getBytes(StandardCharsets.UTF_8); |
| 3866 | + Storage storage = StorageOptions.getDefaultInstance().getService(); |
| 3867 | + WriteChannel ww = storage.writer(blobInfo); |
| 3868 | + ww.setChunkSize(chunkSize); |
| 3869 | + ww.write(ByteBuffer.wrap(randStringBytes)); |
| 3870 | + ww.close(); |
| 3871 | + |
| 3872 | + Blob blobGen1 = storage.get(blobId); |
| 3873 | + |
| 3874 | + final AtomicBoolean exceptionThrown = new AtomicBoolean(false); |
| 3875 | + |
| 3876 | + Storage testStorage = |
| 3877 | + StorageOptions.newBuilder() |
| 3878 | + .setServiceRpcFactory( |
| 3879 | + new StorageRpcFactory() { |
| 3880 | + /** |
| 3881 | + * Here we're creating a proxy of StorageRpc where we can delegate all calls to |
| 3882 | + * the normal implementation, except in the case of {@link |
| 3883 | + * StorageRpc#writeWithResponse(String, byte[], int, long, int, boolean)} where |
| 3884 | + * {@code lastChunk == true}. We allow the call to execute, but instead of |
| 3885 | + * returning the result we throw an IOException to simulate a prematurely close |
| 3886 | + * connection. This behavior is to ensure appropriate handling of a completed |
| 3887 | + * upload where the ACK wasn't received. In particular, if an upload is initiated |
| 3888 | + * against an object where an {@link Option#IF_GENERATION_MATCH} simply calling |
| 3889 | + * get on an object can result in a 404 because the object that is created while |
| 3890 | + * the BlobWriteChannel is executing will be a new generation. |
| 3891 | + */ |
| 3892 | + @SuppressWarnings("UnstableApiUsage") |
| 3893 | + @Override |
| 3894 | + public StorageRpc create(final StorageOptions options) { |
| 3895 | + return Reflection.newProxy( |
| 3896 | + StorageRpc.class, |
| 3897 | + new AbstractInvocationHandler() { |
| 3898 | + final StorageRpc delegate = |
| 3899 | + (StorageRpc) StorageOptions.getDefaultInstance().getRpc(); |
| 3900 | + |
| 3901 | + @Override |
| 3902 | + protected Object handleInvocation( |
| 3903 | + Object proxy, Method method, Object[] args) throws Throwable { |
| 3904 | + if ("writeWithResponse".equals(method.getName())) { |
| 3905 | + Object result = method.invoke(delegate, args); |
| 3906 | + boolean lastChunk = (boolean) args[5]; |
| 3907 | + // if we're on the lastChunk simulate a connection failure which |
| 3908 | + // happens after the request was processed but before response could |
| 3909 | + // be received by the client. |
| 3910 | + if (lastChunk) { |
| 3911 | + exceptionThrown.set(true); |
| 3912 | + throw StorageException.translate( |
| 3913 | + new IOException("simulated Connection closed prematurely")); |
| 3914 | + } else { |
| 3915 | + return result; |
| 3916 | + } |
| 3917 | + } |
| 3918 | + return method.invoke(delegate, args); |
| 3919 | + } |
| 3920 | + }); |
| 3921 | + } |
| 3922 | + }) |
| 3923 | + .build() |
| 3924 | + .getService(); |
| 3925 | + |
| 3926 | + try (WriteChannel w = testStorage.writer(blobGen1, BlobWriteOption.generationMatch())) { |
| 3927 | + w.setChunkSize(chunkSize); |
| 3928 | + |
| 3929 | + ByteBuffer buffer = ByteBuffer.wrap(randStringBytes); |
| 3930 | + w.write(buffer); |
| 3931 | + } |
| 3932 | + |
| 3933 | + assertTrue("Expected an exception to be thrown for the last chunk", exceptionThrown.get()); |
| 3934 | + |
| 3935 | + Blob blobGen2 = storage.get(blobId); |
| 3936 | + assertEquals(contentSize, (long) blobGen2.getSize()); |
| 3937 | + assertNotEquals(blobInfo.getGeneration(), blobGen2.getGeneration()); |
| 3938 | + ByteArrayOutputStream actualData = new ByteArrayOutputStream(); |
| 3939 | + blobGen2.downloadTo(actualData); |
| 3940 | + assertArrayEquals(randStringBytes, actualData.toByteArray()); |
| 3941 | + } |
| 3942 | + |
| 3943 | + private static String randString(Random rand, int length) { |
| 3944 | + final StringBuilder sb = new StringBuilder(); |
| 3945 | + while (sb.length() < length) { |
| 3946 | + int i = rand.nextInt('z'); |
| 3947 | + char c = (char) i; |
| 3948 | + if (Character.isLetter(c) || Character.isDigit(c)) { |
| 3949 | + sb.append(c); |
| 3950 | + } |
| 3951 | + } |
| 3952 | + return sb.toString(); |
| 3953 | + } |
3816 | 3954 | }
|
0 commit comments