Skip to content

Commit fec45b6

Browse files
Copilotradicaldavidfowl
authored
Fix race condition in concurrent DeploymentState access causing intermittent AzureDeployerTests failures (#11974)
* Initial plan * Fix race condition in JsonExtensions.Prop causing AzureDeployerTests failures Co-authored-by: radical <[email protected]> * Add thread-safety tests for JsonExtensions.Prop Co-authored-by: radical <[email protected]> * Use ConditionalWeakTable for lock objects instead of locking on JsonObject Co-authored-by: radical <[email protected]> * Move thread-safety from JsonExtensions.Prop to ProvisioningContext level Address feedback to provide thread-safety at the ProvisioningContext level rather than in the Prop method itself. This ensures all DeploymentState access is synchronized when multiple resources are provisioned in parallel. - Added WithDeploymentState methods to ProvisioningContext for thread-safe access - Updated BicepProvisioner to use WithDeploymentState for all state modifications - Reverted JsonExtensions.Prop to simpler implementation without locking - Updated tests: removed concurrent test from JsonExtensionsTests - Added comprehensive thread-safety tests to ProvisioningContextTests Co-authored-by: radical <[email protected]> * Revert JsonExtensions.cs to original implementation As suggested by @davidfowl, revert changes to JsonExtensions.cs since the core thread-safety issue is now properly handled by WithDeploymentState at the ProvisioningContext level. The original TryAdd logic with retry is restored. Co-authored-by: davidfowl <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: radical <[email protected]> Co-authored-by: davidfowl <[email protected]>
1 parent 620997e commit fec45b6

File tree

4 files changed

+286
-25
lines changed

4 files changed

+286
-25
lines changed

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

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -218,38 +218,41 @@ await notificationService.PublishUpdateAsync(resource, state =>
218218
// e.g. { "sqlServerName": { "type": "String", "value": "<value>" }}
219219
var outputObj = outputs?.ToObjectFromJson<JsonObject>();
220220

221-
// Populate values into deployment state
222-
var az = context.DeploymentState.Prop("Azure");
223-
az["Tenant"] = context.Tenant.DefaultDomain;
221+
// Populate values into deployment state with thread-safe synchronization
222+
context.WithDeploymentState(deploymentState =>
223+
{
224+
var az = deploymentState.Prop("Azure");
225+
az["Tenant"] = context.Tenant.DefaultDomain;
224226

225-
var resourceConfig = context.DeploymentState
226-
.Prop("Azure")
227-
.Prop("Deployments")
228-
.Prop(resource.Name);
227+
var resourceConfig = deploymentState
228+
.Prop("Azure")
229+
.Prop("Deployments")
230+
.Prop(resource.Name);
229231

230-
// Clear the entire section
231-
resourceConfig.AsObject().Clear();
232+
// Clear the entire section
233+
resourceConfig.AsObject().Clear();
232234

233-
// Save the deployment id to the configuration
234-
resourceConfig["Id"] = deployment.Id.ToString();
235+
// Save the deployment id to the configuration
236+
resourceConfig["Id"] = deployment.Id.ToString();
235237

236-
// Stash all parameters as a single JSON string
237-
resourceConfig["Parameters"] = parameters.ToJsonString();
238+
// Stash all parameters as a single JSON string
239+
resourceConfig["Parameters"] = parameters.ToJsonString();
238240

239-
if (outputObj is not null)
240-
{
241-
// Same for outputs
242-
resourceConfig["Outputs"] = outputObj.ToJsonString();
243-
}
241+
if (outputObj is not null)
242+
{
243+
// Same for outputs
244+
resourceConfig["Outputs"] = outputObj.ToJsonString();
245+
}
244246

245-
// Write resource scope to config for consistent checksums
246-
if (scope is not null)
247-
{
248-
resourceConfig["Scope"] = scope.ToJsonString();
249-
}
247+
// Write resource scope to config for consistent checksums
248+
if (scope is not null)
249+
{
250+
resourceConfig["Scope"] = scope.ToJsonString();
251+
}
250252

251-
// Save the checksum to the configuration
252-
resourceConfig["CheckSum"] = BicepUtilities.GetChecksum(resource, parameters, scope);
253+
// Save the checksum to the configuration
254+
resourceConfig["CheckSum"] = BicepUtilities.GetChecksum(resource, parameters, scope);
255+
});
253256

254257
if (outputObj is not null)
255258
{

src/Aspire.Hosting.Azure/Provisioning/ProvisioningContext.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ internal sealed class ProvisioningContext(
2020
JsonObject deploymentState,
2121
DistributedApplicationExecutionContext executionContext)
2222
{
23+
// Lock object to protect concurrent access to DeploymentState from multiple provisioning tasks
24+
private readonly object _deploymentStateLock = new();
25+
2326
public TokenCredential Credential => credential;
2427
public IArmClient ArmClient => armClient;
2528
public ISubscriptionResource Subscription => subscription;
@@ -29,4 +32,30 @@ internal sealed class ProvisioningContext(
2932
public UserPrincipal Principal => principal;
3033
public JsonObject DeploymentState => deploymentState;
3134
public DistributedApplicationExecutionContext ExecutionContext => executionContext;
35+
36+
/// <summary>
37+
/// Executes an action on the DeploymentState with thread-safe synchronization.
38+
/// Use this method to perform any read or write operations on the DeploymentState
39+
/// when multiple resources are being provisioned in parallel.
40+
/// </summary>
41+
public void WithDeploymentState(Action<JsonObject> action)
42+
{
43+
lock (_deploymentStateLock)
44+
{
45+
action(deploymentState);
46+
}
47+
}
48+
49+
/// <summary>
50+
/// Executes a function on the DeploymentState with thread-safe synchronization.
51+
/// Use this method to perform any read or write operations on the DeploymentState
52+
/// when multiple resources are being provisioned in parallel.
53+
/// </summary>
54+
public T WithDeploymentState<T>(Func<JsonObject, T> func)
55+
{
56+
lock (_deploymentStateLock)
57+
{
58+
return func(deploymentState);
59+
}
60+
}
3261
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Text.Json.Nodes;
5+
6+
namespace Aspire.Hosting.Azure.Tests;
7+
8+
public class JsonExtensionsTests
9+
{
10+
[Fact]
11+
public void Prop_ReturnsExistingNode_WhenNodeAlreadyExists()
12+
{
13+
// Arrange
14+
var rootJson = new JsonObject();
15+
var azureNode = rootJson.Prop("Azure");
16+
azureNode.AsObject()["TestProperty"] = "TestValue";
17+
18+
// Act
19+
var retrievedNode = rootJson.Prop("Azure");
20+
21+
// Assert
22+
Assert.Same(azureNode, retrievedNode);
23+
Assert.Equal("TestValue", retrievedNode["TestProperty"]!.GetValue<string>());
24+
}
25+
26+
[Fact]
27+
public void Prop_CreatesNewNode_WhenNodeDoesNotExist()
28+
{
29+
// Arrange
30+
var rootJson = new JsonObject();
31+
32+
// Act
33+
var newNode = rootJson.Prop("NewProperty");
34+
35+
// Assert
36+
Assert.NotNull(newNode);
37+
Assert.Same(rootJson["NewProperty"], newNode);
38+
}
39+
40+
[Fact]
41+
public void Prop_NestedAccess_CreatesHierarchy()
42+
{
43+
// Arrange
44+
var rootJson = new JsonObject();
45+
46+
// Act
47+
var deeply = rootJson.Prop("Level1")
48+
.Prop("Level2")
49+
.Prop("Level3")
50+
.Prop("Level4");
51+
52+
// Assert
53+
Assert.NotNull(rootJson["Level1"]);
54+
Assert.NotNull(rootJson["Level1"]!["Level2"]);
55+
Assert.NotNull(rootJson["Level1"]!["Level2"]!["Level3"]);
56+
Assert.NotNull(rootJson["Level1"]!["Level2"]!["Level3"]!["Level4"]);
57+
Assert.Same(deeply, rootJson["Level1"]!["Level2"]!["Level3"]!["Level4"]);
58+
}
59+
}
60+

tests/Aspire.Hosting.Azure.Tests/ProvisioningContextTests.cs

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,175 @@ public void ProvisioningContext_CanBeCustomized()
157157
Assert.Equal("[email protected]", context.Principal.Name);
158158
Assert.Equal("value", context.DeploymentState["test"]?.ToString());
159159
}
160+
161+
[Fact]
162+
public async Task WithDeploymentState_ConcurrentAccess_IsThreadSafe()
163+
{
164+
// Arrange
165+
var deploymentState = new JsonObject();
166+
var context = ProvisioningTestHelpers.CreateTestProvisioningContext(userSecrets: deploymentState);
167+
const int threadCount = 10;
168+
const int iterationsPerThread = 100;
169+
var tasks = new Task[threadCount];
170+
171+
// Act - Multiple threads accessing the DeploymentState concurrently via WithDeploymentState
172+
for (int i = 0; i < threadCount; i++)
173+
{
174+
int threadId = i;
175+
tasks[i] = Task.Run(() =>
176+
{
177+
for (int j = 0; j < iterationsPerThread; j++)
178+
{
179+
context.WithDeploymentState(state =>
180+
{
181+
// All threads try to get or create the same "Azure" property
182+
var azureNode = state.Prop("Azure");
183+
184+
// Each thread creates a unique property
185+
var threadNode = azureNode.Prop($"Thread{threadId}");
186+
threadNode.AsObject()["Counter"] = j;
187+
188+
// And a shared property under Azure
189+
var deploymentsNode = azureNode.Prop("Deployments");
190+
191+
// Access a deeper nested property
192+
var resourceNode = deploymentsNode.Prop($"Resource{j % 5}");
193+
resourceNode.AsObject()["LastAccess"] = $"Thread{threadId}-{j}";
194+
});
195+
}
196+
});
197+
}
198+
199+
// Assert - Should complete without exceptions
200+
await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(10));
201+
202+
// Verify the structure was created correctly
203+
context.WithDeploymentState(state =>
204+
{
205+
Assert.NotNull(state["Azure"]);
206+
var azureObj = state["Azure"]!.AsObject();
207+
Assert.NotNull(azureObj["Deployments"]);
208+
209+
// Check that all thread-specific nodes were created
210+
for (int i = 0; i < threadCount; i++)
211+
{
212+
Assert.NotNull(azureObj[$"Thread{i}"]);
213+
}
214+
});
215+
}
216+
217+
[Fact]
218+
public void WithDeploymentState_Action_ExecutesSuccessfully()
219+
{
220+
// Arrange
221+
var deploymentState = new JsonObject();
222+
var context = ProvisioningTestHelpers.CreateTestProvisioningContext(userSecrets: deploymentState);
223+
224+
// Act
225+
var executed = false;
226+
context.WithDeploymentState(state =>
227+
{
228+
state["TestKey"] = "TestValue";
229+
executed = true;
230+
});
231+
232+
// Assert
233+
Assert.True(executed);
234+
Assert.Equal("TestValue", deploymentState["TestKey"]!.GetValue<string>());
235+
}
236+
237+
[Fact]
238+
public void WithDeploymentState_Func_ReturnsValue()
239+
{
240+
// Arrange
241+
var deploymentState = new JsonObject();
242+
deploymentState["TestKey"] = "TestValue";
243+
var context = ProvisioningTestHelpers.CreateTestProvisioningContext(userSecrets: deploymentState);
244+
245+
// Act
246+
var result = context.WithDeploymentState(state =>
247+
{
248+
return state["TestKey"]!.GetValue<string>();
249+
});
250+
251+
// Assert
252+
Assert.Equal("TestValue", result);
253+
}
254+
255+
[Fact]
256+
public async Task WithDeploymentState_ConcurrentReadsAndWrites_MaintainsConsistency()
257+
{
258+
// Arrange
259+
var deploymentState = new JsonObject();
260+
var context = ProvisioningTestHelpers.CreateTestProvisioningContext(userSecrets: deploymentState);
261+
const int writerCount = 5;
262+
const int readerCount = 5;
263+
const int iterations = 100;
264+
265+
// Initialize counter
266+
context.WithDeploymentState(state =>
267+
{
268+
state["Counter"] = 0;
269+
});
270+
271+
var writerTasks = new Task[writerCount];
272+
var readerTasks = new Task[readerCount];
273+
274+
// Act - Writers increment counter
275+
for (int i = 0; i < writerCount; i++)
276+
{
277+
writerTasks[i] = Task.Run(() =>
278+
{
279+
for (int j = 0; j < iterations; j++)
280+
{
281+
context.WithDeploymentState(state =>
282+
{
283+
var current = state["Counter"]!.GetValue<int>();
284+
state["Counter"] = current + 1;
285+
});
286+
}
287+
});
288+
}
289+
290+
// Readers read counter
291+
var readValues = new List<int>[readerCount];
292+
for (int i = 0; i < readerCount; i++)
293+
{
294+
int readerIndex = i;
295+
readValues[readerIndex] = new List<int>();
296+
readerTasks[i] = Task.Run(() =>
297+
{
298+
for (int j = 0; j < iterations; j++)
299+
{
300+
var value = context.WithDeploymentState(state =>
301+
{
302+
return state["Counter"]!.GetValue<int>();
303+
});
304+
readValues[readerIndex].Add(value);
305+
Thread.Sleep(1); // Small delay to allow interleaving
306+
}
307+
});
308+
}
309+
310+
await Task.WhenAll(writerTasks.Concat(readerTasks)).WaitAsync(TimeSpan.FromSeconds(15));
311+
312+
// Assert - Final counter value should be exactly writerCount * iterations
313+
var finalValue = context.WithDeploymentState(state =>
314+
{
315+
return state["Counter"]!.GetValue<int>();
316+
});
317+
318+
Assert.Equal(writerCount * iterations, finalValue);
319+
320+
// All read values should be in valid range (0 to finalValue)
321+
foreach (var readerValues in readValues)
322+
{
323+
Assert.All(readerValues, value =>
324+
{
325+
Assert.InRange(value, 0, finalValue);
326+
});
327+
}
328+
}
160329
}
161330

162331
public class ProvisioningServicesTests

0 commit comments

Comments
 (0)