diff --git a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs index c64640a6dd7..e1673ef62ef 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs @@ -218,38 +218,41 @@ await notificationService.PublishUpdateAsync(resource, state => // e.g. { "sqlServerName": { "type": "String", "value": "" }} var outputObj = outputs?.ToObjectFromJson(); - // Populate values into deployment state - var az = context.DeploymentState.Prop("Azure"); - az["Tenant"] = context.Tenant.DefaultDomain; + // Populate values into deployment state with thread-safe synchronization + context.WithDeploymentState(deploymentState => + { + var az = deploymentState.Prop("Azure"); + az["Tenant"] = context.Tenant.DefaultDomain; - var resourceConfig = context.DeploymentState - .Prop("Azure") - .Prop("Deployments") - .Prop(resource.Name); + var resourceConfig = deploymentState + .Prop("Azure") + .Prop("Deployments") + .Prop(resource.Name); - // Clear the entire section - resourceConfig.AsObject().Clear(); + // Clear the entire section + resourceConfig.AsObject().Clear(); - // Save the deployment id to the configuration - resourceConfig["Id"] = deployment.Id.ToString(); + // Save the deployment id to the configuration + resourceConfig["Id"] = deployment.Id.ToString(); - // Stash all parameters as a single JSON string - resourceConfig["Parameters"] = parameters.ToJsonString(); + // Stash all parameters as a single JSON string + resourceConfig["Parameters"] = parameters.ToJsonString(); - if (outputObj is not null) - { - // Same for outputs - resourceConfig["Outputs"] = outputObj.ToJsonString(); - } + if (outputObj is not null) + { + // Same for outputs + resourceConfig["Outputs"] = outputObj.ToJsonString(); + } - // Write resource scope to config for consistent checksums - if (scope is not null) - { - resourceConfig["Scope"] = scope.ToJsonString(); - } + // Write resource scope to config for consistent checksums + if (scope is not null) + { + resourceConfig["Scope"] = scope.ToJsonString(); + } - // Save the checksum to the configuration - resourceConfig["CheckSum"] = BicepUtilities.GetChecksum(resource, parameters, scope); + // Save the checksum to the configuration + resourceConfig["CheckSum"] = BicepUtilities.GetChecksum(resource, parameters, scope); + }); if (outputObj is not null) { diff --git a/src/Aspire.Hosting.Azure/Provisioning/ProvisioningContext.cs b/src/Aspire.Hosting.Azure/Provisioning/ProvisioningContext.cs index cd9ccbdf866..bf1fd22df03 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/ProvisioningContext.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/ProvisioningContext.cs @@ -20,6 +20,9 @@ internal sealed class ProvisioningContext( JsonObject deploymentState, DistributedApplicationExecutionContext executionContext) { + // Lock object to protect concurrent access to DeploymentState from multiple provisioning tasks + private readonly object _deploymentStateLock = new(); + public TokenCredential Credential => credential; public IArmClient ArmClient => armClient; public ISubscriptionResource Subscription => subscription; @@ -29,4 +32,30 @@ internal sealed class ProvisioningContext( public UserPrincipal Principal => principal; public JsonObject DeploymentState => deploymentState; public DistributedApplicationExecutionContext ExecutionContext => executionContext; + + /// + /// Executes an action on the DeploymentState with thread-safe synchronization. + /// Use this method to perform any read or write operations on the DeploymentState + /// when multiple resources are being provisioned in parallel. + /// + public void WithDeploymentState(Action action) + { + lock (_deploymentStateLock) + { + action(deploymentState); + } + } + + /// + /// Executes a function on the DeploymentState with thread-safe synchronization. + /// Use this method to perform any read or write operations on the DeploymentState + /// when multiple resources are being provisioned in parallel. + /// + public T WithDeploymentState(Func func) + { + lock (_deploymentStateLock) + { + return func(deploymentState); + } + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/JsonExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/JsonExtensionsTests.cs new file mode 100644 index 00000000000..b77f2ac2b54 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/JsonExtensionsTests.cs @@ -0,0 +1,60 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Nodes; + +namespace Aspire.Hosting.Azure.Tests; + +public class JsonExtensionsTests +{ + [Fact] + public void Prop_ReturnsExistingNode_WhenNodeAlreadyExists() + { + // Arrange + var rootJson = new JsonObject(); + var azureNode = rootJson.Prop("Azure"); + azureNode.AsObject()["TestProperty"] = "TestValue"; + + // Act + var retrievedNode = rootJson.Prop("Azure"); + + // Assert + Assert.Same(azureNode, retrievedNode); + Assert.Equal("TestValue", retrievedNode["TestProperty"]!.GetValue()); + } + + [Fact] + public void Prop_CreatesNewNode_WhenNodeDoesNotExist() + { + // Arrange + var rootJson = new JsonObject(); + + // Act + var newNode = rootJson.Prop("NewProperty"); + + // Assert + Assert.NotNull(newNode); + Assert.Same(rootJson["NewProperty"], newNode); + } + + [Fact] + public void Prop_NestedAccess_CreatesHierarchy() + { + // Arrange + var rootJson = new JsonObject(); + + // Act + var deeply = rootJson.Prop("Level1") + .Prop("Level2") + .Prop("Level3") + .Prop("Level4"); + + // Assert + Assert.NotNull(rootJson["Level1"]); + Assert.NotNull(rootJson["Level1"]!["Level2"]); + Assert.NotNull(rootJson["Level1"]!["Level2"]!["Level3"]); + Assert.NotNull(rootJson["Level1"]!["Level2"]!["Level3"]!["Level4"]); + Assert.Same(deeply, rootJson["Level1"]!["Level2"]!["Level3"]!["Level4"]); + } +} + diff --git a/tests/Aspire.Hosting.Azure.Tests/ProvisioningContextTests.cs b/tests/Aspire.Hosting.Azure.Tests/ProvisioningContextTests.cs index e7c2a06ddc9..7f872197b0f 100644 --- a/tests/Aspire.Hosting.Azure.Tests/ProvisioningContextTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/ProvisioningContextTests.cs @@ -157,6 +157,175 @@ public void ProvisioningContext_CanBeCustomized() Assert.Equal("custom@example.com", context.Principal.Name); Assert.Equal("value", context.DeploymentState["test"]?.ToString()); } + + [Fact] + public async Task WithDeploymentState_ConcurrentAccess_IsThreadSafe() + { + // Arrange + var deploymentState = new JsonObject(); + var context = ProvisioningTestHelpers.CreateTestProvisioningContext(userSecrets: deploymentState); + const int threadCount = 10; + const int iterationsPerThread = 100; + var tasks = new Task[threadCount]; + + // Act - Multiple threads accessing the DeploymentState concurrently via WithDeploymentState + for (int i = 0; i < threadCount; i++) + { + int threadId = i; + tasks[i] = Task.Run(() => + { + for (int j = 0; j < iterationsPerThread; j++) + { + context.WithDeploymentState(state => + { + // All threads try to get or create the same "Azure" property + var azureNode = state.Prop("Azure"); + + // Each thread creates a unique property + var threadNode = azureNode.Prop($"Thread{threadId}"); + threadNode.AsObject()["Counter"] = j; + + // And a shared property under Azure + var deploymentsNode = azureNode.Prop("Deployments"); + + // Access a deeper nested property + var resourceNode = deploymentsNode.Prop($"Resource{j % 5}"); + resourceNode.AsObject()["LastAccess"] = $"Thread{threadId}-{j}"; + }); + } + }); + } + + // Assert - Should complete without exceptions + await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(10)); + + // Verify the structure was created correctly + context.WithDeploymentState(state => + { + Assert.NotNull(state["Azure"]); + var azureObj = state["Azure"]!.AsObject(); + Assert.NotNull(azureObj["Deployments"]); + + // Check that all thread-specific nodes were created + for (int i = 0; i < threadCount; i++) + { + Assert.NotNull(azureObj[$"Thread{i}"]); + } + }); + } + + [Fact] + public void WithDeploymentState_Action_ExecutesSuccessfully() + { + // Arrange + var deploymentState = new JsonObject(); + var context = ProvisioningTestHelpers.CreateTestProvisioningContext(userSecrets: deploymentState); + + // Act + var executed = false; + context.WithDeploymentState(state => + { + state["TestKey"] = "TestValue"; + executed = true; + }); + + // Assert + Assert.True(executed); + Assert.Equal("TestValue", deploymentState["TestKey"]!.GetValue()); + } + + [Fact] + public void WithDeploymentState_Func_ReturnsValue() + { + // Arrange + var deploymentState = new JsonObject(); + deploymentState["TestKey"] = "TestValue"; + var context = ProvisioningTestHelpers.CreateTestProvisioningContext(userSecrets: deploymentState); + + // Act + var result = context.WithDeploymentState(state => + { + return state["TestKey"]!.GetValue(); + }); + + // Assert + Assert.Equal("TestValue", result); + } + + [Fact] + public async Task WithDeploymentState_ConcurrentReadsAndWrites_MaintainsConsistency() + { + // Arrange + var deploymentState = new JsonObject(); + var context = ProvisioningTestHelpers.CreateTestProvisioningContext(userSecrets: deploymentState); + const int writerCount = 5; + const int readerCount = 5; + const int iterations = 100; + + // Initialize counter + context.WithDeploymentState(state => + { + state["Counter"] = 0; + }); + + var writerTasks = new Task[writerCount]; + var readerTasks = new Task[readerCount]; + + // Act - Writers increment counter + for (int i = 0; i < writerCount; i++) + { + writerTasks[i] = Task.Run(() => + { + for (int j = 0; j < iterations; j++) + { + context.WithDeploymentState(state => + { + var current = state["Counter"]!.GetValue(); + state["Counter"] = current + 1; + }); + } + }); + } + + // Readers read counter + var readValues = new List[readerCount]; + for (int i = 0; i < readerCount; i++) + { + int readerIndex = i; + readValues[readerIndex] = new List(); + readerTasks[i] = Task.Run(() => + { + for (int j = 0; j < iterations; j++) + { + var value = context.WithDeploymentState(state => + { + return state["Counter"]!.GetValue(); + }); + readValues[readerIndex].Add(value); + Thread.Sleep(1); // Small delay to allow interleaving + } + }); + } + + await Task.WhenAll(writerTasks.Concat(readerTasks)).WaitAsync(TimeSpan.FromSeconds(15)); + + // Assert - Final counter value should be exactly writerCount * iterations + var finalValue = context.WithDeploymentState(state => + { + return state["Counter"]!.GetValue(); + }); + + Assert.Equal(writerCount * iterations, finalValue); + + // All read values should be in valid range (0 to finalValue) + foreach (var readerValues in readValues) + { + Assert.All(readerValues, value => + { + Assert.InRange(value, 0, finalValue); + }); + } + } } public class ProvisioningServicesTests