Skip to content

Commit 16fdda3

Browse files
authored
Fix intermittent hot-reload test failures caused by metadata initialization race condition (#3302)
## Why make this change? Related to #2992 Some flakey tests were being masked by faster initialization when the task they were in was isolated. When we refactored the tests to lower the overall runtime in this PR #3245 these flakey tests were eventually revealed. The root cause was a race condition: after a successful hot-reload, `WaitForConditionAsync` detects the "Validated hot-reloaded configuration file" console message and returns, but the engine's metadata providers have not fully re-initialized yet. An immediate HTTP request can arrive before the metadata is ready, causing a 500 error. ## What is this change? Added retry logic to the three non-ignored tests that make HTTP calls expecting success responses immediately after hot-reload: * `HotReloadConfigRuntimePathsEndToEndTest`: REST and GraphQL calls now retry up to 5 times with 1 second delays * `HotReloadConfigConnectionString`: REST call now uses `WaitForRestEndpointAsync` helper * `HotReloadConfigDatabaseType`: REST call now uses `WaitForRestEndpointAsync` helper Added a shared `WaitForRestEndpointAsync` helper method that polls a REST endpoint until it returns the expected status code (5 retries, 1 second delay). This follows the same retry pattern already established in `HotReloadConfigDataSource`, which had this exact fix applied previously. ## How was this tested? Ran a batch of 10 pipeline runs which all succeeded. <img width="1597" height="790" alt="image" src="https://github.com/user-attachments/assets/d7665b53-ba53-45b2-9c8c-333e4e296551" />
1 parent f6dd0df commit 16fdda3

1 file changed

Lines changed: 95 additions & 31 deletions

File tree

src/Service.Tests/Configuration/HotReload/ConfigurationHotReloadTests.cs

Lines changed: 95 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -355,19 +355,20 @@ await WaitForConditionAsync(
355355
HttpResponseMessage badPathRestResult = await _testClient.GetAsync($"rest/Book");
356356
HttpResponseMessage badPathGQLResult = await _testClient.SendAsync(request);
357357

358-
HttpResponseMessage result = await _testClient.GetAsync($"{restPath}/Book");
358+
// After hot-reload, the engine may still be re-initializing metadata providers.
359+
// Poll the REST endpoint to allow time for the engine to become fully ready.
360+
using HttpResponseMessage result = await WaitForRestEndpointAsync($"{restPath}/Book", HttpStatusCode.OK);
359361
string reloadRestContent = await result.Content.ReadAsStringAsync();
360-
JsonElement reloadGQLContents = await GraphQLRequestExecutor.PostGraphQLRequestAsync(
361-
_testClient,
362-
_configProvider,
363-
GQL_QUERY_NAME,
364-
GQL_QUERY);
362+
363+
// Poll the GraphQL endpoint to allow time for the engine to become fully ready.
364+
(bool querySucceeded, JsonElement reloadGQLContents) = await WaitForGraphQLEndpointAsync(GQL_QUERY_NAME, GQL_QUERY);
365365

366366
// Assert
367367
// Old paths are not found.
368368
Assert.AreEqual(HttpStatusCode.BadRequest, badPathRestResult.StatusCode);
369369
Assert.AreEqual(HttpStatusCode.NotFound, badPathGQLResult.StatusCode);
370370
// Hot reloaded paths return correct response.
371+
Assert.IsTrue(querySucceeded, "GraphQL query did not return valid results after hot-reload.");
371372
Assert.IsTrue(SqlTestHelper.JsonStringsDeepEqual(restBookContents, reloadRestContent));
372373
SqlTestHelper.PerformTestEqualJsonStrings(_bookDBOContents, reloadGQLContents.GetProperty("items").ToString());
373374
}
@@ -691,29 +692,8 @@ await WaitForConditionAsync(
691692
RuntimeConfig updatedRuntimeConfig = _configProvider.GetConfig();
692693
MsSqlOptions actualSessionContext = updatedRuntimeConfig.DataSource.GetTypedOptions<MsSqlOptions>();
693694

694-
// Retry GraphQL request because metadata re-initialization happens asynchronously
695-
// after the "Validated hot-reloaded configuration file" message. The metadata provider
696-
// factory clears and re-initializes providers on the hot-reload thread, so requests
697-
// arriving before that completes will fail with "Initialization of metadata incomplete."
698-
JsonElement reloadGQLContents = default;
699-
bool querySucceeded = false;
700-
for (int attempt = 1; attempt <= 10; attempt++)
701-
{
702-
reloadGQLContents = await GraphQLRequestExecutor.PostGraphQLRequestAsync(
703-
_testClient,
704-
_configProvider,
705-
GQL_QUERY_NAME,
706-
GQL_QUERY);
707-
708-
if (reloadGQLContents.ValueKind == JsonValueKind.Object &&
709-
reloadGQLContents.TryGetProperty("items", out _))
710-
{
711-
querySucceeded = true;
712-
break;
713-
}
714-
715-
await Task.Delay(1000);
716-
}
695+
// Poll the GraphQL endpoint to allow time for the engine to become fully ready.
696+
(bool querySucceeded, JsonElement reloadGQLContents) = await WaitForGraphQLEndpointAsync(GQL_QUERY_NAME, GQL_QUERY, maxRetries: 10);
717697

718698
// Assert
719699
Assert.IsTrue(querySucceeded, "GraphQL query did not return valid results after hot-reload. Metadata initialization may not have completed.");
@@ -804,7 +784,9 @@ await WaitForConditionAsync(
804784
succeedConfigLog = _writer.ToString();
805785
}
806786

807-
HttpResponseMessage restResult = await _testClient.GetAsync("/rest/Book");
787+
// After hot-reload, the engine may still be re-initializing metadata providers.
788+
// Poll the REST endpoint to allow time for the engine to become fully ready.
789+
using HttpResponseMessage restResult = await WaitForRestEndpointAsync("/rest/Book", HttpStatusCode.OK);
808790

809791
// Assert
810792
Assert.IsTrue(failedConfigLog.Contains(HOT_RELOAD_FAILURE_MESSAGE));
@@ -897,7 +879,9 @@ await WaitForConditionAsync(
897879
succeedConfigLog = _writer.ToString();
898880
}
899881

900-
HttpResponseMessage restResult = await _testClient.GetAsync("/rest/Book");
882+
// After hot-reload, the engine may still be re-initializing metadata providers.
883+
// Poll the REST endpoint to allow time for the engine to become fully ready.
884+
using HttpResponseMessage restResult = await WaitForRestEndpointAsync("/rest/Book", HttpStatusCode.OK);
901885

902886
// Assert
903887
Assert.IsTrue(failedConfigLog.Contains(HOT_RELOAD_FAILURE_MESSAGE));
@@ -1033,4 +1017,84 @@ private static async Task WaitForConditionAsync(Func<bool> condition, TimeSpan t
10331017

10341018
throw new TimeoutException("The condition was not met within the timeout period.");
10351019
}
1020+
1021+
/// <summary>
1022+
/// Polls a REST endpoint until it returns the expected status code.
1023+
/// After a successful hot-reload, the engine may still be re-initializing
1024+
/// metadata providers, so an immediate request can intermittently fail.
1025+
/// </summary>
1026+
private static async Task<HttpResponseMessage> WaitForRestEndpointAsync(
1027+
string requestUri,
1028+
HttpStatusCode expectedStatus,
1029+
int maxRetries = 5,
1030+
int delayMilliseconds = 1000)
1031+
{
1032+
HttpResponseMessage response = null;
1033+
for (int attempt = 1; attempt <= maxRetries; attempt++)
1034+
{
1035+
response = await _testClient.GetAsync(requestUri);
1036+
if (response.StatusCode == expectedStatus)
1037+
{
1038+
return response;
1039+
}
1040+
1041+
Console.WriteLine($"REST {requestUri} returned {response.StatusCode} on attempt {attempt}/{maxRetries}, retrying...");
1042+
1043+
// Dispose unsuccessful responses to avoid leaking connections/sockets.
1044+
if (attempt < maxRetries)
1045+
{
1046+
response.Dispose();
1047+
}
1048+
1049+
await Task.Delay(delayMilliseconds);
1050+
}
1051+
1052+
// Return the last response (undisposed) so the caller can inspect/assert on it.
1053+
return response;
1054+
}
1055+
1056+
/// <summary>
1057+
/// Polls a GraphQL endpoint until it returns a valid response containing
1058+
/// the expected property. After a successful hot-reload, the engine may
1059+
/// still be re-initializing metadata providers, so an immediate request
1060+
/// can intermittently fail. PostGraphQLRequestAsync can also throw
1061+
/// (e.g. JsonException) if the server returns a non-JSON error response
1062+
/// during re-initialization.
1063+
/// </summary>
1064+
private static async Task<(bool Success, JsonElement Result)> WaitForGraphQLEndpointAsync(
1065+
string queryName,
1066+
string query,
1067+
string expectedProperty = "items",
1068+
int maxRetries = 5,
1069+
int delayMilliseconds = 1000)
1070+
{
1071+
JsonElement result = default;
1072+
for (int attempt = 1; attempt <= maxRetries; attempt++)
1073+
{
1074+
try
1075+
{
1076+
result = await GraphQLRequestExecutor.PostGraphQLRequestAsync(
1077+
_testClient,
1078+
_configProvider,
1079+
queryName,
1080+
query);
1081+
1082+
if (result.ValueKind == JsonValueKind.Object &&
1083+
result.TryGetProperty(expectedProperty, out _))
1084+
{
1085+
return (true, result);
1086+
}
1087+
1088+
Console.WriteLine($"GraphQL query returned {result.ValueKind} on attempt {attempt}/{maxRetries}, retrying...");
1089+
}
1090+
catch (Exception ex) when (ex is JsonException || ex is HttpRequestException)
1091+
{
1092+
Console.WriteLine($"GraphQL request threw {ex.GetType().Name} on attempt {attempt}/{maxRetries}: {ex.Message}");
1093+
}
1094+
1095+
await Task.Delay(delayMilliseconds);
1096+
}
1097+
1098+
return (false, result);
1099+
}
10361100
}

0 commit comments

Comments
 (0)