Skip to content

Commit d7298e0

Browse files
Copilotcaptainsafiadavidfowl
authored
Improve concurrency handling in IDeploymentStateManager with optimistic concurrency control (#12270)
* Improve concurrency handling in IDeploymentStateManager * Serialize SaveStateToStorageAsync calls to prevent concurrent file writes Co-authored-by: davidfowl <[email protected]> * Merge section locks and versions into single ConcurrentDictionary Co-authored-by: davidfowl <[email protected]> * Move version update outside of _saveLock using thread-safe AddOrUpdate Co-authored-by: davidfowl <[email protected]> * Make DeploymentStateSection an immutable snapshot using DeepClone Co-authored-by: davidfowl <[email protected]> * Remove synchronization from DeploymentStateSection and add thread-safety warning Co-authored-by: davidfowl <[email protected]> * Remove IDisposable from DeploymentStateSection and implement atomic version check Co-authored-by: davidfowl <[email protected]> * Update tests/Aspire.Hosting.Tests/Publishing/DeploymentStateManagerTests.cs * Make Version settable and increment after save, remove unused Lock from SectionMetadata Co-authored-by: davidfowl <[email protected]> * Replace ConcurrentDictionary with lock + Dictionary for atomic version checks Co-authored-by: davidfowl <[email protected]> * Fix Azure tests to use new DeploymentStateSection constructor without IDisposable Co-authored-by: davidfowl <[email protected]> --------- Co-authored-by: Safia Abdalla <[email protected]> Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: davidfowl <[email protected]> Co-authored-by: David Fowler <[email protected]>
1 parent 05ef476 commit d7298e0

23 files changed

+855
-617
lines changed

src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs

Lines changed: 5 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,11 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet
7272
var createContextStep = new PipelineStep
7373
{
7474
Name = "create-provisioning-context",
75-
Action = async ctx => provisioningContext = await CreateProvisioningContextAsync(ctx).ConfigureAwait(false)
75+
Action = async ctx =>
76+
{
77+
var provisioningContextProvider = ctx.Services.GetRequiredService<IProvisioningContextProvider>();
78+
provisioningContext = await provisioningContextProvider.CreateProvisioningContextAsync(ctx.CancellationToken).ConfigureAwait(false);
79+
}
7680
};
7781
createContextStep.DependsOn(validateStep);
7882

@@ -165,33 +169,9 @@ await context.ReportingStep.CompleteAsync(
165169
}
166170
}
167171

168-
private static async Task<ProvisioningContext> CreateProvisioningContextAsync(PipelineStepContext context)
169-
{
170-
var provisioningContextProvider = context.Services.GetRequiredService<IProvisioningContextProvider>();
171-
var deploymentStateManager = context.Services.GetRequiredService<IDeploymentStateManager>();
172-
var configuration = context.Services.GetRequiredService<IConfiguration>();
173-
174-
var userSecrets = await deploymentStateManager.LoadStateAsync(context.CancellationToken)
175-
.ConfigureAwait(false);
176-
var provisioningContext = await provisioningContextProvider
177-
.CreateProvisioningContextAsync(userSecrets, context.CancellationToken)
178-
.ConfigureAwait(false);
179-
180-
var clearCache = configuration.GetValue<bool>("Publishing:ClearCache");
181-
if (!clearCache)
182-
{
183-
await deploymentStateManager.SaveStateAsync(
184-
provisioningContext.DeploymentState,
185-
context.CancellationToken).ConfigureAwait(false);
186-
}
187-
188-
return provisioningContext;
189-
}
190-
191172
private static async Task ProvisionAzureBicepResourcesAsync(PipelineStepContext context, ProvisioningContext provisioningContext)
192173
{
193174
var bicepProvisioner = context.Services.GetRequiredService<IBicepProvisioner>();
194-
var deploymentStateManager = context.Services.GetRequiredService<IDeploymentStateManager>();
195175
var configuration = context.Services.GetRequiredService<IConfiguration>();
196176

197177
var bicepResources = context.Model.Resources.OfType<AzureBicepResource>()
@@ -258,14 +238,6 @@ await resourceTask.CompleteAsync(
258238
});
259239

260240
await Task.WhenAll(provisioningTasks).ConfigureAwait(false);
261-
262-
var clearCache = configuration.GetValue<bool>("Publishing:ClearCache");
263-
if (!clearCache)
264-
{
265-
await deploymentStateManager.SaveStateAsync(
266-
provisioningContext.DeploymentState,
267-
context.CancellationToken).ConfigureAwait(false);
268-
}
269241
}
270242

