-
Couldn't load subscription status.
- Fork 2.1k
[Feature] Log-structured grain storage #9450
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
Merged
Merged
Changes from 4 commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
e8fe1ac
[Feature] Log-structured grain storage
ReubenBond f122330
Enable tests in CI
ReubenBond 863b0bd
Update src/Orleans.Journaling/DurableState.cs
ReubenBond be11a7c
Update src/Orleans.Journaling/DurableTaskCompletionSource.cs
ReubenBond a992c07
Review feedback
ReubenBond c29e893
LoggerMessage
ReubenBond d402a20
Partial revert
ReubenBond d71604a
Partial revert
ReubenBond f2540da
wip
ReubenBond 04d4679
Add comments and rename tests
ReubenBond 891608d
Add more tests
ReubenBond 8077d1b
Add assertions
ReubenBond 7399e13
Add additional tests
ReubenBond a408685
Fix style
ReubenBond 34f4f76
Address PR feedback
ReubenBond File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
175 changes: 175 additions & 0 deletions
175
src/Azure/Orleans.Journaling.AzureStorage/AzureAppendBlobLogStorage.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| using Azure; | ||
| using Azure.Storage.Blobs.Specialized; | ||
| using Azure.Storage.Blobs.Models; | ||
| using System.Runtime.CompilerServices; | ||
| using Azure.Storage.Sas; | ||
| using Orleans.Serialization.Buffers; | ||
| using Microsoft.Extensions.Logging; | ||
|
|
||
| namespace Orleans.Journaling; | ||
|
|
||
| internal sealed class AzureAppendBlobLogStorage : IStateMachineStorage | ||
| { | ||
| private static readonly AppendBlobCreateOptions CreateOptions = new() { Conditions = new() { IfNoneMatch = ETag.All } }; | ||
| private readonly AppendBlobClient _client; | ||
| private readonly ILogger<AzureAppendBlobLogStorage> _logger; | ||
| private readonly LogExtentBuilder.ReadOnlyStream _stream; | ||
| private readonly AppendBlobAppendBlockOptions _appendOptions; | ||
| private bool _exists; | ||
| private int _numBlocks; | ||
|
|
||
| public bool IsCompactionRequested => _numBlocks > 10; | ||
|
|
||
| public AzureAppendBlobLogStorage(AppendBlobClient client, ILogger<AzureAppendBlobLogStorage> logger) | ||
| { | ||
| _client = client; | ||
| _logger = logger; | ||
| _stream = new(); | ||
|
|
||
| // For the first request, if we have not performed a read yet, we want to guard against clobbering an existing blob. | ||
| _appendOptions = new AppendBlobAppendBlockOptions() { Conditions = new AppendBlobRequestConditions { IfNoneMatch = ETag.All } }; | ||
| } | ||
|
|
||
| public async ValueTask AppendAsync(LogExtentBuilder value, CancellationToken cancellationToken) | ||
| { | ||
| if (!_exists) | ||
| { | ||
| var response = await _client.CreateAsync(CreateOptions, cancellationToken); | ||
| _appendOptions.Conditions.IfNoneMatch = default; | ||
| _appendOptions.Conditions.IfMatch = response.Value.ETag; | ||
| _exists = true; | ||
| } | ||
|
|
||
| _stream.SetBuilder(value); | ||
| var result = await _client.AppendBlockAsync(_stream, _appendOptions, cancellationToken).ConfigureAwait(false); | ||
| if (_logger.IsEnabled(LogLevel.Debug)) | ||
| { | ||
| var length = value.Length; | ||
| _logger.LogDebug("Appended {Length} bytes to blob \"{ContainerName}/{BlobName}\"", length, _client.BlobContainerName, _client.Name); | ||
| } | ||
|
|
||
| _stream.Reset(); | ||
| _appendOptions.Conditions.IfNoneMatch = default; | ||
| _appendOptions.Conditions.IfMatch = result.Value.ETag; | ||
| _numBlocks = result.Value.BlobCommittedBlockCount; | ||
| } | ||
|
|
||
| public async ValueTask DeleteAsync(CancellationToken cancellationToken) | ||
| { | ||
| var conditions = new BlobRequestConditions { IfMatch = _appendOptions.Conditions.IfMatch }; | ||
| await _client.DeleteAsync(conditions: conditions, cancellationToken: cancellationToken).ConfigureAwait(false); | ||
|
|
||
| // Expect no blob to have been created when we append to it. | ||
| _appendOptions.Conditions.IfNoneMatch = ETag.All; | ||
| _appendOptions.Conditions.IfMatch = default; | ||
| _numBlocks = 0; | ||
| } | ||
|
|
||
| public async IAsyncEnumerable<LogExtent> ReadAsync([EnumeratorCancellation] CancellationToken cancellationToken) | ||
| { | ||
| Response<BlobDownloadStreamingResult> result; | ||
| try | ||
| { | ||
| // If the blob was not newly created, then download the blob. | ||
| result = await _client.DownloadStreamingAsync(cancellationToken: cancellationToken).ConfigureAwait(false); | ||
| } | ||
| catch (RequestFailedException exception) when (exception.Status is 404) | ||
| { | ||
| _exists = false; | ||
| yield break; | ||
| } | ||
|
|
||
| // If the blob has a size of zero, check for a snapshot and restore the blob from the snapshot if one exists. | ||
| if (result.Value.Details.ContentLength == 0) | ||
| { | ||
| if (result.Value.Details.Metadata.TryGetValue("snapshot", out var snapshot) && snapshot is { Length: > 0 }) | ||
| { | ||
| result = await CopyFromSnapshotAsync(result.Value.Details.ETag, snapshot, cancellationToken).ConfigureAwait(false); | ||
| } | ||
| } | ||
|
|
||
| _numBlocks = result.Value.Details.BlobCommittedBlockCount; | ||
| _appendOptions.Conditions.IfNoneMatch = default; | ||
| _appendOptions.Conditions.IfMatch = result.Value.Details.ETag; | ||
| _exists = true; | ||
|
|
||
| // Read everything into a single log segment. We could change this to read in chunks, | ||
| // yielding when the stream does not return synchronously, if we wanted to support larger state machines. | ||
| var rawStream = result.Value.Content; | ||
| using var buffer = new ArcBufferWriter(); | ||
| while (true) | ||
| { | ||
| var mem = buffer.GetMemory(); | ||
| var bytesRead = await rawStream.ReadAsync(mem, cancellationToken); | ||
| if (bytesRead == 0) | ||
| { | ||
| if (buffer.Length > 0) | ||
| { | ||
| if (_logger.IsEnabled(LogLevel.Debug)) | ||
| { | ||
| var length = buffer.Length; | ||
| _logger.LogDebug("Read {Length} bytes from blob \"{ContainerName}/{BlobName}\"", length, _client.BlobContainerName, _client.Name); | ||
| } | ||
|
|
||
| yield return new LogExtent(buffer.ConsumeSlice(buffer.Length)); | ||
| } | ||
|
|
||
| yield break; | ||
| } | ||
|
|
||
| buffer.AdvanceWriter(bytesRead); | ||
| } | ||
| } | ||
|
|
||
| private async Task<Response<BlobDownloadStreamingResult>> CopyFromSnapshotAsync(ETag eTag, string snapshotDetail, CancellationToken cancellationToken) | ||
| { | ||
| // Read snapshot and append it to the blob. | ||
| var snapshot = _client.WithSnapshot(snapshotDetail); | ||
| var uri = snapshot.GenerateSasUri(permissions: BlobSasPermissions.Read, expiresOn: DateTimeOffset.UtcNow.AddHours(1)); | ||
| var copyResult = await _client.SyncCopyFromUriAsync( | ||
| uri, | ||
| new BlobCopyFromUriOptions { DestinationConditions = new BlobRequestConditions { IfNoneMatch = eTag } }, | ||
| cancellationToken).ConfigureAwait(false); | ||
| if (copyResult.Value.CopyStatus is not CopyStatus.Success) | ||
| { | ||
| throw new InvalidOperationException($"Copy did not complete successfully. Status: {copyResult.Value.CopyStatus}"); | ||
| } | ||
|
|
||
| var result = await _client.DownloadStreamingAsync(cancellationToken: cancellationToken).ConfigureAwait(false); | ||
| _exists = true; | ||
| return result; | ||
| } | ||
|
|
||
| public async ValueTask ReplaceAsync(LogExtentBuilder value, CancellationToken cancellationToken) | ||
| { | ||
| // Create a snapshot of the blob for recovery purposes. | ||
| var blobSnapshot = await _client.CreateSnapshotAsync(conditions: _appendOptions.Conditions, cancellationToken: cancellationToken).ConfigureAwait(false); | ||
|
|
||
| // Open the blob for writing, overwriting existing contents. | ||
| var createOptions = new AppendBlobCreateOptions() | ||
| { | ||
| Conditions = _appendOptions.Conditions, | ||
| Metadata = new Dictionary<string, string> { ["snapshot"] = blobSnapshot.Value.Snapshot }, | ||
| }; | ||
| var createResult = await _client.CreateAsync(createOptions, cancellationToken).ConfigureAwait(false); | ||
| _appendOptions.Conditions.IfMatch = createResult.Value.ETag; | ||
| _appendOptions.Conditions.IfNoneMatch = default; | ||
|
|
||
| // Write the state machine snapshot. | ||
| _stream.SetBuilder(value); | ||
| var result = await _client.AppendBlockAsync(_stream, _appendOptions, cancellationToken).ConfigureAwait(false); | ||
| if (_logger.IsEnabled(LogLevel.Debug)) | ||
| { | ||
| var length = value.Length; | ||
| _logger.LogDebug("Replaced blob \"{ContainerName}/{BlobName}\", writing {Length} bytes", _client.BlobContainerName, _client.Name, length); | ||
| } | ||
|
|
||
| _stream.Reset(); | ||
| _appendOptions.Conditions.IfNoneMatch = default; | ||
| _appendOptions.Conditions.IfMatch = result.Value.ETag; | ||
| _numBlocks = result.Value.BlobCommittedBlockCount; | ||
|
|
||
| // Delete the blob snapshot. | ||
| await _client.WithSnapshot(blobSnapshot.Value.Snapshot).DeleteAsync(cancellationToken: cancellationToken).ConfigureAwait(false); | ||
| } | ||
| } |
120 changes: 120 additions & 0 deletions
120
src/Azure/Orleans.Journaling.AzureStorage/AzureAppendBlobStateMachineStorageOptions.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| using Azure; | ||
| using Azure.Storage.Blobs; | ||
| using Azure.Storage; | ||
| using Azure.Core; | ||
| using Orleans.Runtime; | ||
|
|
||
| namespace Orleans.Journaling; | ||
|
|
||
| /// <summary> | ||
| /// Options for configuring the Azure Append Blob state machine storage provider. | ||
| /// </summary> | ||
| public sealed class AzureAppendBlobStateMachineStorageOptions | ||
| { | ||
| private BlobServiceClient? _blobServiceClient; | ||
|
|
||
| /// <summary> | ||
| /// Container name where state machine state is stored. | ||
| /// </summary> | ||
| public string ContainerName { get; set; } = DEFAULT_CONTAINER_NAME; | ||
| public const string DEFAULT_CONTAINER_NAME = "state"; | ||
|
|
||
| /// <summary> | ||
| /// Gets or sets the delegate used to generate the blob name for a given grain. | ||
| /// </summary> | ||
| public Func<GrainId, string> GetBlobName { get; set; } = DefaultGetBlobName; | ||
|
|
||
| private static readonly Func<GrainId, string> DefaultGetBlobName = static (GrainId grainId) => $"{grainId}.bin"; | ||
|
|
||
| /// <summary> | ||
| /// Options to be used when configuring the blob storage client, or <see langword="null"/> to use the default options. | ||
| /// </summary> | ||
| public BlobClientOptions? ClientOptions { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// Gets or sets the client used to access the Azure Blob Service. | ||
| /// </summary> | ||
| public BlobServiceClient? BlobServiceClient | ||
| { | ||
| get => _blobServiceClient; | ||
| set | ||
| { | ||
| ArgumentNullException.ThrowIfNull(value); | ||
| _blobServiceClient = value; | ||
| CreateClient = ct => Task.FromResult(value); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// The optional delegate used to create a <see cref="BlobServiceClient"/> instance. | ||
| /// </summary> | ||
| internal Func<CancellationToken, Task<BlobServiceClient>>? CreateClient { get; private set; } | ||
|
|
||
| /// <summary> | ||
| /// Stage of silo lifecycle where storage should be initialized. Storage must be initialized prior to use. | ||
| /// </summary> | ||
| public int InitStage { get; set; } = DEFAULT_INIT_STAGE; | ||
| public const int DEFAULT_INIT_STAGE = ServiceLifecycleStage.ApplicationServices; | ||
|
|
||
| /// <summary> | ||
| /// A function for building container factory instances. | ||
| /// </summary> | ||
| public Func<IServiceProvider, AzureAppendBlobStateMachineStorageOptions, IBlobContainerFactory> BuildContainerFactory { get; set; } | ||
| = static (provider, options) => new DefaultBlobContainerFactory(options); | ||
|
|
||
| /// <summary> | ||
| /// Configures the <see cref="BlobServiceClient"/> using a connection string. | ||
| /// </summary> | ||
| public void ConfigureBlobServiceClient(string connectionString) | ||
| { | ||
| ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); | ||
| CreateClient = ct => Task.FromResult(new BlobServiceClient(connectionString, ClientOptions)); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Configures the <see cref="BlobServiceClient"/> using an authenticated service URI. | ||
| /// </summary> | ||
| public void ConfigureBlobServiceClient(Uri serviceUri) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(serviceUri); | ||
| CreateClient = ct => Task.FromResult(new BlobServiceClient(serviceUri, ClientOptions)); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Configures the <see cref="BlobServiceClient"/> using the provided callback. | ||
| /// </summary> | ||
| public void ConfigureBlobServiceClient(Func<CancellationToken, Task<BlobServiceClient>> createClientCallback) | ||
| { | ||
| CreateClient = createClientCallback ?? throw new ArgumentNullException(nameof(createClientCallback)); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Configures the <see cref="BlobServiceClient"/> using an authenticated service URI and a <see cref="TokenCredential"/>. | ||
| /// </summary> | ||
| public void ConfigureBlobServiceClient(Uri serviceUri, TokenCredential tokenCredential) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(serviceUri); | ||
| ArgumentNullException.ThrowIfNull(tokenCredential); | ||
| CreateClient = ct => Task.FromResult(new BlobServiceClient(serviceUri, tokenCredential, ClientOptions)); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Configures the <see cref="BlobServiceClient"/> using an authenticated service URI and a <see cref="AzureSasCredential"/>. | ||
| /// </summary> | ||
| public void ConfigureBlobServiceClient(Uri serviceUri, AzureSasCredential azureSasCredential) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(serviceUri); | ||
| ArgumentNullException.ThrowIfNull(azureSasCredential); | ||
| CreateClient = ct => Task.FromResult(new BlobServiceClient(serviceUri, azureSasCredential, ClientOptions)); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Configures the <see cref="BlobServiceClient"/> using an authenticated service URI and a <see cref="StorageSharedKeyCredential"/>. | ||
| /// </summary> | ||
| public void ConfigureBlobServiceClient(Uri serviceUri, StorageSharedKeyCredential sharedKeyCredential) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(serviceUri); | ||
| ArgumentNullException.ThrowIfNull(sharedKeyCredential); | ||
| CreateClient = ct => Task.FromResult(new BlobServiceClient(serviceUri, sharedKeyCredential, ClientOptions)); | ||
| } | ||
| } | ||
37 changes: 37 additions & 0 deletions
37
src/Azure/Orleans.Journaling.AzureStorage/AzureAppendBlobStateMachineStorageProvider.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| using Azure.Storage.Blobs.Specialized; | ||
| using Microsoft.Extensions.Options; | ||
| using Microsoft.Extensions.Logging; | ||
| using Orleans.Runtime; | ||
|
|
||
| namespace Orleans.Journaling; | ||
|
|
||
| internal sealed class AzureAppendBlobStateMachineStorageProvider( | ||
| IOptions<AzureAppendBlobStateMachineStorageOptions> options, | ||
| IServiceProvider serviceProvider, | ||
| ILogger<AzureAppendBlobLogStorage> logger) : IStateMachineStorageProvider, ILifecycleParticipant<ISiloLifecycle> | ||
| { | ||
| private readonly IBlobContainerFactory _containerFactory = options.Value.BuildContainerFactory(serviceProvider, options.Value); | ||
| private readonly AzureAppendBlobStateMachineStorageOptions _options = options.Value; | ||
|
|
||
| private async Task Initialize(CancellationToken cancellationToken) | ||
| { | ||
| var client = await _options.CreateClient!(cancellationToken); | ||
| await _containerFactory.InitializeAsync(client, cancellationToken).ConfigureAwait(false); | ||
| } | ||
|
|
||
| public IStateMachineStorage Create(IGrainContext grainContext) | ||
| { | ||
| var container = _containerFactory.GetBlobContainerClient(grainContext.GrainId); | ||
| var blobName = _options.GetBlobName(grainContext.GrainId); | ||
| var blobClient = container.GetAppendBlobClient(blobName); | ||
| return new AzureAppendBlobLogStorage(blobClient, logger); | ||
| } | ||
|
|
||
| public void Participate(ISiloLifecycle observer) | ||
| { | ||
| observer.Subscribe( | ||
| nameof(AzureAppendBlobStateMachineStorageProvider), | ||
| ServiceLifecycleStage.RuntimeInitialize, | ||
| onStart: Initialize); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this container name have to be globally unique for the storage account? Should we prepend something here to avoid collisions?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's configurable, so developers can configure it how they like (eg, prefixing it with a unique id, possibly based on ServiceId). They can also provide a factory via the
BuildContainerFactoryproperty below to customize it on a per-grain basis.They have options already, but we could set a different default value or potentially prefix it with the ServiceId automatically. What do you prefer?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If a collision error will be obvious, I guess it's fine to leave as is.