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);