Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use Azure blob batch API to delete blobs in batches #114566

Merged
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
8e08e60
Experimental batch-blob deletion support WIP
nicktindall Oct 11, 2024
8f67b0a
Handle null responses on success?! (temporary workaround)
nicktindall Oct 11, 2024
86a59e9
Check error responses, track metrics
nicktindall Oct 14, 2024
22cfdb8
Skip metrics on batched requests more explicitly
nicktindall Oct 14, 2024
83ef063
Test that batch delete is tracked
nicktindall Oct 14, 2024
798b83b
Undo broken request time tracking
nicktindall Oct 14, 2024
57286c4
Merge remote-tracking branch 'origin/main' into ES-9777_support_batch…
nicktindall Oct 14, 2024
219cf40
Use batch delete when deleting blob directory
nicktindall Oct 14, 2024
d0fc6ee
Fix naming
nicktindall Oct 14, 2024
c566966
Tidy up response templates
nicktindall Oct 14, 2024
d5aa10f
Remove most debug logging from AzureHttpHandler
nicktindall Oct 14, 2024
87b08e6
Submit all batch deletes concurrently
nicktindall Oct 14, 2024
b034c50
Don't swallow exception
nicktindall Oct 14, 2024
ebc47e8
Track "blob batch" instead of "batch delete" (we can't be specific wi…
nicktindall Oct 14, 2024
e3cb397
Fix date/time format
nicktindall Oct 14, 2024
5df3384
Add test for batch delete failure metrics/behaviour
nicktindall Oct 14, 2024
caf1ab7
Merge remote-tracking branch 'origin/main' into ES-9777_support_batch…
nicktindall Oct 14, 2024
05e01c1
Remove redundant code from metrics handler
nicktindall Oct 15, 2024
e744cbd
Make max deletes per batch configurable
nicktindall Oct 15, 2024
6115885
Improve handling of individual batch failures
nicktindall Oct 16, 2024
b08d6af
Make batches execute concurrently
nicktindall Oct 16, 2024
06a3b5d
Merge remote-tracking branch 'origin/main' into ES-9777_support_batch…
nicktindall Oct 16, 2024
84211f9
Remove redundant null check
nicktindall Oct 16, 2024
aa7ecfb
Don't close response body
nicktindall Oct 16, 2024
1b12a63
Log to indicate which authentication method we're using
nicktindall Oct 16, 2024
9a3718a
Assert that the non-failed deletes succeeded
nicktindall Oct 16, 2024
2f37338
Write our own subscriber to the delete tasks to more accurately captu…
nicktindall Oct 16, 2024
dda6e8a
Merge remote-tracking branch 'origin/main' into ES-9777_support_batch…
nicktindall Oct 16, 2024
d546ec5
Use reactor to list and delete
nicktindall Oct 22, 2024
8588247
Merge remote-tracking branch 'origin/main' into ES-9777_support_batch…
nicktindall Oct 22, 2024
4641580
Add docs for max_concurrent_batch_deletes
nicktindall Oct 22, 2024
6b94158
Tidy up
nicktindall Oct 22, 2024
8efb2e1
Remove SocketAccess#doPrivilegedVoidExceptionExplicit
nicktindall Oct 23, 2024
09f27b8
Naming
nicktindall Oct 23, 2024
b913fd0
Limit amount of suppressed errors
nicktindall Oct 23, 2024
ac772ec
Randomise max_concurrent_batch_deletes
nicktindall Oct 23, 2024
dfe0d9c
Comment wording
nicktindall Oct 23, 2024
6818ec7
Tidy
nicktindall Oct 23, 2024
eab8700
Update docs/changelog/114566.yaml
nicktindall Oct 23, 2024
93bfe04
Update docs/reference/snapshot-restore/repository-azure.asciidoc
nicktindall Oct 24, 2024
d9ce4b5
Add info log prefix pattern
nicktindall Oct 24, 2024
84ff3c2
Indicate the total count of errors when it exceeds our limit for incl…
nicktindall Oct 24, 2024
103aec4
Catch Exception instead of RuntimeException
nicktindall Oct 24, 2024
6c4697e
Set maximum for max_concurrent_batch_deletes
nicktindall Oct 24, 2024
beae254
Merge remote-tracking branch 'origin/main' into ES-9777_support_batch…
nicktindall Oct 24, 2024
5f1143e
Fix batch error handling
nicktindall Oct 24, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions gradle/verification-metadata.xml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,11 @@
<sha256 value="31915426834400cac854f48441c168d55aa6fc054527f28f1d242a7067affd14" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.azure" name="azure-storage-blob-batch" version="12.23.1">
<artifact name="azure-storage-blob-batch-12.23.1.jar">
<sha256 value="8c11749c783222873f63f22575aa5ae7ee8f285388183b82d1a18db21f4d2eba" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.azure" name="azure-storage-common" version="12.26.1">
<artifact name="azure-storage-common-12.26.1.jar">
<sha256 value="b0297ac1a9017ccd8a1e5cf41fb8d00ff0adbdd06849f6c5aafb3208708264dd" origin="Generated by Gradle"/>
Expand Down
1 change: 1 addition & 0 deletions modules/repository-azure/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dependencies {
api "com.azure:azure-identity:1.13.2"
api "com.azure:azure-json:1.2.0"
api "com.azure:azure-storage-blob:12.27.1"
api "com.azure:azure-storage-blob-batch:12.23.1"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the version consistent with the others from the BOM

api "com.azure:azure-storage-common:12.26.1"
api "com.azure:azure-storage-internal-avro:12.12.1"
api "com.azure:azure-xml:1.1.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,8 @@ protected void maybeTrack(String request, Headers headers) {
trackRequest("PutBlockList");
} else if (Regex.simpleMatch("PUT /*/*", request)) {
trackRequest("PutBlob");
} else if (Regex.simpleMatch("POST /*?*comp=batch*", request)) {
trackRequest("BatchDelete");
}
}

