Skip to content

Commit 0dbe034

Browse files
authored
Implement CAS support for GCP test fixture (elastic#118236)
Closes ES-5679
1 parent 74c91aa commit 0dbe034

File tree

9 files changed

+760
-178
lines changed

9 files changed

+760
-178
lines changed

modules/repository-gcs/src/main/java/org/elasticsearch/repositories/gcs/GoogleCloudStorageBlobContainer.java

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,13 +145,11 @@ public void compareAndExchangeRegister(
145145
BytesReference updated,
146146
ActionListener<OptionalBytesReference> listener
147147
) {
148-
if (skipCas(listener)) return;
149148
ActionListener.completeWith(listener, () -> blobStore.compareAndExchangeRegister(buildKey(key), path, key, expected, updated));
150149
}
151150

152151
@Override
153152
public void getRegister(OperationPurpose purpose, String key, ActionListener<OptionalBytesReference> listener) {
154-
if (skipCas(listener)) return;
155153
ActionListener.completeWith(listener, () -> blobStore.getRegister(buildKey(key), path, key));
156154
}
157155
}

modules/repository-gcs/src/test/java/org/elasticsearch/repositories/gcs/GoogleCloudStorageBlobContainerRetriesTests.java

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,6 @@
5959
import java.util.concurrent.atomic.AtomicInteger;
6060
import java.util.concurrent.atomic.AtomicReference;
6161