271243
private static async Task BuildContainerImagesAsync(PipelineStepContext context)

src/Aspire.Hosting.Azure/Provisioning/Internal/BaseProvisioningContextProvider.cs

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
2+
#pragma warning disable ASPIREPUBLISHERS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
23

34
// Licensed to the .NET Foundation under one or more agreements.
45
// The .NET Foundation licenses this file to you under the MIT license.
56

6-
using System.Text.Json.Nodes;
77
using System.Text.RegularExpressions;
8+
using Aspire.Hosting.Publishing;
89
using Azure;
910
using Azure.Core;
1011
using Azure.ResourceManager.Resources;
@@ -25,6 +26,7 @@ internal abstract partial class BaseProvisioningContextProvider(
2526
IArmClientProvider armClientProvider,
2627
IUserPrincipalProvider userPrincipalProvider,
2728
ITokenCredentialProvider tokenCredentialProvider,
29+
IDeploymentStateManager deploymentStateManager,
2830
DistributedApplicationExecutionContext distributedApplicationExecutionContext) : IProvisioningContextProvider
2931
{
3032
internal const string LocationName = "Location";
@@ -73,7 +75,7 @@ protected static bool IsValidResourceGroupName(string? name)
7375
return !name.Contains("..");
7476
}
7577

76-
public virtual async Task<ProvisioningContext> CreateProvisioningContextAsync(JsonObject deploymentState, CancellationToken cancellationToken = default)
78+
public virtual async Task<ProvisioningContext> CreateProvisioningContextAsync(CancellationToken cancellationToken = default)
7779
{
7880
var subscriptionId = _options.SubscriptionId ?? throw new MissingConfigurationException("An Azure subscription id is required. Set the Azure:SubscriptionId configuration value.");
7981

@@ -98,6 +100,9 @@ public virtual async Task<ProvisioningContext> CreateProvisioningContextAsync(Js
98100
throw new MissingConfigurationException("An azure location/region is required. Set the Azure:Location configuration value.");
99101
}
100102

103+
// Acquire Azure state section for reading/writing configuration
104+
var azureStateSection = await deploymentStateManager.AcquireSectionAsync("Azure", cancellationToken).ConfigureAwait(false);
105+
101106
string resourceGroupName;
102107
bool createIfAbsent;
103108

@@ -109,7 +114,7 @@ public virtual async Task<ProvisioningContext> CreateProvisioningContextAsync(Js
109114

110115
createIfAbsent = true;
111116

112-
deploymentState.Prop("Azure")["ResourceGroup"] = resourceGroupName;
117+
azureStateSection.Data["ResourceGroup"] = resourceGroupName;
113118
}
114119
else
115120
{
@@ -158,7 +163,7 @@ public virtual async Task<ProvisioningContext> CreateProvisioningContextAsync(Js
158163
var principal = await _userPrincipalProvider.GetUserPrincipalAsync(cancellationToken).ConfigureAwait(false);
159164

160165
// Persist the provisioning options to deployment state so they can be reused in the future
161-
var azureSection = deploymentState.Prop("Azure");
166+
var azureSection = azureStateSection.Data;
162167
azureSection["Location"] = _options.Location;
163168
azureSection["SubscriptionId"] = _options.SubscriptionId;
164169
azureSection["ResourceGroup"] = resourceGroupName;
@@ -171,6 +176,8 @@ public virtual async Task<ProvisioningContext> CreateProvisioningContextAsync(Js
171176
azureSection["AllowResourceGroupCreation"] = _options.AllowResourceGroupCreation.Value;
172177
}
173178

179+
await deploymentStateManager.SaveSectionAsync(azureStateSection, cancellationToken).ConfigureAwait(false);
180+
174181
return new ProvisioningContext(
175182
credential,
176183
armClient,
@@ -179,7 +186,6 @@ public virtual async Task<ProvisioningContext> CreateProvisioningContextAsync(Js
179186
tenantResource,
180187
location,
181188
principal,
182-
deploymentState,
183189
_distributedApplicationExecutionContext);
184190
}
185191

@@ -200,25 +206,25 @@ public virtual async Task<ProvisioningContext> CreateProvisioningContextAsync(Js
200206
if (tenantList.Count > 0)
201207
{
202208
tenantOptions = tenantList
203-
.Select(t =>
209+
.Select(t =>
204210
{
205211
var tenantId = t.TenantId?.ToString() ?? "";
206-
212+
207213
// Build display name: prefer DisplayName, fall back to domain, then to "Unknown"
208-
var displayName = !string.IsNullOrEmpty(t.DisplayName)
209-
? t.DisplayName
210-
: !string.IsNullOrEmpty(t.DefaultDomain)
211-
? t.DefaultDomain
214+
var displayName = !string.IsNullOrEmpty(t.DisplayName)
215+
? t.DisplayName
216+
: !string.IsNullOrEmpty(t.DefaultDomain)
217+
? t.DefaultDomain
212218
: "Unknown";
213-
219+
214220
// Build full description
215221
var description = displayName;
216222
if (!string.IsNullOrEmpty(t.DefaultDomain) && t.DisplayName != t.DefaultDomain)
217223
{
218224
description += $" ({t.DefaultDomain})";
219225
}
220226
description += $" — {tenantId}";
221-
227+
222228
return KeyValuePair.Create(tenantId, description);
223229
})
224230
.OrderBy(kvp => kvp.Value)

src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
using System.Text.Json.Nodes;
54
using Azure;
65
using Azure.Core;
76
using Azure.ResourceManager;
@@ -57,7 +56,9 @@ internal interface IProvisioningContextProvider
5756
/// <summary>
5857
/// Creates a provisioning context for Azure resource operations.
5958
/// </summary>
60-
Task<ProvisioningContext> CreateProvisioningContextAsync(JsonObject userSecrets, CancellationToken cancellationToken = default);
59+
/// <param name="cancellationToken">The cancellation token.</param>
60+
/// <returns>A provisioning context.</returns>
61+
Task<ProvisioningContext> CreateProvisioningContextAsync(CancellationToken cancellationToken = default);
6162
}
6263

6364
/// <summary>

src/Aspire.Hosting.Azure/Provisioning/Internal/PublishModeProvisioningContextProvider.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
#pragma warning disable ASPIREINTERACTION001
55
#pragma warning disable ASPIREPUBLISHERS001
66

7-
using System.Text.Json.Nodes;
87
using Aspire.Hosting.Azure.Resources;
98
using Aspire.Hosting.Azure.Utils;
109
using Aspire.Hosting.Pipelines;
10+
using Aspire.Hosting.Publishing;
1111
using Microsoft.Extensions.Hosting;
1212
using Microsoft.Extensions.Logging;
1313
using Microsoft.Extensions.Options;
@@ -26,6 +26,7 @@ internal sealed class PublishModeProvisioningContextProvider(
2626
IArmClientProvider armClientProvider,
2727
IUserPrincipalProvider userPrincipalProvider,
2828
ITokenCredentialProvider tokenCredentialProvider,
29+
IDeploymentStateManager deploymentStateManager,
2930
DistributedApplicationExecutionContext distributedApplicationExecutionContext,
3031
IPipelineActivityReporter activityReporter) : BaseProvisioningContextProvider(
3132
interactionService,
@@ -35,6 +36,7 @@ internal sealed class PublishModeProvisioningContextProvider(
3536
armClientProvider,
3637
userPrincipalProvider,
3738
tokenCredentialProvider,
39+
deploymentStateManager,
3840
distributedApplicationExecutionContext)
3941
{
4042
protected override string GetDefaultResourceGroupName()
@@ -58,7 +60,7 @@ protected override string GetDefaultResourceGroupName()
5860
return $"{prefix}-{normalizedApplicationName}";
5961
}
6062

61-
public override async Task<ProvisioningContext> CreateProvisioningContextAsync(JsonObject userSecrets, CancellationToken cancellationToken = default)
63+
public override async Task<ProvisioningContext> CreateProvisioningContextAsync(CancellationToken cancellationToken = default)
6264
{
6365
try
6466
{
@@ -74,7 +76,7 @@ public override async Task<ProvisioningContext> CreateProvisioningContextAsync(J
7476
_logger.LogError(ex, "Failed to retrieve Azure provisioning options.");
7577
}
7678

77-
return await base.CreateProvisioningContextAsync(userSecrets, cancellationToken).ConfigureAwait(false);
79+
return await base.CreateProvisioningContextAsync(cancellationToken).ConfigureAwait(false);
7880
}
7981

8082
private async Task RetrieveAzureProvisioningOptions(CancellationToken cancellationToken = default)

src/Aspire.Hosting.Azure/Provisioning/Internal/RunModeProvisioningContextProvider.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
2+
#pragma warning disable ASPIREPUBLISHERS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
23

34
// Licensed to the .NET Foundation under one or more agreements.
45
// The .NET Foundation licenses this file to you under the MIT license.
56

67
using System.Security.Cryptography;
7-
using System.Text.Json.Nodes;
88
using Aspire.Hosting.Azure.Resources;
99
using Aspire.Hosting.Azure.Utils;
10+
using Aspire.Hosting.Publishing;
1011
using Microsoft.Extensions.Hosting;
1112
using Microsoft.Extensions.Logging;
1213
using Microsoft.Extensions.Options;
@@ -24,6 +25,7 @@ internal sealed class RunModeProvisioningContextProvider(
2425
IArmClientProvider armClientProvider,
2526
IUserPrincipalProvider userPrincipalProvider,
2627
ITokenCredentialProvider tokenCredentialProvider,
28+
IDeploymentStateManager deploymentStateManager,
2729
DistributedApplicationExecutionContext distributedApplicationExecutionContext) : BaseProvisioningContextProvider(
2830
interactionService,
2931
options,
@@ -32,6 +34,7 @@ internal sealed class RunModeProvisioningContextProvider(
3234
armClientProvider,
3335
userPrincipalProvider,
3436
tokenCredentialProvider,
37+
deploymentStateManager,
3538
distributedApplicationExecutionContext)
3639
{
3740
private readonly TaskCompletionSource _provisioningOptionsAvailable = new(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -87,13 +90,13 @@ private void EnsureProvisioningOptions()
8790
});
8891
}
8992

90-
public override async Task<ProvisioningContext> CreateProvisioningContextAsync(JsonObject userSecrets, CancellationToken cancellationToken = default)
93+
public override async Task<ProvisioningContext> CreateProvisioningContextAsync(CancellationToken cancellationToken = default)
9194
{
9295
EnsureProvisioningOptions();
9396

9497
await _provisioningOptionsAvailable.Task.ConfigureAwait(false);
9598

96-
return await base.CreateProvisioningContextAsync(userSecrets, cancellationToken).ConfigureAwait(false);
99+
return await base.CreateProvisioningContextAsync(cancellationToken).ConfigureAwait(false);
97100
}
98101

99102
private async Task RetrieveAzureProvisioningOptions(CancellationToken cancellationToken = default)

src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
using Aspire.Hosting.Azure.Provisioning.Internal;
99
using Aspire.Hosting.Eventing;
1010
using Aspire.Hosting.Lifecycle;
11-
using Aspire.Hosting.Publishing;
1211
using Microsoft.Extensions.Configuration;
1312
using Microsoft.Extensions.Logging;
1413

@@ -22,8 +21,7 @@ internal sealed class AzureProvisioner(
2221
ResourceNotificationService notificationService,
2322
ResourceLoggerService loggerService,
2423
IDistributedApplicationEventing eventing,
25-
IProvisioningContextProvider provisioningContextProvider,
26-
IDeploymentStateManager deploymentStateManager
24+
IProvisioningContextProvider provisioningContextProvider
2725
) : IDistributedApplicationEventingSubscriber
2826
{
2927
internal const string AspireResourceNameTag = "aspire-resource-name";
@@ -164,11 +162,8 @@ private async Task ProvisionAzureResources(
164162
IList<(IResource Resource, IAzureResource AzureResource)> azureResources,
165163
CancellationToken cancellationToken)
166164
{
167-
// Load deployment state first so it can be passed to the provisioning context
168-
var deploymentState = await deploymentStateManager.LoadStateAsync(cancellationToken).ConfigureAwait(false);
169-
170165
// Make resources wait on the same provisioning context
171-
var provisioningContextLazy = new Lazy<Task<ProvisioningContext>>(() => provisioningContextProvider.CreateProvisioningContextAsync(deploymentState, cancellationToken));
166+
var provisioningContextLazy = new Lazy<Task<ProvisioningContext>>(() => provisioningContextProvider.CreateProvisioningContextAsync(cancellationToken));
172167

173168
var tasks = new List<Task>();
174169

@@ -182,9 +177,6 @@ private async Task ProvisionAzureResources(
182177
// Suppress throwing so that we can save the deployment state even if the task fails
183178
await task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
184179

185-
// If we created any resources then save the deployment state
186-
await deploymentStateManager.SaveStateAsync(deploymentState, cancellationToken).ConfigureAwait(false);
187-
188180
// Set the completion source for all resources
189181
foreach (var resource in azureResources)
190182
{

0 commit comments

Comments
 (0)