Expand Down
5 changes: 1 addition & 4 deletions modules/repository-azure/src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,15 @@
requires org.apache.logging.log4j;
requires org.apache.logging.log4j.core;

requires com.azure.core;
requires com.azure.http.netty;
requires com.azure.storage.blob;
requires com.azure.storage.common;
requires com.azure.identity;

requires io.netty.buffer;
requires io.netty.transport;
requires io.netty.resolver;
requires io.netty.common;

requires reactor.core;
requires reactor.netty.core;
requires reactor.netty.http;
requires com.azure.storage.blob.batch;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IntelliJ seemed to optimize the requires, the ones removed above are all transitively required by com.azure.storage.blob.batch.

}
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@
import com.azure.core.util.BinaryData;
import com.azure.storage.blob.BlobAsyncClient;
import com.azure.storage.blob.BlobClient;
import com.azure.storage.blob.BlobContainerAsyncClient;
import com.azure.storage.blob.BlobContainerClient;
import com.azure.storage.blob.BlobServiceAsyncClient;
import com.azure.storage.blob.BlobServiceClient;
import com.azure.storage.blob.batch.BlobBatch;
import com.azure.storage.blob.batch.BlobBatchAsyncClient;
import com.azure.storage.blob.batch.BlobBatchClientBuilder;
import com.azure.storage.blob.batch.BlobBatchStorageException;
import com.azure.storage.blob.models.BlobErrorCode;
import com.azure.storage.blob.models.BlobItem;
import com.azure.storage.blob.models.BlobItemProperties;
Expand Down Expand Up @@ -84,21 +87,20 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Spliterator;
import java.util.Spliterators;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
import java.util.function.BiPredicate;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;

import static org.elasticsearch.core.Strings.format;

public class AzureBlobStore implements BlobStore {
private static final Logger logger = LogManager.getLogger(AzureBlobStore.class);
// See https://learn.microsoft.com/en-us/rest/api/storageservices/blob-batch#request-body
private static final int MAX_ELEMENTS_PER_BATCH = 256;
ywangd marked this conversation as resolved.
Show resolved Hide resolved
private static final long DEFAULT_READ_CHUNK_SIZE = new ByteSizeValue(32, ByteSizeUnit.MB).getBytes();
private static final int DEFAULT_UPLOAD_BUFFERS_SIZE = (int) new ByteSizeValue(64, ByteSizeUnit.KB).getBytes();

Expand Down Expand Up @@ -147,7 +149,8 @@ public AzureBlobStore(
&& isPutBlockRequest(httpMethod, url) == false
&& isPutBlockListRequest(httpMethod, url) == false,
Operation.PUT_BLOB
)
),
new RequestMatcher(AzureBlobStore::isBatchDelete, Operation.BATCH_DELETE)
);