62-
import static fixture.gcs.GoogleCloudStorageHttpHandler.getContentRangeEnd;
63-
import static fixture.gcs.GoogleCloudStorageHttpHandler.getContentRangeLimit;
64-
import static fixture.gcs.GoogleCloudStorageHttpHandler.getContentRangeStart;
6562
import static fixture.gcs.GoogleCloudStorageHttpHandler.parseMultipartRequestBody;
6663
import static fixture.gcs.TestUtils.createServiceAccount;
6764
import static java.nio.charset.StandardCharsets.UTF_8;
@@ -369,14 +366,14 @@ public void testWriteLargeBlob() throws IOException {
369366

370367
assertThat(Math.toIntExact(requestBody.length()), anyOf(equalTo(defaultChunkSize), equalTo(lastChunkSize)));
371368

372-
final int rangeStart = getContentRangeStart(range);
373-
final int rangeEnd = getContentRangeEnd(range);
369+
final HttpHeaderParser.ContentRange contentRange = HttpHeaderParser.parseContentRangeHeader(range);
370+
final int rangeStart = Math.toIntExact(contentRange.start());
371+
final int rangeEnd = Math.toIntExact(contentRange.end());
374372
assertThat(rangeEnd + 1 - rangeStart, equalTo(Math.toIntExact(requestBody.length())));
375373
assertThat(new BytesArray(data, rangeStart, rangeEnd - rangeStart + 1), is(requestBody));
376374
bytesReceived.updateAndGet(existing -> Math.max(existing, rangeEnd));
377375

378-
final Integer limit = getContentRangeLimit(range);
379-
if (limit != null) {
376+
if (contentRange.size() != null) {
380377
exchange.sendResponseHeaders(RestStatus.OK.getStatus(), -1);
381378
return;
382379
} else {

server/src/main/java/org/elasticsearch/common/blobstore/support/AbstractBlobContainer.java

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99

1010
package org.elasticsearch.common.blobstore.support;
1111

12-
import org.elasticsearch.action.ActionListener;
1312
import org.elasticsearch.common.blobstore.BlobContainer;
1413
import org.elasticsearch.common.blobstore.BlobPath;
1514

@@ -24,17 +23,6 @@ protected AbstractBlobContainer(BlobPath path) {
2423
this.path = path;
2524
}
2625

27-
/**
28-
* Temporary check that permits disabling CAS operations at runtime; TODO remove this when no longer needed
29-
*/
30-
protected static boolean skipCas(ActionListener<?> listener) {
31-
if ("true".equals(System.getProperty("test.repository_test_kit.skip_cas"))) {
32-
listener.onFailure(new UnsupportedOperationException());
33-
return true;
34-
}
35-
return false;
36-
}
37-
3826
@Override
3927
public BlobPath path() {
4028
return this.path;

test/fixtures/gcs-fixture/src/main/java/fixture/gcs/GoogleCloudStorageHttpHandler.java

Lines changed: 94 additions & 90 deletions
Large diffs are not rendered by default.
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
package fixture.gcs;
11+
12+
import org.elasticsearch.ExceptionsHelper;
13+
import org.elasticsearch.common.UUIDs;
14+
import org.elasticsearch.common.bytes.BytesArray;
15+
import org.elasticsearch.common.bytes.BytesReference;
16+
import org.elasticsearch.common.bytes.CompositeBytesReference;
17+
import org.elasticsearch.rest.RestStatus;
18+
import org.elasticsearch.test.fixture.HttpHeaderParser;
19+
20+
import java.util.Map;
21+
import java.util.concurrent.ConcurrentHashMap;
22+
import java.util.concurrent.ConcurrentMap;
23+
import java.util.concurrent.atomic.AtomicReference;
24+
25+
public class MockGcsBlobStore {
26+
27+
private static final int RESUME_INCOMPLETE = 308;
28+
private final ConcurrentMap<String, BlobVersion> blobs = new ConcurrentHashMap<>();
29+
private final ConcurrentMap<String, ResumableUpload> resumableUploads = new ConcurrentHashMap<>();
30+
31+
record BlobVersion(String path, long generation, BytesReference contents) {}
32+
33+
record ResumableUpload(String uploadId, String path, Long ifGenerationMatch, BytesReference contents, boolean completed) {
34+
35+
public ResumableUpload update(BytesReference contents, boolean completed) {
36+
return new ResumableUpload(uploadId, path, ifGenerationMatch, contents, completed);
37+
}
38+
}
39+
40+
BlobVersion getBlob(String path, Long ifGenerationMatch) {
41+
final BlobVersion blob = blobs.get(path);
42+
if (blob == null) {
43+
throw new BlobNotFoundException(path);
44+
} else {
45+
if (ifGenerationMatch != null) {
46+
if (blob.generation != ifGenerationMatch) {
47+
throw new GcsRestException(
48+
RestStatus.PRECONDITION_FAILED,
49+
"Generation mismatch, expected " + ifGenerationMatch + " but got " + blob.generation
50+
);
51+
}
52+
}
53+
return blob;
54+
}
55+
}
56+
57+
BlobVersion updateBlob(String path, Long ifGenerationMatch, BytesReference contents) {
58+
return blobs.compute(path, (name, existing) -> {
59+
if (existing != null) {
60+
if (ifGenerationMatch != null) {
61+
if (ifGenerationMatch == 0) {
62+
throw new GcsRestException(
63+
RestStatus.PRECONDITION_FAILED,
64+
"Blob already exists at generation " + existing.generation
65+
);
66+
} else if (ifGenerationMatch != existing.generation) {
67+
throw new GcsRestException(
68+
RestStatus.PRECONDITION_FAILED,
69+
"Generation mismatch, expected " + ifGenerationMatch + ", got" + existing.generation
70+
);
71+
}
72+
}
73+
return new BlobVersion(path, existing.generation + 1, contents);
74+
} else {
75+
if (ifGenerationMatch != null && ifGenerationMatch != 0) {
76+
throw new GcsRestException(
77+
RestStatus.PRECONDITION_FAILED,
78+
"Blob does not exist, expected generation " + ifGenerationMatch
79+
);
80+
}
81+
return new BlobVersion(path, 1, contents);
82+
}
83+
});
84+
}
85+
86+
ResumableUpload createResumableUpload(String path, Long ifGenerationMatch) {
87+
final String uploadId = UUIDs.randomBase64UUID();
88+
final ResumableUpload value = new ResumableUpload(uploadId, path, ifGenerationMatch, BytesArray.EMPTY, false);
89+
resumableUploads.put(uploadId, value);
90+
return value;
91+
}
92+
93+
/**
94+
* Update or query a resumable upload
95+
*
96+
* @see <a href="https://cloud.google.com/storage/docs/resumable-uploads">GCS Resumable Uploads</a>
97+
* @param uploadId The upload ID
98+
* @param contentRange The range being submitted
99+
* @param requestBody The data for that range
100+
* @return The response to the request
101+
*/
102+
UpdateResponse updateResumableUpload(String uploadId, HttpHeaderParser.ContentRange contentRange, BytesReference requestBody) {
103+
final AtomicReference<UpdateResponse> updateResponse = new AtomicReference<>();
104+
resumableUploads.compute(uploadId, (uid, existing) -> {
105+
if (existing == null) {
106+
throw failAndThrow("Attempted to update a non-existent resumable: " + uid);
107+
}
108+
109+
if (contentRange.hasRange() == false) {
110+
// Content-Range: */... is a status check https://cloud.google.com/storage/docs/performing-resumable-uploads#status-check
111+
if (existing.completed) {
112+
updateResponse.set(new UpdateResponse(RestStatus.OK.getStatus(), calculateRangeHeader(blobs.get(existing.path))));
113+
} else {
114+
final HttpHeaderParser.Range range = calculateRangeHeader(existing);
115+
updateResponse.set(new UpdateResponse(RESUME_INCOMPLETE, range));
116+
}
117+
return existing;
118+
} else {
119+
if (contentRange.start() > contentRange.end()) {
120+
throw failAndThrow("Invalid content range " + contentRange);
121+
}
122+
if (contentRange.start() > existing.contents.length()) {
123+
throw failAndThrow(
124+
"Attempted to append after the end of the current content: size="
125+
+ existing.contents.length()
126+
+ ", start="
127+
+ contentRange.start()
128+
);
129+
}
130+
if (contentRange.end() < existing.contents.length()) {
131+
throw failAndThrow("Attempted to upload no new data");
132+
}
133+
final int offset = Math.toIntExact(existing.contents.length() - contentRange.start());
134+
final BytesReference updatedContent = CompositeBytesReference.of(
135+
existing.contents,
136+
requestBody.slice(offset, requestBody.length())
137+
);
138+
// We just received the last chunk, update the blob and remove the resumable upload from the map
139+
if (contentRange.hasSize() && updatedContent.length() == contentRange.size()) {
140+
updateBlob(existing.path(), existing.ifGenerationMatch, updatedContent);
141+
updateResponse.set(new UpdateResponse(RestStatus.OK.getStatus(), null));
142+
return existing.update(BytesArray.EMPTY, true);
143+
}
144+
final ResumableUpload updated = existing.update(updatedContent, false);
145+
updateResponse.set(new UpdateResponse(RESUME_INCOMPLETE, calculateRangeHeader(updated)));
146+
return updated;
147+
}
148+
});
149+
assert updateResponse.get() != null : "Should always produce an update response";
150+
return updateResponse.get();
151+
}
152+
153+
private static HttpHeaderParser.Range calculateRangeHeader(ResumableUpload resumableUpload) {
154+
return resumableUpload.contents.length() > 0 ? new HttpHeaderParser.Range(0, resumableUpload.contents.length() - 1) : null;
155+
}
156+
157+
private static HttpHeaderParser.Range calculateRangeHeader(BlobVersion blob) {
158+
return blob.contents.length() > 0 ? new HttpHeaderParser.Range(0, blob.contents.length() - 1) : null;
159+
}
160+
161+
record UpdateResponse(int statusCode, HttpHeaderParser.Range rangeHeader) {}
162+
163+
void deleteBlob(String path) {
164+
blobs.remove(path);
165+
}
166+
167+
Map<String, BlobVersion> listBlobs() {
168+
return Map.copyOf(blobs);
169+
}
170+
171+
static class BlobNotFoundException extends GcsRestException {
172+
173+
BlobNotFoundException(String path) {
174+
super(RestStatus.NOT_FOUND, "Blob not found: " + path);
175+
}
176+
}
177+
178+
static class GcsRestException extends RuntimeException {
179+
180+
private final RestStatus status;
181+
182+
GcsRestException(RestStatus status, String errorMessage) {
183+
super(errorMessage);
184+
this.status = status;
185+
}
186+
187+
public RestStatus getStatus() {
188+
return status;
189+
}
190+
}
191+
192+
/**
193+
* Fail the test with an assertion error and throw an exception in-line
194+
*
195+
* @param message The message to use on the {@link Throwable}s
196+
* @return nothing, but claim to return an exception to help with static analysis
197+
*/
198+
public static RuntimeException failAndThrow(String message) {
199+
ExceptionsHelper.maybeDieOnAnotherThread(new AssertionError(message));
200+
throw new IllegalStateException(message);
201+
}
202+
}

0 commit comments

Comments
 (0)