From 123793060c1811a872880ff11a822d6106357cbe Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 16 Dec 2024 18:33:53 +0100 Subject: [PATCH] Optimize Azure Blob Storage Persisted Operation Store. --- .../AzureBlobOperationDocumentStorage.cs | 123 ++++++++++++------ ...rationsRequestExecutorBuilderExtensions.cs | 19 +-- ...edOperationsServiceCollectionExtensions.cs | 22 ++-- ...lobStorageOperationDocumentStorageTests.cs | 16 +-- .../IntegrationTests.cs | 11 +- .../automatic-persisted-operations.md | 2 +- .../v15/performance/persisted-operations.md | 4 +- 7 files changed, 123 insertions(+), 74 deletions(-) diff --git a/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/AzureBlobOperationDocumentStorage.cs b/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/AzureBlobOperationDocumentStorage.cs index e94ae95557c..4c01b120b67 100644 --- a/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/AzureBlobOperationDocumentStorage.cs +++ b/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/AzureBlobOperationDocumentStorage.cs @@ -1,3 +1,4 @@ +using System.Buffers; using Azure; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; @@ -11,7 +12,9 @@ namespace HotChocolate.PersistedOperations.AzureBlobStorage; /// public class AzureBlobOperationDocumentStorage : IOperationDocumentStorage { - private static readonly BlobOpenWriteOptions _defaultBlobOpenWriteOptions = new() + private static readonly char[] _fileExtension = ".graphql".ToCharArray(); + + private static readonly BlobOpenWriteOptions _writeOptions = new() { HttpHeaders = new BlobHttpHeaders { @@ -21,28 +24,20 @@ public class AzureBlobOperationDocumentStorage : IOperationDocumentStorage } }; - private readonly BlobContainerClient _blobContainerClient; - private readonly string _blobNamePrefix; - private readonly string _blobNameSuffix; + private readonly BlobContainerClient _client; /// /// Initializes a new instance of the class. /// - /// The blob container client instance. - /// This prefix string is prepended before the hash of the document. - /// This suffix is appended after the hash of the document. - public AzureBlobOperationDocumentStorage( - BlobContainerClient containerClient, - string blobNamePrefix, - string blobNameSuffix) + /// The blob container client instance. + public AzureBlobOperationDocumentStorage(BlobContainerClient client) { - ArgumentNullException.ThrowIfNull(containerClient); - ArgumentNullException.ThrowIfNull(blobNamePrefix); - ArgumentNullException.ThrowIfNull(blobNameSuffix); + if (client == null) + { + throw new ArgumentNullException(nameof(client)); + } - _blobContainerClient = containerClient; - _blobNamePrefix = blobNamePrefix; - _blobNameSuffix = blobNameSuffix; + _client = client; } /// @@ -60,20 +55,41 @@ public AzureBlobOperationDocumentStorage( private async ValueTask TryReadInternalAsync( OperationDocumentId documentId, - CancellationToken cancellationToken) + CancellationToken ct) { - var blobClient = _blobContainerClient.GetBlobClient(BlobName(documentId)); + var blobClient = _client.GetBlobClient(CreateFileName(documentId)); + var buffer = ArrayPool.Shared.Rent(1024); + var position = 0; try { - await using var blobStream = await blobClient - .OpenReadAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - - await using var memoryStream = new MemoryStream(); - await blobStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); - return memoryStream.Length == 0 - ? null - : new OperationDocument(Utf8GraphQLParser.Parse(memoryStream.ToArray())); + await using var blobStream = await blobClient.OpenReadAsync(cancellationToken: ct).ConfigureAwait(false); + while (true) + { + if (buffer.Length < position + 256) + { + var newBuffer = ArrayPool.Shared.Rent(buffer.Length * 2); + Array.Copy(buffer, newBuffer, buffer.Length); + ArrayPool.Shared.Return(buffer); + buffer = newBuffer; + } + + var read = await blobStream.ReadAsync(buffer, position, 256, ct); + position += read; + + if (read < 256) + { + break; + } + } + + if (position == 0) + { + return null; + } + + var span = new ReadOnlySpan(buffer, 0, position); + return new OperationDocument(Utf8GraphQLParser.Parse(span)); } catch (RequestFailedException e) { @@ -84,6 +100,15 @@ public AzureBlobOperationDocumentStorage( throw; } + finally + { + if(position > 0) + { + buffer.AsSpan().Slice(0, position).Clear(); + } + + ArrayPool.Shared.Return(buffer); + } } /// @@ -92,10 +117,14 @@ public ValueTask SaveAsync( IOperationDocument document, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(document); + if(document == null) + { + throw new ArgumentNullException(nameof(document)); + } + if (OperationDocumentId.IsNullOrEmpty(documentId)) { - throw new ArgumentNullException(nameof(documentId)); + throw new ArgumentException(nameof(documentId)); } return SaveInternalAsync(documentId, document, cancellationToken); @@ -104,15 +133,35 @@ public ValueTask SaveAsync( private async ValueTask SaveInternalAsync( OperationDocumentId documentId, IOperationDocument document, - CancellationToken cancellationToken) + CancellationToken ct) { - var blobClient = _blobContainerClient.GetBlobClient(BlobName(documentId)); - await using var outStream = await blobClient - .OpenWriteAsync(true, _defaultBlobOpenWriteOptions, cancellationToken).ConfigureAwait(false); - - await document.WriteToAsync(outStream, cancellationToken).ConfigureAwait(false); - await outStream.FlushAsync(cancellationToken).ConfigureAwait(false); + var blobClient = _client.GetBlobClient(CreateFileName(documentId)); + await using var outStream = await blobClient.OpenWriteAsync(true, _writeOptions, ct).ConfigureAwait(false); + await document.WriteToAsync(outStream, ct).ConfigureAwait(false); + await outStream.FlushAsync(ct).ConfigureAwait(false); } - private string BlobName(OperationDocumentId documentId) => $"{_blobNamePrefix}{documentId.Value}{_blobNameSuffix}"; + + private static string CreateFileName(OperationDocumentId documentId) + { + var length = documentId.Value.Length + _fileExtension.Length; + char[]? rented = null; + Span span = length <= 256 + ? stackalloc char[length] + : rented = ArrayPool.Shared.Rent(length); + + try + { + documentId.Value.AsSpan().CopyTo(span); + _fileExtension.AsSpan().CopyTo(span.Slice(documentId.Value.Length)); + return new string(span); + } + finally + { + if (rented != null) + { + ArrayPool.Shared.Return(rented); + } + } + } } diff --git a/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Extensions/HotChocolateAzureBlobStoragePersistedOperationsRequestExecutorBuilderExtensions.cs b/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Extensions/HotChocolateAzureBlobStoragePersistedOperationsRequestExecutorBuilderExtensions.cs index 04968342032..b99b9c556d5 100644 --- a/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Extensions/HotChocolateAzureBlobStoragePersistedOperationsRequestExecutorBuilderExtensions.cs +++ b/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Extensions/HotChocolateAzureBlobStoragePersistedOperationsRequestExecutorBuilderExtensions.cs @@ -19,19 +19,22 @@ public static class HotChocolateAzureBlobStoragePersistedOperationsRequestExecut /// A factory that resolves the Azure Blob Container Client that /// shall be used for persistence. /// - /// This prefix string is prepended before the hash of the document. - /// This suffix is appended after the hash of the document. public static IRequestExecutorBuilder AddAzureBlobStorageOperationDocumentStorage( this IRequestExecutorBuilder builder, - Func containerClientFactory, - string blobNamePrefix = "", - string blobNameSuffix = ".graphql") + Func containerClientFactory) { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(containerClientFactory); + if(builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if(containerClientFactory is null) + { + throw new ArgumentNullException(nameof(containerClientFactory)); + } return builder.ConfigureSchemaServices( s => s.AddAzureBlobStorageOperationDocumentStorage( - sp => containerClientFactory(sp.GetCombinedServices()), blobNamePrefix, blobNameSuffix)); + sp => containerClientFactory(sp.GetCombinedServices()))); } } diff --git a/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Extensions/HotChocolateAzureBlobStoragePersistedOperationsServiceCollectionExtensions.cs b/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Extensions/HotChocolateAzureBlobStoragePersistedOperationsServiceCollectionExtensions.cs index 6b8cfc029a2..67c1fb8a9d9 100644 --- a/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Extensions/HotChocolateAzureBlobStoragePersistedOperationsServiceCollectionExtensions.cs +++ b/src/HotChocolate/PersistedOperations/src/PersistedOperations.AzureBlobStorage/Extensions/HotChocolateAzureBlobStoragePersistedOperationsServiceCollectionExtensions.cs @@ -6,7 +6,7 @@ namespace HotChocolate; /// -/// Provides utility methods to setup dependency injection. +/// Provides utility methods to set up dependency injection. /// public static class HotChocolateAzureBlobStoragePersistedOperationsServiceCollectionExtensions { @@ -20,22 +20,24 @@ public static class HotChocolateAzureBlobStoragePersistedOperationsServiceCollec /// A factory that resolves the Azure Blob Container Client that /// shall be used for persistence. /// - /// This prefix string is prepended before the hash of the document. - /// This suffix is appended after the hash of the document. public static IServiceCollection AddAzureBlobStorageOperationDocumentStorage( this IServiceCollection services, - Func containerClientFactory, - string blobNamePrefix = "", - string blobNameSuffix = ".graphql" - ) + Func containerClientFactory) { - ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(containerClientFactory); + if(services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + if(containerClientFactory == null) + { + throw new ArgumentNullException(nameof(containerClientFactory)); + } return services .RemoveService() .AddSingleton( - sp => new AzureBlobOperationDocumentStorage(containerClientFactory(sp), blobNamePrefix, blobNameSuffix)); + sp => new AzureBlobOperationDocumentStorage(containerClientFactory(sp))); } private static IServiceCollection RemoveService( diff --git a/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/AzureBlobStorageOperationDocumentStorageTests.cs b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/AzureBlobStorageOperationDocumentStorageTests.cs index 15015f99de9..cf3e0111bec 100644 --- a/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/AzureBlobStorageOperationDocumentStorageTests.cs +++ b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/AzureBlobStorageOperationDocumentStorageTests.cs @@ -9,8 +9,6 @@ namespace HotChocolate.PersistedOperations.AzureBlobStorage; public class AzureBlobStorageOperationDocumentStorageTests : IClassFixture { private readonly BlobContainerClient _client; - private const string Prefix = "hc_"; - private const string Suffix = ".graphql"; public AzureBlobStorageOperationDocumentStorageTests(AzureStorageBlobResource blobStorageResource) { @@ -23,7 +21,7 @@ public async Task Write_OperationDocument_To_Storage() { // arrange var documentId = new OperationDocumentId(Guid.NewGuid().ToString("N")); - var storage = new AzureBlobOperationDocumentStorage(_client, Prefix, Suffix); + var storage = new AzureBlobOperationDocumentStorage(_client); var document = new OperationDocumentSourceText("{ foo }"); // act @@ -41,21 +39,21 @@ public async Task Write_OperationDocument_documentId_Invalid() { // arrange var documentId = new OperationDocumentId(); - var storage = new AzureBlobOperationDocumentStorage(_client, Prefix, Suffix); + var storage = new AzureBlobOperationDocumentStorage(_client); var document = new OperationDocumentSourceText("{ foo }"); // act async Task Action() => await storage.SaveAsync(documentId, document); // assert - await Assert.ThrowsAsync(Action); + await Assert.ThrowsAsync(Action); } [Fact] public async Task Write_OperationDocument_OperationDocument_Is_Null() { // arrange - var storage = new AzureBlobOperationDocumentStorage(_client, Prefix, Suffix); + var storage = new AzureBlobOperationDocumentStorage(_client); var documentId = new OperationDocumentId(Guid.NewGuid().ToString("N")); // act @@ -70,7 +68,7 @@ public async Task Read_OperationDocument_From_Storage() { // arrange var documentId = new OperationDocumentId(Guid.NewGuid().ToString("N")); - var storage = new AzureBlobOperationDocumentStorage(_client, Prefix, Suffix); + var storage = new AzureBlobOperationDocumentStorage(_client); var buffer = "{ foo }"u8.ToArray(); await WriteBlob(documentId.Value, buffer); @@ -89,7 +87,7 @@ public async Task Read_OperationDocument_documentId_Invalid() { // arrange var documentId = new OperationDocumentId(); - var storage = new AzureBlobOperationDocumentStorage(_client, Prefix, Suffix); + var storage = new AzureBlobOperationDocumentStorage(_client); // act async Task Action() => await storage.TryReadAsync(documentId); @@ -116,5 +114,5 @@ private async Task WriteBlob(string key, byte[] buffer) private async Task DeleteBlob(string key) => await _client.DeleteBlobAsync(BlobName(key)); - private static string BlobName(string key) => $"{Prefix}{key}{Suffix}"; + private static string BlobName(string key) => $"{key}.graphql"; } diff --git a/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/IntegrationTests.cs b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/IntegrationTests.cs index 8199361e1f2..e18e145f6ab 100644 --- a/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/IntegrationTests.cs +++ b/src/HotChocolate/PersistedOperations/test/PersistedOperations.AzureBlobStorage.Tests/IntegrationTests.cs @@ -8,9 +8,6 @@ namespace HotChocolate.PersistedOperations.AzureBlobStorage; public class IntegrationTests : IClassFixture { - private const string Prefix = "hc_"; - private const string Suffix = ".graphql"; - private readonly BlobContainerClient _client; public IntegrationTests(AzureStorageBlobResource blobStorageResource) @@ -24,7 +21,7 @@ public async Task ExecutePersistedOperation() { // arrange var documentId = new OperationDocumentId(Guid.NewGuid().ToString("N")); - var storage = new AzureBlobOperationDocumentStorage(_client, Prefix, Suffix); + var storage = new AzureBlobOperationDocumentStorage(_client); await storage.SaveAsync( documentId, @@ -34,7 +31,7 @@ await storage.SaveAsync( await new ServiceCollection() .AddGraphQL() .AddQueryType(c => c.Name("Query").Field("a").Resolve("b")) - .AddAzureBlobStorageOperationDocumentStorage(_ => _client, Prefix, Suffix) + .AddAzureBlobStorageOperationDocumentStorage(_ => _client) .UseRequest(n => async c => { await n(c); @@ -62,14 +59,14 @@ public async Task ExecutePersistedOperation_NotFound() { // arrange var documentId = new OperationDocumentId(Guid.NewGuid().ToString("N")); - var storage = new AzureBlobOperationDocumentStorage(_client, Prefix, Suffix); + var storage = new AzureBlobOperationDocumentStorage(_client); await storage.SaveAsync(documentId, new OperationDocumentSourceText("{ __typename }")); var executor = await new ServiceCollection() .AddGraphQL() .AddQueryType(c => c.Name("Query").Field("a").Resolve("b")) - .AddAzureBlobStorageOperationDocumentStorage(_ => _client, Prefix, Suffix) + .AddAzureBlobStorageOperationDocumentStorage(_ => _client) .UseRequest(n => async c => { await n(c); diff --git a/website/src/docs/hotchocolate/v15/performance/automatic-persisted-operations.md b/website/src/docs/hotchocolate/v15/performance/automatic-persisted-operations.md index cd5679fbff5..14c87c749a9 100644 --- a/website/src/docs/hotchocolate/v15/performance/automatic-persisted-operations.md +++ b/website/src/docs/hotchocolate/v15/performance/automatic-persisted-operations.md @@ -215,7 +215,7 @@ curl -g 'http://localhost:5000/graphql/?query={__typename}&extensions={"persiste ## Step 4: Use a persisted operation document storage -If you run multiple Hot Chocolate server instances and want to preserve stored operation documents after a server restart, you can opt to use a persisted operation document storage. Hot Chocolate supports a file-system-based operation document storage, Azure Blob Storage or a Redis cache. See [the persisted operations manual](/docs/hotchocolate/v15/performance/persisted-operations) to learn more about each option. The following example uses a Redis cache. +If you run multiple Hot Chocolate server instances and want to preserve stored operation documents after a server restart, you can opt to use a persisted operation document storage. Hot Chocolate supports a file-system-based operation document storage, Azure Blob Storage or a Redis cache. 1. Setup a Redis docker container. diff --git a/website/src/docs/hotchocolate/v15/performance/persisted-operations.md b/website/src/docs/hotchocolate/v15/performance/persisted-operations.md index 8756e73211b..0506559ddb3 100644 --- a/website/src/docs/hotchocolate/v15/performance/persisted-operations.md +++ b/website/src/docs/hotchocolate/v15/performance/persisted-operations.md @@ -86,7 +86,7 @@ This file is expected to contain the operation document that the hash was genera > Warning: Do not forget to ensure that the server has access to the directory. -## Redis +### Redis To load persisted operation documents from Redis, we have to add the following package. @@ -108,7 +108,7 @@ public void ConfigureServices(IServiceCollection services) Keys in the specified Redis database are expected to be operation IDs (hashes) and contain the actual operation document as the value. -## Azure Blob Storage +### Azure Blob Storage To load persisted operation documents from Azure Blob Storage, we have to add the following package.