Skip to content

Commit

Permalink
Optimize Azure Blob Storage Persisted Operation Store.
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelstaib committed Dec 16, 2024
1 parent f8f1db0 commit 1237930
Show file tree
Hide file tree
Showing 7 changed files with 123 additions and 74 deletions.
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Buffers;
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
Expand All @@ -11,7 +12,9 @@ namespace HotChocolate.PersistedOperations.AzureBlobStorage;
/// </summary>
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
{
Expand All @@ -21,28 +24,20 @@ public class AzureBlobOperationDocumentStorage : IOperationDocumentStorage
}
};

private readonly BlobContainerClient _blobContainerClient;
private readonly string _blobNamePrefix;
private readonly string _blobNameSuffix;
private readonly BlobContainerClient _client;

/// <summary>
/// Initializes a new instance of the class.
/// </summary>
/// <param name="containerClient">The blob container client instance.</param>
/// <param name="blobNamePrefix">This prefix string is prepended before the hash of the document.</param>
/// <param name="blobNameSuffix">This suffix is appended after the hash of the document.</param>
public AzureBlobOperationDocumentStorage(
BlobContainerClient containerClient,
string blobNamePrefix,
string blobNameSuffix)
/// <param name="client">The blob container client instance.</param>
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;
}

/// <inheritdoc />
Expand All @@ -60,20 +55,41 @@ public AzureBlobOperationDocumentStorage(

private async ValueTask<IOperationDocument?> TryReadInternalAsync(
OperationDocumentId documentId,
CancellationToken cancellationToken)
CancellationToken ct)
{
var blobClient = _blobContainerClient.GetBlobClient(BlobName(documentId));
var blobClient = _client.GetBlobClient(CreateFileName(documentId));
var buffer = ArrayPool<byte>.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<byte>.Shared.Rent(buffer.Length * 2);
Array.Copy(buffer, newBuffer, buffer.Length);
ArrayPool<byte>.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<byte>(buffer, 0, position);
return new OperationDocument(Utf8GraphQLParser.Parse(span));
}
catch (RequestFailedException e)
{
Expand All @@ -84,6 +100,15 @@ public AzureBlobOperationDocumentStorage(

throw;
}
finally
{
if(position > 0)
{
buffer.AsSpan().Slice(0, position).Clear();
}

ArrayPool<byte>.Shared.Return(buffer);
}
}

/// <inheritdoc />
Expand All @@ -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);
Expand All @@ -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<char> span = length <= 256
? stackalloc char[length]
: rented = ArrayPool<char>.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<char>.Shared.Return(rented);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,22 @@ public static class HotChocolateAzureBlobStoragePersistedOperationsRequestExecut
/// A factory that resolves the Azure Blob Container Client that
/// shall be used for persistence.
/// </param>
/// <param name="blobNamePrefix">This prefix string is prepended before the hash of the document.</param>
/// <param name="blobNameSuffix">This suffix is appended after the hash of the document.</param>
public static IRequestExecutorBuilder AddAzureBlobStorageOperationDocumentStorage(
this IRequestExecutorBuilder builder,
Func<IServiceProvider, BlobContainerClient> containerClientFactory,
string blobNamePrefix = "",
string blobNameSuffix = ".graphql")
Func<IServiceProvider, BlobContainerClient> 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())));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
namespace HotChocolate;

/// <summary>
/// Provides utility methods to setup dependency injection.
/// Provides utility methods to set up dependency injection.
/// </summary>
public static class HotChocolateAzureBlobStoragePersistedOperationsServiceCollectionExtensions
{
Expand All @@ -20,22 +20,24 @@ public static class HotChocolateAzureBlobStoragePersistedOperationsServiceCollec
/// A factory that resolves the Azure Blob Container Client that
/// shall be used for persistence.
/// </param>
/// <param name="blobNamePrefix">This prefix string is prepended before the hash of the document.</param>
/// <param name="blobNameSuffix">This suffix is appended after the hash of the document.</param>
public static IServiceCollection AddAzureBlobStorageOperationDocumentStorage(
this IServiceCollection services,
Func<IServiceProvider, BlobContainerClient> containerClientFactory,
string blobNamePrefix = "",
string blobNameSuffix = ".graphql"
)
Func<IServiceProvider, BlobContainerClient> 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<IOperationDocumentStorage>()
.AddSingleton<IOperationDocumentStorage>(
sp => new AzureBlobOperationDocumentStorage(containerClientFactory(sp), blobNamePrefix, blobNameSuffix));
sp => new AzureBlobOperationDocumentStorage(containerClientFactory(sp)));
}

private static IServiceCollection RemoveService<TService>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ namespace HotChocolate.PersistedOperations.AzureBlobStorage;
public class AzureBlobStorageOperationDocumentStorageTests : IClassFixture<AzureStorageBlobResource>
{
private readonly BlobContainerClient _client;
private const string Prefix = "hc_";
private const string Suffix = ".graphql";

public AzureBlobStorageOperationDocumentStorageTests(AzureStorageBlobResource blobStorageResource)
{
Expand All @@ -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
Expand All @@ -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<ArgumentNullException>(Action);
await Assert.ThrowsAsync<ArgumentException>(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
Expand All @@ -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);

Expand All @@ -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);
Expand All @@ -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";
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,6 @@ namespace HotChocolate.PersistedOperations.AzureBlobStorage;

public class IntegrationTests : IClassFixture<AzureStorageBlobResource>
{
private const string Prefix = "hc_";
private const string Suffix = ".graphql";

private readonly BlobContainerClient _client;

public IntegrationTests(AzureStorageBlobResource blobStorageResource)
Expand All @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand Down

0 comments on commit 1237930

Please sign in to comment.