Skip to content

Commit c480d95

Browse files
committed
Fix S3 deletion bugs
Bug the first: `S3BlobContainer#delete` spuriously attempts to delete a blob named after the container, as if it were a directory in a filesystem. This makes sense for filesystem repositories but in S3 the blob key is an opaque string, may be a prefix of other blob keys, and can legitimately end in a `/`. We never create such a blob in the first place, but this was hidden because AWS S3 silently ignores these deletion requests, and also because... Bug the second: `S3HttpHandler` would delete extant blobs but ignore nonexistent blobs when processing a multi-object delete request. This is apparently how S3 behaves in practice but it's not documented as such so we cannot rely on it and must be stricter in our tests. Fixing this exposed... Bug the third: `S3BlobContainer#deleteBlobsIgnoringIfNotExists` wasn't actually ignoring `NoSuchKey` errors should any arise, and the S3 reference documentation does not proscribe this behaviour, so we must handle it properly.
1 parent a671505 commit c480d95

File tree

4 files changed

+94
-48
lines changed

4 files changed

+94
-48
lines changed

modules/repository-s3/src/main/java/org/elasticsearch/repositories/s3/S3BlobContainer.java

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,7 @@ public DeleteResult delete(OperationPurpose purpose) throws IOException {
399399
final AtomicLong deletedBytes = new AtomicLong();
400400
try (var clientReference = blobStore.clientReference()) {
401401
ListObjectsV2Response prevListing = null;
402-
while (true) {
402+
while (prevListing == null || prevListing.isTruncated()) {
403403
final var listObjectsRequestBuilder = ListObjectsV2Request.builder().bucket(blobStore.bucket()).prefix(keyPath);
404404
S3BlobStore.configureRequestForMetrics(listObjectsRequestBuilder, blobStore, Operation.LIST_OBJECTS, purpose);
405405
if (prevListing != null) {
@@ -412,13 +412,8 @@ public DeleteResult delete(OperationPurpose purpose) throws IOException {
412412
deletedBytes.addAndGet(s3Object.size());
413413
return s3Object.key();
414414
});
415-
if (listObjectsResponse.isTruncated()) {
416-
blobStore.deleteBlobs(purpose, blobNameIterator);
417-
prevListing = listObjectsResponse;
418-
} else {
419-
blobStore.deleteBlobs(purpose, Iterators.concat(blobNameIterator, Iterators.single(keyPath)));
420-
break;
421-
}
415+
blobStore.deleteBlobs(purpose, false, blobNameIterator);
416+
prevListing = listObjectsResponse;
422417
}
423418
} catch (final SdkException e) {
424419
throw new IOException("Exception when deleting blob container [" + keyPath + "]", e);
@@ -428,7 +423,7 @@ public DeleteResult delete(OperationPurpose purpose) throws IOException {
428423

429424
@Override
430425
public void deleteBlobsIgnoringIfNotExists(OperationPurpose purpose, Iterator<String> blobNames) throws IOException {
431-
blobStore.deleteBlobs(purpose, Iterators.map(blobNames, this::buildKey));
426+
blobStore.deleteBlobs(purpose, true, Iterators.map(blobNames, this::buildKey));
432427
}
433428

434429
@Override

modules/repository-s3/src/main/java/org/elasticsearch/repositories/s3/S3BlobStore.java

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@
1818
import software.amazon.awssdk.metrics.MetricCollection;
1919
import software.amazon.awssdk.metrics.MetricPublisher;
2020
import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest;
21+
import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse;
2122
import software.amazon.awssdk.services.s3.model.ObjectCannedACL;
2223
import software.amazon.awssdk.services.s3.model.ObjectIdentifier;
23-
import software.amazon.awssdk.services.s3.model.S3Error;
2424
import software.amazon.awssdk.services.s3.model.StorageClass;
2525

2626
import org.apache.logging.log4j.LogManager;
@@ -306,6 +306,11 @@ public BlobContainer blobContainer(BlobPath path) {
306306
private static class DeletionExceptions {
307307
Exception exception = null;
308308
private int count = 0;
309+
final boolean ignoreNoSuchKey;
310+
311+
DeletionExceptions(boolean ignoreNoSuchKey) {
312+
this.ignoreNoSuchKey = ignoreNoSuchKey;
313+
}
309314

310315
void useOrMaybeSuppress(Exception e) {
311316
if (count < MAX_DELETE_EXCEPTIONS) {
@@ -315,15 +320,15 @@ void useOrMaybeSuppress(Exception e) {
315320
}
316321
}
317322

318-
void deleteBlobs(OperationPurpose purpose, Iterator<String> blobNames) throws IOException {
323+
void deleteBlobs(OperationPurpose purpose, boolean ignoreNoSuchKey, Iterator<String> blobNames) throws IOException {
319324
if (blobNames.hasNext() == false) {
320325
return;
321326
}
322327

323328
final List<ObjectIdentifier> partition = new ArrayList<>();
324329
try {
325330
// S3 API only allows 1k blobs per delete so we split up the given blobs into requests of max. 1k deletes
326-
final var deletionExceptions = new DeletionExceptions();
331+
final var deletionExceptions = new DeletionExceptions(ignoreNoSuchKey);
327332
blobNames.forEachRemaining(key -> {
328333
partition.add(ObjectIdentifier.builder().key(key).build());
329334
if (partition.size() == bulkDeletionBatchSize) {
@@ -355,13 +360,9 @@ private void deletePartition(OperationPurpose purpose, List<ObjectIdentifier> pa
355360
while (true) {
356361
try (AmazonS3Reference clientReference = clientReference()) {
357362
final var response = clientReference.client().deleteObjects(bulkDelete(purpose, this, partition));
358-
if (response.hasErrors()) {
359-
final var exception = new ElasticsearchException(buildDeletionErrorMessage(response.errors()));
360-
logger.warn(exception.getMessage(), exception);
361-
deletionExceptions.useOrMaybeSuppress(exception);
362-
return;
363+
if (maybeRecordDeleteErrors(response, deletionExceptions) == false) {
364+
s3RepositoriesMetrics.retryDeletesHistogram().record(retryCounter);
363365
}
364-
s3RepositoriesMetrics.retryDeletesHistogram().record(retryCounter);
365366
return;
366367
} catch (SdkException e) {
367368
if (shouldRetryDelete(purpose) && RetryUtils.isThrottlingException(e)) {
@@ -383,23 +384,45 @@ private void deletePartition(OperationPurpose purpose, List<ObjectIdentifier> pa
383384
}
384385
}
385386

386-
private String buildDeletionErrorMessage(List<S3Error> errors) {
387-
final var sb = new StringBuilder("Failed to delete some blobs ");
388-
for (int i = 0; i < errors.size() && i < MAX_DELETE_EXCEPTIONS; i++) {
389-
final var err = errors.get(i);
390-
sb.append("[").append(err.key()).append("][").append(err.code()).append("][").append(err.message()).append("]");
391-
if (i < errors.size() - 1) {
392-
sb.append(",");
387+
private static boolean maybeRecordDeleteErrors(DeleteObjectsResponse response, DeletionExceptions deletionExceptions) {
388+
if (response.hasErrors() == false) {
389+
return false;
390+
}
391+
392+
final var errors = response.errors();
393+
int errorCount = 0;
394+
StringBuilder sb = null;
395+
396+
for (final var err : errors) {
397+
if (deletionExceptions.ignoreNoSuchKey && "NoSuchKey".equals(err.code())) {
398+
// The blob does not exist, which is what we wanted, so let's count that as a win
399+
continue;
393400
}
401+
402+
if (errorCount < MAX_DELETE_EXCEPTIONS) {
403+
if (errorCount == 0) {
404+
sb = new StringBuilder("Failed to delete some blobs ");
405+
} else {
406+
sb.append(",");
407+
}
408+
sb.append("[").append(err.key()).append("][").append(err.code()).append("][").append(err.message()).append("]");
409+
}
410+
411+
errorCount += 1;
394412
}
395-
if (errors.size() > MAX_DELETE_EXCEPTIONS) {
396-
sb.append("... (")
397-
.append(errors.size())
398-
.append(" in total, ")
399-
.append(errors.size() - MAX_DELETE_EXCEPTIONS)
400-
.append(" omitted)");
413+
414+
if (errorCount == 0) {
415+
return false;
401416
}
402-
return sb.toString();
417+
418+
if (MAX_DELETE_EXCEPTIONS < errorCount) {
419+
sb.append("... (").append(errorCount).append(" in total, ").append(errorCount - MAX_DELETE_EXCEPTIONS).append(" omitted)");
420+
}
421+
422+
final var exception = new ElasticsearchException(sb.toString());
423+
logger.warn(exception.getMessage(), exception);
424+
deletionExceptions.useOrMaybeSuppress(exception);
425+
return true;
403426
}
404427

405428
/**

test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpHandler.java

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
import org.elasticsearch.test.fixture.HttpHeaderParser;
2929

3030
import java.io.IOException;
31-
import java.io.InputStreamReader;
3231
import java.io.PrintStream;
3332
import java.nio.charset.StandardCharsets;
3433
import java.util.ArrayList;
@@ -46,9 +45,12 @@
4645
import java.util.regex.Matcher;
4746
import java.util.regex.Pattern;
4847

48+
import javax.xml.namespace.QName;
4949
import javax.xml.parsers.DocumentBuilderFactory;
50+
import javax.xml.stream.XMLInputFactory;
51+
import javax.xml.stream.XMLStreamConstants;
52+
import javax.xml.stream.XMLStreamException;
5053

51-
import static java.nio.charset.StandardCharsets.UTF_8;
5254
import static org.elasticsearch.test.fixture.HttpHeaderParser.parseRangeHeader;
5355
import static org.junit.Assert.assertEquals;
5456
import static org.junit.Assert.assertNotNull;
@@ -85,6 +87,8 @@ public S3HttpHandler(final String bucket, @Nullable final String basePath) {
8587
*/
8688
private static final Set<String> METHODS_HAVING_NO_REQUEST_BODY = Set.of("GET", "HEAD", "DELETE");
8789

90+
private static final QName MULTI_OBJECT_DELETE_KEY_QNAME = new QName("http://s3.amazonaws.com/doc/2006-03-01/", "Key");
91+
8892
@Override
8993
public void handle(final HttpExchange exchange) throws IOException {
9094
// Remove custom query parameters before processing the request. This simulates how S3 ignores them.
@@ -350,22 +354,47 @@ public void handle(final HttpExchange exchange) throws IOException {
350354
exchange.sendResponseHeaders((deletions > 0 ? RestStatus.OK : RestStatus.NO_CONTENT).getStatus(), -1);
351355

352356
} else if (request.isMultiObjectDeleteRequest()) {
353-
final String requestBody = Streams.copyToString(new InputStreamReader(exchange.getRequestBody(), UTF_8));
354357

355-
final StringBuilder deletes = new StringBuilder();
356-
deletes.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
357-
deletes.append("<DeleteResult>");
358-
for (Iterator<Map.Entry<String, BytesReference>> iterator = blobs.entrySet().iterator(); iterator.hasNext();) {
359-
Map.Entry<String, BytesReference> blob = iterator.next();
360-
String key = blob.getKey().replace("/" + bucket + "/", "");
361-
if (requestBody.contains("<Key>" + key + "</Key>")) {
362-
deletes.append("<Deleted><Key>").append(key).append("</Key></Deleted>");
363-
iterator.remove();
358+
final var resultBuilder = new StringBuilder();
359+
resultBuilder.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
360+
resultBuilder.append("<DeleteResult>");
361+
362+
final var errorBuilder = new StringBuilder();
363+
364+
try {
365+
final var xmlStreamReader = XMLInputFactory.newDefaultFactory().createXMLStreamReader(exchange.getRequestBody());
366+
try {
367+
for (; xmlStreamReader.getEventType() != XMLStreamConstants.END_DOCUMENT; xmlStreamReader.next()) {
368+
if (xmlStreamReader.getEventType() == XMLStreamConstants.START_ELEMENT) {
369+
if (xmlStreamReader.getName().equals(MULTI_OBJECT_DELETE_KEY_QNAME)) {
370+
xmlStreamReader.next();
371+
assertEquals(XMLStreamConstants.CHARACTERS, xmlStreamReader.getEventType());
372+
final var blobName = xmlStreamReader.getText();
373+
if (blobs.remove("/" + bucket + "/" + blobName) == null) {
374+
errorBuilder.append("<Error><Code>NoSuchKey</Code><Key>")
375+
.append(blobName)
376+
.append("</Key><Message>")
377+
.append(blobs.keySet())
378+
.append("</Message><VersionId>")
379+
.append(UUIDs.randomBase64UUID())
380+
.append("</VersionId></Error>");
381+
} else {
382+
resultBuilder.append("<Deleted><Key>").append(blobName).append("</Key></Deleted>");
383+
}
384+
}
385+
}
386+
}
387+
} finally {
388+
xmlStreamReader.close();
364389
}
390+
} catch (XMLStreamException xmlStreamException) {
391+
logger.error("XML exception in multi-object delete", xmlStreamException);
392+
exchange.sendResponseHeaders(RestStatus.INTERNAL_SERVER_ERROR.getStatus(), -1);
393+
return;
365394
}
366-
deletes.append("</DeleteResult>");
367395

368-
byte[] response = deletes.toString().getBytes(StandardCharsets.UTF_8);
396+
resultBuilder.append(errorBuilder.toString()).append("</DeleteResult>");
397+
byte[] response = resultBuilder.toString().getBytes(StandardCharsets.UTF_8);
369398
exchange.getResponseHeaders().add("Content-Type", "application/xml");
370399
exchange.sendResponseHeaders(RestStatus.OK.getStatus(), response.length);
371400
exchange.getResponseBody().write(response);

test/framework/src/main/java/org/elasticsearch/repositories/blobstore/ESBlobStoreRepositoryIntegTestCase.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,8 +230,7 @@ public void testDeleteBlobs() throws IOException {
230230
assertEquals(container.listBlobs(randomPurpose()).size(), 2);
231231
container.deleteBlobsIgnoringIfNotExists(randomPurpose(), blobNames.iterator());
232232
assertTrue(container.listBlobs(randomPurpose()).isEmpty());
233-
container.deleteBlobsIgnoringIfNotExists(randomPurpose(), blobNames.iterator()); // does not raise when blobs
234-
// don't exist
233+
container.deleteBlobsIgnoringIfNotExists(randomPurpose(), blobNames.iterator()); // does not raise when blobs don't exist
235234
}
236235
}
237236

0 commit comments

Comments
 (0)