Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
1 change: 1 addition & 0 deletions eng/.docsettings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ known_presence_issues:
- ['sdk/keyvault','#5499']
- ['sdk/eventhub','#5499']
- ['sdk/attestation/Microsoft.Azure.Attestation','#5499']
- ['sdk/storage/Azure.Storage.Blobs.AspNetCore.DataProtection','#9960']

# List for changelogs begins here
- ['sdk/applicationinsights/Microsoft.Azure.ApplicationInsights.Query/CHANGELOG.md','#5499']
Expand Down
1 change: 1 addition & 0 deletions eng/Packages.Data.props
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
<PackageReference Update="Azure.ClientSdk.Analyzers" Version="0.1.1-dev.20200116.2" />
<PackageReference Update="System.Memory" Version="4.5.3" />
<PackageReference Update="Microsoft.Bcl.AsyncInterfaces" Version="1.0.0" />
<PackageReference Update="Microsoft.AspNetCore.DataProtection" Version="2.1.0" />
<PackageReference Update="Microsoft.Extensions.DependencyInjection.Abstractions" Version="2.1.0" />
<PackageReference Update="Microsoft.Extensions.DependencyInjection" Version="2.1.0" />
<PackageReference Update="Microsoft.Extensions.Logging.Abstractions" Version="2.1.0" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace Azure.Storage.Blobs.AspNetCore.DataProtection
{
public sealed partial class AzureBlobXmlRepository : Microsoft.AspNetCore.DataProtection.Repositories.IXmlRepository
{
public AzureBlobXmlRepository(Azure.Storage.Blobs.BlobClient blobClient) { }
public System.Collections.Generic.IReadOnlyCollection<System.Xml.Linq.XElement> GetAllElements() { throw null; }
public void StoreElement(System.Xml.Linq.XElement element, string friendlyName) { }
}
}
namespace Microsoft.AspNetCore.DataProtection
{
public static partial class AzureStorageBlobDataProtectionBuilderExtensions
{
public static Microsoft.AspNetCore.DataProtection.IDataProtectionBuilder PersistKeysToAzureBlobStorage(this Microsoft.AspNetCore.DataProtection.IDataProtectionBuilder builder, Azure.Storage.Blobs.BlobClient blobClient) { throw null; }
public static Microsoft.AspNetCore.DataProtection.IDataProtectionBuilder PersistKeysToAzureBlobStorage(this Microsoft.AspNetCore.DataProtection.IDataProtectionBuilder builder, System.Uri blobUri) { throw null; }
public static Microsoft.AspNetCore.DataProtection.IDataProtectionBuilder PersistKeysToAzureBlobStorage(this Microsoft.AspNetCore.DataProtection.IDataProtectionBuilder builder, System.Uri blobUri, Azure.Core.TokenCredential tokenCredential) { throw null; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>$(RequiredTargetFrameworks)</TargetFrameworks>
<Description>Microsoft Azure Blob storage support as key store.</Description>
<PackageTags>aspnetcore;dataprotection;azure;blob</PackageTags>
<Version>1.0.0-preview.1</Version>
<EnableApiCompat>false</EnableApiCompat>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.DataProtection" />
<PackageReference Include="Azure.Storage.Blobs" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Runtime.ExceptionServices;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Linq;
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Microsoft.AspNetCore.DataProtection.Repositories;

namespace Azure.Storage.Blobs.AspNetCore.DataProtection
{
/// <summary>
/// An <see cref="IXmlRepository"/> which is backed by Azure Blob Storage.
/// </summary>
/// <remarks>
/// Instances of this type are thread-safe.
/// </remarks>
public sealed class AzureBlobXmlRepository : IXmlRepository
{
private const int ConflictMaxRetries = 5;
private static readonly TimeSpan ConflictBackoffPeriod = TimeSpan.FromMilliseconds(200);

private static readonly XName RepositoryElementName = "repository";

private readonly Random _random;
private BlobData _cachedBlobData;
private readonly BlobClient _blobClient;

/// <summary>
/// Creates a new instance of the <see cref="AzureBlobXmlRepository"/>.
/// </summary>
/// <param name="blobClient">A <see cref="BlobClient"/> that is connected to the blob we are reading from and writing to.</param>
public AzureBlobXmlRepository(BlobClient blobClient)
{
_random = new Random();
_blobClient = blobClient;
}

/// <inheritdoc />
public IReadOnlyCollection<XElement> GetAllElements()
{
// Shunt the work onto a ThreadPool thread so that it's independent of any
// existing sync context or other potentially deadlock-causing items.

var elements = Task.Run(() => GetAllElementsAsync()).GetAwaiter().GetResult();
return new ReadOnlyCollection<XElement>(elements);
}

/// <inheritdoc />
public void StoreElement(XElement element, string friendlyName)
{
if (element == null)
{
throw new ArgumentNullException(nameof(element));
}

// Shunt the work onto a ThreadPool thread so that it's independent of any
// existing sync context or other potentially deadlock-causing items.

Task.Run(() => StoreElementAsync(element)).GetAwaiter().GetResult();
}

private XDocument CreateDocumentFromBlob(byte[] blob)
{
using (var memoryStream = new MemoryStream(blob))
{
var xmlReaderSettings = new XmlReaderSettings()
{
DtdProcessing = DtdProcessing.Prohibit, IgnoreProcessingInstructions = true
};

using (var xmlReader = XmlReader.Create(memoryStream, xmlReaderSettings))
{
return XDocument.Load(xmlReader);
}
}
}

private async Task<IList<XElement>> GetAllElementsAsync()
{
var data = await GetLatestDataAsync().ConfigureAwait(false);

if (data == null || data.BlobContents.Length == 0)
{
// no data in blob storage
return Array.Empty<XElement>();
}

// The document will look like this:
//
// <root>
// <child />
// <child />
// ...
// </root>
//
// We want to return the first-level child elements to our caller.

var doc = CreateDocumentFromBlob(data.BlobContents);
return doc.Root.Elements().ToList();
}

private async Task<BlobData> GetLatestDataAsync()
{
// Set the appropriate AccessCondition based on what we believe the latest
// file contents to be, then make the request.

var latestCachedData = Volatile.Read(ref _cachedBlobData); // local ref so field isn't mutated under our feet
var requestCondition = (latestCachedData != null)
? new BlobRequestConditions() { IfNoneMatch = latestCachedData.ETag }
: null;

try
{
using (var memoryStream = new MemoryStream())
{
var response = await _blobClient.DownloadToAsync(
destination: memoryStream,
conditions: requestCondition).ConfigureAwait(false);

// At this point, our original cache either didn't exist or was outdated.
// We'll update it now and return the updated value
latestCachedData = new BlobData()
{
BlobContents = memoryStream.ToArray(),
ETag = response.Headers.ETag
};

}
Volatile.Write(ref _cachedBlobData, latestCachedData);
}
catch (RequestFailedException ex) when (ex.Status == 304)
{
// 304 Not Modified
// Thrown when we already have the latest cached data.
// This isn't an error; we'll return our cached copy of the data.
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// 404 Not Found
// Thrown when no file exists in storage.
// This isn't an error; we'll delete our cached copy of data.

latestCachedData = null;
Volatile.Write(ref _cachedBlobData, latestCachedData);
}

return latestCachedData;
}

private int GetRandomizedBackoffPeriod()
{
// returns a TimeSpan in the range [0.8, 1.0) * ConflictBackoffPeriod
// not used for crypto purposes
var multiplier = 0.8 + (_random.NextDouble() * 0.2);
return (int) (multiplier * ConflictBackoffPeriod.Ticks);
Copy link
Member

Choose a reason for hiding this comment

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

I feel like this is the third copy of this code. Might be worth filing a bug to create a shared source version of it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I know we have exponential backoff in couple places but haven't seen random back off anywhere else. Do you have an example?

Copy link
Member

Choose a reason for hiding this comment

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

}

private async Task StoreElementAsync(XElement element)
{
// holds the last error in case we need to rethrow it
ExceptionDispatchInfo lastError = null;

for (var i = 0; i < ConflictMaxRetries; i++)
{
if (i > 1)
{
// If multiple conflicts occurred, wait a small period of time before retrying
// the operation so that other writers can make forward progress.
await Task.Delay(GetRandomizedBackoffPeriod()).ConfigureAwait(false);
}

if (i > 0)
{
// If at least one conflict occurred, make sure we have an up-to-date
// view of the blob contents.
await GetLatestDataAsync().ConfigureAwait(false);
}

// Merge the new element into the document. If no document exists,
// create a new default document and inject this element into it.

var latestData = Volatile.Read(ref _cachedBlobData);
var doc = (latestData != null)
? CreateDocumentFromBlob(latestData.BlobContents)
: new XDocument(new XElement(RepositoryElementName));
doc.Root.Add(element);

// Turn this document back into a byte[].

var serializedDoc = new MemoryStream();
doc.Save(serializedDoc, SaveOptions.DisableFormatting);
serializedDoc.Position = 0;

// Generate the appropriate precondition header based on whether or not
// we believe data already exists in storage.

BlobRequestConditions requestConditions;
BlobHttpHeaders headers = null;
if (latestData != null)
{
requestConditions = new BlobRequestConditions() { IfMatch = latestData.ETag };
}
else
{
requestConditions = new BlobRequestConditions() { IfNoneMatch = ETag.All };
// set content type on first write
headers = new BlobHttpHeaders() { ContentType = "application/xml; charset=utf-8" };
}

try
{
// Send the request up to the server.
var response = await _blobClient.UploadAsync(
serializedDoc,
httpHeaders: headers,
conditions: requestConditions).ConfigureAwait(false);

// If we got this far, success!
// We can update the cached view of the remote contents.

Volatile.Write(ref _cachedBlobData, new BlobData()
{
BlobContents = serializedDoc.ToArray(),
ETag = response.Value.ETag // was updated by Upload routine
});

return;
}
catch (RequestFailedException ex)
when (ex.Status == 409 || ex.Status == 412)
{
// 409 Conflict
// This error is rare but can be thrown in very special circumstances,
// such as if the blob in the process of being created. We treat it
// as equivalent to 412 for the purposes of retry logic.

// 412 Precondition Failed
// We'll get this error if another writer updated the repository and we
// have an outdated view of its contents. If this occurs, we'll just
// refresh our view of the remote contents and try again up to the max
// retry limit.

lastError = ExceptionDispatchInfo.Capture(ex);
}
}

// if we got this far, something went awry
lastError.Throw();
}

private sealed class BlobData
{
internal byte[] BlobContents;
internal ETag? ETag;
}
}
}
Loading