diff --git a/.github/workflows/E2ETest.yml b/.github/workflows/E2ETest.yml index 371790fca..1d0ec3d08 100644 --- a/.github/workflows/E2ETest.yml +++ b/.github/workflows/E2ETest.yml @@ -22,13 +22,21 @@ on: jobs: e2e-azurestorage-linux: runs-on: ubuntu-latest - name: e2e-azurestorage-linux (${{ matrix.targetFramework }}) + name: e2e-azurestorage-linux (${{ matrix.targetFramework }}, ${{ matrix.language.name }}) strategy: fail-fast: false matrix: targetFramework: [net8.0, net10.0] + language: + - { name: dotnet-isolated, app: BasicDotNetIsolated, filter: 'AzureStorage!=Skip&Dotnet!=Skip&Dotnet-AzureStorage!=Skip' } + - { name: powershell, app: BasicPowerShell, filter: 'AzureStorage!=Skip&PowerShell!=Skip&PowerShell-AzureStorage!=Skip' } + - { name: python, app: BasicPython, filter: 'AzureStorage!=Skip&Python!=Skip&Python-AzureStorage!=Skip' } + - { name: node, app: BasicNode, filter: 'AzureStorage!=Skip&Node!=Skip&Node-AzureStorage!=Skip' } + - { name: java, app: BasicJava, filter: 'AzureStorage!=Skip&Java!=Skip&Java-AzureStorage!=Skip' } env: E2E_TEST_DURABLE_BACKEND: 'AzureStorage' + E2E_TEST_FUNCTIONS_LANGUAGE: ${{ matrix.language.name }} + TEST_APP_NAME: ${{ matrix.language.app }} steps: - uses: actions/checkout@v4 @@ -48,11 +56,13 @@ jobs: node-version: '20.x' - name: Set up Python + if: matrix.language.name == 'python' uses: actions/setup-python@v2 with: python-version: 3.13 - name: Set up JDK 17 + if: matrix.language.name == 'java' uses: actions/setup-java@v4 with: java-version: '17' @@ -62,71 +72,33 @@ jobs: - name: Setup E2E tests shell: pwsh run: | - .\test\e2e\Tests\build-e2e-test.ps1 -TargetFramework ${{ matrix.targetFramework }} + .\test\e2e\Tests\build-e2e-test.ps1 -E2EAppName ${{ matrix.language.app }} -TargetFramework ${{ matrix.targetFramework }} - name: Build Test Project working-directory: test/e2e/Tests run: dotnet build -f ${{ matrix.targetFramework }} - - name: Setup Environment for dotnet - shell: pwsh - run: | - Add-Content -Path $env:GITHUB_ENV -Value "E2E_TEST_FUNCTIONS_LANGUAGE=dotnet-isolated" - Add-Content -Path $env:GITHUB_ENV -Value "TEST_APP_NAME=BasicDotNetIsolated" - - - name: Run E2E tests (dotnet-isolated) + - name: Run E2E tests (${{ matrix.language.name }}) working-directory: test/e2e/Tests - run: dotnet test -f ${{ matrix.targetFramework }} --filter 'AzureStorage!=Skip&Dotnet!=Skip&Dotnet-AzureStorage!=Skip' - - - name: Setup Environment for PowerShell - shell: pwsh - run: | - Add-Content -Path $env:GITHUB_ENV -Value "E2E_TEST_FUNCTIONS_LANGUAGE=powershell" - Add-Content -Path $env:GITHUB_ENV -Value "TEST_APP_NAME=BasicPowerShell" - - - name: Run E2E tests (PowerShell) - working-directory: test/e2e/Tests - run: dotnet test -f ${{ matrix.targetFramework }} --filter 'AzureStorage!=Skip&PowerShell!=Skip&PowerShell-AzureStorage!=Skip' - - - name: Setup Environment for Python - shell: pwsh - run: | - Add-Content -Path $env:GITHUB_ENV -Value "E2E_TEST_FUNCTIONS_LANGUAGE=python" - Add-Content -Path $env:GITHUB_ENV -Value "TEST_APP_NAME=BasicPython" - - - name: Run E2E tests (Python) - working-directory: test/e2e/Tests - run: dotnet test -f ${{ matrix.targetFramework }} --filter 'AzureStorage!=Skip&Python!=Skip&Python-AzureStorage!=Skip' - - - name: Setup Environment for Node - shell: pwsh - run: | - Add-Content -Path $env:GITHUB_ENV -Value "E2E_TEST_FUNCTIONS_LANGUAGE=node" - Add-Content -Path $env:GITHUB_ENV -Value "TEST_APP_NAME=BasicNode" - - - name: Run E2E tests (Node) - working-directory: test/e2e/Tests - run: dotnet test -f ${{ matrix.targetFramework }} --filter 'AzureStorage!=Skip&Node!=Skip&Node-AzureStorage!=Skip' - - - name: Setup Environment for Java - shell: pwsh - run: | - Add-Content -Path $env:GITHUB_ENV -Value "E2E_TEST_FUNCTIONS_LANGUAGE=java" - Add-Content -Path $env:GITHUB_ENV -Value "TEST_APP_NAME=BasicJava" - - - name: Run E2E tests (Java) - working-directory: test/e2e/Tests - run: dotnet test -f ${{ matrix.targetFramework }} --filter 'AzureStorage!=Skip&Java!=Skip&Java-AzureStorage!=Skip' + run: dotnet test -f ${{ matrix.targetFramework }} --filter '${{ matrix.language.filter }}' e2e-azurestorage-windows: runs-on: windows-latest - name: e2e-azurestorage-windows (${{ matrix.targetFramework }}) + name: e2e-azurestorage-windows (${{ matrix.targetFramework }}, ${{ matrix.language.name }}) strategy: fail-fast: false matrix: targetFramework: [net8.0, net10.0] + language: + - { name: dotnet-isolated, app: BasicDotNetIsolated, filter: 'AzureStorage!=Skip&Dotnet!=Skip&Dotnet-AzureStorage!=Skip' } + - { name: powershell, app: BasicPowerShell, filter: 'AzureStorage!=Skip&PowerShell!=Skip&PowerShell-AzureStorage!=Skip' } + - { name: python, app: BasicPython, filter: 'AzureStorage!=Skip&Python!=Skip&Python-AzureStorage!=Skip' } + - { name: node, app: BasicNode, filter: 'AzureStorage!=Skip&Node!=Skip&Node-AzureStorage!=Skip' } + - { name: java, app: BasicJava, filter: 'AzureStorage!=Skip&Java!=Skip&Java-AzureStorage!=Skip' } env: E2E_TEST_DURABLE_BACKEND: 'AzureStorage' + E2E_TEST_FUNCTIONS_LANGUAGE: ${{ matrix.language.name }} + TEST_APP_NAME: ${{ matrix.language.app }} steps: - uses: actions/checkout@v4 @@ -146,11 +118,13 @@ jobs: node-version: '20.x' - name: Set up Python + if: matrix.language.name == 'python' uses: actions/setup-python@v2 with: python-version: 3.13 - name: Set up JDK 17 + if: matrix.language.name == 'java' uses: actions/setup-java@v4 with: java-version: '17' @@ -160,61 +134,15 @@ jobs: - name: Setup E2E tests shell: pwsh run: | - .\test\e2e\Tests\build-e2e-test.ps1 -TargetFramework ${{ matrix.targetFramework }} + .\test\e2e\Tests\build-e2e-test.ps1 -E2EAppName ${{ matrix.language.app }} -TargetFramework ${{ matrix.targetFramework }} - name: Build Test Project working-directory: test/e2e/Tests run: dotnet build -f ${{ matrix.targetFramework }} - - name: Setup Environment for dotnet - shell: pwsh - run: | - Add-Content -Path $env:GITHUB_ENV -Value "E2E_TEST_FUNCTIONS_LANGUAGE=dotnet-isolated" - Add-Content -Path $env:GITHUB_ENV -Value "TEST_APP_NAME=BasicDotNetIsolated" - - - name: Run E2E tests (dotnet-isolated) - working-directory: test/e2e/Tests - run: dotnet test -f ${{ matrix.targetFramework }} --filter 'AzureStorage!=Skip&Dotnet!=Skip&Dotnet-AzureStorage!=Skip' - - - name: Setup Environment for PowerShell - shell: pwsh - run: | - Add-Content -Path $env:GITHUB_ENV -Value "E2E_TEST_FUNCTIONS_LANGUAGE=powershell" - Add-Content -Path $env:GITHUB_ENV -Value "TEST_APP_NAME=BasicPowerShell" - - - name: Run E2E tests (PowerShell) + - name: Run E2E tests (${{ matrix.language.name }}) working-directory: test/e2e/Tests - run: dotnet test -f ${{ matrix.targetFramework }} --filter 'AzureStorage!=Skip&PowerShell!=Skip&PowerShell-AzureStorage!=Skip' - - - name: Setup Environment for Python - shell: pwsh - run: | - Add-Content -Path $env:GITHUB_ENV -Value "E2E_TEST_FUNCTIONS_LANGUAGE=python" - Add-Content -Path $env:GITHUB_ENV -Value "TEST_APP_NAME=BasicPython" - - - name: Run E2E tests (Python) - working-directory: test/e2e/Tests - run: dotnet test -f ${{ matrix.targetFramework }} --filter 'AzureStorage!=Skip&Python!=Skip&Python-AzureStorage!=Skip' - - - name: Setup Environment for Node - shell: pwsh - run: | - Add-Content -Path $env:GITHUB_ENV -Value "E2E_TEST_FUNCTIONS_LANGUAGE=node" - Add-Content -Path $env:GITHUB_ENV -Value "TEST_APP_NAME=BasicNode" - - - name: Run E2E tests (Node) - working-directory: test/e2e/Tests - run: dotnet test -f ${{ matrix.targetFramework }} --filter 'AzureStorage!=Skip&Node!=Skip&Node-AzureStorage!=Skip' - - - name: Setup Environment for Java - shell: pwsh - run: | - Add-Content -Path $env:GITHUB_ENV -Value "E2E_TEST_FUNCTIONS_LANGUAGE=java" - Add-Content -Path $env:GITHUB_ENV -Value "TEST_APP_NAME=BasicJava" - - - name: Run E2E tests (Java) - working-directory: test/e2e/Tests - run: dotnet test -f ${{ matrix.targetFramework }} --filter 'AzureStorage!=Skip&Java!=Skip&Java-AzureStorage!=Skip' + run: dotnet test -f ${{ matrix.targetFramework }} --filter '${{ matrix.language.filter }}' e2e-mssql: runs-on: ubuntu-latest @@ -320,13 +248,21 @@ jobs: e2e-dts: runs-on: ubuntu-latest - name: e2e-dts (${{ matrix.targetFramework }}) + name: e2e-dts (${{ matrix.targetFramework }}, ${{ matrix.language.name }}) strategy: fail-fast: false matrix: targetFramework: [net8.0, net10.0] + language: + - { name: dotnet-isolated, app: BasicDotNetIsolated, filter: 'DTS!=Skip&Dotnet!=Skip&Dotnet-DTS!=Skip' } + - { name: powershell, app: BasicPowerShell, filter: 'DTS!=Skip&PowerShell!=Skip&PowerShell-DTS!=Skip' } + - { name: python, app: BasicPython, filter: 'DTS!=Skip&Python!=Skip&Python-DTS!=Skip' } + - { name: node, app: BasicNode, filter: 'DTS!=Skip&Node!=Skip&Node-DTS!=Skip' } + - { name: java, app: BasicJava, filter: 'DTS!=Skip&Java!=Skip&Java-DTS!=Skip' } env: E2E_TEST_DURABLE_BACKEND: "azureManaged" + E2E_TEST_FUNCTIONS_LANGUAGE: ${{ matrix.language.name }} + TEST_APP_NAME: ${{ matrix.language.app }} steps: - uses: actions/checkout@v4 @@ -346,11 +282,13 @@ jobs: node-version: '20.x' - name: Set up Python + if: matrix.language.name == 'python' uses: actions/setup-python@v2 with: python-version: 3.13 - name: Set up JDK 17 + if: matrix.language.name == 'java' uses: actions/setup-java@v4 with: java-version: '17' @@ -360,58 +298,12 @@ jobs: - name: Setup E2E tests shell: pwsh run: | - .\test\e2e\Tests\build-e2e-test.ps1 -StartDTSContainer -TargetFramework ${{ matrix.targetFramework }} + .\test\e2e\Tests\build-e2e-test.ps1 -StartDTSContainer -E2EAppName ${{ matrix.language.app }} -TargetFramework ${{ matrix.targetFramework }} - name: Build Test Project working-directory: test/e2e/Tests run: dotnet build -f ${{ matrix.targetFramework }} - - name: Setup Environment for dotnet - shell: pwsh - run: | - Add-Content -Path $env:GITHUB_ENV -Value "E2E_TEST_FUNCTIONS_LANGUAGE=dotnet-isolated" - Add-Content -Path $env:GITHUB_ENV -Value "TEST_APP_NAME=BasicDotNetIsolated" - - - name: Run E2E tests (dotnet-isolated) - working-directory: test/e2e/Tests - run: dotnet test -f ${{ matrix.targetFramework }} --filter 'DTS!=Skip&Dotnet!=Skip&Dotnet-DTS!=Skip' - - - name: Setup Environment for PowerShell - shell: pwsh - run: | - Add-Content -Path $env:GITHUB_ENV -Value "E2E_TEST_FUNCTIONS_LANGUAGE=powershell" - Add-Content -Path $env:GITHUB_ENV -Value "TEST_APP_NAME=BasicPowerShell" - - - name: Run E2E tests (PowerShell) - working-directory: test/e2e/Tests - run: dotnet test -f ${{ matrix.targetFramework }} --filter 'DTS!=Skip&PowerShell!=Skip&PowerShell-DTS!=Skip' - - - name: Setup Environment for Python - shell: pwsh - run: | - Add-Content -Path $env:GITHUB_ENV -Value "E2E_TEST_FUNCTIONS_LANGUAGE=python" - Add-Content -Path $env:GITHUB_ENV -Value "TEST_APP_NAME=BasicPython" - - - name: Run E2E tests (Python) - working-directory: test/e2e/Tests - run: dotnet test -f ${{ matrix.targetFramework }} --filter 'DTS!=Skip&Python!=Skip&Python-DTS!=Skip' - - - name: Setup Environment for Node - shell: pwsh - run: | - Add-Content -Path $env:GITHUB_ENV -Value "E2E_TEST_FUNCTIONS_LANGUAGE=node" - Add-Content -Path $env:GITHUB_ENV -Value "TEST_APP_NAME=BasicNode" - - - name: Run E2E tests (Node) - working-directory: test/e2e/Tests - run: dotnet test -f ${{ matrix.targetFramework }} --filter 'DTS!=Skip&Node!=Skip&Node-DTS!=Skip' - - - name: Setup Environment for Java - shell: pwsh - run: | - Add-Content -Path $env:GITHUB_ENV -Value "E2E_TEST_FUNCTIONS_LANGUAGE=java" - Add-Content -Path $env:GITHUB_ENV -Value "TEST_APP_NAME=BasicJava" - - - name: Run E2E tests (Java) + - name: Run E2E tests (${{ matrix.language.name }}) working-directory: test/e2e/Tests - run: dotnet test -f ${{ matrix.targetFramework }} --filter 'DTS!=Skip&Java!=Skip&Java-DTS!=Skip' \ No newline at end of file + run: dotnet test -f ${{ matrix.targetFramework }} --filter '${{ matrix.language.filter }}' \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7f0354d38..0f94ff776 100644 --- a/.gitignore +++ b/.gitignore @@ -305,4 +305,6 @@ functions-extensions/ **/*.runsettings # Java build artifacts -**/.gradle/** \ No newline at end of file +**/.gradle/**__blobstorage__/ +__azurite_db_* +AzuriteConfig/ diff --git a/test/e2e/Tests/Tests/DedupeStatusesTests.cs b/test/e2e/Tests/Tests/DedupeStatusesTests.cs index 42400b9ba..b74616fcd 100644 --- a/test/e2e/Tests/Tests/DedupeStatusesTests.cs +++ b/test/e2e/Tests/Tests/DedupeStatusesTests.cs @@ -22,75 +22,101 @@ public DedupeStatusesTests(FunctionAppFixture fixture, ITestOutputHelper testOut [Fact] public async Task CanStartOrchestration_WithSameId_ForAllStatuses_ForEmptyDedupeStatuses() { - HttpResponseMessage terminateResponse; - + bool testTerminated = this.fixture.functionLanguageLocalizer.GetLanguageType() != LanguageType.Java + || this.fixture.GetDurabilityProvider() != FunctionAppFixture.ConfiguredDurabilityProviderType.MSSQL; + bool testPending = this.fixture.functionLanguageLocalizer.GetLanguageType() == LanguageType.DotnetIsolated + || this.fixture.functionLanguageLocalizer.GetLanguageType() == LanguageType.Java; + + // HttpLongRunningOrchestrator (timer-based, no activity spam) is only available in dotnet-isolated. + // For other languages, LongRunningOrchestrator is used for this test scenario. + string longRunningOrch = this.fixture.functionLanguageLocalizer.GetLanguageType() == LanguageType.DotnetIsolated + ? "HttpLongRunningOrchestrator" + : "LongRunningOrchestrator"; + + string completedId = Guid.NewGuid().ToString(); + string failedId = Guid.NewGuid().ToString(); + string terminatedId = Guid.NewGuid().ToString(); + string runningId = Guid.NewGuid().ToString(); + string suspendedId = Guid.NewGuid().ToString(); + string pendingId = Guid.NewGuid().ToString(); + + // Phase 1: Start all first-attempt orchestrations and wait for initial states concurrently // Completed - string completedInstanceId = Guid.NewGuid().ToString(); - using HttpResponseMessage startCompletedResponseFirstAttempt = await StartAndWaitForState( - "HelloCities", completedInstanceId, "Completed"); - using HttpResponseMessage startCompletedResponseSecondAttempt = await StartAndWaitForState( - "HelloCities", completedInstanceId, "Completed"); - - // Failed - // This invocation will fail because the "LargeOutputOrchestrator" expects a non-zero input, but we provide none - string failedInstanceId = Guid.NewGuid().ToString(); - using HttpResponseMessage startFailedResponseFirstAttempt = await StartAndWaitForState( - "LargeOutputOrchestrator", failedInstanceId, "Failed"); - using HttpResponseMessage startFailedResponseSecondAttempt = await StartAndWaitForState( - "LargeOutputOrchestrator", failedInstanceId, "Failed"); - - // Terminated - if (this.fixture.functionLanguageLocalizer.GetLanguageType() != LanguageType.Java - || this.fixture.GetDurabilityProvider() != FunctionAppFixture.ConfiguredDurabilityProviderType.MSSQL) // Bug: https://github.com/microsoft/durabletask-java/issues/237 + var completedFirst = StartAndWaitForState("HelloCities", completedId, "Completed"); + // Failed — "LargeOutputOrchestrator" expects a non-zero input, but we provide none + var failedFirst = StartAndWaitForState("LargeOutputOrchestrator", failedId, "Failed"); + // Running + var runningFirst = StartAndWaitForState(longRunningOrch, runningId, "Running"); + // Suspended (starts as Running, transitioned in Phase 2) + var suspendedFirst = StartAndWaitForState(longRunningOrch, suspendedId, "Running"); + // Terminated (starts as Running, transitioned in Phase 2) + var terminatedFirst = testTerminated + ? StartAndWaitForState(longRunningOrch, terminatedId, "Running") + : Task.FromResult(null!); + // Pending — scheduled start times are currently only implemented in Java and .NET isolated, + // which is the only true way to get an orchestration in a "Pending" state + var pendingFirst = testPending + ? StartAndWaitForState("HelloCities", pendingId, "Pending", scheduledStartTime: DateTime.UtcNow.AddMinutes(10)) + : Task.FromResult(null!); + await Task.WhenAll(completedFirst, failedFirst, runningFirst, suspendedFirst, terminatedFirst, pendingFirst); + + // Phase 2: Apply state transitions concurrently + var transitions = new List(); + if (testTerminated) + transitions.Add(TerminateAndWaitForState(terminatedId, await terminatedFirst)); + transitions.Add(SuspendAndWaitForState(suspendedId, await suspendedFirst)); + await Task.WhenAll(transitions); + + // Dispose Phase 1 responses (no longer needed after extracting statusQueryGetUri) + (await completedFirst)?.Dispose(); + (await failedFirst)?.Dispose(); + (await runningFirst)?.Dispose(); + (await suspendedFirst)?.Dispose(); + (await terminatedFirst)?.Dispose(); + (await pendingFirst)?.Dispose(); + + // Phase 3: Start all second-attempt orchestrations concurrently (verify restart works) + var phase3Tasks = new List> { - string terminatedInstanceId = Guid.NewGuid().ToString(); - using HttpResponseMessage startTerminatedResponseFirstAttempt = await StartAndWaitForState( - "LongRunningOrchestrator", terminatedInstanceId, "Running"); - await TerminateAndWaitForState(terminatedInstanceId, startTerminatedResponseFirstAttempt); - using HttpResponseMessage startTerminatedResponseSecondAttempt = await StartAndWaitForState( - "LongRunningOrchestrator", terminatedInstanceId, "Running"); - // Clean-up - terminateResponse = await HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={terminatedInstanceId}"); - Assert.Equal(HttpStatusCode.OK, terminateResponse.StatusCode); - terminateResponse.Dispose(); - } - + // Completed + StartAndWaitForState("HelloCities", completedId, "Completed"), + // Failed — "LargeOutputOrchestrator" expects a non-zero input, but we provide none + StartAndWaitForState("LargeOutputOrchestrator", failedId, "Failed"), + // Running + StartAndWaitForState(longRunningOrch, runningId, "Running"), + // Suspended + StartAndWaitForState(longRunningOrch, suspendedId, "Running"), + }; + // Terminated + if (testTerminated) + phase3Tasks.Add(StartAndWaitForState(longRunningOrch, terminatedId, "Running")); // Pending - // Scheduled start times are currently only implemented in Java and .NET isolated, which is the only true way - // to get an orchestration in a "Pending" state - if (this.fixture.functionLanguageLocalizer.GetLanguageType() == LanguageType.DotnetIsolated - || this.fixture.functionLanguageLocalizer.GetLanguageType() == LanguageType.Java) + if (testPending) + phase3Tasks.Add(StartAndWaitForState("HelloCities", pendingId, "Completed")); + foreach (var r in await Task.WhenAll(phase3Tasks)) + r?.Dispose(); + + // Phase 4: Clean up non-terminal orchestrations concurrently + var cleanups = new List> + { + HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={runningId}"), + HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={suspendedId}"), + }; + if (testTerminated) + cleanups.Add(HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={terminatedId}")); + if (testPending) + cleanups.Add(HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={pendingId}")); + foreach (var r in await Task.WhenAll(cleanups)) { - string pendingInstanceId = Guid.NewGuid().ToString(); - DateTime scheduledStartTime = DateTime.UtcNow.AddMinutes(10); - using HttpResponseMessage startPendingResponseFirstAttempt = await StartAndWaitForState( - "HelloCities", pendingInstanceId, "Pending", scheduledStartTime: scheduledStartTime); - using HttpResponseMessage startPendingResponseSecondAttempt = await StartAndWaitForState( - "HelloCities", pendingInstanceId, "Completed"); + using (r) + { + if (r.StatusCode != HttpStatusCode.OK) + { + this.output.WriteLine( + $"TerminateInstance cleanup returned status code {r.StatusCode} for request {r.RequestMessage?.RequestUri}"); + } + } } - - // Running - string runningInstanceId = Guid.NewGuid().ToString(); - using HttpResponseMessage startRunningResponseFirstAttempt = await StartAndWaitForState( - "LongRunningOrchestrator", runningInstanceId, "Running"); - using HttpResponseMessage startRunningResponseSecondAttempt = await StartAndWaitForState( - "LongRunningOrchestrator", runningInstanceId, "Running"); - // Clean-up - terminateResponse = await HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={runningInstanceId}"); - Assert.Equal(HttpStatusCode.OK, terminateResponse.StatusCode); - terminateResponse.Dispose(); - - // Suspended - string suspendedInstanceId = Guid.NewGuid().ToString(); - using HttpResponseMessage startSuspendedResponseFirstAttempt = await StartAndWaitForState( - "LongRunningOrchestrator", suspendedInstanceId, "Running"); - await SuspendAndWaitForState(suspendedInstanceId, startSuspendedResponseFirstAttempt); - using HttpResponseMessage startSuspendedResponseSecondAttempt = await StartAndWaitForState( - "LongRunningOrchestrator", suspendedInstanceId, "Running"); - // Clean-up - terminateResponse = await HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={suspendedInstanceId}"); - Assert.Equal(HttpStatusCode.OK, terminateResponse.StatusCode); - terminateResponse.Dispose(); } [Theory] @@ -102,92 +128,84 @@ public async Task CanStartOrchestration_WithSameId_ForAllStatuses_ForEmptyDedupe [InlineData("Pending", "Failed")] public async Task StartOrchestration_WithSameId_FailsIfExistingStatus_InDedupeStatuses(params string[] dedupeStatuses) { - HttpResponseMessage terminateResponse; + // This test is dotnet-isolated only (Java/PowerShell/Python/Node are skipped via traits) + string longRunningOrch = "HttpLongRunningOrchestrator"; - // Completed - string completedInstanceId = Guid.NewGuid().ToString(); - using HttpResponseMessage startCompletedResponseFirstAttempt = await StartAndWaitForStateWithDedupeStatuses( - "HelloCities", completedInstanceId, "Completed", dedupeStatuses); - using HttpResponseMessage startCompletedResponseSecondAttempt = await StartAndWaitForStateWithDedupeStatuses( - "HelloCities", - completedInstanceId, - "Completed", - dedupeStatuses, - expectedCode: dedupeStatuses.Contains("Completed") ? HttpStatusCode.Conflict : HttpStatusCode.Accepted); + string completedId = Guid.NewGuid().ToString(); + string failedId = Guid.NewGuid().ToString(); + string terminatedId = Guid.NewGuid().ToString(); + string runningId = Guid.NewGuid().ToString(); + string suspendedId = Guid.NewGuid().ToString(); + string pendingId = Guid.NewGuid().ToString(); - // Terminated - string terminatedInstanceId = Guid.NewGuid().ToString(); - using HttpResponseMessage startTerminatedResponseFirstAttempt = await StartAndWaitForStateWithDedupeStatuses( - "LongRunningOrchestrator", terminatedInstanceId, "Running", dedupeStatuses); - await TerminateAndWaitForState(terminatedInstanceId, startTerminatedResponseFirstAttempt); - using HttpResponseMessage startTerminatedResponseSecondAttempt = await StartAndWaitForStateWithDedupeStatuses( - "LongRunningOrchestrator", - terminatedInstanceId, - "Running", - dedupeStatuses, - expectedCode: dedupeStatuses.Contains("Terminated") ? HttpStatusCode.Conflict : HttpStatusCode.Accepted); - // Clean-up + // Phase 1: Start all first-attempt orchestrations concurrently + // Completed + var completedFirst = StartAndWaitForStateWithDedupeStatuses("HelloCities", completedId, "Completed", dedupeStatuses); + // Failed — "LargeOutputOrchestrator" expects a non-zero input, but we provide none + var failedFirst = StartAndWaitForStateWithDedupeStatuses("LargeOutputOrchestrator", failedId, "Failed", dedupeStatuses); + // Terminated (starts as Running, transitioned in Phase 2) + var terminatedFirst = StartAndWaitForStateWithDedupeStatuses(longRunningOrch, terminatedId, "Running", dedupeStatuses); + // Running + var runningFirst = StartAndWaitForStateWithDedupeStatuses(longRunningOrch, runningId, "Running", dedupeStatuses); + // Suspended (starts as Running, transitioned in Phase 2) + var suspendedFirst = StartAndWaitForStateWithDedupeStatuses(longRunningOrch, suspendedId, "Running", dedupeStatuses); + // Pending + var pendingFirst = StartAndWaitForStateWithDedupeStatuses( + "HelloCities", pendingId, "Pending", dedupeStatuses, scheduledStartTime: DateTime.UtcNow.AddMinutes(10)); + await Task.WhenAll(completedFirst, failedFirst, terminatedFirst, runningFirst, suspendedFirst, pendingFirst); + + // Phase 2: Apply state transitions concurrently + await Task.WhenAll( + TerminateAndWaitForState(terminatedId, await terminatedFirst), + SuspendAndWaitForState(suspendedId, await suspendedFirst)); + + // Dispose Phase 1 responses + foreach (var t in new Task[] { completedFirst, failedFirst, terminatedFirst, runningFirst, suspendedFirst, pendingFirst }) + (await t)?.Dispose(); + + // Phase 3: Start all second-attempt orchestrations concurrently (check dedupe behavior) + var phase3 = await Task.WhenAll( + // Completed + StartAndWaitForStateWithDedupeStatuses("HelloCities", completedId, "Completed", dedupeStatuses, + expectedCode: dedupeStatuses.Contains("Completed") ? HttpStatusCode.Conflict : HttpStatusCode.Accepted), + // Failed — "LargeOutputOrchestrator" expects a non-zero input, but we provide none + StartAndWaitForStateWithDedupeStatuses("LargeOutputOrchestrator", failedId, "Failed", dedupeStatuses, + expectedCode: dedupeStatuses.Contains("Failed") ? HttpStatusCode.Conflict : HttpStatusCode.Accepted), + // Terminated + StartAndWaitForStateWithDedupeStatuses(longRunningOrch, terminatedId, "Running", dedupeStatuses, + expectedCode: dedupeStatuses.Contains("Terminated") ? HttpStatusCode.Conflict : HttpStatusCode.Accepted), + // Running + StartAndWaitForStateWithDedupeStatuses(longRunningOrch, runningId, "Running", dedupeStatuses, + expectedCode: dedupeStatuses.Contains("Running") ? HttpStatusCode.Conflict : HttpStatusCode.Accepted), + // Suspended + StartAndWaitForStateWithDedupeStatuses(longRunningOrch, suspendedId, "Running", dedupeStatuses, + expectedCode: dedupeStatuses.Contains("Suspended") ? HttpStatusCode.Conflict : HttpStatusCode.Accepted), + // Pending + StartAndWaitForStateWithDedupeStatuses("HelloCities", pendingId, "Completed", dedupeStatuses, + expectedCode: dedupeStatuses.Contains("Pending") ? HttpStatusCode.Conflict : HttpStatusCode.Accepted)); + foreach (var r in phase3) + r?.Dispose(); + + // Phase 4: Clean up running orchestrations concurrently + var cleanups = new List> + { + HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={runningId}"), + HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={suspendedId}"), + HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={pendingId}"), + }; if (!dedupeStatuses.Contains("Terminated")) + cleanups.Add(HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={terminatedId}")); + foreach (var r in await Task.WhenAll(cleanups)) { - terminateResponse = await HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={terminatedInstanceId}"); - Assert.Equal(HttpStatusCode.OK, terminateResponse.StatusCode); - terminateResponse.Dispose(); + using (r) + { + if (r.StatusCode != HttpStatusCode.OK) + { + this.output.WriteLine( + $"TerminateInstance cleanup returned status code {r.StatusCode} for request {r.RequestMessage?.RequestUri}"); + } + } } - - // Failed - // This invocation will fail because the "LargeOutputOrchestrator" expects a non-zero input, but we provide none - string failedInstanceId = Guid.NewGuid().ToString(); - using HttpResponseMessage startFailedResponseFirstAttempt = await StartAndWaitForStateWithDedupeStatuses( - "LargeOutputOrchestrator", failedInstanceId, "Failed", dedupeStatuses); - using HttpResponseMessage startFailedResponseSecondAttempt = await StartAndWaitForStateWithDedupeStatuses( - "LargeOutputOrchestrator", - failedInstanceId, - "Failed", - dedupeStatuses, - expectedCode: dedupeStatuses.Contains("Failed") ? HttpStatusCode.Conflict : HttpStatusCode.Accepted); - - // Pending - string pendingInstanceId = Guid.NewGuid().ToString(); - DateTime scheduledStartTime = DateTime.UtcNow.AddMinutes(10); - using HttpResponseMessage startPendingResponseFirstAttempt = await StartAndWaitForStateWithDedupeStatuses( - "HelloCities", pendingInstanceId, "Pending", dedupeStatuses, scheduledStartTime: scheduledStartTime); - using HttpResponseMessage startPendingResponseSecondAttempt = await StartAndWaitForStateWithDedupeStatuses( - "HelloCities", - pendingInstanceId, - "Completed", - dedupeStatuses, - expectedCode: dedupeStatuses.Contains("Pending") ? HttpStatusCode.Conflict : HttpStatusCode.Accepted); - - // Running - string runningInstanceId = Guid.NewGuid().ToString(); - using HttpResponseMessage startRunningResponseFirstAttempt = await StartAndWaitForStateWithDedupeStatuses( - "LongRunningOrchestrator", runningInstanceId, "Running", dedupeStatuses); - using HttpResponseMessage startRunningResponseSecondAttempt = await StartAndWaitForStateWithDedupeStatuses( - "LongRunningOrchestrator", - runningInstanceId, - expectedState: "Running", - dedupeStatuses, - expectedCode: dedupeStatuses.Contains("Running") ? HttpStatusCode.Conflict : HttpStatusCode.Accepted); - // Clean-up - terminateResponse = await HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={runningInstanceId}"); - Assert.Equal(HttpStatusCode.OK, terminateResponse.StatusCode); - terminateResponse.Dispose(); - - // Suspended - string suspendedInstanceId = Guid.NewGuid().ToString(); - using HttpResponseMessage startSuspendedResponseFirstAttempt = await StartAndWaitForStateWithDedupeStatuses( - "LongRunningOrchestrator", suspendedInstanceId, "Running", dedupeStatuses); - await SuspendAndWaitForState(suspendedInstanceId, startSuspendedResponseFirstAttempt); - using HttpResponseMessage startSuspendedResponseSecondAttempt = await StartAndWaitForStateWithDedupeStatuses( - "LongRunningOrchestrator", - suspendedInstanceId, - "Running", - dedupeStatuses, - expectedCode: dedupeStatuses.Contains("Suspended") ? HttpStatusCode.Conflict : HttpStatusCode.Accepted); - // Clean-up - terminateResponse = await HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={suspendedInstanceId}"); - Assert.Equal(HttpStatusCode.OK, terminateResponse.StatusCode); - terminateResponse.Dispose(); } [Theory] diff --git a/test/e2e/Tests/Tests/PurgeInstancesTests.cs b/test/e2e/Tests/Tests/PurgeInstancesTests.cs index a6055a35c..97a28a321 100644 --- a/test/e2e/Tests/Tests/PurgeInstancesTests.cs +++ b/test/e2e/Tests/Tests/PurgeInstancesTests.cs @@ -124,7 +124,7 @@ public async Task PurgeAfterPurge_ZeroRows() string purgeMessage = await purgeResponse.Content.ReadAsStringAsync(); Assert.Matches(@"^Purged [0-9]* records$", purgeMessage); using HttpResponseMessage purgeAgainResponse = await HttpHelpers.InvokeHttpTrigger("PurgeOrchestrationHistory", $"?purgeEndTime={purgeEndTime:o}"); - Assert.Equal(HttpStatusCode.OK, purgeAgainResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, purgeAgainResponse.StatusCode); await AssertPurgeCount(purgeAgainResponse, 0); } @@ -132,7 +132,7 @@ public async Task PurgeAfterPurge_ZeroRows() [Trait("PowerShell", "Skip")] // Instance purging not supported in PowerShell public async Task PurgeOnlyPurgesTerminalOrchestrations() { - // For all of the following tests, since non-.NET languages throw a generic error in the case of a failure to purge there is no great way + // For all of the following tests, since non-.NET languages throw a generic error in the case of a failure to purge there is no great way // to return specific status codes, whereas .NET isolated returns specific error types which can be used to return specific status codes. // So, in the non-.NET case, we simply check for the InternalServerError status code. void AssertFailedPurgeResponseStatusCode(HttpResponseMessage purgeHttpResponse) @@ -147,118 +147,170 @@ void AssertFailedPurgeResponseStatusCode(HttpResponseMessage purgeHttpResponse) } } - // Completed orchestration, should succeed - using HttpResponseMessage startCompletedOrchestrationResponse = await HttpHelpers.InvokeHttpTrigger( - "StartOrchestration", - "?orchestrationName=HelloCities"); - Assert.Equal(HttpStatusCode.Accepted, startCompletedOrchestrationResponse.StatusCode); - string completedInstanceId = await DurableHelpers.ParseInstanceIdAsync(startCompletedOrchestrationResponse); - string completedStatusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(startCompletedOrchestrationResponse); - await DurableHelpers.WaitForOrchestrationStateAsync(completedStatusQueryGetUri, "Completed", 30); - HttpResponseMessage purgeCompleted = await HttpHelpers.InvokeHttpTrigger("PurgeOrchestrationHistory", $"?instanceId={completedInstanceId}"); - Assert.Equal(HttpStatusCode.OK, purgeCompleted.StatusCode); - await AssertPurgeCount(purgeCompleted, 1); + bool testTerminated = this.fixture.functionLanguageLocalizer.GetLanguageType() != LanguageType.Java + || this.fixture.GetDurabilityProvider() != FunctionAppFixture.ConfiguredDurabilityProviderType.MSSQL; + bool testPending = this.fixture.functionLanguageLocalizer.GetLanguageType() == LanguageType.DotnetIsolated + || this.fixture.functionLanguageLocalizer.GetLanguageType() == LanguageType.Java; + + // HttpLongRunningOrchestrator (timer-based, no activity spam) is only available in dotnet-isolated. + // For other languages, LongRunningOrchestrator is used, which generates activity load against the + // configured durability provider. + string longRunningOrch = this.fixture.functionLanguageLocalizer.GetLanguageType() == LanguageType.DotnetIsolated + ? "HttpLongRunningOrchestrator" + : "LongRunningOrchestrator"; + + // Phase 1: Start all orchestrations and wait for initial states concurrently + // Completed orchestration, should succeed purge + var completedStart = StartOrchAndWaitForStatus("HelloCities", "Completed"); + // Failed orchestration, should succeed purge + var failedStart = StartOrchAndWaitForStatus("HelloActivityDIFailure", "Failed"); + // Terminated orchestration, should succeed purge + var terminatedStart = testTerminated ? StartOrchAndWaitForStatus(longRunningOrch, "Running") : null; + // Running orchestration, should fail purge + var runningStart = StartOrchAndWaitForStatus(longRunningOrch, "Running"); + // Suspended orchestration, should fail purge + var suspendedStart = StartOrchAndWaitForStatus(longRunningOrch, "Running"); + // Pending orchestration, should fail purge + // Scheduled start times are currently only implemented in Java and .NET isolated, + // which is the only true way to get an orchestration in a "Pending" state + Task<(string instanceId, string statusUri)>? pendingStart = testPending + ? StartOrchAndWaitForStatus("HelloCities", "Pending", scheduledStartTime: DateTime.UtcNow + TimeSpan.FromMinutes(1)) + : null; + + var phase1Tasks = new List { completedStart, failedStart, runningStart, suspendedStart }; + if (terminatedStart != null) phase1Tasks.Add(terminatedStart); + if (pendingStart != null) phase1Tasks.Add(pendingStart); + await Task.WhenAll(phase1Tasks); + + // Phase 2: Apply transitions concurrently (terminate, suspend) + var transitions = new List(); + if (testTerminated) + { + var (termId, termUri) = await terminatedStart!; + transitions.Add(TerminateAndWaitForStatus(termId, termUri)); + } + var (suspId, suspUri) = await suspendedStart; + transitions.Add(SuspendAndWaitForStatus(suspId, suspUri)); + await Task.WhenAll(transitions); + + // Phase 3: Test purge behavior — terminal states should succeed, non-terminal should fail + var (completedId, _) = await completedStart; + var (failedId, _) = await failedStart; + var (runningId, _) = await runningStart; + + // Terminal state purges (can run concurrently) + var terminalPurgeTasks = new List(); + terminalPurgeTasks.Add(AssertPurgeSucceeds(completedId)); + if (testTerminated) + terminalPurgeTasks.Add(AssertPurgeSucceeds((await terminatedStart!).instanceId)); + terminalPurgeTasks.Add(AssertPurgeSucceeds(failedId)); + await Task.WhenAll(terminalPurgeTasks); + + // Non-existent orchestration, should succeed and have purge count of 0 + using HttpResponseMessage purgeNonExistent = await HttpHelpers.InvokeHttpTrigger("PurgeOrchestrationHistory", $"?instanceId={Guid.NewGuid()}"); + Assert.Equal(HttpStatusCode.OK, purgeNonExistent.StatusCode); + await AssertPurgeCount(purgeNonExistent, 0); + + // Non-terminal state purges should fail (can run concurrently) + var nonTerminalPurgeTasks = new List> + { + HttpHelpers.InvokeHttpTrigger("PurgeOrchestrationHistory", $"?instanceId={runningId}"), + HttpHelpers.InvokeHttpTrigger("PurgeOrchestrationHistory", $"?instanceId={suspId}"), + }; + if (testPending) + nonTerminalPurgeTasks.Add(HttpHelpers.InvokeHttpTrigger("PurgeOrchestrationHistory", $"?instanceId={(await pendingStart!).instanceId}")); + var nonTerminalResponses = await Task.WhenAll(nonTerminalPurgeTasks); + foreach (var response in nonTerminalResponses) + { + using (response) + { + AssertFailedPurgeResponseStatusCode(response); + } + } // Verify that the ClientOperationReceived logs were emitted with a FunctionInvocationId ClientOperationLogHelpers.AssertClientOperationLogExists( () => this.fixture.TestLogs.CoreToolsLogs, "StartOrchestration", - completedInstanceId, + completedId, this.fixture.functionLanguageLocalizer.GetLanguageType()); ClientOperationLogHelpers.AssertClientOperationLogExists( () => this.fixture.TestLogs.CoreToolsLogs, "PurgeInstances", - completedInstanceId, + completedId, this.fixture.functionLanguageLocalizer.GetLanguageType()); - // Terminated orchestration, should succeed - if (this.fixture.functionLanguageLocalizer.GetLanguageType() != LanguageType.Java - || this.fixture.GetDurabilityProvider() != FunctionAppFixture.ConfiguredDurabilityProviderType.MSSQL) // Bug: https://github.com/microsoft/durabletask-java/issues/237 + // Best-effort cleanup of non-terminal instances to avoid background load on subsequent tests. + // Terminate may return non-OK for already-completed or purged instances; log and dispose. + var cleanups = new List> + { + HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={runningId}"), + HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={suspId}"), + }; + if (testPending) + cleanups.Add(HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={(await pendingStart!).instanceId}")); + foreach (var r in await Task.WhenAll(cleanups)) { - using HttpResponseMessage startTerminatedOrchestrationResponse = await HttpHelpers.InvokeHttpTrigger( - "StartOrchestration", - "?orchestrationName=LongRunningOrchestrator"); - Assert.Equal(HttpStatusCode.Accepted, startTerminatedOrchestrationResponse.StatusCode); - string terminatedInstanceId = await DurableHelpers.ParseInstanceIdAsync(startTerminatedOrchestrationResponse); - string terminatedStatusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(startTerminatedOrchestrationResponse); - await DurableHelpers.WaitForOrchestrationStateAsync(terminatedStatusQueryGetUri, "Running", 30); - using HttpResponseMessage terminateResponse = await HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={terminatedInstanceId}"); - Assert.Equal(HttpStatusCode.OK, terminateResponse.StatusCode); - await DurableHelpers.WaitForOrchestrationStateAsync(terminatedStatusQueryGetUri, "Terminated", 30); - using HttpResponseMessage purgeTerminated = await HttpHelpers.InvokeHttpTrigger("PurgeOrchestrationHistory", $"?instanceId={terminatedInstanceId}"); - Assert.Equal(HttpStatusCode.OK, purgeTerminated.StatusCode); - await AssertPurgeCount(purgeTerminated, 1); + using (r) + { + if (!r.IsSuccessStatusCode) + { + this.output.WriteLine( + $"TerminateInstance cleanup returned status {r.StatusCode} for request {r.RequestMessage?.RequestUri}"); + } + } } + } - // Failed orchestration, should succeed - using HttpResponseMessage startFailedOrchestrationResponse = await HttpHelpers.InvokeHttpTrigger( - "StartOrchestration", - "?orchestrationName=HelloActivityDIFailure"); - Assert.Equal(HttpStatusCode.Accepted, startFailedOrchestrationResponse.StatusCode); - string failedInstanceId = await DurableHelpers.ParseInstanceIdAsync(startFailedOrchestrationResponse); - string failedStatusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(startFailedOrchestrationResponse); - await DurableHelpers.WaitForOrchestrationStateAsync(failedStatusQueryGetUri, "Failed", 30); - using HttpResponseMessage purgeFailed = await HttpHelpers.InvokeHttpTrigger("PurgeOrchestrationHistory", $"?instanceId={failedInstanceId}"); - Assert.Equal(HttpStatusCode.OK, purgeFailed.StatusCode); - await AssertPurgeCount(purgeFailed, 1); - - // Non-existent orchestration, should succeed and have purge count of 0 - using HttpResponseMessage purgeNonExistent = await HttpHelpers.InvokeHttpTrigger("PurgeOrchestrationHistory", $"?instanceId={Guid.NewGuid()}"); - Assert.Equal(HttpStatusCode.OK, purgeNonExistent.StatusCode); - await AssertPurgeCount(purgeNonExistent, 0); - - // Running orchestration, should fail - using HttpResponseMessage startRunningOrchestrationResponse = await HttpHelpers.InvokeHttpTrigger( - "StartOrchestration", - "?orchestrationName=LongRunningOrchestrator"); - Assert.Equal(HttpStatusCode.Accepted, startRunningOrchestrationResponse.StatusCode); - string runningInstanceId = await DurableHelpers.ParseInstanceIdAsync(startRunningOrchestrationResponse); - string runningStatusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(startRunningOrchestrationResponse); - await DurableHelpers.WaitForOrchestrationStateAsync(runningStatusQueryGetUri, "Running", 30); - using HttpResponseMessage purgeRunning = await HttpHelpers.InvokeHttpTrigger("PurgeOrchestrationHistory", $"?instanceId={runningInstanceId}"); - AssertFailedPurgeResponseStatusCode(purgeRunning); - - // Pending orchestration, should fail - // Scheduled start times are currently only implemented in Java and .NET isolated, which is the only true way to get an orchestration in a "Pending" state - if (this.fixture.functionLanguageLocalizer.GetLanguageType() == LanguageType.DotnetIsolated - || this.fixture.functionLanguageLocalizer.GetLanguageType() == LanguageType.Java) - { - DateTime scheduledStartTime = DateTime.UtcNow + TimeSpan.FromMinutes(1); - using HttpResponseMessage startPendingOrchestrationResponse = await HttpHelpers.InvokeHttpTrigger( - "HelloCities_HttpStart_Scheduled", - $"?ScheduledStartTime={scheduledStartTime:o}"); - Assert.Equal(HttpStatusCode.Accepted, startPendingOrchestrationResponse.StatusCode); - string pendingInstanceId = await DurableHelpers.ParseInstanceIdAsync(startPendingOrchestrationResponse); - string pendingStatusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(startPendingOrchestrationResponse); - await DurableHelpers.WaitForOrchestrationStateAsync(pendingStatusQueryGetUri, "Pending", 30); - using HttpResponseMessage purgePending = await HttpHelpers.InvokeHttpTrigger("PurgeOrchestrationHistory", $"?instanceId={pendingInstanceId}"); - AssertFailedPurgeResponseStatusCode(purgePending); + private async Task<(string instanceId, string statusUri)> StartOrchAndWaitForStatus( + string orchestrationName, string targetStatus, DateTime? scheduledStartTime = null) + { + string functionName = "StartOrchestration"; + string queryParams = $"?orchestrationName={orchestrationName}"; + if (scheduledStartTime is not null) + { + functionName = "HelloCities_HttpStart_Scheduled"; + queryParams = $"?orchestrationName={orchestrationName}&ScheduledStartTime={scheduledStartTime:o}"; } - // Suspended orchestration, should fail - using HttpResponseMessage startSuspendedOrchestrationResponse = await HttpHelpers.InvokeHttpTrigger( - "StartOrchestration", - "?orchestrationName=LongRunningOrchestrator"); - Assert.Equal(HttpStatusCode.Accepted, startSuspendedOrchestrationResponse.StatusCode); - string suspendedInstanceId = await DurableHelpers.ParseInstanceIdAsync(startSuspendedOrchestrationResponse); - string suspendedStatusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(startSuspendedOrchestrationResponse); - await DurableHelpers.WaitForOrchestrationStateAsync(suspendedStatusQueryGetUri, "Running", 30); - using HttpResponseMessage suspendResponse = await HttpHelpers.InvokeHttpTrigger("SuspendInstance", $"?instanceId={suspendedInstanceId}"); + using var response = await HttpHelpers.InvokeHttpTrigger(functionName, queryParams); + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string instanceId = await DurableHelpers.ParseInstanceIdAsync(response); + string statusUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + await DurableHelpers.WaitForOrchestrationStateAsync(statusUri, targetStatus, 30); + return (instanceId, statusUri); + } + + private async Task TerminateAndWaitForStatus(string instanceId, string statusUri) + { + using var terminateResponse = await HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={instanceId}"); + Assert.Equal(HttpStatusCode.OK, terminateResponse.StatusCode); + await DurableHelpers.WaitForOrchestrationStateAsync(statusUri, "Terminated", 30); + } + + private async Task SuspendAndWaitForStatus(string instanceId, string statusUri) + { + using var suspendResponse = await HttpHelpers.InvokeHttpTrigger("SuspendInstance", $"?instanceId={instanceId}"); Assert.Equal(HttpStatusCode.OK, suspendResponse.StatusCode); - await DurableHelpers.WaitForOrchestrationStateAsync(suspendedStatusQueryGetUri, "Suspended", 30); - using HttpResponseMessage purgeSuspended = await HttpHelpers.InvokeHttpTrigger("PurgeOrchestrationHistory", $"?instanceId={suspendedInstanceId}"); - AssertFailedPurgeResponseStatusCode(purgeSuspended); - } - - [Fact] + await DurableHelpers.WaitForOrchestrationStateAsync(statusUri, "Suspended", 30); + } + + private async Task AssertPurgeSucceeds(string instanceId) + { + using var response = await HttpHelpers.InvokeHttpTrigger("PurgeOrchestrationHistory", $"?instanceId={instanceId}"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + await AssertPurgeCount(response, 1); + } + + [Fact] [Trait("PowerShell", "Skip")] // Instance purging not supported in PowerShell [Trait("Java", "Skip")] // Entities are not implemented in Java [Trait("MSSQL", "Skip")] // Entities are not supported in MSSQL - public async Task PurgeEntity() + public async Task PurgeEntity() { // Start an orchestration that interacts with an entity HttpResponseMessage orchestrationResponse = await HttpHelpers.InvokeHttpTrigger( - "StartOrchestration", + "StartOrchestration", "?orchestrationName=InvokeDummyEntityOrchestration"); Assert.Equal(HttpStatusCode.Accepted, orchestrationResponse.StatusCode); @@ -274,19 +326,19 @@ public async Task PurgeEntity() "PurgeOrchestrationHistory", $"?instanceId={new EntityInstanceId(entityName, entityKey)}"); Assert.Equal(HttpStatusCode.OK, purgeExistentEntity.StatusCode); - await AssertPurgeCount(purgeExistentEntity, 1); - - // Now attempt to purge a non-existent entity instance, purge count should be 0 + await AssertPurgeCount(purgeExistentEntity, 1); + + // Now attempt to purge a non-existent entity instance, purge count should be 0 using HttpResponseMessage purgeNonExistentEntity = await HttpHelpers.InvokeHttpTrigger( "PurgeOrchestrationHistory", $"?instanceId={new EntityInstanceId(entityName + "3", entityKey)}"); Assert.Equal(HttpStatusCode.OK, purgeNonExistentEntity.StatusCode); - await AssertPurgeCount(purgeNonExistentEntity, 0); - } - - private static async Task AssertPurgeCount(HttpResponseMessage purgeHttpResponse, int purgeCount) - { - string purgeMessage = await purgeHttpResponse.Content.ReadAsStringAsync(); - Assert.Matches($@"^Purged {purgeCount} records$", purgeMessage); + await AssertPurgeCount(purgeNonExistentEntity, 0); + } + + private static async Task AssertPurgeCount(HttpResponseMessage purgeHttpResponse, int purgeCount) + { + string purgeMessage = await purgeHttpResponse.Content.ReadAsStringAsync(); + Assert.Matches($@"^Purged {purgeCount} records$", purgeMessage); } } diff --git a/test/e2e/Tests/build-e2e-test.ps1 b/test/e2e/Tests/build-e2e-test.ps1 index 113405c73..6c4f790df 100644 --- a/test/e2e/Tests/build-e2e-test.ps1 +++ b/test/e2e/Tests/build-e2e-test.ps1 @@ -267,9 +267,22 @@ function StartMSSQLContainer($mssqlPwd) { exit $LASTEXITCODE } - # The container needs a bit more time before it can start accepting commands - Write-Host "Sleeping for 30 seconds to let the container finish initializing..." -ForegroundColor Yellow - Start-Sleep -Seconds 30 + # Wait for SQL Server to be ready by polling with sqlcmd + Write-Host "Waiting for SQL Server to become ready..." -ForegroundColor Yellow + $maxAttempts = 30 + for ($i = 1; $i -le $maxAttempts; $i++) { + $result = docker exec mssql-server /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$mssqlPwd" -Q "SELECT 1" -C -b 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Host "SQL Server is ready after $i seconds." -ForegroundColor Green + break + } + if ($i -eq $maxAttempts) { + Write-Error "SQL Server did not become ready within $maxAttempts seconds." + docker logs mssql-server 2>&1 | Select-Object -Last 20 + exit 1 + } + Start-Sleep -Seconds 1 + } # Check to see what containers are running docker ps @@ -281,15 +294,33 @@ function StartDTSContainer() { # Start the DTS Server docker container with the specified edition Write-Host "Starting DTS docker container on port 8080" -ForegroundColor DarkYellow - docker run -i -p 8080:8080 -p 8082:8082 -d mcr.microsoft.com/dts/dts-emulator:latest + docker run -i --name dts-emulator --rm -p 8080:8080 -p 8081:8081 -p 8082:8082 -d mcr.microsoft.com/dts/dts-emulator:latest if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - # The container needs a bit more time before it can start accepting commands - Write-Host "Sleeping for 30 seconds to let the container finish initializing..." -ForegroundColor Yellow - Start-Sleep -Seconds 30 + # Poll until the emulator port is accepting TCP connections instead of a fixed sleep + Write-Host "Waiting for DTS emulator to become ready..." -ForegroundColor Yellow + $maxAttempts = 60 + for ($i = 1; $i -le $maxAttempts; $i++) { + try { + $tcp = New-Object System.Net.Sockets.TcpClient + try { + $tcp.Connect("localhost", 8080) + Write-Host "DTS emulator is ready after $i seconds." -ForegroundColor Green + break + } finally { + $tcp.Dispose() + } + } catch { } + if ($i -eq $maxAttempts) { + Write-Error "DTS emulator did not become ready within $maxAttempts seconds." + docker logs dts-emulator 2>&1 | Select-Object -Last 20 + exit 1 + } + Start-Sleep -Seconds 1 + } # Check to see what containers are running docker ps