this.requestMetricsHandler = (purpose, method, url, metrics) -> {
Expand All @@ -172,6 +175,10 @@ && isPutBlockListRequest(httpMethod, url) == false,
};
}

private static boolean isBatchDelete(HttpMethod method, URL url) {
return method == HttpMethod.POST && url.getQuery() != null && url.getQuery().contains("comp=batch");
}

private static boolean isListRequest(HttpMethod httpMethod, URL url) {
return httpMethod == HttpMethod.GET && url.getQuery() != null && url.getQuery().contains("comp=list");
}
Expand Down Expand Up @@ -231,30 +238,28 @@ public boolean blobExists(OperationPurpose purpose, String blob) throws IOExcept
}
}

// number of concurrent blob delete requests to use while bulk deleting
private static final int CONCURRENT_DELETES = 100;

public DeleteResult deleteBlobDirectory(OperationPurpose purpose, String path) {
final AtomicInteger blobsDeleted = new AtomicInteger(0);
final AtomicLong bytesDeleted = new AtomicLong(0);
final List<String> blobNames = new ArrayList<>();

SocketAccess.doPrivilegedVoidException(() -> {
final BlobContainerAsyncClient blobContainerAsyncClient = asyncClient(purpose).getBlobContainerAsyncClient(container);
final ListBlobsOptions options = new ListBlobsOptions().setPrefix(path)
.setDetails(new BlobListDetails().setRetrieveMetadata(true));
final AzureBlobServiceClient client = getAzureBlobServiceClientClient(purpose);
final BlobContainerClient blobContainerClient = client.getSyncClient().getBlobContainerClient(container);
try {
blobContainerAsyncClient.listBlobs(options, null).flatMap(blobItem -> {
if (blobItem.isPrefix() != null && blobItem.isPrefix()) {
return Mono.empty();
} else {
final String blobName = blobItem.getName();
BlobAsyncClient blobAsyncClient = blobContainerAsyncClient.getBlobAsyncClient(blobName);
final Mono<Void> deleteTask = getDeleteTask(blobName, blobAsyncClient);
bytesDeleted.addAndGet(blobItem.getProperties().getContentLength());
blobsDeleted.incrementAndGet();
return deleteTask;
final ListBlobsOptions options = new ListBlobsOptions().setPrefix(path)
.setDetails(new BlobListDetails().setRetrieveMetadata(true));
for (BlobItem blobItem : blobContainerClient.listBlobs(options, null)) {
if (blobItem.isPrefix()) {
continue;
}
}, CONCURRENT_DELETES).then().block();
blobNames.add(blobItem.getName());
bytesDeleted.addAndGet(blobItem.getProperties().getContentLength());
blobsDeleted.incrementAndGet();
}
if (blobNames.isEmpty() == false) {
deleteListOfBlobs(client, blobNames.iterator());
}
} catch (Exception e) {
filterDeleteExceptionsAndRethrow(e, new IOException("Deleting directory [" + path + "] failed"));
}
Expand All @@ -278,48 +283,44 @@ private static void filterDeleteExceptionsAndRethrow(Exception e, IOException ex
throw exception;
}

/**
* {@inheritDoc}
* <p>
* Note that in this Azure implementation we issue a series of individual
* <a href="https://learn.microsoft.com/en-us/rest/api/storageservices/delete-blob">delete blob</a> calls rather than aggregating
* deletions into <a href="https://learn.microsoft.com/en-us/rest/api/storageservices/blob-batch">blob batch</a> calls.
* The reason for this is that the blob batch endpoint has limited support for SAS token authentication.
*
* @see <a href="https://learn.microsoft.com/en-us/rest/api/storageservices/blob-batch?tabs=shared-access-signatures#authorization">
* API docs around SAS auth limitations</a>
* @see <a href="https://github.com/Azure/azure-storage-java/issues/538">Java SDK issue</a>
* @see <a href="https://github.com/elastic/elasticsearch/pull/65140#discussion_r528752070">Discussion on implementing PR</a>
*/
@Override
public void deleteBlobsIgnoringIfNotExists(OperationPurpose purpose, Iterator<String> blobs) {
if (blobs.hasNext() == false) {
public void deleteBlobsIgnoringIfNotExists(OperationPurpose purpose, Iterator<String> blobNames) throws IOException {
if (blobNames.hasNext() == false) {
return;
}

BlobServiceAsyncClient asyncClient = asyncClient(purpose);
SocketAccess.doPrivilegedVoidException(() -> {
final BlobContainerAsyncClient blobContainerClient = asyncClient.getBlobContainerAsyncClient(container);
try {
Flux.fromStream(StreamSupport.stream(Spliterators.spliteratorUnknownSize(blobs, Spliterator.ORDERED), false))
.flatMap(blob -> getDeleteTask(blob, blobContainerClient.getBlobAsyncClient(blob)), CONCURRENT_DELETES)
.then()
.block();
} catch (Exception e) {
filterDeleteExceptionsAndRethrow(e, new IOException("Unable to delete blobs"));
}
});
SocketAccess.doPrivilegedVoidException(() -> deleteListOfBlobs(getAzureBlobServiceClientClient(purpose), blobNames));
}

private static Mono<Void> getDeleteTask(String blobName, BlobAsyncClient blobAsyncClient) {
return blobAsyncClient.delete()
// Ignore not found blobs, as it's possible that due to network errors a request
// for an already deleted blob is retried, causing an error.
.onErrorResume(
e -> e instanceof BlobStorageException blobStorageException && blobStorageException.getStatusCode() == 404,
throwable -> Mono.empty()
)
.onErrorMap(throwable -> new IOException("Error deleting blob " + blobName, throwable));
private void deleteListOfBlobs(AzureBlobServiceClient azureBlobServiceClient, Iterator<String> blobNames) throws IOException {
// We need to use a container-scoped BlobBatchClient, so the restype=container parameter
// is sent, and we can support all SAS token types
// See https://learn.microsoft.com/en-us/rest/api/storageservices/blob-batch?tabs=shared-access-signatures#authorization
final BlobBatchAsyncClient batchAsyncClient = new BlobBatchClientBuilder(
azureBlobServiceClient.getAsyncClient().getBlobContainerAsyncClient(container)
).buildAsyncClient();
Comment on lines +279 to +284
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this still works with non-container-scoped tokens and other azure crendential types that we support?

Copy link
Contributor Author

@nicktindall nicktindall Oct 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've just kicked off the tests with the latest changes https://buildkite.com/elastic/elasticsearch-periodic/builds/4557

EDIT: third party tests all still pass :)

final List<Mono<Void>> batchResponses = new ArrayList<>();
while (blobNames.hasNext()) {
final BlobBatch currentBatch = batchAsyncClient.getBlobBatch();
int counter = 0;
while (counter < MAX_ELEMENTS_PER_BATCH && blobNames.hasNext()) {
currentBatch.deleteBlob(container, blobNames.next());
counter++;
}
batchResponses.add(batchAsyncClient.submitBatch(currentBatch));
}
try {
Flux.merge(batchResponses).collectList().block();
} catch (BlobBatchStorageException bbse) {
final Iterable<BlobStorageException> batchExceptions = bbse.getBatchExceptions();
for (BlobStorageException bse : batchExceptions) {
// If one of the requests failed with something other than a BLOB_NOT_FOUND, throw the encompassing exception
if (BlobErrorCode.BLOB_NOT_FOUND.equals(bse.getErrorCode()) == false) {
throw new IOException("Failed to delete batch", bbse);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For my education: Does blobBatch process all batches before it throws any exception or does it stop processing as soon as the first exception is encountered? I also assume all sub-requests in a batch will all get processed if the batch itself is processed regardless whether all of the sub-requests run into error?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah it'll run the full batch then return all the results. This is tested in testDeleteBlobsIgnoringIfNotExists where the batches contain some blobs which are not found. We assert at the end that the blob store is empty at the end (i.e. all the valid deletes were performed)

}
} catch (Exception e) {
throw new IOException("Unable to delete blobs");
}
}

public InputStream getInputStream(OperationPurpose purpose, String blob, long position, final @Nullable Long length) {
Expand Down Expand Up @@ -689,7 +690,8 @@ enum Operation {
GET_BLOB_PROPERTIES("GetBlobProperties"),
PUT_BLOB("PutBlob"),
PUT_BLOCK("PutBlock"),
PUT_BLOCK_LIST("PutBlockList");
PUT_BLOCK_LIST("PutBlockList"),
BATCH_DELETE("BatchDelete");

private final String key;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,11 @@ private enum RetryMetricsTracker implements HttpPipelinePolicy {

@Override
public Mono<HttpResponse> process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) {
if (requestIsPartOfABatch(context)) {
// Batch deletes fire once for each of the constituent requests, and they have a null response. Ignore those, we'll track
// metrics at the bulk level.
return next.process();
}
Comment on lines +320 to +324
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels a bit odd that each individual deletion would go through the pipeline. But I assume that's just how azure sdk works.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It actually might come in handy if we were batching billable actions. Delete is free so it doesn't matter, but the other action you can batch ("set blob tier") is not, and batches are billed per consituent request. If we were batching these we'd want to count those individual requests.

Optional<Object> metricsData = context.getData(RequestMetricsTracker.ES_REQUEST_METRICS_CONTEXT_KEY);
if (metricsData.isPresent() == false) {
assert false : "No metrics object associated with request " + context.getHttpRequest();
Expand Down Expand Up @@ -361,6 +366,11 @@ private RequestMetricsTracker(OperationPurpose purpose, RequestMetricsHandler re

@Override
public Mono<HttpResponse> process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) {
if (requestIsPartOfABatch(context)) {
// Batch deletes fire once for each of the constituent requests, and they have a null response. Ignore those, we'll track
// metrics at the bulk level.
return next.process();
}
final RequestMetrics requestMetrics = new RequestMetrics();
context.setData(ES_REQUEST_METRICS_CONTEXT_KEY, requestMetrics);
return next.process().doOnSuccess((httpResponse) -> {
Expand Down Expand Up @@ -389,6 +399,10 @@ public HttpPipelinePosition getPipelinePosition() {
}
}

private static boolean requestIsPartOfABatch(HttpPipelineCallContext context) {
return context.getData("Batch-Operation-Info").isPresent();
}

/**
* The {@link RequestMetricsTracker} calls this when a request completes
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.List;
import java.util.Map;

public class AzureBlobContainerStatsTests extends AbstractAzureServerTestCase {
Expand Down Expand Up @@ -47,6 +48,8 @@ public void testOperationPurposeIsReflectedInBlobStoreStats() throws IOException
os.write(blobContent);
os.flush();
});
// BATCH_DELETE
blobStore.deleteBlobsIgnoringIfNotExists(purpose, List.of(randomIdentifier(), randomIdentifier(), randomIdentifier()).iterator());

Map<String, Long> stats = blobStore.stats();
String statsMapString = stats.toString();
Expand All @@ -55,6 +58,7 @@ public void testOperationPurposeIsReflectedInBlobStoreStats() throws IOException
assertEquals(statsMapString, Long.valueOf(1L), stats.get(statsKey(purpose, AzureBlobStore.Operation.GET_BLOB_PROPERTIES)));
assertEquals(statsMapString, Long.valueOf(1L), stats.get(statsKey(purpose, AzureBlobStore.Operation.PUT_BLOCK)));
assertEquals(statsMapString, Long.valueOf(1L), stats.get(statsKey(purpose, AzureBlobStore.Operation.PUT_BLOCK_LIST)));
assertEquals(statsMapString, Long.valueOf(1L), stats.get(statsKey(purpose, AzureBlobStore.Operation.BATCH_DELETE)));
}

public void testOperationPurposeIsNotReflectedInBlobStoreStatsWhenNotServerless() throws IOException {
Expand All @@ -79,6 +83,11 @@ public void testOperationPurposeIsNotReflectedInBlobStoreStatsWhenNotServerless(
os.write(blobContent);
os.flush();
});
// BATCH_DELETE
blobStore.deleteBlobsIgnoringIfNotExists(
purpose,
List.of(randomIdentifier(), randomIdentifier(), randomIdentifier()).iterator()
);
}

Map<String, Long> stats = blobStore.stats();
Expand All @@ -88,6 +97,7 @@ public void testOperationPurposeIsNotReflectedInBlobStoreStatsWhenNotServerless(
assertEquals(statsMapString, Long.valueOf(repeatTimes), stats.get(AzureBlobStore.Operation.GET_BLOB_PROPERTIES.getKey()));
assertEquals(statsMapString, Long.valueOf(repeatTimes), stats.get(AzureBlobStore.Operation.PUT_BLOCK.getKey()));
assertEquals(statsMapString, Long.valueOf(repeatTimes), stats.get(AzureBlobStore.Operation.PUT_BLOCK_LIST.getKey()));
assertEquals(statsMapString, Long.valueOf(repeatTimes), stats.get(AzureBlobStore.Operation.BATCH_DELETE.getKey()));
}

private static String statsKey(OperationPurpose purpose, AzureBlobStore.Operation operation) {
Expand Down
Loading