diff --git a/.pipelines/mssql-pipelines.yml b/.pipelines/mssql-pipelines.yml
index d2d2d8c7fa..c11bfa133c 100644
--- a/.pipelines/mssql-pipelines.yml
+++ b/.pipelines/mssql-pipelines.yml
@@ -1,9 +1,10 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
-# MsSql Integration Testing Pipeline config is split into two jobs:
-# 1) LinuxTests -> Run SQL Server 2019 in Linux Docker Image
-# 2) WindowsTests -> Run LocalDB preinstalled on machine
+# MsSql Integration Testing Pipeline config is split into parallel jobs:
+# 1) linux (disabled) -> Run SQL Server 2019 in Linux Docker Image
+# 2) windows_combined -> GraphQL, REST, Unit, HotReload, OpenApi, Auth, Telemetry, Caching on LocalDB
+# 3) windows_configuration -> Configuration tests on LocalDB (with schema init)
trigger:
batch: true
@@ -151,7 +152,19 @@ jobs:
summaryFileLocation: '$(Agent.TempDirectory)/**/*cobertura.xml'
-- job: windows
+# MsSql Integration Testing is split into two parallel jobs (~20 min each):
+#
+# 1) windows_combined -> GraphQL, HotReload, REST, Unit, OpenApi, Auth,
+# Telemetry, and Caching tests.
+# SqlTestBase-inheriting tests (GraphQL, REST) create the DB schema;
+# the remaining tests find it already in place.
+#
+# 2) windows_configuration -> Pure ConfigurationTests.
+# No SqlTestBase tests in this bucket, so initDbSchema creates the
+# schema via SqlClient before tests run.
+
+- job: windows_combined
+ displayName: 'Windows - Combined Integration Tests'
pool:
vmImage: 'windows-latest'
variables:
@@ -167,117 +180,32 @@ jobs:
SqlVersionCode: '15.0'
steps:
- - task: CmdLine@2
- displayName: 'Set flag to publish received files when previous step fails'
- condition: failed()
- inputs:
- script: 'echo ##vso[task.setvariable variable=publishverify]Yes'
-
- - task: NuGetAuthenticate@1
- displayName: 'NuGet Authenticate'
-
- # The .NET CLI commands in proceeding tasks use the .NET SDK version specified ("selected") here.
- # Per Microsoft Learn Docs, "Selecting the .NET SDK version is independent from
- # specifying the runtime version a project targets."
- - task: UseDotNet@2
- displayName: Setup .NET SDK v8.0.x
- inputs:
- packageType: sdk
- version: 8.0.x
-
- - task: NuGetToolInstaller@1
-
- - task: DotNetCoreCLI@2
- displayName: Restore NuGet packages
- inputs:
- command: restore
- projects: '$(solution)'
- feedsToUse: config
- nugetConfigPath: Nuget.config
- restoreArguments: '/p:RuntimeIdentifiers=""'
-
- - task: PowerShell@2
- displayName: Install SQL LocalDB
- inputs:
- targetType: 'inline'
- script: |
- SqlLocalDb.exe start
- SqlLocalDB.exe info "MSSQLLocalDB"
- Write-Host "Downloading"
- Import-Module BitsTransfer
- Start-BitsTransfer -Source $(InstallerUrl) -Destination SqlLocalDB.msi
- Write-Host "Installing"
- Start-Process -FilePath "SqlLocalDB.msi" -Wait -ArgumentList "/qn", "/norestart", "/l*v SqlLocalDBInstall.log", "IACCEPTSQLLOCALDBLICENSETERMS=YES";
- SqlLocalDB.exe stop MSSQLLocalDB -k
- SqlLocalDB.exe delete MSSQLLocalDB
-
- - task: PowerShell@2
- displayName: 'Start MSSQLLocalDB'
- inputs:
- targetType: 'inline'
- script: |
- SqlLocalDb.exe start MSSQLLocalDB
- SqlLocalDb.exe info "MSSQLLocalDB"
-
- - task: DotNetCoreCLI@2
- displayName: Build
- inputs:
- command: build
- projects: |
- **/*.csproj
- !**/*Tests*.csproj
- arguments: '-p:generateConfigFileForDbType=MsSql --configuration $(buildConfiguration)' # Update this to match your need
-
- - task: DotNetCoreCLI@2
- displayName: Build Test Projects
- inputs:
- command: build
- projects: '**/*Tests/*.csproj'
- arguments: '--configuration $(buildConfiguration)'
-
- - task: FileTransform@1.206.0
- displayName: 'Generate dab-config.MsSql.json'
- inputs:
- folderPath: '$(System.DefaultWorkingDirectory)'
- fileType: 'json'
- targetFiles: 'src/out/tests/*/dab-config.MsSql.json'
-
- - task: DotNetCoreCLI@2
- displayName: 'MsSql Integration Tests'
- inputs:
- command: test
- arguments: '--filter "TestCategory=MsSql&FullyQualifiedName!~ConfigurationHotReloadTests" --no-build --configuration $(buildConfiguration) --collect "XPlat Code coverage"'
- projects: '**/*Tests/*.csproj'
-
-
- - task: DotNetCoreCLI@2
- displayName: 'Hot-Reload Tests'
- inputs:
- command: test
- arguments: '--filter "TestCategory=MsSql&FullyQualifiedName~ConfigurationHotReloadTests" --no-build --configuration $(buildConfiguration) --collect "XPlat Code coverage" --logger "console;verbosity=detailed"'
- projects: '**/*Tests/*.csproj'
- timeoutInMinutes: 45
-
- - task: PublishCodeCoverageResults@1
- displayName: 'Publish code coverage'
- inputs:
- codeCoverageTool: Cobertura
- summaryFileLocation: '$(Agent.TempDirectory)/**/*cobertura.xml'
-
- - task: CopyFiles@2
- condition: eq(variables['publishverify'], 'Yes')
- displayName: 'Copy received files to Artifact Staging'
- inputs:
- contents: '**\*.received.*'
- targetFolder: '$(Build.ArtifactStagingDirectory)\Verify'
- cleanTargetFolder: true
- overWrite: true
+ - template: templates/mssql-test-steps.yml
+ parameters:
+ testFilter: 'TestCategory=MsSql&(FullyQualifiedName~SqlTests.GraphQL|FullyQualifiedName~ConfigurationHotReloadTests|FullyQualifiedName~SqlTests.Rest|FullyQualifiedName~UnitTests|FullyQualifiedName~OpenApi|FullyQualifiedName~Telemetry|FullyQualifiedName~Authorization|FullyQualifiedName~Caching)'
+ testDisplayName: 'MsSql Combined Integration Tests'
+ artifactSuffix: '-Combined'
+
+- job: windows_configuration
+ displayName: 'Windows - Configuration Tests'
+ pool:
+ vmImage: 'windows-latest'
+ variables:
+ solution: '**/*.sln'
+ buildPlatform: 'Any CPU'
+ buildConfiguration: 'Release'
+ # Need to override the connection string set on the pipeline UI
+ # since windows needs a different string.
+ # The variable setting on the pipeline UI sets the connection string
+ # for the linux job above.
+ data-source.connection-string: Server=(localdb)\MSSQLLocalDB;Persist Security Info=False;Integrated Security=True;MultipleActiveResultSets=False;Connection Timeout=30;TrustServerCertificate=True;
+ InstallerUrl: https://download.microsoft.com/download/7/c/1/7c14e92e-bdcb-4f89-b7cf-93543e7112d1/SqlLocalDB.msi
+ SqlVersionCode: '15.0'
- - task: PublishBuildArtifacts@1
- displayName: 'Publish received files as Artifacts'
- name: 'verifypublish'
- condition: eq(variables['publishverify'], 'Yes')
- inputs:
- PathtoPublish: '$(Build.ArtifactStagingDirectory)\Verify'
- ArtifactName: 'Verify'
- publishLocation: 'Container'
+ steps:
+ - template: templates/mssql-test-steps.yml
+ parameters:
+ testFilter: 'TestCategory=MsSql&FullyQualifiedName!~SqlTests.GraphQL&FullyQualifiedName!~ConfigurationHotReloadTests&FullyQualifiedName!~SqlTests.Rest&FullyQualifiedName!~UnitTests&FullyQualifiedName!~OpenApi&FullyQualifiedName!~Telemetry&FullyQualifiedName!~Authorization&FullyQualifiedName!~Caching'
+ testDisplayName: 'MsSql Configuration Tests'
+ artifactSuffix: '-Configuration'
+ initDbSchema: true
diff --git a/.pipelines/templates/mssql-test-steps.yml b/.pipelines/templates/mssql-test-steps.yml
new file mode 100644
index 0000000000..e834132639
--- /dev/null
+++ b/.pipelines/templates/mssql-test-steps.yml
@@ -0,0 +1,161 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+# Common setup and test execution steps for MSSQL integration test jobs.
+# Used by mssql-pipelines.yml to run parallel test jobs.
+
+parameters:
+ - name: testFilter
+ type: string
+ - name: testDisplayName
+ type: string
+ default: 'MsSql Integration Tests'
+ - name: testTimeout
+ type: number
+ default: 0
+ - name: additionalTestArgs
+ type: string
+ default: ''
+ - name: artifactSuffix
+ type: string
+ default: ''
+ - name: initDbSchema
+ type: boolean
+ default: false
+
+steps:
+- task: NuGetAuthenticate@1
+ displayName: 'NuGet Authenticate'
+
+# The .NET CLI commands in proceeding tasks use the .NET SDK version specified ("selected") here.
+# Per Microsoft Learn Docs, "Selecting the .NET SDK version is independent from
+# specifying the runtime version a project targets."
+- task: UseDotNet@2
+ displayName: Setup .NET SDK v8.0.x
+ inputs:
+ packageType: sdk
+ version: 8.0.x
+
+- task: NuGetToolInstaller@1
+
+- task: DotNetCoreCLI@2
+ displayName: Restore NuGet packages
+ inputs:
+ command: restore
+ projects: '$(solution)'
+ feedsToUse: config
+ nugetConfigPath: Nuget.config
+ restoreArguments: '/p:RuntimeIdentifiers=""'
+
+- task: PowerShell@2
+ displayName: Install SQL LocalDB
+ inputs:
+ targetType: 'inline'
+ script: |
+ SqlLocalDb.exe start
+ SqlLocalDB.exe info "MSSQLLocalDB"
+ Write-Host "Downloading"
+ Import-Module BitsTransfer
+ Start-BitsTransfer -Source $(InstallerUrl) -Destination SqlLocalDB.msi
+ Write-Host "Installing"
+ Start-Process -FilePath "SqlLocalDB.msi" -Wait -ArgumentList "/qn", "/norestart", "/l*v SqlLocalDBInstall.log", "IACCEPTSQLLOCALDBLICENSETERMS=YES";
+ SqlLocalDB.exe stop MSSQLLocalDB -k
+ SqlLocalDB.exe delete MSSQLLocalDB
+
+- task: PowerShell@2
+ displayName: 'Start MSSQLLocalDB'
+ inputs:
+ targetType: 'inline'
+ script: |
+ SqlLocalDb.exe start MSSQLLocalDB
+ SqlLocalDb.exe info "MSSQLLocalDB"
+
+- ${{ if eq(parameters.initDbSchema, true) }}:
+ - task: PowerShell@2
+ displayName: 'Initialize database schema'
+ inputs:
+ targetType: 'inline'
+ script: |
+ Write-Host "Running DatabaseSchema-MsSql.sql against LocalDB via SqlClient..."
+ $sqlFile = "$(System.DefaultWorkingDirectory)\src\Service.Tests\DatabaseSchema-MsSql.sql"
+ $sql = Get-Content $sqlFile -Raw
+ $connStr = "Server=(localdb)\MSSQLLocalDB;Integrated Security=True;TrustServerCertificate=True;Connection Timeout=30;"
+ $connection = New-Object System.Data.SqlClient.SqlConnection($connStr)
+ try {
+ $connection.Open()
+ Write-Host "Connected to LocalDB successfully."
+ $command = $connection.CreateCommand()
+ $command.CommandText = $sql
+ $command.CommandTimeout = 120
+ $command.ExecuteNonQuery() | Out-Null
+ Write-Host "Database schema initialized successfully."
+ }
+ catch {
+ Write-Error "Schema initialization failed: $_"
+ throw
+ }
+ finally {
+ $connection.Close()
+ }
+
+- task: DotNetCoreCLI@2
+ displayName: Build
+ inputs:
+ command: build
+ projects: |
+ **/*.csproj
+ !**/*Tests*.csproj
+ arguments: '-p:generateConfigFileForDbType=MsSql --configuration $(buildConfiguration)' # Update this to match your need
+
+- task: DotNetCoreCLI@2
+ displayName: Build Test Projects
+ inputs:
+ command: build
+ projects: '**/*Tests/*.csproj'
+ arguments: '--configuration $(buildConfiguration)'
+
+- task: FileTransform@1.206.0
+ displayName: 'Generate dab-config.MsSql.json'
+ inputs:
+ folderPath: '$(System.DefaultWorkingDirectory)'
+ fileType: 'json'
+ targetFiles: 'src/out/tests/*/dab-config.MsSql.json'
+
+- task: DotNetCoreCLI@2
+ displayName: '${{ parameters.testDisplayName }}'
+ inputs:
+ command: test
+ arguments: '--filter "${{ parameters.testFilter }}" --no-build --configuration $(buildConfiguration) --collect "XPlat Code coverage" ${{ parameters.additionalTestArgs }}'
+ projects: '**/*Tests/*.csproj'
+ ${{ if ne(parameters.testTimeout, 0) }}:
+ timeoutInMinutes: ${{ parameters.testTimeout }}
+
+- task: CmdLine@2
+ displayName: 'Set flag to publish received files when tests fail'
+ condition: failed()
+ inputs:
+ script: 'echo ##vso[task.setvariable variable=publishverify]Yes'
+
+- task: PublishCodeCoverageResults@1
+ displayName: 'Publish code coverage'
+ inputs:
+ codeCoverageTool: Cobertura
+ summaryFileLocation: '$(Agent.TempDirectory)/**/*cobertura.xml'
+
+- task: CopyFiles@2
+ condition: eq(variables['publishverify'], 'Yes')
+ displayName: 'Copy received files to Artifact Staging'
+ inputs:
+ contents: '**\*.received.*'
+ targetFolder: '$(Build.ArtifactStagingDirectory)\Verify'
+ cleanTargetFolder: true
+ overWrite: true
+
+- task: PublishBuildArtifacts@1
+ displayName: 'Publish received files as Artifacts'
+ name: 'verifypublish'
+ condition: eq(variables['publishverify'], 'Yes')
+ inputs:
+ PathtoPublish: '$(Build.ArtifactStagingDirectory)\Verify'
+ ArtifactName: 'Verify${{ parameters.artifactSuffix }}'
+ publishLocation: 'Container'
diff --git a/src/Config/ConfigFileWatcher.cs b/src/Config/ConfigFileWatcher.cs
index 288a95e3d7..e1afb39838 100644
--- a/src/Config/ConfigFileWatcher.cs
+++ b/src/Config/ConfigFileWatcher.cs
@@ -20,8 +20,10 @@ namespace Azure.DataApiBuilder.Config;
///
///
///
-public class ConfigFileWatcher
+public class ConfigFileWatcher : IDisposable
{
+ private bool _disposed;
+
///
/// Watches a specific file for modifications and alerts
/// this class when a change is detected.
@@ -120,4 +122,25 @@ private void OnConfigFileChange(object sender, FileSystemEventArgs e)
Console.WriteLine("Unable to hot reload configuration file due to " + ex.Message);
}
}
+
+ ///
+ /// Disposes the file watcher and unsubscribes from events to release
+ /// file handles and prevent further file change notifications.
+ ///
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+
+ if (_fileWatcher is not null)
+ {
+ _fileWatcher.EnableRaisingEvents = false;
+ _fileWatcher.Changed -= OnConfigFileChange;
+ _fileWatcher.Dispose();
+ }
+ }
}
diff --git a/src/Config/FileSystemRuntimeConfigLoader.cs b/src/Config/FileSystemRuntimeConfigLoader.cs
index ecefd6a9c2..7b888a82bf 100644
--- a/src/Config/FileSystemRuntimeConfigLoader.cs
+++ b/src/Config/FileSystemRuntimeConfigLoader.cs
@@ -30,8 +30,9 @@ namespace Azure.DataApiBuilder.Config;
/// which allows for mocking of the file system in tests, providing a way to run the test
/// in isolation of other tests or the actual file system.
///
-public class FileSystemRuntimeConfigLoader : RuntimeConfigLoader
+public class FileSystemRuntimeConfigLoader : RuntimeConfigLoader, IDisposable
{
+ private bool _disposed;
///
/// This stores either the default config name e.g. dab-config.json
/// or user provided config file which could be a relative file path,
@@ -102,6 +103,27 @@ public FileSystemRuntimeConfigLoader(
_logBuffer = logBuffer;
}
+ ///
+ /// Disposes the config file watcher to release file handles and stop
+ /// monitoring the config file for changes.
+ ///
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+
+ if (_configFileWatcher is not null)
+ {
+ _configFileWatcher.NewFileContentsDetected -= OnNewFileContentsDetected;
+ _configFileWatcher.Dispose();
+ _configFileWatcher = null;
+ }
+ }
+
///
/// Get the directory name of the config file and
/// return as a string.
diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs
index 8abafb4bdb..77765298ed 100644
--- a/src/Service.Tests/Configuration/ConfigurationTests.cs
+++ b/src/Service.Tests/Configuration/ConfigurationTests.cs
@@ -710,9 +710,26 @@ type Moon {
[TestCleanup]
public void CleanupAfterEachTest()
{
+ // Retry file deletion with exponential back-off to handle cases where a
+ // file watcher or hot-reload process may still hold a handle on the file.
if (File.Exists(CUSTOM_CONFIG_FILENAME))
{
- File.Delete(CUSTOM_CONFIG_FILENAME);
+ int retryCount = 0;
+ const int maxRetries = 3;
+ while (true)
+ {
+ try
+ {
+ File.Delete(CUSTOM_CONFIG_FILENAME);
+ break;
+ }
+ catch (IOException ex) when (retryCount < maxRetries)
+ {
+ retryCount++;
+ Console.WriteLine($"CleanupAfterEachTest: Retry {retryCount}/{maxRetries} deleting {CUSTOM_CONFIG_FILENAME}. {ex.Message}");
+ Thread.Sleep(TimeSpan.FromSeconds(Math.Pow(2, retryCount)));
+ }
+ }
}
TestHelper.UnsetAllDABEnvironmentVariables();
@@ -733,7 +750,7 @@ public async Task TestNoConfigReturnsServiceUnavailable(
string[] args,
bool isUpdateableRuntimeConfig)
{
- TestServer server;
+ TestServer server = null;
try
{
@@ -758,6 +775,10 @@ public async Task TestNoConfigReturnsServiceUnavailable(
$"Could not initialize the engine with the runtime config file: {DEFAULT_CONFIG_FILE_NAME}",
e.Message);
}
+ finally
+ {
+ server?.Dispose();
+ }
}
///
@@ -1020,7 +1041,7 @@ public void TestConnectionStringIsCorrectlyUpdatedWithApplicationName(
[DataRow(CONFIGURATION_ENDPOINT_V2)]
public async Task TestConflictAlreadySetConfiguration(string configurationEndpoint)
{
- TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
+ using TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
HttpClient httpClient = server.CreateClient();
JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint);
@@ -1039,7 +1060,7 @@ public async Task TestConflictLocalConfiguration(string configurationEndpoint)
{
Environment.SetEnvironmentVariable
(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, COSMOS_ENVIRONMENT);
- TestServer server = new(Program.CreateWebHostBuilder(Array.Empty()));
+ using TestServer server = new(Program.CreateWebHostBuilder(Array.Empty()));
HttpClient httpClient = server.CreateClient();
ValidateCosmosDbSetup(server);
@@ -1056,7 +1077,7 @@ public async Task TestConflictLocalConfiguration(string configurationEndpoint)
[DataRow(CONFIGURATION_ENDPOINT_V2)]
public async Task TestSettingConfigurations(string configurationEndpoint)
{
- TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
+ using TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
HttpClient httpClient = server.CreateClient();
JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint);
@@ -1071,7 +1092,7 @@ public async Task TestSettingConfigurations(string configurationEndpoint)
[DataRow(CONFIGURATION_ENDPOINT_V2)]
public async Task TestInvalidConfigurationAtRuntime(string configurationEndpoint)
{
- TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
+ using TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
HttpClient httpClient = server.CreateClient();
JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint, "invalidString");
@@ -1086,7 +1107,7 @@ public async Task TestInvalidConfigurationAtRuntime(string configurationEndpoint
[DataRow(CONFIGURATION_ENDPOINT_V2)]
public async Task TestSettingFailureConfigurations(string configurationEndpoint)
{
- TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
+ using TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
HttpClient httpClient = server.CreateClient();
JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint);
@@ -1108,7 +1129,7 @@ public async Task TestSettingFailureConfigurations(string configurationEndpoint)
[DataRow(CONFIGURATION_ENDPOINT_V2)]
public async Task TestLongRunningConfigUpdatedHandlerConfigurations(string configurationEndpoint)
{
- TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
+ using TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
HttpClient httpClient = server.CreateClient();
JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint);
@@ -1147,7 +1168,7 @@ public async Task TestLongRunningConfigUpdatedHandlerConfigurations(string confi
[DataRow(CONFIGURATION_ENDPOINT_V2)]
public async Task TestSqlSettingPostStartupConfigurations(string configurationEndpoint)
{
- TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
+ using TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
HttpClient httpClient = server.CreateClient();
RuntimeConfig configuration = AuthorizationHelpers.InitRuntimeConfig(
@@ -1226,7 +1247,7 @@ public async Task TestSqlSettingPostStartupConfigurations(string configurationEn
[DataRow(CONFIGURATION_ENDPOINT_V2)]
public async Task TestValidMultiSourceRunTimePostStartupConfigurations(string configurationEndpoint)
{
- TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
+ using TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
HttpClient httpClient = server.CreateClient();
RuntimeConfig config = AuthorizationHelpers.InitRuntimeConfig(
@@ -1258,7 +1279,7 @@ public async Task TestValidMultiSourceRunTimePostStartupConfigurations(string co
public void TestLoadingLocalCosmosSettings()
{
Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, COSMOS_ENVIRONMENT);
- TestServer server = new(Program.CreateWebHostBuilder(Array.Empty()));
+ using TestServer server = new(Program.CreateWebHostBuilder(Array.Empty()));
ValidateCosmosDbSetup(server);
}
@@ -1268,7 +1289,7 @@ public void TestLoadingLocalCosmosSettings()
[DataRow(CONFIGURATION_ENDPOINT_V2)]
public async Task TestLoadingAccessTokenForCosmosClient(string configurationEndpoint)
{
- TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
+ using TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
HttpClient httpClient = server.CreateClient();
JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint, null, true);
@@ -1286,7 +1307,7 @@ public async Task TestLoadingAccessTokenForCosmosClient(string configurationEndp
public void TestLoadingLocalMsSqlSettings()
{
Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, MSSQL_ENVIRONMENT);
- TestServer server = new(Program.CreateWebHostBuilder(Array.Empty()));
+ using TestServer server = new(Program.CreateWebHostBuilder(Array.Empty()));
QueryEngineFactory queryEngineFactory = (QueryEngineFactory)server.Services.GetService(typeof(IQueryEngineFactory));
Assert.IsInstanceOfType(queryEngineFactory.GetQueryEngine(DatabaseType.MSSQL), typeof(SqlQueryEngine));
@@ -1306,7 +1327,7 @@ public void TestLoadingLocalMsSqlSettings()
public void TestLoadingLocalPostgresSettings()
{
Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, POSTGRESQL_ENVIRONMENT);
- TestServer server = new(Program.CreateWebHostBuilder(Array.Empty()));
+ using TestServer server = new(Program.CreateWebHostBuilder(Array.Empty()));
QueryEngineFactory queryEngineFactory = (QueryEngineFactory)server.Services.GetService(typeof(IQueryEngineFactory));
Assert.IsInstanceOfType(queryEngineFactory.GetQueryEngine(DatabaseType.PostgreSQL), typeof(SqlQueryEngine));
@@ -1326,7 +1347,7 @@ public void TestLoadingLocalPostgresSettings()
public void TestLoadingLocalMySqlSettings()
{
Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, MYSQL_ENVIRONMENT);
- TestServer server = new(Program.CreateWebHostBuilder(Array.Empty()));
+ using TestServer server = new(Program.CreateWebHostBuilder(Array.Empty()));
QueryEngineFactory queryEngineFactory = (QueryEngineFactory)server.Services.GetService(typeof(IQueryEngineFactory));
Assert.IsInstanceOfType(queryEngineFactory.GetQueryEngine(DatabaseType.MySQL), typeof(SqlQueryEngine));
@@ -1348,7 +1369,7 @@ public void TestLoadingLocalMySqlSettings()
public async Task TestOverridingLocalSettingsFails(string configurationEndpoint)
{
Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, COSMOS_ENVIRONMENT);
- TestServer server = new(Program.CreateWebHostBuilder(Array.Empty()));
+ using TestServer server = new(Program.CreateWebHostBuilder(Array.Empty()));
HttpClient client = server.CreateClient();
JsonContent config = GetJsonContentForCosmosConfigRequest(configurationEndpoint);
@@ -1362,7 +1383,7 @@ public async Task TestOverridingLocalSettingsFails(string configurationEndpoint)
[DataRow(CONFIGURATION_ENDPOINT_V2)]
public async Task TestSettingConfigurationCreatesCorrectClasses(string configurationEndpoint)
{
- TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
+ using TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()));
HttpClient client = server.CreateClient();
JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint);
@@ -1472,7 +1493,7 @@ public void TestCommandLineConfigurationProvider()
$"{COSMOS_ENVIRONMENT}{CONFIG_EXTENSION}"
};
- TestServer server = new(Program.CreateWebHostBuilder(args));
+ using TestServer server = new(Program.CreateWebHostBuilder(args));
ValidateCosmosDbSetup(server);
}
@@ -1489,7 +1510,7 @@ public void TestRuntimeEnvironmentVariable()
Environment.SetEnvironmentVariable(
RUNTIME_ENVIRONMENT_VAR_NAME, COSMOS_ENVIRONMENT);
- TestServer server = new(Program.CreateWebHostBuilder(Array.Empty()));
+ using TestServer server = new(Program.CreateWebHostBuilder(Array.Empty()));
ValidateCosmosDbSetup(server);
}
@@ -2230,7 +2251,7 @@ public void TestConnectionStringEnvVarHasHighestPrecedence()
try
{
- TestServer server = new(Program.CreateWebHostBuilder(Array.Empty()));
+ using TestServer server = new(Program.CreateWebHostBuilder(Array.Empty()));
_ = server.Services.GetService(typeof(CosmosClientProvider)) as CosmosClientProvider;
Assert.Fail($"{RUNTIME_ENV_CONNECTION_STRING} is not given highest precedence");
}
@@ -4344,7 +4365,7 @@ private static void ValidateLogLevelFilters(LogLevel logLevel, string loggingFil
// Start a new server with the custom log level to ensure the
// instantiation of the valid log level filters works as expected.
- TestServer server = new(Program.CreateWebHostBuilder(args));
+ using TestServer server = new(Program.CreateWebHostBuilder(args));
RuntimeConfigProvider runtimeConfigProvider = server.Services.GetService();
// RuntimeConfig with instantiated log level filters.
diff --git a/src/Service.Tests/Configuration/HotReload/ConfigurationHotReloadTests.cs b/src/Service.Tests/Configuration/HotReload/ConfigurationHotReloadTests.cs
index b1770f504d..d85c3ddf01 100644
--- a/src/Service.Tests/Configuration/HotReload/ConfigurationHotReloadTests.cs
+++ b/src/Service.Tests/Configuration/HotReload/ConfigurationHotReloadTests.cs
@@ -355,19 +355,20 @@ await WaitForConditionAsync(
HttpResponseMessage badPathRestResult = await _testClient.GetAsync($"rest/Book");
HttpResponseMessage badPathGQLResult = await _testClient.SendAsync(request);
- HttpResponseMessage result = await _testClient.GetAsync($"{restPath}/Book");
+ // After hot-reload, the engine may still be re-initializing metadata providers.
+ // Poll the REST endpoint to allow time for the engine to become fully ready.
+ using HttpResponseMessage result = await WaitForRestEndpointAsync($"{restPath}/Book", HttpStatusCode.OK);
string reloadRestContent = await result.Content.ReadAsStringAsync();
- JsonElement reloadGQLContents = await GraphQLRequestExecutor.PostGraphQLRequestAsync(
- _testClient,
- _configProvider,
- GQL_QUERY_NAME,
- GQL_QUERY);
+
+ // Poll the GraphQL endpoint to allow time for the engine to become fully ready.
+ (bool querySucceeded, JsonElement reloadGQLContents) = await WaitForGraphQLEndpointAsync(GQL_QUERY_NAME, GQL_QUERY);
// Assert
// Old paths are not found.
Assert.AreEqual(HttpStatusCode.BadRequest, badPathRestResult.StatusCode);
Assert.AreEqual(HttpStatusCode.NotFound, badPathGQLResult.StatusCode);
// Hot reloaded paths return correct response.
+ Assert.IsTrue(querySucceeded, "GraphQL query did not return valid results after hot-reload.");
Assert.IsTrue(SqlTestHelper.JsonStringsDeepEqual(restBookContents, reloadRestContent));
SqlTestHelper.PerformTestEqualJsonStrings(_bookDBOContents, reloadGQLContents.GetProperty("items").ToString());
}
@@ -690,13 +691,12 @@ await WaitForConditionAsync(
RuntimeConfig updatedRuntimeConfig = _configProvider.GetConfig();
MsSqlOptions actualSessionContext = updatedRuntimeConfig.DataSource.GetTypedOptions();
- JsonElement reloadGQLContents = await GraphQLRequestExecutor.PostGraphQLRequestAsync(
- _testClient,
- _configProvider,
- GQL_QUERY_NAME,
- GQL_QUERY);
+
+ // Poll the GraphQL endpoint to allow time for the engine to become fully ready.
+ (bool querySucceeded, JsonElement reloadGQLContents) = await WaitForGraphQLEndpointAsync(GQL_QUERY_NAME, GQL_QUERY, maxRetries: 10);
// Assert
+ Assert.IsTrue(querySucceeded, "GraphQL query did not return valid results after hot-reload. Metadata initialization may not have completed.");
Assert.AreNotEqual(previousSessionContext, actualSessionContext);
Assert.AreEqual(false, actualSessionContext.SetSessionContext);
SqlTestHelper.PerformTestEqualJsonStrings(_bookDBOContents, reloadGQLContents.GetProperty("items").ToString());
@@ -784,7 +784,9 @@ await WaitForConditionAsync(
succeedConfigLog = _writer.ToString();
}
- HttpResponseMessage restResult = await _testClient.GetAsync("/rest/Book");
+ // After hot-reload, the engine may still be re-initializing metadata providers.
+ // Poll the REST endpoint to allow time for the engine to become fully ready.
+ using HttpResponseMessage restResult = await WaitForRestEndpointAsync("/rest/Book", HttpStatusCode.OK);
// Assert
Assert.IsTrue(failedConfigLog.Contains(HOT_RELOAD_FAILURE_MESSAGE));
@@ -815,8 +817,12 @@ await WaitForConditionAsync(
TimeSpan.FromSeconds(HOT_RELOAD_TIMEOUT_SECONDS),
TimeSpan.FromMilliseconds(500));
+ // After hot-reload, the engine may still be re-initializing metadata providers.
+ // Poll the REST endpoint to allow time for the engine to become fully ready.
+ using HttpResponseMessage hotReloadRestResult = await WaitForRestEndpointAsync("rest/HotReload_books", HttpStatusCode.OK);
+
+ // Once the engine is fully ready, verify the old autoentity name is no longer recognized.
HttpResponseMessage failRestResult = await _testClient.GetAsync($"rest/autoentity_books");
- HttpResponseMessage hotReloadRestResult = await _testClient.GetAsync($"rest/HotReload_books");
// Assert
Assert.AreEqual(HttpStatusCode.OK, restResult.StatusCode,
@@ -877,7 +883,9 @@ await WaitForConditionAsync(
succeedConfigLog = _writer.ToString();
}
- HttpResponseMessage restResult = await _testClient.GetAsync("/rest/Book");
+ // After hot-reload, the engine may still be re-initializing metadata providers.
+ // Poll the REST endpoint to allow time for the engine to become fully ready.
+ using HttpResponseMessage restResult = await WaitForRestEndpointAsync("/rest/Book", HttpStatusCode.OK);
// Assert
Assert.IsTrue(failedConfigLog.Contains(HOT_RELOAD_FAILURE_MESSAGE));
@@ -1013,4 +1021,84 @@ private static async Task WaitForConditionAsync(Func condition, TimeSpan t
throw new TimeoutException("The condition was not met within the timeout period.");
}
+
+ ///
+ /// Polls a REST endpoint until it returns the expected status code.
+ /// After a successful hot-reload, the engine may still be re-initializing
+ /// metadata providers, so an immediate request can intermittently fail.
+ ///
+ private static async Task WaitForRestEndpointAsync(
+ string requestUri,
+ HttpStatusCode expectedStatus,
+ int maxRetries = 5,
+ int delayMilliseconds = 1000)
+ {
+ HttpResponseMessage response = null;
+ for (int attempt = 1; attempt <= maxRetries; attempt++)
+ {
+ response = await _testClient.GetAsync(requestUri);
+ if (response.StatusCode == expectedStatus)
+ {
+ return response;
+ }
+
+ Console.WriteLine($"REST {requestUri} returned {response.StatusCode} on attempt {attempt}/{maxRetries}, retrying...");
+
+ // Dispose unsuccessful responses to avoid leaking connections/sockets.
+ if (attempt < maxRetries)
+ {
+ response.Dispose();
+ }
+
+ await Task.Delay(delayMilliseconds);
+ }
+
+ // Return the last response (undisposed) so the caller can inspect/assert on it.
+ return response;
+ }
+
+ ///
+ /// Polls a GraphQL endpoint until it returns a valid response containing
+ /// the expected property. After a successful hot-reload, the engine may
+ /// still be re-initializing metadata providers, so an immediate request
+ /// can intermittently fail. PostGraphQLRequestAsync can also throw
+ /// (e.g. JsonException) if the server returns a non-JSON error response
+ /// during re-initialization.
+ ///
+ private static async Task<(bool Success, JsonElement Result)> WaitForGraphQLEndpointAsync(
+ string queryName,
+ string query,
+ string expectedProperty = "items",
+ int maxRetries = 5,
+ int delayMilliseconds = 1000)
+ {
+ JsonElement result = default;
+ for (int attempt = 1; attempt <= maxRetries; attempt++)
+ {
+ try
+ {
+ result = await GraphQLRequestExecutor.PostGraphQLRequestAsync(
+ _testClient,
+ _configProvider,
+ queryName,
+ query);
+
+ if (result.ValueKind == JsonValueKind.Object &&
+ result.TryGetProperty(expectedProperty, out _))
+ {
+ return (true, result);
+ }
+
+ Console.WriteLine($"GraphQL query returned {result.ValueKind} on attempt {attempt}/{maxRetries}, retrying...");
+ }
+ catch (Exception ex) when (ex is JsonException || ex is HttpRequestException)
+ {
+ Console.WriteLine($"GraphQL request threw {ex.GetType().Name} on attempt {attempt}/{maxRetries}: {ex.Message}");
+ }
+
+ await Task.Delay(delayMilliseconds);
+ }
+
+ return (false, result);
+ }
}
diff --git a/src/Service.Tests/UnitTests/EntitySourceNamesParserUnitTests.cs b/src/Service.Tests/UnitTests/EntitySourceNamesParserUnitTests.cs
index 3acf94b946..3e04e4353b 100644
--- a/src/Service.Tests/UnitTests/EntitySourceNamesParserUnitTests.cs
+++ b/src/Service.Tests/UnitTests/EntitySourceNamesParserUnitTests.cs
@@ -13,7 +13,7 @@ namespace Azure.DataApiBuilder.Service.Tests.UnitTests
/// can handle a wide range of valid formats correctly,
/// and throws exceptions for invalid formats as expected.
///
- [TestClass, TestCategory(TestCategory.MSSQL)]
+ [TestClass]
public class EntitySourceNamesParserUnitTests
{
diff --git a/src/Service.Tests/UnitTests/RequestContextUnitTests.cs b/src/Service.Tests/UnitTests/RequestContextUnitTests.cs
index afbd812628..d6ab84d838 100644
--- a/src/Service.Tests/UnitTests/RequestContextUnitTests.cs
+++ b/src/Service.Tests/UnitTests/RequestContextUnitTests.cs
@@ -16,7 +16,7 @@ namespace Azure.DataApiBuilder.Service.Tests.UnitTests
/// Context classes that are not easily tested through
/// integration testing.
///
- [TestClass, TestCategory(TestCategory.MSSQL)]
+ [TestClass]
public class RequestContextUnitTests
{
private static DatabaseObject _defaultDbObject = new DatabaseTable()
diff --git a/src/Service.Tests/UnitTests/RequestValidatorUnitTests.cs b/src/Service.Tests/UnitTests/RequestValidatorUnitTests.cs
index 5be1375c0f..35c86abc82 100644
--- a/src/Service.Tests/UnitTests/RequestValidatorUnitTests.cs
+++ b/src/Service.Tests/UnitTests/RequestValidatorUnitTests.cs
@@ -25,7 +25,7 @@ namespace Azure.DataApiBuilder.Service.Tests.UnitTests
/// Unit tests for RequestValidator.cs. Makes sure the proper primary key validation
/// occurs for REST requests for FindOne().
///
- [TestClass, TestCategory(TestCategory.MSSQL)]
+ [TestClass]
public class RequestValidatorUnitTests
{
private static Mock _mockMetadataStore;
diff --git a/src/Service.Tests/UnitTests/RestServiceUnitTests.cs b/src/Service.Tests/UnitTests/RestServiceUnitTests.cs
index 3f296b4403..ac7b3667e6 100644
--- a/src/Service.Tests/UnitTests/RestServiceUnitTests.cs
+++ b/src/Service.Tests/UnitTests/RestServiceUnitTests.cs
@@ -25,7 +25,7 @@
namespace Azure.DataApiBuilder.Service.Tests.UnitTests
{
- [TestClass, TestCategory(TestCategory.MSSQL)]
+ [TestClass]
public class RestServiceUnitTests
{
private static RestService _restService;
diff --git a/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs b/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs
index c34290999a..347bf5e328 100644
--- a/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs
+++ b/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs
@@ -27,7 +27,7 @@ namespace Azure.DataApiBuilder.Service.Tests.UnitTests
/// we throw the right exception when environment
/// variable names are not found.
///
- [TestClass, TestCategory(TestCategory.MSSQL)]
+ [TestClass]
public class RuntimeConfigLoaderJsonDeserializerTests
{
#region Positive Tests
diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs
index a1553d0a71..618feef454 100644
--- a/src/Service/Startup.cs
+++ b/src/Service/Startup.cs
@@ -119,7 +119,7 @@ public void ConfigureServices(IServiceCollection services)
_configProvider = configProvider;
services.AddSingleton(fileSystem);
- services.AddSingleton(configLoader);
+ services.AddSingleton(sp => configLoader);
services.AddSingleton(configProvider);
bool runtimeConfigAvailable = configProvider.TryGetConfig(out RuntimeConfig? runtimeConfig);